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:
Felipe Lalanne 2021-07-30 21:54:30 +00:00
parent ca7c22d854
commit f6692ab918
11 changed files with 700 additions and 728 deletions

View File

@ -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,

View File

@ -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

View File

@ -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(

View File

@ -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);

View File

@ -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;

View File

@ -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);

View File

@ -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));
}
/**

View File

@ -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>;

View File

@ -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 };

View File

@ -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;
});
});
});

View File

@ -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],
};