Add some more unit tests to the multicontainer supervisor

We add a bunch of additional unit tests, and also a coverage report using istanbul.

The tests are not meant to cover everything, but they're a first attempt at having *some* unit testing
on the supervisor. There's much to improve but hopefully it helps catch obvious errors.

Change-Type: patch
Signed-off-by: Pablo Carranza Velez <pablo@resin.io>
This commit is contained in:
Pablo Carranza Velez 2017-10-31 23:47:48 -07:00
parent 4e5f8cabb8
commit 652b596c80
30 changed files with 3407 additions and 16 deletions

15
.gitignore vendored
View File

@ -4,17 +4,14 @@
/meta-resin/
*.swp
/data/
bin/gosuper
gosuper/bin/
base-image/build/bitbake.lock
base-image/build/cache
base-image/build/downloads
base-image/build/sstate-cache
base-image/build/tmp-glibc
/build/
/dist/
tools/dind/config/
tools/dind/config.json*
tools/dind/apps.json
test/data/config.json
test/data/config-apibinder.json
test/data/*.sqlite
test/data/led_file
/coverage/
report.xml

View File

@ -11,8 +11,9 @@
"start": "./entry.sh",
"build": "webpack",
"lint": "resin-lint src/ test/",
"test": "npm run lint && JUNIT_REPORT_PATH=report.xml mocha --exit -r ts-node/register -r coffee-script/register -r register-coffee-coverage test/*.{js,coffee} && npm run coverage",
"versionist": "versionist",
"test": "npm run lint && mocha -r ts-node/register -r coffee-script/register test/**/*"
"coverage": "istanbul report text && istanbul report html"
},
"dependencies": {
"@types/lodash": "^4.14.108",
@ -28,7 +29,7 @@
"bluebird": "^3.5.0",
"body-parser": "^1.12.0",
"buffer-equal-constant-time": "^1.0.1",
"chai": "^4.1.2",
"chai-events": "0.0.1",
"coffee-loader": "^0.7.3",
"coffee-script": "~1.11.0",
"copy-webpack-plugin": "^4.2.3",
@ -39,6 +40,7 @@
"duration-js": "^4.0.0",
"event-stream": "^3.0.20",
"express": "^4.0.0",
"istanbul": "^0.4.5",
"json-mask": "^0.3.8",
"knex": "~0.12.3",
"lockfile": "^1.0.1",
@ -47,13 +49,14 @@
"memoizee": "^0.4.1",
"mixpanel": "0.0.20",
"mkdirp": "^0.5.1",
"mocha": "^5.0.5",
"mocha": "^5.1.1",
"mochainon": "^2.0.0",
"network-checker": "~0.0.5",
"node-loader": "^0.6.0",
"null-loader": "^0.1.1",
"pinejs-client": "^2.4.0",
"pubnub": "^3.7.13",
"register-coffee-coverage": "0.0.1",
"request": "^2.51.0",
"resin-lint": "^1.3.1",
"resin-register-device": "^3.0.0",

View File

@ -165,6 +165,10 @@ module.exports = class Logger
@opts = opts
@_startBackend()
stop: =>
if @backend?
@backend.stop()
_startBackend: =>
if checkTruthy(@opts.nativeLogger)
@backend = new NativeLoggerBackend()

19
test/00-init.coffee Normal file
View File

@ -0,0 +1,19 @@
process.env.ROOT_MOUNTPOINT = './test/data'
process.env.BOOT_MOUNTPOINT = '/mnt/boot'
process.env.CONFIG_JSON_PATH = '/config.json'
process.env.DATABASE_PATH = './test/data/database.sqlite'
process.env.DATABASE_PATH_2 = './test/data/database2.sqlite'
process.env.DATABASE_PATH_3 = './test/data/database3.sqlite'
process.env.LED_FILE = './test/data/led_file'
m = require 'mochainon'
{ stub } = m.sinon
dbus = require 'dbus-native'
stub(dbus, 'systemBus').returns({
invoke: (obj, cb) ->
console.log(obj)
cb()
})

View File

@ -0,0 +1,12 @@
prepare = require './lib/prepare'
m = require 'mochainon'
{ expect } = m.chai
constants = require '../src/lib/constants'
describe 'constants', ->
before ->
prepare()
it 'has the correct configJsonPathOnHost', ->
expect(constants.configJsonPathOnHost).to.equal('/config.json')
it 'has the correct rootMountPoint', ->
expect(constants.rootMountPoint).to.equal('./test/data')

83
test/02-db.spec.coffee Normal file
View File

@ -0,0 +1,83 @@
prepare = require './lib/prepare'
Promise = require 'bluebird'
m = require 'mochainon'
{ expect } = m.chai
fs = Promise.promisifyAll(require('fs'))
Knex = require('knex')
DB = require('../src/db')
createOldDatabase = (path) ->
knex = new Knex(
client: 'sqlite3'
connection:
filename: path
useNullAsDefault: true
)
createEmptyTable = (name, fn) ->
knex.schema.createTable name, (t) ->
fn(t) if fn?
createEmptyTable 'app', (t) ->
t.increments('id').primary()
t.boolean('privileged')
t.string('containerId')
.then ->
createEmptyTable 'config', (t) ->
t.string('key')
t.string('value')
.then ->
createEmptyTable 'dependentApp', (t) ->
t.increments('id').primary()
.then ->
createEmptyTable 'dependentDevice', (t) ->
t.increments('id').primary()
.then ->
return knex
describe 'DB', ->
before ->
prepare()
@db = new DB()
it 'initializes correctly, running the migrations', ->
expect(@db.init()).to.be.fulfilled
it 'creates a database at the path from an env var', ->
promise = fs.statAsync(process.env.DATABASE_PATH)
expect(promise).to.be.fulfilled
it 'creates a database at the path passed on creation', ->
db2 = new DB({ databasePath: process.env.DATABASE_PATH_2 })
promise = db2.init().then( -> fs.statAsync(process.env.DATABASE_PATH_2))
expect(promise).to.be.fulfilled
it 'adds new fields and removes old ones in an old database', ->
databasePath = process.env.DATABASE_PATH_3
createOldDatabase(databasePath)
.then (knexForDB) ->
db = new DB({ databasePath })
db.init()
.then ->
Promise.all([
expect(knexForDB.schema.hasColumn('app', 'appId')).to.eventually.be.true
expect(knexForDB.schema.hasColumn('app', 'releaseId')).to.eventually.be.true
expect(knexForDB.schema.hasColumn('app', 'config')).to.eventually.be.false
expect(knexForDB.schema.hasColumn('app', 'privileged')).to.eventually.be.false
expect(knexForDB.schema.hasColumn('app', 'containerId')).to.eventually.be.false
expect(knexForDB.schema.hasColumn('dependentApp', 'environment')).to.eventually.be.true
expect(knexForDB.schema.hasColumn('dependentDevice', 'markedForDeletion')).to.eventually.be.true
expect(knexForDB.schema.hasColumn('dependentDevice', 'localId')).to.eventually.be.true
expect(knexForDB.schema.hasColumn('dependentDevice', 'is_managed_by')).to.eventually.be.true
expect(knexForDB.schema.hasColumn('dependentDevice', 'lock_expiry_date')).to.eventually.be.true
])
it 'creates a deviceConfig table with a single default value', ->
promise = @db.models('deviceConfig').select()
Promise.all([
expect(promise).to.eventually.have.lengthOf(1)
expect(promise).to.eventually.deep.equal([ { targetValues: '{}' } ])
])
it 'allows performing transactions', ->
@db.transaction (trx) ->
expect(trx.commit()).to.be.fulfilled

116
test/03-config.spec.coffee Normal file
View File

@ -0,0 +1,116 @@
prepare = require './lib/prepare'
Promise = require 'bluebird'
m = require 'mochainon'
{ expect } = m.chai
fs = Promise.promisifyAll(require('fs'))
m.chai.use(require('chai-events'))
DB = require('../src/db')
Config = require('../src/config')
constants = require('../src/lib/constants')
describe 'Config', ->
before ->
prepare()
@db = new DB()
@conf = new Config({ @db })
@initialization = @db.init().then =>
@conf.init()
it 'uses the correct config.json path', ->
expect(@conf.configJsonPath()).to.eventually.equal('test/data/config.json')
it 'uses the correct config.json path from the root mount when passed as argument to the constructor', ->
conf2 = new Config({ @db, configPath: '/foo.json' })
expect(conf2.configJsonPath()).to.eventually.equal('test/data/foo.json')
it 'initializes correctly', ->
expect(@initialization).to.be.fulfilled
it 'reads and exposes values from the config.json', ->
promise = @conf.get('applicationId')
expect(promise).to.eventually.equal(78373)
it 'allows reading several values in one getMany call', ->
promise = @conf.getMany([ 'applicationId', 'apiEndpoint' ])
expect(promise).to.eventually.deep.equal({ applicationId: 78373, apiEndpoint: 'https://api.resin.io' })
it 'provides the correct pubnub config', ->
promise = @conf.get('pubnub')
expect(promise).to.eventually.deep.equal({ subscribe_key: 'foo', publish_key: 'bar', ssl: true })
it 'generates a uuid and stores it in config.json', ->
promise = @conf.get('uuid')
promise2 = fs.readFileAsync('./test/data/config.json').then(JSON.parse).get('uuid')
Promise.all([
expect(promise).to.be.fulfilled
expect(promise2).to.be.fulfilled
]).then ([uuid1, uuid2]) ->
expect(uuid1).to.be.a('string')
expect(uuid1).to.have.lengthOf(62)
expect(uuid1).to.equal(uuid2)
it 'does not allow setting an immutable field', ->
promise = @conf.set({ username: 'somebody else' })
# We catch it to avoid the unhandled error log
promise.catch(->)
expect(promise).to.be.rejected
it 'allows setting both config.json and database fields transparently', ->
promise = @conf.set({ appUpdatePollInterval: 30000, name: 'a new device name' }).then =>
@conf.getMany([ 'appUpdatePollInterval', 'name' ])
expect(promise).to.eventually.deep.equal({ appUpdatePollInterval: 30000, name: 'a new device name' })
it 'allows removing a db key', ->
promise = @conf.remove('name').then =>
@conf.get('name')
expect(promise).to.be.fulfilled
expect(promise).to.eventually.be.undefined
it 'allows deleting a config.json key and returns a default value if none is set', ->
promise = @conf.remove('appUpdatePollInterval').then =>
@conf.get('appUpdatePollInterval')
expect(promise).to.be.fulfilled
expect(promise).to.eventually.equal(60000)
it 'allows deleting a config.json key if it is null', ->
promise = @conf.set('apiKey': null).then =>
@conf.get('apiKey')
expect(promise).to.be.fulfilled
expect(promise).to.eventually.be.undefined
.then ->
fs.readFileAsync('./test/data/config.json')
.then(JSON.parse)
.then (confFromFile) ->
expect(confFromFile).to.not.have.property('apiKey')
it 'does not allow modifying or removing a function value', ->
promise1 = @conf.remove('version')
promise1.catch(->)
promise2 = @conf.set(version: '2.0')
promise2.catch(->)
Promise.all([
expect(promise1).to.be.rejected
expect(promise2).to.be.rejected
])
it 'throws when asked for an unknown key', ->
promise = @conf.get('unknownInvalidValue')
promise.catch(->)
expect(promise).to.be.rejected
it 'emits a change event when values are set', (done) ->
@conf.on 'change', (val) ->
expect(val).to.deep.equal({ name: 'someValue' })
done()
@conf.set({ name: 'someValue' })
expect(@conf).to.emit('change')
return
it "returns an undefined OS variant if it doesn't exist", ->
oldPath = constants.hostOSVersionPath
constants.hostOSVersionPath = 'test/data/etc/os-release-novariant'
@conf.get('osVariant')
.then (osVariant) ->
constants.hostOSVersionPath = oldPath
expect(osVariant).to.be.undefined

View File

@ -1,5 +1,6 @@
{ expect } = require 'chai'
Service = require '../../src/compose/service'
m = require 'mochainon'
{ expect } = m.chai
Service = require '../src/compose/service'
describe 'compose/service.cofee', ->

View File

@ -0,0 +1,277 @@
Promise = require 'bluebird'
_ = require 'lodash'
m = require 'mochainon'
{ stub } = m.sinon
m.chai.use(require('chai-events'))
{ expect } = m.chai
prepare = require './lib/prepare'
DeviceState = require '../src/device-state'
DB = require('../src/db')
Config = require('../src/config')
Service = require '../src/compose/service'
mockedInitialConfig = {
'RESIN_HOST_LOG_TO_DISPLAY': '1'
'RESIN_SUPERVISOR_CONNECTIVITY_CHECK': 'true'
'RESIN_SUPERVISOR_DELTA': 'false'
'RESIN_SUPERVISOR_DELTA_APPLY_TIMEOUT': ''
'RESIN_SUPERVISOR_DELTA_REQUEST_TIMEOUT': '30000'
'RESIN_SUPERVISOR_DELTA_RETRY_COUNT': '30'
'RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL': '10000'
'RESIN_SUPERVISOR_LOCAL_MODE': 'false'
'RESIN_SUPERVISOR_LOG_CONTROL': 'true'
'RESIN_SUPERVISOR_OVERRIDE_LOCK': 'false'
'RESIN_SUPERVISOR_POLL_INTERVAL': '60000'
'RESIN_SUPERVISOR_VPN_CONTROL': 'true'
}
testTarget1 = {
local: {
name: 'aDevice'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '256'
'RESIN_HOST_LOG_TO_DISPLAY': '0'
'RESIN_SUPERVISOR_CONNECTIVITY_CHECK': 'true'
'RESIN_SUPERVISOR_DELTA': 'false'
'RESIN_SUPERVISOR_DELTA_APPLY_TIMEOUT': ''
'RESIN_SUPERVISOR_DELTA_REQUEST_TIMEOUT': '30000'
'RESIN_SUPERVISOR_DELTA_RETRY_COUNT': '30'
'RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL': '10000'
'RESIN_SUPERVISOR_LOCAL_MODE': 'false'
'RESIN_SUPERVISOR_LOG_CONTROL': 'true'
'RESIN_SUPERVISOR_NATIVE_LOGGER': 'true'
'RESIN_SUPERVISOR_OVERRIDE_LOCK': 'false'
'RESIN_SUPERVISOR_POLL_INTERVAL': '60000'
'RESIN_SUPERVISOR_VPN_CONTROL': 'true'
}
apps: {
'1234': {
appId: 1234
name: 'superapp'
commit: 'abcdef'
releaseId: 1
services: [
{
appId: 1234
serviceId: 23
imageId: 12345
serviceName: 'someservice'
releaseId: 1
image: 'registry2.resin.io/superapp/abcdef:latest'
labels: {
'io.resin.something': 'bar'
}
}
]
volumes: {}
networks: {}
}
}
}
dependent: { apps: [], devices: [] }
}
testTarget2 = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: {
'1234': {
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: {
'23': {
serviceName: 'aservice'
imageId: 12345
image: 'registry2.resin.io/superapp/edfabc'
environment: {
'FOO': 'bar'
}
labels: {}
},
'24': {
serviceName: 'anotherService'
imageId: 12346
image: 'registry2.resin.io/superapp/afaff'
environment: {
'FOO': 'bro'
}
labels: {}
}
}
}
}
}
dependent: { apps: [], devices: [] }
}
testTargetWithDefaults2 = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
'RESIN_SUPERVISOR_CONNECTIVITY_CHECK': 'true'
'RESIN_SUPERVISOR_DELTA': 'false'
'RESIN_SUPERVISOR_DELTA_APPLY_TIMEOUT': ''
'RESIN_SUPERVISOR_DELTA_REQUEST_TIMEOUT': '30000'
'RESIN_SUPERVISOR_DELTA_RETRY_COUNT': '30'
'RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL': '10000'
'RESIN_SUPERVISOR_LOCAL_MODE': 'false'
'RESIN_SUPERVISOR_LOG_CONTROL': 'true'
'RESIN_SUPERVISOR_NATIVE_LOGGER': 'true'
'RESIN_SUPERVISOR_OVERRIDE_LOCK': 'false'
'RESIN_SUPERVISOR_POLL_INTERVAL': '60000'
'RESIN_SUPERVISOR_VPN_CONTROL': 'true'
}
apps: {
'1234': {
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: [
_.merge({ appId: 1234, serviceId: 23, releaseId: 2 }, _.clone(testTarget2.local.apps['1234'].services['23'])),
_.merge({ appId: 1234, serviceId: 24, releaseId: 2 }, _.clone(testTarget2.local.apps['1234'].services['24']))
]
volumes: {}
networks: {}
}
}
}
dependent: { apps: [], devices: [] }
}
testTargetInvalid = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: '1234'
name: 'superapp'
commit: 'afafafa'
releaseId: '2'
config: {}
services: [
{
serviceId: '23'
serviceName: 'aservice'
imageId: '12345'
image: 'registry2.resin.io/superapp/edfabc'
config: {}
environment: {
' FOO': 'bar'
}
labels: {}
},
{
serviceId: '24'
serviceName: 'anotherService'
imageId: '12346'
image: 'registry2.resin.io/superapp/afaff'
config: {}
environment: {
'FOO': 'bro'
}
labels: {}
}
]
}
]
}
dependent: { apps: [], devices: [] }
}
describe 'deviceState', ->
before ->
@timeout(5000)
prepare()
@db = new DB()
@config = new Config({ @db })
eventTracker = {
track: console.log
}
stub(Service.prototype, 'extendEnvVars').callsFake (env) ->
@environment['ADDITIONAL_ENV_VAR'] = 'foo'
return @environment
@deviceState = new DeviceState({ @db, @config, eventTracker })
stub(@deviceState.applications.docker, 'getNetworkGateway').returns(Promise.resolve('172.17.0.1'))
stub(@deviceState.applications.images, 'inspectByName').callsFake ->
Promise.try ->
err = new Error()
err.statusCode = 404
throw err
@db.init()
.then =>
@config.init()
after ->
Service.prototype.extendEnvVars.restore()
@deviceState.applications.docker.getNetworkGateway.restore()
@deviceState.applications.images.inspectByName.restore()
it 'loads a target state from an apps.json file and saves it as target state, then returns it', ->
stub(@deviceState.applications.images, 'save').returns(Promise.resolve())
stub(@deviceState.deviceConfig, 'getCurrent').returns(Promise.resolve(mockedInitialConfig))
@deviceState.loadTargetFromFile(process.env.ROOT_MOUNTPOINT + '/apps.json')
.then =>
@deviceState.getTarget()
.then (targetState) =>
testTarget = _.cloneDeep(testTarget1)
testTarget.local.apps['1234'].services = _.map testTarget.local.apps['1234'].services, (s) ->
s.imageName = s.image
return new Service(s)
# We serialize and parse JSON to avoid checking fields that are functions or undefined
expect(JSON.parse(JSON.stringify(targetState))).to.deep.equal(JSON.parse(JSON.stringify(testTarget)))
@deviceState.applications.images.save.restore()
@deviceState.deviceConfig.getCurrent.restore()
it 'emits a change event when a new state is reported', ->
@deviceState.reportCurrentState({ someStateDiff: 'someValue' })
expect(@deviceState).to.emit('change')
it 'returns the current state'
it 'writes the target state to the db with some extra defaults', ->
testTarget = _.cloneDeep(testTargetWithDefaults2)
Promise.map testTarget.local.apps['1234'].services, (s) =>
@deviceState.applications.images.normalise(s.image)
.then (imageName) ->
s.image = imageName
s.imageName = imageName
new Service(s)
.then (services) =>
testTarget.local.apps['1234'].services = services
@deviceState.setTarget(testTarget2)
.then =>
@deviceState.getTarget()
.then (target) ->
expect(JSON.parse(JSON.stringify(target))).to.deep.equal(JSON.parse(JSON.stringify(testTarget)))
it 'does not allow setting an invalid target state', ->
promise = @deviceState.setTarget(testTargetInvalid)
promise.catch(->)
expect(promise).to.be.rejected
it 'allows triggering applying the target state', (done) ->
stub(@deviceState, 'applyTarget').returns(Promise.resolve())
@deviceState.triggerApplyTarget({ force: true })
expect(@deviceState.applyTarget).to.not.be.called
setTimeout =>
expect(@deviceState.applyTarget).to.be.calledWith({ force: true, initial: false })
@deviceState.applyTarget.restore()
done()
, 5
it 'applies the target state for device config'
it 'applies the target state for applications'

