mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-02-20 17:52:51 +00:00
Convert target state types to io-ts for better validation
This simplifies target state validation and improves validation messages. Change-type: patch
This commit is contained in:
parent
ca7c22d854
commit
f6692ab918
@ -23,7 +23,7 @@
|
||||
"release": "tsc --project tsconfig.release.json && mv build/src/* build",
|
||||
"packagejson:copy": "cp package.json build/",
|
||||
"testitems:copy": "cp -r test/data build/test/",
|
||||
"sync": "ts-node sync/sync.ts",
|
||||
"sync": "ts-node --files sync/sync.ts",
|
||||
"clean": "rimraf build"
|
||||
},
|
||||
"private": true,
|
||||
|
@ -32,7 +32,7 @@ import { createV2Api } from '../device-api/v2';
|
||||
import { CompositionStep, generateStep } from './composition-steps';
|
||||
import {
|
||||
InstancedAppState,
|
||||
TargetApplications,
|
||||
TargetApps,
|
||||
DeviceStatus,
|
||||
DeviceReportFields,
|
||||
TargetState,
|
||||
@ -463,13 +463,13 @@ export async function executeStep(
|
||||
|
||||
// FIXME: This shouldn't be in this module
|
||||
export async function setTarget(
|
||||
apps: TargetApplications,
|
||||
apps: TargetApps,
|
||||
dependent: TargetState['dependent'],
|
||||
source: string,
|
||||
maybeTrx?: Transaction,
|
||||
) {
|
||||
const setInTransaction = async (
|
||||
$filteredApps: TargetApplications,
|
||||
$filteredApps: TargetApps,
|
||||
trx: Transaction,
|
||||
) => {
|
||||
await dbFormat.setApps($filteredApps, source, trx);
|
||||
@ -534,7 +534,7 @@ export async function setTarget(
|
||||
}
|
||||
}
|
||||
|
||||
export async function getTargetApps(): Promise<TargetApplications> {
|
||||
export async function getTargetApps(): Promise<TargetApps> {
|
||||
const apps = await dbFormat.getTargetJson();
|
||||
|
||||
// Whilst it may make sense here to return the target state generated from the
|
||||
|
@ -242,6 +242,7 @@ export async function create(service: Service) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// TODO: this seems a bit late to be checking this
|
||||
const deviceName = await config.get('name');
|
||||
if (!isValidDeviceName(deviceName)) {
|
||||
throw new Error(
|
||||
|
@ -4,6 +4,8 @@ import { EventEmitter } from 'events';
|
||||
import * as express from 'express';
|
||||
import * as _ from 'lodash';
|
||||
import StrictEventEmitter from 'strict-event-emitter-types';
|
||||
import { isRight } from 'fp-ts/lib/Either';
|
||||
import Reporter from 'io-ts-reporters';
|
||||
|
||||
import prettyMs = require('pretty-ms');
|
||||
|
||||
@ -42,52 +44,15 @@ import * as apiKeys from './lib/api-keys';
|
||||
|
||||
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
|
||||
|
||||
function validateLocalState(state: any): asserts state is TargetState['local'] {
|
||||
if (state.name != null) {
|
||||
if (!validation.isValidShortText(state.name)) {
|
||||
throw new Error('Invalid device name');
|
||||
}
|
||||
}
|
||||
if (state.apps == null || !validation.isValidAppsObject(state.apps)) {
|
||||
throw new Error('Invalid apps');
|
||||
}
|
||||
if (state.config == null || !validation.isValidEnv(state.config)) {
|
||||
throw new Error('Invalid device configuration');
|
||||
}
|
||||
}
|
||||
function parseTargetState(state: unknown): TargetState {
|
||||
const res = TargetState.decode(state);
|
||||
|
||||
function validateDependentState(
|
||||
state: any,
|
||||
): asserts state is TargetState['dependent'] {
|
||||
if (
|
||||
state.apps != null &&
|
||||
!validation.isValidDependentAppsObject(state.apps)
|
||||
) {
|
||||
throw new Error('Invalid dependent apps');
|
||||
if (isRight(res)) {
|
||||
return res.right;
|
||||
}
|
||||
if (
|
||||
state.devices != null &&
|
||||
!validation.isValidDependentDevicesObject(state.devices)
|
||||
) {
|
||||
throw new Error('Invalid dependent devices');
|
||||
}
|
||||
}
|
||||
|
||||
function validateState(state: any): asserts state is TargetState {
|
||||
if (!_.isObject(state)) {
|
||||
throw new Error('State must be an object');
|
||||
}
|
||||
// these any typings seem unnecessary but the `isObject`
|
||||
// call above tells typescript that state is of type
|
||||
// `object` - which apparently does not allow any fields
|
||||
// to be accessed
|
||||
if (!_.isObject((state as any).local)) {
|
||||
throw new Error('Local state must be an object');
|
||||
}
|
||||
validateLocalState((state as any).local);
|
||||
if ((state as any).dependent != null) {
|
||||
return validateDependentState((state as any).dependent);
|
||||
}
|
||||
const errors = ['Invalid target state.'].concat(Reporter.report(res));
|
||||
throw new Error(errors.join('\n'));
|
||||
}
|
||||
|
||||
// TODO (refactor): This shouldn't be here, and instead should be part of the other
|
||||
@ -502,7 +467,8 @@ export async function setTarget(target: TargetState, localSource?: boolean) {
|
||||
}
|
||||
failedUpdates = 0;
|
||||
|
||||
validateState(target);
|
||||
// This will throw if target state is invalid
|
||||
target = parseTargetState(target);
|
||||
|
||||
globalEventBus.getInstance().emit('targetStateChanged', target);
|
||||
|
||||
|
@ -8,9 +8,9 @@ import * as images from '../compose/images';
|
||||
|
||||
import {
|
||||
InstancedAppState,
|
||||
TargetApplication,
|
||||
TargetApplications,
|
||||
TargetApplicationService,
|
||||
TargetApp,
|
||||
TargetApps,
|
||||
TargetService,
|
||||
} from '../types/state';
|
||||
import { checkInt } from '../lib/validation';
|
||||
|
||||
@ -37,7 +37,7 @@ export async function getApps(): Promise<InstancedAppState> {
|
||||
}
|
||||
|
||||
export async function setApps(
|
||||
apps: { [appId: number]: TargetApplication },
|
||||
apps: { [appId: number]: TargetApp },
|
||||
source: string,
|
||||
trx?: db.Transaction,
|
||||
) {
|
||||
@ -72,9 +72,9 @@ export async function setApps(
|
||||
await targetStateCache.setTargetApps(dbApps, trx);
|
||||
}
|
||||
|
||||
export async function getTargetJson(): Promise<TargetApplications> {
|
||||
export async function getTargetJson(): Promise<TargetApps> {
|
||||
const dbApps = await getDBEntry();
|
||||
const apps: TargetApplications = {};
|
||||
const apps: TargetApps = {};
|
||||
await Promise.all(
|
||||
dbApps.map(async (app) => {
|
||||
const parsedServices = JSON.parse(app.services);
|
||||
@ -82,8 +82,7 @@ export async function getTargetJson(): Promise<TargetApplications> {
|
||||
const services = _(parsedServices)
|
||||
.keyBy('serviceId')
|
||||
.mapValues(
|
||||
(svc: TargetApplicationService) =>
|
||||
_.omit(svc, 'commit') as TargetApplicationService,
|
||||
(svc: TargetService) => _.omit(svc, 'commit') as TargetService,
|
||||
)
|
||||
.value();
|
||||
|
||||
@ -96,7 +95,7 @@ export async function getTargetJson(): Promise<TargetApplications> {
|
||||
networks: JSON.parse(app.networks),
|
||||
volumes: JSON.parse(app.volumes),
|
||||
// We can add this cast because it's required in the db
|
||||
} as TargetApplication;
|
||||
} as TargetApp;
|
||||
}),
|
||||
);
|
||||
return apps;
|
||||
|
@ -23,18 +23,15 @@ import {
|
||||
import { docker } from '../lib/docker-utils';
|
||||
import { exec, pathExistsOnHost, mkdirp } from '../lib/fs-utils';
|
||||
import { log } from '../lib/supervisor-console';
|
||||
import type {
|
||||
AppsJsonFormat,
|
||||
TargetApplication,
|
||||
TargetState,
|
||||
} from '../types/state';
|
||||
import type { AppsJsonFormat, TargetApp, TargetState } from '../types/state';
|
||||
import type { DatabaseApp } from '../device-state/target-state-cache';
|
||||
import { ShortString } from '../types';
|
||||
|
||||
export const defaultLegacyVolume = () => 'resin-data';
|
||||
|
||||
export function singleToMulticontainerApp(
|
||||
app: Dictionary<any>,
|
||||
): TargetApplication & { appId: string } {
|
||||
): TargetApp & { appId: string } {
|
||||
const environment: Dictionary<string> = {};
|
||||
for (const key in app.env) {
|
||||
if (!/^RESIN_/.test(key)) {
|
||||
@ -44,7 +41,7 @@ export function singleToMulticontainerApp(
|
||||
|
||||
const { appId } = app;
|
||||
const conf = app.config != null ? app.config : {};
|
||||
const newApp: TargetApplication & { appId: string } = {
|
||||
const newApp: TargetApp & { appId: string } = {
|
||||
appId: appId.toString(),
|
||||
commit: app.commit,
|
||||
name: app.name,
|
||||
@ -72,7 +69,7 @@ export function singleToMulticontainerApp(
|
||||
// tslint:disable-next-line
|
||||
'1': {
|
||||
appId,
|
||||
serviceName: 'main',
|
||||
serviceName: 'main' as ShortString,
|
||||
imageId: 1,
|
||||
commit: app.commit,
|
||||
releaseId: 1,
|
||||
@ -132,7 +129,7 @@ export async function normaliseLegacyDatabase() {
|
||||
}
|
||||
|
||||
for (const app of apps) {
|
||||
let services: Array<TargetApplication['services']['']>;
|
||||
let services: Array<TargetApp['services']['']>;
|
||||
|
||||
try {
|
||||
services = JSON.parse(app.services);
|
||||
|
@ -1,17 +1,10 @@
|
||||
import { isRight } from 'fp-ts/lib/Either';
|
||||
import * as _ from 'lodash';
|
||||
import { inspect } from 'util';
|
||||
|
||||
import { TargetState } from '../types/state';
|
||||
import { EnvVarObject, LabelObject } from '../types';
|
||||
|
||||
import log from './supervisor-console';
|
||||
|
||||
import { DeviceName } from '../types';
|
||||
export interface CheckIntOptions {
|
||||
positive?: boolean;
|
||||
}
|
||||
|
||||
const ENV_VAR_KEY_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
const LABEL_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9\.\-]*$/;
|
||||
const NUMERALS_REGEX = /^-?[0-9]+\.?0*$/; // Allows trailing 0 decimals
|
||||
const TRUTHY = ['1', 'true', true, 'on', 1];
|
||||
const FALSEY = ['0', 'false', false, 'off', 0];
|
||||
@ -93,434 +86,8 @@ export function checkFalsey(v: unknown): boolean {
|
||||
return FALSEY.includes(v as any);
|
||||
}
|
||||
|
||||
/*
|
||||
* isValidShortText
|
||||
*
|
||||
* Check that the input string is definitely a string,
|
||||
* and has a length which is less than 255
|
||||
*/
|
||||
export function isValidShortText(t: unknown): boolean {
|
||||
return _.isString(t) && t.length <= 255;
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidEnv
|
||||
*
|
||||
* Given a env var object, check types and values for the keys
|
||||
* and values
|
||||
*/
|
||||
export function isValidEnv(obj: EnvVarObject): boolean {
|
||||
if (!_.isObject(obj)) {
|
||||
log.debug(
|
||||
`Non-object passed to validation.isValidEnv\nobj: ${inspect(obj)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.every(obj, (val, key) => {
|
||||
if (!isValidShortText(key)) {
|
||||
log.debug(
|
||||
`Non-valid short text env var key passed to validation.isValidEnv\nKey: ${inspect(
|
||||
key,
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!ENV_VAR_KEY_REGEX.test(key)) {
|
||||
log.debug(
|
||||
`Invalid env var key passed to validation.isValidEnv\nKey: ${inspect(
|
||||
key,
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_.isString(val)) {
|
||||
log.debug(
|
||||
`Non-string value passed to validation.isValidEnv\nValue: ${inspect(
|
||||
key,
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidLabelsObject
|
||||
*
|
||||
* Given a labels object, test the types and values for validity
|
||||
*/
|
||||
export function isValidLabelsObject(obj: LabelObject): boolean {
|
||||
if (!_.isObject(obj)) {
|
||||
log.debug(
|
||||
`Non-object passed to validation.isValidLabelsObject\nobj: ${inspect(
|
||||
obj,
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.every(obj, (val, key) => {
|
||||
if (!isValidShortText(key)) {
|
||||
log.debug(
|
||||
`Non-valid short text label key passed to validation.isValidLabelsObject\nKey: ${inspect(
|
||||
key,
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!LABEL_NAME_REGEX.test(key)) {
|
||||
log.debug(
|
||||
`Invalid label name passed to validation.isValidLabelsObject\nKey: ${inspect(
|
||||
key,
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_.isString(val)) {
|
||||
log.debug(
|
||||
`Non-string value passed to validation.isValidLabelsObject\nValue: ${inspect(
|
||||
val,
|
||||
)}`,
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function isValidDeviceName(name: string): boolean {
|
||||
// currently the only disallowed value in a device name is a newline
|
||||
const newline = name.indexOf('\n') !== -1;
|
||||
if (newline) {
|
||||
log.debug(
|
||||
'Newline found in device name. This is invalid and should be removed',
|
||||
);
|
||||
}
|
||||
return !newline;
|
||||
}
|
||||
|
||||
function undefinedOrValidEnv(val: EnvVarObject): boolean {
|
||||
return val == null || isValidEnv(val);
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidDependentAppsObject
|
||||
*
|
||||
* Given a dependent apps object from a state endpoint, validate it
|
||||
*
|
||||
* TODO: Type the input
|
||||
*/
|
||||
export function isValidDependentAppsObject(apps: unknown): boolean {
|
||||
if (!_.isObject(apps)) {
|
||||
log.debug(
|
||||
'Non-object passed to validation.isValidDependentAppsObject\nApps:',
|
||||
inspect(apps),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.every(apps, (v, appId) => {
|
||||
const val: TargetState['dependent']['apps'][any] = _.defaults(_.clone(v), {
|
||||
config: undefined,
|
||||
environment: undefined,
|
||||
commit: undefined,
|
||||
image: undefined,
|
||||
});
|
||||
|
||||
if (!isValidShortText(appId) || !checkInt(appId)) {
|
||||
log.debug(
|
||||
'Invalid appId passed to validation.isValidDependentAppsObject\nappId:',
|
||||
inspect(appId),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.conformsTo(val, {
|
||||
parentApp: (n: any) => {
|
||||
if (!checkInt(n)) {
|
||||
log.debug(
|
||||
'Invalid parentApp passed to validation.isValidDependentAppsObject\nName:',
|
||||
inspect(n),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
name: (n: any) => {
|
||||
if (!isValidShortText(n)) {
|
||||
log.debug(
|
||||
'Invalid name passed to validation.isValidDependentAppsObject\nName:',
|
||||
inspect(n),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
image: (i: any) => {
|
||||
if (val.commit != null && !isValidShortText(i)) {
|
||||
log.debug(
|
||||
'Non valid image passed to validation.isValidDependentAppsObject\nImage:',
|
||||
inspect(i),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
commit: (c: any) => {
|
||||
if (c != null && !isValidShortText(c)) {
|
||||
log.debug(
|
||||
'invalid commit passed to validation.isValidDependentAppsObject\nCommit:',
|
||||
inspect(c),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
config: (c: any) => {
|
||||
if (!undefinedOrValidEnv(c)) {
|
||||
log.debug(
|
||||
'Invalid config passed to validation.isValidDependentAppsObject\nConfig:',
|
||||
inspect(c),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function isValidService(service: any, serviceId: string): boolean {
|
||||
if (!isValidShortText(serviceId) || !checkInt(serviceId)) {
|
||||
log.debug(
|
||||
'Invalid service id passed to validation.isValidService\nService ID:',
|
||||
inspect(serviceId),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.conformsTo(service, {
|
||||
serviceName: (n: any) => {
|
||||
if (!isValidShortText(n)) {
|
||||
log.debug(
|
||||
'Invalid service name passed to validation.isValidService\nService Name:',
|
||||
inspect(n),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
image: (i: any) => {
|
||||
if (!isValidShortText(i)) {
|
||||
log.debug(
|
||||
'Invalid image passed to validation.isValidService\nImage:',
|
||||
inspect(i),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
environment: (e: any) => {
|
||||
if (!isValidEnv(e)) {
|
||||
log.debug(
|
||||
'Invalid env passed to validation.isValidService\nEnvironment:',
|
||||
inspect(e),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
imageId: (i: any) => {
|
||||
if (checkInt(i) == null) {
|
||||
log.debug(
|
||||
'Invalid image id passed to validation.isValidService\nImage ID:',
|
||||
inspect(i),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
labels: (l: any) => {
|
||||
if (!isValidLabelsObject(l)) {
|
||||
log.debug(
|
||||
'Invalid labels object passed to validation.isValidService\nLabels:',
|
||||
inspect(l),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidAppsObject
|
||||
*
|
||||
* Given an apps object from the state endpoint, validate the fields and
|
||||
* return whether it's valid.
|
||||
*
|
||||
* TODO: Type the input correctly
|
||||
*/
|
||||
export function isValidAppsObject(obj: any): boolean {
|
||||
if (!_.isObject(obj)) {
|
||||
log.debug(
|
||||
'Invalid object passed to validation.isValidAppsObject\nobj:',
|
||||
inspect(obj),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.every(obj, (v, appId) => {
|
||||
if (!isValidShortText(appId) || !checkInt(appId)) {
|
||||
log.debug(
|
||||
'Invalid appId passed to validation.isValidAppsObject\nApp ID:',
|
||||
inspect(appId),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
// TODO: Remove this partial and validate the extra fields
|
||||
const val: Partial<TargetState['local']['apps'][any]> = _.defaults(
|
||||
_.clone(v),
|
||||
{ releaseId: undefined },
|
||||
);
|
||||
|
||||
return _.conformsTo(val, {
|
||||
name: (n: any) => {
|
||||
if (!isValidShortText(n)) {
|
||||
log.debug(
|
||||
'Invalid service name passed to validation.isValidAppsObject\nName:',
|
||||
inspect(n),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
releaseId: (r: any) => {
|
||||
if (r != null && checkInt(r) == null) {
|
||||
log.debug(
|
||||
'Invalid releaseId passed to validation.isValidAppsObject\nRelease ID',
|
||||
inspect(r),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
services: (s: any) => {
|
||||
if (!_.isObject(s)) {
|
||||
log.debug(
|
||||
'Non-object service passed to validation.isValidAppsObject\nServices:',
|
||||
inspect(s),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.every(s, (svc, svcId) => {
|
||||
if (!isValidService(svc, svcId)) {
|
||||
log.debug(
|
||||
'Invalid service object passed to validation.isValidAppsObject\nService:',
|
||||
inspect(svc),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* isValidDependentDevicesObject
|
||||
*
|
||||
* Validate a dependent devices object from the state endpoint.
|
||||
*/
|
||||
export function isValidDependentDevicesObject(devices: any): boolean {
|
||||
if (!_.isObject(devices)) {
|
||||
log.debug(
|
||||
'Non-object passed to validation.isValidDependentDevicesObject\nDevices:',
|
||||
inspect(devices),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.every(devices, (val, uuid) => {
|
||||
if (!isValidShortText(uuid)) {
|
||||
log.debug(
|
||||
'Invalid uuid passed to validation.isValidDependentDevicesObject\nuuid:',
|
||||
inspect(uuid),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.conformsTo(val as TargetState['dependent']['devices'][any], {
|
||||
name: (n: any) => {
|
||||
if (!isValidShortText(n)) {
|
||||
log.debug(
|
||||
'Invalid device name passed to validation.isValidDependentDevicesObject\nName:',
|
||||
inspect(n),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
apps: (a: any) => {
|
||||
if (!_.isObject(a)) {
|
||||
log.debug(
|
||||
'Invalid apps object passed to validation.isValidDependentDevicesObject\nApps:',
|
||||
inspect(a),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (_.isEmpty(a)) {
|
||||
log.debug(
|
||||
'Empty object passed to validation.isValidDependentDevicesObject',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
return _.every(
|
||||
a as TargetState['dependent']['devices'][any]['apps'],
|
||||
(app) => {
|
||||
app = _.defaults(_.clone(app), {
|
||||
config: undefined,
|
||||
environment: undefined,
|
||||
});
|
||||
return _.conformsTo(app, {
|
||||
config: (c: any) => {
|
||||
if (!undefinedOrValidEnv(c)) {
|
||||
log.debug(
|
||||
'Invalid config passed to validation.isValidDependentDevicesObject\nConfig:',
|
||||
inspect(c),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
environment: (e: any) => {
|
||||
if (!undefinedOrValidEnv(e)) {
|
||||
log.debug(
|
||||
'Invalid environment passed to validation.isValidDependentDevicesObject\nConfig:',
|
||||
inspect(e),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
});
|
||||
});
|
||||
export function isValidDeviceName(v: unknown): v is DeviceName {
|
||||
return isRight(DeviceName.decode(v));
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -1,7 +1,182 @@
|
||||
export interface EnvVarObject {
|
||||
[name: string]: string;
|
||||
}
|
||||
import * as t from 'io-ts';
|
||||
import { chain } from 'fp-ts/lib/Either';
|
||||
import { pipe } from 'fp-ts/function';
|
||||
|
||||
export interface LabelObject {
|
||||
[name: string]: string;
|
||||
}
|
||||
/**
|
||||
* A short string is a non null string between
|
||||
* 0 and 255 characters
|
||||
*/
|
||||
export const ShortString = new t.Type<string, string>(
|
||||
'ShortString',
|
||||
(i: unknown): i is string => t.string.is(i) && i.length <= 255,
|
||||
(i, c) =>
|
||||
pipe(
|
||||
t.string.validate(i, c),
|
||||
chain((s) =>
|
||||
s.length <= 255
|
||||
? t.success(s)
|
||||
: t.failure(s, c, 'must be at most 255 chars long'),
|
||||
),
|
||||
),
|
||||
t.identity,
|
||||
);
|
||||
|
||||
// Note: assigning this type to a string will not throw compilation errorrs.
|
||||
//
|
||||
// e.g. the following will compile without issues.
|
||||
// ```
|
||||
// const x: ShortString = 'a'.repeat(300);
|
||||
// ```
|
||||
export type ShortString = t.TypeOf<typeof ShortString>;
|
||||
|
||||
/**
|
||||
* A string identifier is a string that encodes a
|
||||
* positive integer (an id to be used as a database id)
|
||||
*
|
||||
* e.g.
|
||||
* Invalid decimal strings: 'aaa', '0xaaa'
|
||||
* Valid decimal strings: '0', '123'
|
||||
*/
|
||||
export const StringIdentifier = new t.Type<string, string>(
|
||||
'StringIdentifier',
|
||||
(i: unknown): i is string =>
|
||||
t.string.is(i) && !isNaN(+i) && +i === parseInt(i, 10) && +i >= 0,
|
||||
(i, c) =>
|
||||
pipe(
|
||||
t.string.validate(i, c),
|
||||
chain((s) =>
|
||||
!isNaN(+s) && +s === parseInt(s, 10) && +s >= 0
|
||||
? t.success(s)
|
||||
: t.failure(s, c, 'must be be an positive integer'),
|
||||
),
|
||||
),
|
||||
String,
|
||||
);
|
||||
|
||||
export type StringIdentifier = t.TypeOf<typeof StringIdentifier>;
|
||||
|
||||
export const StringOrNumber = t.union([t.number, t.string]);
|
||||
export type StringOrNumber = t.TypeOf<typeof StringOrNumber>;
|
||||
|
||||
/**
|
||||
* A numeric identifier is any valid identifier encoded as a string or number
|
||||
*/
|
||||
export const NumericIdentifier = new t.Type<number, StringOrNumber>(
|
||||
'NumericIdentifier',
|
||||
(i): i is number =>
|
||||
StringOrNumber.is(i) &&
|
||||
!isNaN(+i) &&
|
||||
+i === parseInt(String(i), 10) &&
|
||||
+i >= 0,
|
||||
(i, c) =>
|
||||
pipe(
|
||||
StringOrNumber.validate(i, c),
|
||||
chain((n) =>
|
||||
!isNaN(+n) && +n === parseInt(String(n), 10) && +n >= 0
|
||||
? t.success(+n)
|
||||
: t.failure(n, c, 'must be be an positive integer'),
|
||||
),
|
||||
),
|
||||
Number,
|
||||
);
|
||||
export type NumericIdentifier = t.TypeOf<typeof NumericIdentifier>;
|
||||
|
||||
/**
|
||||
* Valid variable names are between 0 and 255 characters
|
||||
* and match /^[a-zA-Z_][a-zA-Z0-9_]*$/
|
||||
*/
|
||||
const VAR_NAME_REGEX = /^[a-zA-Z_][a-zA-Z0-9_]*$/;
|
||||
|
||||
export const VariableName = new t.Type<string, string>(
|
||||
'VariableName',
|
||||
(s: unknown): s is string => ShortString.is(s) && VAR_NAME_REGEX.test(s),
|
||||
(i, c) =>
|
||||
pipe(
|
||||
ShortString.validate(i, c),
|
||||
chain((s) =>
|
||||
VAR_NAME_REGEX.test(s)
|
||||
? t.success(s)
|
||||
: t.failure(s, c, "may only contain alphanumeric chars plus '_'"),
|
||||
),
|
||||
),
|
||||
t.identity,
|
||||
);
|
||||
export type VariableName = t.TypeOf<typeof VariableName>;
|
||||
|
||||
/**
|
||||
* Valid label names are between 0 and 255 characters
|
||||
* and match /^[a-zA-Z][a-zA-Z0-9\.\-]*$/
|
||||
*/
|
||||
const LABEL_NAME_REGEX = /^[a-zA-Z][a-zA-Z0-9\.\-]*$/;
|
||||
|
||||
export const LabelName = new t.Type<string, string>(
|
||||
'LabelName',
|
||||
(s: unknown): s is string => ShortString.is(s) && LABEL_NAME_REGEX.test(s),
|
||||
(i, c) =>
|
||||
pipe(
|
||||
ShortString.validate(i, c),
|
||||
chain((s) =>
|
||||
LABEL_NAME_REGEX.test(s)
|
||||
? t.success(s)
|
||||
: t.failure(
|
||||
s,
|
||||
c,
|
||||
"may only contain alphanumeric chars plus '-' and '.'",
|
||||
),
|
||||
),
|
||||
),
|
||||
t.identity,
|
||||
);
|
||||
export type LabelName = t.TypeOf<typeof LabelName>;
|
||||
|
||||
/**
|
||||
* An env var object is a dictionary with valid variables as keys
|
||||
*/
|
||||
export const EnvVarObject = t.record(VariableName, t.string);
|
||||
export type EnvVarObject = t.TypeOf<typeof EnvVarObject>;
|
||||
|
||||
/**
|
||||
* An env var object is a dictionary with valid labels as keys
|
||||
*/
|
||||
export const LabelObject = t.record(LabelName, t.string);
|
||||
export type LabelObject = t.TypeOf<typeof LabelObject>;
|
||||
|
||||
// Valid docker container and volume name according to
|
||||
// https://github.com/moby/moby/blob/04c6f09fbdf60c7765cc4cb78883faaa9d971fa5/daemon/daemon.go#L56
|
||||
// [a-zA-Z0-9][a-zA-Z0-9_.-]
|
||||
const DOCKER_NAME_REGEX = /^[a-zA-Z0-9][a-zA-Z0-9_\.\-]*$/;
|
||||
export const DockerName = new t.Type<string, string>(
|
||||
'DockerName',
|
||||
(s: unknown): s is string => ShortString.is(s) && DOCKER_NAME_REGEX.test(s),
|
||||
(i, c) =>
|
||||
pipe(
|
||||
ShortString.validate(i, c),
|
||||
chain((s) =>
|
||||
DOCKER_NAME_REGEX.test(s)
|
||||
? t.success(s)
|
||||
: t.failure(s, c, 'only "[a-zA-Z0-9][a-zA-Z0-9_.-]" are allowed'),
|
||||
),
|
||||
),
|
||||
t.identity,
|
||||
);
|
||||
export type DockerName = t.TypeOf<typeof DockerName>;
|
||||
|
||||
/**
|
||||
* Device name can have any characters except '\n'
|
||||
*/
|
||||
export const DeviceName = new t.Type<string, string>(
|
||||
'DeviceName',
|
||||
(i: unknown): i is string => ShortString.is(i) && i.indexOf('\n') === -1,
|
||||
(i, c) =>
|
||||
pipe(
|
||||
ShortString.validate(i, c),
|
||||
chain((s) =>
|
||||
s.indexOf('\n') === -1
|
||||
? t.success(s)
|
||||
: t.failure(s, c, 'must not contain newline chars'),
|
||||
),
|
||||
),
|
||||
t.identity,
|
||||
);
|
||||
|
||||
export type DeviceName = t.TypeOf<typeof DeviceName>;
|
||||
|
@ -1,9 +1,19 @@
|
||||
import * as t from 'io-ts';
|
||||
|
||||
// TODO: move all these exported types to ../compose/types
|
||||
import { ComposeNetworkConfig } from '../compose/types/network';
|
||||
import { ServiceComposeConfig } from '../compose/types/service';
|
||||
import { ComposeVolumeConfig } from '../compose/volume';
|
||||
|
||||
import { EnvVarObject, LabelObject } from './basic';
|
||||
import {
|
||||
DockerName,
|
||||
EnvVarObject,
|
||||
LabelObject,
|
||||
StringIdentifier,
|
||||
NumericIdentifier,
|
||||
ShortString,
|
||||
DeviceName,
|
||||
} from './basic';
|
||||
|
||||
import App from '../compose/app';
|
||||
|
||||
@ -48,70 +58,176 @@ export interface DeviceStatus {
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
// TODO: Define this with io-ts so we can perform validation
|
||||
// on the target state from the api, local mode, and preload
|
||||
export interface TargetState {
|
||||
local: {
|
||||
name: string;
|
||||
config: EnvVarObject;
|
||||
apps: {
|
||||
[appId: string]: {
|
||||
name: string;
|
||||
commit?: string;
|
||||
releaseId?: number;
|
||||
services: {
|
||||
[serviceId: string]: {
|
||||
labels: LabelObject;
|
||||
imageId: number;
|
||||
serviceName: string;
|
||||
image: string;
|
||||
running?: boolean;
|
||||
environment: Dictionary<string>;
|
||||
contract?: Dictionary<any>;
|
||||
} & ServiceComposeConfig;
|
||||
};
|
||||
volumes: Dictionary<Partial<ComposeVolumeConfig>>;
|
||||
networks: Dictionary<Partial<ComposeNetworkConfig>>;
|
||||
};
|
||||
};
|
||||
};
|
||||
dependent: {
|
||||
apps: {
|
||||
[appId: string]: {
|
||||
name: string;
|
||||
parentApp: number;
|
||||
config: EnvVarObject;
|
||||
releaseId?: number;
|
||||
imageId?: number;
|
||||
commit?: string;
|
||||
image?: string;
|
||||
};
|
||||
};
|
||||
devices: {
|
||||
[uuid: string]: {
|
||||
name: string;
|
||||
apps: {
|
||||
[id: string]: {
|
||||
config: EnvVarObject;
|
||||
environment: EnvVarObject;
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
// Return a type with a default value
|
||||
const withDefault = <T extends t.Any>(
|
||||
type: T,
|
||||
defaultValue: t.TypeOf<T>,
|
||||
): t.Type<t.TypeOf<T>> =>
|
||||
new t.Type(
|
||||
type.name,
|
||||
type.is,
|
||||
(v, c) => type.validate(!!v ? v : defaultValue, c),
|
||||
type.encode,
|
||||
);
|
||||
|
||||
export type LocalTargetState = TargetState['local'];
|
||||
export type TargetApplications = LocalTargetState['apps'];
|
||||
export type TargetApplication = LocalTargetState['apps'][0];
|
||||
export type TargetApplicationService = TargetApplication['services'][0];
|
||||
export type AppsJsonFormat = Omit<TargetState['local'], 'name'> & {
|
||||
pinDevice?: boolean;
|
||||
apps: {
|
||||
// The releaseId/commit are required for preloading
|
||||
[id: string]: Required<TargetState['local']['apps'][string]>;
|
||||
};
|
||||
};
|
||||
/**
|
||||
* Utility function to return a io-ts type from a native typescript
|
||||
* type.
|
||||
*
|
||||
* **IMPORTANT**: This will NOT validate the type, just allow to combine the generated
|
||||
* type with other io-ts types.
|
||||
*
|
||||
* Please do NOT export. This is a placeholder while updating other related
|
||||
* types to io-ts
|
||||
*
|
||||
* Example:
|
||||
* ```
|
||||
* export
|
||||
*
|
||||
* type MyType = { one: string };
|
||||
* const MyType = fromType<MyType>('MyType'); // both name and generic value are required :(
|
||||
* const OtherType = t.type({name: t.string, other: MyType });
|
||||
* OtherType.decode({name: 'john', other: {one: 1}); // will decode to true
|
||||
*
|
||||
* type OtherType = t.TypeOf<typeof OtherType>; // will have the correct type definition
|
||||
* ```
|
||||
*/
|
||||
const fromType = <T extends object>(name: string) =>
|
||||
new t.Type<T>(
|
||||
name,
|
||||
(input: unknown): input is T => typeof input === 'object' && input !== null,
|
||||
(input, context) =>
|
||||
typeof input === 'object' && input !== null
|
||||
? (t.success(input) as t.Validation<T>)
|
||||
: t.failure(
|
||||
input,
|
||||
context,
|
||||
`Expected value to be an object of type ${name}, got: ${input}`,
|
||||
),
|
||||
t.identity,
|
||||
);
|
||||
|
||||
export const TargetService = t.intersection([
|
||||
t.type({
|
||||
serviceName: DockerName,
|
||||
imageId: NumericIdentifier,
|
||||
image: ShortString,
|
||||
environment: EnvVarObject,
|
||||
labels: LabelObject,
|
||||
}),
|
||||
t.partial({
|
||||
running: withDefault(t.boolean, true),
|
||||
contract: t.record(t.string, t.unknown),
|
||||
}),
|
||||
// This will not be validated
|
||||
// TODO: convert ServiceComposeConfig to a io-ts type
|
||||
fromType<ServiceComposeConfig>('ServiceComposition'),
|
||||
]);
|
||||
export type TargetService = t.TypeOf<typeof TargetService>;
|
||||
|
||||
const TargetApp = t.intersection(
|
||||
[
|
||||
t.type({
|
||||
name: ShortString,
|
||||
services: withDefault(t.record(StringIdentifier, TargetService), {}),
|
||||
volumes: withDefault(
|
||||
t.record(
|
||||
DockerName,
|
||||
// TargetVolume format will NOT be validated
|
||||
// TODO: convert ComposeVolumeConfig to a io-ts type
|
||||
fromType<Partial<ComposeVolumeConfig>>('Volume'),
|
||||
),
|
||||
{},
|
||||
),
|
||||
networks: withDefault(
|
||||
t.record(
|
||||
DockerName,
|
||||
// TargetNetwork format will NOT be validated
|
||||
// TODO: convert ComposeVolumeConfig to a io-ts type
|
||||
fromType<Partial<ComposeNetworkConfig>>('Network'),
|
||||
),
|
||||
{},
|
||||
),
|
||||
}),
|
||||
t.partial({
|
||||
commit: ShortString,
|
||||
releaseId: NumericIdentifier,
|
||||
}),
|
||||
],
|
||||
'App',
|
||||
);
|
||||
export type TargetApp = t.TypeOf<typeof TargetApp>;
|
||||
|
||||
export const TargetApps = t.record(StringIdentifier, TargetApp);
|
||||
export type TargetApps = t.TypeOf<typeof TargetApps>;
|
||||
|
||||
const DependentApp = t.intersection(
|
||||
[
|
||||
t.type({
|
||||
name: ShortString,
|
||||
parentApp: NumericIdentifier,
|
||||
config: EnvVarObject,
|
||||
}),
|
||||
t.partial({
|
||||
releaseId: NumericIdentifier,
|
||||
imageId: NumericIdentifier,
|
||||
commit: ShortString,
|
||||
image: ShortString,
|
||||
}),
|
||||
],
|
||||
'DependentApp',
|
||||
);
|
||||
|
||||
const DependentDevice = t.type(
|
||||
{
|
||||
name: ShortString, // device uuid
|
||||
apps: t.record(
|
||||
StringIdentifier,
|
||||
t.type({ config: EnvVarObject, environment: EnvVarObject }),
|
||||
),
|
||||
},
|
||||
'DependentDevice',
|
||||
);
|
||||
|
||||
// Although the original types for dependent apps and dependent devices was a dictionary,
|
||||
// proxyvisor.js accepts both a dictionary and an array. Unfortunately
|
||||
// the CLI sends an array, thus the types need to accept both
|
||||
const DependentApps = t.union([
|
||||
t.array(DependentApp),
|
||||
t.record(StringIdentifier, DependentApp),
|
||||
]);
|
||||
|
||||
const DependentDevices = t.union([
|
||||
t.array(DependentDevice),
|
||||
t.record(StringIdentifier, DependentDevice),
|
||||
]);
|
||||
|
||||
export const TargetState = t.type({
|
||||
local: t.type({
|
||||
name: DeviceName,
|
||||
config: EnvVarObject,
|
||||
apps: TargetApps,
|
||||
}),
|
||||
dependent: t.type({
|
||||
apps: DependentApps,
|
||||
devices: DependentDevices,
|
||||
}),
|
||||
});
|
||||
export type TargetState = t.TypeOf<typeof TargetState>;
|
||||
|
||||
const TargetAppWithRelease = t.intersection([
|
||||
TargetApp,
|
||||
t.type({ commit: t.string, releaseId: NumericIdentifier }),
|
||||
]);
|
||||
|
||||
const AppsJsonFormat = t.intersection([
|
||||
t.type({
|
||||
config: EnvVarObject,
|
||||
apps: t.record(StringIdentifier, TargetAppWithRelease),
|
||||
}),
|
||||
t.partial({ pinDevice: t.boolean }),
|
||||
]);
|
||||
export type AppsJsonFormat = t.TypeOf<typeof AppsJsonFormat>;
|
||||
|
||||
export type InstancedAppState = { [appId: number]: App };
|
||||
|
||||
|
@ -1,9 +1,16 @@
|
||||
import * as _ from 'lodash';
|
||||
import { expect } from 'chai';
|
||||
|
||||
import * as validation from '../src/lib/validation';
|
||||
import { isRight } from 'fp-ts/lib/Either';
|
||||
import {
|
||||
StringIdentifier,
|
||||
ShortString,
|
||||
DeviceName,
|
||||
NumericIdentifier,
|
||||
TargetApps,
|
||||
} from '../src/types';
|
||||
|
||||
const almostTooLongText = _.times(255, () => 'a').join('');
|
||||
import * as validation from '../src/lib/validation';
|
||||
|
||||
describe('validation', () => {
|
||||
describe('checkBooleanish', () => {
|
||||
@ -144,175 +151,319 @@ describe('validation', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidShortText', () => {
|
||||
it('returns true for a short text', () => {
|
||||
expect(validation.isValidShortText('foo')).to.equal(true);
|
||||
expect(validation.isValidShortText('')).to.equal(true);
|
||||
expect(validation.isValidShortText(almostTooLongText)).to.equal(true);
|
||||
describe('short string', () => {
|
||||
it('accepts strings below 255 chars', () => {
|
||||
expect(isRight(ShortString.decode('aaaa'))).to.be.true;
|
||||
expect(isRight(ShortString.decode('1234'))).to.be.true;
|
||||
expect(isRight(ShortString.decode('some longish alphanumeric text 1236')))
|
||||
.to.be.true;
|
||||
expect(isRight(ShortString.decode('a'.repeat(255)))).to.be.true;
|
||||
});
|
||||
it('returns false for a text longer than 255 characters', () =>
|
||||
expect(validation.isValidShortText(almostTooLongText + 'a')).to.equal(
|
||||
false,
|
||||
));
|
||||
|
||||
it('returns false when passed a non-string', () => {
|
||||
expect(validation.isValidShortText({})).to.equal(false);
|
||||
expect(validation.isValidShortText(1)).to.equal(false);
|
||||
expect(validation.isValidShortText(null)).to.equal(false);
|
||||
expect(validation.isValidShortText(undefined)).to.equal(false);
|
||||
it('rejects non strings or strings longer than 255 chars', () => {
|
||||
expect(isRight(ShortString.decode(null))).to.be.false;
|
||||
expect(isRight(ShortString.decode(undefined))).to.be.false;
|
||||
expect(isRight(ShortString.decode([]))).to.be.false;
|
||||
expect(isRight(ShortString.decode(1234))).to.be.false;
|
||||
expect(isRight(ShortString.decode('a'.repeat(256)))).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidAppsObject', () => {
|
||||
it('returns true for a valid object', () => {
|
||||
const apps = {
|
||||
'1234': {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validation.isValidAppsObject(apps)).to.equal(true);
|
||||
describe('device name', () => {
|
||||
it('accepts strings below 255 chars', () => {
|
||||
expect(isRight(DeviceName.decode('aaaa'))).to.be.true;
|
||||
expect(isRight(DeviceName.decode('1234'))).to.be.true;
|
||||
expect(isRight(DeviceName.decode('some longish alphanumeric text 1236')))
|
||||
.to.be.true;
|
||||
expect(isRight(DeviceName.decode('a'.repeat(255)))).to.be.true;
|
||||
});
|
||||
|
||||
it('returns false with an invalid environment', () => {
|
||||
const apps = {
|
||||
'1234': {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: { ' baz': 'bat' },
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validation.isValidAppsObject(apps)).to.equal(false);
|
||||
it('rejects non strings or strings longer than 255 chars', () => {
|
||||
expect(isRight(DeviceName.decode(null))).to.be.false;
|
||||
expect(isRight(DeviceName.decode(undefined))).to.be.false;
|
||||
expect(isRight(DeviceName.decode([]))).to.be.false;
|
||||
expect(isRight(DeviceName.decode(1234))).to.be.false;
|
||||
expect(isRight(DeviceName.decode('a'.repeat(256)))).to.be.false;
|
||||
});
|
||||
|
||||
it('returns false with an invalid appId', () => {
|
||||
const apps = {
|
||||
boo: {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validation.isValidAppsObject(apps)).to.equal(false);
|
||||
});
|
||||
|
||||
it('returns true with a missing releaseId', () => {
|
||||
const apps = {
|
||||
'1234': {
|
||||
name: 'something',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validation.isValidAppsObject(apps)).to.equal(true);
|
||||
});
|
||||
|
||||
it('returns false with an invalid releaseId', () => {
|
||||
const apps = {
|
||||
'1234': {
|
||||
name: 'something',
|
||||
releaseId: '123a',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validation.isValidAppsObject(apps)).to.equal(false);
|
||||
it('rejects strings with new lines', () => {
|
||||
expect(isRight(DeviceName.decode('\n'))).to.be.false;
|
||||
expect(isRight(DeviceName.decode('aaaa\nbbbb'))).to.be.false;
|
||||
expect(isRight(DeviceName.decode('\n' + 'a'.repeat(254)))).to.be.false;
|
||||
expect(isRight(DeviceName.decode('\n' + 'a'.repeat(255)))).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('isValidDependentDevicesObject', () => {
|
||||
it('returns true for a valid object', () => {
|
||||
const devices: Dictionary<any> = {};
|
||||
devices[almostTooLongText] = {
|
||||
name: 'foo',
|
||||
apps: {
|
||||
'234': {
|
||||
config: { bar: 'baz' },
|
||||
environment: { dead: 'beef' },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validation.isValidDependentDevicesObject(devices)).to.equal(true);
|
||||
describe('string identifier', () => {
|
||||
it('accepts integer strings as input', () => {
|
||||
expect(isRight(StringIdentifier.decode('0'))).to.be.true;
|
||||
expect(isRight(StringIdentifier.decode('1234'))).to.be.true;
|
||||
expect(isRight(StringIdentifier.decode('51564189199'))).to.be.true;
|
||||
});
|
||||
|
||||
it('returns false with a missing apps object', () => {
|
||||
const devices = {
|
||||
abcd1234: {
|
||||
name: 'foo',
|
||||
},
|
||||
};
|
||||
expect(validation.isValidDependentDevicesObject(devices)).to.equal(false);
|
||||
it('rejects non strings or non numeric strings', () => {
|
||||
expect(isRight(StringIdentifier.decode(null))).to.be.false;
|
||||
expect(isRight(StringIdentifier.decode(undefined))).to.be.false;
|
||||
expect(isRight(StringIdentifier.decode([1]))).to.be.false;
|
||||
expect(isRight(StringIdentifier.decode('[1]'))).to.be.false;
|
||||
expect(isRight(StringIdentifier.decode(12345))).to.be.false;
|
||||
expect(isRight(StringIdentifier.decode(-12345))).to.be.false;
|
||||
expect(isRight(StringIdentifier.decode('aaaa'))).to.be.false;
|
||||
expect(isRight(StringIdentifier.decode('-125'))).to.be.false;
|
||||
expect(isRight(StringIdentifier.decode('0xffff'))).to.be.false;
|
||||
expect(isRight(StringIdentifier.decode('1544.333'))).to.be.false;
|
||||
});
|
||||
|
||||
it('returns false with an invalid environment', () => {
|
||||
const devices = {
|
||||
abcd1234: {
|
||||
name: 'foo',
|
||||
apps: {
|
||||
'234': {
|
||||
config: { bar: 'baz' },
|
||||
environment: { dead: 1 },
|
||||
it('decodes to a string', () => {
|
||||
expect(StringIdentifier.decode('12345'))
|
||||
.to.have.property('right')
|
||||
.that.equals('12345');
|
||||
});
|
||||
});
|
||||
|
||||
describe('numeric identifier', () => {
|
||||
it('accepts integers and integer strings as input', () => {
|
||||
expect(isRight(NumericIdentifier.decode('0'))).to.be.true;
|
||||
expect(isRight(NumericIdentifier.decode('1234'))).to.be.true;
|
||||
expect(isRight(NumericIdentifier.decode(1234))).to.be.true;
|
||||
expect(isRight(NumericIdentifier.decode(51564189199))).to.be.true;
|
||||
expect(isRight(NumericIdentifier.decode('51564189199'))).to.be.true;
|
||||
});
|
||||
|
||||
it('rejects non strings or non numeric strings', () => {
|
||||
expect(isRight(NumericIdentifier.decode(null))).to.be.false;
|
||||
expect(isRight(NumericIdentifier.decode(undefined))).to.be.false;
|
||||
expect(isRight(NumericIdentifier.decode([1]))).to.be.false;
|
||||
expect(isRight(NumericIdentifier.decode('[1]'))).to.be.false;
|
||||
expect(isRight(NumericIdentifier.decode('aaaa'))).to.be.false;
|
||||
expect(isRight(NumericIdentifier.decode('-125'))).to.be.false;
|
||||
expect(isRight(NumericIdentifier.decode('0xffff'))).to.be.false;
|
||||
expect(isRight(NumericIdentifier.decode('1544.333'))).to.be.false;
|
||||
expect(isRight(NumericIdentifier.decode(1544.333))).to.be.false;
|
||||
expect(isRight(NumericIdentifier.decode(-1544.333))).to.be.false;
|
||||
});
|
||||
|
||||
it('decodes to a number', () => {
|
||||
expect(NumericIdentifier.decode('12345'))
|
||||
.to.have.property('right')
|
||||
.that.equals(12345);
|
||||
expect(NumericIdentifier.decode(12345))
|
||||
.to.have.property('right')
|
||||
.that.equals(12345);
|
||||
});
|
||||
});
|
||||
|
||||
describe('target apps', () => {
|
||||
it('accept valid target apps', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
name: 'something',
|
||||
services: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validation.isValidDependentDevicesObject(devices)).to.equal(false);
|
||||
}),
|
||||
),
|
||||
'accepts apps with no no release id or commit',
|
||||
).to.be.true;
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {},
|
||||
},
|
||||
}),
|
||||
),
|
||||
'accepts apps with no services',
|
||||
).to.be.true;
|
||||
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: { MY_SERVICE_ENV_VAR: '123' },
|
||||
labels: { 'io.balena.features.supervisor-api': 'true' },
|
||||
},
|
||||
},
|
||||
volumes: {},
|
||||
networks: {},
|
||||
},
|
||||
}),
|
||||
),
|
||||
'accepts apps with a service',
|
||||
).to.be.true;
|
||||
});
|
||||
|
||||
it('returns false if the uuid is too long', () => {
|
||||
const devices: Dictionary<any> = {};
|
||||
devices[almostTooLongText + 'a'] = {
|
||||
name: 'foo',
|
||||
apps: {
|
||||
'234': {
|
||||
config: { bar: 'baz' },
|
||||
environment: { dead: 'beef' },
|
||||
},
|
||||
},
|
||||
};
|
||||
expect(validation.isValidDependentDevicesObject(devices)).to.equal(false);
|
||||
it('rejects app with invalid environment', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: { ' baz': 'bat' },
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('rejects app with invalid labels', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
'1234': {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: { ' not a valid #name': 'label value' },
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('rejects an invalid appId', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('rejects a commit that is too long', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'a'.repeat(256),
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
|
||||
it('rejects a service with an invalid docker name', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'a'.repeat(256),
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: ' not a valid name',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a commit that is too long', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
name: 'something',
|
||||
releaseId: 123,
|
||||
commit: 'a'.repeat(256),
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('rejects app with an invalid releaseId', () => {
|
||||
expect(
|
||||
isRight(
|
||||
TargetApps.decode({
|
||||
boo: {
|
||||
name: 'something',
|
||||
releaseId: '123aaa',
|
||||
commit: 'bar',
|
||||
services: {
|
||||
'45': {
|
||||
serviceName: 'bazbaz',
|
||||
imageId: 34,
|
||||
image: 'foo',
|
||||
environment: {},
|
||||
labels: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
),
|
||||
).to.be.false;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
@ -10,7 +10,7 @@ import * as images from '../src/compose/images';
|
||||
import App from '../src/compose/app';
|
||||
import Service from '../src/compose/service';
|
||||
import Network from '../src/compose/network';
|
||||
import { TargetApplication } from '../src/types/state';
|
||||
import { TargetApp } from '../src/types/state';
|
||||
|
||||
function getDefaultNetworks(appId: number) {
|
||||
return {
|
||||
@ -123,7 +123,7 @@ describe('DB Format', () => {
|
||||
|
||||
it('should write target states to the database', async () => {
|
||||
const target = await import('./data/state-endpoints/simple.json');
|
||||
const dbApps: { [appId: number]: TargetApplication } = {};
|
||||
const dbApps: { [appId: number]: TargetApp } = {};
|
||||
dbApps[1234] = {
|
||||
...target.local.apps[1234],
|
||||
};
|
||||
|
Loading…
x
Reference in New Issue
Block a user