Merge pull request #1015 from balena-io/1014-delay-logstream

Dont setup a logstream until we're provisioned
This commit is contained in:
CameronDiver 2019-07-09 10:00:50 -07:00 committed by GitHub
commit e46fec2118
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 303 additions and 209 deletions

81
package-lock.json generated
View File

@ -243,9 +243,9 @@
"dev": true
},
"@types/dockerode": {
"version": "2.5.13",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.13.tgz",
"integrity": "sha512-TgSP2nhCZgKOYcuMyuUs1SvLWZCd20z6SczPadLL11iCEEMDiblE23cwIyc1BR7FPpntwT9Z+IcdFNAXUAKmKQ==",
"version": "2.5.20",
"resolved": "https://registry.npmjs.org/@types/dockerode/-/dockerode-2.5.20.tgz",
"integrity": "sha512-g2eM9q+pur7iZc897K/OSq8sCL7VdVcCPzNkdeTukUokfvgl3TaP+nT7G8BMpnSSojrJFKl7VdTciP7hbVgfKA==",
"dev": true,
"requires": {
"@types/node": "*"
@ -1609,8 +1609,17 @@
"deep-equal": "^1.0.1",
"dns-equal": "^1.0.0",
"dns-txt": "^2.0.2",
"multicast-dns": "git+https://github.com/resin-io-modules/multicast-dns.git#a15c63464eb43e8925b187ed5cb9de6892e8aacc",
"multicast-dns-service-types": "^1.1.0"
},
"dependencies": {
"multicast-dns": {
"version": "git+https://github.com/resin-io-modules/multicast-dns.git#a15c63464eb43e8925b187ed5cb9de6892e8aacc",
"from": "git+https://github.com/resin-io-modules/multicast-dns.git#a15c63464eb43e8925b187ed5cb9de6892e8aacc",
"requires": {
"dns-packet": "^1.0.1",
"thunky": "^0.1.0"
}
}
}
},
"brace-expansion": {
@ -2794,16 +2803,6 @@
"integrity": "sha1-s55/HabrCnW6nBcySzR1PEfgZU0=",
"dev": true
},
"dns-packet": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/dns-packet/-/dns-packet-1.3.1.tgz",
"integrity": "sha512-0UxfQkMhYAUaZI+xrNZOz/as5KgDU0M/fQ9b6SpkyLbk3GEswDi6PADJVaYJradtRVsRIlF1zLyOodbcTCDzUg==",
"dev": true,
"requires": {
"ip": "^1.1.0",
"safe-buffer": "^5.0.1"
}
},
"dns-txt": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/dns-txt/-/dns-txt-2.0.2.tgz",
@ -3993,8 +3992,7 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"aproba": {
"version": "1.2.0",
@ -4015,14 +4013,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -4037,14 +4033,12 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"console-control-strings": {
"version": "1.1.0",
@ -4166,8 +4160,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"ini": {
"version": "1.3.5",
@ -4179,7 +4172,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -4194,7 +4186,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -4202,8 +4193,7 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"minipass": {
"version": "2.3.5",
@ -4307,8 +4297,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"object-assign": {
"version": "4.1.1",
@ -4460,7 +4449,6 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -7319,15 +7307,6 @@
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=",
"dev": true
},
"multicast-dns": {
"version": "git+https://github.com/resin-io-modules/multicast-dns.git#a15c63464eb43e8925b187ed5cb9de6892e8aacc",
"from": "git+https://github.com/resin-io-modules/multicast-dns.git#listen-on-all-interfaces",
"dev": true,
"requires": {
"dns-packet": "^1.0.1",
"thunky": "^0.1.0"
}
},
"multicast-dns-service-types": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/multicast-dns-service-types/-/multicast-dns-service-types-1.1.0.tgz",
@ -10074,12 +10053,6 @@
"xtend": "~4.0.1"
}
},
"thunky": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-0.1.0.tgz",
"integrity": "sha1-vzAUaCTituZ7Dy16Ssi+smkIaE4=",
"dev": true
},
"tildify": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/tildify/-/tildify-1.2.0.tgz",
@ -10810,14 +10783,12 @@
"balanced-match": {
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"brace-expansion": {
"version": "1.1.11",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
@ -10837,8 +10808,7 @@
"concat-map": {
"version": "0.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"console-control-strings": {
"version": "1.1.0",
@ -10986,7 +10956,6 @@
"version": "3.0.4",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"brace-expansion": "^1.1.7"
}
@ -10994,8 +10963,7 @@
"minimist": {
"version": "0.0.8",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"minipass": {
"version": "2.3.5",
@ -11099,8 +11067,7 @@
"number-is-nan": {
"version": "1.0.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"object-assign": {
"version": "4.1.1",

View File

@ -40,7 +40,7 @@
"@types/bluebird": "^3.5.25",
"@types/chai": "^4.1.7",
"@types/common-tags": "^1.8.0",
"@types/dockerode": "^2.5.13",
"@types/dockerode": "^2.5.20",
"@types/event-stream": "^3.3.34",
"@types/express": "^4.11.1",
"@types/knex": "^0.14.14",

View File

@ -9,7 +9,7 @@ import { PinejsClientRequest, StatusError } from 'pinejs-client-request';
import * as deviceRegister from 'resin-register-device';
import * as url from 'url';
import Config from './config';
import Config, { ConfigType } from './config';
import Database from './db';
import DeviceConfig from './device-config';
import { EventTracker } from './event-tracker';
@ -25,10 +25,10 @@ import { request, requestOpts } from './lib/request';
import { writeLock } from './lib/update-lock';
import { DeviceApplicationState } from './types/state';
import { SchemaReturn as ConfigSchemaType } from './config/schema-type';
import log from './lib/supervisor-console';
import DeviceState = require('./device-state');
const REPORT_SUCCESS_DELAY = 1000;
const MAX_REPORT_RETRY_DELAY = 60000;
@ -42,11 +42,7 @@ export interface APIBinderConstructOpts {
config: Config;
// FIXME: Remove this
db: Database;
// TODO: Typings
deviceState: {
deviceConfig: DeviceConfig;
[key: string]: any;
};
deviceState: DeviceState;
eventTracker: EventTracker;
}
@ -67,7 +63,7 @@ interface DeviceTag {
value: string;
}
type KeyExchangeOpts = ConfigSchemaType<'provisioningOptions'>;
type KeyExchangeOpts = ConfigType<'provisioningOptions'>;
export class APIBinder {
public router: express.Router;
@ -79,7 +75,7 @@ export class APIBinder {
};
private eventTracker: EventTracker;
private balenaApi: PinejsClientRequest | null = null;
public balenaApi: PinejsClientRequest | null = null;
private cachedBalenaApi: PinejsClientRequest | null = null;
private lastReportedState: DeviceApplicationState = {
local: {},
@ -532,7 +528,9 @@ export class APIBinder {
// the watchdog to kill the supervisor - and killing the supervisor will
// not help in this situation
log.error(
'Non-200 response from the API! Status code: ${e.statusCode} - message:',
`Non-200 response from the API! Status code: ${
e.statusCode
} - message:`,
e,
);
} else {
@ -972,3 +970,5 @@ export class APIBinder {
return router;
}
}
export default APIBinder;

View File

@ -1,15 +0,0 @@
do ->
# Make NodeJS RFC 3484 compliant for properly handling IPv6
# See: https://github.com/nodejs/node/pull/14731
# https://github.com/nodejs/node/pull/17793
dns = require('dns')
{ lookup } = dns
dns.lookup = (name, opts, cb) ->
if typeof cb isnt 'function'
return lookup(name, { verbatim: true }, opts)
return lookup(name, Object.assign({ verbatim: true }, opts), cb)
Supervisor = require './supervisor'
supervisor = new Supervisor()
supervisor.init()

21
src/app.ts Normal file
View File

@ -0,0 +1,21 @@
// This was originally wrapped in a do block in
// coffeescript, and it's not clear now why that was the
// case, so I'm going to maintain that behaviour
(() => {
// Make NodeJS RFC 3484 compliant for properly handling IPv6
// See: https://github.com/nodejs/node/pull/14731
// https://github.com/nodejs/node/pull/17793
const dns = require('dns');
const { lookup } = dns;
dns.lookup = (name: string, opts: any, cb: (err?: Error) => void) => {
if (typeof cb !== 'function') {
return lookup(name, { verbatim: true }, opts);
}
return lookup(name, Object.assign({ verbatim: true }, opts), cb);
};
})();
import Supervisor from './supervisor';
const supervisor = new Supervisor();
supervisor.init();

View File

@ -45,6 +45,8 @@ export class ApplicationManager extends EventEmitter {
public db: DB;
public images: Images;
public proxyvisor: any;
public getCurrentApp(appId: number): Bluebird<Application | null>;
// TODO: This actually returns an object, but we don't need the values just yet

View File

@ -23,14 +23,20 @@ import {
interface ConfigOpts {
db: DB;
configPath: string;
configPath?: string;
}
type ConfigMap<T extends SchemaTypeKey> = { [key in T]: SchemaReturn<key> };
type ConfigChangeMap<T extends SchemaTypeKey> = {
export type ConfigMap<T extends SchemaTypeKey> = {
[key in T]: SchemaReturn<key>
};
export type ConfigChangeMap<T extends SchemaTypeKey> = {
[key in T]?: SchemaReturn<key>
};
// Export this type renamed, for storing config keys
export type ConfigKey = SchemaTypeKey;
export type ConfigType<T extends ConfigKey> = SchemaReturn<T>;
interface ConfigEvents {
change: ConfigChangeMap<SchemaTypeKey>;
}

31
src/device-state.d.ts vendored Normal file
View File

@ -0,0 +1,31 @@
import { EventEmitter } from 'events';
import { Router } from 'express';
import ApplicationManager from './application-manager';
import Config from './config';
import Database from './db';
import DeviceConfig from './device-config';
import EventTracker from './event-tracker';
import Logger from './logger';
// This is a very incomplete definition of the device state
// class, which should be rewritten in typescript soon
class DeviceState extends EventEmitter {
public applications: ApplicationManager;
public router: Router;
public deviceConfig: DeviceConfig;
public constructor(args: {
config: Config;
db: Database;
eventTracker: EventTracker;
logger: Logger;
});
public healthcheck(): Promise<void>;
public normaliseLegacy(client: PinejsClientRequest): Promise<void>;
public async init();
}
export = DeviceState;

View File

@ -5,16 +5,17 @@ import * as memoizee from 'memoizee';
import Mixpanel = require('mixpanel');
import { ConfigType } from './config';
import log from './lib/supervisor-console';
import supervisorVersion = require('./lib/supervisor-version');
export type EventTrackProperties = Dictionary<any>;
interface InitArgs {
uuid: string;
unmanaged: boolean;
mixpanelHost: { host: string; path: string } | null;
mixpanelToken: string;
uuid: ConfigType<'uuid'>;
unmanaged: ConfigType<'unmanaged'>;
mixpanelHost: ConfigType<'mixpanelHost'>;
mixpanelToken: ConfigType<'mixpanelToken'>;
}
// The minimum amount of time to wait between sending
@ -112,3 +113,5 @@ export class EventTracker {
return _.merge({}, properties, this.defaultProperties);
}
}
export default EventTracker;

View File

@ -1,6 +1,7 @@
import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import Config, { ConfigChangeMap, ConfigKey, ConfigType } from './config';
import DB from './db';
import { EventTracker } from './event-tracker';
import Docker from './lib/docker-utils';
@ -18,12 +19,14 @@ import LogMonitor from './logging/monitor';
import log from './lib/supervisor-console';
interface LoggerSetupOptions {
apiEndpoint: string;
uuid: string;
deviceApiKey: string;
unmanaged: boolean;
apiEndpoint: ConfigType<'apiEndpoint'>;
uuid: ConfigType<'uuid'>;
deviceApiKey: ConfigType<'deviceApiKey'>;
unmanaged: ConfigType<'unmanaged'>;
localMode: ConfigType<'localMode'>;
enableLogs: boolean;
localMode: boolean;
config: Config;
}
type LogEventObject = Dictionary<any> | null;
@ -57,6 +60,7 @@ export class Logger {
unmanaged,
enableLogs,
localMode,
config,
}: LoggerSetupOptions) {
this.balenaBackend = new BalenaLogBackend(apiEndpoint, uuid, deviceApiKey);
this.localBackend = new LocalLogBackend();
@ -65,6 +69,44 @@ export class Logger {
this.backend.unmanaged = unmanaged;
this.backend.publishEnabled = enableLogs;
// Only setup a config listener if we have to
if (!this.balenaBackend.isIntialised()) {
const handler = async (values: ConfigChangeMap<ConfigKey>) => {
if (
'uuid' in values ||
'apiEndpoint' in values ||
'deviceApiKey' in values
) {
// If any of the values we're interested in have
// changed, retrieve all of the values, check that
// they're all set, and provide them to the
// balenaBackend
const conf = await config.getMany([
'uuid',
'apiEndpoint',
'deviceApiKey',
]);
// We use Boolean here, as deviceApiKey when unset
// is '' for legacy reasons. Once we're totally
// typescript, we can make it have a default value
// of undefined.
if (_.every(conf, Boolean)) {
// Everything is set, provide the values to the
// balenaBackend, and remove our listener
this.balenaBackend!.assignFields(
conf.apiEndpoint,
conf.uuid!,
conf.deviceApiKey,
);
config.removeListener('change', handler);
}
}
};
config.on('change', handler);
}
}
public switchBackend(localMode: boolean) {

View File

@ -31,17 +31,18 @@ export class BalenaLogBackend extends LogBackend {
private stream: stream.PassThrough;
private timeout: NodeJS.Timer;
public constructor(apiEndpoint: string, uuid: string, deviceApiKey: string) {
public initialised = false;
public constructor(
apiEndpoint: string,
uuid: Nullable<string>,
deviceApiKey: string,
) {
super();
this.opts = url.parse(`${apiEndpoint}/device/v2/${uuid}/log-stream`) as any;
this.opts.method = 'POST';
this.opts.headers = {
Authorization: `Bearer ${deviceApiKey}`,
'Content-Type': 'application/x-ndjson',
'Content-Encoding': 'gzip',
};
if (uuid != null && deviceApiKey !== '') {
this.assignFields(apiEndpoint, uuid, deviceApiKey);
}
// This stream serves serves as a message buffer during reconnections
// while we unpipe the old, malfunctioning connection and then repipe a
// new one.
@ -71,8 +72,15 @@ export class BalenaLogBackend extends LogBackend {
});
}
public isIntialised(): boolean {
return this.initialised;
}
public log(message: LogMessage) {
if (this.unmanaged || !this.publishEnabled) {
// TODO: Perhaps don't just drop logs when we haven't
// yet initialised (this happens when a device has not yet
// been provisioned)
if (this.unmanaged || !this.publishEnabled || !this.initialised) {
return;
}
@ -100,6 +108,18 @@ export class BalenaLogBackend extends LogBackend {
this.write(message);
}
public assignFields(apiEndpoint: string, uuid: string, deviceApiKey: string) {
this.opts = url.parse(`${apiEndpoint}/device/v2/${uuid}/log-stream`) as any;
this.opts.method = 'POST';
this.opts.headers = {
Authorization: `Bearer ${deviceApiKey}`,
'Content-Type': 'application/x-ndjson',
'Content-Encoding': 'gzip',
};
this.initialised = true;
}
private setup = _.throttle(() => {
this.req = https.request(this.opts);

View File

@ -1,101 +0,0 @@
EventEmitter = require 'events'
{ EventTracker } = require './event-tracker'
{ DB } = require './db'
{ Config } = require './config'
{ APIBinder } = require './api-binder'
DeviceState = require './device-state'
{ SupervisorAPI } = require './supervisor-api'
{ Logger } = require './logger'
version = require './lib/supervisor-version'
{ log } = require './lib/supervisor-console'
constants = require './lib/constants'
startupConfigFields = [
'uuid'
'listenPort'
'apiEndpoint'
'apiSecret'
'apiTimeout'
'unmanaged'
'deviceApiKey'
'mixpanelToken'
'mixpanelHost'
'loggingEnabled'
'localMode'
'legacyAppsPresent'
]
module.exports = class Supervisor extends EventEmitter
constructor: ->
@db = new DB()
@config = new Config({ @db })
@eventTracker = new EventTracker()
@logger = new Logger({ @db, @eventTracker })
@deviceState = new DeviceState({ @config, @db, @eventTracker, @logger })
@apiBinder = new APIBinder({ @config, @db, @deviceState, @eventTracker })
# FIXME: rearchitect proxyvisor to avoid this circular dependency
# by storing current state and having the APIBinder query and report it / provision devices
@deviceState.applications.proxyvisor.bindToAPI(@apiBinder)
# We could also do without the below dependency, but it's part of a much larger refactor
@deviceState.applications.apiBinder = @apiBinder
@api = new SupervisorAPI({
@config,
@eventTracker,
routers: [
@apiBinder.router,
@deviceState.router
],
healthchecks: [
@apiBinder.healthcheck.bind(@apiBinder),
@deviceState.healthcheck.bind(@deviceState)
]
})
init: =>
log.info("Supervisor v#{version} starting up...")
@db.init()
.tap =>
@config.init() # Ensures uuid, deviceApiKey, apiSecret
.then =>
@config.getMany(startupConfigFields)
.then (conf) =>
# We can't print to the dashboard until the logger has started up,
# so we leave a trail of breadcrumbs in the logs in case runtime
# fails to get to the first dashboard logs
log.debug('Starting event tracker')
@eventTracker.init(conf)
.then =>
log.debug('Starting up api binder')
@apiBinder.initClient()
.then =>
log.debug('Starting logging infrastructure')
@logger.init({
apiEndpoint: conf.apiEndpoint,
uuid: conf.uuid,
deviceApiKey: conf.deviceApiKey,
unmanaged: conf.unmanaged,
enableLogs: conf.loggingEnabled,
localMode: conf.localMode
})
.then =>
@logger.logSystemMessage('Supervisor starting', {}, 'Supervisor start')
.then =>
if conf.legacyAppsPresent
log.info('Legacy app detected, running migration')
@deviceState.normaliseLegacy(@apiBinder.balenaApi)
.then =>
@deviceState.init()
.then =>
# initialize API
log.info('Starting API server')
@api.listen(constants.allowedInterfaces, conf.listenPort, conf.apiTimeout)
@deviceState.on('shutdown', => @api.stop())
.then =>
@apiBinder.start() # this will first try to provision if it's a new device

118
src/supervisor.ts Normal file
View File

@ -0,0 +1,118 @@
import APIBinder from './api-binder';
import Config, { ConfigKey } from './config';
import Database from './db';
import EventTracker from './event-tracker';
import Logger from './logger';
import SupervisorAPI from './supervisor-api';
import DeviceState = require('./device-state');
import constants = require('./lib/constants');
import log from './lib/supervisor-console';
import version = require('./lib/supervisor-version');
const startupConfigFields: ConfigKey[] = [
'uuid',
'listenPort',
'apiEndpoint',
'apiSecret',
'apiTimeout',
'unmanaged',
'deviceApiKey',
'mixpanelToken',
'mixpanelHost',
'loggingEnabled',
'localMode',
'legacyAppsPresent',
];
export class Supervisor {
private db: Database;
private config: Config;
private eventTracker: EventTracker;
private logger: Logger;
private deviceState: DeviceState;
private apiBinder: APIBinder;
private api: SupervisorAPI;
public constructor() {
this.db = new Database();
this.config = new Config({ db: this.db });
this.eventTracker = new EventTracker();
this.logger = new Logger({ db: this.db, eventTracker: this.eventTracker });
this.deviceState = new DeviceState({
config: this.config,
db: this.db,
eventTracker: this.eventTracker,
logger: this.logger,
});
this.apiBinder = new APIBinder({
config: this.config,
db: this.db,
deviceState: this.deviceState,
eventTracker: this.eventTracker,
});
// FIXME: rearchitect proxyvisor to avoid this circular dependency
// by storing current state and having the APIBinder query and report it / provision devices
this.deviceState.applications.proxyvisor.bindToAPI(this.apiBinder);
// We could also do without the below dependency, but it's part of a much larger refactor
this.deviceState.applications.apiBinder = this.apiBinder;
this.api = new SupervisorAPI({
config: this.config,
eventTracker: this.eventTracker,
routers: [this.apiBinder.router, this.deviceState.router],
healthchecks: [
this.apiBinder.healthcheck.bind(this.apiBinder),
this.deviceState.healthcheck.bind(this.deviceState),
],
});
}
public async init() {
log.info(`Supervisor v${version} starting up...`);
await this.db.init();
await this.config.init();
const conf = await this.config.getMany(startupConfigFields);
// We can't print to the dashboard until the logger
// has started up, so we leave a trail of breadcrumbs
// in the logs in case runtime fails to get to the
// first dashboard logs
log.debug('Starting event tracker');
await this.eventTracker.init(conf);
log.debug('Starting api binder');
await this.apiBinder.initClient();
log.debug('Starting logging infrastructure');
this.logger.init({
enableLogs: conf.loggingEnabled,
config: this.config,
...conf,
});
this.logger.logSystemMessage('Supervisor starting', {}, 'Supervisor start');
if (conf.legacyAppsPresent) {
log.info('Legacy app detected, running migration');
this.deviceState.normaliseLegacy(this.apiBinder.balenaApi);
}
await this.deviceState.init();
log.info('Starting API server');
this.api.listen(
constants.allowedInterfaces,
conf.listenPort,
conf.apiTimeout,
);
this.deviceState.on('shutdown', () => this.api.stop());
await this.apiBinder.start();
}
}
export default Supervisor;

View File

@ -1,6 +1,6 @@
{ expect } = require './lib/chai-config'
Supervisor = require '../src/supervisor'
{ Supervisor } = require '../src/supervisor'
describe 'Startup', ->
it 'should startup correctly', ->

View File

@ -69,7 +69,7 @@ module.exports = function(env) {
return {
mode: env == null || !env.noOptimize ? 'production' : 'development',
devtool: 'none',
entry: './src/app.coffee',
entry: './src/app.ts',
output: {
filename: 'app.js',
path: path.resolve(__dirname, 'dist'),