View File

@ -0,0 +1,43 @@
Promise = require 'bluebird'
iptables = require '../src/lib/iptables'
childProcess = require('child_process')
m = require 'mochainon'
{ stub } = m.sinon
{ expect } = m.chai
describe 'iptables', ->
it 'calls iptables to delete and recreate rules to block a port', ->
stub(childProcess, 'execAsync').returns(Promise.resolve())
iptables.rejectOnAllInterfacesExcept(['foo', 'bar'], 42)
.then ->
expect(childProcess.execAsync.callCount).to.equal(6)
expect(childProcess.execAsync).to.be.calledWith('iptables -D INPUT -p tcp --dport 42 -i foo -j ACCEPT')
expect(childProcess.execAsync).to.be.calledWith('iptables -I INPUT -p tcp --dport 42 -i foo -j ACCEPT')
expect(childProcess.execAsync).to.be.calledWith('iptables -D INPUT -p tcp --dport 42 -i bar -j ACCEPT')
expect(childProcess.execAsync).to.be.calledWith('iptables -I INPUT -p tcp --dport 42 -i bar -j ACCEPT')
expect(childProcess.execAsync).to.be.calledWith('iptables -D INPUT -p tcp --dport 42 -j REJECT')
expect(childProcess.execAsync).to.be.calledWith('iptables -A INPUT -p tcp --dport 42 -j REJECT')
.then ->
childProcess.execAsync.restore()
it "falls back to blocking the port with DROP if there's no REJECT support", ->
stub(childProcess, 'execAsync').callsFake (cmd) ->
if /REJECT$/.test(cmd)
Promise.reject()
else
Promise.resolve()
iptables.rejectOnAllInterfacesExcept(['foo', 'bar'], 42)
.then ->
expect(childProcess.execAsync.callCount).to.equal(8)
expect(childProcess.execAsync).to.be.calledWith('iptables -D INPUT -p tcp --dport 42 -i foo -j ACCEPT')
expect(childProcess.execAsync).to.be.calledWith('iptables -I INPUT -p tcp --dport 42 -i foo -j ACCEPT')
expect(childProcess.execAsync).to.be.calledWith('iptables -D INPUT -p tcp --dport 42 -i bar -j ACCEPT')
expect(childProcess.execAsync).to.be.calledWith('iptables -I INPUT -p tcp --dport 42 -i bar -j ACCEPT')
expect(childProcess.execAsync).to.be.calledWith('iptables -D INPUT -p tcp --dport 42 -j REJECT')
expect(childProcess.execAsync).to.be.calledWith('iptables -A INPUT -p tcp --dport 42 -j REJECT')
expect(childProcess.execAsync).to.be.calledWith('iptables -D INPUT -p tcp --dport 42 -j DROP')
expect(childProcess.execAsync).to.be.calledWith('iptables -A INPUT -p tcp --dport 42 -j DROP')
.then ->
childProcess.execAsync.restore()

