mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-03 04:26:44 +00:00
6217546894
This also updates code to use the default import syntax instead of `import * as` when the imported module exposes a default. This is needed with the latest typescript version. Change-type: patch
739 lines
20 KiB
TypeScript
739 lines
20 KiB
TypeScript
import _ from 'lodash';
|
|
import { inspect } from 'util';
|
|
import { promises as fs } from 'fs';
|
|
|
|
import * as config from './config';
|
|
import * as db from './db';
|
|
import * as logger from './logger';
|
|
import * as dbus from './lib/dbus';
|
|
import type { EnvVarObject } from './types';
|
|
import { UnitNotLoadedError } from './lib/errors';
|
|
import { checkInt, checkTruthy } from './lib/validation';
|
|
import log from './lib/supervisor-console';
|
|
import * as configUtils from './config/utils';
|
|
import type { SchemaTypeKey } from './config/schema-type';
|
|
import { matchesAnyBootConfig } from './config/backends';
|
|
import type { ConfigBackend } from './config/backends/backend';
|
|
import { Odmdata } from './config/backends/odmdata';
|
|
import * as fsUtils from './lib/fs-utils';
|
|
import { pathOnRoot } from './lib/host-utils';
|
|
|
|
const vpnServiceName = 'openvpn';
|
|
|
|
// This indicates the file on the host /tmp directory that
|
|
// marks the need for a reboot. Since reboot is only triggered for now
|
|
// by some config changes, we leave this here for now. There is planned
|
|
// functionality to allow image installs to require reboots, at that moment
|
|
// this constant can be moved somewhere else
|
|
const REBOOT_BREADCRUMB = pathOnRoot(
|
|
'/tmp/balena-supervisor/reboot-after-apply',
|
|
);
|
|
|
|
interface ConfigOption {
|
|
envVarName: string;
|
|
varType: string;
|
|
defaultValue: string;
|
|
rebootRequired?: boolean;
|
|
}
|
|
|
|
// FIXME: Bring this and the deviceState and
|
|
// applicationState steps together
|
|
export interface ConfigStep {
|
|
// TODO: This is a bit of a mess, the DeviceConfig class shouldn't
|
|
// know that the reboot action exists as it is implemented by
|
|
// DeviceState. Fix this weird circular dependency
|
|
action: keyof DeviceActionExecutors | 'reboot' | 'noop';
|
|
humanReadableTarget?: Dictionary<string>;
|
|
target?: string | Dictionary<string>;
|
|
}
|
|
|
|
interface DeviceActionExecutorOpts {
|
|
initial?: boolean;
|
|
}
|
|
|
|
type DeviceActionExecutorFn = (
|
|
step: ConfigStep,
|
|
opts?: DeviceActionExecutorOpts,
|
|
) => Promise<void>;
|
|
|
|
interface DeviceActionExecutors {
|
|
changeConfig: DeviceActionExecutorFn;
|
|
setVPNEnabled: DeviceActionExecutorFn;
|
|
setBootConfig: DeviceActionExecutorFn;
|
|
setRebootBreadcrumb: DeviceActionExecutorFn;
|
|
}
|
|
|
|
const actionExecutors: DeviceActionExecutors = {
|
|
changeConfig: async (step) => {
|
|
try {
|
|
if (step.humanReadableTarget) {
|
|
logger.logConfigChange(step.humanReadableTarget);
|
|
}
|
|
if (!_.isObject(step.target)) {
|
|
throw new Error('Non-dictionary value passed to changeConfig');
|
|
}
|
|
// TODO: Change the typing of step so that the types automatically
|
|
// work out and we don't need this cast to any
|
|
await config.set(step.target as { [key in SchemaTypeKey]: any });
|
|
if (step.humanReadableTarget) {
|
|
logger.logConfigChange(step.humanReadableTarget, {
|
|
success: true,
|
|
});
|
|
}
|
|
} catch (err: any) {
|
|
if (step.humanReadableTarget) {
|
|
logger.logConfigChange(step.humanReadableTarget, {
|
|
err,
|
|
});
|
|
}
|
|
throw err;
|
|
}
|
|
},
|
|
setVPNEnabled: async (step, opts = {}) => {
|
|
const { initial = false } = opts;
|
|
if (typeof step.target !== 'string') {
|
|
throw new Error('Non-string value passed to setVPNEnabled');
|
|
}
|
|
const logValue = { SUPERVISOR_VPN_CONTROL: step.target };
|
|
if (!initial) {
|
|
logger.logConfigChange(logValue);
|
|
}
|
|
try {
|
|
await setVPNEnabled(step.target);
|
|
if (!initial) {
|
|
logger.logConfigChange(logValue, { success: true });
|
|
}
|
|
} catch (err: any) {
|
|
logger.logConfigChange(logValue, { err });
|
|
throw err;
|
|
}
|
|
},
|
|
setBootConfig: async (step) => {
|
|
if (!_.isObject(step.target)) {
|
|
throw new Error('Non-dictionary passed to DeviceConfig.setBootConfig');
|
|
}
|
|
const backends = await getConfigBackends();
|
|
for (const backend of backends) {
|
|
await setBootConfig(backend, step.target as Dictionary<string>);
|
|
}
|
|
},
|
|
setRebootBreadcrumb: async () => {
|
|
// Just create the file. The last step in the target state calculation will check
|
|
// the file and create a reboot step
|
|
await fsUtils.touch(REBOOT_BREADCRUMB);
|
|
},
|
|
};
|
|
|
|
const configBackends: ConfigBackend[] = [];
|
|
|
|
const configKeys: Dictionary<ConfigOption> = {
|
|
appUpdatePollInterval: {
|
|
envVarName: 'SUPERVISOR_POLL_INTERVAL',
|
|
varType: 'int',
|
|
defaultValue: '900000',
|
|
},
|
|
instantUpdates: {
|
|
envVarName: 'SUPERVISOR_INSTANT_UPDATE_TRIGGER',
|
|
varType: 'bool',
|
|
defaultValue: 'true',
|
|
},
|
|
localMode: {
|
|
envVarName: 'SUPERVISOR_LOCAL_MODE',
|
|
varType: 'bool',
|
|
defaultValue: 'false',
|
|
},
|
|
connectivityCheckEnabled: {
|
|
envVarName: 'SUPERVISOR_CONNECTIVITY_CHECK',
|
|
varType: 'bool',
|
|
defaultValue: 'true',
|
|
},
|
|
loggingEnabled: {
|
|
envVarName: 'SUPERVISOR_LOG_CONTROL',
|
|
varType: 'bool',
|
|
defaultValue: 'true',
|
|
},
|
|
delta: {
|
|
envVarName: 'SUPERVISOR_DELTA',
|
|
varType: 'bool',
|
|
defaultValue: 'false',
|
|
},
|
|
deltaRequestTimeout: {
|
|
envVarName: 'SUPERVISOR_DELTA_REQUEST_TIMEOUT',
|
|
varType: 'int',
|
|
defaultValue: '59000',
|
|
},
|
|
deltaApplyTimeout: {
|
|
envVarName: 'SUPERVISOR_DELTA_APPLY_TIMEOUT',
|
|
varType: 'int',
|
|
defaultValue: '0',
|
|
},
|
|
deltaRetryCount: {
|
|
envVarName: 'SUPERVISOR_DELTA_RETRY_COUNT',
|
|
varType: 'int',
|
|
defaultValue: '30',
|
|
},
|
|
deltaRetryInterval: {
|
|
envVarName: 'SUPERVISOR_DELTA_RETRY_INTERVAL',
|
|
varType: 'int',
|
|
defaultValue: '10000',
|
|
},
|
|
deltaVersion: {
|
|
envVarName: 'SUPERVISOR_DELTA_VERSION',
|
|
varType: 'int',
|
|
defaultValue: '2',
|
|
},
|
|
lockOverride: {
|
|
envVarName: 'SUPERVISOR_OVERRIDE_LOCK',
|
|
varType: 'bool',
|
|
defaultValue: 'false',
|
|
},
|
|
persistentLogging: {
|
|
envVarName: 'SUPERVISOR_PERSISTENT_LOGGING',
|
|
varType: 'bool',
|
|
defaultValue: 'false',
|
|
rebootRequired: true,
|
|
},
|
|
firewallMode: {
|
|
envVarName: 'HOST_FIREWALL_MODE',
|
|
varType: 'string',
|
|
defaultValue: 'off',
|
|
},
|
|
hostDiscoverability: {
|
|
envVarName: 'HOST_DISCOVERABILITY',
|
|
varType: 'bool',
|
|
defaultValue: 'true',
|
|
},
|
|
hardwareMetrics: {
|
|
envVarName: 'SUPERVISOR_HARDWARE_METRICS',
|
|
varType: 'bool',
|
|
defaultValue: 'true',
|
|
},
|
|
};
|
|
|
|
export const validKeys = [
|
|
'SUPERVISOR_VPN_CONTROL',
|
|
'OVERRIDE_LOCK',
|
|
..._.map(configKeys, 'envVarName'),
|
|
];
|
|
|
|
const rateLimits: Dictionary<{
|
|
duration: number;
|
|
lastAttempt: number | null;
|
|
}> = {
|
|
setVPNEnabled: {
|
|
// Only try to switch the VPN once an hour
|
|
duration: 60 * 60 * 1000,
|
|
lastAttempt: null,
|
|
},
|
|
};
|
|
|
|
async function getConfigBackends(): Promise<ConfigBackend[]> {
|
|
// Exit early if we already have a list
|
|
if (configBackends.length > 0) {
|
|
return configBackends;
|
|
}
|
|
// Get all the configurable backends this device supports
|
|
const backends = await configUtils.getSupportedBackends();
|
|
// Initialize each backend
|
|
for (const backend of backends) {
|
|
await backend.initialise();
|
|
}
|
|
// Return list of initialized ConfigBackends
|
|
return backends;
|
|
}
|
|
|
|
export async function setTarget(
|
|
target: Dictionary<string>,
|
|
trx?: db.Transaction,
|
|
): Promise<void> {
|
|
const $db = trx ?? db.models.bind(db);
|
|
|
|
const formatted = formatConfigKeys(target);
|
|
// check for legacy keys
|
|
if (formatted['OVERRIDE_LOCK'] != null) {
|
|
formatted['SUPERVISOR_OVERRIDE_LOCK'] = formatted['OVERRIDE_LOCK'];
|
|
}
|
|
|
|
const confToUpdate = {
|
|
targetValues: JSON.stringify(formatted),
|
|
};
|
|
|
|
await $db('deviceConfig').update(confToUpdate);
|
|
}
|
|
|
|
export async function getTarget({
|
|
initial = false,
|
|
}: { initial?: boolean } = {}) {
|
|
const [unmanaged, [devConfig]] = await Promise.all([
|
|
config.get('unmanaged'),
|
|
db.models('deviceConfig').select('targetValues'),
|
|
]);
|
|
|
|
let conf: Dictionary<string>;
|
|
try {
|
|
conf = JSON.parse(devConfig.targetValues);
|
|
} catch (e: any) {
|
|
throw new Error(`Corrupted supervisor database! Error: ${e.message}`);
|
|
}
|
|
if (initial || conf.SUPERVISOR_VPN_CONTROL == null) {
|
|
conf.SUPERVISOR_VPN_CONTROL = 'true';
|
|
}
|
|
if (unmanaged && conf.SUPERVISOR_LOCAL_MODE == null) {
|
|
conf.SUPERVISOR_LOCAL_MODE = 'true';
|
|
}
|
|
|
|
_.defaults(
|
|
conf,
|
|
_(configKeys).mapKeys('envVarName').mapValues('defaultValue').value(),
|
|
);
|
|
|
|
return conf;
|
|
}
|
|
|
|
export async function getCurrent(): Promise<Dictionary<string>> {
|
|
// Build a Dictionary of currently set config values
|
|
const currentConf: Dictionary<string> = {};
|
|
// Get environment variables
|
|
const conf = await config.getMany(
|
|
['deviceType'].concat(_.keys(configKeys)) as SchemaTypeKey[],
|
|
);
|
|
// Add each value
|
|
for (const key of _.keys(configKeys)) {
|
|
const { envVarName } = configKeys[key];
|
|
const confValue = conf[key as SchemaTypeKey];
|
|
currentConf[envVarName] = confValue != null ? confValue.toString() : '';
|
|
}
|
|
// Add VPN information
|
|
currentConf['SUPERVISOR_VPN_CONTROL'] = (await isVPNEnabled())
|
|
? 'true'
|
|
: 'false';
|
|
// Get list of configurable backends
|
|
const backends = await getConfigBackends();
|
|
// Add each backends configurable values
|
|
for (const backend of backends) {
|
|
Object.assign(currentConf, await getBootConfig(backend));
|
|
}
|
|
// Return compiled configuration
|
|
return currentConf;
|
|
}
|
|
|
|
export function formatConfigKeys(conf: {
|
|
[key: string]: any;
|
|
}): Dictionary<any> {
|
|
const namespaceRegex = /^BALENA_(.*)/;
|
|
const legacyNamespaceRegex = /^RESIN_(.*)/;
|
|
const confFromNamespace = configUtils.filterNamespaceFromConfig(
|
|
namespaceRegex,
|
|
conf,
|
|
);
|
|
const confFromLegacyNamespace = configUtils.filterNamespaceFromConfig(
|
|
legacyNamespaceRegex,
|
|
conf,
|
|
);
|
|
const noNamespaceConf = _.pickBy(conf, (_v, k) => {
|
|
return !_.startsWith(k, 'RESIN_') && !_.startsWith(k, 'BALENA_');
|
|
});
|
|
const confWithoutNamespace = _.defaults(
|
|
confFromNamespace,
|
|
confFromLegacyNamespace,
|
|
noNamespaceConf,
|
|
);
|
|
|
|
return _.pickBy(
|
|
confWithoutNamespace,
|
|
(_v, k) => validKeys.includes(k) || matchesAnyBootConfig(k),
|
|
);
|
|
}
|
|
|
|
export function getDefaults() {
|
|
return _.extend(
|
|
{
|
|
SUPERVISOR_VPN_CONTROL: 'true',
|
|
},
|
|
_.mapValues(_.mapKeys(configKeys, 'envVarName'), 'defaultValue'),
|
|
);
|
|
}
|
|
|
|
export function resetRateLimits() {
|
|
_.each(rateLimits, (action) => {
|
|
action.lastAttempt = null;
|
|
});
|
|
}
|
|
|
|
export function bootConfigChangeRequired(
|
|
configBackend: ConfigBackend,
|
|
current: Dictionary<string>,
|
|
target: Dictionary<string>,
|
|
deviceType: string,
|
|
): boolean {
|
|
const targetBootConfig = configUtils.envToBootConfig(configBackend, target);
|
|
const currentBootConfig = configUtils.envToBootConfig(configBackend, current);
|
|
|
|
// Some devices require specific overlays, here we apply them
|
|
configBackend.ensureRequiredConfig(deviceType, targetBootConfig);
|
|
|
|
// Search for any unsupported values
|
|
_.each(targetBootConfig, (value, key) => {
|
|
if (
|
|
!configBackend.isSupportedConfig(key) &&
|
|
currentBootConfig[key] !== value
|
|
) {
|
|
const err = `Attempt to change blacklisted config value ${key}`;
|
|
logger.logSystemMessage(err, { error: err }, 'Apply boot config error');
|
|
throw new Error(err);
|
|
}
|
|
});
|
|
|
|
if (!_.isEqual(currentBootConfig, targetBootConfig)) {
|
|
// Check if the only difference is the targetBootConfig not containing a special case
|
|
const SPECIAL_CASE = 'configuration'; // ODMDATA Mode for TX2 devices
|
|
if (!(SPECIAL_CASE in targetBootConfig)) {
|
|
// Create a copy to modify
|
|
const targetCopy = structuredClone(targetBootConfig);
|
|
// Add current value to simulate if the value was set in the cloud on provision
|
|
targetCopy[SPECIAL_CASE] = currentBootConfig[SPECIAL_CASE];
|
|
if (_.isEqual(targetCopy, currentBootConfig)) {
|
|
// This proves the only difference is ODMDATA configuration is not set in target config.
|
|
// This special case is to allow devices that upgrade to SV with ODMDATA support
|
|
// and have no set a ODMDATA configuration in the cloud yet.
|
|
// Normally on provision this value would have been sent to the cloud.
|
|
return false; // (no change is required)
|
|
}
|
|
}
|
|
// Change is required because configs do not match
|
|
return true;
|
|
}
|
|
|
|
// Return false (no change is required)
|
|
return false;
|
|
}
|
|
|
|
function getConfigSteps(
|
|
current: Dictionary<string>,
|
|
target: Dictionary<string>,
|
|
) {
|
|
const configChanges: Dictionary<string> = {};
|
|
const humanReadableConfigChanges: Dictionary<string> = {};
|
|
let reboot = false;
|
|
const steps: ConfigStep[] = [];
|
|
|
|
_.each(
|
|
configKeys,
|
|
(
|
|
{ envVarName, varType, rebootRequired: $rebootRequired, defaultValue },
|
|
key,
|
|
) => {
|
|
let changingValue: null | string = null;
|
|
// Test if the key is different
|
|
if (!configTest(varType, current[envVarName], target[envVarName])) {
|
|
// Check that the difference is not due to the variable having an invalid
|
|
// value set from the cloud
|
|
if (config.valueIsValid(key as SchemaTypeKey, target[envVarName])) {
|
|
// Save the change if it is both valid and different
|
|
changingValue = target[envVarName];
|
|
} else {
|
|
if (!configTest(varType, current[envVarName], defaultValue)) {
|
|
const message = `Warning: Ignoring invalid device configuration value for ${key}, value: ${inspect(
|
|
target[envVarName],
|
|
)}. Falling back to default (${defaultValue})`;
|
|
logger.logSystemMessage(
|
|
message,
|
|
{ key: envVarName, value: target[envVarName] },
|
|
'invalidDeviceConfig',
|
|
false,
|
|
);
|
|
// Set it to the default value if it is different to the current
|
|
changingValue = defaultValue;
|
|
}
|
|
}
|
|
if (changingValue != null) {
|
|
configChanges[key] = changingValue;
|
|
humanReadableConfigChanges[envVarName] = changingValue;
|
|
reboot = $rebootRequired || reboot;
|
|
}
|
|
}
|
|
},
|
|
);
|
|
|
|
if (!_.isEmpty(configChanges)) {
|
|
if (reboot) {
|
|
steps.push({ action: 'setRebootBreadcrumb' });
|
|
}
|
|
|
|
steps.push({
|
|
action: 'changeConfig',
|
|
target: configChanges,
|
|
humanReadableTarget: humanReadableConfigChanges,
|
|
});
|
|
}
|
|
|
|
return steps;
|
|
}
|
|
|
|
async function getVPNSteps(
|
|
current: Dictionary<string>,
|
|
target: Dictionary<string>,
|
|
) {
|
|
const { unmanaged } = await config.getMany(['unmanaged']);
|
|
|
|
let steps: ConfigStep[] = [];
|
|
|
|
// Check for special case actions for the VPN
|
|
if (
|
|
!unmanaged &&
|
|
!_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) &&
|
|
checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL')
|
|
) {
|
|
steps.push({
|
|
action: 'setVPNEnabled',
|
|
target: target['SUPERVISOR_VPN_CONTROL'],
|
|
});
|
|
}
|
|
|
|
// TODO: the only step that requires rate limiting is setVPNEnabled
|
|
// do not use rate limiting in the future as it probably will change.
|
|
// The reason rate limiting is needed for this step is because the dbus
|
|
// API does not wait for the service response when a unit is started/stopped.
|
|
// This would cause too many requests on systemd and a possible error.
|
|
// Promisifying the dbus api to wait for the response would be the right solution
|
|
const now = Date.now();
|
|
steps = _.map(steps, (step) => {
|
|
const action = step.action;
|
|
if (action in rateLimits) {
|
|
const lastAttempt = rateLimits[action].lastAttempt;
|
|
rateLimits[action].lastAttempt = now;
|
|
|
|
// If this step should be rate limited, we replace it with a noop.
|
|
// We do this instead of removing it, as we don't actually want the
|
|
// state engine to think that it's successfully applied the target state,
|
|
// as it won't reattempt the change until the target state changes
|
|
if (
|
|
lastAttempt != null &&
|
|
Date.now() - lastAttempt < rateLimits[action].duration
|
|
) {
|
|
return { action: 'noop' } as ConfigStep;
|
|
}
|
|
}
|
|
return step;
|
|
});
|
|
|
|
return steps;
|
|
}
|
|
|
|
async function getBackendSteps(
|
|
current: Dictionary<string>,
|
|
target: Dictionary<string>,
|
|
) {
|
|
const steps: ConfigStep[] = [];
|
|
const backends = await getConfigBackends();
|
|
const { deviceType } = await config.getMany(['deviceType']);
|
|
|
|
// Check for required bootConfig changes
|
|
for (const backend of backends) {
|
|
if (changeRequired(backend, current, target, deviceType)) {
|
|
steps.push({
|
|
action: 'setBootConfig',
|
|
target,
|
|
});
|
|
}
|
|
}
|
|
|
|
return [
|
|
// All backend steps require a reboot
|
|
...(steps.length > 0
|
|
? [{ action: 'setRebootBreadcrumb' } as ConfigStep]
|
|
: []),
|
|
...steps,
|
|
];
|
|
}
|
|
|
|
async function isRebootRequired() {
|
|
const hasBreadcrumb = await fsUtils.exists(REBOOT_BREADCRUMB);
|
|
if (hasBreadcrumb) {
|
|
const stats = await fs.stat(REBOOT_BREADCRUMB);
|
|
|
|
// If the breadcrumb exists and the last modified time is greater than the
|
|
// boot time, that means we need to reboot
|
|
return stats.mtime.getTime() > fsUtils.getBootTime().getTime();
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export async function getRequiredSteps(
|
|
currentState: { local?: { config?: EnvVarObject } },
|
|
targetState: { local?: { config: EnvVarObject } },
|
|
): Promise<ConfigStep[]> {
|
|
const current = currentState?.local?.config ?? {};
|
|
const target = targetState?.local?.config ?? {};
|
|
|
|
const configSteps = getConfigSteps(current, target);
|
|
const steps = [
|
|
...configSteps,
|
|
...(await getVPNSteps(current, target)),
|
|
|
|
// Only apply backend steps if no more config changes are left since
|
|
// changing config.json may restart the supervisor
|
|
...(configSteps.length > 0 &&
|
|
// if any config step is a not 'noop' step, skip the backend steps
|
|
configSteps.filter((s) => s.action !== 'noop').length > 0
|
|
? // Set a 'noop' action so the apply function knows to retry
|
|
[{ action: 'noop' } as ConfigStep]
|
|
: await getBackendSteps(current, target)),
|
|
];
|
|
|
|
// Check if there is either no steps, or they are all
|
|
// noops, and we need to reboot. We want to do this
|
|
// because in a preloaded setting with no internet
|
|
// connection, the device will try to start containers
|
|
// before any boot config has been applied, which can
|
|
// cause problems
|
|
const rebootRequired = await isRebootRequired();
|
|
if (_.every(steps, { action: 'noop' }) && rebootRequired) {
|
|
steps.push({
|
|
action: 'reboot',
|
|
});
|
|
}
|
|
|
|
return steps;
|
|
}
|
|
|
|
function changeRequired(
|
|
configBackend: ConfigBackend,
|
|
currentConfig: Dictionary<string>,
|
|
targetConfig: Dictionary<string>,
|
|
deviceType: string,
|
|
): boolean {
|
|
let aChangeIsRequired = false;
|
|
try {
|
|
aChangeIsRequired = bootConfigChangeRequired(
|
|
configBackend,
|
|
currentConfig,
|
|
targetConfig,
|
|
deviceType,
|
|
);
|
|
} catch (e) {
|
|
switch (e) {
|
|
case 'Value missing from target configuration.':
|
|
if (configBackend instanceof Odmdata) {
|
|
// In this special case, devices with ODMDATA support may have
|
|
// empty configuration options in the target if they upgraded to a SV
|
|
// version with ODMDATA support and didn't set a value in the cloud.
|
|
// If this is the case then we will update the cloud with the device's
|
|
// current config and then continue without an error
|
|
aChangeIsRequired = false;
|
|
} else {
|
|
log.debug(`
|
|
The device has a configuration setting that the cloud does not have set.\nNo configurations for this backend will be set.`);
|
|
// Set changeRequired to false so we do not get stuck in a loop trying to fix this mismatch
|
|
aChangeIsRequired = false;
|
|
}
|
|
throw e;
|
|
default:
|
|
throw e;
|
|
}
|
|
}
|
|
return aChangeIsRequired;
|
|
}
|
|
|
|
export function executeStepAction(
|
|
step: ConfigStep,
|
|
opts: DeviceActionExecutorOpts,
|
|
) {
|
|
if (step.action !== 'reboot' && step.action !== 'noop') {
|
|
return actionExecutors[step.action](step, opts);
|
|
}
|
|
}
|
|
|
|
export function isValidAction(action: string): boolean {
|
|
return _.keys(actionExecutors).includes(action);
|
|
}
|
|
|
|
export async function getBootConfig(
|
|
backend: ConfigBackend | null,
|
|
): Promise<EnvVarObject> {
|
|
if (backend == null) {
|
|
return {};
|
|
}
|
|
const conf = await backend.getBootConfig();
|
|
return configUtils.bootConfigToEnv(backend, conf);
|
|
}
|
|
|
|
// Exported for tests
|
|
export async function setBootConfig(
|
|
backend: ConfigBackend | null,
|
|
target: Dictionary<string>,
|
|
) {
|
|
if (backend == null) {
|
|
return false;
|
|
}
|
|
|
|
const conf = configUtils.envToBootConfig(backend, target);
|
|
logger.logSystemMessage(
|
|
`Applying boot config: ${JSON.stringify(conf)}`,
|
|
{},
|
|
'Apply boot config in progress',
|
|
);
|
|
|
|
// Ensure the required target config is available
|
|
backend.ensureRequiredConfig(await config.get('deviceType'), conf);
|
|
|
|
try {
|
|
await backend.setBootConfig(conf);
|
|
logger.logSystemMessage(
|
|
`Applied boot config: ${JSON.stringify(conf)}`,
|
|
{},
|
|
'Apply boot config success',
|
|
);
|
|
return true;
|
|
} catch (err) {
|
|
logger.logSystemMessage(
|
|
`Error setting boot config: ${err}`,
|
|
{ error: err },
|
|
'Apply boot config error',
|
|
);
|
|
throw err;
|
|
}
|
|
}
|
|
|
|
async function isVPNEnabled(): Promise<boolean> {
|
|
try {
|
|
const activeState = await dbus.serviceActiveState(vpnServiceName);
|
|
return !['inactive', 'deactivating'].includes(activeState);
|
|
} catch (e: any) {
|
|
if (UnitNotLoadedError(e)) {
|
|
return false;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
async function setVPNEnabled(value: string | boolean = true) {
|
|
const enable = checkTruthy(value);
|
|
if (enable) {
|
|
await dbus.startService(vpnServiceName);
|
|
} else {
|
|
await dbus.stopService(vpnServiceName);
|
|
}
|
|
}
|
|
|
|
function configTest(method: string, a: string, b: string): boolean {
|
|
switch (method) {
|
|
case 'bool':
|
|
return checkTruthy(a) === checkTruthy(b);
|
|
case 'int':
|
|
return checkInt(a) === checkInt(b);
|
|
case 'string':
|
|
return a === b;
|
|
default:
|
|
throw new Error('Incorrect datatype passed to DeviceConfig.configTest');
|
|
}
|
|
}
|
|
|
|
function checkBoolChanged(
|
|
current: Dictionary<string>,
|
|
target: Dictionary<string>,
|
|
key: string,
|
|
): boolean {
|
|
return checkTruthy(current[key]) !== checkTruthy(target[key]);
|
|
}
|