Merge pull request #2176 from balena-os/improve-engine-host-race-fix-implementation

Improve engine host race fix implementation
This commit is contained in:
flowzone-app[bot] 2023-06-19 19:00:29 +00:00 committed by GitHub
commit 2ddacb6b44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 516 additions and 335 deletions

View File

@ -12,8 +12,7 @@ import {
generateStep,
CompositionStepAction,
} from './composition-steps';
import { isOlderThan } from './utils';
import { inspectByDockerContainerId } from './service-manager';
import { isValidDateAndOlderThan } from './utils';
import * as targetStateCache from '../device-state/target-state-cache';
import { getNetworkGateway } from '../lib/docker-utils';
import * as constants from '../lib/constants';
@ -27,7 +26,7 @@ import { ServiceComposeConfig, DeviceMetadata } from './types/service';
import { pathExistsOnRoot } from '../lib/host-utils';
import { isSupervisor } from '../lib/supervisor-metadata';
const SECONDS_TO_WAIT_FOR_START = 60;
const SECONDS_TO_WAIT_FOR_START = 30;
export interface AppConstructOpts {
appId: number;
@ -102,10 +101,10 @@ export class App {
}
}
public async nextStepsForAppUpdate(
public nextStepsForAppUpdate(
state: UpdateState,
target: App,
): Promise<CompositionStep[]> {
): CompositionStep[] {
// Check to see if we need to polyfill in some "new" data for legacy services
this.migrateLegacy(target);
@ -131,12 +130,11 @@ export class App {
}
}
const { removePairs, installPairs, updatePairs } =
await this.compareServices(
this.services,
target.services,
state.containerIds,
);
const { removePairs, installPairs, updatePairs } = this.compareServices(
this.services,
target.services,
state.containerIds,
);
for (const { current: svc } of removePairs) {
// All removes get a kill action if they're not already stopping
@ -149,19 +147,18 @@ export class App {
// For every service which needs to be updated, update via update strategy.
const servicePairs = updatePairs.concat(installPairs);
const serviceSteps = await Promise.all(
servicePairs.map((pair) =>
this.generateStepsForService(pair, {
...state,
servicePairs,
targetApp: target,
networkPairs: networkChanges,
volumePairs: volumeChanges,
}),
),
);
steps = steps.concat(
serviceSteps.filter((step) => step != null) as CompositionStep[],
servicePairs
.map((pair) =>
this.generateStepsForService(pair, {
...state,
servicePairs,
targetApp: target,
networkPairs: networkChanges,
volumePairs: volumeChanges,
}),
)
.filter((step) => step != null) as CompositionStep[],
);
// Generate volume steps
@ -192,9 +189,9 @@ export class App {
return steps;
}
public async stepsToRemoveApp(
public stepsToRemoveApp(
state: Omit<UpdateState, 'availableImages'> & { keepVolumes: boolean },
): Promise<CompositionStep[]> {
): CompositionStep[] {
if (Object.keys(this.services).length > 0) {
return Object.values(this.services).map((service) =>
generateStep('kill', { current: service }),
@ -300,15 +297,15 @@ export class App {
return outputs;
}
private async compareServices(
private compareServices(
current: Service[],
target: Service[],
containerIds: Dictionary<string>,
): Promise<{
): {
installPairs: Array<ChangingPair<Service>>;
removePairs: Array<ChangingPair<Service>>;
updatePairs: Array<ChangingPair<Service>>;
}> {
} {
const currentByServiceName = _.keyBy(current, 'serviceName');
const targetByServiceName = _.keyBy(target, 'serviceName');
@ -367,7 +364,7 @@ export class App {
* @param serviceCurrent
* @param serviceTarget
*/
const shouldBeStarted = async (
const shouldBeStarted = (
serviceCurrent: Service,
serviceTarget: Service,
) => {
@ -391,10 +388,7 @@ export class App {
return (
(serviceCurrent.status === 'Installing' ||
serviceCurrent.status === 'Installed' ||
(await this.requirementsMetForSpecialStart(
serviceCurrent,
serviceTarget,
))) &&
this.requirementsMetForSpecialStart(serviceCurrent, serviceTarget)) &&
isEqualExceptForRunningState(serviceCurrent, serviceTarget)
);
};
@ -430,24 +424,18 @@ export class App {
/**
* Filter all the services which should be updated due to run state change, or config mismatch.
*/
const satisfiesRequirementsForUpdate = async (c: Service, t: Service) =>
!isEqualExceptForRunningState(c, t) ||
(await shouldBeStarted(c, t)) ||
shouldBeStopped(c, t) ||
shouldWaitForStop(c);
const maybeUpdatePairs = maybeUpdate.map((serviceName) => ({
current: currentByServiceName[serviceName],
target: targetByServiceName[serviceName],
}));
const maybeUpdatePairsFiltered = await Promise.all(
maybeUpdatePairs.map(({ current: c, target: t }) =>
satisfiesRequirementsForUpdate(c, t),
),
);
const toBeUpdated = maybeUpdatePairs.filter(
(__, idx) => maybeUpdatePairsFiltered[idx],
);
const toBeUpdated = maybeUpdate
.map((serviceName) => ({
current: currentByServiceName[serviceName],
target: targetByServiceName[serviceName],
}))
.filter(
({ current: c, target: t }) =>
!isEqualExceptForRunningState(c, t) ||
shouldBeStarted(c, t) ||
shouldBeStopped(c, t) ||
shouldWaitForStop(c),
);
return {
installPairs: toBeInstalled,
@ -513,7 +501,7 @@ export class App {
return steps;
}
private async generateStepsForService(
private generateStepsForService(
{ current, target }: ChangingPair<Service>,
context: {
availableImages: Image[];
@ -524,7 +512,7 @@ export class App {
volumePairs: Array<ChangingPair<Volume>>;
servicePairs: Array<ChangingPair<Service>>;
},
): Promise<Nullable<CompositionStep>> {
): Nullable<CompositionStep> {
if (current?.status === 'Stopping') {
// Theres a kill step happening already, emit a noop to ensure we stay alive while
// this happens
@ -578,7 +566,7 @@ export class App {
current.isEqualConfig(target, context.containerIds)
) {
// we're only starting/stopping a service
return await this.generateContainerStep(current, target);
return this.generateContainerStep(current, target);
}
let strategy =
@ -656,11 +644,10 @@ export class App {
// restart policy (this can happen in cases of Engine race conditions with
// host resources that are slower to be created but that a service relies on),
// we need to start the container after a delay.
private async requirementsMetForSpecialStart(
private requirementsMetForSpecialStart(
current: Service,
target: Service,
): Promise<boolean> {
// Shortcut the Engine inspect queries if status isn't exited
): boolean {
if (
current.status !== 'exited' ||
current.config.running !== false ||
@ -668,37 +655,23 @@ export class App {
) {
return false;
}
let conditionsMetWithRestartPolicy = ['always', 'unless-stopped'].includes(
target.config.restart,
);
if (current.containerId != null && !conditionsMetWithRestartPolicy) {
const containerInspect = await inspectByDockerContainerId(
current.containerId,
);
// If the container has previously been started but exited unsuccessfully, it needs to be started.
// If the container has a restart policy of 'no' and has never been started, it
// should also be started, however, in that case, the status of the container will
// be 'Installed' and the state funnel will already be trying to start it until success,
// so we don't need to add any additional handling.
if (
target.config.restart === 'on-failure' &&
containerInspect.State.ExitCode !== 0
) {
conditionsMetWithRestartPolicy = true;
}
}
return conditionsMetWithRestartPolicy;
const restartIsAlwaysOrUnlessStopped = [
'always',
'unless-stopped',
].includes(target.config.restart);
const restartIsOnFailureWithNonZeroExit =
target.config.restart === 'on-failure' && current.exitCode !== 0;
return restartIsAlwaysOrUnlessStopped || restartIsOnFailureWithNonZeroExit;
}
private async generateContainerStep(current: Service, target: Service) {
private generateContainerStep(current: Service, target: Service) {
// if the services release doesn't match, then rename the container...
if (current.commit !== target.commit) {
return generateStep('updateMetadata', { current, target });
} else if (target.config.running !== current.config.running) {
if (
(await this.requirementsMetForSpecialStart(current, target)) &&
!isOlderThan(current.createdAt, SECONDS_TO_WAIT_FOR_START)
this.requirementsMetForSpecialStart(current, target) &&
!isValidDateAndOlderThan(current.createdAt, SECONDS_TO_WAIT_FOR_START)
) {
return generateStep('noop', {});
}

View File

@ -207,7 +207,7 @@ export async function inferNextSteps(
// do to move to the target state
for (const id of targetAndCurrent) {
steps = steps.concat(
await currentApps[id].nextStepsForAppUpdate(
currentApps[id].nextStepsForAppUpdate(
{
availableImages,
containerIds: containerIdsByAppId[id],
@ -221,7 +221,7 @@ export async function inferNextSteps(
// For apps in the current state but not target, we call their "destructor"
for (const id of onlyCurrent) {
steps = steps.concat(
await currentApps[id].stepsToRemoveApp({
currentApps[id].stepsToRemoveApp({
keepVolumes,
downloading,
containerIds: containerIdsByAppId[id],
@ -243,7 +243,7 @@ export async function inferNextSteps(
false,
);
steps = steps.concat(
await emptyCurrent.nextStepsForAppUpdate(
emptyCurrent.nextStepsForAppUpdate(
{
availableImages,
containerIds: containerIdsByAppId[id] ?? {},

View File

@ -135,7 +135,7 @@ export async function getState() {
export async function getByDockerContainerId(
containerId: string,
): Promise<Service | null> {
const container = await inspectByDockerContainerId(containerId);
const container = await docker.getContainer(containerId).inspect();
if (
container.Config.Labels['io.balena.supervised'] == null &&
container.Config.Labels['io.resin.supervised'] == null
@ -145,12 +145,6 @@ export async function getByDockerContainerId(
return Service.fromDockerContainer(container);
}
export async function inspectByDockerContainerId(
containerId: string,
): Promise<Dockerode.ContainerInspectInfo> {
return await docker.getContainer(containerId).inspect();
}
export async function updateMetadata(service: Service, target: Service) {
const svc = await get(service);
if (svc.containerId == null) {

View File

@ -61,6 +61,7 @@ export class Service {
public serviceId: number;
public imageName: string | null;
public containerId: string | null;
public exitCode: number | null;
public dependsOn: string[] | null;
@ -503,6 +504,7 @@ export class Service {
svc.createdAt = new Date(container.Created);
svc.containerId = container.Id;
svc.exitCode = container.State.ExitCode;
let hostname = container.Config.Hostname;
if (hostname.length === 12 && _.startsWith(container.Id, hostname)) {

View File

@ -694,8 +694,20 @@ export function dockerMountToServiceMount(
return mount as LongDefinition;
}
export function isOlderThan(currentTime: Date | null, seconds: number) {
if (currentTime == null) {
function isDate(d: unknown): d is Date {
return d instanceof Date;
}
/**
* @param currentTime Date instance
* @param seconds time in seconds to check against currentTime
* @returns
*/
export function isValidDateAndOlderThan(
currentTime: unknown,
seconds: number,
): boolean {
if (!isDate(currentTime)) {
return false;
}
return new Date().getTime() - currentTime.getTime() > seconds * 1000;

View File

@ -1422,22 +1422,21 @@ describe('compose/application-manager', () => {
// 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 needs to wait a grace period for the Engine to start the container, and if this does not occur, the Supervisor
// deduces the existence of this race condition and generates another start step after a delay (1 minute by default).
// deduces the existence of this race condition and generates another start step after a delay (SECONDS_TO_WAIT_FOR_START).
describe('handling Engine restart policy inaction when host resource required by container is delayed in creation', () => {
// Time 61 seconds ago
const date61SecondsAgo = new Date();
date61SecondsAgo.setSeconds(date61SecondsAgo.getSeconds() - 61);
const SECONDS_TO_WAIT_FOR_START = 30;
const newer = SECONDS_TO_WAIT_FOR_START - 2;
const older = SECONDS_TO_WAIT_FOR_START + 2;
// Time 59 seconds ago
const date50SecondsAgo = new Date();
date50SecondsAgo.setSeconds(date50SecondsAgo.getSeconds() - 50);
const getTimeNSecondsAgo = (date: Date, seconds: number) => {
const newDate = new Date(date);
newDate.setSeconds(newDate.getSeconds() - seconds);
return newDate;
};
// TODO: We need to be able to start a service with restart policy "no" if that service did not start at all due to
// the host resource race condition described above. However, this is harder to parse as the containers do not include
// the proper metadata for this. The last resort would be parsing the error message that caused the container to exit.
it('should not infer any steps for a service with a status of "exited" if restart policy is "no" or "on-failure"', async () => {
it('should not infer any steps for a service with a status of "exited" if restart policy is "no"', async () => {
// Conditions:
// - restart: "no" || "on-failure"
// - restart: "no"
// - status: "exited"
const targetApps = createApps(
{
@ -1456,20 +1455,6 @@ describe('compose/application-manager', () => {
restart: 'no',
},
}),
await createService({
image: 'image-3',
serviceName: 'three',
composition: {
restart: 'on-failure',
},
}),
await createService({
image: 'image-4',
serviceName: 'four',
composition: {
restart: 'on-failure',
},
}),
],
networks: [DEFAULT_NETWORK],
},
@ -1490,8 +1475,7 @@ describe('compose/application-manager', () => {
{
state: {
status: 'exited',
// Should not generate noop if exited within 1 minute
createdAt: date50SecondsAgo,
createdAt: getTimeNSecondsAgo(new Date(), newer),
},
},
),
@ -1506,40 +1490,7 @@ describe('compose/application-manager', () => {
{
state: {
status: 'exited',
// Should not generate start if exited more than 1 minute ago
createdAt: date61SecondsAgo,
},
},
),
await createService(
{
image: 'image-3',
serviceName: 'three',
composition: {
restart: 'on-failure',
},
},
{
state: {
status: 'exited',
// Should not generate noop if exited within 1 minute
createdAt: date50SecondsAgo,
},
},
),
await createService(
{
image: 'image-4',
serviceName: 'four',
composition: {
restart: 'on-failure',
},
},
{
state: {
status: 'exited',
// Should not generate start if exited more than 1 minute ago
createdAt: date61SecondsAgo,
createdAt: getTimeNSecondsAgo(new Date(), older),
},
},
),
@ -1554,14 +1505,6 @@ describe('compose/application-manager', () => {
name: 'image-2',
serviceName: 'two',
}),
createImage({
name: 'image-3',
serviceName: 'three',
}),
createImage({
name: 'image-4',
serviceName: 'four',
}),
],
});
@ -1578,36 +1521,41 @@ describe('compose/application-manager', () => {
expect(steps).to.have.lengthOf(0);
});
it('should infer a noop step for a service that was created <=1 min ago with status of "exited" if restart policy is "always" or "unless-stopped"', async () => {
// Conditions:
// - restart: "always" || "unless-stopped"
// - status: "exited"
// - createdAt: <= SECONDS_TO_WAIT_FOR_START ago
const targetApps = createApps(
{
services: [
await createService({
image: 'image-1',
serviceName: 'one',
composition: {
restart: 'always',
},
}),
await createService({
image: 'image-2',
serviceName: 'two',
composition: {
restart: 'unless-stopped',
},
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
describe('restart policy "on-failure"', () => {
it('should not infer any steps for a service that exited with code 0', async () => {
// Conditions:
// - restart: "on-failure"
// - status: "exited"
// - exitCode: 0
const targetApps = createApps(
{
services: [
await createService({
image: 'image-1',
serviceName: 'one',
composition: {
restart: 'on-failure',
},
}),
await createService({
image: 'image-2',
serviceName: 'two',
composition: {
restart: 'on-failure',
},
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
const {
currentApps,
availableImages,
downloading,
containerIdsByAppId,
} = createCurrentState({
services: [
await createService(
{
@ -1615,13 +1563,14 @@ describe('compose/application-manager', () => {
serviceName: 'one',
running: false,
composition: {
restart: 'always',
restart: 'on-failure',
},
},
{
state: {
status: 'exited',
createdAt: date50SecondsAgo,
exitCode: 0,
createdAt: getTimeNSecondsAgo(new Date(), newer),
},
},
),
@ -1631,13 +1580,14 @@ describe('compose/application-manager', () => {
serviceName: 'two',
running: false,
composition: {
restart: 'unless-stopped',
restart: 'on-failure',
},
},
{
state: {
status: 'exited',
createdAt: date50SecondsAgo,
exitCode: 0,
createdAt: getTimeNSecondsAgo(new Date(), older),
},
},
),
@ -1655,49 +1605,189 @@ describe('compose/application-manager', () => {
],
});
const [noopStep1, noopStep2, ...nextSteps] =
await applicationManager.inferNextSteps(currentApps, targetApps, {
downloading,
const [...steps] = await applicationManager.inferNextSteps(
currentApps,
targetApps,
{
downloading,
availableImages,
containerIdsByAppId,
},
);
expect(steps).to.have.lengthOf(0);
});
it('should infer a noop step for a service that was created <= SECONDS_TO_WAIT_FOR_START ago and exited non-zero', async () => {
// Conditions:
// - restart: "on-failure"
// - status: "exited"
// - exitCode: non-zero
// - createdAt: <= SECONDS_TO_WAIT_FOR_START
const targetApps = createApps(
{
services: [
await createService({
image: 'image-1',
serviceName: 'one',
composition: {
restart: 'on-failure',
},
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const {
currentApps,
availableImages,
downloading,
containerIdsByAppId,
} = createCurrentState({
services: [
await createService(
{
image: 'image-1',
serviceName: 'one',
running: false,
composition: {
restart: 'on-failure',
},
},
{
state: {
status: 'exited',
exitCode: 1,
createdAt: getTimeNSecondsAgo(new Date(), newer),
},
},
),
],
networks: [DEFAULT_NETWORK],
images: [
createImage({
name: 'image-1',
serviceName: 'one',
}),
],
});
expect(noopStep1).to.have.property('action').that.equals('noop');
expect(noopStep2).to.have.property('action').that.equals('noop');
const [noopStep, ...nextSteps] =
await applicationManager.inferNextSteps(currentApps, targetApps, {
downloading,
availableImages,
containerIdsByAppId,
});
expect(nextSteps).to.have.lengthOf(0);
expect(noopStep).to.have.property('action').that.equals('noop');
expect(nextSteps).to.have.lengthOf(0);
});
it('should infer a start step for a service that was created > SECONDS_TO_WAIT_FOR_START ago and exited non-zero', async () => {
// Conditions:
// - restart: "on-failure"
// - status: "exited"
// - exitCode: non-zero\
// - createdAt: > SECONDS_TO_WAIT_FOR_START
const targetApps = createApps(
{
services: [
await createService({
image: 'image-1',
serviceName: 'one',
composition: {
restart: 'on-failure',
},
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const {
currentApps,
availableImages,
downloading,
containerIdsByAppId,
} = createCurrentState({
services: [
await createService(
{
image: 'image-1',
serviceName: 'one',
running: false,
composition: {
restart: 'on-failure',
},
},
{
state: {
status: 'exited',
exitCode: 1,
createdAt: getTimeNSecondsAgo(new Date(), older),
},
},
),
],
networks: [DEFAULT_NETWORK],
images: [
createImage({
name: 'image-1',
serviceName: 'one',
}),
],
});
const [startStep, ...nextSteps] =
await applicationManager.inferNextSteps(currentApps, targetApps, {
downloading,
availableImages,
containerIdsByAppId,
});
expect(startStep).to.have.property('action').that.equals('start');
expect(nextSteps).to.have.lengthOf(0);
});
});
it('should infer a start step for a service that was created >1 min ago with status of "exited" if restart policy is "always" or "unless-stopped"', async () => {
// Conditions:
// - restart: "always" || "unless-stopped"
// - status: "exited"
// - createdAt: <= SECONDS_TO_WAIT_FOR_START ago
const targetApps = createApps(
{
services: [
await createService({
image: 'image-1',
serviceName: 'one',
composition: {
restart: 'always',
},
}),
await createService({
image: 'image-2',
serviceName: 'two',
composition: {
restart: 'unless-stopped',
},
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
describe('restart policy "always" or "unless-stopped"', () => {
it('should infer a noop step for a service that was created <= SECONDS_TO_WAIT_FOR_START ago with status of "exited"', async () => {
// Conditions:
// - restart: "always" || "unless-stopped"
// - status: "exited"
// - createdAt: <= SECONDS_TO_WAIT_FOR_START ago
const targetApps = createApps(
{
services: [
await createService({
image: 'image-1',
serviceName: 'one',
composition: {
restart: 'always',
},
}),
await createService({
image: 'image-2',
serviceName: 'two',
composition: {
restart: 'unless-stopped',
},
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const { currentApps, availableImages, downloading, containerIdsByAppId } =
createCurrentState({
const {
currentApps,
availableImages,
downloading,
containerIdsByAppId,
} = createCurrentState({
services: [
await createService(
{
@ -1711,7 +1801,7 @@ describe('compose/application-manager', () => {
{
state: {
status: 'exited',
createdAt: date61SecondsAgo,
createdAt: getTimeNSecondsAgo(new Date(), newer),
},
},
),
@ -1727,7 +1817,7 @@ describe('compose/application-manager', () => {
{
state: {
status: 'exited',
createdAt: date61SecondsAgo,
createdAt: getTimeNSecondsAgo(new Date(), newer),
},
},
),
@ -1745,21 +1835,116 @@ describe('compose/application-manager', () => {
],
});
const [startStep1, startStep2, ...nextSteps] =
await applicationManager.inferNextSteps(currentApps, targetApps, {
downloading,
const [noopStep1, noopStep2, ...nextSteps] =
await applicationManager.inferNextSteps(currentApps, targetApps, {
downloading,
availableImages,
containerIdsByAppId,
});
expect(noopStep1).to.have.property('action').that.equals('noop');
expect(noopStep2).to.have.property('action').that.equals('noop');
expect(nextSteps).to.have.lengthOf(0);
});
it('should infer a start step for a service that was created > SECONDS_TO_WAIT_FOR_START ago with status of "exited"', async () => {
// Conditions:
// - restart: "always" || "unless-stopped"
// - status: "exited"
// - createdAt: > SECONDS_TO_WAIT_FOR_START ago
const targetApps = createApps(
{
services: [
await createService({
image: 'image-1',
serviceName: 'one',
composition: {
restart: 'always',
},
}),
await createService({
image: 'image-2',
serviceName: 'two',
composition: {
restart: 'unless-stopped',
},
}),
],
networks: [DEFAULT_NETWORK],
},
true,
);
const {
currentApps,
availableImages,
downloading,
containerIdsByAppId,
} = createCurrentState({
services: [
await createService(
{
image: 'image-1',
serviceName: 'one',
running: false,
composition: {
restart: 'always',
},
},
{
state: {
status: 'exited',
createdAt: getTimeNSecondsAgo(new Date(), older),
},
},
),
await createService(
{
image: 'image-2',
serviceName: 'two',
running: false,
composition: {
restart: 'unless-stopped',
},
},
{
state: {
status: 'exited',
createdAt: getTimeNSecondsAgo(new Date(), older),
},
},
),
],
networks: [DEFAULT_NETWORK],
images: [
createImage({
name: 'image-1',
serviceName: 'one',
}),
createImage({
name: 'image-2',
serviceName: 'two',
}),
],
});
[startStep1, startStep2].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']);
const [startStep1, startStep2, ...nextSteps] =
await applicationManager.inferNextSteps(currentApps, targetApps, {
downloading,
availableImages,
containerIdsByAppId,
});
[startStep1, startStep2].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']);
});
expect(nextSteps).to.have.lengthOf(0);
});
expect(nextSteps).to.have.lengthOf(0);
});
});
});

View File

@ -111,7 +111,7 @@ const defaultNetwork = Network.fromComposeObject('default', 1, 'appuuid', {});
describe('compose/app', () => {
describe('volume state behavior', () => {
it('should correctly infer a volume create step', async () => {
it('should correctly infer a volume create step', () => {
// Setup current and target apps
const current = createApp();
const target = createApp({
@ -120,7 +120,7 @@ describe('compose/app', () => {
});
// Calculate the steps
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
// Check that a createVolume step has been created
const [createVolumeStep] = expectSteps('createVolume', steps);
@ -129,7 +129,7 @@ describe('compose/app', () => {
.that.deep.includes({ name: 'test-volume' });
});
it('should correctly infer more than one volume create step', async () => {
it('should correctly infer more than one volume create step', () => {
const current = createApp();
const target = createApp({
volumes: [
@ -139,7 +139,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
// Check that 2 createVolume steps are found
const createVolumeSteps = expectSteps('createVolume', steps, 2);
@ -160,7 +160,7 @@ describe('compose/app', () => {
});
// We don't remove volumes until the end
it('should not infer a volume remove step when the app is still referenced', async () => {
it('should not infer a volume remove step when the app is still referenced', () => {
const current = createApp({
volumes: [
Volume.fromComposeObject('test-volume', 1, 'deadbeef'),
@ -172,11 +172,11 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectNoStep('removeVolume', steps);
});
it('should correctly infer volume recreation steps', async () => {
it('should correctly infer volume recreation steps', () => {
const current = createApp({
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
@ -190,7 +190,7 @@ describe('compose/app', () => {
});
// First step should create a volume removal step
const stepsForRemoval = await current.nextStepsForAppUpdate(
const stepsForRemoval = current.nextStepsForAppUpdate(
defaultContext,
target,
);
@ -212,7 +212,7 @@ describe('compose/app', () => {
});
// This test is extra since we have already tested that the volume gets created
const stepsForCreation = await intermediate.nextStepsForAppUpdate(
const stepsForCreation = intermediate.nextStepsForAppUpdate(
defaultContext,
target,
);
@ -254,7 +254,7 @@ describe('compose/app', () => {
});
// Calculate steps
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
expect(killStep)
@ -262,12 +262,12 @@ describe('compose/app', () => {
.that.deep.includes({ serviceName: 'test' });
});
it('should correctly infer to remove an app volumes when the app is being removed', async () => {
it('should correctly infer to remove an app volumes when the app is being removed', () => {
const current = createApp({
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
});
const steps = await current.stepsToRemoveApp(defaultContext);
const steps = current.stepsToRemoveApp(defaultContext);
const [removeVolumeStep] = expectSteps('removeVolume', steps);
expect(removeVolumeStep).to.have.property('current').that.deep.includes({
@ -294,7 +294,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectNoStep('kill', steps);
});
@ -333,7 +333,7 @@ describe('compose/app', () => {
});
// Step 1: kill
const steps = await current.nextStepsForAppUpdate(
const steps = current.nextStepsForAppUpdate(
contextWithImages,
intermediateTarget,
);
@ -341,7 +341,7 @@ describe('compose/app', () => {
// Step 2: noop (service is stopping)
service.status = 'Stopping';
const secondStageSteps = await current.nextStepsForAppUpdate(
const secondStageSteps = current.nextStepsForAppUpdate(
contextWithImages,
intermediateTarget,
);
@ -355,7 +355,7 @@ describe('compose/app', () => {
volumes: [volume],
});
expect(
await currentWithServiceRemoved.nextStepsForAppUpdate(
currentWithServiceRemoved.nextStepsForAppUpdate(
contextWithImages,
intermediateTarget,
),
@ -377,7 +377,7 @@ describe('compose/app', () => {
isTarget: true,
});
const recreateVolumeSteps =
await currentWithVolumesRemoved.nextStepsForAppUpdate(
currentWithVolumesRemoved.nextStepsForAppUpdate(
contextWithImages,
target,
);
@ -393,7 +393,7 @@ describe('compose/app', () => {
});
const createServiceSteps =
await currentWithVolumeRecreated.nextStepsForAppUpdate(
currentWithVolumeRecreated.nextStepsForAppUpdate(
contextWithImages,
target,
);
@ -402,14 +402,14 @@ describe('compose/app', () => {
});
describe('network state behavior', () => {
it('should correctly infer a network create step', async () => {
it('should correctly infer a network create step', () => {
const current = createApp({ networks: [] });
const target = createApp({
networks: [Network.fromComposeObject('default', 1, 'deadbeef', {})],
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep).to.have.property('target').that.deep.includes({
@ -417,7 +417,7 @@ describe('compose/app', () => {
});
});
it('should correctly infer a network remove step', async () => {
it('should correctly infer a network remove step', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
@ -425,7 +425,7 @@ describe('compose/app', () => {
});
const target = createApp({ networks: [], isTarget: true });
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('removeNetwork', steps);
@ -434,7 +434,7 @@ describe('compose/app', () => {
});
});
it('should correctly remove default duplicate networks', async () => {
it('should correctly remove default duplicate networks', () => {
const current = createApp({
networks: [defaultNetwork, defaultNetwork],
});
@ -443,7 +443,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('removeNetwork', steps);
@ -452,7 +452,7 @@ describe('compose/app', () => {
});
});
it('should correctly remove duplicate networks', async () => {
it('should correctly remove duplicate networks', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
@ -468,7 +468,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('removeNetwork', steps);
@ -477,7 +477,7 @@ describe('compose/app', () => {
});
});
it('should ignore the duplicates if there are changes already', async () => {
it('should ignore the duplicates if there are changes already', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
@ -494,7 +494,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('removeNetwork', steps);
expect(removeNetworkStep).to.have.property('current').that.deep.includes({
@ -534,7 +534,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('kill', steps);
@ -543,7 +543,7 @@ describe('compose/app', () => {
});
});
it('should correctly infer more than one network removal step', async () => {
it('should correctly infer more than one network removal step', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
@ -553,7 +553,7 @@ describe('compose/app', () => {
});
const target = createApp({ networks: [], isTarget: true });
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [first, second] = expectSteps('removeNetwork', steps, 2);
@ -565,7 +565,7 @@ describe('compose/app', () => {
});
});
it('should correctly infer a network recreation step', async () => {
it('should correctly infer a network recreation step', () => {
const current = createApp({
networks: [
Network.fromComposeObject('test-network', 1, 'deadbeef', {}),
@ -580,7 +580,7 @@ describe('compose/app', () => {
isTarget: true,
});
const stepsForRemoval = await current.nextStepsForAppUpdate(
const stepsForRemoval = current.nextStepsForAppUpdate(
defaultContext,
target,
);
@ -595,7 +595,7 @@ describe('compose/app', () => {
networks: [],
});
const stepsForCreation = await intermediate.nextStepsForAppUpdate(
const stepsForCreation = intermediate.nextStepsForAppUpdate(
defaultContext,
target,
);
@ -641,7 +641,7 @@ describe('compose/app', () => {
const availableImages = [createImage({ appUuid: 'deadbeef' })];
const steps = await current.nextStepsForAppUpdate(
const steps = current.nextStepsForAppUpdate(
{ ...defaultContext, availableImages },
target,
);
@ -674,7 +674,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
expect(killStep)
@ -730,7 +730,7 @@ describe('compose/app', () => {
createImage({ appId: 1, serviceName: 'two', name: 'alpine' }),
];
const steps = await current.nextStepsForAppUpdate(
const steps = current.nextStepsForAppUpdate(
{ ...defaultContext, availableImages },
target,
);
@ -793,7 +793,7 @@ describe('compose/app', () => {
createImage({ appId: 1, serviceName: 'two', name: 'alpine' }),
];
const steps = await current.nextStepsForAppUpdate(
const steps = current.nextStepsForAppUpdate(
{ ...defaultContext, availableImages },
target,
);
@ -808,11 +808,11 @@ describe('compose/app', () => {
expectNoStep('removeNetwork', steps);
});
it('should create the default network if it does not exist', async () => {
it('should create the default network if it does not exist', () => {
const current = createApp({ networks: [] });
const target = createApp({ networks: [], isTarget: true });
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
// A default network should always be created
const [createNetworkStep] = expectSteps('createNetwork', steps);
@ -821,13 +821,13 @@ describe('compose/app', () => {
.that.deep.includes({ name: 'default' });
});
it('should not create the default network if it already exists', async () => {
it('should not create the default network if it already exists', () => {
const current = createApp({
networks: [Network.fromComposeObject('default', 1, 'deadbeef', {})],
});
const target = createApp({ networks: [], isTarget: true });
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
// The network should not be created again
expectNoStep('createNetwork', steps);
@ -854,7 +854,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep)
@ -883,7 +883,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep)
@ -892,11 +892,11 @@ describe('compose/app', () => {
.that.deep.includes({ configOnly: false });
});
it('should create a config-only network if there are no services in the app', async () => {
it('should create a config-only network if there are no services in the app', () => {
const current = createApp({});
const target = createApp({ isTarget: true });
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep)
@ -921,7 +921,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
expect(killStep)
.to.have.property('current')
@ -939,7 +939,7 @@ describe('compose/app', () => {
});
const target = createApp({ services: [], isTarget: true });
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectSteps('noop', steps);
// Kill was already emitted for this service
@ -959,7 +959,7 @@ describe('compose/app', () => {
services: [await createService({ serviceName: 'main', running: true })],
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectSteps('noop', steps);
});
@ -977,7 +977,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeStep] = expectSteps('remove', steps);
expect(removeStep)
@ -996,7 +996,7 @@ describe('compose/app', () => {
});
const target = createApp({ services: [], isTarget: true });
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeStep] = expectSteps('remove', steps);
expect(removeStep)
@ -1013,7 +1013,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(
const steps = current.nextStepsForAppUpdate(
{ ...defaultContext, ...{ downloading: ['main-image'] } },
target,
);
@ -1034,7 +1034,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [updateMetadataStep] = expectSteps('updateMetadata', steps);
expect(updateMetadataStep)
@ -1057,7 +1057,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [stopStep] = expectSteps('stop', steps);
expect(stopStep)
.to.have.property('current')
@ -1086,7 +1086,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectNoStep('start', steps);
});
@ -1118,7 +1118,7 @@ describe('compose/app', () => {
});
// should see a 'stop'
const stepsToIntermediate = await current.nextStepsForAppUpdate(
const stepsToIntermediate = current.nextStepsForAppUpdate(
contextWithImages,
target,
);
@ -1135,7 +1135,7 @@ describe('compose/app', () => {
});
// now should see a 'start'
const stepsToTarget = await intermediate.nextStepsForAppUpdate(
const stepsToTarget = intermediate.nextStepsForAppUpdate(
contextWithImages,
target,
);
@ -1193,7 +1193,7 @@ describe('compose/app', () => {
isTarget: true,
});
const stepsToIntermediate = await current.nextStepsForAppUpdate(
const stepsToIntermediate = current.nextStepsForAppUpdate(
contextWithImages,
target,
);
@ -1216,7 +1216,7 @@ describe('compose/app', () => {
});
// we should now see a start for the 'main' service...
const stepsToTarget = await intermediate.nextStepsForAppUpdate(
const stepsToTarget = intermediate.nextStepsForAppUpdate(
{ ...contextWithImages, ...{ containerIds: { dep: 'dep-id' } } },
target,
);
@ -1248,10 +1248,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(
contextWithImages,
target,
);
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
// There should be no steps since the engine manages restart policy for stopped containers
expect(steps.length).to.equal(0);
@ -1295,7 +1292,7 @@ describe('compose/app', () => {
isTarget: true,
});
const stepsToIntermediate = await current.nextStepsForAppUpdate(
const stepsToIntermediate = current.nextStepsForAppUpdate(
contextWithImages,
target,
);
@ -1311,7 +1308,7 @@ describe('compose/app', () => {
networks: [defaultNetwork],
});
const stepsToTarget = await intermediate.nextStepsForAppUpdate(
const stepsToTarget = intermediate.nextStepsForAppUpdate(
contextWithImages,
target,
);
@ -1382,10 +1379,7 @@ describe('compose/app', () => {
});
// No kill steps should be generated
const steps = await current.nextStepsForAppUpdate(
contextWithImages,
target,
);
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
expectNoStep('kill', steps);
});
@ -1427,7 +1421,7 @@ describe('compose/app', () => {
isTarget: true,
});
const stepsFirstTry = await current.nextStepsForAppUpdate(
const stepsFirstTry = current.nextStepsForAppUpdate(
contextWithImages,
target,
);
@ -1438,7 +1432,7 @@ describe('compose/app', () => {
.that.deep.includes({ serviceName: 'main' });
// if at first you don't succeed
const stepsSecondTry = await current.nextStepsForAppUpdate(
const stepsSecondTry = current.nextStepsForAppUpdate(
contextWithImages,
target,
);
@ -1470,7 +1464,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [killStep] = expectSteps('kill', steps);
expect(killStep)
.to.have.property('current')
@ -1493,7 +1487,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [createNetworkStep] = expectSteps('createNetwork', steps);
expect(createNetworkStep)
.to.have.property('target')
@ -1534,7 +1528,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
expectSteps('kill', steps, 2);
});
@ -1596,10 +1590,7 @@ describe('compose/app', () => {
});
// No kill steps should be generated
const steps = await current.nextStepsForAppUpdate(
contextWithImages,
target,
);
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
expectNoStep('kill', steps);
});
@ -1638,10 +1629,7 @@ describe('compose/app', () => {
});
// No kill steps should be generated
const steps = await current.nextStepsForAppUpdate(
contextWithImages,
target,
);
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
expectNoStep('start', steps);
});
@ -1685,10 +1673,7 @@ describe('compose/app', () => {
});
// No kill steps should be generated
const steps = await current.nextStepsForAppUpdate(
contextWithImages,
target,
);
const steps = current.nextStepsForAppUpdate(contextWithImages, target);
expectSteps('start', steps, 2);
});
});
@ -1701,7 +1686,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [fetchStep] = expectSteps('fetch', steps);
expect(fetchStep)
.to.have.property('image')
@ -1723,7 +1708,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(
const steps = current.nextStepsForAppUpdate(
contextWithDownloading,
target,
);
@ -1739,7 +1724,7 @@ describe('compose/app', () => {
isTarget: true,
});
const steps = await current.nextStepsForAppUpdate(defaultContext, target);
const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [fetchStep] = expectSteps('fetch', steps);
expect(fetchStep)

View File

@ -1,8 +1,8 @@
import { expect } from 'chai';
import * as ComposeUtils from '~/src/compose/utils';
describe('compose/utils', () =>
it('Should correctly camel case the configuration', function () {
describe('compose/utils', () => {
it('should correctly camel case the configuration', () => {
const config = {
networks: ['test', 'test2'],
};
@ -10,4 +10,34 @@ describe('compose/utils', () =>
expect(ComposeUtils.camelCaseConfig(config)).to.deep.equal({
networks: ['test', 'test2'],
});
}));
});
it('should return whether a date is valid and older than an interval of seconds', () => {
const now = new Date(Date.now());
expect(ComposeUtils.isValidDateAndOlderThan(now, 60)).to.equal(false);
const time59SecondsAgo = new Date(Date.now() - 59 * 1000);
expect(ComposeUtils.isValidDateAndOlderThan(time59SecondsAgo, 60)).to.equal(
false,
);
const time61SecondsAgo = new Date(Date.now() - 61 * 1000);
expect(ComposeUtils.isValidDateAndOlderThan(time61SecondsAgo, 60)).to.equal(
true,
);
const notDates = [
null,
undefined,
'',
'test',
123,
0,
-1,
Infinity,
NaN,
{},
];
notDates.forEach((n) => {
expect(ComposeUtils.isValidDateAndOlderThan(n, 0)).to.equal(false);
});
});
});