View File

@ -2,7 +2,7 @@ _ = require 'lodash'
m = require 'mochainon'
{ expect } = m.chai
validation = require '../../src/lib/validation'
validation = require '../src/lib/validation'
almostTooLongText = _.map([0...255], -> 'a').join('')
@ -216,4 +216,4 @@ describe 'validation', ->
}
}
}
expect(validation.isValidDependentDevicesObject(devices)).to.equal(false)
expect(validation.isValidDependentDevicesObject(devices)).to.equal(false)

21
test/08-blink.spec.coffee Normal file
View File

@ -0,0 +1,21 @@
Promise = require 'bluebird'
constants = require '../src/lib/constants'
fs = Promise.promisifyAll(require('fs'))
blink = require('../src/lib/blink')
m = require 'mochainon'
{ expect } = m.chai
describe 'blink', ->
it 'is a blink function', ->
expect(blink).to.be.a('function')
it 'has a pattern property with start and stop functions', ->
expect(blink.pattern.start).to.be.a('function')
expect(blink.pattern.stop).to.be.a('function')
it 'writes to a file that represents the LED, and writes a 0 at the end to turn the LED off', ->
blink(1)
.then ->
fs.readFileAsync(constants.ledFile)
.then (contents) ->
expect(contents.toString()).to.equal('0')

