Merge pull request #2381 from balena-os/reboot-required

Add support for `io.balena.update.requires-reboot` label
This commit is contained in:
flowzone-app[bot] 2025-01-14 18:15:04 +00:00 committed by GitHub
commit bc306c1bc9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 320 additions and 100 deletions

View File

@ -5,7 +5,6 @@ import _ from 'lodash';
import type { PinejsClientRequest } from 'pinejs-client-request';
import * as config from '../config';
import * as deviceConfig from '../device-config';
import * as eventTracker from '../event-tracker';
import { loadBackupFromMigration } from '../lib/migration';
@ -332,10 +331,10 @@ async function reportInitialEnv(
);
}
const defaultConfig = deviceConfig.getDefaults();
const defaultConfig = deviceState.getDefaultConfig();
const currentConfig = await deviceConfig.getCurrent();
const targetConfig = deviceConfig.formatConfigKeys(targetConfigUnformatted);
const currentConfig = await deviceState.getCurrentConfig();
const targetConfig = deviceState.formatConfigKeys(targetConfigUnformatted);
if (!currentConfig) {
throw new InternalInconsistencyError(

View File

@ -654,6 +654,7 @@ class AppImpl implements App {
context.targetApp,
needsDownload,
servicesLocked,
context.rebootBreadcrumbSet,
context.appsToLock,
context.availableImages,
context.networkPairs,
@ -682,6 +683,8 @@ class AppImpl implements App {
context.appsToLock,
context.targetApp.services,
servicesLocked,
context.rebootBreadcrumbSet,
context.bootTime,
);
}
@ -761,6 +764,8 @@ class AppImpl implements App {
appsToLock: AppsToLockMap,
targetServices: Service[],
servicesLocked: boolean,
rebootBreadcrumbSet: boolean,
bootTime: Date,
): CompositionStep[] {
// Update container metadata if service release has changed
if (current.commit !== target.commit) {
@ -774,16 +779,38 @@ class AppImpl implements App {
return [];
}
} else if (target.config.running !== current.config.running) {
// Take lock for all services before starting/stopping container
if (!servicesLocked) {
this.services.concat(targetServices).forEach((s) => {
appsToLock[target.appId].add(s.serviceName);
});
return [];
}
if (target.config.running) {
// if the container has a reboot
// required label and the boot time is before the creation time, then
// return a 'noop' to ensure a reboot happens before starting the container
const requiresReboot =
checkTruthy(
target.config.labels?.['io.balena.update.requires-reboot'],
) &&
current.createdAt != null &&
current.createdAt > bootTime;
if (requiresReboot && rebootBreadcrumbSet) {
// Do not return a noop to allow locks to be released by the
// app module
return [];
} else if (requiresReboot) {
return [
generateStep('requireReboot', {
serviceName: target.serviceName,
}),
];
}
return [generateStep('start', { target })];
} else {
// Take lock for all services before stopping container
if (!servicesLocked) {
this.services.concat(targetServices).forEach((s) => {
appsToLock[target.appId].add(s.serviceName);
});
return [];
}
return [generateStep('stop', { current })];
}
} else {
@ -796,6 +823,7 @@ class AppImpl implements App {
targetApp: App,
needsDownload: boolean,
servicesLocked: boolean,
rebootBreadcrumbSet: boolean,
appsToLock: AppsToLockMap,
availableImages: UpdateState['availableImages'],
networkPairs: Array<ChangingPair<Network>>,
@ -832,8 +860,10 @@ class AppImpl implements App {
}
return [generateStep('start', { target })];
} else {
// Wait for dependencies to be started
return [generateStep('noop', {})];
// Wait for dependencies to be started unless there is a
// reboot breadcrumb set, in which case we need to allow the state
// to settle for the reboot to happen
return rebootBreadcrumbSet ? [] : [generateStep('noop', {})];
}
} else {
return [];
@ -897,11 +927,11 @@ class AppImpl implements App {
return false;
}
const depedencyUnmet = _.some(target.dependsOn, (dep) =>
const dependencyUnmet = _.some(target.dependsOn, (dep) =>
_.some(servicePairs, (pair) => pair.target?.serviceName === dep),
);
if (depedencyUnmet) {
if (dependencyUnmet) {
return false;
}

View File

@ -40,6 +40,8 @@ import type {
Image,
InstancedAppState,
} from './types';
import { isRebootBreadcrumbSet } from '../lib/reboot';
import { getBootTime } from '../lib/fs-utils';
type ApplicationManagerEventEmitter = StrictEventEmitter<
EventEmitter,
@ -127,6 +129,7 @@ export async function getRequiredSteps(
config.getMany(['localMode', 'delta']),
]);
const containerIdsByAppId = getAppContainerIds(currentApps);
const rebootBreadcrumbSet = await isRebootBreadcrumbSet();
// Local mode sets the image and volume retention only
// if not explicitely set by the caller
@ -149,6 +152,7 @@ export async function getRequiredSteps(
availableImages,
containerIdsByAppId,
appLocks: lockRegistry,
rebootBreadcrumbSet,
});
}
@ -161,6 +165,7 @@ interface InferNextOpts {
availableImages: UpdateState['availableImages'];
containerIdsByAppId: { [appId: number]: UpdateState['containerIds'] };
appLocks: LockRegistry;
rebootBreadcrumbSet: boolean;
}
// Calculate the required steps from the current to the target state
@ -176,6 +181,7 @@ export async function inferNextSteps(
availableImages = [],
containerIdsByAppId = {},
appLocks = {},
rebootBreadcrumbSet = false,
}: Partial<InferNextOpts>,
) {
const currentAppIds = Object.keys(currentApps).map((i) => parseInt(i, 10));
@ -184,6 +190,7 @@ export async function inferNextSteps(
const withLeftoverLocks = await Promise.all(
currentAppIds.map((id) => hasLeftoverLocks(id)),
);
const bootTime = getBootTime();
let steps: CompositionStep[] = [];
@ -245,6 +252,8 @@ export async function inferNextSteps(
force,
lock: appLocks[id],
hasLeftoverLocks: withLeftoverLocks[id],
rebootBreadcrumbSet,
bootTime,
},
targetApps[id],
),
@ -261,6 +270,8 @@ export async function inferNextSteps(
force,
lock: appLocks[id],
hasLeftoverLocks: withLeftoverLocks[id],
rebootBreadcrumbSet,
bootTime,
}),
);
}
@ -287,6 +298,8 @@ export async function inferNextSteps(
force,
lock: appLocks[id],
hasLeftoverLocks: false,
rebootBreadcrumbSet,
bootTime,
},
targetApps[id],
),

