From 9dcd57379b6c674c53261b362350cec992918348 Mon Sep 17 00:00:00 2001 From: Rob Chartier Date: Wed, 9 Dec 2020 17:14:16 -0800 Subject: [PATCH] Fixed issue with intermittent installs (#129) --- packages/appengine/src/appObject.ts | 14 +- .../appengine/src/applying/appliers/object.ts | 421 +++++++++--------- packages/appengine/src/mixins/createApply.ts | 69 ++- .../src/templates/latest/deployment.ts | 2 - packages/appengine/src/ui/main.ts | 5 +- .../src/ui/views/appEngineBaseView.ts | 4 - 6 files changed, 272 insertions(+), 243 deletions(-) diff --git a/packages/appengine/src/appObject.ts b/packages/appengine/src/appObject.ts index 4b7edfbf..8ba6a49f 100644 --- a/packages/appengine/src/appObject.ts +++ b/packages/appengine/src/appObject.ts @@ -1,5 +1,5 @@ import { LabelsMetadata } from "./parsing" -import * as fs from 'fs' +//import * as fs from 'fs' import createDebug from 'debug' const debug = createDebug('@appengine:timing') @@ -15,10 +15,9 @@ export interface TimingReporter { } export class AppEngineState { - timing: Array + timing: AppProvisionerTimer[] labels: LabelsMetadata args: any - payload: any parsed: boolean platform: string timestamp: Date @@ -55,7 +54,7 @@ export class AppEngineState { } - constructor(labels: LabelsMetadata, args?: any, payload?: any) { + constructor(labels: LabelsMetadata, args?: any) { this.timing = new Array() this.labels = labels this.parsed = false @@ -70,11 +69,6 @@ export class AppEngineState { this.args = {} else this.args = args - - if (payload === undefined) - this.payload = {} - else - this.payload = payload } } export class AppProvisionerTimer { @@ -195,7 +189,7 @@ export class Helper { if (!file) file = 'debug.json' file = `${__dirname}/${file}` if(!file.endsWith('.json')) file = `${file}.json` - fs.writeFileSync(file, JSON.stringify(json, null, 2)) + //fs.writeFileSync(file, JSON.stringify(json, null, 2)) debug(file, json) return file } diff --git a/packages/appengine/src/applying/appliers/object.ts b/packages/appengine/src/applying/appliers/object.ts index 0d7089cd..09c58b77 100644 --- a/packages/appengine/src/applying/appliers/object.ts +++ b/packages/appengine/src/applying/appliers/object.ts @@ -13,227 +13,236 @@ export class ObjectApplier implements Applier { // eslint-disable-next-line @typescript-eslint/no-explicit-any async apply(manifest: AppManifest, state: AppEngineState, manager: ProvisionerManager) { + try { + state.startTimer('object-apply') + + const deployment = await templates.getDeploymentTemplate( + manifest.name, + manifest.namespace, + manifest.provisioner.image, + state.labels, + manifest.provisioner.tag, + manifest.provisioner.imagePullPolicy, + manifest.provisioner.command, + ) + + // if (spec.link) { + // //we have features/dependancies to deal with, lets jump to that first + // await this.installFeatures(manifest.namespace, spec, manager) + // } + + debug('applying secrets') + await this.applySecrets(manifest, state, manager, deployment) + debug('applying configs') + await this.applyConfigs(manifest, state, manager, deployment) + debug('applying ports') + await this.applyPorts(manifest, state, manager, deployment) + debug('applying volumes') + await this.applyVolumes(manifest, state, manager, deployment) + debug('applying deployment') + await this.applyDeployment(manifest, state, manager, deployment) + debug('done') + + state.endTimer('object-apply') + } catch (e) { + debug('APPX apply', JSON.stringify(e)) + } - state.startTimer('object-apply') - - const deployment = await templates.getDeploymentTemplate( - manifest.provisioner.name, - manifest.namespace, - manifest.provisioner.image, - state.labels, - manifest.provisioner.tag, - manifest.provisioner.imagePullPolicy, - manifest.provisioner.command, - ) - - // if (spec.link) { - // //we have features/dependancies to deal with, lets jump to that first - // await this.installFeatures(manifest.namespace, spec, manager) - // } - - debug('applying secrets') - await this.applySecrets(manifest, state, manager, deployment) - debug('applying configs') - await this.applyConfigs(manifest, state, manager, deployment) - debug('applying ports') - await this.applyPorts(manifest, state, manager, deployment) - debug('applying volumes') - await this.applyVolumes(manifest, state, manager, deployment) - debug('applying deployment') - await this.applyDeployment(manifest, state, manager, deployment) - debug('done') - - state.endTimer('object-apply') } // eslint-disable-next-line @typescript-eslint/no-explicit-any async applyDeployment(manifest: AppManifest, state: AppEngineState, manager: ProvisionerManager, deployment: any) { - state.startTimer('apply-deployment') - - debug(`Installing the Deployment:${JSON.stringify(deployment)}`) - this.helper.PrettyPrintJsonFile(deployment, `${manifest.appId}-deployment.json`) - - await manager.cluster - .begin(`Installing the Deployment for ${manifest.displayName}`) - .addOwner(manager.document) - .upsert(deployment) - .end() - - state.endTimer('apply-deployment') + try { + state.startTimer('apply-deployment') + debug(`Installing the Deployment:${JSON.stringify(deployment)}`) + await manager.cluster + .begin(`Installing the Deployment for ${manifest.displayName}`) + .addOwner(manager.document) + .upsert(deployment) + .end() + state.endTimer('apply-deployment') + } catch (e) { + debug('APPX applyDeployment', JSON.stringify(e), JSON.stringify(deployment)) + } } // eslint-disable-next-line @typescript-eslint/no-explicit-any async applyVolumes(manifest: AppManifest, state: AppEngineState, manager: ProvisionerManager, deployment: any) { - state.startTimer('apply-volumes') - - if (manifest.provisioner.volumes?.length) { + try { + state.startTimer('apply-volumes') + if (manifest.provisioner.volumes?.length) { - if (!deployment.spec.template.spec.containers[0].volumeMounts) - deployment.spec.template.spec.containers[0].volumeMounts = [] + if (!deployment.spec.template.spec.containers[0].volumeMounts) + deployment.spec.template.spec.containers[0].volumeMounts = [] - if (!deployment.spec.template.spec.volumes) - deployment.spec.template.spec.volumes = [] + if (!deployment.spec.template.spec.volumes) + deployment.spec.template.spec.volumes = [] - for (const item of manifest.provisioner.volumes) { + for (const item of manifest.provisioner.volumes) { - if (item.size && item.size !== '') { - const pvc = templates.getPVCTemplate(item, manifest.namespace, state.labels) + if (item.size && item.size !== '') { + const pvc = templates.getPVCTemplate(item, manifest.namespace, state.labels) - debug(`Installing Volume Claim:${JSON.stringify(pvc)}`) - this.helper.PrettyPrintJsonFile(pvc, `${manifest.appId}-pvc.json`) + debug(`Installing Volume Claim:${JSON.stringify(pvc)}`) + await manager.cluster + .begin(`Installing the Volume Claim for ${manifest.displayName}`) + //TODO: Advanced installer needs to choose the volumes to delete + .addOwner(manager.document) + .upsert(pvc) + .end() - await manager.cluster - .begin(`Installing the Volume Claim for ${manifest.displayName}`) - //TODO: Advanced installer needs to choose the volumes to delete - .addOwner(manager.document) - .upsert(pvc) - .end() - - deployment.spec.template.spec.volumes.push({ name: item.name, persistentVolumeClaim: { claimName: item.name } }) - } + deployment.spec.template.spec.volumes.push({ name: item.name, persistentVolumeClaim: { claimName: item.name } }) + } - if (item.mountPath && item.mountPath !== '') { - const mount = { name: item.name, mountPath: item.mountPath, subPath: undefined } + if (item.mountPath && item.mountPath !== '') { + const mount = { name: item.name, mountPath: item.mountPath, subPath: undefined } - if (item.subPath && item.subPath !== '') - mount.subPath = item.subPath - else - delete mount.subPath + if (item.subPath && item.subPath !== '') + mount.subPath = item.subPath + else + delete mount.subPath - deployment.spec.template.spec.containers[0].volumeMounts.push(mount) + deployment.spec.template.spec.containers[0].volumeMounts.push(mount) + } } - } + } + state.endTimer('apply-volumes') + } catch (e) { + debug('APPX applyVolumes', JSON.stringify(e)) } - state.endTimer('apply-volumes') } // eslint-disable-next-line @typescript-eslint/no-explicit-any async applyPorts(manifest: AppManifest, state: AppEngineState, manager: ProvisionerManager, deployment: any) { - state.startTimer('apply-ports') - - if (manifest.provisioner.ports?.length) { + try { + state.startTimer('apply-ports') + if (manifest.provisioner.ports?.length) { - const service = templates.getPortTemplate(manifest.appId, manifest.namespace, state.labels) + const service = templates.getPortTemplate(manifest.appId, manifest.namespace, state.labels) - if (!deployment.spec.template.spec.containers[0].ports) - deployment.spec.template.spec.containers[0].ports = [] + if (!deployment.spec.template.spec.containers[0].ports) + deployment.spec.template.spec.containers[0].ports = [] - for (const item of manifest.provisioner.ports) { - if (item.protocol) item.protocol = item.protocol.toUpperCase() - service.spec.ports.push({ name: item.name, port: item.port, targetPort: item.targetPort, protocol: item.protocol }) - deployment.spec.template.spec.containers[0].ports.push({ name: item.name, containerPort: item.port }) + for (const item of manifest.provisioner.ports) { + if (item.protocol) item.protocol = item.protocol.toUpperCase() + service.spec.ports.push({ name: item.name, port: item.port, targetPort: item.targetPort, protocol: item.protocol }) + deployment.spec.template.spec.containers[0].ports.push({ name: item.name, containerPort: item.port }) - if (item.probe?.length) { - //we have probes - for (const probe of item.probe) { - if (!probe.port || probe.port <= 0) probe.port = item.port //allow for a more terse syntax in the yaml; no need to specifiy the port, it will adopt the port from the parent instance + if (item.probe?.length) { + //we have probes + for (const probe of item.probe) { + if (!probe.port || probe.port <= 0) probe.port = item.port //allow for a more terse syntax in the yaml; no need to specifiy the port, it will adopt the port from the parent instance - const template = templates.getProbeTemplate(probe) + const template = templates.getProbeTemplate(probe) - if (template) { - debug('Probe will be applied to deployment container; template: ', template) - deployment.spec.template.spec.containers[0] = { ...deployment.spec.template.spec.containers[0], ...template } - debug('Probe applied to deployment container, container: ', deployment.spec.template.spec.containers[0]) + if (template) { + debug('Probe will be applied to deployment container; template: ', JSON.stringify(template)) + deployment.spec.template.spec.containers[0] = { ...deployment.spec.template.spec.containers[0], ...template } + debug('Probe applied to deployment container, container: ', JSON.stringify(deployment.spec.template.spec.containers[0])) + } } } } - } - debug(`Installing Networking Services:${JSON.stringify(deployment)}|${JSON.stringify(deployment.spec.template.spec.containers[0].ports)}`,) - this.helper.PrettyPrintJsonFile(service, `${manifest.appId}-service.json`) + debug(`Installing Networking Services:${JSON.stringify(deployment)}|${JSON.stringify(deployment.spec.template.spec.containers[0].ports)}`,) - await manager.cluster - .begin(`Installing the Networking Services for ${manifest.displayName}`) - .addOwner(manager.document) - .upsert(service) - .end() + await manager.cluster + .begin(`Installing the Networking Services for ${manifest.displayName}`) + .addOwner(manager.document) + .upsert(service) + .end() + } + state.endTimer('apply-ports') + } catch (e) { + debug('APPX applyPorts', JSON.stringify(e)) } - state.endTimer('apply-ports') } // eslint-disable-next-line @typescript-eslint/no-explicit-any async applyConfigs(manifest: AppManifest, state: AppEngineState, manager: ProvisionerManager, deployment: any) { - state.startTimer('apply-configs') - - if (!manifest.provisioner.configs?.length) manifest.provisioner.configs = [] + try { + state.startTimer('apply-configs') + if (!manifest.provisioner.configs?.length) manifest.provisioner.configs = [] - //provide some basic codezero app details to the provisioner - manifest.provisioner.configs.push({ name: 'name', value: state.labels.appId, env: 'CZ_APP' }) - manifest.provisioner.configs.push({ name: 'edition', value: state.labels.edition, env: 'CZ_EDITION' }) - manifest.provisioner.configs.push({ name: 'instanceId', value: state.labels.instanceId, env: 'CZ_INSTANCE_ID' }) + //provide some basic codezero app details to the provisioner + manifest.provisioner.configs.push({ name: 'name', value: state.labels.appId, env: 'CZ_APP' }) + manifest.provisioner.configs.push({ name: 'edition', value: state.labels.edition, env: 'CZ_EDITION' }) + manifest.provisioner.configs.push({ name: 'instanceId', value: state.labels.instanceId, env: 'CZ_INSTANCE_ID' }) - const config = templates.getConfigTemplate(manifest.appId, manifest.namespace, state.labels) + const config = templates.getConfigTemplate(manifest.appId, manifest.namespace, state.labels) - for (const item of manifest.provisioner.configs) { - if (!item.env || item.env === '') item.env = item.name + for (const item of manifest.provisioner.configs) { + if (!item.env || item.env === '') item.env = item.name - if (item.value === '$PUBLIC_DNS') { - item.value = '' - } - config.data[item.name] = String(item.value) - if (item.env !== 'NONE') { - deployment.spec.template.spec.containers[0].env.push( - { - name: item.env, - valueFrom: { - configMapKeyRef: { - name: config.metadata.name, - key: item.name + if (item.value === '$PUBLIC_DNS') { + item.value = '' + } + config.data[item.name] = String(item.value) + if (item.env !== 'NONE') { + deployment.spec.template.spec.containers[0].env.push( + { + name: item.env, + valueFrom: { + configMapKeyRef: { + name: config.metadata.name, + key: item.name + } } - } - }) + }) + } } - } - // if(spec.name === 'mysql') { - - // const configAuth = { - // apiVersion: 'v1', - // kind: 'ConfigMap', - // metadata: { - // namespace, - // name: 'mysql-config', - // labels: { - // app: spec.name - // } - // }, - // data: { - // 'default_auth': '[mysqld]\ndefault_authentication_plugin=mysql_native_password' - // } - // } - - // await manager.cluster - // .begin('Installing mysql specific configuration') - // .addOwner(manager.document) - // .upsert(configAuth) - // .end() - - // if(!deployment.spec.template.spec.volumes) - // deployment.spec.template.spec.volumes = [] - - // if(!deployment.spec.template.spec.containers[0].volumeMounts) - // deployment.spec.template.spec.containers[0].volumeMounts = [] - - - // deployment.spec.template.spec.volumes.push({ name: 'mysql-config-volume', configMap: { name: 'mysql-config' }}) - // deployment.spec.template.spec.containers[0].volumeMounts.push({ name: 'mysql-config-volume', mountPath: '/etc/mysql/conf.d/default_auth.cnf', subPath: 'default_auth' }) - - // } - - debug(`Installing configs:${JSON.stringify(deployment.spec.template.spec.containers[0].env)}`,) - this.helper.PrettyPrintJsonFile(config, `${manifest.appId}-config.json`) + // if(spec.name === 'mysql') { + + // const configAuth = { + // apiVersion: 'v1', + // kind: 'ConfigMap', + // metadata: { + // namespace, + // name: 'mysql-config', + // labels: { + // app: spec.name + // } + // }, + // data: { + // 'default_auth': '[mysqld]\ndefault_authentication_plugin=mysql_native_password' + // } + // } + + // await manager.cluster + // .begin('Installing mysql specific configuration') + // .addOwner(manager.document) + // .upsert(configAuth) + // .end() + + // if(!deployment.spec.template.spec.volumes) + // deployment.spec.template.spec.volumes = [] + + // if(!deployment.spec.template.spec.containers[0].volumeMounts) + // deployment.spec.template.spec.containers[0].volumeMounts = [] + + + // deployment.spec.template.spec.volumes.push({ name: 'mysql-config-volume', configMap: { name: 'mysql-config' }}) + // deployment.spec.template.spec.containers[0].volumeMounts.push({ name: 'mysql-config-volume', mountPath: '/etc/mysql/conf.d/default_auth.cnf', subPath: 'default_auth' }) + + // } + + debug(`Installing configs:${JSON.stringify(deployment.spec.template.spec.containers[0].env)}`,) + await manager.cluster + .begin(`Installing the Configuration Settings for ${manifest.displayName}`) + .addOwner(manager.document) + .upsert(config) + .end() - await manager.cluster - .begin(`Installing the Configuration Settings for ${manifest.displayName}`) - .addOwner(manager.document) - .upsert(config) - .end() + state.endTimer('apply-configs') + } catch (e) { + debug('APPX applyConfigs', JSON.stringify(e)) + } - state.endTimer('apply-configs') } @@ -241,57 +250,61 @@ export class ObjectApplier implements Applier { // eslint-disable-next-line @typescript-eslint/no-explicit-any async applySecrets(manifest: AppManifest, state: AppEngineState, manager: ProvisionerManager, deployment: any) { - state.startTimer('apply-secrets') + try { + state.startTimer('apply-secrets') - if (manifest.provisioner.secrets && manifest.provisioner.secrets.length > 0) { + if (manifest.provisioner.secrets && manifest.provisioner.secrets.length > 0) { - const secret = templates.getSecretTemplate(manifest.appId, manifest.namespace, state.labels) + const secret = templates.getSecretTemplate(manifest.appId, manifest.namespace, state.labels) - for (const item of manifest.provisioner.secrets) { + for (const item of manifest.provisioner.secrets) { - if (!item.env || item.env === '') item.env = item.name + if (!item.env || item.env === '') item.env = item.name - let val = String(item.value)?.trim() - if (val !== '') { - if (val.startsWith('$RANDOM')) { - if (val === '$RANDOM') - val = this.helper.makeRandom(10) - else { - if (val.indexOf(':') > 0) { - const len = Number(val.substr(val.indexOf(':') + 1)) - val = this.helper.makeRandom(len) + let val = String(item.value)?.trim() + if (val !== '') { + if (val.startsWith('$RANDOM')) { + if (val === '$RANDOM') + val = this.helper.makeRandom(10) + else { + if (val.indexOf(':') > 0) { + const len = Number(val.substr(val.indexOf(':') + 1)) + val = this.helper.makeRandom(len) + } } } - } - const value = Buffer.from(val).toString('base64') - secret.data[item.name] = value - if (item.env !== '$NONE') { - deployment.spec.template.spec.containers[0].env.push( - { - name: item.env, - valueFrom: { - secretKeyRef: { - name: secret.metadata.name, - key: item.name + const value = Buffer.from(val).toString('base64') + secret.data[item.name] = value + if (item.env !== '$NONE') { + deployment.spec.template.spec.containers[0].env.push( + { + name: item.env, + valueFrom: { + secretKeyRef: { + name: secret.metadata.name, + key: item.name + } } - } - }) + }) + } } } - } - debug(`Installing secrets:${JSON.stringify(deployment.spec.template.spec.containers[0].env)}`) - this.helper.PrettyPrintJsonFile(secret, `${manifest.appId}-secret.json`) + debug(`Installing secrets:${JSON.stringify(deployment.spec.template.spec.containers[0].env)}`) - await manager.cluster - .begin(`Installing the Secrets for ${manifest.displayName}`) - .addOwner(manager.document) - .upsert(secret) - .end() + await manager.cluster + .begin(`Installing the Secrets for ${manifest.displayName}`) + .addOwner(manager.document) + .upsert(secret) + .end() + } + + state.endTimer('apply-secrets') + } catch (e) { + debug('APPX applySecrets', JSON.stringify(e)) } - state.endTimer('apply-secrets') } diff --git a/packages/appengine/src/mixins/createApply.ts b/packages/appengine/src/mixins/createApply.ts index e799e355..cd57eef6 100644 --- a/packages/appengine/src/mixins/createApply.ts +++ b/packages/appengine/src/mixins/createApply.ts @@ -1,7 +1,7 @@ import { baseProvisionerType } from '../index' import { ApplierFactory as applierFactory } from '../applying/' import createDebug from 'debug' -import { AppObject, AppManifest, TimingReporter } from '../appObject' +import { AppObject, AppManifest, TimingReporter, AppEngineState } from '../appObject' const debug = createDebug('@appengine:createApply') @@ -20,35 +20,60 @@ export const createApplyMixin = (base: baseProvisionerType) => class extends bas } async createApply() { const manifest = new AppObject(this.manager.document) as AppManifest - this.state.startTimer('apply') - await this.ensureServiceNamespacesExist() - await this.installApp(manifest) - await this.ensureAppIsRunning(manifest) - this.state.endTimer('apply') - this.helper.PrettyPrintJsonFile(manifest, `${manifest.appId}-completed-manifest`) - this.helper.PrettyPrintJsonFile(this.state, `${manifest.appId}-completed-state`) + if (!this.state) { + this.state = new AppEngineState( + { + name: manifest.name, + appId: manifest.appId, + partOf: manifest.appId, + edition: manifest.edition, + }) - new TimingReporter().report(this.state) + } + debug('APPX createApply - manifest', manifest) + debug('APPX createApply - state', this.state) + + try { + this.state.startTimer('apply') + await this.ensureServiceNamespacesExist() + await this.installApp(manifest) + await this.ensureAppIsRunning(manifest) + this.state.endTimer('apply') + new TimingReporter().report(this.state) + } catch (e) { + debug('APPX createApply', e) + } } async installApp(manifest: AppManifest) { - this.state.startTimer('install') - const applierType = manifest.provisioner.applier || 'ObjectApplier' - await applierFactory.getApplier(applierType).apply(manifest, this.state, this.manager) - this.state.endTimer('install') + try { + + this.state.startTimer('install') + const applierType = manifest.provisioner.applier || 'ObjectApplier' + await applierFactory.getApplier(applierType).apply(manifest, this.state, this.manager) + if((manifest as any).fieldTypes) delete (manifest as any).fieldTypes + this.state.endTimer('install') + } catch (e) { + debug('APPX installApp', e) + } } async ensureAppIsRunning(manifest: AppManifest) { - this.state.startTimer('watch-pod') - await this.manager.cluster. - begin(`Ensure ${manifest.displayName} services are running`) - .beginWatch(this.pods(manifest.namespace, manifest.appId)) - .whenWatch(({ condition }) => condition.Ready === 'True', (processor) => { - processor.endWatch() - }) - .end() - this.state.endTimer('watch-pod') + try { + + this.state.startTimer('watch-pod') + await this.manager.cluster. + begin(`Ensure ${manifest.displayName} services are running`) + .beginWatch(this.pods(manifest.namespace, manifest.appId)) + .whenWatch(({ condition }) => condition.Ready === 'True', (processor) => { + processor.endWatch() + }) + .end() + this.state.endTimer('watch-pod') + } catch (e) { + debug('APPX ensureAppIsRunning', e) + } } } \ No newline at end of file diff --git a/packages/appengine/src/templates/latest/deployment.ts b/packages/appengine/src/templates/latest/deployment.ts index 97707efc..fc972eab 100644 --- a/packages/appengine/src/templates/latest/deployment.ts +++ b/packages/appengine/src/templates/latest/deployment.ts @@ -29,8 +29,6 @@ export function getDeploymentTemplate( imageWithTag = `${image}:${tag}` } - - return { apiVersion: 'apps/v1', kind: 'Deployment', diff --git a/packages/appengine/src/ui/main.ts b/packages/appengine/src/ui/main.ts index 24636526..ead9e408 100644 --- a/packages/appengine/src/ui/main.ts +++ b/packages/appengine/src/ui/main.ts @@ -14,7 +14,6 @@ export class AppEngineSettings extends AppEngineBaseView implements StoreFlowSte if (!this.state.parsed) parser.parseInputsToSpec(null, this.manifest) - this.state.endTimer('ui-main-begin') if (this.manifest.hasCustomConfigFields()) { this.mediator.appendFlow('appengine-install-configs') @@ -23,7 +22,11 @@ export class AppEngineSettings extends AppEngineBaseView implements StoreFlowSte } else { new TimingReporter().report(this.state) } + this.state.endTimer('ui-main-begin') + + console.log(this.state) await (this.mediator as any).handleNext() + } } \ No newline at end of file diff --git a/packages/appengine/src/ui/views/appEngineBaseView.ts b/packages/appengine/src/ui/views/appEngineBaseView.ts index da3524ff..93ac4e9c 100644 --- a/packages/appengine/src/ui/views/appEngineBaseView.ts +++ b/packages/appengine/src/ui/views/appEngineBaseView.ts @@ -25,10 +25,6 @@ export class AppEngineBaseView extends LitElement implements StoreFlowStep { edition: this.manifest.edition }) } - console.log('ROBX BEGIN STATE', this.state) - console.log('ROBX BEGIN MANIFEST', this.manifest) - - this.state.onTimerChanged(e=>console.log('ROBX STATE CHANGE:', e)) } }