View File

@ -0,0 +1,79 @@
mixpanel = require 'mixpanel'
m = require 'mochainon'
{ expect } = m.chai
{ stub } = m.sinon
EventTracker = require '../src/event-tracker'
describe 'EventTracker', ->
before ->
stub(mixpanel, 'init').callsFake (token) ->
return {
token: token
track: stub().returns()
}
@eventTrackerOffline = new EventTracker()
@eventTracker = new EventTracker()
stub(EventTracker.prototype, '_logEvent')
after ->
EventTracker.prototype._logEvent.restore()
mixpanel.init.restore()
it 'initializes in offline mode', ->
promise = @eventTrackerOffline.init({
offlineMode: true
uuid: 'foobar'
})
expect(promise).to.be.fulfilled
.then =>
expect(@eventTrackerOffline._client).to.be.null
it 'logs events in offline mode, with the correct properties', ->
@eventTrackerOffline.track('Test event', { appId: 'someValue' })
expect(@eventTrackerOffline._logEvent).to.be.calledWith('Event:', 'Test event', JSON.stringify({ appId: 'someValue' }))
it 'initializes a mixpanel client when not in offline mode', ->
promise = @eventTracker.init({
mixpanelToken: 'someToken'
uuid: 'barbaz'
})
expect(promise).to.be.fulfilled
.then =>
expect(mixpanel.init).to.have.been.calledWith('someToken')
expect(@eventTracker._client.token).to.equal('someToken')
expect(@eventTracker._client.track).to.be.a('function')
it 'calls the mixpanel client track function with the event, properties and uuid as distinct_id', ->
@eventTracker.track('Test event 2', { appId: 'someOtherValue' })
expect(@eventTracker._logEvent).to.be.calledWith('Event:', 'Test event 2', JSON.stringify({ appId: 'someOtherValue' }))
expect(@eventTracker._client.track).to.be.calledWith('Test event 2', { appId: 'someOtherValue', uuid: 'barbaz', distinct_id: 'barbaz' })
it 'can be passed an Error and it is added to the event properties', ->
theError = new Error('something went wrong')
@eventTracker.track('Error event', theError)
expect(@eventTracker._client.track).to.be.calledWith('Error event', {
error:
message: theError.message
stack: theError.stack
uuid: 'barbaz'
distinct_id: 'barbaz'
})
it 'hides service environment variables, to avoid logging keys or secrets', ->
props = {
service:
appId: '1'
environment: {
RESIN_API_KEY: 'foo'
RESIN_SUPERVISOR_API_KEY: 'bar'
OTHER_VAR: 'hi'
}
}
@eventTracker.track('Some app event', props)
expect(@eventTracker._client.track).to.be.calledWith('Some app event', {
service: { appId: '1' }
uuid: 'barbaz'
distinct_id: 'barbaz'
})

View File

@ -0,0 +1,72 @@
os = require 'os'
m = require 'mochainon'
{ expect } = m.chai
{ stub } = m.sinon
network = require '../src/network'
describe 'network', ->
describe 'getIPAddresses', ->
before ->
stub(os, 'networkInterfaces').returns({
lo:
[{
address: '127.0.0.1',
netmask: '255.0.0.0',
family: 'IPv4',
mac: '00:00:00:00:00:00',
internal: true
},
{
address: '::1',
netmask: 'ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff',
family: 'IPv6',
mac: '00:00:00:00:00:00',
scopeid: 0,
internal: true
}]
docker0:
[{
address: '172.17.0.1',
netmask: '255.255.0.0',
family: 'IPv4',
mac: '02:42:0f:33:06:ad',
internal: false
},
{
address: 'fe80::42:fff:fe33:6ad',
netmask: 'ffff:ffff:ffff:ffff::',
family: 'IPv6',
mac: '02:42:0f:33:06:ad',
scopeid: 3,
internal: false
}]
wlan0:
[{
address: '192.168.1.137',
netmask: '255.255.255.0',
family: 'IPv4',
mac: '60:6d:c7:c6:44:3d',
internal: false
},
{
address: '2605:9080:1103:3011:2dbe:35e3:1b5a:b99',
netmask: 'ffff:ffff:ffff:ffff::',
family: 'IPv6',
mac: '60:6d:c7:c6:44:3d',
scopeid: 0,
internal: false
}]
'resin-vpn':
[{
address: '10.10.2.14',
netmask: '255.255.0.0',
family: 'IPv4',
mac: '01:43:1f:32:05:bd',
internal: false
}]
})
after ->
os.networkInterfaces.restore()
it 'returns only the relevant IP addresses', ->
expect(network.getIPAddresses()).to.deep.equal([ '192.168.1.137' ])

View File

@ -0,0 +1,137 @@
prepare = require './lib/prepare'
Promise = require 'bluebird'
resinAPI = require './lib/mocked-resin-api'
fs = Promise.promisifyAll(require('fs'))
m = require 'mochainon'
{ expect } = m.chai
{ stub, spy } = m.sinon
DB = require('../src/db')
Config = require('../src/config')
DeviceState = require('../src/device-state')
APIBinder = require('../src/api-binder')
initModels = ->
@timeout(5000)
prepare()
@db = new DB()
@config = new Config({ @db, configPath: '/config-apibinder.json' })
@eventTracker = {
track: stub().callsFake (ev, props) ->
console.log(ev, props)
}
@deviceState = new DeviceState({ @db, @config, @eventTracker })
@apiBinder = new APIBinder({ @db, @config, @eventTracker, @deviceState })
@db.init()
.then =>
@config.init()
.then =>
@apiBinder.initClient() # Initializes the clients but doesn't trigger provisioning
mockProvisioningOpts = {
apiEndpoint: 'http://0.0.0.0:3000'
uuid: 'abcd'
deviceApiKey: 'averyvalidkey'
provisioningApiKey: 'anotherveryvalidkey'
apiTimeout: 30000
}
describe 'APIBinder', ->
before ->
spy(resinAPI.resinBackend, 'registerHandler')
@server = resinAPI.listen(3000)
after ->
resinAPI.resinBackend.registerHandler.restore()
try
@server.close()
# We do not support older OS versions anymore, so we only test this case
describe 'on an OS with deviceApiKey support', ->
before ->
initModels.call(this)
it 'provisions a device', ->
promise = @apiBinder.provisionDevice()
expect(promise).to.be.fulfilled
.then =>
expect(resinAPI.resinBackend.registerHandler).to.be.calledOnce
resinAPI.resinBackend.registerHandler.reset()
expect(@eventTracker.track).to.be.calledWith('Device bootstrap success')
it 'deletes the provisioning key', ->
expect(@config.get('apiKey')).to.eventually.be.undefined
it 'sends the correct parameters when provisioning', ->
fs.readFileAsync('./test/data/config-apibinder.json')
.then(JSON.parse)
.then (conf) ->
expect(resinAPI.resinBackend.devices).to.deep.equal({
'1': {
id: 1
user: conf.userId
application: conf.applicationId
uuid: conf.uuid
device_type: conf.deviceType
api_key: conf.deviceApiKey
}
})
describe 'fetchDevice', ->
before ->
initModels.call(this)
it 'gets a device by its uuid from the Resin API', ->
# Manually add a device to the mocked API
resinAPI.resinBackend.devices[3] = {
id: 3
user: 'foo'
application: 1337
uuid: 'abcd'
device_type: 'intel-nuc'
api_key: 'verysecure'
}
@apiBinder.fetchDevice('abcd', 'someApiKey', 30000)
.then (theDevice) ->
expect(theDevice).to.deep.equal(resinAPI.resinBackend.devices[3])
describe '_exchangeKeyAndGetDevice', ->
before ->
initModels.call(this)
it 'returns the device if it can fetch it with the deviceApiKey', ->
spy(resinAPI.resinBackend, 'deviceKeyHandler')
fetchDeviceStub = stub(@apiBinder, 'fetchDevice')
fetchDeviceStub.onCall(0).resolves({ id: 1 })
@apiBinder._exchangeKeyAndGetDevice(mockProvisioningOpts)
.then (device) =>
expect(resinAPI.resinBackend.deviceKeyHandler).to.not.be.called
expect(device).to.deep.equal({ id: 1 })
expect(@apiBinder.fetchDevice).to.be.calledOnce
@apiBinder.fetchDevice.restore()
resinAPI.resinBackend.deviceKeyHandler.restore()
it 'throws if it cannot get the device with any of the keys', ->
spy(resinAPI.resinBackend, 'deviceKeyHandler')
stub(@apiBinder, 'fetchDevice').returns(Promise.resolve(null))
promise = @apiBinder._exchangeKeyAndGetDevice(mockProvisioningOpts)
promise.catch(->)
expect(promise).to.be.rejected
.then =>
expect(resinAPI.resinBackend.deviceKeyHandler).to.not.be.called
expect(@apiBinder.fetchDevice).to.be.calledTwice
@apiBinder.fetchDevice.restore()
resinAPI.resinBackend.deviceKeyHandler.restore()
it 'exchanges the key and returns the device if the provisioning key is valid', ->
spy(resinAPI.resinBackend, 'deviceKeyHandler')
fetchDeviceStub = stub(@apiBinder, 'fetchDevice')
fetchDeviceStub.onCall(0).returns(Promise.resolve(null))
fetchDeviceStub.onCall(1).returns(Promise.resolve({ id: 1 }))
@apiBinder._exchangeKeyAndGetDevice(mockProvisioningOpts)
.then (device) =>
expect(resinAPI.resinBackend.deviceKeyHandler).to.be.calledOnce
expect(device).to.deep.equal({ id: 1 })
expect(@apiBinder.fetchDevice).to.be.calledTwice
@apiBinder.fetchDevice.restore()
resinAPI.resinBackend.deviceKeyHandler.restore()

