import { expect } from 'chai';
import * as sinon from 'sinon';
import Docker from 'dockerode';
import * as applicationManager from '~/src/compose/application-manager';
import * as imageManager from '~/src/compose/images';
import * as serviceManager from '~/src/compose/service-manager';
import { Network } from '~/src/compose/network';
import * as networkManager from '~/src/compose/network-manager';
import { Volume } from '~/src/compose/volume';
import * as config from '~/src/config';
import { createDockerImage } from '~/test-lib/docker-helper';
import {
	createService,
	createImage,
	createApps,
	createCurrentState,
	DEFAULT_NETWORK,
	expectSteps,
	expectNoStep,
} from '~/test-lib/state-helper';
import type { InstancedAppState } from '~/src/compose/types';

// TODO: application manager inferNextSteps still queries some stuff from
// the engine instead of receiving that information as parameter. Refactoring
// the method to be more of a pure function would allow us to move a lot of these tests
// to unit tests, leaving the need of integration tests just for more complex stuff that
// the application-manager also does and that is not currently tested.
// TODO: also, there is some redundancy between what is tested here and what is tested in
// the app spec, remove that redundancy to simplify the tests
describe('compose/application-manager', () => {
	before(async () => {
		// Service.fromComposeObject gets api keys from the database
		// which also depend on the local mode. This ensures the database
		// is initialized. This can be removed when ApplicationManager and Service
		// a refactored to work as pure functions
		await config.initialized();
	});

	beforeEach(async () => {
		// Set up network by default
		await networkManager.ensureSupervisorNetwork();
	});

	afterEach(async () => {
		// Delete any created networks
		const docker = new Docker();
		const allNetworks = await docker.listNetworks();
		await Promise.all(
			allNetworks
				// exclude docker default networks from the cleanup
				.filter(({ Name }) => !['bridge', 'host', 'none'].includes(Name))
				.map(({ Name }) => docker.getNetwork(Name).remove()),
		);
	});

	// TODO: we don't test application manager initialization as it sets up a bunch of timers
	// and listeners that may affect other tests. This is a bad pattern and it needs to be purged
	// from the codebase
	it.skip('should init', async () => {
		await applicationManager.initialized();
	});

	// TODO: missing tests for getCurrentApps

	it('should not infer a start step when all that changes is a running state', async () => {
		const targetApps = createApps(
			{
				services: [await createService({ running: true, appId: 1 })],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [await createService({ running: false, appId: 1 })],
				networks: [DEFAULT_NETWORK],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		// There should be no steps since the engine manages restart policy for stopped containers
		expect(steps.length).to.equal(0);
	});

	it('infers a kill step when a service has to be removed', async () => {
		const targetApps = createApps(
			{
				services: [],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [await createService()],
				networks: [DEFAULT_NETWORK],
			});

		const [killStep] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock lock taken to avoid takeLock step
				appLocks: {
					'1': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		expect(killStep).to.have.property('action').that.equals('kill');
		expect(killStep)
			.to.have.property('current')
			.that.deep.includes({ serviceName: 'main' });
	});

	it('infers a fetch step when a service has to be updated', async () => {
		const targetApps = createApps(
			{
				services: [await createService({ image: 'image-new', appId: 1 })],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [await createService({ appId: 1 })],
				networks: [DEFAULT_NETWORK],
				images: [],
			});

		const [fetchStep] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		expect(fetchStep).to.have.property('action').that.equals('fetch');
		expect(fetchStep)
			.to.have.property('image')
			.that.deep.includes({ name: 'image-new' });
	});

	it('does not infer a fetch step when the download is already in progress', async () => {
		const targetApps = createApps(
			{
				services: [await createService({ image: 'image-new', appId: 1 })],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [await createService({ appId: 1 })],
				networks: [DEFAULT_NETWORK],
				downloading: ['image-new'],
			});

		const [noopStep, ...nextSteps] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		expect(noopStep).to.have.property('action').that.equals('noop');
		expect(nextSteps).to.have.lengthOf(0);
	});

	it('infers 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 targetApps = createApps(
			{
				services: [
					await createService({
						image: 'image-new',
						labels,
						appId: 1,
						commit: 'new-release',
					}),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService(
						{
							image: 'image-old',
							labels,
							appId: 1,
							commit: 'old-release',
						},
						{ options: { imageInfo: { Id: 'sha256:image-old-id' } } },
					),
				],
				networks: [DEFAULT_NETWORK],
			});

		const [killStep] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock lock taken to avoid takeLock step
				appLocks: {
					'1': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		expect(killStep).to.have.property('action').that.equals('kill');
		expect(killStep)
			.to.have.property('current')
			.that.deep.includes({ serviceName: 'main' });
	});

	it('infers a kill step when a service has to be updated but the strategy is delete-then-download', async () => {
		const labels = {
			'io.balena.update.strategy': 'delete-then-download',
		};
		const targetApps = createApps(
			{
				services: [
					await createService({
						image: 'image-new',
						labels,
						appId: 1,
						commit: 'new-release',
					}),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService(
						{
							image: 'image-old',
							labels,
							appId: 1,
							commit: 'old-release',
						},
						{ options: { imageInfo: { Id: 'sha256:image-old-id' } } },
					),
				],
				networks: [DEFAULT_NETWORK],
			});

		const [killStep] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock lock taken to avoid takeLock step
				appLocks: {
					'1': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		expect(killStep).to.have.property('action').that.equals('kill');
		expect(killStep)
			.to.have.property('current')
			.that.deep.includes({ serviceName: 'main' });
	});

	it('infers a remove step when the current service has stopped and the strategy is delete-then-download', async () => {
		const labels = {
			'io.balena.update.strategy': 'delete-then-download',
		};
		const targetApps = createApps(
			{
				services: [
					await createService({
						image: 'image-new',
						labels,
						appId: 1,
						serviceName: 'main',
						commit: 'new-release',
					}),
				],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				images: [
					createImage({
						appId: 1,
						name: 'image-old',
						serviceName: 'main',
						dockerImageId: 'image-old-id',
					}),
				],
				networks: [DEFAULT_NETWORK],
			});

		const [removeImage] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		// First we should see a kill
		expect(removeImage).to.have.property('action').that.equals('removeImage');
		expect(removeImage)
			.to.have.property('image')
			.that.deep.includes({ name: 'image-old' });
	});

	// These cases test whether the Supervisor handles multi-container updates
	// on a per-app basis rather than a per-service while also respecting update strategies.
	describe('update strategy compliance for multi-container apps', () => {
		const createCurrentAndTargetServicesWithLabels = async (labels: {
			[key: string]: string;
		}) => {
			const currentServices = [
				await createService({
					image: 'image-main',
					labels,
					appId: 1,
					serviceName: 'main',
					commit: 'old-release',
					releaseId: 1,
				}),
				await createService({
					image: 'image-old',
					labels,
					appId: 1,
					serviceName: 'old',
					commit: 'old-release',
					releaseId: 1,
				}),
			];
			const targetServices = [
				await createService({
					image: 'image-main',
					labels,
					appId: 1,
					serviceName: 'main',
					commit: 'new-release',
					releaseId: 2,
				}),
				await createService({
					image: 'image-new',
					labels,
					appId: 1,
					serviceName: 'new',
					commit: 'new-release',
					releaseId: 2,
				}),
			];
			return { currentServices, targetServices };
		};

		it('should not infer a kill step for current service A before target service B download finishes with download-then-kill', async () => {
			const labels = {
				'io.balena.update.strategy': 'download-then-kill',
			};
			const { currentServices, targetServices } =
				await createCurrentAndTargetServicesWithLabels(labels);

			/**
			 * Before target image finishes downloading, do not infer kill step
			 */
			let targetApps = createApps(
				{
					services: targetServices,
				},
				true,
			);
			const c1 = createCurrentState({
				services: currentServices,
				networks: [DEFAULT_NETWORK],
				downloading: ['image-new'],
			});
			const steps = await applicationManager.inferNextSteps(
				c1.currentApps,
				targetApps,
				{
					downloading: c1.downloading,
					availableImages: c1.availableImages,
					containerIdsByAppId: c1.containerIdsByAppId,
					// Mock lock taken for `main` service which just needs metadata updated
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			// There should be two noop steps, one for target service which is still downloading,
			// and one for current service which is waiting on target download to complete.
			expectSteps('noop', steps, 2);
			// No kill step yet
			expectNoStep('kill', steps);

			/**
			 * After target image finishes downloading, infer a kill step
			 */
			targetApps = createApps(
				{
					services: targetServices,
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: currentServices,
					networks: [DEFAULT_NETWORK],
					images: [
						// Both images have been downloaded or had their metadata updated
						createImage({
							name: 'image-main',
							appId: 1,
							serviceName: 'main',
							commit: 'new-release',
							releaseId: 2,
						}),
						createImage({
							name: 'image-new',
							appId: 1,
							serviceName: 'new',
							commit: 'new-release',
							releaseId: 2,
						}),
					],
				});
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			// Service `old` is safe to kill after download for `new` has completed
			const [killStep] = expectSteps('kill', steps2, 1);
			expect(killStep)
				.to.have.property('current')
				.that.deep.includes({ serviceName: 'old' });
			// Service `new` in target release should be started as download has completed
			const [startStep] = expectSteps('start', steps2, 1);
			expect(startStep)
				.to.have.property('target')
				.that.deep.includes({ serviceName: 'new' });
			// No noop steps
			expectNoStep('noop', steps2);
		});

		it('should infer a fetch step for target service B together with current service A kill with kill-then-download', async () => {
			const labels = {
				'io.balena.update.strategy': 'kill-then-download',
			};
			const { currentServices, targetServices } =
				await createCurrentAndTargetServicesWithLabels(labels);
			const targetApps = createApps(
				{
					services: targetServices,
				},
				true,
			);

			/**
			 * Infer fetch step for new target service together with kill & updateMetadata steps for current services
			 */
			const c1 = createCurrentState({
				services: currentServices,
				networks: [DEFAULT_NETWORK],
			});
			const steps = await applicationManager.inferNextSteps(
				c1.currentApps,
				targetApps,
				{
					downloading: c1.downloading,
					// Simulate old images already removed from image table
					// to avoid removeImage steps
					availableImages: [],
					containerIdsByAppId: c1.containerIdsByAppId,
					// Mock locks  to avoid takeLock step
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			// Service `new` should be fetched
			const [fetchStep] = expectSteps('fetch', steps, 1);
			expect(fetchStep).to.have.property('serviceName').that.equals('new');
			// Service `old` should be killed
			const [killStep] = expectSteps('kill', steps, 1);
			expect(killStep)
				.to.have.property('current')
				.that.deep.includes({ serviceName: 'old' });
			// Service `main` should have its metadata updated
			const [updateMetadataStep] = expectSteps('updateMetadata', steps, 1);
			expect(updateMetadataStep)
				.to.have.property('target')
				.that.deep.includes({ serviceName: 'main' });

			/**
			 * Noop while target image is downloading
			 */
			const c2 = createCurrentState({
				// Simulate `kill` and `updateMetadata` steps already executed
				services: currentServices
					.filter(({ serviceName }) => serviceName !== 'old')
					.map((svc) => {
						svc.releaseId = 2;
						svc.commit = 'new-release';
						return svc;
					}),
				networks: [DEFAULT_NETWORK],
			});
			const steps2 = await applicationManager.inferNextSteps(
				c2.currentApps,
				targetApps,
				{
					downloading: ['image-new'],
					availableImages: c2.availableImages,
					containerIdsByAppId: c2.containerIdsByAppId,
				},
			);
			// Noop while service `new` is downloading
			expectSteps('noop', steps2, 1);
			// No start step should be generated until fetch completes
			expectNoStep('start', steps2);
			// No other steps
			expect(steps2).to.have.length(1);

			/**
			 * Infer start step only after download completes
			 */
			const steps3 = await applicationManager.inferNextSteps(
				c2.currentApps,
				targetApps,
				{
					downloading: c1.downloading,
					// Both images have been downloaded or had their metadata updated
					availableImages: [
						createImage({
							name: 'image-main',
							appId: 1,
							serviceName: 'main',
							commit: 'new-release',
							releaseId: 2,
						}),
						createImage({
							name: 'image-new',
							appId: 1,
							serviceName: 'new',
							commit: 'new-release',
							releaseId: 2,
						}),
					],
					containerIdsByAppId: c1.containerIdsByAppId,
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			// Service `new` should be started
			const [startStep] = expectSteps('start', steps3, 1);
			expect(startStep)
				.to.have.property('target')
				.that.deep.includes({ serviceName: 'new' });
			// No other steps
			expect(steps3).to.have.length(1);
		});

		it('should infer a fetch step for target service B together with current service A removal with delete-then-download', async () => {
			const labels = {
				'io.balena.update.strategy': 'delete-then-download',
			};
			const { currentServices, targetServices } =
				await createCurrentAndTargetServicesWithLabels(labels);
			const targetApps = createApps(
				{
					services: targetServices,
				},
				true,
			);

			/**
			 * Infer fetch step for new target service together with kill & updateMetadata steps for current services
			 */
			const c1 = createCurrentState({
				services: currentServices,
				networks: [DEFAULT_NETWORK],
			});
			const steps = await applicationManager.inferNextSteps(
				c1.currentApps,
				targetApps,
				{
					downloading: c1.downloading,
					// Simulate old images already removed from image table
					// to avoid removeImage steps
					availableImages: [],
					containerIdsByAppId: c1.containerIdsByAppId,
					// Mock locks for service to be updated via updateMetadata
					// or kill to avoid takeLock step
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			// Service `new` should be fetched
			const [fetchStep] = expectSteps('fetch', steps, 1);
			expect(fetchStep).to.have.property('serviceName').that.equals('new');
			// Service `old` should be killed
			const [killStep] = expectSteps('kill', steps, 1);
			expect(killStep)
				.to.have.property('current')
				.that.deep.includes({ serviceName: 'old' });
			// Service `main` should have its metadata updated
			const [updateMetadataStep] = expectSteps('updateMetadata', steps, 1);
			expect(updateMetadataStep)
				.to.have.property('target')
				.that.deep.includes({ serviceName: 'main' });

			/**
			 * Noop while target image is downloading
			 */
			const c2 = createCurrentState({
				// Simulate `kill` and `updateMetadata` steps already executed
				services: currentServices
					.filter(({ serviceName }) => serviceName !== 'old')
					.map((svc) => {
						svc.releaseId = 2;
						svc.commit = 'new-release';
						return svc;
					}),
				networks: [DEFAULT_NETWORK],
			});
			const steps2 = await applicationManager.inferNextSteps(
				c2.currentApps,
				targetApps,
				{
					downloading: ['image-new'],
					availableImages: c2.availableImages,
					containerIdsByAppId: c2.containerIdsByAppId,
				},
			);
			// Noop while service `new` is downloading
			expectSteps('noop', steps2, 1);
			// No start step should be generated until fetch completes
			expectNoStep('start', steps2);
			// No other steps
			expect(steps2).to.have.length(1);

			/**
			 * Infer start step only after download completes
			 */
			const steps3 = await applicationManager.inferNextSteps(
				c2.currentApps,
				targetApps,
				{
					downloading: c1.downloading,
					// Both images have been downloaded or had their metadata updated
					availableImages: [
						createImage({
							name: 'image-main',
							appId: 1,
							serviceName: 'main',
							commit: 'new-release',
							releaseId: 2,
						}),
						createImage({
							name: 'image-new',
							appId: 1,
							serviceName: 'new',
							commit: 'new-release',
							releaseId: 2,
						}),
					],
					containerIdsByAppId: c1.containerIdsByAppId,
					// Mock lock taken for all services in target state
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			// Service `new` should be started
			const [startStep] = expectSteps('start', steps3, 1);
			expect(startStep)
				.to.have.property('target')
				.that.deep.includes({ serviceName: 'new' });
			// No other steps
			expect(steps3).to.have.length(1);
		});

		it('should not start target services until all downloads have completed with kill|delete-then-download', async () => {
			const targetServices = [
				await createService({
					serviceName: 'one',
					image: 'image-one',
					labels: {
						'io.balena.update.strategy': 'kill-then-download',
					},
				}),
				await createService({
					serviceName: 'two',
					image: 'image-two',
					labels: {
						'io.balena.update.strategy': 'delete-then-download',
					},
				}),
			];
			const targetApps = createApps(
				{
					services: targetServices,
				},
				true,
			);
			const { currentApps, availableImages, containerIdsByAppId } =
				createCurrentState({
					services: [],
					networks: [DEFAULT_NETWORK],
				});

			/**
			 * Noop while both images are still downloading
			 */
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading: ['image-one', 'image-two'],
					availableImages,
					containerIdsByAppId,
				},
			);
			expectSteps('noop', steps, 2);
			// No other steps
			expect(steps).to.have.length(2);

			/**
			 * Noop while one image is still downloading
			 */
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading: ['image-two'],
					availableImages: [
						createImage({
							name: 'image-one',
							serviceName: 'one',
						}),
					],
					containerIdsByAppId,
				},
			);
			expectSteps('noop', steps2, 1);
			// No other steps
			expect(steps2).to.have.length(1);

			/**
			 * Only start target services after both images downloaded
			 */
			const steps3 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading: [],
					availableImages: [
						createImage({
							name: 'image-one',
							serviceName: 'one',
						}),
						createImage({
							name: 'image-two',
							serviceName: 'two',
						}),
					],
					containerIdsByAppId,
					// Mock locks taken for all services in target state
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('start', steps3, 2);
			// No other steps
			expect(steps3).to.have.length(2);
		});
	});

	it('does not infer to kill a service with default strategy if a dependency is not downloaded', async () => {
		const targetApps = createApps(
			{
				services: [
					await createService({
						image: 'main-image',
						appId: 1,
						commit: 'new-release',
						serviceName: 'main',
						composition: {
							depends_on: ['dep'],
						},
					}),
					await createService({
						image: 'dep-image',
						appId: 1,
						commit: 'new-release',
						serviceName: 'dep',
					}),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService({
						appId: 1,
						commit: 'old-release',
						serviceName: 'main',
						composition: {
							depends_on: ['dep'],
						},
					}),
					await createService({
						appId: 1,
						commit: 'old-release',
						serviceName: 'dep',
					}),
				],
				networks: [DEFAULT_NETWORK],
				downloading: ['dep-image'], // dep-image is still being downloaded
				images: [
					// main-image was already downloaded
					createImage({
						appId: 1,
						name: 'main-image',
						serviceName: 'main',
					}),
				],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock lock taken to avoid takeLock step
				appLocks: {
					'1': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		// Only noop steps should be seen at this point
		expect(steps.filter((s) => s.action !== 'noop')).to.have.lengthOf(0);
	});

	it('infers to kill several services as long as there is no unmet dependency', async () => {
		const targetApps = createApps(
			{
				services: [
					await createService({
						image: 'main-image',
						appId: 1,
						appUuid: 'appuuid',
						commit: 'new-release',
						serviceName: 'main',
						composition: {
							depends_on: ['dep'],
						},
					}),
					await createService({
						image: 'dep-image',
						appId: 1,
						appUuid: 'appuuid',
						commit: 'new-release',
						serviceName: 'dep',
					}),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);

		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService({
						appId: 1,
						appUuid: 'appuuid',
						commit: 'old-release',
						serviceName: 'main',
						composition: {
							depends_on: ['dep'],
						},
					}),
					await createService({
						appId: 1,
						appUuid: 'appuuid',
						commit: 'old-release',
						serviceName: 'dep',
					}),
				],
				networks: [DEFAULT_NETWORK],
				images: [
					// Both images have been downloaded
					createImage({
						appId: 1,
						appUuid: 'appuuid',
						name: 'main-image',
						serviceName: 'main',
						commit: 'new-release',
					}),
					createImage({
						appId: 1,
						appUuid: 'appuuid',
						name: 'dep-image',
						serviceName: 'dep',
						commit: 'new-release',
					}),
				],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock locks taken to avoid takeLock step
				appLocks: {
					'1': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		// We should see kill steps for both currently running services
		expect(
			steps.filter(
				(s: any) => s.action === 'kill' && s.current.serviceName === 'dep',
			),
		).to.have.lengthOf(1);
		expect(
			steps.filter(
				(s: any) => s.action === 'kill' && s.current.serviceName === 'main',
			),
		).to.have.lengthOf(1);
	});

	it('infers to start the dependency first', async () => {
		const targetApps = createApps(
			{
				services: [
					await createService({
						image: 'main-image',
						serviceName: 'main',
						commit: 'new-release',
						composition: {
							depends_on: ['dep'],
						},
					}),
					await createService({
						image: 'dep-image',
						serviceName: 'dep',
						commit: 'new-release',
					}),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);

		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				networks: [DEFAULT_NETWORK],
				images: [
					// Both images have been downloaded
					createImage({
						name: 'main-image',
						serviceName: 'main',
						commit: 'new-release',
					}),
					createImage({
						name: 'dep-image',
						serviceName: 'dep',
						commit: 'new-release',
					}),
				],
			});

		const [startStep, ...nextSteps] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock locks taken to avoid takeLock step
				appLocks: {
					'1': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		// A start step should happen for the dependant service first
		expect(startStep).to.have.property('action').that.equals('start');
		expect(startStep)
			.to.have.property('target')
			.that.deep.includes({ serviceName: 'dep' });

		// No more steps until the first container has been started
		expect(nextSteps).to.have.lengthOf(0);
	});

	it('infers to start a service once its dependency has been met', async () => {
		const targetApps = createApps(
			{
				services: [
					await createService({
						image: 'main-image',
						serviceName: 'main',
						commit: 'new-release',
						composition: {
							depends_on: ['dep'],
						},
					}),
					await createService({
						image: 'dep-image',
						serviceName: 'dep',
						commit: 'new-release',
					}),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);

		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService({
						image: 'dep-image',
						serviceName: 'dep',
						commit: 'new-release',
					}),
				],
				networks: [DEFAULT_NETWORK],
				images: [
					// Both images have been downloaded
					createImage({
						name: 'main-image',
						serviceName: 'main',
						commit: 'new-release',
					}),
					createImage({
						name: 'dep-image',
						serviceName: 'dep',
						commit: 'new-release',
					}),
				],
			});

		const [startStep, ...nextSteps] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock locks taken to avoid takeLock step
				appLocks: {
					'1': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		// A start step shoud happen for the depended service first
		expect(startStep).to.have.property('action').that.equals('start');
		expect(startStep)
			.to.have.property('target')
			.that.deep.includes({ serviceName: 'main' });

		expect(nextSteps).to.have.lengthOf(0);
	});

	it('infers to remove spurious containers', async () => {
		const targetApps = createApps(
			{
				services: [await createService({ image: 'main-image' })],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService({ appId: 5, serviceName: 'old-service' }),
				],
				networks: [DEFAULT_NETWORK],
				images: [
					// Image has been downloaded
					createImage({
						name: 'main-image',
						serviceName: 'main',
					}),
				],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock lock already taken for the new and leftover services
				appLocks: {
					'1': {
						async unlock() {
							/* noop */
						},
					},
					'5': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		// Start the new service
		expect(
			steps.filter(
				(s: any) => s.action === 'start' && s.target.serviceName === 'main',
			),
		).to.have.lengthOf(1);

		// Remove the leftover service
		expect(
			steps.filter(
				(s: any) =>
					s.action === 'kill' && s.current.serviceName === 'old-service',
			),
		).to.have.lengthOf(1);
	});

	it('should not remove an app volumes when they are no longer referenced', async () => {
		const targetApps = createApps({ networks: [DEFAULT_NETWORK] }, true);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				networks: [DEFAULT_NETWORK],
				volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		expect(steps.filter((s) => s.action === 'removeVolume')).to.be.empty;
	});

	it('should remove volumes from previous applications', async () => {
		const targetApps = createApps({ networks: [DEFAULT_NETWORK] }, true);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				networks: [],
				// Volume with different id
				volumes: [Volume.fromComposeObject('test-volume', 2, 'deadbeef')],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		expect(steps.filter((s) => s.action === 'removeVolume')).to.not.be.empty;
	});

	it('should remove volumes from previous applications except if keepVolumes is set', async () => {
		const targetApps = createApps({ networks: [DEFAULT_NETWORK] }, true);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				networks: [],
				// Volume with different id
				volumes: [Volume.fromComposeObject('test-volume', 2, 'deadbeef')],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				keepVolumes: true,
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		expect(steps.filter((s) => s.action === 'removeVolume')).to.be.empty;
	});

	it('should infer that we need to create the supervisor network if it does not exist', async () => {
		const docker = new Docker();
		await docker.getNetwork('supervisor0').remove();

		const targetApps = createApps(
			{ services: [await createService()], networks: [DEFAULT_NETWORK] },
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				networks: [DEFAULT_NETWORK],
			});

		const [ensureNetworkStep, ...nextSteps] =
			await applicationManager.inferNextSteps(currentApps, targetApps, {
				downloading,
				availableImages,
				containerIdsByAppId,
			});
		expect(ensureNetworkStep).to.deep.include({
			action: 'ensureSupervisorNetwork',
		});
		expect(nextSteps).to.have.lengthOf(0);
	});

	it('should kill a service which depends on the supervisor network, if we need to create the network', async () => {
		const docker = new Docker();
		await docker.getNetwork('supervisor0').remove();

		const labels = { 'io.balena.features.supervisor-api': 'true' };

		const targetApps = createApps(
			{
				services: [
					await createService({ labels }, { options: { listenPort: '48484' } }),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService({ labels }, { options: { listenPort: '48484' } }),
				],
				networks: [DEFAULT_NETWORK],
			});

		const [killStep] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		// A start step shoud happen for the depended service first
		expect(killStep).to.have.property('action').that.equals('kill');
		expect(killStep)
			.to.have.property('current')
			.that.deep.includes({ serviceName: 'main' });
	});

	it('should infer a cleanup step when a cleanup is required', async () => {
		// Create a dangling image; this is done by building an image again with
		// some slightly different metadata, leaving the old image with no metadata.
		const docker = new Docker();
		const dockerImageIdOne = await createDockerImage(
			'some-image:some-tag',
			['io.balena.testing=1'],
			docker,
		);
		const dockerImageIdTwo = await createDockerImage(
			'some-image:some-tag',
			['io.balena.testing=2'],
			docker,
		);
		// Remove the tagged image, leaving only the dangling image
		await docker.getImage(dockerImageIdTwo).remove();

		const targetApps = createApps(
			{
				services: [await createService()],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [await createService()],
				networks: [DEFAULT_NETWORK],
			});

		const [cleanupStep, ...nextSteps] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		// Cleanup needs to happen first
		expect(cleanupStep).to.deep.include({
			action: 'cleanup',
		});
		expect(nextSteps).to.have.lengthOf(0);

		await docker.getImage(dockerImageIdOne).remove();
	});

	it('should infer that an image should be removed if it is no longer referenced in current or target state (only target)', async () => {
		const targetApps = createApps(
			{
				services: [
					await createService(
						{ image: 'main-image' },
						// Target has a matching image already
						{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
					),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				networks: [DEFAULT_NETWORK],
				images: [
					// An image for a service that no longer exists
					createImage({
						name: 'old-image',
						appId: 5,
						serviceName: 'old-service',
						dockerImageId: 'sha256:aaaa',
					}),
					createImage({
						name: 'main-image',
						appId: 1,
						serviceName: 'main',
						dockerImageId: 'sha256:bbbb',
					}),
				],
			});

		const [removeImageStep] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		// A start step shoud happen for the depended service first
		expect(removeImageStep)
			.to.have.property('action')
			.that.equals('removeImage');
		expect(removeImageStep)
			.to.have.property('image')
			.that.deep.includes({ name: 'old-image' });
	});

	it('should infer that an image should be removed if it is no longer referenced in current or target state (only target) unless keepImages is true', async () => {
		const targetApps = createApps(
			{
				services: [
					await createService(
						{ image: 'main-image' },
						// Target has a matching image already
						{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
					),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				networks: [DEFAULT_NETWORK],
				images: [
					// An image for a service that no longer exists
					createImage({
						name: 'old-image',
						appId: 5,
						serviceName: 'old-service',
						dockerImageId: 'sha256:aaaa',
					}),
					createImage({
						name: 'main-image',
						appId: 1,
						serviceName: 'main',
						dockerImageId: 'sha256:bbbb',
					}),
				],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				keepImages: true,
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);
		expect(steps.filter((s) => s.action === 'removeImage')).to.be.empty;
	});

	it('should infer that an image should be removed if it is no longer referenced in current or target state (only current)', async () => {
		const targetApps = createApps(
			{
				services: [],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService(
						{ image: 'main-image' },
						// Target has a matching image already
						{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
					),
				],
				networks: [DEFAULT_NETWORK],
				images: [
					// An image for a service that no longer exists
					createImage({
						name: 'old-image',
						appId: 5,
						serviceName: 'old-service',
						dockerImageId: 'sha256:aaaa',
					}),
					createImage({
						name: 'main-image',
						appId: 1,
						serviceName: 'main',
						dockerImageId: 'sha256:bbbb',
					}),
				],
			});

		const [removeImageStep] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		// A start step shoud happen for the depended service first
		expect(removeImageStep)
			.to.have.property('action')
			.that.equals('removeImage');
		expect(removeImageStep)
			.to.have.property('image')
			.that.deep.includes({ name: 'old-image' });
	});

	it('should infer that an image should be removed if it is no longer referenced in current or target state (only current) unless keepImages is true', async () => {
		const targetApps = createApps(
			{
				services: [],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService(
						{ image: 'main-image' },
						// Target has a matching image already
						{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
					),
				],
				networks: [DEFAULT_NETWORK],
				images: [
					// An image for a service that no longer exists
					createImage({
						name: 'old-image',
						appId: 5,
						serviceName: 'old-service',
						dockerImageId: 'sha256:aaaa',
					}),
					createImage({
						name: 'main-image',
						appId: 1,
						serviceName: 'main',
						dockerImageId: 'sha256:bbbb',
					}),
				],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				keepImages: true,
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);
		expect(steps.filter((s) => s.action === 'removeImage')).to.be.empty;
	});

	it('should infer that an image should be saved if it is not in the available image list but it can be found on disk', async () => {
		const targetApps = createApps(
			{
				services: [
					await createService(
						{ image: 'main-image' },
						// Target has image info
						{ options: { imageInfo: { Id: 'sha256:bbbb' } } },
					),
				],
				networks: [DEFAULT_NETWORK],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				networks: [DEFAULT_NETWORK],
				images: [], // no available images exist
			});

		const [saveImageStep] = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
			},
		);

		// A start step shoud happen for the depended service first
		expect(saveImageStep).to.have.property('action').that.equals('saveImage');
		expect(saveImageStep)
			.to.have.property('image')
			.that.deep.includes({ name: 'main-image' });
	});

	it('should not calculate steps for a rejected app', async () => {
		const targetApps = createApps(
			{
				services: [
					await createService({
						running: true,
						image: 'main-image-1',
						appId: 1,
						appUuid: 'app-one',
						commit: 'commit-for-app-1',
					}),
					await createService({
						running: true,
						image: 'main-image-2',
						appId: 2,
						appUuid: 'app-two',
						commit: 'commit-for-app-2',
					}),
				],
				networks: [
					// Default networks for two apps
					Network.fromComposeObject('default', 1, 'app-one', {}),
					Network.fromComposeObject('default', 2, 'app-two', {}),
				],
				rejectedAppIds: [1],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [
					await createService({
						running: true,
						image: 'old-image-1',
						appId: 1,
						appUuid: 'app-one',
						commit: 'commit-for-app-0',
					}),
				],
				networks: [
					// Default networks for two apps
					Network.fromComposeObject('default', 1, 'app-one', {}),
					Network.fromComposeObject('default', 2, 'app-two', {}),
				],
				images: [
					createImage({
						name: 'main-image-1',
						appId: 1,
						appUuid: 'app-one',
						serviceName: 'main',
						commit: 'commit-for-app-1',
					}),
					createImage({
						name: 'main-image-2',
						appId: 2,
						appUuid: 'app-two',
						serviceName: 'main',
						commit: 'commit-for-app-2',
					}),
				],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock locks taken to avoid takeLock step
				appLocks: {
					'2': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		// Expect a start step for both apps
		expect(
			steps.filter((s: any) => s.target && s.target.appId === 1),
		).to.have.lengthOf(0);
		expect(
			steps.filter((s: any) => s.image && s.image.appId === 1),
		).to.have.lengthOf(0);
		expect(
			steps.filter(
				(s: any) =>
					s.action === 'start' &&
					s.target.appId === 2 &&
					s.target.serviceName === 'main',
			),
		).to.have.lengthOf(1);
	});

	it('should correctly generate steps for multiple apps', async () => {
		const targetApps = createApps(
			{
				services: [
					await createService({
						running: true,
						image: 'main-image-1',
						appId: 1,
						appUuid: 'app-one',
						commit: 'commit-for-app-1',
					}),
					await createService({
						running: true,
						image: 'main-image-2',
						appId: 2,
						appUuid: 'app-two',
						commit: 'commit-for-app-2',
					}),
				],
				networks: [
					// Default networks for two apps
					Network.fromComposeObject('default', 1, 'app-one', {}),
					Network.fromComposeObject('default', 2, 'app-two', {}),
				],
			},
			true,
		);
		const { currentApps, availableImages, downloading, containerIdsByAppId } =
			createCurrentState({
				services: [],
				networks: [
					// Default networks for two apps
					Network.fromComposeObject('default', 1, 'app-one', {}),
					Network.fromComposeObject('default', 2, 'app-two', {}),
				],
				images: [
					createImage({
						name: 'main-image-1',
						appId: 1,
						appUuid: 'app-one',
						serviceName: 'main',
						commit: 'commit-for-app-1',
					}),
					createImage({
						name: 'main-image-2',
						appId: 2,
						appUuid: 'app-two',
						serviceName: 'main',
						commit: 'commit-for-app-2',
					}),
				],
			});

		const steps = await applicationManager.inferNextSteps(
			currentApps,
			targetApps,
			{
				downloading,
				availableImages,
				containerIdsByAppId,
				// Mock locks taken to avoid takeLock step
				appLocks: {
					'1': {
						async unlock() {
							/* noop */
						},
					},
					'2': {
						async unlock() {
							/* noop */
						},
					},
				},
			},
		);

		// Expect a start step for both apps
		expect(
			steps.filter(
				(s: any) =>
					s.action === 'start' &&
					s.target.appId === 1 &&
					s.target.serviceName === 'main',
			),
		).to.have.lengthOf(1);
		expect(
			steps.filter(
				(s: any) =>
					s.action === 'start' &&
					s.target.appId === 2 &&
					s.target.serviceName === 'main',
			),
		).to.have.lengthOf(1);
	});

	describe('taking and releasing locks', () => {
		it('should take locks for all services in current state when they should be killed', async () => {
			const targetApps = createApps(
				{
					services: [],
					networks: [DEFAULT_NETWORK],
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: [
						await createService({ serviceName: 'one' }),
						await createService({ serviceName: 'two' }),
					],
					networks: [DEFAULT_NETWORK],
					images: [],
				});

			// takeLock
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
				},
			);
			const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
			expect(takeLockStep)
				.to.have.property('services')
				.that.deep.includes.members(['one', 'two']);

			// kill
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('kill', steps2, 2);
		});

		it('should take locks for all services in current state when they should be stopped', async () => {
			const targetApps = createApps(
				{
					services: [
						await createService({
							serviceName: 'one',
							running: false,
						}),
						await createService({
							serviceName: 'two',
							running: false,
						}),
					],
					networks: [DEFAULT_NETWORK],
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: [
						await createService({ serviceName: 'one' }),
						await createService({ serviceName: 'two' }),
					],
					networks: [DEFAULT_NETWORK],
					images: [],
				});

			// takeLock
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
				},
			);
			const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
			expect(takeLockStep)
				.to.have.property('services')
				.that.deep.includes.members(['one', 'two']);

			// stop
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('stop', steps2, 2);
		});

		it('should take locks for all services in target state before they should be started', async () => {
			const targetApps = createApps(
				{
					services: [
						await createService({ serviceName: 'one', image: 'one-image' }),
						await createService({ serviceName: 'two', image: 'two-image' }),
					],
					networks: [DEFAULT_NETWORK],
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: [],
					networks: [DEFAULT_NETWORK],
					images: [
						createImage({
							serviceName: 'one',
							name: 'one-image',
						}),
						createImage({
							serviceName: 'two',
							name: 'two-image',
						}),
					],
				});

			// takeLock
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
				},
			);
			const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
			expect(takeLockStep)
				.to.have.property('services')
				.that.deep.includes.members(['one', 'two']);

			// start
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('start', steps2, 2);
		});

		it('should take locks for all services in current and target states when services should be stopped, started, or killed', async () => {
			const targetApps = createApps(
				{
					services: [
						await createService({
							serviceName: 'to-stop',
							image: 'image-to-stop',
							running: false,
						}),
						await createService({
							serviceName: 'to-start',
							image: 'image-to-start',
						}),
						await createService({
							serviceName: 'unchanged',
							image: 'image-unchanged',
						}),
					],
					networks: [DEFAULT_NETWORK],
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: [
						await createService({
							serviceName: 'to-stop',
							image: 'image-to-stop',
						}),
						await createService({
							serviceName: 'to-kill',
							image: 'image-to-kill',
						}),
						await createService({
							serviceName: 'unchanged',
							image: 'image-unchanged',
						}),
					],
					networks: [DEFAULT_NETWORK],
					images: [
						createImage({ serviceName: 'to-start', name: 'image-to-start' }),
						createImage({ serviceName: 'to-stop', name: 'image-to-stop' }),
						createImage({ serviceName: 'unchanged', name: 'image-unchanged' }),
					],
				});

			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
				},
			);
			// No matter the number of services, we should see a single takeLock for all services
			// regardless if they're only in current or only in target
			const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
			expect(takeLockStep)
				.to.have.property('services')
				.that.deep.includes.members([
					'to-stop',
					'to-kill',
					'to-start',
					'unchanged',
				]);
		});

		it('should download images before taking locks & killing when update strategy is download-then-kill or handover', async () => {
			const targetApps = createApps(
				{
					services: [
						await createService({
							serviceName: 'one',
							image: 'one',
							labels: {
								'io.balena.update.strategy': 'download-then-kill',
								'io.updated': 'true',
							},
						}),
						await createService({
							serviceName: 'two',
							image: 'two',
							labels: {
								'io.balena.update.strategy': 'handover',
								'io.updated': 'true',
							},
						}),
					],
					networks: [DEFAULT_NETWORK],
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: [
						await createService({
							serviceName: 'one',
							image: 'one',
							labels: { 'io.balena.update.strategy': 'download-then-kill' },
						}),
						await createService({
							serviceName: 'two',
							image: 'two',
							labels: { 'io.balena.update.strategy': 'handover' },
						}),
					],
					networks: [DEFAULT_NETWORK],
				});

			// fetch
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading: [],
					availableImages: [],
					containerIdsByAppId,
				},
			);
			expectSteps('fetch', steps, 2);

			// noop while downloading
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading: ['one', 'two'],
					availableImages: [],
					containerIdsByAppId,
				},
			);
			expectSteps('noop', steps2, 2);

			// takeLock after download complete
			const steps3 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
				},
			);
			const [takeLockStep] = expectSteps('takeLock', steps3, 1);
			expect(takeLockStep)
				.to.have.property('services')
				.that.deep.includes.members(['one', 'two']);

			// kill
			const steps4 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('kill', steps4, 2);
		});

		it('should take locks & kill before downloading images when update strategy is kill|delete-then-download', async () => {
			const targetApps = createApps(
				{
					services: [
						await createService({
							serviceName: 'one',
							labels: {
								'io.balena.update.strategy': 'kill-then-download',
								'io.updated': 'true',
							},
						}),
						await createService({
							serviceName: 'two',
							labels: {
								'io.balena.update.strategy': 'delete-then-download',
								'io.updated': 'true',
							},
						}),
					],
					networks: [DEFAULT_NETWORK],
				},
				true,
			);
			const { currentApps, availableImages, containerIdsByAppId } =
				createCurrentState({
					services: [
						await createService({
							serviceName: 'one',
							labels: { 'io.balena.update.strategy': 'kill-then-download' },
						}),
						await createService({
							serviceName: 'two',
							labels: { 'io.balena.update.strategy': 'delete-then-download' },
						}),
					],
					networks: [DEFAULT_NETWORK],
				});

			// takeLock
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					// Images haven't finished downloading,
					// but kill steps should still be inferred
					downloading: ['one', 'two'],
					availableImages,
					containerIdsByAppId,
				},
			);
			const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
			expect(takeLockStep)
				.to.have.property('services')
				.that.deep.includes.members(['one', 'two']);

			// kill
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading: ['one', 'two'],
					availableImages,
					containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('kill', steps2, 2);
		});

		it('should infer takeLock & kill steps for dependent services before their network should be removed', async () => {
			const targetApps = createApps(
				{
					services: [
						await createService({ serviceName: 'main', appUuid: 'deadbeef' }),
					],
					networks: [DEFAULT_NETWORK],
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: [
						await createService({
							serviceName: 'main',
							appUuid: 'deadbeef',
							composition: { networks: { test: {} } },
						}),
					],
					networks: [
						DEFAULT_NETWORK,
						Network.fromComposeObject('test', 1, 'deadbeef', {
							driver: 'bridge',
						}),
					],
				});

			// takeLock
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
				},
			);
			const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
			expect(takeLockStep)
				.to.have.property('services')
				.that.deep.includes.members(['main']);

			// kill
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('kill', steps2);

			// removeNetwork
			const intermediateCurrent = createCurrentState({
				services: [],
				networks: [
					DEFAULT_NETWORK,
					Network.fromComposeObject('test', 1, 'deadbeef', {
						driver: 'bridge',
					}),
				],
			});
			const steps3 = await applicationManager.inferNextSteps(
				intermediateCurrent.currentApps,
				targetApps,
				{
					downloading: intermediateCurrent.downloading,
					availableImages: intermediateCurrent.availableImages,
					containerIdsByAppId: intermediateCurrent.containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('removeNetwork', steps3);
		});

		it('should infer takeLock & kill steps for dependent services before their network should have config changed', async () => {
			const targetApps = createApps(
				{
					services: [
						await createService({
							serviceName: 'main',
							appUuid: 'deadbeef',
							composition: { networks: { test: {} } },
						}),
					],
					networks: [
						DEFAULT_NETWORK,
						Network.fromComposeObject('test', 1, 'deadbeef', {
							driver: 'local',
						}),
					],
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: [
						await createService({
							serviceName: 'main',
							appUuid: 'deadbeef',
							composition: { networks: { test: {} } },
						}),
					],
					networks: [
						DEFAULT_NETWORK,
						Network.fromComposeObject('test', 1, 'deadbeef', {
							driver: 'bridge',
						}),
					],
				});

			// takeLock
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
				},
			);
			const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
			expect(takeLockStep)
				.to.have.property('services')
				.that.deep.includes.members(['main']);

			// kill
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('kill', steps2, 1);

			// removeNetwork
			const intermediateCurrent = createCurrentState({
				services: [],
				networks: [
					DEFAULT_NETWORK,
					Network.fromComposeObject('test', 1, 'deadbeef', {
						driver: 'bridge',
					}),
				],
			});
			const steps3 = await applicationManager.inferNextSteps(
				intermediateCurrent.currentApps,
				targetApps,
				{
					downloading: intermediateCurrent.downloading,
					availableImages: intermediateCurrent.availableImages,
					containerIdsByAppId: intermediateCurrent.containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('removeNetwork', steps3);
		});

		it('should infer takeLock & kill steps for dependent services before their volume should have config changed', async () => {
			const targetApps = createApps(
				{
					services: [
						await createService({
							serviceName: 'main',
							appUuid: 'deadbeef',
							composition: { volumes: ['test:/test'] },
						}),
					],
					networks: [DEFAULT_NETWORK],
					volumes: [
						Volume.fromComposeObject('test', 1, 'deadbeef', {
							labels: { 'io.updated': 'true' },
						}),
					],
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: [
						await createService({
							serviceName: 'main',
							appUuid: 'deadbeef',
							composition: { volumes: ['test:/test'] },
						}),
					],
					networks: [DEFAULT_NETWORK],
					volumes: [Volume.fromComposeObject('test', 1, 'deadbeef')],
				});

			// takeLock
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
				},
			);
			const [takeLockStep] = expectSteps('takeLock', steps, 1, 1);
			expect(takeLockStep)
				.to.have.property('services')
				.that.deep.includes.members(['main']);

			// kill
			const steps2 = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('kill', steps2, 1);

			// removeVolume
			const intermediateCurrent = createCurrentState({
				services: [],
				networks: [DEFAULT_NETWORK],
				volumes: [Volume.fromComposeObject('test', 1, 'deadbeef')],
			});
			const steps3 = await applicationManager.inferNextSteps(
				intermediateCurrent.currentApps,
				targetApps,
				{
					downloading: intermediateCurrent.downloading,
					availableImages: intermediateCurrent.availableImages,
					containerIdsByAppId: intermediateCurrent.containerIdsByAppId,
					// Mock locks taken
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			expectSteps('removeVolume', steps3);
		});

		it('should release locks before settling state', async () => {
			const targetApps = createApps(
				{
					services: [
						await createService({ serviceName: 'one' }),
						await createService({ serviceName: 'two' }),
					],
					networks: [DEFAULT_NETWORK],
				},
				true,
			);
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				createCurrentState({
					services: [
						await createService({ serviceName: 'one' }),
						await createService({ serviceName: 'two' }),
					],
					networks: [DEFAULT_NETWORK],
				});
			const steps = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				},
			);
			const [releaseLockStep] = expectSteps('releaseLock', steps, 1, 1);
			expect(releaseLockStep).to.have.property('appId').that.equals(1);
		});
	});

	describe("getting application's current state", () => {
		let getImagesState: sinon.SinonStub;
		let getServicesState: sinon.SinonStub;

		before(() => {
			getImagesState = sinon.stub(imageManager, 'getState');
			getServicesState = sinon.stub(serviceManager, 'getState');
		});

		afterEach(() => {
			getImagesState.reset();
			getServicesState.reset();
		});

		after(() => {
			getImagesState.restore();
			getServicesState.restore();
		});

		it('reports the state of images if no service is available', async () => {
			getImagesState.resolves([
				{
					name: 'ubuntu:latest',
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'ubuntu',
					status: 'Downloaded',
				},
				{
					name: 'alpine:latest',
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'alpine',
					status: 'Downloading',
					downloadProgress: 50,
				},
				{
					name: 'fedora:latest',
					commit: 'newrelease',
					appUuid: 'fedora',
					serviceName: 'fedora',
					status: 'Downloading',
					downloadProgress: 75,
				},
				{
					name: 'fedora:older',
					commit: 'oldrelease',
					appUuid: 'fedora',
					serviceName: 'fedora',
					status: 'Downloaded',
				},
			]);
			getServicesState.resolves([]);

			expect(await applicationManager.getState()).to.deep.equal({
				myapp: {
					releases: {
						latestrelease: {
							services: {
								ubuntu: {
									image: 'ubuntu:latest',
									status: 'Downloaded',
								},
								alpine: {
									image: 'alpine:latest',
									status: 'Downloading',
									download_progress: 50,
								},
							},
							update_status: 'downloading',
						},
					},
				},
				fedora: {
					releases: {
						oldrelease: {
							services: {
								fedora: {
									image: 'fedora:older',
									status: 'Downloaded',
								},
							},
							update_status: 'downloaded',
						},
						newrelease: {
							services: {
								fedora: {
									image: 'fedora:latest',
									status: 'Downloading',
									download_progress: 75,
								},
							},
							update_status: 'downloading',
						},
					},
				},
			});
		});

		it('augments the service data with image data', async () => {
			getImagesState.resolves([
				{
					name: 'ubuntu:latest',
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'ubuntu',
					status: 'Downloaded',
				},
				{
					name: 'node:latest',
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'node',
					status: 'Downloading',
					downloadProgress: 0,
				},
				{
					name: 'alpine:latest',
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'alpine',
					status: 'Downloading',
					downloadProgress: 50,
				},
				{
					name: 'fedora:older',
					commit: 'oldrelease',
					appUuid: 'fedora',
					serviceName: 'fedora',
					status: 'Downloaded',
				},
			]);
			getServicesState.resolves([
				{
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'ubuntu',
					status: 'Running',
					createdAt: new Date('2021-09-01T13:00:00'),
				},
				{
					commit: 'oldrelease',
					serviceName: 'fedora',
					status: 'exited',
					createdAt: new Date('2021-09-01T12:00:00'),
				},
				{
					// Service without an image should not show on the final state
					appUuid: 'debian',
					commit: 'otherrelease',
					serviceName: 'debian',
					status: 'exited',
					createdAt: new Date('2021-09-01T12:00:00'),
				},
			]);

			expect(await applicationManager.getState()).to.deep.equal({
				myapp: {
					releases: {
						latestrelease: {
							services: {
								ubuntu: {
									image: 'ubuntu:latest',
									status: 'Running',
								},
								alpine: {
									image: 'alpine:latest',
									status: 'Downloading',
									download_progress: 50,
								},
								node: {
									image: 'node:latest',
									status: 'Downloading',
									download_progress: 0,
								},
							},
							update_status: 'downloading',
						},
					},
				},
				fedora: {
					releases: {
						oldrelease: {
							services: {
								fedora: {
									image: 'fedora:older',
									status: 'exited',
								},
							},
							update_status: 'done',
						},
					},
				},
			});
		});

		it('reports aborted state if one of the services/images status is aborted', async () => {
			getImagesState.resolves([
				{
					name: 'ubuntu:latest',
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'ubuntu',
					status: 'Downloaded',
				},
				{
					name: 'node:latest',
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'node',
					status: 'Downloaded',
					downloadProgress: 100,
				},
				{
					name: 'alpine:latest',
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'alpine',
					status: 'Aborted',
					downloadProgress: 0,
				},
			]);
			getServicesState.resolves([
				{
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'ubuntu',
					status: 'Running',
					createdAt: new Date('2021-09-01T13:00:00'),
				},
				{
					appUuid: 'myapp',
					commit: 'latestrelease',
					serviceName: 'node',
					// we don't have a way to abort a failing service install yet, but
					// once we do it will need to use the status field
					status: 'Aborted',
					createdAt: new Date('2021-09-01T12:00:00'),
				},
			]);

			expect(await applicationManager.getState()).to.deep.equal({
				myapp: {
					releases: {
						latestrelease: {
							services: {
								ubuntu: {
									image: 'ubuntu:latest',
									status: 'Running',
								},
								alpine: {
									image: 'alpine:latest',
									// we don't have a way to abort a failing download yet, but
									// once we do it will need to use the status field
									status: 'Aborted',
									download_progress: 0,
								},
								node: {
									image: 'node:latest',
									status: 'Aborted',
									download_progress: 100,
								},
							},
							update_status: 'aborted',
						},
					},
				},
			});
		});

		it('reports handover state if multiple services are running for the same app', async () => {
			getImagesState.resolves([
				{
					name: 'alpine:3.13',
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'alpine',
					status: 'Downloaded',
				},
				{
					name: 'alpine:3.12',
					commit: 'oldrelease',
					appUuid: 'myapp',
					serviceName: 'alpine',
					status: 'Downloaded',
				},
			]);
			getServicesState.resolves([
				{
					commit: 'latestrelease',
					appUuid: 'myapp',
					serviceName: 'alpine',
					status: 'Running',
					createdAt: new Date('2021-09-01T13:00:00'),
				},
				{
					commit: 'oldrelease',
					appUuid: 'myapp',
					serviceName: 'alpine',
					status: 'Running',
					createdAt: new Date('2021-09-01T12:00:00'),
				},
			]);

			expect(await applicationManager.getState()).to.deep.equal({
				myapp: {
					releases: {
						latestrelease: {
							services: {
								alpine: {
									image: 'alpine:3.13',
									status: 'Awaiting handover',
								},
							},
							update_status: 'applying changes',
						},
						oldrelease: {
							services: {
								alpine: {
									image: 'alpine:3.12',
									status: 'Handing over',
								},
							},
							update_status: 'done',
						},
					},
				},
			});
		});
	});

	// In the case where a container requires a host resource such as a network interface that is not created by the time the Engine
	// comes up, the Engine will not attempt to restart the container which seems to be Docker's implemented behavior (if not the correct behavior).
	// An example of a host resource would be a port binding such as 192.168.88.1:3000:3000, where the IP is an interface delayed in creation by host.
	// In this case, the Supervisor parses the exit message of the container, and if it matches the error regex, start the container.
	describe('handling Engine restart policy inaction when host resource required by container is delayed in creation', () => {
		const getCurrentState = async (withHostError: boolean) => {
			const exitErrorMessage = withHostError
				? 'driver failed programming external connectivity on endpoint one_1_1_deadbeef (deadca1f): Error starting userland proxy: listen tcp4 192.168.88.1:8081: bind: cannot assign requested address'
				: 'My test error';
			return createCurrentState({
				services: [
					await createService(
						{
							image: 'test-image',
							serviceName: 'one',
							running: false,
							composition: {
								restart: 'always',
							},
						},
						{
							state: {
								status: 'exited',
								exitErrorMessage,
							},
						},
					),
					await createService(
						{
							image: 'test-image',
							serviceName: 'two',
							running: false,
							composition: {
								restart: 'unless-stopped',
							},
						},
						{
							state: {
								status: 'exited',
								exitErrorMessage,
							},
						},
					),
					await createService(
						{
							image: 'test-image',
							serviceName: 'three',
							running: false,
							composition: {
								restart: 'on-failure',
							},
						},
						{
							state: {
								status: 'exited',
								exitErrorMessage,
							},
						},
					),
					await createService(
						{
							image: 'test-image',
							serviceName: 'four',
							running: false,
							composition: {
								restart: 'no',
							},
						},
						{
							state: {
								status: 'exited',
								exitErrorMessage,
							},
						},
					),
				],
				networks: [DEFAULT_NETWORK],
				images: [
					createImage({
						name: 'test-image',
						serviceName: 'one',
					}),
					createImage({
						name: 'test-image',
						serviceName: 'two',
					}),
					createImage({
						name: 'test-image',
						serviceName: 'three',
					}),
					createImage({
						name: 'test-image',
						serviceName: 'four',
					}),
				],
			});
		};

		let targetApps: InstancedAppState;

		before(async () => {
			targetApps = createApps(
				{
					services: [
						await createService({
							image: 'test-image',
							serviceName: 'one',
							running: true,
							composition: {
								restart: 'always',
							},
						}),
						await createService({
							image: 'test-image',
							serviceName: 'two',
							running: true,
							composition: {
								restart: 'unless-stopped',
							},
						}),
						await createService({
							image: 'test-image',
							serviceName: 'three',
							running: true,
							composition: {
								restart: 'on-failure',
							},
						}),
						await createService({
							image: 'test-image',
							serviceName: 'four',
							running: true,
							composition: {
								restart: 'no',
							},
						}),
					],
					networks: [DEFAULT_NETWORK],
				},
				true,
			);
		});

		it('should infer a start step for a service that exited with the "userland proxy" error for all restart policies', async () => {
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				await getCurrentState(true);

			const [startStep1, startStep2, startStep3, startStep4, ...nextSteps] =
				await applicationManager.inferNextSteps(currentApps, targetApps, {
					downloading,
					availableImages,
					containerIdsByAppId,
					// Mock locks taken for all services in target state
					appLocks: {
						'1': {
							async unlock() {
								/* noop */
							},
						},
					},
				});

			[startStep1, startStep2, startStep3, startStep4].forEach((step) => {
				expect(step).to.have.property('action').that.equals('start');
				expect(step)
					.to.have.property('target')
					.that.has.property('serviceName')
					.that.is.oneOf(['one', 'two', 'three', 'four']);
			});
			expect(nextSteps).to.have.lengthOf(0);
		});

		it('should not infer any steps for a service with a status of "exited" without the "userland proxy" error message', async () => {
			const { currentApps, availableImages, downloading, containerIdsByAppId } =
				await getCurrentState(false);

			const [...steps] = await applicationManager.inferNextSteps(
				currentApps,
				targetApps,
				{
					downloading,
					availableImages,
					containerIdsByAppId,
				},
			);

			expect(steps).to.have.lengthOf(0);
		});
	});
});
