diff --git a/package.json b/package.json index 41cfb47c..f78c62f6 100644 --- a/package.json +++ b/package.json @@ -50,6 +50,7 @@ "console-log-level": "^1.4.0", "extend": "^3.0.2", "findit2": "^2.2.3", + "firebase-admin": "^9.11.1", "gcp-metadata": "^4.0.0", "p-limit": "^3.0.1", "semver": "^7.0.0", diff --git a/src/agent/config.ts b/src/agent/config.ts index 79da4b56..9e9d5c31 100644 --- a/src/agent/config.ts +++ b/src/agent/config.ts @@ -362,6 +362,21 @@ export interface ResolvedDebugAgentConfig extends GoogleAuthOptions { * in defaultConfig. */ resetV8DebuggerThreshold: number; + + /** + * If set, use Firebase Realtime Database as the backend instead of the + * Cloud Debugger API. Requires many things, which will be documented later. + */ + useFirebase: boolean; + + /** + * If set, use this key for Firebase activities instead of default google credentials. + */ + firebaseKeyPath?: string; + /** + * If set, use this as the firebase database url. If not set, a FIXME default will be used. + */ + firebaseDbUrl?: string; } export interface StackdriverConfig extends GoogleAuthOptions { @@ -412,4 +427,6 @@ export const defaultConfig: ResolvedDebugAgentConfig = { forceNewAgent_: false, testMode_: false, resetV8DebuggerThreshold: 30, + + useFirebase: false, }; diff --git a/src/agent/controller.ts b/src/agent/controller.ts index e8a511d5..bf0815c6 100644 --- a/src/agent/controller.ts +++ b/src/agent/controller.ts @@ -16,46 +16,14 @@ * @module debug/controller */ -import {ServiceObject} from '@google-cloud/common'; -import * as assert from 'assert'; -import * as qs from 'querystring'; -import * as t from 'teeny-request'; - -import {URL} from 'url'; - -import {Debug} from '../client/stackdriver/debug'; import {Debuggee} from '../debuggee'; import * as stackdriver from '../types/stackdriver'; -export class Controller extends ServiceObject { - private nextWaitToken: string | null; - private agentId: string | null; - - apiUrl: string; - - /** - * @constructor - */ - - constructor(debug: Debug, config?: {apiUrl?: string}) { - super({parent: debug, baseUrl: '/controller'}); - - /** @private {string} */ - this.nextWaitToken = null; - this.agentId = null; - - this.apiUrl = `https://${debug.apiEndpoint}/v2/controller`; - - if (config && config.apiUrl) { - this.apiUrl = config.apiUrl + new URL(this.apiUrl).pathname; - } - } - +export interface Controller { /** * Register to the API (implementation) * * @param {!function(?Error,Object=)} callback - * @private */ register( debuggee: Debuggee, @@ -66,91 +34,7 @@ export class Controller extends ServiceObject { agentId: string; } ) => void - ): void { - const options = { - uri: this.apiUrl + '/debuggees/register', - method: 'POST', - json: true, - body: {debuggee}, - }; - this.request( - options, - (err, body: {debuggee: Debuggee; agentId: string}, response) => { - if (err) { - callback(err); - } else if (response!.statusCode !== 200) { - callback( - new Error('unable to register, statusCode ' + response!.statusCode) - ); - } else if (!body.debuggee) { - callback(new Error('invalid response body from server')); - } else { - debuggee.id = body.debuggee.id; - this.agentId = body.agentId; - callback(null, body); - } - } - ); - } - - /** - * Fetch the list of breakpoints from the server. Assumes we have registered. - * @param {!function(?Error,Object=,Object=)} callback accepting (err, response, - * body) - */ - listBreakpoints( - debuggee: Debuggee, - callback: ( - err: Error | null, - response?: t.Response, - body?: stackdriver.ListBreakpointsResponse - ) => void - ): void { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; - assert(debuggee.id, 'should have a registered debuggee'); - const query: stackdriver.ListBreakpointsQuery = {successOnTimeout: true}; - if (that.nextWaitToken) { - query.waitToken = that.nextWaitToken; - } - if (that.agentId) { - query.agentId = that.agentId; - } - - const uri = - this.apiUrl + - '/debuggees/' + - encodeURIComponent(debuggee.id) + - '/breakpoints?' + - qs.stringify(query as qs.ParsedUrlQueryInput); - that.request( - {uri, json: true}, - (err, body: stackdriver.ListBreakpointsResponse, response) => { - if (!response) { - callback( - err || new Error('unknown error - request response missing') - ); - return; - } else if (response.statusCode === 404) { - // The v2 API returns 404 (google.rpc.Code.NOT_FOUND) when the agent - // registration expires. We should re-register. - callback(null, response as {} as t.Response); - return; - } else if (response.statusCode !== 200) { - callback( - new Error( - 'unable to list breakpoints, status code ' + response.statusCode - ) - ); - return; - } else { - body = body || {}; - that.nextWaitToken = body.nextWaitToken; - callback(null, response as {} as t.Response, body); - } - } - ); - } + ): void; /** * Update the server about breakpoint state @@ -162,34 +46,21 @@ export class Controller extends ServiceObject { debuggee: Debuggee, breakpoint: stackdriver.Breakpoint, callback: (err?: Error, body?: {}) => void - ): void { - assert(debuggee.id, 'should have a registered debuggee'); + ): void; - breakpoint.action = 'CAPTURE'; - breakpoint.isFinalState = true; - const options = { - uri: - this.apiUrl + - '/debuggees/' + - encodeURIComponent(debuggee.id) + - // TODO: Address the case where `breakpoint.id` is `undefined`. - '/breakpoints/' + - encodeURIComponent(breakpoint.id as string), - json: true, - method: 'PUT', - body: {debuggeeId: debuggee.id, breakpoint}, - }; + /** + * Start listening to breakpoints updates. The callback will be called when + * there is an unrecoverable error or when the set of active breakpoints has changed. + * @param {!Debuggee} debuggee + * @param {!function(?Error,Object=)} callback accepting (err, breakpoints) + */ + subscribeToBreakpoints( + debuggee: Debuggee, + callback: (err: Error | null, breakpoints: stackdriver.Breakpoint[]) => void + ): void; - // We need to have a try/catch here because a JSON.stringify will be done - // by request. Some V8 debug mirror objects get a throw when we attempt to - // stringify them. The try-catch keeps it resilient and avoids crashing the - // user's app. - try { - this.request(options, (err, body /*, response */) => { - callback(err!, body); - }); - } catch (error) { - callback(error); - } - } + /** + * Stops the Controller. This is for testing purposes only. + */ + stop(): void; } diff --git a/src/agent/debuglet.ts b/src/agent/debuglet.ts index da3ca1ec..61769327 100644 --- a/src/agent/debuglet.ts +++ b/src/agent/debuglet.ts @@ -35,11 +35,13 @@ import { ResolvedDebugAgentConfig, } from './config'; import {Controller} from './controller'; +import {OnePlatformController} from './oneplatform-controller'; import * as scanner from './io/scanner'; import * as SourceMapper from './io/sourcemapper'; import * as utils from './util/utils'; import * as debugapi from './v8/debugapi'; import {DebugApi} from './v8/debugapi'; +import {FirebaseController} from './firebase-controller'; const readFilep = util.promisify(fs.readFile); @@ -55,8 +57,6 @@ const NODE_10_CIRC_REF_MESSAGE = ' and Node 12.' + ' See https://github.com/googleapis/cloud-debug-nodejs/issues/516 for more' + ' information.'; -const BREAKPOINT_ACTION_MESSAGE = - 'The only currently supported breakpoint actions' + ' are CAPTURE and LOG.'; // PROMISE_RESOLVE_CUT_OFF_IN_MILLISECONDS is a heuristic duration that we set // to force the debug agent to return a new promise for isReady. The value is @@ -189,9 +189,11 @@ export class Debuglet extends EventEmitter { private v8debug: DebugApi | null; private running: boolean; private project: string | null; - private controller: Controller; + private controller: Controller | null; private completedBreakpointMap: {[key: string]: boolean}; + // The following four variables are used for the "isReady" functionality. + // breakpointFetchedTimestamp represents the last timestamp when // breakpointFetched was resolved, which means breakpoint update was // successful. @@ -255,8 +257,7 @@ export class Debuglet extends EventEmitter { level: Debuglet.logLevelToName(this.config.logLevel), }); - /** @private {DebugletApi} */ - this.controller = new Controller(this.debug, {apiUrl: config.apiUrl}); + this.controller = null; /** @private {Debuggee} */ this.debuggee = null; @@ -350,23 +351,21 @@ export class Debuglet extends EventEmitter { * @private */ async start(): Promise { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; const stat = util.promisify(fs.stat); try { - await stat(path.join(that.config.workingDirectory, 'package.json')); + await stat(path.join(this.config.workingDirectory, 'package.json')); } catch (err) { - that.logger.error('No package.json located in working directory.'); - that.emit('initError', new Error('No package.json found.')); + this.logger.error('No package.json located in working directory.'); + this.emit('initError', new Error('No package.json found.')); return; } - const workingDir = that.config.workingDirectory; + const workingDir = this.config.workingDirectory; // Don't continue if the working directory is a root directory // unless the user wants to force using the root directory if ( - !that.config.allowRootAsWorkingDirectory && + !this.config.allowRootAsWorkingDirectory && path.join(workingDir, '..') === workingDir ) { const message = @@ -374,8 +373,8 @@ export class Debuglet extends EventEmitter { 'to avoid a scan of the entire filesystem for JavaScript files. ' + 'Use config `allowRootAsWorkingDirectory` if you really want to ' + 'do this.'; - that.logger.error(message); - that.emit('initError', new Error(message)); + this.logger.error(message); + this.emit('initError', new Error(message)); return; } @@ -386,39 +385,39 @@ export class Debuglet extends EventEmitter { let findResults: FindFilesResult; try { - findResults = await Debuglet.findFiles(that.config, gaeId); - findResults.errors.forEach(that.logger.warn); + findResults = await Debuglet.findFiles(this.config, gaeId); + findResults.errors.forEach(this.logger.warn); } catch (err) { - that.logger.error('Error scanning the filesystem.', err); - that.emit('initError', err); + this.logger.error('Error scanning the filesystem.', err); + this.emit('initError', err); return; } let mapper; try { - mapper = await SourceMapper.create(findResults.mapFiles, that.logger); + mapper = await SourceMapper.create(findResults.mapFiles, this.logger); } catch (err3) { - that.logger.error('Error processing the sourcemaps.', err3); - that.emit('initError', err3); + this.logger.error('Error processing the sourcemaps.', err3); + this.emit('initError', err3); return; } - that.v8debug = debugapi.create( - that.logger, - that.config, + this.v8debug = debugapi.create( + this.logger, + this.config, findResults.jsStats, mapper ); const id: string = gaeId || findResults.hash; - that.logger.info('Unique ID for this Application: ' + id); + this.logger.info('Unique ID for this Application: ' + id); let onGCP: boolean; try { onGCP = await Debuglet.runningOnGCP(); } catch (err) { - that.logger.warn( + this.logger.warn( 'Unexpected error detecting GCE metadata service: ' + err.message ); // Continue, assuming not on GCP. @@ -426,26 +425,47 @@ export class Debuglet extends EventEmitter { } let project: string; - try { - project = await that.debug.authClient.getProjectId(); - } catch (err) { - that.logger.error( - 'The project ID could not be determined: ' + err.message + if (this.config.useFirebase) { + try { + const firebaseDb = await FirebaseController.initialize({ + keyPath: this.config.firebaseKeyPath, + databaseUrl: this.config.firebaseDbUrl, + projectId: this.config.projectId, + }); + this.controller = new FirebaseController(firebaseDb); + project = (this.controller as FirebaseController).getProjectId(); + } catch (err) { + this.logger.error('Unable to connect to Firebase: ' + err.message); + this.emit('initError', err); + return; + } + } else { + try { + project = await this.debug.authClient.getProjectId(); + } catch (err) { + this.logger.error( + 'The project ID could not be determined: ' + err.message + ); + this.emit('initError', err); + return; + } + this.controller = new OnePlatformController( + this.debug, + this.config, + this.logger ); - that.emit('initError', err); - return; } if ( onGCP && - (!that.config.serviceContext || !that.config.serviceContext.service) + (!this.config.serviceContext || !this.config.serviceContext.service) ) { - // If on GCP, check if the clusterName instance attribute is availble. + // If on GCP, check if the clusterName instance attribute is available. // Use this as the service context for better service identification on // GKE. try { const clusterName = await Debuglet.getClusterNameFromMetadata(); - that.config.serviceContext = { + this.config.serviceContext = { service: clusterName, version: 'unversioned', minorVersion_: undefined, @@ -458,10 +478,10 @@ export class Debuglet extends EventEmitter { let sourceContext; try { sourceContext = - (that.config.sourceContext as {} as SourceContext) || + (this.config.sourceContext as {} as SourceContext) || (await Debuglet.getSourceContextFromFile()); } catch (err5) { - that.logger.warn('Unable to discover source context', err5); + this.logger.warn('Unable to discover source context', err5); // This is ignorable. } @@ -470,7 +490,7 @@ export class Debuglet extends EventEmitter { this.config.capture.maxDataSize === 0 && utils.satisfies(process.version, '>=10 <10.15.3 || >=11 <11.7 || >=12') ) { - that.logger.warn(NODE_10_CIRC_REF_MESSAGE); + this.logger.warn(NODE_10_CIRC_REF_MESSAGE); } const platform = Debuglet.getPlatform(); @@ -480,30 +500,30 @@ export class Debuglet extends EventEmitter { } // We can register as a debuggee now. - that.logger.debug('Starting debuggee, project', project); - that.running = true; + this.logger.debug('Starting debuggee, project', project); + this.running = true; - that.project = project; - that.debuggee = Debuglet.createDebuggee( + this.project = project; + this.debuggee = Debuglet.createDebuggee( project, id, - that.config.serviceContext, + this.config.serviceContext, sourceContext, onGCP, - that.debug.packageInfo, + this.debug.packageInfo, platform, - that.config.description, + this.config.description, /*errorMessage=*/ undefined, region ); - that.scheduleRegistration_(0 /* immediately */); - that.emit('started'); + this.scheduleRegistration_(0 /* immediately */); + this.emit('started'); } /** * isReady returns a promise that only resolved if the last breakpoint update - * happend within a duration (PROMISE_RESOLVE_CUT_OFF_IN_MILLISECONDS). This + * happened within a duration (PROMISE_RESOLVE_CUT_OFF_IN_MILLISECONDS). This * feature is mainly used in Google Cloud Function (GCF), as it is a * serverless environment and we wanted to make sure debug agent always * captures the snapshots. @@ -518,10 +538,7 @@ export class Debuglet extends EventEmitter { if (this.breakpointFetched) return this.breakpointFetched.get(); this.breakpointFetched = new CachedPromise(); this.debuggeeRegistered.get().then(() => { - this.scheduleBreakpointFetch_( - 0 /*immediately*/, - true /*only fetch once*/ - ); + this.startListeningForBreakpoints_(); }); return this.breakpointFetched.get(); } @@ -560,12 +577,12 @@ export class Debuglet extends EventEmitter { let desc = process.title + ' ' + mainScript; const labels: {[key: string]: string} = { - 'main script': mainScript, - 'process.title': process.title, - 'node version': process.versions.node, - 'V8 version': process.versions.v8, - 'agent.name': packageInfo.name, - 'agent.version': packageInfo.version, + main_script: mainScript, + process_title: process.title, + node_version: process.versions.node, + V8_version: process.versions.v8, + agent_name: packageInfo.name, + agent_version: packageInfo.version, projectid: projectId, platform, }; @@ -689,7 +706,12 @@ export class Debuglet extends EventEmitter { } /** - * @param {number} seconds + * Registers the debuggee after `seconds` seconds. + * On failure, uses an exponential backoff to retry. + * If successful, emits a 'registered' event, resolves the debuggeeRegistered promise, + * and starts listening for breakpoint updates. + * + * @param {number} seconds - The number of seconds to wait before registering. * @private */ scheduleRegistration_(seconds: number): void { @@ -709,6 +731,7 @@ export class Debuglet extends EventEmitter { } setTimeout(() => { + assert(that.controller); if (!that.running) { onError(new Error('Debuglet not running')); return; @@ -751,130 +774,43 @@ export class Debuglet extends EventEmitter { that.emit('registered', (result as {debuggee: Debuggee}).debuggee.id); that.debuggeeRegistered.resolve(); if (!that.fetcherActive) { - that.scheduleBreakpointFetch_(0, false); + that.startListeningForBreakpoints_(); } } ); }, seconds * 1000).unref(); } - /** - * @param {number} seconds - * @param {boolean} once - * @private - */ - scheduleBreakpointFetch_(seconds: number, once: boolean): void { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; - if (!once) { - that.fetcherActive = true; - } - setTimeout(() => { - if (!that.running) { - return; - } - - if (!once) { - assert(that.fetcherActive); - } + startListeningForBreakpoints_(): void { + assert(this.controller); + // TODO: Handle the case where this.debuggee is null or not properly registered. + this.controller.subscribeToBreakpoints( + this.debuggee!, + (err: Error | null, breakpoints: stackdriver.Breakpoint[]) => { + if (err) { + // There was an error, and the subscription is cancelled. + // Re-register and resubscribe. + const delay = + err.name === 'RegistrationExpiredError' + ? 0 + : this.config.internal.registerDelayOnFetcherErrorSec; + // The debuglet is no longer ready and the promises are stale. + this.updatePromise(); + this.scheduleRegistration_(delay); + } - that.logger.info('Fetching breakpoints'); - // TODO: Address the case when `that.debuggee` is `null`. - that.controller.listBreakpoints( - that.debuggee as Debuggee, - (err, response, body) => { - if (err) { - that.logger.error( - 'Error fetching breakpoints – scheduling retry', - err - ); - that.fetcherActive = false; - // We back-off from fetching breakpoints, and try to register - // again after a while. Successful registration will restart the - // breakpoint fetcher. - that.updatePromise(); - that.scheduleRegistration_( - that.config.internal.registerDelayOnFetcherErrorSec - ); - return; - } - // TODO: Address the case where `response` is `undefined`. - switch (response!.statusCode) { - case 404: - // Registration expired. Deactivate the fetcher and queue - // re-registration, which will re-active breakpoint fetching. - that.logger.info('\t404 Registration expired.'); - that.fetcherActive = false; - that.updatePromise(); - that.scheduleRegistration_(0 /*immediately*/); - return; - - default: - // TODO: Address the case where `response` is `undefined`. - that.logger.info('\t' + response!.statusCode + ' completed.'); - if (!body) { - that.logger.error('\tinvalid list response: empty body'); - that.scheduleBreakpointFetch_( - that.config.breakpointUpdateIntervalSec, - once - ); - return; - } - if (body.waitExpired) { - that.logger.info('\tLong poll completed.'); - that.scheduleBreakpointFetch_(0 /*immediately*/, once); - return; - } - // eslint-disable-next-line no-case-declarations - const bps = (body.breakpoints || []).filter( - (bp: stackdriver.Breakpoint) => { - const action = bp.action || 'CAPTURE'; - if (action !== 'CAPTURE' && action !== 'LOG') { - that.logger.warn( - 'Found breakpoint with invalid action:', - action - ); - bp.status = new StatusMessage( - StatusMessage.UNSPECIFIED, - BREAKPOINT_ACTION_MESSAGE, - true - ); - that.rejectBreakpoint_(bp); - return false; - } - return true; - } - ); - that.updateActiveBreakpoints_(bps); - if (Object.keys(that.activeBreakpointMap).length) { - that.logger.info( - formatBreakpoints( - 'Active Breakpoints: ', - that.activeBreakpointMap - ) - ); - } - that.breakpointFetchedTimestamp = Date.now(); - if (once) { - if (that.breakpointFetched) { - that.breakpointFetched.resolve(); - that.breakpointFetched = null; - } - } else { - that.scheduleBreakpointFetch_( - that.config.breakpointUpdateIntervalSec, - once - ); - } - return; - } + this.breakpointFetchedTimestamp = Date.now(); + if (this.breakpointFetched) { + this.breakpointFetched.resolve(); + this.breakpointFetched = null; } - ); - }, seconds * 1000).unref(); + this.updateActiveBreakpoints_(breakpoints); + } + ); } /** - * updatePromise_ is called when debuggee is expired. debuggeeRegistered + * updatePromise is called when debuggee is expired. debuggeeRegistered * CachedPromise will be refreshed. Also, breakpointFetched CachedPromise will * be resolved so that uses (such as GCF users) will not hang forever to wait * non-fetchable breakpoints. @@ -893,30 +829,28 @@ export class Debuglet extends EventEmitter { * @private */ updateActiveBreakpoints_(breakpoints: stackdriver.Breakpoint[]): void { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; const updatedBreakpointMap = this.convertBreakpointListToMap_(breakpoints); if (breakpoints.length) { - that.logger.info( + this.logger.info( formatBreakpoints('Server breakpoints: ', updatedBreakpointMap) ); } breakpoints.forEach((breakpoint: stackdriver.Breakpoint) => { // TODO: Address the case when `breakpoint.id` is `undefined`. if ( - !that.completedBreakpointMap[breakpoint.id as string] && - !that.activeBreakpointMap[breakpoint.id as string] + !this.completedBreakpointMap[breakpoint.id as string] && + !this.activeBreakpointMap[breakpoint.id as string] ) { // New breakpoint - that.addBreakpoint_(breakpoint, err => { + this.addBreakpoint_(breakpoint, err => { if (err) { - that.completeBreakpoint_(breakpoint, false); + this.completeBreakpoint_(breakpoint, false); } }); // Schedule the expiry of server breakpoints. - that.scheduleBreakpointExpiry_(breakpoint); + this.scheduleBreakpointExpiry_(breakpoint); } }); @@ -929,7 +863,7 @@ export class Debuglet extends EventEmitter { // field. It is possible that breakpoint.id is always // undefined! // TODO: Make sure the use of `that` here is correct. - delete that.completedBreakpointMap[(breakpoint as {} as {id: number}).id]; + delete this.completedBreakpointMap[(breakpoint as {} as {id: number}).id]; }); // Remove active breakpoints that the server no longer care about. @@ -985,14 +919,11 @@ export class Debuglet extends EventEmitter { breakpoint: stackdriver.Breakpoint, cb: (ob: Error | string) => void ): void { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; - if ( - !that.config.allowExpressions && + !this.config.allowExpressions && (breakpoint.condition || breakpoint.expressions) ) { - that.logger.error(ALLOW_EXPRESSIONS_MESSAGE); + this.logger.error(ALLOW_EXPRESSIONS_MESSAGE); breakpoint.status = new StatusMessage( StatusMessage.UNSPECIFIED, ALLOW_EXPRESSIONS_MESSAGE, @@ -1006,7 +937,7 @@ export class Debuglet extends EventEmitter { if (utils.satisfies(process.version, '5.2 || <4')) { const message = NODE_VERSION_MESSAGE; - that.logger.error(message); + this.logger.error(message); breakpoint.status = new StatusMessage( StatusMessage.UNSPECIFIED, message, @@ -1018,42 +949,41 @@ export class Debuglet extends EventEmitter { return; } - // TODO: Address the case when `that.v8debug` is `null`. - (that.v8debug as DebugApi).set(breakpoint, err1 => { + // TODO: Address the case when `this.v8debug` is `null`. + (this.v8debug as DebugApi).set(breakpoint, err1 => { if (err1) { cb(err1); return; } - - that.logger.info('\tsuccessfully added breakpoint ' + breakpoint.id); + this.logger.info('\tsuccessfully added breakpoint ' + breakpoint.id); // TODO: Address the case when `breakpoint.id` is `undefined`. - that.activeBreakpointMap[breakpoint.id as string] = breakpoint; + this.activeBreakpointMap[breakpoint.id as string] = breakpoint; if (breakpoint.action === 'LOG') { - // TODO: Address the case when `that.v8debug` is `null`. - (that.v8debug as DebugApi).log( + // TODO: Address the case when `this.v8debug` is `null`. + (this.v8debug as DebugApi).log( breakpoint, (fmt: string, exprs: string[]) => { - that.config.log.logFunction( + this.config.log.logFunction( `LOGPOINT: ${Debuglet.format(fmt, exprs)}` ); }, () => { // TODO: Address the case when `breakpoint.id` is `undefined`. - return that.completedBreakpointMap[breakpoint.id as string]; + return this.completedBreakpointMap[breakpoint.id as string]; } ); } else { - // TODO: Address the case when `that.v8debug` is `null`. - (that.v8debug as DebugApi).wait(breakpoint, err2 => { + // TODO: Address the case when `this.v8debug` is `null`. + (this.v8debug as DebugApi).wait(breakpoint, err2 => { if (err2) { - that.logger.error(err2); + this.logger.error(err2); cb(err2); return; } - that.logger.info('Breakpoint hit!: ' + breakpoint.id); - that.completeBreakpoint_(breakpoint); + this.logger.info('Breakpoint hit!: ' + breakpoint.id); + this.completeBreakpoint_(breakpoint); }); } }); @@ -1069,21 +999,24 @@ export class Debuglet extends EventEmitter { breakpoint: stackdriver.Breakpoint, deleteFromV8 = true ): void { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; + assert(this.controller); - that.logger.info('\tupdating breakpoint data on server', breakpoint.id); - that.controller.updateBreakpoint( - // TODO: Address the case when `that.debuggee` is `null`. - that.debuggee as Debuggee, + this.logger.info('\tupdating breakpoint data on server', breakpoint.id); + this.controller.updateBreakpoint( + // TODO: Address the case when `this.debuggee` is `null`. + this.debuggee as Debuggee, breakpoint, (err /*, body*/) => { if (err) { - that.logger.error('Unable to complete breakpoint on server', err); - } else { + this.logger.error('Unable to complete breakpoint on server', err); + return; + } + // The Firebase controller will remove the breakpoint during the update + // by removing it from the database. + if (!this.config.useFirebase) { // TODO: Address the case when `breakpoint.id` is `undefined`. - that.completedBreakpointMap[breakpoint.id as string] = true; - that.removeBreakpoint_(breakpoint, deleteFromV8); + this.completedBreakpointMap[breakpoint.id as string] = true; + this.removeBreakpoint_(breakpoint, deleteFromV8); } } ); @@ -1095,16 +1028,15 @@ export class Debuglet extends EventEmitter { * @private */ rejectBreakpoint_(breakpoint: stackdriver.Breakpoint): void { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; + assert(this.controller); - // TODO: Address the case when `that.debuggee` is `null`. - that.controller.updateBreakpoint( - that.debuggee as Debuggee, + // TODO: Address the case when `this.debuggee` is `null`. + this.controller.updateBreakpoint( + this.debuggee as Debuggee, breakpoint, (err /*, body*/) => { if (err) { - that.logger.error('Unable to complete breakpoint on server', err); + this.logger.error('Unable to complete breakpoint on server', err); } } ); @@ -1114,28 +1046,25 @@ export class Debuglet extends EventEmitter { * This schedules a delayed operation that will delete the breakpoint from the * server after the expiry period. * FIXME: we should cancel the timer when the breakpoint completes. Otherwise - * we hold onto the closure memory until the breapointExpirateion timeout. + * we hold onto the closure memory until the breapointExpiration timeout. * @param {Breakpoint} breakpoint Server breakpoint object * @private */ scheduleBreakpointExpiry_(breakpoint: stackdriver.Breakpoint): void { - // eslint-disable-next-line @typescript-eslint/no-this-alias - const that = this; - const now = Date.now() / 1000; const createdTime = breakpoint.createdTime ? Number(breakpoint.createdTime.seconds) : now; - const expiryTime = createdTime + that.config.breakpointExpirationSec; + const expiryTime = createdTime + this.config.breakpointExpirationSec; setTimeout(() => { - that.logger.info('Expiring breakpoint ' + breakpoint.id); + this.logger.info('Expiring breakpoint ' + breakpoint.id); breakpoint.status = { description: {format: 'The snapshot has expired'}, isError: true, refersTo: StatusMessage.BREAKPOINT_AGE, }; - that.completeBreakpoint_(breakpoint); + this.completeBreakpoint_(breakpoint); }, (expiryTime - now) * 1000).unref(); } @@ -1146,9 +1075,11 @@ export class Debuglet extends EventEmitter { * pending operations. */ stop(): void { + assert(this.controller); assert.ok(this.running, 'stop can only be called on a running agent'); this.logger.debug('Stopping Debuglet'); this.running = false; + this.controller.stop(); this.emit('stopped'); } diff --git a/src/agent/firebase-controller.ts b/src/agent/firebase-controller.ts new file mode 100644 index 00000000..beed2024 --- /dev/null +++ b/src/agent/firebase-controller.ts @@ -0,0 +1,246 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/*! + * @module debug/firebase-controller + */ + +import * as assert from 'assert'; + +import {Controller} from './controller'; +import {Debuggee} from '../debuggee'; +import * as stackdriver from '../types/stackdriver'; +import * as crypto from 'crypto'; + +import * as firebase from 'firebase-admin'; +import * as gcpMetadata from 'gcp-metadata'; + +import * as util from 'util'; +const debuglog = util.debuglog('cdbg.firebase'); + +export class FirebaseController implements Controller { + db: firebase.database.Database; + debuggeeId?: string; + bpRef?: firebase.database.Reference; + + /** + * Connects to the Firebase database. + * + * The project Id passed in options is preferred over any other sources. + * + * @param options specifies which database and credentials to use + * @returns database connection + */ + static async initialize(options: { + keyPath?: string; + databaseUrl?: string; + projectId?: string; + }): Promise { + let credential = undefined; + let projectId = options.projectId; + + if (options.keyPath) { + // Use the project id and credentials in the path specified by the keyPath. + // eslint-disable-next-line @typescript-eslint/no-var-requires + const serviceAccount = require(options.keyPath); + projectId = projectId ?? serviceAccount['project_id']; + credential = firebase.credential.cert(serviceAccount); + } else { + if (!projectId) { + // Try grabbing it from the GCE metadata server. + if (await gcpMetadata.isAvailable()) { + projectId = await gcpMetadata.project('project-id'); + } + } + } + + if (!projectId) { + throw new Error('Cannot determine project ID'); + } + + // Build the database URL. + let databaseUrl: string; + if (options.databaseUrl) { + databaseUrl = options.databaseUrl; + } else { + // TODO: Test whether this exists. If not, fall back to -default. + databaseUrl = `https://${projectId}-cdbg.firebaseio.com`; + } + + if (credential) { + firebase.initializeApp({ + credential: credential, + databaseURL: databaseUrl, + }); + } else { + // Use the default credentials. + firebase.initializeApp({ + databaseURL: databaseUrl, + }); + } + + const db = firebase.database(); + + // TODO: Test this setup and emit a reasonable error. + debuglog('Firebase app initialized. Connected to', databaseUrl); + return db; + } + + /** + * @constructor + */ + constructor(db: firebase.database.Database) { + this.db = db; + } + + getProjectId(): string { + // TODO: Confirm that this is set in all supported cases. + return this.db.app.options.projectId ?? 'unknown'; + } + + /** + * Register to the API (implementation) + * + * @param {!function(?Error,Object=)} callback + * @private + */ + register( + debuggee: Debuggee, + callback: ( + err: Error | null, + result?: { + debuggee: Debuggee; + agentId: string; + } + ) => void + ): void { + debuglog('registering'); + // Firebase hates undefined attributes. Patch the debuggee, just in case. + if (!debuggee.canaryMode) { + debuggee.canaryMode = 'CANARY_MODE_UNSPECIFIED'; + } + + // Calculate the debuggee id as the hash of the object. + // This MUST be consistent across all debuggee instances. + this.debuggeeId = crypto + .createHash('md5') + .update(JSON.stringify(debuggee)) + .digest('hex'); + debuggee.id = this.debuggeeId; + + const debuggeeRef = this.db.ref(`cdbg/debuggees/${this.debuggeeId}`); + debuggeeRef.set(debuggee); + + // TODO: Handle errors. I can .set(data, (error) => if (error) {}) + const agentId = 'unsupported'; + callback(null, {debuggee, agentId}); + } + + /** + * Update the server about breakpoint state + * @param {!Debuggee} debuggee + * @param {!Breakpoint} breakpoint + * @param {!Function} callback accepting (err, body) + */ + updateBreakpoint( + debuggee: Debuggee, + breakpoint: stackdriver.Breakpoint, + callback: (err?: Error, body?: {}) => void + ): void { + debuglog('updating a breakpoint'); + assert(debuggee.id, 'should have a registered debuggee'); + + // By default if action is not present, it's a snapshot, so for it to be + // a logpoint it must be present and equal to 'LOG', anything else is a + // snapshot. + const is_logpoint = breakpoint.action === 'LOG'; + const is_snapshot = !is_logpoint; + + if (is_snapshot) { + breakpoint.action = 'CAPTURE'; + } + + breakpoint.isFinalState = true; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const breakpoint_map: {[key: string]: any} = {...breakpoint}; + + // Magic value. Firebase RTDB will replace the {'.sv': 'timestamp'} with + // the unix time since epoch in milliseconds. + // https://firebase.google.com/docs/reference/rest/database#section-server-values + breakpoint_map['finalTimeUnixMsec'] = {'.sv': 'timestamp'}; + + this.db + .ref(`cdbg/breakpoints/${this.debuggeeId}/active/${breakpoint.id}`) + .remove(); + + // TODO: error handling from here on + if (is_snapshot) { + // We could also restrict this to only write to this node if it wasn't + // an error and there is actual snapshot data. For now though we'll + // write it regardless, makes sense if you want to get everything for + // a snapshot it's at this location, regardless of what it contains. + this.db + .ref(`cdbg/breakpoints/${this.debuggeeId}/snapshot/${breakpoint.id}`) + .set(breakpoint_map); + // Now strip the snapshot data for the write to 'final' path. + const fields_to_strip = [ + 'evaluatedExpressions', + 'stackFrames', + 'variableTable', + ]; + fields_to_strip.forEach(field => delete breakpoint_map[field]); + } + + this.db + .ref(`cdbg/breakpoints/${this.debuggeeId}/final/${breakpoint.id}`) + .set(breakpoint_map); + + // Indicate success to the caller. + callback(); + } + + subscribeToBreakpoints( + debuggee: Debuggee, + callback: (err: Error | null, breakpoints: stackdriver.Breakpoint[]) => void + ): void { + debuglog('Started subscription for breakpoint updates'); + assert(debuggee.id, 'should have a registered debuggee'); + + this.bpRef = this.db.ref(`cdbg/breakpoints/${this.debuggeeId}/active`); + + let breakpoints = [] as stackdriver.Breakpoint[]; + this.bpRef.on('child_added', (snapshot: firebase.database.DataSnapshot) => { + debuglog(`new breakpoint: ${snapshot.key}`); + const breakpoint = snapshot.val(); + breakpoint.id = snapshot.key; + breakpoints.push(breakpoint); + callback(null, breakpoints); + }); + this.bpRef.on('child_removed', snapshot => { + // remove the breakpoint. + const bpId = snapshot.key; + breakpoints = breakpoints.filter(bp => bp.id !== bpId); + debuglog(`breakpoint removed: ${bpId}`); + callback(null, breakpoints); + }); + } + + stop(): void { + if (this.bpRef) { + this.bpRef.off(); + this.bpRef = undefined; + } + } +} diff --git a/src/agent/oneplatform-controller.ts b/src/agent/oneplatform-controller.ts new file mode 100644 index 00000000..6ea9711f --- /dev/null +++ b/src/agent/oneplatform-controller.ts @@ -0,0 +1,328 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +/*! + * @module debug/oneplatform-controller + */ + +import {ServiceObject} from '@google-cloud/common'; +import * as assert from 'assert'; +import * as qs from 'querystring'; +import * as t from 'teeny-request'; + +import {URL} from 'url'; + +import {Logger, ResolvedDebugAgentConfig} from './config'; +import {Controller} from './controller'; +import {StatusMessage} from '../client/stackdriver/status-message'; +import {Debug} from '../client/stackdriver/debug'; +import {Debuggee} from '../debuggee'; +import * as stackdriver from '../types/stackdriver'; + +const BREAKPOINT_ACTION_MESSAGE = + 'The only currently supported breakpoint actions' + ' are CAPTURE and LOG.'; + +export class OnePlatformController extends ServiceObject implements Controller { + private nextWaitToken: string | null; + private agentId: string | null; + private config: ResolvedDebugAgentConfig; + private fetcherActive: boolean; + private running: boolean; + + apiUrl: string; + + logger: Logger; + + /** + * @constructor + */ + constructor(debug: Debug, config: ResolvedDebugAgentConfig, logger: Logger) { + super({parent: debug, baseUrl: '/controller'}); + + /** @private {string} */ + this.nextWaitToken = null; + this.agentId = null; + + this.apiUrl = `https://${debug.apiEndpoint}/v2/controller`; + + this.fetcherActive = false; + this.running = true; + + /** @private */ + this.logger = logger; + + if (config && config.apiUrl) { + this.apiUrl = config.apiUrl + new URL(this.apiUrl).pathname; + } + + this.config = config; + } + + /** + * Register to the API (implementation) + * + * @param {!function(?Error,Object=)} callback + * @private + */ + register( + debuggee: Debuggee, + callback: ( + err: Error | null, + result?: { + debuggee: Debuggee; + agentId: string; + } + ) => void + ): void { + const options = { + uri: this.apiUrl + '/debuggees/register', + method: 'POST', + json: true, + body: {debuggee}, + }; + this.request( + options, + (err, body: {debuggee: Debuggee; agentId: string}, response) => { + if (err) { + callback(err); + } else if (response!.statusCode !== 200) { + callback( + new Error('unable to register, statusCode ' + response!.statusCode) + ); + } else if (!body.debuggee) { + callback(new Error('invalid response body from server')); + } else { + debuggee.id = body.debuggee.id; + this.agentId = body.agentId; + callback(null, body); + } + } + ); + } + + /** + * Fetch the list of breakpoints from the server. Assumes we have registered. + * @param {!function(?Error,Object=,Object=)} callback accepting (err, response, + * body) + */ + listBreakpoints( + debuggee: Debuggee, + callback: ( + err: Error | null, + response?: t.Response, + body?: stackdriver.ListBreakpointsResponse + ) => void + ): void { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const that = this; + assert(debuggee.id, 'should have a registered debuggee'); + const query: stackdriver.ListBreakpointsQuery = {successOnTimeout: true}; + if (that.nextWaitToken) { + query.waitToken = that.nextWaitToken; + } + if (that.agentId) { + query.agentId = that.agentId; + } + + const uri = + this.apiUrl + + '/debuggees/' + + encodeURIComponent(debuggee.id) + + '/breakpoints?' + + qs.stringify(query as qs.ParsedUrlQueryInput); + that.request( + {uri, json: true}, + (err, body: stackdriver.ListBreakpointsResponse, response) => { + if (!response) { + callback( + err || new Error('unknown error - request response missing') + ); + return; + } else if (response.statusCode === 404) { + // The v2 API returns 404 (google.rpc.Code.NOT_FOUND) when the agent + // registration expires. We should re-register. + callback(null, response as {} as t.Response); + return; + } else if (response.statusCode !== 200) { + callback( + new Error( + 'unable to list breakpoints, status code ' + response.statusCode + ) + ); + return; + } else { + body = body || {}; + that.nextWaitToken = body.nextWaitToken; + callback(null, response as {} as t.Response, body); + } + } + ); + } + + /** + * Update the server about breakpoint state + * @param {!Debuggee} debuggee + * @param {!Breakpoint} breakpoint + * @param {!Function} callback accepting (err, body) + */ + updateBreakpoint( + debuggee: Debuggee, + breakpoint: stackdriver.Breakpoint, + callback: (err?: Error, body?: {}) => void + ): void { + assert(debuggee.id, 'should have a registered debuggee'); + + breakpoint.action = 'CAPTURE'; + breakpoint.isFinalState = true; + const options = { + uri: + this.apiUrl + + '/debuggees/' + + encodeURIComponent(debuggee.id) + + // TODO: Address the case where `breakpoint.id` is `undefined`. + '/breakpoints/' + + encodeURIComponent(breakpoint.id as string), + json: true, + method: 'PUT', + body: {debuggeeId: debuggee.id, breakpoint}, + }; + + // We need to have a try/catch here because a JSON.stringify will be done + // by request. Some V8 debug mirror objects get a throw when we attempt to + // stringify them. The try-catch keeps it resilient and avoids crashing the + // user's app. + try { + this.request(options, (err, body /*, response */) => { + callback(err!, body); + }); + } catch (error) { + callback(error); + } + } + + subscribeToBreakpoints( + debuggee: Debuggee, + callback: (err: Error | null, breakpoints: stackdriver.Breakpoint[]) => void + ): void { + if (!this.fetcherActive) { + this.scheduleBreakpointFetch_(debuggee, 0, false, callback); + } + } + + scheduleBreakpointFetch_( + debuggee: Debuggee, + seconds: number, + once: boolean, + callback: (err: Error | null, breakpoints: stackdriver.Breakpoint[]) => void + ): void { + // eslint-disable-next-line @typescript-eslint/no-this-alias + const that = this; + if (!once) { + that.fetcherActive = true; + } + setTimeout(() => { + if (!that.running) { + return; + } + + that.logger.info('Fetching breakpoints'); + if (!once) { + that.fetcherActive = true; + } + // TODO: Address the case when `that.debuggee` is `null`. + that.listBreakpoints(debuggee, (err, response, body) => { + if (err) { + that.logger.error( + 'Error fetching breakpoints – scheduling retry', + err + ); + // Return the error, prompting a re-registration. + that.fetcherActive = false; + callback(err, []); + return; + } + switch (response!.statusCode) { + case 404: { + // Registration expired. Deactivate the fetcher and queue + // re-registration, which will re-active breakpoint fetching. + that.logger.info('\t404 Registration expired.'); + that.fetcherActive = false; + const expiredError = new Error(response!.statusMessage); + expiredError.name = 'RegistrationExpiredError'; + callback(expiredError, []); + return; + } + + default: + // TODO: Address the case where `response` is `undefined`. + that.logger.info('\t' + response!.statusCode + ' completed.'); + if (!body) { + that.logger.error('\tinvalid list response: empty body'); + that.scheduleBreakpointFetch_( + debuggee, + that.config.breakpointUpdateIntervalSec, + once, + callback + ); + return; + } + if (body.waitExpired) { + that.logger.info('\tLong poll completed.'); + that.scheduleBreakpointFetch_(debuggee, 0, once, callback); + return; + } + // eslint-disable-next-line no-case-declarations + const bps = (body.breakpoints || []).filter( + (bp: stackdriver.Breakpoint) => { + const action = bp.action || 'CAPTURE'; + if (action !== 'CAPTURE' && action !== 'LOG') { + that.logger.warn( + 'Found breakpoint with invalid action:', + action + ); + bp.status = new StatusMessage( + StatusMessage.UNSPECIFIED, + BREAKPOINT_ACTION_MESSAGE, + true + ); + that.updateBreakpoint(debuggee, bp, (err /*, body*/) => { + if (err) { + that.logger.error( + 'Unable to complete breakpoint on server', + err + ); + } + }); + return false; + } + return true; + } + ); + callback(null, bps); + that.scheduleBreakpointFetch_( + debuggee, + that.config.breakpointUpdateIntervalSec, + once, + callback + ); + } + return; + }); + }, seconds * 1000).unref(); + } + + stop(): void { + this.running = false; + } +} diff --git a/src/index.ts b/src/index.ts index 1b42c1d3..8d2c0858 100644 --- a/src/index.ts +++ b/src/index.ts @@ -19,6 +19,9 @@ import {Debug} from './client/stackdriver/debug'; // eslint-disable-next-line @typescript-eslint/no-var-requires const pjson = require('../../package.json'); +import * as util from 'util'; +const debuglog = util.debuglog('cdbg'); + // Singleton. let debuglet: Debuglet; @@ -47,8 +50,14 @@ export function start( throw new Error('Debug Agent has already been started'); } - const debug = new Debug(options, pjson); - debuglet = new Debuglet(debug, agentConfig); + if (agentConfig.useFirebase) { + debuglog('Running with experimental firebase backend.'); + debuglet = new Debuglet({packageInfo: pjson} as Debug, agentConfig); + } else { + const debug = new Debug(options, pjson); + debuglet = new Debuglet(debug, agentConfig); + } + debuglet.start(); return agentConfig.testMode_ ? debuglet : debuglet.isReadyManager; diff --git a/system-test/test-controller.ts b/system-test/test-controller.ts index aa9448c8..4ee11905 100644 --- a/system-test/test-controller.ts +++ b/system-test/test-controller.ts @@ -26,9 +26,11 @@ assert.ok( ); import * as stackdriver from '../src/types/stackdriver'; -import {Controller} from '../src/agent/controller'; +import {OnePlatformController} from '../src/agent/oneplatform-controller'; import {Debuggee} from '../src/debuggee'; import {Debug, PackageInfo} from '../src/client/stackdriver/debug'; +import {defaultConfig as DEFAULT_CONFIG} from '../src/agent/config'; +import {MockLogger} from '../test/mock-logger'; const packageInfo: PackageInfo = { name: 'SomeName', @@ -40,9 +42,10 @@ const debug = new Debug({}, packageInfo); describe('Controller', function () { this.timeout(60 * 1000); + const logger = new MockLogger(); it('should register successfully', done => { - const controller = new Controller(debug); + const controller = new OnePlatformController(debug, DEFAULT_CONFIG, logger); const debuggee = new Debuggee({ project: process.env.GCLOUD_PROJECT, uniquifier: 'test-uid-' + Date.now(), @@ -61,7 +64,7 @@ describe('Controller', function () { }); it('should list breakpoints', done => { - const controller = new Controller(debug); + const controller = new OnePlatformController(debug, DEFAULT_CONFIG, logger); const debuggee = new Debuggee({ project: process.env.GCLOUD_PROJECT, uniquifier: 'test-uid-' + Date.now(), @@ -85,7 +88,7 @@ describe('Controller', function () { it('should pass success on timeout', done => { this.timeout(100000); - const controller = new Controller(debug); + const controller = new OnePlatformController(debug, DEFAULT_CONFIG, logger); const debuggee = new Debuggee({ project: process.env.GCLOUD_PROJECT, uniquifier: 'test-uid-' + Date.now(), diff --git a/test/test-controller.ts b/test/test-controller.ts index 0f825168..7637f1aa 100644 --- a/test/test-controller.ts +++ b/test/test-controller.ts @@ -18,15 +18,17 @@ import * as nock from 'nock'; import {Debug} from '../src/client/stackdriver/debug'; import {Debuggee} from '../src/debuggee'; +import {defaultConfig as DEFAULT_CONFIG} from '../src/agent/config'; import * as stackdriver from '../src/types/stackdriver'; import * as t from 'teeny-request'; // types only import {teenyRequest} from 'teeny-request'; +import {MockLogger} from './mock-logger'; // the tests in this file rely on the GCLOUD_PROJECT environment variable // not being set delete process.env.GCLOUD_PROJECT; -import {Controller} from '../src/agent/controller'; +import {OnePlatformController} from '../src/agent/oneplatform-controller'; // TODO: Fix fakeDebug to actually implement Debug. const fakeDebug = { apiEndpoint: 'clouddebugger.googleapis.com', @@ -44,6 +46,8 @@ const api = '/v2/controller'; nock.disableNetConnect(); describe('Controller API', () => { + const logger = new MockLogger(); + describe('register', () => { it('should get a debuggeeId', done => { const scope = nock(url) @@ -58,7 +62,11 @@ describe('Controller API', () => { description: 'unit test', agentVersion, }); - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); // TODO: Determine if this type signature is correct. controller.register(debuggee, (err, result) => { assert(!err, 'not expecting an error'); @@ -83,7 +91,11 @@ describe('Controller API', () => { description: 'unit test', agentVersion, }); - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); // TODO: Determine if this type signature is correct. controller.register(debuggee, (err, result) => { assert(!err, 'not expecting an error'); @@ -107,7 +119,11 @@ describe('Controller API', () => { description: 'unit test', agentVersion, }); - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); controller.register(debuggee, (err, result) => { // TODO: Fix this incorrect method signature. (assert as {ifError: Function}).ifError(err, 'not expecting an error'); @@ -135,7 +151,11 @@ describe('Controller API', () => { description: 'unit test', agentVersion, }); - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); controller.register(debuggee, (err /*, result*/) => { assert.ifError(err); done(); @@ -148,7 +168,11 @@ describe('Controller API', () => { .reply(200, {kind: 'whatever'}); const debuggee = {id: 'fake-debuggee'}; - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); // TODO: Fix debuggee to actually implement Debuggee // TODO: Determine if the response parameter should be used. controller.listBreakpoints( @@ -176,7 +200,11 @@ describe('Controller API', () => { ) .reply(200, invalidResponse); const debuggee = {id: 'fake-debuggee'}; - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); // TODO: Fix debuggee to actually implement Debuggee // TODO: Determine if the response parameter should be used. controller.listBreakpoints( @@ -202,7 +230,11 @@ describe('Controller API', () => { .reply(403); // TODO: Fix debuggee to actually implement Debuggee const debuggee: Debuggee = {id: 'fake-debuggee'} as Debuggee; - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); // TODO: Determine if the response parameter should be used. controller.listBreakpoints(debuggee, (err, response, result) => { assert(err instanceof Error, 'expecting an error'); @@ -218,7 +250,11 @@ describe('Controller API', () => { .reply(200, {waitExpired: true}); // TODO: Fix debuggee to actually implement Debuggee const debuggee: Debuggee = {id: 'fake-debuggee'} as Debuggee; - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); // TODO: Determine if the result parameter should be used. controller.listBreakpoints(debuggee, (err, response) => { // TODO: Fix this incorrect method signature. @@ -253,7 +289,11 @@ describe('Controller API', () => { description: 'unit test', agentVersion, }); - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); controller.register(debuggee, (err1 /*, response1*/) => { assert.ifError(err1); const debuggeeWithId: Debuggee = {id: 'fake-debuggee'} as Debuggee; @@ -282,7 +322,11 @@ describe('Controller API', () => { .reply(200, {breakpoints}); // TODO: Fix debuggee to actually implement Debuggee const debuggee: Debuggee = {id: 'fake-debuggee'} as Debuggee; - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); // TODO: Determine if the response parameter should be used. controller.listBreakpoints(debuggee, (err, response, result) => { assert(!err, 'not expecting an error'); @@ -321,7 +365,11 @@ describe('Controller API', () => { }); // TODO: Fix debuggee to actually implement Debuggee const debuggee: Debuggee = {id: 'fake-debuggee'} as Debuggee; - const controller = new Controller(fakeDebug); + const controller = new OnePlatformController( + fakeDebug, + DEFAULT_CONFIG, + logger + ); controller.updateBreakpoint( debuggee as Debuggee, breakpoint, diff --git a/test/test-debuglet.ts b/test/test-debuglet.ts index d65ac138..7a02ce2e 100644 --- a/test/test-debuglet.ts +++ b/test/test-debuglet.ts @@ -1188,7 +1188,6 @@ describe('Debuglet', () => { .post(REGISTER_PATH) .reply(200, {debuggee: {id: DEBUGGEE_ID}}) .get(BPS_PATH + '?successOnTimeout=true') - .twice() .reply(200, {breakpoints: [breakpoint]}); const debugPromise = debuglet.isReadyManager.isReady(); debuglet.once('registered', () => { @@ -1437,6 +1436,7 @@ describe('Debuglet', () => { forceNewAgent_: true, }); const debuglet = new Debuglet(debug, config); + let unexpectedUpdate = false; const scope = nock(config.apiUrl) .post(REGISTER_PATH) .reply(200, {debuggee: {id: DEBUGGEE_ID}}) @@ -1456,15 +1456,20 @@ describe('Debuglet', () => { .times(4) .reply(200, {breakpoints: [bp]}); + // Get ready to fail the test if any additional updates come through. + nock.emitter.on('no match', req => { + if (req.path.startsWith(BPS_PATH) && req.method === 'PUT') { + unexpectedUpdate = true; + } + }); + debuglet.once('registered', (id: string) => { assert.strictEqual(id, DEBUGGEE_ID); setTimeout(() => { assert.deepStrictEqual(debuglet.activeBreakpointMap.test, bp); setTimeout(() => { assert(!debuglet.activeBreakpointMap.test); - // Fetcher disables if we re-update since endpoint isn't mocked - // twice - assert(debuglet.fetcherActive); + assert(!unexpectedUpdate, 'unexpected update request'); debuglet.stop(); scope.done(); done(); diff --git a/test/test-firebase-controller.ts b/test/test-firebase-controller.ts new file mode 100644 index 00000000..5e1b29d7 --- /dev/null +++ b/test/test-firebase-controller.ts @@ -0,0 +1,329 @@ +// Copyright 2022 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import * as assert from 'assert'; +import {describe, it} from 'mocha'; + +import {Debuggee} from '../src/debuggee'; +import * as stackdriver from '../src/types/stackdriver'; +import * as firebase from 'firebase-admin'; + +import {FirebaseController} from '../src/agent/firebase-controller'; +import {DataSnapshot, EventType, Reference} from '@firebase/database-types'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +class MockSnapshot { + key: string; + value: any; + constructor(key: string, value: any) { + this.key = key; + this.value = value; + } + + val() { + return this.value; + } +} + +class MockReference { + key: string; + value?: any; + parentRef?: MockReference; + children = new Map(); + // Simplification: there's only one listener for each event type. + listeners = new Map any>(); + + constructor(key: string, parentRef?: MockReference) { + this.key = key.slice(); + this.parentRef = parentRef; + } + + remove(onComplete?: (a: Error | null) => any): Promise { + if (this.parentRef) { + this.parentRef.childRemoved(this.key); + this.parentRef.children.delete(this.key); + } + if (onComplete) { + onComplete(null); + } + return Promise.resolve(); + } + + getOrAdd(key: string): MockReference { + if (!this.children.has(key)) { + this.children.set(key, new MockReference(key, this)); + } + return this.children.get(key)!; + } + + childRemoved(key: string) { + if (this.listeners.has('child_removed')) { + this.listeners.get('child_removed')!( + new MockSnapshot(key, {}) as {} as DataSnapshot + ); + } + if (this.parentRef) { + this.parentRef.childRemoved(`${this.key}/${key}`); + } + } + + childAdded(key: string, value: any) { + if (this.listeners.has('child_added')) { + this.listeners.get('child_added')!( + new MockSnapshot(key, value) as {} as DataSnapshot + ); + } + if (this.parentRef) { + this.parentRef.childAdded(`${this.key}/${key}`, value); + } + } + + set(value: any, onComplete?: (a: Error | null) => any): Promise { + let creating = false; + if (!this.value) { + creating = true; + } + this.value = value; + if (onComplete) { + onComplete(null); + } + if (creating && this.parentRef) { + this.parentRef.childAdded(this.key, value); + } + return Promise.resolve(); + } + + on( + eventType: EventType, + callback: (a: DataSnapshot, b?: string | null) => any + ): (a: DataSnapshot | null, b?: string | null) => any { + this.listeners.set(eventType, callback); + + // Callback will be called with each existing child: https://firebase.google.com/docs/database/admin/retrieve-data#child-added + if (eventType === 'child_added') { + this.children.forEach(child => this.childAdded(child.key, child.value)); + } + // Don't care about return value. + return () => null; + } + + off() { + // No-op. Needed to cleanly detach in the real firebase implementation. + } +} +/* eslint-enable @typescript-eslint/no-explicit-any */ + +class MockDatabase { + root = new MockReference(''); + mockRef(path: string): MockReference { + const parts = path.split('/'); + let ref = this.root; + for (let i = 0; i < parts.length; i++) { + ref = ref.getOrAdd(parts[i]); + } + return ref; + } + ref(path: string): Reference { + return this.mockRef(path) as {} as Reference; + } +} + +describe('Firebase Controller', () => { + const debuggee = new Debuggee({ + project: 'fake-project', + uniquifier: 'fake-id', + description: 'unit test', + agentVersion: 'SomeName/client/SomeVersion', + }); + + describe('register', () => { + it('should get a debuggeeId', done => { + const db = new MockDatabase(); + // Debuggee Id is the md5 hash of the json representation of the debuggee. + const debuggeeId = 'e1662bd77f69383284621966af8eb5e9'; + const controller = new FirebaseController( + db as {} as firebase.database.Database + ); + controller.register(debuggee, (err, result) => { + assert(!err, 'not expecting an error'); + assert.ok(result); + assert.strictEqual(result!.debuggee.id, debuggeeId); + assert.strictEqual( + db.mockRef(`cdbg/debuggees/${debuggeeId}`).value, + debuggee + ); + done(); + }); + }); + }); + + describe('subscribeToBreakpoints', () => { + const breakpoints = [ + {id: 'breakpoint-0', location: {path: 'foo.js', line: 18}}, + {id: 'breakpoint-1', location: {path: 'bar.js', line: 23}}, + ]; + const debuggee: Debuggee = {id: 'fake-debuggee'} as Debuggee; + + it('should notice added and removed breakpoints', done => { + const db = new MockDatabase(); + const controller = new FirebaseController( + db as {} as firebase.database.Database + ); + controller.debuggeeId = 'debuggeeId'; + + // Add a breakpoint before listening. + db.mockRef(`cdbg/breakpoints/debuggeeId/active/${breakpoints[0].id}`).set( + breakpoints[0] + ); + + const expectedResults = [ + [breakpoints[0]], + [breakpoints[0], breakpoints[1]], + [breakpoints[1]], + ]; + let callbackCount = 0; + controller.subscribeToBreakpoints(debuggee, (err, bps) => { + assert(!err, 'not expecting an error'); + assert.deepStrictEqual( + bps, + expectedResults[callbackCount], + 'breakpoints mismatch' + ); + callbackCount++; + if (callbackCount === expectedResults.length) { + controller.stop(); + done(); + } + }); + + db.mockRef(`cdbg/breakpoints/debuggeeId/active/${breakpoints[1].id}`).set( + breakpoints[1] + ); + db.mockRef( + `cdbg/breakpoints/debuggeeId/active/${breakpoints[0].id}` + ).remove(); + }); + }); + + describe('updateBreakpoint', () => { + it('should update the database correctly for snapshots', done => { + const breakpointId = 'breakpointId'; + const debuggeeId = 'debuggeeId'; + const breakpoint: stackdriver.Breakpoint = { + id: breakpointId, + action: 'CAPTURE', + location: {path: 'foo.js', line: 99}, + } as stackdriver.Breakpoint; + const debuggee: Debuggee = {id: 'fake-debuggee'} as Debuggee; + const db = new MockDatabase(); + const controller = new FirebaseController( + db as {} as firebase.database.Database + ); + controller.debuggeeId = debuggeeId; + + let removed = false; + db.ref(`cdbg/breakpoints/${debuggeeId}/active`).on( + 'child_removed', + data => { + assert.strictEqual(data.key, breakpointId); + removed = true; + } + ); + + let finalized = false; + db.ref(`cdbg/breakpoints/${debuggeeId}/final`).on('child_added', data => { + assert.strictEqual(data.key, breakpointId); + assert.deepStrictEqual(data.val(), { + ...breakpoint, + isFinalState: true, + finalTimeUnixMsec: {'.sv': 'timestamp'}, + }); + finalized = true; + }); + + let snapshotted = false; + db.ref(`cdbg/breakpoints/${debuggeeId}/snapshot`).on( + 'child_added', + data => { + assert.strictEqual(data.key, breakpointId); + assert.deepStrictEqual(data.val(), { + ...breakpoint, + isFinalState: true, + finalTimeUnixMsec: {'.sv': 'timestamp'}, + }); + snapshotted = true; + } + ); + + controller.updateBreakpoint(debuggee as Debuggee, breakpoint, err => { + assert(!err, 'not expecting an error'); + assert(removed, 'should have been removed'); + assert(finalized, 'should have been finalized'); + assert(snapshotted, 'should have been snapshotted'); + done(); + }); + }); + it('should update the database correctly for logpoints', done => { + const breakpointId = 'breakpointId'; + const debuggeeId = 'debuggeeId'; + const breakpoint: stackdriver.Breakpoint = { + id: breakpointId, + action: 'LOG', + location: {path: 'foo.js', line: 99}, + } as stackdriver.Breakpoint; + const debuggee: Debuggee = {id: 'fake-debuggee'} as Debuggee; + const db = new MockDatabase(); + const controller = new FirebaseController( + db as {} as firebase.database.Database + ); + controller.debuggeeId = debuggeeId; + + let removed = false; + db.ref(`cdbg/breakpoints/${debuggeeId}/active`).on( + 'child_removed', + data => { + assert.strictEqual(data.key, breakpointId); + removed = true; + } + ); + + let finalized = false; + db.ref(`cdbg/breakpoints/${debuggeeId}/final`).on('child_added', data => { + assert.strictEqual(data.key, breakpointId); + assert.deepStrictEqual(data.val(), { + ...breakpoint, + isFinalState: true, + finalTimeUnixMsec: {'.sv': 'timestamp'}, + }); + finalized = true; + }); + + let snapshotted = false; + db.ref(`cdbg/breakpoints/${debuggeeId}/snapshot`).on( + 'child_added', + () => { + snapshotted = true; + } + ); + + controller.updateBreakpoint(debuggee as Debuggee, breakpoint, err => { + assert(!err, 'not expecting an error'); + assert(removed, 'should have been removed'); + assert(finalized, 'should have been finalized'); + assert(!snapshotted, 'should not have been snapshotted'); + done(); + }); + }); + }); +});