View File

@ -0,0 +1,39 @@
m = require 'mochainon'
{ expect } = m.chai
{ spy, useFakeTimers } = m.sinon
Logger = require '../src/logger'
describe 'Logger', ->
before ->
@fakeBinder = {
logBatch: spy()
}
@fakeEventTracker = {
track: spy()
}
@logger = new Logger({ eventTracker: @fakeEventTracker })
@logger.init({ pubnub: {}, channel: 'foo', offlineMode: 'false', enable: 'true', nativeLogger: 'true', apiBinder: @fakeBinder })
after ->
@logger.stop()
it 'publishes logs to the resin API by default', (done) ->
theTime = Date.now()
@logger.log(message: 'Hello!', timestamp: theTime)
setTimeout( =>
expect(@fakeBinder.logBatch).to.be.calledWith([ { message: 'Hello!', timestamp: theTime } ])
@fakeBinder.logBatch.reset()
done()
, 1020)
it 'allows logging system messages which are also reported to the eventTracker', (done) ->
clock = useFakeTimers()
clock.tick(10)
@logger.logSystemMessage('Hello there!', { someProp: 'someVal' }, 'Some event name')
clock.restore()
setTimeout( =>
expect(@fakeBinder.logBatch).to.be.calledWith([ { message: 'Hello there!', timestamp: 10, isSystem: true } ])
expect(@fakeEventTracker.track).to.be.calledWith('Some event name', { someProp: 'someVal' })
done()
, 1020)

View File

@ -0,0 +1,152 @@
Promise = require 'bluebird'
prepare = require './lib/prepare'
m = require 'mochainon'
{ expect } = m.chai
{ stub, spy } = m.sinon
fsUtils = require '../src/lib/fs-utils'
DeviceConfig = require '../src/device-config'
childProcess = require 'child_process'
describe 'DeviceConfig', ->
before ->
@timeout(5000)
prepare()
@fakeDB = {}
@fakeConfig = {}
@fakeLogger = {
logSystemMessage: spy()
}
@deviceConfig = new DeviceConfig({ logger: @fakeLogger, db: @fakeDB, config: @fakeConfig })
# Test that the format for special values like initramfs and array variables is parsed correctly
it 'allows getting boot config with getBootConfig', ->
stub(@deviceConfig, 'readBootConfig').resolves('\
initramfs initramf.gz 0x00800000\n\
dtparam=i2c=on\n\
dtparam=audio=on\n\
dtoverlay=ads7846\n\
dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13\n\
foobar=baz\n\
')
@deviceConfig.getBootConfig('raspberry-pi')
.then (conf) =>
@deviceConfig.readBootConfig.restore()
expect(conf).to.deep.equal({
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'baz'
})
it 'properly reads a real config.txt file', ->
@deviceConfig.getBootConfig('raspberrypi3')
.then (conf) ->
expect(conf).to.deep.equal({
RESIN_HOST_CONFIG_dtparam: '"i2c_arm=on","spi=on","audio=on"'
RESIN_HOST_CONFIG_enable_uart: '1'
RESIN_HOST_CONFIG_disable_splash: '1'
RESIN_HOST_CONFIG_avoid_warnings: '1'
RESIN_HOST_CONFIG_gpu_mem: '16'
})
it 'correctly transforms environments to boot config objects', ->
bootConfig = @deviceConfig.envToBootConfig({
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'baz'
})
expect(bootConfig).to.deep.equal({
initramfs: 'initramf.gz 0x00800000'
dtparam: [ 'i2c=on', 'audio=on' ]
dtoverlay: [ 'ads7846', 'lirc-rpi,gpio_out_pin=17,gpio_in_pin=13' ]
foobar: 'baz'
})
# Test that the format for special values like initramfs and array variables is preserved
it 'does not allow setting forbidden keys', ->
current = {
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'baz'
}
target = {
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00810000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'baz'
}
promise = Promise.try =>
@deviceConfig.bootConfigChangeRequired('raspberry-pi', current, target)
expect(promise).to.be.rejected
promise.catch (err) =>
expect(@fakeLogger.logSystemMessage).to.be.calledOnce
expect(@fakeLogger.logSystemMessage).to.be.calledWith('Attempt to change blacklisted config value initramfs', {
error: 'Attempt to change blacklisted config value initramfs'
}, 'Apply boot config error')
@fakeLogger.logSystemMessage.reset()
it 'does not try to change config.txt if it should not change', ->
current = {
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'baz'
}
target = {
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'baz'
}
promise = Promise.try =>
@deviceConfig.bootConfigChangeRequired('raspberry-pi', current, target)
expect(promise).to.eventually.equal(false)
promise.then =>
expect(@fakeLogger.logSystemMessage).to.not.be.called
@fakeLogger.logSystemMessage.reset()
it 'writes the target config.txt', ->
stub(fsUtils, 'writeFileAtomic').resolves()
stub(childProcess, 'execAsync').resolves()
current = {
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'baz'
}
target = {
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=off"'
RESIN_HOST_CONFIG_dtoverlay: '"lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'bat'
RESIN_HOST_CONFIG_foobaz: 'bar'
}
promise = Promise.try =>
@deviceConfig.bootConfigChangeRequired('raspberry-pi', current, target)
expect(promise).to.eventually.equal(true)
promise.then =>
@deviceConfig.setBootConfig('raspberry-pi', target)
.then =>
expect(childProcess.execAsync).to.be.calledOnce
expect(@fakeLogger.logSystemMessage).to.be.calledTwice
expect(@fakeLogger.logSystemMessage.getCall(1).args[2]).to.equal('Apply boot config success')
expect(fsUtils.writeFileAtomic).to.be.calledWith('./test/data/mnt/boot/config.txt', '\
initramfs initramf.gz 0x00800000\n\
dtparam=i2c=on\n\
dtparam=audio=off\n\
dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13\n\
foobar=bat\n\
foobaz=bar\n\
')
fsUtils.writeFileAtomic.restore()
childProcess.execAsync.restore()
@fakeLogger.logSystemMessage.reset()
# This will require stubbing device.reboot, gosuper.post, config.get/set
it 'applies the target state'

View File