View File

@ -6,6 +6,7 @@ import * as networkManager from './network-manager';
import * as volumeManager from './volume-manager';
import * as commitStore from './commit';
import { Lockable, cleanLocksForApp } from '../lib/update-lock';
import { setRebootBreadcrumb } from '../lib/reboot';
import type { DeviceLegacyReport } from '../types/state';
import type { CompositionStepAction, CompositionStepT } from './types';
import type { Lock } from '../lib/update-lock';
@ -157,6 +158,9 @@ export function getExecutors(app: { callbacks: CompositionCallbacks }) {
// Clean up any remaining locks
await cleanLocksForApp(step.appId);
},
requireReboot: async (step) => {
await setRebootBreadcrumb({ serviceName: step.serviceName });
},
};
return executors;

View File

@ -19,7 +19,7 @@ import {
isStatusError,
} from '../lib/errors';
import * as LogTypes from '../lib/log-types';
import { checkInt, isValidDeviceName } from '../lib/validation';
import { checkInt, isValidDeviceName, checkTruthy } from '../lib/validation';
import { Service } from './service';
import type { ServiceStatus } from './types';
import { serviceNetworksToDockerNetworks } from './utils';
@ -27,6 +27,7 @@ import { serviceNetworksToDockerNetworks } from './utils';
import log from '../lib/supervisor-console';
import logMonitor from '../logging/monitor';
import { setTimeout } from 'timers/promises';
import { getBootTime } from '../lib/fs-utils';
interface ServiceManagerEvents {
change: void;
@ -233,7 +234,7 @@ export async function remove(service: Service) {
}
}
async function create(service: Service) {
async function create(service: Service): Promise<Service> {
const mockContainerId = config.newUniqueKey();
try {
const existing = await get(service);
@ -242,7 +243,7 @@ async function create(service: Service) {
`No containerId provided for service ${service.serviceName} in ServiceManager.updateMetadata. Service: ${service}`,
);
}
return docker.getContainer(existing.containerId);
return existing;
} catch (e: unknown) {
if (!isNotFoundError(e)) {
logger.logSystemEvent(LogTypes.installServiceError, {
@ -287,7 +288,9 @@ async function create(service: Service) {
reportNewStatus(mockContainerId, service, 'Installing');
const container = await docker.createContainer(conf);
service.containerId = container.id;
const inspectInfo = await container.inspect();
service = Service.fromDockerContainer(inspectInfo);
await Promise.all(
_.map((nets || {}).EndpointsConfig, (endpointConfig, name) =>
@ -299,7 +302,7 @@ async function create(service: Service) {
);
logger.logSystemEvent(LogTypes.installServiceSuccess, { service });
return container;
return service;
} finally {
reportChange(mockContainerId);
}
@ -310,13 +313,25 @@ export async function start(service: Service) {
let containerId: string | null = null;
try {
const container = await create(service);
const svc = await create(service);
const container = docker.getContainer(svc.containerId!);
const requiresReboot =
checkTruthy(
service.config.labels?.['io.balena.update.requires-reboot'],
) &&
svc.createdAt != null &&
svc.createdAt > getBootTime();
if (requiresReboot) {
log.warn(`Skipping start of service ${svc.serviceName} until reboot`);
}
// Exit here if the target state of the service
// is set to running: false
// is set to running: false or we are waiting for a reboot
// QUESTION: should we split the service steps into
// 'install' and 'start' instead of doing this?
if (service.config.running === false) {
if (service.config.running === false || requiresReboot) {
return container;
}

View File

@ -128,7 +128,6 @@ class ServiceImpl implements Service {
service.releaseId = parseInt(appConfig.releaseId, 10);
service.serviceId = parseInt(appConfig.serviceId, 10);
service.imageName = appConfig.image;
service.createdAt = appConfig.createdAt;
service.commit = appConfig.commit;
service.appUuid = appConfig.appUuid;

View File

@ -12,6 +12,8 @@ export interface UpdateState {
hasLeftoverLocks: boolean;
lock: Lock | null;
force: boolean;
rebootBreadcrumbSet: boolean;
bootTime: Date;
}
export interface App {

View File

@ -76,6 +76,7 @@ export interface CompositionStepArgs {
appId: string | number;
lock: Lock | null;
};
requireReboot: { serviceName: string };
}
export type CompositionStepAction = keyof CompositionStepArgs;

View File

@ -11,7 +11,6 @@ import { Volume } from '../compose/volume';
import * as commitStore from '../compose/commit';
import * as config from '../config';
import * as db from '../db';
import * as deviceConfig from '../device-config';
import * as logger from '../logging';
import * as images from '../compose/images';
import * as volumeManager from '../compose/volume-manager';
@ -512,7 +511,7 @@ router.get('/v2/device/tags', async (_req, res) => {
});
router.get('/v2/device/vpn', async (_req, res) => {
const conf = await deviceConfig.getCurrent();
const conf = await deviceState.getCurrentConfig();
// Build VPNInfo
const info = {
enabled: conf.SUPERVISOR_VPN_CONTROL === 'true',

View File

@ -1,34 +1,24 @@
import _ from 'lodash';
import { inspect } from 'util';
import { promises as fs } from 'fs';
import * as config from './config';
import * as db from './db';
import * as logger from './logging';
import * as dbus from './lib/dbus';
import type { EnvVarObject } from './types';
import { UnitNotLoadedError } from './lib/errors';
import { checkInt, checkTruthy } from './lib/validation';
import log from './lib/supervisor-console';
import * as configUtils from './config/utils';
import type { SchemaTypeKey } from './config/schema-type';
import { matchesAnyBootConfig } from './config/backends';
import type { ConfigBackend } from './config/backends/backend';
import { Odmdata } from './config/backends/odmdata';
import * as fsUtils from './lib/fs-utils';
import { pathOnRoot } from './lib/host-utils';
import * as config from '../config';
import * as db from '../db';
import * as logger from '../logging';
import * as dbus from '../lib/dbus';
import type { EnvVarObject } from '../types';
import { UnitNotLoadedError } from '../lib/errors';
import { checkInt, checkTruthy } from '../lib/validation';
import log from '../lib/supervisor-console';
import { setRebootBreadcrumb } from '../lib/reboot';
import * as configUtils from '../config/utils';
import type { SchemaTypeKey } from '../config/schema-type';
import { matchesAnyBootConfig } from '../config/backends';
import type { ConfigBackend } from '../config/backends/backend';
import { Odmdata } from '../config/backends/odmdata';
const vpnServiceName = 'openvpn';
// This indicates the file on the host /tmp directory that
// marks the need for a reboot. Since reboot is only triggered for now
// by some config changes, we leave this here for now. There is planned
// functionality to allow image installs to require reboots, at that moment
// this constant can be moved somewhere else
const REBOOT_BREADCRUMB = pathOnRoot(
'/tmp/balena-supervisor/reboot-after-apply',
);
interface ConfigOption {
envVarName: string;
varType: string;
@ -39,10 +29,7 @@ interface ConfigOption {
// FIXME: Bring this and the deviceState and
// applicationState steps together
export interface ConfigStep {
// TODO: This is a bit of a mess, the DeviceConfig class shouldn't
// know that the reboot action exists as it is implemented by
// DeviceState. Fix this weird circular dependency
action: keyof DeviceActionExecutors | 'reboot' | 'noop';
action: keyof DeviceActionExecutors | 'noop';
humanReadableTarget?: Dictionary<string>;
target?: string | Dictionary<string>;
}
@ -117,10 +104,12 @@ const actionExecutors: DeviceActionExecutors = {
await setBootConfig(backend, step.target as Dictionary<string>);
}
},
setRebootBreadcrumb: async () => {
// Just create the file. The last step in the target state calculation will check
// the file and create a reboot step
await fsUtils.touch(REBOOT_BREADCRUMB);
setRebootBreadcrumb: async (step) => {
const changes =
step != null && step.target != null && typeof step.target === 'object'
? step.target
: {};
return setRebootBreadcrumb(changes);
},
};
@ -210,7 +199,7 @@ const configKeys: Dictionary<ConfigOption> = {
},
};
export const validKeys = [
const validKeys = [
'SUPERVISOR_VPN_CONTROL',
'OVERRIDE_LOCK',
..._.map(configKeys, 'envVarName'),
@ -413,6 +402,7 @@ function getConfigSteps(
target: Dictionary<string>,
) {
const configChanges: Dictionary<string> = {};
const rebootingChanges: Dictionary<string> = {};
const humanReadableConfigChanges: Dictionary<string> = {};
let reboot = false;
const steps: ConfigStep[] = [];
@ -448,6 +438,9 @@ function getConfigSteps(
}
if (changingValue != null) {
configChanges[key] = changingValue;
if ($rebootRequired) {
rebootingChanges[key] = changingValue;
}
humanReadableConfigChanges[envVarName] = changingValue;
reboot = $rebootRequired || reboot;
}
@ -457,7 +450,7 @@ function getConfigSteps(
if (!_.isEmpty(configChanges)) {
if (reboot) {
steps.push({ action: 'setRebootBreadcrumb' });
steps.push({ action: 'setRebootBreadcrumb', target: rebootingChanges });
}
steps.push({
@ -544,24 +537,16 @@ async function getBackendSteps(
return [
// All backend steps require a reboot except fan control
...(steps.length > 0 && rebootRequired
? [{ action: 'setRebootBreadcrumb' } as ConfigStep]
? [
{
action: 'setRebootBreadcrumb',
} as ConfigStep,
]
: []),
...steps,
];
}
async function isRebootRequired() {
const hasBreadcrumb = await fsUtils.exists(REBOOT_BREADCRUMB);
if (hasBreadcrumb) {
const stats = await fs.stat(REBOOT_BREADCRUMB);
// If the breadcrumb exists and the last modified time is greater than the
// boot time, that means we need to reboot
return stats.mtime.getTime() > fsUtils.getBootTime().getTime();
}
return false;
}
export async function getRequiredSteps(
currentState: { local?: { config?: EnvVarObject } },
targetState: { local?: { config: EnvVarObject } },
@ -584,19 +569,6 @@ export async function getRequiredSteps(
: await getBackendSteps(current, target)),
];
// Check if there is either no steps, or they are all
// noops, and we need to reboot. We want to do this
// because in a preloaded setting with no internet
// connection, the device will try to start containers
// before any boot config has been applied, which can
// cause problems
const rebootRequired = await isRebootRequired();
if (_.every(steps, { action: 'noop' }) && rebootRequired) {
steps.push({
action: 'reboot',
});
}
return steps;
}
@ -642,7 +614,7 @@ export function executeStepAction(
step: ConfigStep,
opts: DeviceActionExecutorOpts,
) {
if (step.action !== 'reboot' && step.action !== 'noop') {
if (step.action !== 'noop') {
return actionExecutors[step.action](step, opts);
}
}

View File

@ -9,7 +9,7 @@ import * as config from '../config';
import * as logger from '../logging';
import * as network from '../network';
import * as deviceConfig from '../device-config';
import * as deviceConfig from './device-config';
import * as constants from '../lib/constants';
import * as dbus from '../lib/dbus';
@ -19,6 +19,7 @@ import * as updateLock from '../lib/update-lock';
import { getGlobalApiKey } from '../lib/api-keys';
import * as sysInfo from '../lib/system-info';
import { log } from '../lib/supervisor-console';
import { isRebootRequired } from '../lib/reboot';
import { loadTargetFromFile } from './preload';
import * as applicationManager from '../compose/application-manager';
import * as commitStore from '../compose/commit';
@ -26,6 +27,12 @@ import type { InstancedDeviceState } from './target-state';
import * as TargetState from './target-state';
export { getTarget, setTarget } from './target-state';
export {
formatConfigKeys,
getCurrent as getCurrentConfig,
getDefaults as getDefaultConfig,
} from './device-config';
import type { DeviceLegacyState, DeviceState, DeviceReport } from '../types';
import type {
CompositionStepT,
@ -512,7 +519,7 @@ export async function executeStepAction(
}
}
export async function applyStep(
async function applyStep(
step: DeviceStateStep<PossibleStepTargets>,
{
force,
@ -609,11 +616,12 @@ export const applyTarget = async ({
({ action }) => action === 'noop',
);
let backoff: boolean;
const rebootRequired = await isRebootRequired();
let backoff = false;
let steps: Array<DeviceStateStep<PossibleStepTargets>>;
if (!noConfigSteps) {
backoff = false;
steps = deviceConfigSteps;
} else {
const appSteps = await applicationManager.getRequiredSteps(
@ -640,6 +648,21 @@ export const applyTarget = async ({
}
}
// Check if there is either no steps, or they are all
// noops, and we need to reboot. We want to do this
// because in a preloaded setting with no internet
// connection, the device will try to start containers
// before any boot config has been applied, which can
// cause problems
// For application manager, the reboot breadcrumb should
// be set after all downloads are ready and target containers
// have been installed
if (steps.every(({ action }) => action === 'noop') && rebootRequired) {
steps.push({
action: 'reboot',
});
}
if (_.isEmpty(steps)) {
emitAsync('apply-target-state-end', null);
if (!intermediate) {

View File

@ -6,9 +6,9 @@ import { imageFromService } from '../compose/images';
import { NumericIdentifier } from '../types';
import { setTarget } from './target-state';
import * as config from '../config';
import * as deviceConfig from '../device-config';
import * as eventTracker from '../event-tracker';
import * as imageManager from '../compose/images';
import * as deviceState from '../device-state';
import {
AppsJsonParseError,
@ -126,8 +126,8 @@ export async function loadTargetFromFile(appsPath: string): Promise<boolean> {
await imageManager.save(image);
}
const deviceConf = await deviceConfig.getCurrent();
const formattedConf = deviceConfig.formatConfigKeys(preloadState.config);
const deviceConf = await deviceState.getCurrentConfig();
const formattedConf = deviceState.formatConfigKeys(preloadState.config);
const localState = {
[uuid]: {
name: '',

View File

@ -6,7 +6,7 @@ import * as config from '../config';
import * as db from '../db';
import * as globalEventBus from '../event-bus';
import * as deviceConfig from '../device-config';
import * as deviceConfig from './device-config';
import { TargetStateError } from '../lib/errors';
import { takeGlobalLockRO, takeGlobalLockRW } from '../lib/process-lock';

View File

@ -87,5 +87,4 @@ export const touch = (file: string, time = new Date()) =>
);
// Get the system boot time as a Date object
export const getBootTime = () =>
new Date(new Date().getTime() - uptime() * 1000);
export const getBootTime = () => new Date(Date.now() - uptime() * 1000);

40
src/lib/reboot.ts Normal file
View File

@ -0,0 +1,40 @@
import { pathOnRoot } from '../lib/host-utils';
import * as fsUtils from '../lib/fs-utils';
import { promises as fs } from 'fs';
import * as logger from '../logging';
// This indicates the file on the host /tmp directory that
// marks the need for a reboot. Since reboot is only triggered for now
// by some config changes, we leave this here for now. There is planned
// functionality to allow image installs to require reboots, at that moment
// this constant can be moved somewhere else
const REBOOT_BREADCRUMB = pathOnRoot(
'/tmp/balena-supervisor/reboot-after-apply',
);
export async function setRebootBreadcrumb(source: Dictionary<any> = {}) {
// Just create the file. The last step in the target state calculation will check
// the file and create a reboot step
await fsUtils.touch(REBOOT_BREADCRUMB);
logger.logSystemMessage(
`Reboot has been scheduled to apply changes: ${JSON.stringify(source)}`,
{},
'Reboot scheduled',
);
}
export async function isRebootBreadcrumbSet() {
return await fsUtils.exists(REBOOT_BREADCRUMB);
}
export async function isRebootRequired() {
const hasBreadcrumb = await fsUtils.exists(REBOOT_BREADCRUMB);
if (hasBreadcrumb) {
const stats = await fs.stat(REBOOT_BREADCRUMB);
// If the breadcrumb exists and the last modified time is greater than the
// boot time, that means we need to reboot
return stats.mtime.getTime() > fsUtils.getBootTime().getTime();
}
return false;
}

View File

@ -5,7 +5,7 @@ import type { SinonStub, SinonSpy } from 'sinon';
import { stub, spy } from 'sinon';
import { expect } from 'chai';
import * as deviceConfig from '~/src/device-config';
import * as deviceConfig from '~/src/device-state/device-config';
import * as fsUtils from '~/lib/fs-utils';
import * as logger from '~/src/logging';
import { Extlinux } from '~/src/config/backends/extlinux';

View File

@ -8,7 +8,7 @@ import { expect } from 'chai';
import * as TargetState from '~/src/api-binder/poll';
import Log from '~/lib/supervisor-console';
import * as request from '~/lib/request';
import * as deviceConfig from '~/src/device-config';
import * as deviceConfig from '~/src/device-state/device-config';
import { UpdatesLockedError } from '~/lib/errors';
import { setTimeout } from 'timers/promises';

View File

@ -21,6 +21,8 @@ const defaultContext = {
downloading: [] as string[],
lock: null,
hasLeftoverLocks: false,
rebootBreadcrumbSet: false,
bootTime: new Date(Date.now() - 30 * 60 * 1000), // 30 minutes ago
};
const mockLock: Lock = {
@ -2111,6 +2113,128 @@ describe('compose/app', () => {
);
expectSteps('start', steps3, 2);
});
it('should set the reboot breadcrumb after a service with `requires-reboot` has been installed', async () => {
// Container is a "run once" type of service so it has exitted.
const current = createApp({
services: [
await createService(
{
labels: { 'io.balena.update.requires-reboot': 'true' },
running: false,
},
{ state: { createdAt: new Date(), status: 'Installed' } },
),
],
networks: [DEFAULT_NETWORK],
});
// Now test that another start step is not added on this service
const target = createApp({
services: [
await createService({
labels: { 'io.balena.update.requires-reboot': 'true' },
running: true,
}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
rebootBreadcrumbSet: false,
// 30 minutes ago
bootTime: new Date(Date.now() - 30 * 60 * 1000),
},
target,
);
expect(steps.length).to.equal(1);
expectSteps('requireReboot', steps);
});
it('should not try to start a container with `requires-reboot` if the reboot has not taken place yet', async () => {
// Container is a "run once" type of service so it has exitted.
const current = createApp({
services: [
await createService(
{
labels: { 'io.balena.update.requires-reboot': 'true' },
running: false,
},
{ state: { createdAt: new Date(), status: 'Installed' } },
),
],
networks: [DEFAULT_NETWORK],
});
// Now test that another start step is not added on this service
const target = createApp({
services: [
await createService({
labels: { 'io.balena.update.requires-reboot': 'true' },
running: true,
}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
rebootBreadcrumbSet: true,
bootTime: new Date(Date.now() - 30 * 60 * 1000),
},
target,
);
expect(steps.length).to.equal(0);
expectNoStep('start', steps);
});
it('should start a container with `requires-reboot` after reboot has taken place', async () => {
// Container is a "run once" type of service so it has exitted.
const current = createApp({
services: [
await createService(
{
labels: { 'io.balena.update.requires-reboot': 'true' },
running: false,
},
// Container was created 5 minutes ago
{
state: {
createdAt: new Date(Date.now() - 5 * 60 * 1000),
status: 'Installed',
},
},
),
],
networks: [DEFAULT_NETWORK],
});
// Now test that another start step is not added on this service
const target = createApp({
services: [
await createService({
labels: { 'io.balena.update.requires-reboot': 'true' },
running: true,
}),
],
isTarget: true,
});
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
rebootBreadcrumbSet: true,
// Reboot just happened
bootTime: new Date(),
},
target,
);
expect(steps.length).to.equal(1);
expectSteps('start', steps);
});
});
describe('image state behavior', () => {