Update types and create methods for reporting v3 state

This commit is contained in:
Felipe Lalanne 2021-09-01 22:13:27 +00:00
parent e9af9d8e83
commit 97f3b2a51e
9 changed files with 507 additions and 43 deletions

View File

@ -215,7 +215,7 @@ export async function patchDevice(
} }
if (!conf.provisioned) { if (!conf.provisioned) {
throw new Error('DEvice must be provisioned to update a device'); throw new Error('Device must be provisioned to update a device');
} }
if (balenaApi == null) { if (balenaApi == null) {
@ -308,11 +308,7 @@ export async function fetchDeviceTags(): Promise<DeviceTag[]> {
)}`, )}`,
); );
} }
return { return { id: id.right, name: name.right, value: value.right };
id: id.right,
name: name.right,
value: value.right,
};
}); });
} }
@ -408,17 +404,14 @@ async function reportInitialEnv(
const defaultConfig = deviceConfig.getDefaults(); const defaultConfig = deviceConfig.getDefaults();
const currentState = await deviceState.getCurrentState(); const currentConfig = await deviceConfig.getCurrent();
const targetConfig = await deviceConfig.formatConfigKeys( const targetConfig = deviceConfig.formatConfigKeys(targetConfigUnformatted);
targetConfigUnformatted,
);
if (!currentState.local.config) { if (!currentConfig) {
throw new InternalInconsistencyError( throw new InternalInconsistencyError(
'No config defined in reportInitialEnv', 'No config defined in reportInitialEnv',
); );
} }
const currentConfig: Dictionary<string> = currentState.local.config;
for (const [key, value] of _.toPairs(currentConfig)) { for (const [key, value] of _.toPairs(currentConfig)) {
let varValue = value; let varValue = value;
// We want to disable local mode when joining a cloud // We want to disable local mode when joining a cloud

View File

@ -36,7 +36,9 @@ import { CompositionStep, generateStep } from './composition-steps';
import { import {
InstancedAppState, InstancedAppState,
TargetApps, TargetApps,
DeviceReportFields, DeviceLegacyReport,
AppState,
ServiceState,
} from '../types/state'; } from '../types/state';
import { checkTruthy } from '../lib/validation'; import { checkTruthy } from '../lib/validation';
import { Proxyvisor } from '../proxyvisor'; import { Proxyvisor } from '../proxyvisor';
@ -44,7 +46,7 @@ import { EventEmitter } from 'events';
type ApplicationManagerEventEmitter = StrictEventEmitter< type ApplicationManagerEventEmitter = StrictEventEmitter<
EventEmitter, EventEmitter,
{ change: DeviceReportFields } { change: DeviceLegacyReport }
>; >;
const events: ApplicationManagerEventEmitter = new EventEmitter(); const events: ApplicationManagerEventEmitter = new EventEmitter();
export const on: typeof events['on'] = events.on.bind(events); export const on: typeof events['on'] = events.on.bind(events);
@ -997,3 +999,140 @@ export async function getLegacyState() {
return { local: apps, dependent }; return { local: apps, dependent };
} }
// TODO: this function is probably more inefficient than it needs to be, since
// it tried to optimize for readability, look for a way to make it simpler
export async function getState() {
const [services, images] = await Promise.all([
serviceManager.getState(),
imageManager.getState(),
]);
type ServiceInfo = {
appId: number;
appUuid: string;
commit: string;
serviceName: string;
createdAt?: Date;
} & ServiceState;
// Get service data from images
const stateFromImages: ServiceInfo[] = images.map(
({
appId,
appUuid,
name,
commit,
serviceName,
status,
downloadProgress,
}) => ({
appId,
appUuid,
image: name,
commit,
serviceName,
status: status as string,
...(downloadProgress && { download_progress: downloadProgress }),
}),
);
// Get all services and augment service data from the image if any
const stateFromServices = services
.map(({ appId, appUuid, commit, serviceName, status, createdAt }) => [
// Only include appUuid if is available, if not available we'll get it from the image
{
appId,
...(appUuid && { appUuid }),
commit,
serviceName,
status,
createdAt,
},
// Get the corresponding image to augment the service data
stateFromImages.find(
(img) => img.serviceName === serviceName && img.commit === commit,
),
])
// We cannot report services that do not have an image as the API
// requires passing the image name
.filter(([, img]) => !!img)
.map(([svc, img]) => ({ ...img, ...svc } as ServiceInfo))
.map((svc, __, serviceList) => {
// If the service is not running it cannot be a handover
if (svc.status !== 'Running') {
return svc;
}
// If there one or more running services with the same name and appUuid, but different
// release, then we are still handing over so we need to report the appropriate
// status
const siblings = serviceList.filter(
(s) =>
s.appUuid === svc.appUuid &&
s.serviceName === svc.serviceName &&
s.status === 'Running' &&
s.commit !== svc.commit,
);
// There should really be only one element on the `siblings` array, but
// we chose the oldest service to have its status reported as 'Handing over'
if (
siblings.length > 0 &&
siblings.every((s) => svc.createdAt!.getTime() < s.createdAt!.getTime())
) {
return { ...svc, status: 'Handing over' };
} else if (siblings.length > 0) {
return { ...svc, status: 'Awaiting handover' };
}
return svc;
});
const servicesToReport =
// The full list of services is the union of images that have no container created yet
stateFromImages
.filter(
(img) =>
!stateFromServices.some(
(svc) =>
img.serviceName === svc.serviceName && img.commit === svc.commit,
),
)
// With the services that have a container
.concat(stateFromServices);
// Get the list of commits for all appIds from the database
const commitsForApp = (
await Promise.all(
// Deduplicate appIds first
[...new Set(servicesToReport.map((svc) => svc.appId))].map(
async (appId) => ({
[appId]: await commitStore.getCommitForApp(appId),
}),
),
)
).reduce((commits, c) => ({ ...commits, ...c }), {});
// Assemble the state of apps
return servicesToReport.reduce(
(apps, { appId, appUuid, commit, serviceName, createdAt, ...svc }) => ({
...apps,
[appUuid]: {
...(apps[appUuid] ?? {}),
// Add the release_uuid if the commit has been stored in the database
...(commitsForApp[appId] && { release_uuid: commitsForApp[appId] }),
releases: {
...(apps[appUuid]?.releases ?? {}),
[commit]: {
...(apps[appUuid]?.releases[commit] ?? {}),
services: {
...(apps[appUuid]?.releases[commit]?.services ?? {}),
[serviceName]: svc,
},
},
},
},
}),
{} as { [appUuid: string]: AppState },
);
}

View File

@ -13,7 +13,7 @@ import Volume from './volume';
import { checkTruthy } from '../lib/validation'; import { checkTruthy } from '../lib/validation';
import * as networkManager from './network-manager'; import * as networkManager from './network-manager';
import * as volumeManager from './volume-manager'; import * as volumeManager from './volume-manager';
import { DeviceReportFields } from '../types/state'; import { DeviceLegacyReport } from '../types/state';
import * as commitStore from './commit'; import * as commitStore from './commit';
interface BaseCompositionStepArgs { interface BaseCompositionStepArgs {
@ -135,7 +135,7 @@ interface CompositionCallbacks {
fetchStart: () => void; fetchStart: () => void;
fetchEnd: () => void; fetchEnd: () => void;
fetchTime: (time: number) => void; fetchTime: (time: number) => void;
stateReport: (state: DeviceReportFields) => void; stateReport: (state: DeviceLegacyReport) => void;
bestDeltaSource: (image: Image, available: Image[]) => string | null; bestDeltaSource: (image: Image, available: Image[]) => string | null;
} }

View File

@ -435,18 +435,18 @@ export const getState = async () => {
const images = (await getAvailable()).map((img) => ({ const images = (await getAvailable()).map((img) => ({
...img, ...img,
status: 'Downloaded' as Image['status'], status: 'Downloaded' as Image['status'],
downloadImageSuccess: null, downloadProgress: null,
})); }));
const imagesFromRunningTasks = Object.values(runningTasks).map( const imagesFromRunningTasks = Object.values(runningTasks).map(
(task) => task.context, (task) => task.context,
); );
const runningImageIds = imagesFromRunningTasks.map((img) => img.imageId); const runningImageNames = imagesFromRunningTasks.map((img) => img.name);
// TODO: this is possibly wrong, the value from getAvailable should be more reliable // TODO: this is possibly wrong, the value from getAvailable should be more reliable
// than the value from running tasks // than the value from running tasks
return imagesFromRunningTasks.concat( return imagesFromRunningTasks.concat(
images.filter((img) => !runningImageIds.includes(img.imageId)), images.filter((img) => !runningImageNames.includes(img.name)),
); );
}; };

View File

@ -119,6 +119,7 @@ export async function getState() {
if (status[service.containerId] == null) { if (status[service.containerId] == null) {
status[service.containerId] = _.pick(service, [ status[service.containerId] = _.pick(service, [
'appId', 'appId',
'appUuid',
'imageId', 'imageId',
'status', 'status',
'releaseId', 'releaseId',
@ -508,7 +509,15 @@ function reportNewStatus(
containerId, containerId,
_.merge( _.merge(
{ status }, { status },
_.pick(service, ['imageId', 'appId', 'releaseId', 'commit']), _.pick(service, [
'imageId',
'appId',
'appUuid',
'serviceName',
'releaseId',
'createdAt',
'commit',
]),
), ),
); );
} }

View File

@ -37,11 +37,11 @@ import * as deviceConfig from './device-config';
import { ConfigStep } from './device-config'; import { ConfigStep } from './device-config';
import { log } from './lib/supervisor-console'; import { log } from './lib/supervisor-console';
import { import {
DeviceReportFields, DeviceLegacyReport,
DeviceStatus, DeviceLegacyState,
InstancedDeviceState, InstancedDeviceState,
TargetState, TargetState,
InstancedAppState, DeviceState,
} from './types'; } from './types';
import * as dbFormat from './device-state/db-format'; import * as dbFormat from './device-state/db-format';
import * as apiKeys from './lib/api-keys'; import * as apiKeys from './lib/api-keys';
@ -166,7 +166,7 @@ function createDeviceStateRouter() {
router.get('/v1/device', async (_req, res) => { router.get('/v1/device', async (_req, res) => {
try { try {
const state = await getCurrentForReport(); const state = await getLegacyState();
const stateToSend = _.pick(state.local, [ const stateToSend = _.pick(state.local, [
'api_port', 'api_port',
'ip_address', 'ip_address',
@ -248,7 +248,7 @@ type DeviceStateStep<T extends PossibleStepTargets> =
| CompositionStepT<T extends CompositionStepAction ? T : never> | CompositionStepT<T extends CompositionStepAction ? T : never>
| ConfigStep; | ConfigStep;
let currentVolatile: DeviceReportFields = {}; let currentVolatile: DeviceLegacyReport = {};
const writeLock = updateLock.writeLock; const writeLock = updateLock.writeLock;
const readLock = updateLock.readLock; const readLock = updateLock.readLock;
let maxPollTime: number; let maxPollTime: number;
@ -534,9 +534,10 @@ export function getTarget({
// the same format as the target state. This method, // the same format as the target state. This method,
// getCurrent and getCurrentForComparison should probably get // getCurrent and getCurrentForComparison should probably get
// merged into a single method // merged into a single method
export async function getCurrentForReport(): Promise<DeviceStatus> { // @deprecated
export async function getLegacyState(): Promise<DeviceLegacyState> {
const appsStatus = await applicationManager.getLegacyState(); const appsStatus = await applicationManager.getLegacyState();
const theState: DeepPartial<DeviceStatus> = { const theState: DeepPartial<DeviceLegacyState> = {
local: {}, local: {},
dependent: {}, dependent: {},
}; };
@ -566,9 +567,28 @@ export async function getCurrentForReport(): Promise<DeviceStatus> {
} }
} }
return theState as DeviceStatus; return theState as DeviceLegacyState;
} }
// Return current state in a way that the API understands
export async function getCurrentForReport(): Promise<DeviceState> {
const apps = await applicationManager.getState();
const { name, uuid } = await config.getMany(['name', 'uuid']);
if (!uuid) {
throw new InternalInconsistencyError('No uuid found for local device');
}
return {
[uuid]: {
name,
apps,
},
};
}
// Get the current state as object instances
export async function getCurrentState(): Promise<InstancedDeviceState> { export async function getCurrentState(): Promise<InstancedDeviceState> {
const [name, devConfig, apps, dependent] = await Promise.all([ const [name, devConfig, apps, dependent] = await Promise.all([
config.get('name'), config.get('name'),
@ -587,9 +607,7 @@ export async function getCurrentState(): Promise<InstancedDeviceState> {
}; };
} }
export function reportCurrentState( export function reportCurrentState(newState: DeviceLegacyReport = {}) {
newState: DeviceReportFields & Partial<InstancedAppState> = {},
) {
if (newState == null) { if (newState == null) {
newState = {}; newState = {};
} }

View File

@ -9,7 +9,7 @@ import { InternalInconsistencyError, StatusError } from '../lib/errors';
import { getRequestInstance } from '../lib/request'; import { getRequestInstance } from '../lib/request';
import * as sysInfo from '../lib/system-info'; import * as sysInfo from '../lib/system-info';
import { DeviceStatus } from '../types'; import { DeviceLegacyState } from '../types';
import * as config from '../config'; import * as config from '../config';
import { SchemaTypeKey, SchemaReturn } from '../config/schema-type'; import { SchemaTypeKey, SchemaReturn } from '../config/schema-type';
import * as eventTracker from '../event-tracker'; import * as eventTracker from '../event-tracker';
@ -22,7 +22,7 @@ const INTERNAL_STATE_KEYS = [
]; ];
export let stateReportErrors = 0; export let stateReportErrors = 0;
const lastReportedState: DeviceStatus = { const lastReportedState: DeviceLegacyState = {
local: {}, local: {},
dependent: {}, dependent: {},
}; };
@ -43,7 +43,7 @@ type CurrentStateReportConf = {
}; };
type StateReport = { type StateReport = {
stateDiff: DeviceStatus; stateDiff: DeviceLegacyState;
conf: Omit<CurrentStateReportConf, 'deviceId' | 'hardwareMetrics'>; conf: Omit<CurrentStateReportConf, 'deviceId' | 'hardwareMetrics'>;
}; };
@ -51,7 +51,9 @@ type StateReport = {
* Returns an object that contains only status fields relevant for the local mode. * Returns an object that contains only status fields relevant for the local mode.
* It basically removes information about applications state. * It basically removes information about applications state.
*/ */
const stripDeviceStateInLocalMode = (state: DeviceStatus): DeviceStatus => { const stripDeviceStateInLocalMode = (
state: DeviceLegacyState,
): DeviceLegacyState => {
return { return {
local: _.cloneDeep( local: _.cloneDeep(
_.omit(state.local, 'apps', 'is_on__commit', 'logs_channel'), _.omit(state.local, 'apps', 'is_on__commit', 'logs_channel'),
@ -103,7 +105,7 @@ async function report({ stateDiff, conf }: StateReport): Promise<boolean> {
return true; return true;
} }
function newStateDiff(stateForReport: DeviceStatus): DeviceStatus { function newStateDiff(stateForReport: DeviceLegacyState): DeviceLegacyState {
const lastReportedLocal = lastReportedState.local; const lastReportedLocal = lastReportedState.local;
const lastReportedDependent = lastReportedState.dependent; const lastReportedDependent = lastReportedState.dependent;
if (lastReportedLocal == null || lastReportedDependent == null) { if (lastReportedLocal == null || lastReportedDependent == null) {
@ -117,7 +119,7 @@ function newStateDiff(stateForReport: DeviceStatus): DeviceStatus {
const diff = { const diff = {
local: _.omitBy( local: _.omitBy(
stateForReport.local, stateForReport.local,
(val, key: keyof NonNullable<DeviceStatus['local']>) => (val, key: keyof NonNullable<DeviceLegacyState['local']>) =>
INTERNAL_STATE_KEYS.includes(key) || INTERNAL_STATE_KEYS.includes(key) ||
_.isEqual(lastReportedLocal[key], val) || _.isEqual(lastReportedLocal[key], val) ||
!sysInfo.isSignificantChange( !sysInfo.isSignificantChange(
@ -128,7 +130,7 @@ function newStateDiff(stateForReport: DeviceStatus): DeviceStatus {
), ),
dependent: _.omitBy( dependent: _.omitBy(
stateForReport.dependent, stateForReport.dependent,
(val, key: keyof DeviceStatus['dependent']) => (val, key: keyof DeviceLegacyState['dependent']) =>
INTERNAL_STATE_KEYS.includes(key) || INTERNAL_STATE_KEYS.includes(key) ||
_.isEqual(lastReportedDependent[key], val), _.isEqual(lastReportedDependent[key], val),
), ),
@ -197,7 +199,7 @@ function handleRetry(retryInfo: OnFailureInfo) {
async function generateStateForReport() { async function generateStateForReport() {
const { hardwareMetrics } = await config.getMany(['hardwareMetrics']); const { hardwareMetrics } = await config.getMany(['hardwareMetrics']);
const currentDeviceState = await deviceState.getCurrentForReport(); const currentDeviceState = await deviceState.getLegacyState();
// If hardwareMetrics is false, send null patch for system metrics to cloud API // If hardwareMetrics is false, send null patch for system metrics to cloud API
const info = { const info = {

View File

@ -16,7 +16,7 @@ import {
import App from '../compose/app'; import App from '../compose/app';
export type DeviceReportFields = Partial<{ export type DeviceLegacyReport = Partial<{
api_port: number; api_port: number;
api_secret: string | null; api_secret: string | null;
ip_address: string; ip_address: string;
@ -29,15 +29,15 @@ export type DeviceReportFields = Partial<{
update_failed: boolean; update_failed: boolean;
update_pending: boolean; update_pending: boolean;
update_downloaded: boolean; update_downloaded: boolean;
is_on__commit: string;
logs_channel: null; logs_channel: null;
mac_address: string | null; mac_address: string | null;
}>; }>;
// This is the state that is sent to the cloud // This is the state that is sent to the cloud
export interface DeviceStatus { export interface DeviceLegacyState {
local?: { local?: {
config?: Dictionary<string>; config?: Dictionary<string>;
is_on__commit?: string;
apps?: { apps?: {
[appId: string]: { [appId: string]: {
services: { services: {
@ -49,7 +49,7 @@ export interface DeviceStatus {
}; };
}; };
}; };
} & DeviceReportFields; } & DeviceLegacyReport;
// TODO: Type the dependent entry correctly // TODO: Type the dependent entry correctly
dependent?: { dependent?: {
[key: string]: any; [key: string]: any;
@ -57,6 +57,71 @@ export interface DeviceStatus {
commit?: string; commit?: string;
} }
export type ServiceState = {
image: string;
status: string;
download_progress?: number | null;
};
export type ReleaseState = {
services: {
[serviceName: string]: ServiceState;
};
};
export type ReleasesState = {
[releaseUuid: string]: ReleaseState;
};
export type AppState = {
release_uuid?: string;
releases: ReleasesState;
};
export type DeviceReport = {
name?: string;
status?: string;
os_version?: string | null; // TODO: Should these purely come from the os app?
os_variant?: string | null; // TODO: Should these purely come from the os app?
supervisor_version?: string; // TODO: Should this purely come from the supervisor app?
provisioning_progress?: number | null; // TODO: should this be reported as part of the os app?
provisioning_state?: string | null; // TODO: should this be reported as part of the os app?
ip_address?: string;
mac_address?: string | null;
api_port?: number; // TODO: should this be reported as part of the supervisor app?
api_secret?: string | null; // TODO: should this be reported as part of the supervisor app?
logs_channel?: string; // TODO: should this be reported as part of the supervisor app? or should it not be reported anymore at all?
memory_usage?: number;
memory_total?: number;
storage_block_device?: string;
storage_usage?: number;
storage_total?: number;
cpu_temp?: number;
cpu_usage?: number;
cpu_id?: string;
is_undervolted?: boolean;
// TODO: these are ignored by the API but are used by supervisor local API, remove?
update_failed?: boolean;
update_pending?: boolean;
update_downloaded?: boolean;
};
export type DeviceState = {
[deviceUuid: string]: DeviceReport & {
/**
* Used for setting dependent devices as online
*/
is_online?: boolean;
/**
* Used for setting gateway device of dependent devices
*/
parent_device?: number;
apps?: {
[appUuid: string]: AppState;
};
};
};
// Return a type with a default value // Return a type with a default value
const withDefault = <T extends t.Any>( const withDefault = <T extends t.Any>(
type: T, type: T,

View File

@ -4,6 +4,7 @@ import { stub } from 'sinon';
import App from '../../../src/compose/app'; import App from '../../../src/compose/app';
import * as applicationManager from '../../../src/compose/application-manager'; import * as applicationManager from '../../../src/compose/application-manager';
import * as imageManager from '../../../src/compose/images'; import * as imageManager from '../../../src/compose/images';
import * as serviceManager from '../../../src/compose/service-manager';
import { Image } from '../../../src/compose/images'; import { Image } from '../../../src/compose/images';
import Network from '../../../src/compose/network'; import Network from '../../../src/compose/network';
import * as networkManager from '../../../src/compose/network-manager'; import * as networkManager from '../../../src/compose/network-manager';
@ -1266,4 +1267,241 @@ describe('compose/application-manager', () => {
), ),
).to.have.lengthOf(1); ).to.have.lengthOf(1);
}); });
describe('getting applications 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,
},
},
},
},
},
fedora: {
releases: {
oldrelease: {
services: {
fedora: {
image: 'fedora:older',
status: 'Downloaded',
},
},
},
newrelease: {
services: {
fedora: {
image: 'fedora:latest',
status: 'Downloading',
download_progress: 75,
},
},
},
},
},
});
});
it('augments the service data with image data', 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: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: 'Stopped',
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: 'Stopped',
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,
},
},
},
},
},
fedora: {
releases: {
oldrelease: {
services: {
fedora: {
image: 'fedora:older',
status: 'Stopped',
},
},
},
},
},
});
});
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',
},
},
},
oldrelease: {
services: {
alpine: {
image: 'alpine:3.12',
status: 'Handing over',
},
},
},
},
},
});
});
});
}); });