@ -0,0 +1,370 @@
Promise = require 'bluebird'
_ = require 'lodash'
m = require 'mochainon'
{ stub } = m.sinon
m.chai.use(require('chai-events'))
{ expect } = m.chai
prepare = require './lib/prepare'
DeviceState = require '../src/device-state'
DB = require('../src/db')
Config = require('../src/config')
Service = require '../src/compose/service'
{ currentState, targetState, availableImages } = require './lib/application-manager-test-states'
appDBFormatNormalised = {
appId: 1234
commit: 'bar'
releaseId: 2
name: 'app'
services: JSON.stringify([
{
appId: 1234
serviceName: 'serv'
imageId: 12345
environment: { FOO: 'var2' }
labels: {}
image: 'foo/bar:latest'
releaseId: 2
serviceId: 4
commit: 'bar'
}
])
networks: '{}'
volumes: '{}'
}
appStateFormat = {
appId: 1234
commit: 'bar'
releaseId: 2
name: 'app'
services: {
'4': {
appId: 1234
serviceName: 'serv'
imageId: 12345
environment: { FOO: 'var2' }
labels: {}
image: 'foo/bar:latest'
}
}
}
appStateFormatNeedsServiceCreate = {
appId: 1234
commit: 'bar'
releaseId: 2
name: 'app'
services: [
{
appId: 1234
environment: {
FOO: 'var2'
}
imageId: 12345
serviceId: 4
releaseId: 2
serviceName: 'serv'
image: 'foo/bar:latest'
}
]
networks: {}
volumes: {}
}
dependentStateFormat = {
appId: 1234
image: 'foo/bar'
commit: 'bar'
releaseId: 3
name: 'app'
config: { RESIN_FOO: 'var' }
environment: { FOO: 'var2' }
parentApp: 256
imageId: 45
}
dependentStateFormatNormalised = {
appId: 1234
image: 'foo/bar:latest'
commit: 'bar'
releaseId: 3
name: 'app'
config: { RESIN_FOO: 'var' }
environment: { FOO: 'var2' }
parentApp: 256
imageId: 45
}
dependentDBFormat = {
appId: 1234
image: 'foo/bar:latest'
commit: 'bar'
releaseId: 3
name: 'app'
config: JSON.stringify({ RESIN_FOO: 'var' })
environment: JSON.stringify({ FOO: 'var2' })
parentApp: 256
imageId: 45
}
describe 'ApplicationManager', ->
before ->
@timeout(5000)
prepare()
@db = new DB()
@config = new Config({ @db })
eventTracker = {
track: console.log
}
@deviceState = new DeviceState({ @db, @config, eventTracker })
@applications = @deviceState.applications
stub(@applications.images, 'inspectByName').callsFake (imageName) ->
Promise.resolve({
Config: {
Cmd: [ 'someCommand' ]
Entrypoint: [ 'theEntrypoint' ]
Env: []
Labels: {}
Volumes: []
}
})
stub(@applications.docker, 'getNetworkGateway').returns(Promise.resolve('172.17.0.1'))
stub(Service.prototype, 'extendEnvVars').callsFake (env) ->
@environment['ADDITIONAL_ENV_VAR'] = 'foo'
return @environment
@normaliseCurrent = (current) ->
Promise.map current.local.apps, (app) ->
Promise.map app.services, (service) ->
new Service(service)
.then (normalisedServices) ->
appCloned = _.clone(app)
appCloned.services = normalisedServices
return appCloned
.then (normalisedApps) ->
currentCloned = _.clone(current)
currentCloned.local.apps = normalisedApps
return currentCloned
@normaliseTarget = (target, available) =>
Promise.map target.local.apps, (app) =>
@applications.normaliseAppForDB(app)
.then (normalisedApp) =>
@applications.normaliseAndExtendAppFromDB(normalisedApp)
.then (apps) ->
targetCloned = _.cloneDeep(target)
# We mock what createTargetService does when an image is available
targetCloned.local.apps = _.map apps, (app) ->
app.services = _.map app.services, (service) ->
img = _.find(available, (i) -> i.name == service.image)
if img?
service.image = img.dockerImageId
return service
return app
return targetCloned
@db.init()
.then =>
@config.init()
after ->
@applications.images.inspectByName.restore()
@applications.docker.getNetworkGateway.restore()
Service.prototype.extendEnvVars.restore()
it 'infers a start step when all that changes is a running state', ->
Promise.join(
@normaliseCurrent(currentState[0])
@normaliseTarget(targetState[0], availableImages[0])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[0], [], true, current, target, false, {})
expect(steps).to.eventually.deep.equal([{
action: 'start'
current: current.local.apps[0].services[1]
target: target.local.apps[0].services[1]
serviceId: 24
appId: 1234
options: {}
}])
)
it 'infers a kill step when a service has to be removed', ->
Promise.join(
@normaliseCurrent(currentState[0])
@normaliseTarget(targetState[1], availableImages[0])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[0], [], true, current, target, false, {})
expect(steps).to.eventually.deep.equal([{
action: 'kill'
current: current.local.apps[0].services[1]
target: null
serviceId: 24
appId: 1234
options: {}
}])
)
it 'infers a fetch step when a service has to be updated', ->
Promise.join(
@normaliseCurrent(currentState[0])
@normaliseTarget(targetState[2], availableImages[0])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[0], [], true, current, target, false, {})
expect(steps).to.eventually.deep.equal([{
action: 'fetch'
image: @applications.imageForService(target.local.apps[0].services[1])
serviceId: 24
appId: 1234
}])
)
it 'does not infer a fetch step when the download is already in progress', ->
Promise.join(
@normaliseCurrent(currentState[0])
@normaliseTarget(targetState[2], availableImages[0])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[0], [ target.local.apps[0].services[1].imageId ], true, current, target, false, {})
expect(steps).to.eventually.deep.equal([{ action: 'noop', appId: 1234 }])
)
it 'infers a kill step when a service has to be updated but the strategy is kill-then-download', ->
Promise.join(
@normaliseCurrent(currentState[0])
@normaliseTarget(targetState[3], availableImages[0])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[0], [], true, current, target, false, {})
expect(steps).to.eventually.deep.equal([{
action: 'kill'
current: current.local.apps[0].services[1]
target: target.local.apps[0].services[1]
serviceId: 24
appId: 1234
options: {}
}])
)
it 'does not infer to kill a service with default strategy if a dependency is not downloaded', ->
Promise.join(
@normaliseCurrent(currentState[4])
@normaliseTarget(targetState[4], availableImages[2])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[2], [], true, current, target, false, {})
expect(steps).to.eventually.deep.equal([{
action: 'fetch'
image: @applications.imageForService(target.local.apps[0].services[0])
serviceId: 23
appId: 1234
}])
)
it 'infers to kill several services as long as there is no unmet dependency', ->
Promise.join(
@normaliseCurrent(currentState[0])
@normaliseTarget(targetState[5], availableImages[1])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[1], [], true, current, target, false, {})
expect(steps).to.eventually.have.deep.members([
{
action: 'kill'
current: current.local.apps[0].services[0]
target: target.local.apps[0].services[0]
serviceId: 23
appId: 1234
options: {}
},
{
action: 'kill'
current: current.local.apps[0].services[1]
target: target.local.apps[0].services[1]
serviceId: 24
appId: 1234
options: {}
}
])
)
it 'infers to start the dependency first', ->
Promise.join(
@normaliseCurrent(currentState[1])
@normaliseTarget(targetState[4], availableImages[1])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[1], [], true, current, target, false, {})
expect(steps).to.eventually.have.deep.members([
{
action: 'start'
current: null
target: target.local.apps[0].services[0]
serviceId: 23
appId: 1234
options: {}
}
])
)
it 'infers to start a service once its dependency has been met', ->
Promise.join(
@normaliseCurrent(currentState[2])
@normaliseTarget(targetState[4], availableImages[1])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[1], [], true, current, target, false, {})
expect(steps).to.eventually.have.deep.members([
{
action: 'start'
current: null
target: target.local.apps[0].services[1]
serviceId: 24
appId: 1234
options: {}
}
])
)
it 'infers to remove spurious containers', ->
Promise.join(
@normaliseCurrent(currentState[3])
@normaliseTarget(targetState[4], availableImages[1])
(current, target) =>
steps = @applications._inferNextSteps(false, availableImages[1], [], true, current, target, false, {})
expect(steps).to.eventually.have.deep.members([
{
action: 'kill'
current: current.local.apps[0].services[0]
target: null
serviceId: 23
appId: 1234
options: {}
},
{
action: 'start'
current: null
target: target.local.apps[0].services[1]
serviceId: 24
appId: 1234
options: {}
}
])
)
it 'converts an app from a state format to a db format, adding missing networks and volumes and normalising the image name', ->
app = @applications.normaliseAppForDB(appStateFormat)
expect(app).to.eventually.deep.equal(appDBFormatNormalised)
it 'converts a dependent app from a state format to a db format, normalising the image name', ->
app = @applications.proxyvisor.normaliseDependentAppForDB(dependentStateFormat)
expect(app).to.eventually.deep.equal(dependentDBFormat)
it 'converts an app in DB format into state format, adding default and missing fields', ->
@applications.normaliseAndExtendAppFromDB(appDBFormatNormalised)
.then (app) ->
appStateFormatWithDefaults = _.cloneDeep(appStateFormatNeedsServiceCreate)
opts = { imageInfo: { Config: { Cmd: [ 'someCommand' ], Entrypoint: [ 'theEntrypoint' ] } } }
appStateFormatWithDefaults.services = _.map appStateFormatWithDefaults.services, (service) ->
service.imageName = service.image
return new Service(service, opts)
expect(JSON.parse(JSON.stringify(app))).to.deep.equal(JSON.parse(JSON.stringify(appStateFormatWithDefaults)))
it 'converts a dependent app in DB format into state format', ->
app = @applications.proxyvisor.normaliseDependentAppFromDB(dependentDBFormat)
expect(app).to.eventually.deep.equal(dependentStateFormatNormalised)

25
test/data/apps.json Normal file
View File

@ -0,0 +1,25 @@
{
"name": "aDevice",
"config": {
"RESIN_HOST_CONFIG_gpu_mem": "256",
"RESIN_HOST_LOG_TO_DISPLAY": "0"
},
"apps": {
"1234": {
"name": "superapp",
"commit": "abcdef",
"releaseId": 1,
"services": {
"23": {
"imageId": 12345,
"serviceName": "someservice",
"image": "registry2.resin.io/superapp/abcdef",
"labels": {
"io.resin.something": "bar"
},
"environment": {}
}
}
}
}
}

1
test/data/etc/hostname Normal file
View File

@ -0,0 +1 @@
foobardevice

2
test/data/etc/os-release Normal file
View File

@ -0,0 +1,2 @@
PRETTY_NAME="Resin OS 2.0.6 (fake)"
VARIANT_ID="dev"

View File

@ -0,0 +1,2 @@
PRETTY_NAME="Resin OS 1.27.0 (fake)"
VARIANT_ID="dev"

View File

@ -0,0 +1 @@
PRETTY_NAME="Resin OS 2.0.6 (fake)"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1 @@
keep me in the repo

View File

@ -0,0 +1 @@
{"applicationName":"supertestrpi3","applicationId":78373,"deviceType":"raspberrypi3","userId":1001,"username":"someone","appUpdatePollInterval":3000,"listenPort":2345,"vpnPort":443,"apiEndpoint":"http://0.0.0.0:3000","vpnEndpoint":"vpn.resin.io","registryEndpoint":"registry2.resin.io","deltaEndpoint":"https://delta.resin.io","pubnubSubscribeKey":"foo","pubnubPublishKey":"bar","mixpanelToken":"baz","apiKey":"boo","version":"2.0.6+rev3.prod"}

View File

@ -0,0 +1 @@
{"applicationName":"supertestrpi3","applicationId":78373,"deviceType":"raspberrypi3","userId":1001,"username":"someone","appUpdatePollInterval":3000,"listenPort":2345,"vpnPort":443,"apiEndpoint":"https://api.resin.io","vpnEndpoint":"vpn.resin.io","registryEndpoint":"registry2.resin.io","deltaEndpoint":"https://delta.resin.io","pubnubSubscribeKey":"foo","pubnubPublishKey":"bar","mixpanelToken":"baz","apiKey":"boo","version":"2.0.6+rev3.prod"}

View File

@ -0,0 +1,685 @@
exports.targetState = targetState = []
targetState[0] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: {
'23': {
appId: 1234
serviceName: 'aservice'
commit: 'afafafa'
imageId: 12345
image: 'registry2.resin.io/superapp/edfabc:latest'
environment: {
'FOO': 'bar'
}
privileged: false
volumes: []
labels: {}
running: true
},
'24': {
appId: 1234
serviceName: 'anotherService'
commit: 'afafafa'
imageId: 12346
image: 'registry2.resin.io/superapp/afaff:latest'
environment: {
'FOO': 'bro'
}
volumes: []
privileged: false
labels: {}
running: true
}
}
volumes: {}
networks: {}
}
]
}
dependent: { apps: [], devices: [] }
}
targetState[1] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: {
'23': {
appId: 1234
serviceName: 'aservice'
commit: 'afafafa'
imageId: 12345
image: 'registry2.resin.io/superapp/edfabc:latest'
environment: {
'FOO': 'bar'
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
volumes: []
labels: {}
running: true
}
}
volumes: {}
networks: {}
}
]
}
dependent: { apps: [], devices: [] }
}
targetState[2] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: {
'23': {
appId: 1234
serviceName: 'aservice'
commit: 'afafafa'
imageId: 12345
image: 'registry2.resin.io/superapp/edfabc:latest'
environment: {
'FOO': 'bar'
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
volumes: []
labels: {}
running: true
},
'24': {
appId: 1234
serviceName: 'anotherService'
commit: 'afafafa'
imageId: 12347
image: 'registry2.resin.io/superapp/foooo:latest'
depends_on: [ 'aservice' ]
environment: {
'FOO': 'bro'
'ADDITIONAL_ENV_VAR': 'foo'
}
volumes: []
privileged: false
labels: {}
running: true
}
}
volumes: {}
networks: {}
}
]
}
dependent: { apps: [], devices: [] }
}
targetState[3] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: {
'23': {
appId: 1234
serviceName: 'aservice'
commit: 'afafafa'
imageId: 12345
image: 'registry2.resin.io/superapp/edfabc:latest'
environment: {
'FOO': 'bar'
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
volumes: []
labels: {}
running: true
},
'24': {
appId: 1234
serviceName: 'anotherService'
commit: 'afafafa'
imageId: 12347
image: 'registry2.resin.io/superapp/foooo:latest'
environment: {
'FOO': 'bro'
'ADDITIONAL_ENV_VAR': 'foo'
}
volumes: []
privileged: false
labels: {
'io.resin.update.strategy': 'kill-then-download'
}
running: true
}
}
volumes: {}
networks: {}
}
]
}
dependent: { apps: [], devices: [] }
}
targetState[4] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: {
'23': {
appId: 1234
serviceName: 'aservice'
commit: 'afafafa'
imageId: 12345
image: 'registry2.resin.io/superapp/edfabc:latest'
environment: {
'FOO': 'THIS VALUE CHANGED'
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
volumes: []
labels: {}
running: true
},
'24': {
appId: 1234
serviceName: 'anotherService'
commit: 'afafafa'
imageId: 12347
image: 'registry2.resin.io/superapp/foooo:latest'
depends_on: [ 'aservice' ]
environment: {
'FOO': 'bro'
'ADDITIONAL_ENV_VAR': 'foo'
}
volumes: []
privileged: false
labels: {}
running: true
}
}
volumes: {}
networks: {}
}
]
}
dependent: { apps: [], devices: [] }
}
targetState[5] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: {
'23': {
appId: 1234
serviceName: 'aservice'
commit: 'afafafa'
imageId: 12345
image: 'registry2.resin.io/superapp/edfabc:latest'
environment: {
'FOO': 'THIS VALUE CHANGED'
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
volumes: []
labels: {}
running: true
},
'24': {
appId: 1234
serviceName: 'anotherService'
commit: 'afafafa'
imageId: 12347
image: 'registry2.resin.io/superapp/foooo:latest'
environment: {
'FOO': 'bro'
'ADDITIONAL_ENV_VAR': 'foo'
}
volumes: []
privileged: false
labels: {}
running: true
}
}
volumes: {}
networks: {}
}
]
}
dependent: { apps: [], devices: [] }
}
exports.currentState = currentState = []
currentState[0] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: [
{
appId: 1234
serviceId: 23
releaseId: 2
commit: 'afafafa'
serviceName: 'aservice'
imageId: 12345
image: 'id1'
environment: {
'FOO': 'bar'
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
volumes: [
'/tmp/resin-supervisor/services/1234/aservice:/tmp/resin'
]
labels: {
'io.resin.app-id': '1234'
'io.resin.service-id': '23'
'io.resin.supervised': 'true'
'io.resin.service-name': 'aservice'
}
running: true
createdAt: new Date()
containerId: '1'
networkMode: '1234_default'
networks: { '1234_default': {} }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
},
{
appId: 1234
serviceId: 24
releaseId: 2
commit: 'afafafa'
serviceName: 'anotherService'
imageId: 12346
image: 'id0'
environment: {
'FOO': 'bro'
'ADDITIONAL_ENV_VAR': 'foo'
}
volumes: [
'/tmp/resin-supervisor/services/1234/anotherService:/tmp/resin'
]
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
labels: {
'io.resin.app-id': '1234'
'io.resin.service-id': '24'
'io.resin.supervised': 'true'
'io.resin.service-name': 'anotherService'
}
running: false
createdAt: new Date()
containerId: '2'
networkMode: '1234_default'
networks: { '1234_default': {} }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
}
]
volumes: {}
networks: { default: {} }
}
]
}
dependent: { apps: [], devices: [] }
}
currentState[1] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: []
volumes: {}
networks: { default: {} }
}
]
}
dependent: { apps: [], devices: [] }
}
currentState[2] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: [
{
appId: 1234
serviceId: 23
releaseId: 2
commit: 'afafafa'
expose: []
ports: []
serviceName: 'aservice'
imageId: 12345
image: 'id1'
environment: {
'FOO': 'THIS VALUE CHANGED'
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
volumes: [
'/tmp/resin-supervisor/services/1234/aservice:/tmp/resin'
]
labels: {
'io.resin.app-id': '1234'
'io.resin.service-id': '23'
'io.resin.supervised': 'true'
'io.resin.service-name': 'aservice'
}
running: true
createdAt: new Date()
containerId: '1'
networkMode: '1234_default'
networks: { '1234_default': {} }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
}
]
volumes: {}
networks: { default: {} }
}
]
}
dependent: { apps: [], devices: [] }
}
currentState[3] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: [
{
appId: 1234
serviceId: 23
serviceName: 'aservice'
imageId: 12345
releaseId: 2
commit: 'afafafa'
expose: []
ports: []
image: 'id1'
environment: {
'FOO': 'THIS VALUE CHANGED'
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
volumes: [
'/tmp/resin-supervisor/services/1234/aservice:/tmp/resin'
]
labels: {
'io.resin.app-id': '1234'
'io.resin.service-id': '23'
'io.resin.supervised': 'true'
'io.resin.service-name': 'aservice'
}
running: true
createdAt: new Date(0)
containerId: '1'
networkMode: '1234_default'
networks: { '1234_default': {} }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
},
{
appId: 1234
serviceId: 23
serviceName: 'aservice'
imageId: 12345
releaseId: 2
commit: 'afafafa'
expose: []
ports: []
image: 'id1'
environment: {
'FOO': 'THIS VALUE CHANGED'
'ADDITIONAL_ENV_VAR': 'foo'
}
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
volumes: [
'/tmp/resin-supervisor/services/1234/aservice:/tmp/resin'
]
labels: {
'io.resin.app-id': '1234'
'io.resin.service-id': '23'
'io.resin.supervised': 'true'
'io.resin.service-name': 'aservice'
}
running: true
createdAt: new Date(1)
containerId: '2'
networkMode: '1234_default'
networks: { '1234_default': {} }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
}
]
volumes: {}
networks: { default: {} }
}
]
}
dependent: { apps: [], devices: [] }
}
currentState[4] = {
local: {
name: 'aDeviceWithDifferentName'
config: {
'RESIN_HOST_CONFIG_gpu_mem': '512'
'RESIN_HOST_LOG_TO_DISPLAY': '1'
}
apps: [
{
appId: 1234
name: 'superapp'
commit: 'afafafa'
releaseId: 2
services: [
{
appId: 1234
serviceId: 24
releaseId: 2
commit: 'afafafa'
serviceName: 'anotherService'
imageId: 12346
image: 'id0'
environment: {
'FOO': 'bro'
'ADDITIONAL_ENV_VAR': 'foo'
}
volumes: [
'/tmp/resin-supervisor/services/1234/anotherService:/tmp/resin'
]
privileged: false
restartPolicy:
Name: 'always'
MaximumRetryCount: 0
labels: {
'io.resin.app-id': '1234'
'io.resin.service-id': '24'
'io.resin.supervised': 'true'
'io.resin.service-name': 'anotherService'
}
running: false
createdAt: new Date()
containerId: '2'
networkMode: '1234_default'
networks: { '1234_default': {} }
command: [ 'someCommand' ]
entrypoint: [ 'theEntrypoint' ]
}
]
volumes: {}
networks: { default: {} }
}
]
}
dependent: { apps: [], devices: [] }
}
exports.availableImages = availableImages = []
availableImages[0] = [
{
name: 'registry2.resin.io/superapp/afaff:latest'
appId: 1234
serviceId: 24
serviceName: 'anotherService'
imageId: 12346
releaseId: 2
dependent: 0
dockerImageId: 'id0'
},
{
name: 'registry2.resin.io/superapp/edfabc:latest'
appId: 1234
serviceId: 23
serviceName: 'aservice'
imageId: 12345
releaseId: 2
dependent: 0
dockerImageId: 'id1'
}
]
availableImages[1] = [
{
name: 'registry2.resin.io/superapp/foooo:latest'
appId: 1234
serviceId: 24
serviceName: 'anotherService'
imageId: 12347
releaseId: 2
dependent: 0
dockerImageId: 'id2'
},
{
name: 'registry2.resin.io/superapp/edfabc:latest'
appId: 1234
serviceId: 23
serviceName: 'aservice'
imageId: 12345
releaseId: 2
dependent: 0
dockerImageId: 'id1'
}
]
availableImages[2] = [
{
name: 'registry2.resin.io/superapp/foooo:latest'
appId: 1234
serviceId: 24
serviceName: 'anotherService'
imageId: 12347
releaseId: 2
dependent: 0
dockerImageId: 'id2'
}
]

View File

@ -0,0 +1,35 @@
express = require 'express'
_ = require 'lodash'
api = express()
api.use(require('body-parser').json())
api.resinBackend = {
currentId: 1
devices: {}
registerHandler: (req, res) ->
console.log('/device/register called with ', req.body)
device = req.body
device.id = api.resinBackend.currentId++
api.resinBackend.devices[device.id] = device
res.status(201).json(device)
getDeviceHandler: (req, res) ->
uuid = req.query['$filter']?.match(/uuid eq '(.*)'/)?[1]
if uuid?
res.json({ d: _.filter(api.resinBackend.devices, (dev) -> dev.uuid is uuid ) })
else
res.json({ d: [] })
deviceKeyHandler: (req, res) ->
res.status(200).send(req.body.apiKey)
}
api.post '/device/register', (req, res) ->
api.resinBackend.registerHandler(req, res)
api.get '/v4/device', (req, res) ->
api.resinBackend.getDeviceHandler(req, res)
api.post '/api-key/device/:deviceId/device-key', (req, res) ->
api.resinBackend.deviceKeyHandler(req, res)
module.exports = api

18
test/lib/prepare.coffee Normal file
View File

@ -0,0 +1,18 @@
fs = require('fs')
module.exports = ->
try
fs.unlinkSync(process.env.DATABASE_PATH)
try
fs.unlinkSync(process.env.DATABASE_PATH_2)
try
fs.unlinkSync(process.env.DATABASE_PATH_3)
try
fs.unlinkSync(process.env.LED_FILE)
try
fs.writeFileSync('./test/data/config.json', fs.readFileSync('./test/data/testconfig.json'))
fs.writeFileSync('./test/data/config-apibinder.json', fs.readFileSync('./test/data/testconfig-apibinder.json'))