Auto-merge for PR #792 via VersionBot

Multicontainer
This commit is contained in:
resin-io-versionbot[bot] 2018-03-09 22:12:00 +00:00 committed by GitHub
commit 4ef0682e5a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 2138 additions and 672 deletions

View File

@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
This project adheres to [Semantic Versioning](http://semver.org/).
## v7.0.0 - 2018-03-09
* Add docker-compose-aware builds and deployments #792 [Akis Kesoglou]
* *BREAKING*: Remove support for plugins entirely #792 [Tim Perry]
* Update dashboard login to use the multicontainer SDK #792 [Alexis Svinartchouk]
* Multicontainer preload: Update resin-preload to 6.0.0-beta4 #792 [Alexis Svinartchouk]
* Update the keys action to use the multicontainer SDK #792 [Alexis Svinartchouk]
* Require multicontainer SDK #792 [Alexis Svinartchouk]
## v6.13.5 - 2018-03-07
* Fix prettier configuration to avoid linting errors #802 [Tim Perry]

View File

@ -81,13 +81,6 @@ _(Typically useful, but not strictly required for all commands)_
Take a look at the full command documentation at [https://docs.resin.io/tools/cli/](https://docs.resin.io/tools/cli/#table-of-contents
), or by running `resin help`.
---
Plugins
-------
The Resin CLI can be extended with plugins to automate laborious tasks and overall provide a better experience when working with Resin.io. Check the [plugin development tutorial](https://github.com/resin-io/resin-plugin-hello) to learn how to build your own!
FAQ
---

View File

@ -1123,8 +1123,7 @@ your shell environment. For more information (including Windows support)
please check the README here: https://github.com/resin-io/resin-cli .
Use this command to preload an application to a local disk image (or
Edison zip archive) with a built commit from Resin.io.
This can be used with cloud builds, or images deployed with resin deploy.
Edison zip archive) with a built release from Resin.io.
Examples:
$ resin preload resin.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image some-image.png
@ -1138,7 +1137,7 @@ id of the application to preload
#### --commit, -c <hash>
a specific application commit to preload, use "latest" to specify the latest commit
the commit hash for a specific application release to preload, use "latest" to specify the latest release
(ignored if no appId is given)
#### --splash-image, -s <splashImage.png>
@ -1433,12 +1432,19 @@ name of container to stop
## build [source]
Use this command to build a container with a provided docker daemon.
Use this command to build an image or a complete multicontainer project
with the provided docker daemon.
You must provide either an application or a device-type/architecture
pair to use the resin Dockerfile pre-processor
(e.g. Dockerfile.template -> Dockerfile).
This command will look into the given source directory (or the current working
directory if one isn't specified) for a compose file. If one is found, this
command will build each service defined in the compose file. If a compose file
isn't found, the command will look for a Dockerfile, and if yet that isn't found,
it will try to generate one.
Examples:
$ resin build
@ -1462,6 +1468,18 @@ The type of device this build is for
The target resin.io application this build is for
#### --projectName, -n <projectName>
Specify an alternate project name; default is the directory name
#### --emulated, -e
Run an emulated build using Qemu
#### --logs
Display full log output
#### --docker, -P <docker>
Path to a local docker socket
@ -1498,20 +1516,24 @@ Set a build-time variable (eg. "-B 'ARG=value'"). Can be specified multiple time
Don't use docker layer caching when building
#### --emulated, -e
Run an emulated build using Qemu
#### --squash
Squash newly built layers into a single new layer
## deploy <appName> [image]
Use this command to deploy an image to an application, optionally building it first.
Use this command to deploy an image or a complete multicontainer project
to an application, optionally building it first.
Usage: `deploy <appName> ([image] | --build [--source build-dir])`
Unless an image is specified, this command will look into the current directory
(or the one specified by --source) for a compose file. If one is found, this
command will deploy each service defined in the compose file, building it first
if an image for it doesn't exist. If a compose file isn't found, the command
will look for a Dockerfile, and if yet that isn't found, it will try to
generate one.
To deploy to an app on which you're a collaborator, use
`resin deploy <appOwnerUsername>/<appName>`.
@ -1519,23 +1541,37 @@ Note: If building with this command, all options supported by `resin build`
are also supported with this command.
Examples:
$ resin deploy myApp
$ resin deploy myApp --build --source myBuildDir/
$ resin deploy myApp myApp/myImage
### Options
#### --build, -b
Build image then deploy
#### --source, -s &#60;source&#62;
The source directory to use when building the image
Specify an alternate source directory; default is the working directory
#### --build, -b
Force a rebuild before deploy
#### --nologupload
Don't upload build logs to the dashboard with image (if building)
#### --projectName, -n &#60;projectName&#62;
Specify an alternate project name; default is the directory name
#### --emulated, -e
Run an emulated build using Qemu
#### --logs
Display full log output
#### --docker, -P &#60;docker&#62;
Path to a local docker socket
@ -1572,10 +1608,6 @@ Set a build-time variable (eg. "-B 'ARG=value'"). Can be specified multiple time
Don't use docker layer caching when building
#### --emulated, -e
Run an emulated build using Qemu
#### --squash
Squash newly built layers into a single new layer

View File

@ -2,32 +2,61 @@
# of this action
Promise = require('bluebird')
dockerUtils = require('../utils/docker')
compose = require('../utils/compose')
getBundleInfo = Promise.method (options) ->
helpers = require('../utils/helpers')
###
Opts must be an object with the following keys:
if options.application?
# An application was provided
return helpers.getArchAndDeviceType(options.application)
.then (app) ->
return [app.arch, app.device_type]
else if options.arch? and options.deviceType?
return [options.arch, options.deviceType]
else
# No information, cannot do resolution
return undefined
appName: the name of the app this build is for; optional
arch: the architecture to build for
deviceType: the device type to build for
projectPath: the project root directory; must be absolute
buildEmulated
buildOpts: arguments to forward to docker build command
###
buildProject = (docker, logger, composeOpts, opts) ->
compose.loadProject(
logger
composeOpts.projectPath
composeOpts.projectName
)
.then (project) ->
compose.buildProject(
docker
logger
project.path
project.name
project.composition
opts.arch
opts.deviceType
opts.buildEmulated
opts.buildOpts
composeOpts.inlineLogs
)
.then ->
logger.logSuccess('Build succeeded!')
.tapCatch (e) ->
logger.logError('Build failed')
module.exports =
signature: 'build [source]'
description: 'Build a container locally'
description: 'Build a single image or a multicontainer project locally'
permission: 'user'
primary: true
help: '''
Use this command to build a container with a provided docker daemon.
Use this command to build an image or a complete multicontainer project
with the provided docker daemon.
You must provide either an application or a device-type/architecture
pair to use the resin Dockerfile pre-processor
(e.g. Dockerfile.template -> Dockerfile).
This command will look into the given source directory (or the current working
directory if one isn't specified) for a compose file. If one is found, this
command will build each service defined in the compose file. If a compose file
isn't found, the command will look for a Dockerfile, and if yet that isn't found,
it will try to generate one.
Examples:
$ resin build
@ -37,7 +66,7 @@ module.exports =
$ resin build --docker '/var/run/docker.sock'
$ resin build --dockerHost my.docker.host --dockerPort 2376 --ca ca.pem --key key.pem --cert cert.pem
'''
options: dockerUtils.appendOptions [
options: dockerUtils.appendOptions compose.appendOptions [
{
signature: 'arch'
parameter: 'arch'
@ -58,7 +87,46 @@ module.exports =
},
]
action: (params, options, done) ->
Logger = require('../utils/logger')
dockerUtils.runBuild(params, options, getBundleInfo, new Logger())
.asCallback(done)
# compositions with many services trigger misleading warnings
require('events').defaultMaxListeners = 1000
helpers = require('../utils/helpers')
Logger = require('../utils/logger')
logger = new Logger()
logger.logDebug('Parsing input...')
Promise.try ->
# `build` accepts `[source]` as a parameter, but compose expects it
# as an option. swap them here
options.source ?= params.source
delete params.source
{ application, arch, deviceType } = options
if (not (arch? and deviceType?) and not application?) or (application? and (arch? or deviceType?))
throw new Error('You must specify either an application or an arch/deviceType pair to build for')
if arch? and deviceType?
[ undefined, arch, deviceType ]
else
helpers.getArchAndDeviceType(application)
.then (app) ->
[ application, app.arch, app.device_type ]
.then ([ appName, arch, deviceType ]) ->
Promise.join(
dockerUtils.getDocker(options)
dockerUtils.generateBuildOpts(options)
compose.generateOpts(options)
(docker, buildOpts, composeOpts) ->
buildProject(docker, logger, composeOpts, {
appName
arch
deviceType
buildEmulated: !!options.emulated
buildOpts
})
)
.asCallback(done)

View File

@ -1,121 +1,113 @@
# Imported here because it's needed for the setup
# of this action
Promise = require('bluebird')
dockerUtils = require('../utils/docker')
compose = require('../utils/compose')
getBuilderPushEndpoint = (baseUrl, owner, app) ->
querystring = require('querystring')
args = querystring.stringify({ owner, app })
"https://builder.#{baseUrl}/v1/push?#{args}"
###
Opts must be an object with the following keys:
getBuilderLogPushEndpoint = (baseUrl, buildId, owner, app) ->
querystring = require('querystring')
args = querystring.stringify({ owner, app, buildId })
"https://builder.#{baseUrl}/v1/pushLogs?#{args}"
app: the application instance to deploy to
image: the image to deploy; optional
shouldPerformBuild
shouldUploadLogs
buildEmulated
buildOpts: arguments to forward to docker build command
###
deployProject = (docker, logger, composeOpts, opts) ->
_ = require('lodash')
doodles = require('resin-doodles')
sdk = require('resin-sdk').fromSharedOptions()
formatImageName = (image) ->
image.split('/').pop()
parseInput = Promise.method (params, options) ->
if not params.appName?
throw new Error('Need an application to deploy to!')
appName = params.appName
image = undefined
if params.image?
if options.build or options.source?
throw new Error('Build and source parameters are not applicable when specifying an image')
options.build = false
image = params.image
else if options.build
source = options.source || '.'
else
throw new Error('Need either an image or a build flag!')
return [appName, options.build, source, image]
showPushProgress = (message) ->
visuals = require('resin-cli-visuals')
progressBar = new visuals.Progress(message)
progressBar.update({ percentage: 0 })
return progressBar
getBundleInfo = (options) ->
helpers = require('../utils/helpers')
helpers.getArchAndDeviceType(options.appName)
.then (app) ->
[app.arch, app.device_type]
performUpload = (imageStream, token, username, url, appName, logger) ->
request = require('request')
progressStream = require('progress-stream')
zlib = require('zlib')
# Need to strip off the newline
progressMessage = logger.formatMessage('info', 'Deploying').slice(0, -1)
progressBar = showPushProgress(progressMessage)
streamWithProgress = imageStream.pipe progressStream
time: 500,
length: imageStream.length
, ({ percentage, eta }) ->
progressBar.update
percentage: Math.min(percentage, 100)
eta: eta
uploadRequest = request.post
url: getBuilderPushEndpoint(url, username, appName)
headers:
'Content-Encoding': 'gzip'
auth:
bearer: token
body: streamWithProgress.pipe(zlib.createGzip({
level: 6
}))
uploadToPromise(uploadRequest, logger)
uploadLogs = (logs, token, url, buildId, username, appName) ->
request = require('request')
request.post
json: true
url: getBuilderLogPushEndpoint(url, buildId, username, appName)
auth:
bearer: token
body: Buffer.from(logs)
uploadToPromise = (uploadRequest, logger) ->
new Promise (resolve, reject) ->
handleMessage = (data) ->
data = data.toString()
logger.logDebug("Received data: #{data}")
try
obj = JSON.parse(data)
catch e
logger.logError('Error parsing reply from remote side')
reject(e)
return
if obj.type?
switch obj.type
when 'error' then reject(new Error("Remote error: #{obj.error}"))
when 'success' then resolve(obj)
when 'status' then logger.logInfo("Remote: #{obj.message}")
else reject(new Error("Received unexpected reply from remote: #{data}"))
else
reject(new Error("Received unexpected reply from remote: #{data}"))
uploadRequest
.on('error', reject)
.on('data', handleMessage)
compose.loadProject(
logger
composeOpts.projectPath
composeOpts.projectName
opts.image
)
.then (project) ->
# find which services use images that already exist locally
Promise.map project.descriptors, (d) ->
# unconditionally build (or pull) if explicitly requested
return d if opts.shouldPerformBuild
docker.getImage(d.image.tag ? d.image).inspect()
.return(d.serviceName)
.catchReturn()
.filter (d) -> !!d
.then (servicesToSkip) ->
# multibuild takes in a composition and always attempts to
# build or pull all services. we workaround that here by
# passing a modified composition.
compositionToBuild = _.cloneDeep(project.composition)
compositionToBuild.services = _.omit(compositionToBuild.services, servicesToSkip)
if _.size(compositionToBuild.services) is 0
logger.logInfo('Everything is up to date (use --build to force a rebuild)')
return {}
compose.buildProject(
docker
logger
project.path
project.name
compositionToBuild
opts.app.arch
opts.app.device_type
opts.buildEmulated
opts.buildOpts
composeOpts.inlineLogs
)
.then (builtImages) ->
_.keyBy(builtImages, 'serviceName')
.then (builtImages) ->
project.descriptors.map (d) ->
builtImages[d.serviceName] ? {
serviceName: d.serviceName,
name: d.image.tag ? d.image
logs: 'Build skipped; image for service already exists.'
props: {}
}
.then (images) ->
Promise.join(
sdk.auth.getUserId()
sdk.auth.getToken()
sdk.settings.get('apiUrl')
(userId, auth, apiEndpoint) ->
compose.deployProject(
docker
logger
project.composition
images
opts.app.id
userId
"Bearer #{auth}"
apiEndpoint
!opts.shouldUploadLogs
)
)
.then (release) ->
logger.logSuccess('Deploy succeeded!')
logger.logSuccess("Release: #{release.commit}")
console.log()
console.log(doodles.getDoodle()) # Show charlie
console.log()
.tapCatch (e) ->
logger.logError('Deploy failed')
module.exports =
signature: 'deploy <appName> [image]'
description: 'Deploy an image to a resin.io application'
description: 'Deploy a single image or a multicontainer project to a resin.io application'
help: '''
Use this command to deploy an image to an application, optionally building it first.
Use this command to deploy an image or a complete multicontainer project
to an application, optionally building it first.
Usage: `deploy <appName> ([image] | --build [--source build-dir])`
Unless an image is specified, this command will look into the current directory
(or the one specified by --source) for a compose file. If one is found, this
command will deploy each service defined in the compose file, building it first
if an image for it doesn't exist. If a compose file isn't found, the command
will look for a Dockerfile, and if yet that isn't found, it will try to
generate one.
To deploy to an app on which you're a collaborator, use
`resin deploy <appOwnerUsername>/<appName>`.
@ -123,23 +115,26 @@ module.exports =
are also supported with this command.
Examples:
$ resin deploy myApp
$ resin deploy myApp --build --source myBuildDir/
$ resin deploy myApp myApp/myImage
'''
permission: 'user'
options: dockerUtils.appendOptions [
{
signature: 'build'
boolean: true
description: 'Build image then deploy'
alias: 'b'
},
primary: true
options: dockerUtils.appendOptions compose.appendOptions [
{
signature: 'source'
parameter: 'source'
description: 'The source directory to use when building the image'
description: 'Specify an alternate source directory; default is the working directory'
alias: 's'
},
{
signature: 'build'
boolean: true
description: 'Force a rebuild before deploy'
alias: 'b'
},
{
signature: 'nologupload'
description: "Don't upload build logs to the dashboard with image (if building)"
@ -147,83 +142,53 @@ module.exports =
}
]
action: (params, options, done) ->
_ = require('lodash')
tmp = require('tmp')
tmpNameAsync = Promise.promisify(tmp.tmpName)
resin = require('resin-sdk-preconfigured')
# compositions with many services trigger misleading warnings
require('events').defaultMaxListeners = 1000
helpers = require('../utils/helpers')
Logger = require('../utils/logger')
logger = new Logger()
# Ensure the tmp files gets deleted
tmp.setGracefulCleanup()
logger.logDebug('Parsing input...')
logs = ''
Promise.try ->
{ appName, image } = params
upload = (token, username, url) ->
dockerUtils.getDocker(options)
.then (docker) ->
# Check input parameters
parseInput(params, options)
.then ([appName, build, source, imageName]) ->
tmpNameAsync()
.then (bufferFile) ->
# look into "resin build" options if appName isn't given
appName = options.application if not appName?
delete options.application
# Setup the build args for how the build routine expects them
options = _.assign({}, options, { appName })
params = _.assign({}, params, { source })
if not appName?
throw new Error('Please specify the name of the application to deploy')
Promise.try ->
if build
dockerUtils.runBuild(params, options, getBundleInfo, logger)
else
{ image: imageName, log: '' }
.then ({ image: imageName, log: buildLogs }) ->
logger.logInfo('Initializing deploy...')
if image? and options.build
throw new Error('Build option is not applicable when specifying an image')
logs = buildLogs
Promise.all [
dockerUtils.bufferImage(docker, imageName, bufferFile)
token
username
url
params.appName
logger
]
.spread(performUpload)
.finally ->
# If the file was never written to (for instance because an error
# has occured before any data was written) this call will throw an
# ugly error, just suppress it
Promise.try ->
require('mz/fs').unlink(bufferFile)
.catch(_.noop)
.tap ({ image: imageName, buildId }) ->
logger.logSuccess("Successfully deployed image: #{formatImageName(imageName)}")
return buildId
.then ({ image: imageName, buildId }) ->
if logs is '' or options.nologupload?
return ''
Promise.join(
helpers.getApplication(appName)
helpers.getArchAndDeviceType(appName)
(app, { arch, device_type }) ->
app.arch = arch
app.device_type = device_type
return app
)
.then (app) ->
[ app, image, !!options.build, !options.nologupload ]
logger.logInfo('Uploading logs to dashboard...')
Promise.join(
logs
token
url
buildId
username
params.appName
uploadLogs
)
.return('Successfully uploaded logs')
.then (msg) ->
logger.logSuccess(msg) if msg isnt ''
.asCallback(done)
Promise.join(
resin.auth.getToken()
resin.auth.whoami()
resin.settings.get('resinUrl')
upload
)
.then ([ app, image, shouldPerformBuild, shouldUploadLogs ]) ->
Promise.join(
dockerUtils.getDocker(options)
dockerUtils.generateBuildOpts(options)
compose.generateOpts(options)
(docker, buildOpts, composeOpts) ->
deployProject(docker, logger, composeOpts, {
app
image
shouldPerformBuild
shouldUploadLogs
buildEmulated: !!options.emulated
buildOpts
})
)
.asCallback(done)

View File

@ -55,8 +55,6 @@ general = (params, options, done) ->
return command.hidden or command.isWildcard()
groupedCommands = _.groupBy commands, (command) ->
if command.plugin
return 'plugins'
if command.primary
return 'primary'
return 'secondary'
@ -64,10 +62,6 @@ general = (params, options, done) ->
print(parse(groupedCommands.primary))
if options.verbose
if not _.isEmpty(groupedCommands.plugins)
console.log('\nInstalled plugins:\n')
print(parse(groupedCommands.plugins))
console.log('\nAdditional commands:\n')
print(parse(groupedCommands.secondary))
else

View File

@ -28,7 +28,7 @@ exports.list =
'''
permission: 'user'
action: (params, options, done) ->
resin = require('resin-sdk-preconfigured')
resin = require('resin-sdk').fromSharedOptions()
visuals = require('resin-cli-visuals')
resin.models.key.getAll().then (keys) ->
@ -50,7 +50,7 @@ exports.info =
'''
permission: 'user'
action: (params, options, done) ->
resin = require('resin-sdk-preconfigured')
resin = require('resin-sdk').fromSharedOptions()
visuals = require('resin-cli-visuals')
resin.models.key.get(params.id).then (key) ->
@ -82,7 +82,7 @@ exports.remove =
options: [ commandOptions.yes ]
permission: 'user'
action: (params, options, done) ->
resin = require('resin-sdk-preconfigured')
resin = require('resin-sdk').fromSharedOptions()
patterns = require('../utils/patterns')
patterns.confirm(options.yes, 'Are you sure you want to delete the key?').then ->
@ -109,7 +109,7 @@ exports.add =
Promise = require('bluebird')
readFileAsync = Promise.promisify(require('fs').readFile)
capitano = require('capitano')
resin = require('resin-sdk-preconfigured')
resin = require('resin-sdk').fromSharedOptions()
Promise.try ->
return readFileAsync(params.path, encoding: 'utf8') if params.path?

View File

@ -1,9 +1,10 @@
Promise = require('bluebird')
_ = require('lodash')
Docker = require('docker-toolbelt')
form = require('resin-cli-form')
chalk = require('chalk')
dockerUtils = require('../../utils/docker')
exports.dockerPort = dockerPort = 2375
exports.dockerTimeout = dockerTimeout = 2000
@ -13,7 +14,7 @@ exports.filterOutSupervisorContainer = filterOutSupervisorContainer = (container
return true
exports.selectContainerFromDevice = Promise.method (deviceIp, filterSupervisor = false) ->
docker = new Docker(host: deviceIp, port: dockerPort, timeout: dockerTimeout)
docker = dockerUtils.createClient(host: deviceIp, port: dockerPort, timeout: dockerTimeout)
# List all containers, including those not running
docker.listContainersAsync(all: true)
@ -38,7 +39,7 @@ exports.selectContainerFromDevice = Promise.method (deviceIp, filterSupervisor =
}
exports.pipeContainerStream = Promise.method ({ deviceIp, name, outStream, follow = false }) ->
docker = new Docker(host: deviceIp, port: dockerPort)
docker = dockerUtils.createClient(host: deviceIp, port: dockerPort)
container = docker.getContainer(name)
container.inspectAsync()

View File

@ -60,10 +60,10 @@ module.exports =
Promise = require('bluebird')
_ = require('lodash')
prettyjson = require('prettyjson')
Docker = require('docker-toolbelt')
{ discover } = require('resin-sync')
{ SpinnerPromise } = require('resin-cli-visuals')
{ dockerPort, dockerTimeout } = require('./common')
dockerUtils = require('../../utils/docker')
if options.timeout?
options.timeout *= 1000
@ -75,7 +75,7 @@ module.exports =
stopMessage: 'Reporting scan results'
.filter ({ address }) ->
Promise.try ->
docker = new Docker(host: address, port: dockerPort, timeout: dockerTimeout)
docker = dockerUtils.createClient(host: address, port: dockerPort, timeout: dockerTimeout)
docker.pingAsync()
.return(true)
.catchReturn(false)
@ -83,7 +83,7 @@ module.exports =
if _.isEmpty(devices)
throw new Error('Could not find any resinOS devices in the local network')
.map ({ host, address }) ->
docker = new Docker(host: address, port: dockerPort, timeout: dockerTimeout)
docker = dockerUtils.createClient(host: address, port: dockerPort, timeout: dockerTimeout)
Promise.props
dockerInfo: docker.infoAsync().catchReturn('Could not get Docker info')
dockerVersion: docker.versionAsync().catchReturn('Could not get Docker version')

View File

@ -20,21 +20,21 @@ LATEST = 'latest'
getApplicationsWithSuccessfulBuilds = (deviceType) ->
preload = require('resin-preload')
resin = require('resin-sdk-preconfigured')
resin = require('resin-sdk').fromSharedOptions()
resin.pine.get
resource: 'my_application'
options:
filter:
device_type: deviceType
build:
owns__release:
$any:
$alias: 'b'
$alias: 'r'
$expr:
b:
r:
status: 'success'
expand: preload.applicationExpandOptions
select: [ 'id', 'app_name', 'device_type', 'commit' ]
select: [ 'id', 'app_name', 'device_type', 'commit', 'should_track_latest_release' ]
orderby: 'app_name asc'
selectApplication = (deviceType) ->
@ -42,14 +42,14 @@ selectApplication = (deviceType) ->
form = require('resin-cli-form')
{ expectedError } = require('../utils/patterns')
applicationInfoSpinner = new visuals.Spinner('Downloading list of applications and builds.')
applicationInfoSpinner = new visuals.Spinner('Downloading list of applications and releases.')
applicationInfoSpinner.start()
getApplicationsWithSuccessfulBuilds(deviceType)
.then (applications) ->
applicationInfoSpinner.stop()
if applications.length == 0
expectedError("You have no apps with successful builds for a '#{deviceType}' device type.")
expectedError("You have no apps with successful releases for a '#{deviceType}' device type.")
form.ask
message: 'Select an application'
type: 'list'
@ -57,25 +57,25 @@ selectApplication = (deviceType) ->
name: app.app_name
value: app
selectApplicationCommit = (builds) ->
selectApplicationCommit = (releases) ->
form = require('resin-cli-form')
{ expectedError } = require('../utils/patterns')
if builds.length == 0
expectedError('This application has no successful builds.')
if releases.length == 0
expectedError('This application has no successful releases.')
DEFAULT_CHOICE = { 'name': LATEST, 'value': LATEST }
choices = [ DEFAULT_CHOICE ].concat builds.map (build) ->
name: "#{build.push_timestamp} - #{build.commit_hash}"
value: build.commit_hash
choices = [ DEFAULT_CHOICE ].concat releases.map (release) ->
name: "#{release.end_timestamp} - #{release.commit}"
value: release.commit
return form.ask
message: 'Select a build'
message: 'Select a release'
type: 'list'
default: LATEST
choices: choices
offerToDisableAutomaticUpdates = (application, commit) ->
Promise = require('bluebird')
resin = require('resin-sdk-preconfigured')
resin = require('resin-sdk').fromSharedOptions()
form = require('resin-cli-form')
if commit == LATEST or not application.should_track_latest_release
@ -84,7 +84,7 @@ offerToDisableAutomaticUpdates = (application, commit) ->
This application is set to automatically update all devices to the latest available version.
This might be unexpected behaviour: with this enabled, the preloaded device will still
download and install the latest build once it is online.
download and install the latest release once it is online.
Do you want to disable automatic updates for this application?
'''
@ -109,8 +109,7 @@ module.exports =
please check the README here: https://github.com/resin-io/resin-cli .
Use this command to preload an application to a local disk image (or
Edison zip archive) with a built commit from Resin.io.
This can be used with cloud builds, or images deployed with resin deploy.
Edison zip archive) with a built release from Resin.io.
Examples:
$ resin preload resin.img --app 1234 --commit e1f2592fc6ee949e68756d4f4a48e49bff8d72a0 --splash-image some-image.png
@ -129,7 +128,7 @@ module.exports =
signature: 'commit'
parameter: 'hash'
description: '''
a specific application commit to preload, use "latest" to specify the latest commit
the commit hash for a specific application release to preload, use "latest" to specify the latest release
(ignored if no appId is given)
'''
alias: 'c'
@ -149,9 +148,8 @@ module.exports =
action: (params, options, done) ->
_ = require('lodash')
Promise = require('bluebird')
resin = require('resin-sdk-preconfigured')
resin = require('resin-sdk').fromSharedOptions()
preload = require('resin-preload')
errors = require('resin-errors')
visuals = require('resin-cli-visuals')
nodeCleanup = require('node-cleanup')
{ expectedError } = require('../utils/patterns')
@ -183,7 +181,9 @@ module.exports =
options.splashImage = options['splash-image']
delete options['splash-image']
if options['dont-check-device-type'] and not options.appId
options.dontCheckDeviceType = options['dont-check-device-type']
delete options['dont-check-device-type']
if options.dontCheckDeviceType and not options.appId
expectedError('You need to specify an app id if you disable the device type check.')
# Get a configured dockerode instance
@ -191,13 +191,14 @@ module.exports =
.then (docker) ->
preloader = new preload.Preloader(
resin,
docker,
options.appId,
options.commit,
options.image,
options.splashImage,
options.proxy,
resin
docker
options.appId
options.commit
options.image
options.splashImage
options.proxy
options.dontCheckDeviceType
)
gotSignal = false
@ -221,51 +222,39 @@ module.exports =
return new Promise (resolve, reject) ->
preloader.on('error', reject)
preloader.build()
preloader.prepare()
.then ->
preloader.prepare()
.then ->
preloader.getDeviceTypeAndPreloadedBuilds()
.then (info) ->
# If no appId was provided, show a list of matching apps
Promise.try ->
if options.appId
return preloader.fetchApplication()
.catch(errors.ResinApplicationNotFound, expectedError)
selectApplication(info.device_type)
.then (application) ->
preloader.setApplication(application)
# Check that the app device type and the image device type match
if not options['dont-check-device-type'] and info.device_type != application.device_type
expectedError(
"Image device type (#{info.device_type}) and application device type (#{application.device_type}) do not match"
)
if not preloader.appId
selectApplication(preloader.config.deviceType)
.then (application) ->
preloader.setApplication(application)
.then ->
# Use the commit given as --commit or show an interactive commit selection menu
Promise.try ->
if options.commit
if options.commit == LATEST and preloader.application.commit
# handle `--commit latest`
return LATEST
release = _.find preloader.application.owns__release, (release) ->
release.commit.startsWith(options.commit)
if not release
expectedError('There is no release matching this commit')
return release.commit
selectApplicationCommit(preloader.application.owns__release)
.then (commit) ->
if commit == LATEST
preloader.commit = preloader.application.commit
else
preloader.commit = commit
# Use the commit given as --commit or show an interactive commit selection menu
Promise.try ->
if options.commit
if options.commit == LATEST and application.commit
# handle `--commit latest`
return LATEST
else if not _.find(application.build, commit_hash: options.commit)
expectedError('There is no build matching this commit')
return options.commit
selectApplicationCommit(application.build)
.then (commit) ->
if commit == LATEST
preloader.commit = application.commit
else
preloader.commit = commit
# Propose to disable automatic app updates if the commit is not the latest
offerToDisableAutomaticUpdates(application, commit)
.then ->
builds = info.preloaded_builds.map (build) ->
build.slice(-preload.BUILD_HASH_LENGTH)
if preloader.commit in builds
throw new preload.errors.ResinError('This build is already preloaded in this image.')
# All options are ready: preload the image.
preloader.preload()
.catch(preload.errors.ResinError, expectedError)
# Propose to disable automatic app updates if the commit is not the latest
offerToDisableAutomaticUpdates(preloader.application, commit)
.then ->
# All options are ready: preload the image.
preloader.preload()
.catch(resin.errors.ResinError, expectedError)
.then(resolve)
.catch(reject)
.then(done)

View File

@ -74,7 +74,6 @@ resin = require('resin-sdk-preconfigured')
actions = require('./actions')
errors = require('./errors')
events = require('./events')
plugins = require('./utils/plugins')
update = require('./utils/update')
# Assign bluebird as the global promise library
@ -209,15 +208,12 @@ capitano.command(actions.deploy)
update.notify()
plugins.register(/^resin-plugin-(.+)$/).then ->
cli = capitano.parse(process.argv)
runCommand = ->
if cli.global?.help
capitanoExecuteAsync(command: "help #{cli.command ? ''}")
else
capitanoExecuteAsync(cli)
Promise.all([events.trackCommand(cli), runCommand()])
cli = capitano.parse(process.argv)
runCommand = ->
if cli.global?.help
capitanoExecuteAsync(command: "help #{cli.command ? ''}")
else
capitanoExecuteAsync(cli)
Promise.all([events.trackCommand(cli), runCommand()])
.catch(errors.handle)

View File

@ -19,7 +19,7 @@ limitations under the License.
###
open = require('opn')
resin = require('resin-sdk-preconfigured')
resin = require('resin-sdk').fromSharedOptions()
server = require('./server')
utils = require('./utils')

747
lib/utils/compose.coffee Normal file
View File

@ -0,0 +1,747 @@
Promise = require('bluebird')
path = require('path')
exports.appendProjectOptions = appendProjectOptions = (opts) ->
opts.concat [
{
signature: 'projectName'
parameter: 'projectName'
description: 'Specify an alternate project name; default is the directory name'
alias: 'n'
},
]
exports.appendOptions = (opts) ->
appendProjectOptions(opts).concat [
{
signature: 'emulated'
description: 'Run an emulated build using Qemu'
boolean: true
alias: 'e'
},
{
signature: 'logs'
description: 'Display full log output'
boolean: true
},
]
exports.generateOpts = (options) ->
fs = require('mz/fs')
fs.realpath(options.source || '.').then (projectPath) ->
projectName: options.projectName
projectPath: projectPath
inlineLogs: !!options.logs
compositionFileNames = [
'resin-compose.yml'
'resin-compose.yaml'
'docker-compose.yml'
'docker-compose.yaml'
]
# look into the given directory for valid compose files and return
# the contents of the first one found.
resolveProject = (rootDir) ->
fs = require('mz/fs')
Promise.any compositionFileNames.map (filename) ->
fs.readFile(path.join(rootDir, filename), 'utf-8')
# Parse the given composition and return a structure with info. Input is:
# - composePath: the *absolute* path to the directory containing the compose file
# - composeStr: the contents of the compose file, as a string
createProject = (composePath, composeStr, projectName = null) ->
yml = require('js-yaml')
compose = require('resin-compose-parse')
# both methods below may throw.
composition = yml.safeLoad(composeStr, schema: yml.FAILSAFE_SCHEMA)
composition = compose.normalize(composition)
projectName ?= path.basename(composePath)
descriptors = compose.parse(composition).map (descr) ->
# generate an image name based on the project and service names
# if one is not given and the service requires a build
if descr.image.context? and not descr.image.tag?
descr.image.tag = [ projectName, descr.serviceName ].join('_')
return descr
return {
path: composePath,
name: projectName,
composition,
descriptors
}
# high-level function resolving a project and creating a composition out
# of it in one go. if image is given, it'll create a default project for
# that without looking for a project. falls back to creating a default
# project if none is found at the given projectPath.
exports.loadProject = (logger, projectPath, projectName, image) ->
compose = require('resin-compose-parse')
logger.logDebug('Loading project...')
Promise.try ->
if image?
logger.logInfo("Creating default composition with image: #{image}")
return compose.defaultComposition(image)
logger.logDebug('Resolving project...')
resolveProject(projectPath)
.tap ->
logger.logInfo('Compose file detected')
.catch (e) ->
logger.logDebug("Failed to resolve project: #{e}")
logger.logInfo("Creating default composition with source: #{projectPath}")
return compose.defaultComposition()
.then (composeStr) ->
logger.logDebug('Creating project...')
createProject(projectPath, composeStr, projectName)
toPosixPath = (systemPath) ->
path = require('path')
systemPath.replace(new RegExp('\\' + path.sep, 'g'), '/')
tarDirectory = (dir) ->
tar = require('tar-stream')
klaw = require('klaw')
path = require('path')
fs = require('mz/fs')
streamToPromise = require('stream-to-promise')
getFiles = ->
streamToPromise(klaw(dir))
.filter((item) -> not item.stats.isDirectory())
.map((item) -> item.path)
pack = tar.pack()
getFiles(dir)
.map (file) ->
relPath = path.relative(path.resolve(dir), file)
Promise.join relPath, fs.stat(file), fs.readFile(file),
(filename, stats, data) ->
pack.entry({ name: toPosixPath(filename), size: stats.size, mode: stats.mode }, data)
.then ->
pack.finalize()
return pack
truncateString = (str, len) ->
return str if str.length < len
str = str.slice(0, len)
# return everything up to the last line. this is a cheeky way to avoid
# having to deal with splitting the string midway through some special
# character sequence.
return str.slice(0, str.lastIndexOf('\n'))
LOG_LENGTH_MAX = 512 * 1024 # 512KB
exports.buildProject = (
docker, logger,
projectPath, projectName, composition,
arch, deviceType,
emulated, buildOpts,
inlineLogs
) ->
_ = require('lodash')
humanize = require('humanize')
compose = require('resin-compose-parse')
builder = require('resin-multibuild')
transpose = require('docker-qemu-transpose')
qemu = require('./qemu')
logger.logInfo("Building for #{arch}/#{deviceType}")
imageDescriptors = compose.parse(composition)
imageDescriptorsByServiceName = _.keyBy(imageDescriptors, 'serviceName')
if inlineLogs
renderer = new BuildProgressInline(logger.streams['build'], imageDescriptors)
else
tty = require('./tty')(process.stdout)
renderer = new BuildProgressUI(tty, imageDescriptors)
renderer.start()
qemu.installQemuIfNeeded(emulated, logger)
.tap (needsQemu) ->
return if not needsQemu
logger.logInfo('Emulation is enabled')
# Copy qemu into all build contexts
Promise.map imageDescriptors, (d) ->
return if not d.image.context? # external image
return qemu.copyQemu(path.join(projectPath, d.image.context))
.then (needsQemu) ->
# Tar up the directory, ready for the build stream
tarDirectory(projectPath)
.then (tarStream) ->
builder.splitBuildStream(composition, tarStream)
.tap (tasks) ->
# Updates each task as a side-effect
builder.performResolution(tasks, arch, deviceType)
.map (task) ->
if not task.external and not task.resolved
throw new Error(
"Project type for service '#{task.serviceName}' could not be determined. " +
'Please add a Dockerfile'
)
.map (task) ->
d = imageDescriptorsByServiceName[task.serviceName]
# multibuild parses the composition internally so any tags we've
# set before are lost; re-assign them here
task.tag ?= [ projectName, task.serviceName ].join('_')
if d.image.context?
d.image.tag = task.tag
# configure build opts appropriately
task.dockerOpts ?= {}
_.merge(task.dockerOpts, buildOpts, { t: task.tag })
if d.image.context?.args?
task.dockerOpts.buildargs ?= {}
_.merge(task.dockerOpts.buildargs, d.image.context.args)
# Get the service-specific log stream
# Caveat: `multibuild.BuildTask` defines no `logStream` property
# but it's convenient to store it there; it's JS ultimately.
task.logStream = renderer.streams[task.serviceName]
task.logBuffer = []
# Setup emulation if needed
return [ task, null ] if task.external or not needsQemu
binPath = qemu.qemuPathInContext(path.join(projectPath, task.context))
transpose.transposeTarStream task.buildStream,
hostQemuPath: toPosixPath(binPath)
containerQemuPath: "/tmp/#{qemu.QEMU_BIN_NAME}"
.then (stream) ->
task.buildStream = stream
.return([ task, binPath ])
.map ([ task, qemuPath ]) ->
Promise.resolve(task).tap (task) ->
captureStream = buildLogCapture(task.external, task.logBuffer)
if task.external
# External image -- there's no build to be performed,
# just follow pull progress.
captureStream.pipe(task.logStream)
task.progressHook = pullProgressAdapter(captureStream)
else
task.streamHook = (stream) ->
if qemuPath?
buildThroughStream = transpose.getBuildThroughStream
hostQemuPath: toPosixPath(qemuPath)
containerQemuPath: "/tmp/#{qemu.QEMU_BIN_NAME}"
rawStream = stream.pipe(buildThroughStream)
else
rawStream = stream
# `stream` sends out raw strings in contrast to `task.progressHook`
# where we're given objects. capture these strings as they come
# before we parse them.
rawStream
.pipe(captureStream)
.pipe(buildProgressAdapter(inlineLogs))
.pipe(task.logStream)
.then (tasks) ->
logger.logDebug 'Prepared tasks; building...'
builder.performBuilds(tasks, docker)
.map (builtImage) ->
if not builtImage.successful
builtImage.error.serviceName = builtImage.serviceName
throw builtImage.error
d = imageDescriptorsByServiceName[builtImage.serviceName]
task = _.find(tasks, serviceName: builtImage.serviceName)
image =
serviceName: d.serviceName
name: d.image.tag ? d.image
logs: truncateString(task.logBuffer.join('\n'), LOG_LENGTH_MAX)
props:
dockerfile: builtImage.dockerfile
projectType: builtImage.projectType
# Times here are timestamps, so test whether they're null
# before creating a date out of them, as `new Date(null)`
# creates a date representing UNIX time 0.
if (startTime = builtImage.startTime)
image.props.startTime = new Date(startTime)
if (endTime = builtImage.endTime)
image.props.endTime = new Date(endTime)
docker.getImage(image.name).inspect().get('Size').then (size) ->
image.props.size = size
.return(image)
.tap (images) ->
summary = _(images).map ({ serviceName, props }) ->
[ serviceName, "Image size: #{humanize.filesize(props.size)}" ]
.fromPairs()
.value()
renderer.end(summary)
.finally(renderer.end)
createRelease = (apiEndpoint, auth, userId, appId, composition) ->
_ = require('lodash')
crypto = require('crypto')
releaseMod = require('resin-release')
client = releaseMod.createClient({ apiEndpoint, auth })
releaseMod.create
client: client
user: userId
application: appId
composition: composition
source: 'local'
commit: crypto.pseudoRandomBytes(16).toString('hex').toLowerCase()
.then ({ release, serviceImages }) ->
release = _.omit(release, [
'created_at'
'belongs_to__application'
'is_created_by__user'
'__metadata'
])
_.keys serviceImages, (serviceName) ->
serviceImages[serviceName] = _.omit(serviceImages[serviceName], [
'created_at'
'is_a_build_of__service'
'__metadata'
])
return { client, release, serviceImages }
tagServiceImages = (docker, images, serviceImages) ->
Promise.map images, (d) ->
serviceImage = serviceImages[d.serviceName]
imageName = serviceImage.is_stored_at__image_location
[ _match, registry, repo, tag = 'latest' ] = /(.*?)\/(.*?)(?::([^/]*))?$/.exec(imageName)
name = "#{registry}/#{repo}"
docker.getImage(d.name).tag({ repo: name, tag, force: true })
.then ->
docker.getImage("#{name}:#{tag}")
.then (localImage) ->
serviceName: d.serviceName
serviceImage: serviceImage
localImage: localImage
registry: registry
repo: repo
logs: d.logs
props: d.props
authorizePush = (tokenAuthEndpoint, registry, images) ->
_ = require('lodash')
sdk = require('resin-sdk').fromSharedOptions()
if not _.isArray(images)
images = [ images ]
sdk.request.send
baseUrl: tokenAuthEndpoint
url: '/auth/v1/token'
qs:
service: registry
scope: images.map (repo) ->
"repository:#{repo}:pull,push"
.get('body')
.get('token')
.catchReturn({})
pushAndUpdateServiceImages = (docker, token, images, afterEach) ->
chalk = require('chalk')
{ DockerProgress } = require('docker-progress')
tty = require('./tty')(process.stdout)
opts = { authconfig: registrytoken: token }
progress = new DockerProgress(dockerToolbelt: docker)
renderer = pushProgressRenderer(tty, chalk.blue('[Push]') + ' ')
reporters = progress.aggregateProgress(images.length, renderer)
Promise.using tty.cursorHidden(), ->
Promise.map images, ({ serviceImage, localImage, props, logs }, index) ->
Promise.join(
localImage.inspect().get('Size')
progress.push(localImage.name, reporters[index], opts).finally(renderer.end)
(size, digest) ->
serviceImage.image_size = size
serviceImage.content_hash = digest
serviceImage.build_log = logs
serviceImage.dockerfile = props.dockerfile
serviceImage.project_type = props.projectType
serviceImage.start_timestamp = props.startTime if props.startTime
serviceImage.end_timestamp = props.endTime if props.endTime
serviceImage.push_timestamp = new Date()
serviceImage.status = 'success'
)
.tapCatch (e) ->
serviceImage.error_message = '' + e
serviceImage.status = 'failed'
.finally ->
afterEach?(serviceImage, props)
exports.deployProject = (
docker, logger,
composition, images,
appId, userId, auth,
apiEndpoint,
skipLogUpload
) ->
_ = require('lodash')
chalk = require('chalk')
releaseMod = require('resin-release')
tty = require('./tty')(process.stdout)
prefix = chalk.cyan('[Info]') + ' '
spinner = createSpinner()
runloop = runSpinner(tty, spinner, "#{prefix}Creating release...")
createRelease(apiEndpoint, auth, userId, appId, composition)
.finally(runloop.end)
.then ({ client, release, serviceImages }) ->
logger.logDebug('Tagging images...')
tagServiceImages(docker, images, serviceImages)
.tap (images) ->
logger.logDebug('Authorizing push...')
authorizePush(apiEndpoint, images[0].registry, _.map(images, 'repo'))
.then (token) ->
logger.logInfo('Pushing images to registry...')
pushAndUpdateServiceImages docker, token, images, (serviceImage) ->
logger.logDebug("Saving image #{serviceImage.is_stored_at__image_location}")
if skipLogUpload
delete serviceImage.build_log
releaseMod.updateImage(client, serviceImage.id, serviceImage)
.finally ->
logger.logDebug('Untagging images...')
Promise.map images, ({ localImage }) ->
localImage.remove()
.then ->
release.status = 'success'
.tapCatch (e) ->
release.status = 'failed'
.finally ->
runloop = runSpinner(tty, spinner, "#{prefix}Saving release...")
release.end_timestamp = new Date()
releaseMod.updateRelease(client, release.id, release)
.finally(runloop.end)
.return(release)
# utilities
renderProgressBar = (percentage, stepCount) ->
_ = require('lodash')
percentage = _.clamp(percentage, 0, 100)
barCount = stepCount * percentage // 100
spaceCount = stepCount - barCount
bar = "[#{_.repeat('=', barCount)}>#{_.repeat(' ', spaceCount)}]"
return "#{bar} #{_.padStart(percentage, 3)}%"
pushProgressRenderer = (tty, prefix) ->
fn = (e) ->
{ error, percentage } = e
throw new Error(error) if error?
bar = renderProgressBar(percentage, 40)
tty.replaceLine("#{prefix}#{bar}\r")
fn.end = ->
tty.clearLine()
return fn
buildLogCapture = (objectMode, buffer) ->
_ = require('lodash')
through = require('through2')
through { objectMode }, (data, enc, cb) ->
return cb(null, data) if not data?
# data from pull stream
if data.error
buffer.push("#{data.error}")
else if data.progress and data.status
buffer.push("#{data.progress}% #{data.status}")
else if data.status
buffer.push("#{data.status}")
# data from build stream
else
# normalise build log output here. it is somewhat ugly
# that this supposedly "passthrough" stream mutates the
# values before forwarding them, but it's convenient
# as it allows to both forward and save normalised logs
# convert to string, split to lines, trim each one and
# filter out empty ones.
lines = _(data.toString('utf-8').split(/\r?\n$/))
.map(_.trimEnd)
.reject(_.isEmpty)
# forward each line separately
lines.forEach (line) =>
buffer.push(line)
@push(line)
return cb()
cb(null, data)
buildProgressAdapter = (inline) ->
through = require('through2')
stepRegex = /^\s*Step\s+(\d+)\/(\d+)\s*:\s+(.+)$/
[ step, numSteps, progress ] = [ null, null, undefined ]
through { objectMode: true }, (str, enc, cb) ->
return cb(null, str) if not str?
if inline
return cb(null, { status: str })
if /^Successfully tagged /.test(str)
progress = undefined
else
if (match = stepRegex.exec(str))
step = match[1]
numSteps = match[2]
str = match[3]
if step?
str = "Step #{step}/#{numSteps}: #{str}"
progress = parseInt(step, 10) * 100 // parseInt(numSteps, 10)
cb(null, { status: str, progress })
pullProgressAdapter = (outStream) ->
return ({ status, id, percentage, error, errorDetail }) ->
if status?
status = status.replace(/^Status: /, '')
if id?
status = "#{id}: #{status}"
if percentage is 100
percentage = undefined
outStream.write
status: status
progress: percentage
error: errorDetail?.message ? error
createSpinner = ->
chars = '|/-\\'
index = 0
-> chars[(index++) % chars.length]
runSpinner = (tty, spinner, msg) ->
runloop = createRunLoop ->
tty.clearLine()
tty.writeLine("#{msg} #{spinner()}")
tty.cursorUp()
runloop.onEnd = ->
tty.clearLine()
tty.writeLine(msg)
return runloop
createRunLoop = (tick) ->
timerId = setInterval(tick, 1000 / 10)
runloop = {
onEnd: ->
end: ->
clearInterval(timerId)
runloop.onEnd()
}
return runloop
class BuildProgressUI
constructor: (tty, descriptors) ->
_ = require('lodash')
chalk = require('chalk')
through = require('through2')
eventHandler = @_handleEvent
services = _.map(descriptors, 'serviceName')
streams = _(services).map (service) ->
stream = through.obj (event, _enc, cb) ->
eventHandler(service, event)
cb()
stream.pipe(tty.stream)
[ service, stream ]
.fromPairs()
.value()
@_tty = tty
@_serviceToDataMap = {}
@_services = services
# Logger magically prefixes the log line with [Build] etc., but it doesn't
# work well with the spinner we're also showing. Manually build the prefix
# here and bypass the logger.
prefix = chalk.blue('[Build]') + ' '
offset = 10 # account for escape sequences inserted for colouring
@_prefixWidth = offset + prefix.length + _.max(_.map(services, 'length'))
@_prefix = prefix
# these are to handle window wrapping
@_maxLineWidth = null
@_lineWidths = []
@_startTime = null
@_ended = false
@_cancelled = false
@_spinner = createSpinner()
@streams = streams
_handleEvent: (service, event) =>
@_serviceToDataMap[service] = event
_handleInterrupt: =>
@_cancelled = true
@end()
process.exit(130) # 128 + SIGINT
start: =>
process.on('SIGINT', @_handleInterrupt)
@_tty.hideCursor()
@_services.forEach (service) =>
@streams[service].write({ status: 'Preparing...' })
@_runloop = createRunLoop(@_display)
@_startTime = Date.now()
end: (summary = null) =>
return if @_ended
@_ended = true
process.removeListener('SIGINT', @_handleInterrupt)
@_runloop.end()
@_runloop = null
@_clear()
@_renderStatus(true)
@_renderSummary(summary ? @_getServiceSummary())
@_tty.showCursor()
_display: =>
@_clear()
@_renderStatus()
@_renderSummary(@_getServiceSummary())
@_tty.cursorUp(@_services.length + 1) # for status line
_clear: ->
@_tty.deleteToEnd()
@_maxLineWidth = @_tty.currentWindowSize().width
_getServiceSummary: ->
_ = require('lodash')
services = @_services
serviceToDataMap = @_serviceToDataMap
_(services).map (service) ->
{ status, progress, error } = serviceToDataMap[service] ? {}
if error
return "#{error}"
else if progress
bar = renderProgressBar(progress, 20)
return "#{bar} #{status}" if status
return "#{bar}"
else if status
return "#{status}"
else
return 'Waiting...'
.map (data, index) ->
[ services[index], data ]
.fromPairs()
.value()
_renderStatus: (end = false) ->
moment = require('moment')
require('moment-duration-format')(moment)
@_tty.clearLine()
@_tty.write(@_prefix)
if end and @_cancelled
@_tty.writeLine('Build cancelled')
else if end
serviceCount = @_services.length
serviceStr = if serviceCount is 1 then '1 service' else "#{serviceCount} services"
runTime = Date.now() - @_startTime
durationStr = moment.duration(runTime // 1000, 'seconds').format()
@_tty.writeLine("Built #{serviceStr} in #{durationStr}")
else
@_tty.writeLine("Building services... #{@_spinner()}")
_renderSummary: (serviceToStrMap) ->
_ = require('lodash')
chalk = require('chalk')
truncate = require('cli-truncate')
strlen = require('string-width')
@_services.forEach (service, index) =>
str = _.padEnd(@_prefix + chalk.bold(service), @_prefixWidth)
str += serviceToStrMap[service]
if @_maxLineWidth?
str = truncate(str, @_maxLineWidth)
@_lineWidths[index] = strlen(str)
@_tty.clearLine()
@_tty.writeLine(str)
class BuildProgressInline
constructor: (outStream, descriptors) ->
_ = require('lodash')
through = require('through2')
services = _.map(descriptors, 'serviceName')
eventHandler = @_renderEvent
streams = _(services).map (service) ->
stream = through.obj (event, _enc, cb) ->
eventHandler(service, event)
cb()
stream.pipe(outStream)
[ service, stream ]
.fromPairs()
.value()
offset = 10 # account for escape sequences inserted for colouring
@_prefixWidth = offset + _.max(_.map(services, 'length'))
@_outStream = outStream
@_services = services
@_startTime = null
@_ended = false
@streams = streams
start: =>
@_outStream.write('Building services...\n')
@_services.forEach (service) =>
@streams[service].write({ status: 'Preparing...' })
@_startTime = Date.now()
end: (summary = null) =>
moment = require('moment')
require('moment-duration-format')(moment)
return if @_ended
@_ended = true
if summary?
@_services.forEach (service) =>
@_renderEvent(service, summary[service])
if @_cancelled
@_outStream.write('Build cancelled\n')
else
serviceCount = @_services.length
serviceStr = if serviceCount is 1 then '1 service' else "#{serviceCount} services"
runTime = Date.now() - @_startTime
durationStr = moment.duration(runTime // 1000, 'seconds').format()
@_outStream.write("Built #{serviceStr} in #{durationStr}\n")
_renderEvent: (service, event) =>
_ = require('lodash')
chalk = require('chalk')
str = do ->
{ status, error } = event
if error
return "#{error}"
else if status
return "#{status}"
else
return 'Waiting...'
prefix = _.padEnd(chalk.bold(service), @_prefixWidth)
@_outStream.write(prefix)
@_outStream.write(str)
@_outStream.write('\n')

View File

@ -1,7 +1,6 @@
# Functions to help actions which rely on using docker
QEMU_VERSION = 'v2.5.50-resin-execve'
QEMU_BIN_NAME = 'qemu-execve'
Promise = require('bluebird')
# Use this function to seed an action's list of capitano options
# with the docker options. Using this interface means that
@ -71,12 +70,6 @@ exports.appendOptions = (opts) ->
description: "Don't use docker layer caching when building"
boolean: true
},
{
signature: 'emulated'
description: 'Run an emulated build using Qemu'
boolean: true
alias: 'e'
},
{
signature: 'squash'
description: 'Squash newly built layers into a single new layer'
@ -84,8 +77,7 @@ exports.appendOptions = (opts) ->
}
]
exports.generateConnectOpts = generateConnectOpts = (opts) ->
Promise = require('bluebird')
generateConnectOpts = (opts) ->
buildDockerodeOpts = require('dockerode-options')
fs = require('mz/fs')
_ = require('lodash')
@ -131,294 +123,56 @@ exports.generateConnectOpts = generateConnectOpts = (opts) ->
return connectOpts
exports.tarDirectory = tarDirectory = (dir) ->
Promise = require('bluebird')
tar = require('tar-stream')
klaw = require('klaw')
path = require('path')
fs = require('mz/fs')
streamToPromise = require('stream-to-promise')
getFiles = ->
streamToPromise(klaw(dir))
.filter((item) -> not item.stats.isDirectory())
.map((item) -> item.path)
pack = tar.pack()
getFiles(dir)
.map (file) ->
relPath = path.relative(path.resolve(dir), file)
Promise.join relPath, fs.stat(file), fs.readFile(file),
(filename, stats, data) ->
pack.entryAsync({ name: toPosixPath(filename), size: stats.size, mode: stats.mode }, data)
.then ->
pack.finalize()
return pack
cacheHighlightStream = ->
colors = require('colors/safe')
es = require('event-stream')
{ EOL } = require('os')
extractArrowMessage = (message) ->
arrowTest = /^\s*-+>\s*(.+)/i
if (match = arrowTest.exec(message))
match[1]
else
undefined
es.mapSync (data) ->
msg = extractArrowMessage(data)
if msg? and msg.toLowerCase() == 'using cache'
data = colors.bgGreen.black(msg)
return data + EOL
parseBuildArgs = (args, onError) ->
parseBuildArgs = (args) ->
_ = require('lodash')
if not _.isArray(args)
args = [ args ]
buildArgs = {}
args.forEach (str) ->
pair = /^([^\s]+?)=(.*)$/.exec(str)
args.forEach (arg) ->
pair = /^([^\s]+?)=(.*)$/.exec(arg)
if pair?
buildArgs[pair[1]] = pair[2]
buildArgs[pair[1]] = pair[2] ? ''
else
onError(str)
throw new Error("Could not parse build argument: '#{arg}'")
return buildArgs
# Pass in the command line parameters and options and also
# a function which will return the information about the bundle
exports.runBuild = (params, options, getBundleInfo, logger) ->
Promise = require('bluebird')
dockerBuild = require('resin-docker-build')
resolver = require('resin-bundle-resolve')
es = require('event-stream')
doodles = require('resin-doodles')
transpose = require('docker-qemu-transpose')
path = require('path')
# The default build context is the current directory
params.source ?= '.'
logs = ''
# Only used in emulated builds
qemuPath = ''
Promise.try ->
return if not (options.emulated and platformNeedsQemu())
hasQemu()
.then (present) ->
if !present
logger.logInfo('Installing qemu for ARM emulation...')
installQemu()
.then ->
# Copy the qemu binary into the build context
copyQemu(params.source)
.then (binPath) ->
qemuPath = path.relative(params.source, binPath)
.then ->
# Tar up the directory, ready for the build stream
tarDirectory(params.source)
.then (tarStream) ->
new Promise (resolve, reject) ->
hooks =
buildSuccess: (image) ->
# Show charlie. In the interest of cloud parity,
# use console.log, not the standard logging streams
doodle = doodles.getDoodle()
console.log()
console.log(doodle)
console.log()
resolve({ image, log: logs + '\n' + doodle + '\n' } )
buildFailure: reject
buildStream: (stream) ->
if options.emulated
logger.logInfo('Running emulated build')
getBundleInfo(options)
.then (info) ->
if !info?
logger.logWarn '''
Warning: No architecture/device type or application information provided.
Dockerfile/project pre-processing will not be performed.
'''
return tarStream
else
[arch, deviceType] = info
# Perform type resolution on the project
bundle = new resolver.Bundle(tarStream, deviceType, arch)
resolver.resolveBundle(bundle, resolver.getDefaultResolvers())
.then (resolved) ->
logger.logInfo("Building #{resolved.projectType} project")
return resolved.tarStream
.then (buildStream) ->
# if we need emulation
if options.emulated and platformNeedsQemu()
return transpose.transposeTarStream buildStream,
hostQemuPath: toPosixPath(qemuPath)
containerQemuPath: "/tmp/#{QEMU_BIN_NAME}"
else
return buildStream
.then (buildStream) ->
# Send the resolved tar stream to the docker daemon
buildStream.pipe(stream)
.catch(reject)
# And print the output
logThroughStream = es.through (data) ->
logs += data.toString()
this.emit('data', data)
if options.emulated and platformNeedsQemu()
buildThroughStream = transpose.getBuildThroughStream
hostQemuPath: toPosixPath(qemuPath)
containerQemuPath: "/tmp/#{QEMU_BIN_NAME}"
newStream = stream.pipe(buildThroughStream)
else
newStream = stream
newStream
.pipe(logThroughStream)
.pipe(cacheHighlightStream())
.pipe(logger.streams.build)
# Create a builder
generateConnectOpts(options)
.tap (connectOpts) ->
ensureDockerSeemsAccessible(connectOpts)
.then (connectOpts) ->
# Allow degugging output, hidden behind an env var
logger.logDebug('Connecting with the following options:')
logger.logDebug(JSON.stringify(connectOpts, null, ' '))
builder = new dockerBuild.Builder(connectOpts)
opts = {}
if options.tag?
opts['t'] = options.tag
if options.nocache?
opts['nocache'] = true
if options.buildArg?
opts['buildargs'] = parseBuildArgs options.buildArg, (arg) ->
logger.logWarn("Could not parse variable: '#{arg}'")
if options.squash?
opts['squash'] = true
builder.createBuildStream(opts, hooks, reject)
# Given an image id or tag, export the image to a tar archive,
# gzip the result, and buffer it to disk.
exports.bufferImage = (docker, imageId, bufferFile) ->
Promise = require('bluebird')
streamUtils = require('./streams')
image = docker.getImage(imageId)
imageMetadata = image.inspectAsync()
Promise.join image.get(), imageMetadata.get('Size'), (imageStream, imageSize) ->
streamUtils.buffer(imageStream, bufferFile)
.tap (bufferedStream) ->
bufferedStream.length = imageSize
exports.generateBuildOpts = (options) ->
opts = {}
if options.tag?
opts.t = options.tag
if options.nocache?
opts.nocache = true
if options.squash?
opts.squash = true
if options.buildArg?
opts.buildargs = parseBuildArgs(options.buildArg)
return opts
exports.getDocker = (options) ->
Docker = require('dockerode')
Promise = require('bluebird')
generateConnectOpts(options)
.tap (connectOpts) ->
ensureDockerSeemsAccessible(connectOpts)
.then (connectOpts) ->
# Use bluebird's promises
connectOpts['Promise'] = Promise
new Docker(connectOpts)
.then(createClient)
.tap(ensureDockerSeemsAccessible)
ensureDockerSeemsAccessible = (options) ->
fs = require('mz/fs')
exports.createClient = createClient = do ->
# docker-toolbelt v3 is not backwards compatible as it removes all *Async
# methods that are in wide use in the CLI. The workaround for now is to
# manually promisify the client and replace all `new Docker()` calls with
# this shared function that returns a promisified client.
#
# **New code must not use the *Async methods.**
#
Docker = require('docker-toolbelt')
Promise.promisifyAll Docker.prototype, {
filter: (name) -> name == 'run'
multiArgs: true
}
Promise.promisifyAll(Docker.prototype)
Promise.promisifyAll(new Docker({}).getImage().constructor.prototype)
Promise.promisifyAll(new Docker({}).getContainer().constructor.prototype)
if options.socketPath?
# If we're trying to use a socket, check it exists and we have access to it
fs.access(options.socketPath, (fs.constants || fs).R_OK | (fs.constants || fs).W_OK)
.return(true)
.catch (err) ->
throw new Error(
"Docker seems to be unavailable (using socket #{options.socketPath}). Is it
installed, and do you have permission to talk to it?"
)
else
# Otherwise, we think we're probably ok
Promise.resolve(true)
return (opts) ->
return new Docker(opts)
hasQemu = ->
fs = require('mz/fs')
getQemuPath()
.then(fs.stat)
.return(true)
.catchReturn(false)
getQemuPath = ->
resin = require('resin-sdk-preconfigured')
path = require('path')
fs = require('mz/fs')
resin.settings.get('binDirectory')
.then (binDir) ->
# The directory might not be created already,
# if not, create it
fs.access(binDir)
.catch code: 'ENOENT', ->
fs.mkdir(binDir)
.then ->
path.join(binDir, QEMU_BIN_NAME)
platformNeedsQemu = ->
os = require('os')
os.platform() == 'linux'
installQemu = ->
request = require('request')
fs = require('fs')
zlib = require('zlib')
getQemuPath()
.then (qemuPath) ->
new Promise (resolve, reject) ->
installStream = fs.createWriteStream(qemuPath)
qemuUrl = "https://github.com/resin-io/qemu/releases/download/#{QEMU_VERSION}/#{QEMU_BIN_NAME}.gz"
request(qemuUrl)
.pipe(zlib.createGunzip())
.pipe(installStream)
.on('error', reject)
.on('finish', resolve)
copyQemu = (context) ->
path = require('path')
fs = require('mz/fs')
# Create a hidden directory in the build context, containing qemu
binDir = path.join(context, '.resin')
binPath = path.join(binDir, QEMU_BIN_NAME)
fs.access(binDir)
.catch code: 'ENOENT', ->
fs.mkdir(binDir)
.then ->
getQemuPath()
.then (qemu) ->
new Promise (resolve, reject) ->
read = fs.createReadStream(qemu)
write = fs.createWriteStream(binPath)
read
.pipe(write)
.on('error', reject)
.on('finish', resolve)
.then ->
fs.chmod(binPath, '755')
.return(binPath)
toPosixPath = (systemPath) ->
path = require('path')
systemPath.replace(new RegExp('\\' + path.sep, 'g'), '/')
ensureDockerSeemsAccessible = (docker) ->
docker.ping().catch ->
throw new Error('Docker seems to be unavailable. Is it installed and running?')

View File

@ -133,7 +133,7 @@ export function getArchAndDeviceType(
);
}
function getApplication(applicationName: string) {
export function getApplication(applicationName: string) {
// Check for an app of the form `user/application`, and send
// that off to a special handler (before importing any modules)
const match = /(\w+)\/(\w+)/.exec(applicationName);

View File

@ -18,13 +18,11 @@ import _ = require('lodash');
import Promise = require('bluebird');
import form = require('resin-cli-form');
import visuals = require('resin-cli-visuals');
import ResinSdk = require('resin-sdk');
import resin = require('resin-sdk-preconfigured');
import chalk from 'chalk';
import validation = require('./validation');
import messages = require('./messages');
const resin = ResinSdk.fromSharedOptions();
export function authenticate(options: {}): Promise<void> {
return form
.run(
@ -132,10 +130,8 @@ export function confirm(
});
}
export function selectApplication(
filter: (app: ResinSdk.Application) => boolean,
) {
resin.models.application
export function selectApplication(filter: (app: resin.Application) => boolean) {
return resin.models.application
.hasAny()
.then(function(hasAnyApplications) {
if (!hasAnyApplications) {
@ -165,7 +161,7 @@ export function selectOrCreateApplication() {
return resin.models.application.getAll().then(applications => {
const appOptions = _.map<
ResinSdk.Application,
resin.Application,
{ name: string; value: string | null }
>(applications, application => ({
name: `${application.app_name} (${application.device_type})`,
@ -227,7 +223,7 @@ export function awaitDevice(uuid: string) {
export function inferOrSelectDevice(preferredUuid: string) {
return resin.models.device
.getAll()
.filter<ResinSdk.Device>(device => device.is_online)
.filter<resin.Device>(device => device.is_online)
.then(onlineDevices => {
if (_.isEmpty(onlineDevices)) {
throw new Error("You don't have any devices online");

View File

@ -1,36 +0,0 @@
/*
Copyright 2016-2017 Resin.io
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 nplugm = require('nplugm');
import _ = require('lodash');
import capitano = require('capitano');
import patterns = require('./patterns');
export function register(regex: RegExp): Promise<void> {
return nplugm
.list(regex)
.map(async function(plugin: any) {
const command = await import(plugin);
command.plugin = true;
if (!_.isArray(command)) {
return capitano.command(command);
}
return _.each(command, capitano.command);
})
.catch((error: Error) => {
return patterns.printErrorMessage(error.message);
});
}

86
lib/utils/qemu.coffee Normal file
View File

@ -0,0 +1,86 @@
Promise = require('bluebird')
exports.QEMU_VERSION = QEMU_VERSION = 'v2.5.50-resin-execve'
exports.QEMU_BIN_NAME = QEMU_BIN_NAME = 'qemu-execve'
exports.installQemuIfNeeded = Promise.method (emulated, logger) ->
return false if not (emulated and platformNeedsQemu())
hasQemu()
.then (present) ->
if !present
logger.logInfo('Installing qemu for ARM emulation...')
installQemu()
.return(true)
exports.qemuPathInContext = (context) ->
path = require('path')
binDir = path.join(context, '.resin')
binPath = path.join(binDir, QEMU_BIN_NAME)
path.relative(context, binPath)
exports.copyQemu = (context) ->
path = require('path')
fs = require('mz/fs')
# Create a hidden directory in the build context, containing qemu
binDir = path.join(context, '.resin')
binPath = path.join(binDir, QEMU_BIN_NAME)
Promise.resolve(fs.mkdir(binDir))
.catch(code: 'EEXIST', ->)
.then ->
getQemuPath()
.then (qemu) ->
new Promise (resolve, reject) ->
read = fs.createReadStream(qemu)
write = fs.createWriteStream(binPath)
read
.pipe(write)
.on('error', reject)
.on('finish', resolve)
.then ->
fs.chmod(binPath, '755')
.then ->
path.relative(context, binPath)
hasQemu = ->
fs = require('mz/fs')
getQemuPath()
.then(fs.stat)
.return(true)
.catchReturn(false)
getQemuPath = ->
resin = require('resin-sdk').fromSharedOptions()
path = require('path')
fs = require('mz/fs')
resin.settings.get('binDirectory')
.then (binDir) ->
Promise.resolve(fs.mkdir(binDir))
.catch(code: 'EEXIST', ->)
.then ->
path.join(binDir, QEMU_BIN_NAME)
platformNeedsQemu = ->
os = require('os')
os.platform() == 'linux'
installQemu = ->
request = require('request')
fs = require('fs')
zlib = require('zlib')
getQemuPath()
.then (qemuPath) ->
new Promise (resolve, reject) ->
installStream = fs.createWriteStream(qemuPath)
qemuUrl = "https://github.com/resin-io/qemu/releases/download/#{QEMU_VERSION}/#{QEMU_BIN_NAME}.gz"
request(qemuUrl)
.on('error', reject)
.pipe(zlib.createGunzip())
.on('error', reject)
.pipe(installStream)
.on('error', reject)
.on('finish', resolve)

65
lib/utils/tty.coffee Normal file
View File

@ -0,0 +1,65 @@
windowSize = {}
updateWindowSize = ->
size = require('window-size').get()
windowSize.width = size.width
windowSize.height = size.height
process.stdout.on('resize', updateWindowSize)
module.exports = (stream = process.stdout) ->
# make sure we get initial metrics
updateWindowSize()
currentWindowSize = ->
# always return a copy
width: windowSize.width
height: windowSize.height
hideCursor = ->
stream.write('\u001B[?25l')
showCursor = ->
stream.write('\u001B[?25h')
cursorUp = (rows = 0) ->
stream.write("\u001B[#{rows}A")
cursorDown = (rows = 0) ->
stream.write("\u001B[#{rows}B")
cursorHidden = ->
Promise = require('bluebird')
Promise.try(hideCursor).disposer(showCursor)
write = (str) ->
stream.write(str)
writeLine = (str) ->
stream.write("#{str}\n")
clearLine = ->
stream.write('\u001B[2K\r')
replaceLine = (str) ->
clearLine()
write(str)
deleteToEnd = ->
stream.write('\u001b[0J')
return {
stream
currentWindowSize
hideCursor
showCursor
cursorHidden
cursorUp
cursorDown
write
writeLine
clearLine
replaceLine
deleteToEnd
}

View File

@ -1,6 +1,6 @@
{
"name": "resin-cli",
"version": "6.13.5",
"version": "7.0.0",
"description": "The official resin.io CLI tool",
"main": "./build/actions/index.js",
"homepage": "https://github.com/resin-io/resin-cli",
@ -91,11 +91,13 @@
"body-parser": "^1.14.1",
"capitano": "^1.7.0",
"chalk": "^2.3.0",
"cli-truncate": "^1.1.0",
"coffeescript": "^1.12.6",
"columnify": "^1.5.2",
"denymount": "^2.2.0",
"docker-progress": "^3.0.1",
"docker-qemu-transpose": "^0.3.4",
"docker-toolbelt": "^1.3.3",
"docker-toolbelt": "^3.1.0",
"dockerode": "^2.5.0",
"dockerode-options": "^0.2.1",
"drivelist": "^5.0.22",
@ -104,17 +106,18 @@
"express": "^4.13.3",
"global-tunnel-ng": "^2.1.1",
"hasbin": "^1.2.3",
"humanize": "0.0.9",
"inquirer": "^3.1.1",
"is-root": "^1.0.0",
"js-yaml": "^3.7.0",
"js-yaml": "^3.10.0",
"klaw": "^1.3.1",
"lodash": "^4.17.4",
"mixpanel": "^0.4.0",
"mkdirp": "^0.5.1",
"moment": "^2.12.0",
"moment": "^2.20.1",
"moment-duration-format": "^2.2.1",
"mz": "^2.6.0",
"node-cleanup": "^2.1.2",
"nplugm": "^3.0.0",
"opn": "^5.1.0",
"president": "^2.0.1",
"prettyjson": "^1.1.3",
@ -122,19 +125,23 @@
"raven": "^1.2.0",
"reconfix": "^0.0.3",
"request": "^2.81.0",
"resin-bundle-resolve": "^0.0.2",
"resin-bundle-resolve": "^0.5.3",
"resin-cli-auth": "^1.2.0",
"resin-cli-errors": "^1.2.0",
"resin-cli-form": "^1.4.1",
"resin-cli-visuals": "^1.4.0",
"resin-compose-parse": "^1.5.2",
"resin-config-json": "^1.0.0",
"resin-device-config": "^4.0.0",
"resin-device-init": "^4.0.0",
"resin-docker-build": "^0.4.0",
"resin-docker-build": "^0.6.2",
"resin-doodles": "0.0.1",
"resin-image-fs": "^2.3.0",
"resin-image-manager": "^5.0.0",
"resin-preload": "^5.0.0",
"resin-sdk": "^7.0.0",
"resin-multibuild": "^0.5.1",
"resin-preload": "^6.0.0",
"resin-release": "^1.1.1",
"resin-sdk": "^9.0.0-beta7",
"resin-sdk-preconfigured": "^6.9.0",
"resin-settings-client": "^3.6.1",
"resin-stream-logger": "^0.1.0",
@ -143,10 +150,14 @@
"rindle": "^1.0.0",
"semver": "^5.3.0",
"stream-to-promise": "^2.2.0",
"string-width": "^2.1.1",
"through2": "^2.0.3",
"tmp": "0.0.31",
"ts-node": "^3.3.0",
"umount": "^1.1.6",
"unzip2": "^0.2.5",
"update-notifier": "^2.2.0"
"update-notifier": "^2.2.0",
"window-size": "^1.1.0"
},
"optionalDependencies": {
"removedrive": "^1.0.0"

View File

@ -1,5 +1,801 @@
declare module 'resin-sdk-preconfigured' {
import { ResinSDK } from 'resin-sdk';
let sdk: ResinSDK;
export = sdk;
import * as Promise from 'bluebird';
import { EventEmitter } from 'events';
import * as ResinErrors from 'resin-errors';
import { Readable } from 'stream';
/* tslint:disable:no-namespace */
namespace Pine {
// based on https://github.com/resin-io/pinejs-client-js/blob/master/core.d.ts
type RawFilter =
| string
| Array<string | Filter<any>>
| { $string: string; [index: string]: Filter<any> | string };
type Lambda<T> = {
$alias: string;
$expr: Filter<T>;
};
type OrderByValues = 'asc' | 'desc';
type OrderBy = string | string[] | { [index: string]: OrderByValues };
type ResourceObjFilter<T> = { [k in keyof T]?: object | number | string };
interface FilterArray<T> extends Array<Filter<T>> {}
type FilterExpressions<T> = {
$raw?: RawFilter;
$?: string | string[];
$and?: Filter<T> | FilterArray<T>;
$or?: Filter<T> | FilterArray<T>;
$in?: Filter<T> | FilterArray<T>;
$not?: Filter<T> | FilterArray<T>;
$any?: Lambda<T>;
$all?: Lambda<T>;
};
type Filter<T> = ResourceObjFilter<T> & FilterExpressions<T>;
type BaseExpandFor<T> = { [k in keyof T]?: object } | keyof T;
export type Expand<T> = BaseExpandFor<T> | Array<BaseExpandFor<T>>;
}
namespace ResinRequest {
interface ResinRequestOptions {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
url: string;
apiKey?: string;
body?: any;
}
interface ResinRequestResponse extends Response {
body: any;
}
interface ResinRequest {
send: (options: ResinRequestOptions) => Promise<ResinRequestResponse>;
}
}
namespace ResinSdk {
interface Interceptor {
request?(response: any): Promise<any>;
response?(response: any): Promise<any>;
requestError?(error: Error): Promise<any>;
responseError?(error: Error): Promise<any>;
}
interface Config {
deployment: string | null;
deviceUrlsBase: string;
adminUrl: string;
apiUrl: string;
actionsUrl: string;
gitServerUrl: string;
pubnub: {
subscribe_key: string;
publish_key: string;
};
ga?: GaConfig;
mixpanelToken?: string;
intercomAppId?: string;
recurlyPublicKey?: string;
deviceTypes: DeviceType[];
DEVICE_ONLINE_ICON: string;
DEVICE_OFFLINE_ICON: string;
signupCodeRequired: boolean;
supportedSocialProviders: string[];
}
interface GaConfig {
site: string;
id: string;
}
interface DeviceType {
slug: string;
name: string;
arch: string;
state?: string;
isDependent?: boolean;
instructions?: string[] | DeviceTypeInstructions;
gettingStartedLink?: string | DeviceTypeGettingStartedLink;
stateInstructions?: { [key: string]: string[] };
options?: DeviceTypeOptions[];
initialization?: {
options?: DeviceInitializationOptions[];
operations: Array<{
command: string;
}>;
};
supportsBlink?: boolean;
yocto: {
fstype?: string;
deployArtifact: string;
};
}
interface DeviceTypeInstructions {
linux: string[];
osx: string[];
windows: string[];
}
interface DeviceTypeGettingStartedLink {
linux: string;
osx: string;
windows: string;
[key: string]: string;
}
interface DeviceTypeOptions {
options: DeviceTypeOptionsGroup[];
collapsed: boolean;
isCollapsible: boolean;
isGroup: boolean;
message: string;
name: string;
}
interface DeviceInitializationOptions {
message: string;
type: string;
name: string;
}
interface DeviceTypeOptionsGroup {
default: number | string;
message: string;
name: string;
type: string;
min?: number;
choices?: string[] | number[];
choicesLabels?: { [key: string]: string };
}
interface WithId {
id: number;
}
interface PineParams {
resource: string;
id?: number;
body?: object;
options?: PineOptions;
}
interface PineOptions {
filter?: object;
expand?: object | string;
orderBy?: Pine.OrderBy;
top?: string;
skip?: string;
select?: string | string[];
}
interface PineParamsFor<T> extends PineParams {
body?: Partial<T>;
options?: PineOptionsFor<T>;
}
interface PineParamsWithIdFor<T> extends PineParamsFor<T> {
id: number;
}
type PineFilterFor<T> = Pine.Filter<T>;
type PineExpandFor<T> = Pine.Expand<T>;
interface PineOptionsFor<T> extends PineOptions {
filter?: PineFilterFor<T>;
expand?: PineExpandFor<T>;
select?: Array<keyof T> | keyof T;
}
interface PineDeferred {
__id: number;
}
/**
* When not selected-out holds a deferred.
* When expanded hold an array with a single element.
*/
type NavigationResource<T = WithId> = T[] | PineDeferred;
/**
* When expanded holds an array, otherwise the property is not present.
* Selecting is not suggested,
* in that case it holds a deferred to the original resource.
*/
type ReverseNavigationResource<T = WithId> = T[] | undefined;
interface SocialServiceAccount {
provider: string;
display_name: string;
created_at: string;
id: number;
remote_id: string;
}
interface User {
id: number;
username: string;
email?: string;
first_name?: string;
last_name?: string;
company?: string;
account_type?: string;
has_disabled_newsletter?: boolean;
jwt_secret: string;
created_at: string;
twoFactorRequired?: boolean;
hasPasswordSet?: boolean;
needsPasswordReset?: boolean;
public_key?: boolean;
features?: string[];
intercomUserName?: string;
intercomUserHash?: string;
permissions?: string[];
loginAs?: boolean;
actualUser?: number;
// this is what the api route returns
social_service_account: ReverseNavigationResource<SocialServiceAccount>;
}
interface Application {
app_name: string;
device_type: string;
git_repository: string;
commit: string;
id: number;
device_type_info?: any;
has_dependent?: boolean;
should_track_latest_release: boolean;
user: NavigationResource<User>;
application_tag: ReverseNavigationResource<ApplicationTag>;
}
type BuildStatus =
| 'cancelled'
| 'error'
| 'interrupted'
| 'local'
| 'running'
| 'success'
| 'timeout'
| null;
interface Build {
log: string;
commit_hash: string;
created_at: string;
end_timestamp: string;
id: number;
message: string | null;
project_type: string;
push_timestamp: string | null;
start_timestamp: string;
status: BuildStatus;
update_timestamp: string | null;
}
interface BillingAccountAddressInfo {
address1: string;
address2: string;
city: string;
state: string;
zip: string;
country: string;
phone: string;
}
interface BillingAccountInfo {
account_state: string;
first_name: string;
last_name: string;
company_name: string;
cc_emails: string;
vat_number: string;
address: BillingAccountAddressInfo;
}
type BillingInfoType = 'bank_account' | 'credit_card' | 'paypal';
interface BillingInfo {
full_name: string;
first_name: string;
last_name: string;
company: string;
vat_number: string;
address1: string;
address2: string;
city: string;
state: string;
zip: string;
country: string;
phone: string;
type?: BillingInfoType;
}
interface CardBillingInfo extends BillingInfo {
card_type: string;
year: string;
month: string;
first_one: string;
last_four: string;
}
interface BankAccountBillingInfo extends BillingInfo {
account_type: string;
last_four: string;
name_on_account: string;
routing_number: string;
}
interface TokenBillingSubmitInfo {
token_id: string;
}
interface BillingPlanInfo {
name: string;
billing?: BillingPlanBillingInfo;
}
interface BillingPlanBillingInfo {
currency: string;
currencySymbol?: string;
}
interface InvoiceInfo {
closed_at: string;
created_at: string;
currency: string;
invoice_number: string;
subtotal_in_cents: string;
total_in_cents: string;
uuid: string;
}
interface Device {
created_at: string;
device_type: string;
id: number;
name: string;
os_version: string;
os_variant?: string;
status_sort_index?: number;
uuid: string;
ip_address: string | null;
vpn_address: string | null;
last_connectivity_event: string;
is_in_local_mode?: boolean;
app_name?: string;
state?: { key: string; name: string };
status: string;
provisioning_state: string;
is_online: boolean;
is_connected_to_vpn: boolean;
supervisor_version: string;
is_web_accessible: boolean;
has_dependent: boolean;
note: string;
location: string;
latitude?: string;
longitude?: string;
custom_latitude?: string;
custom_longitude?: string;
download_progress?: number;
provisioning_progress?: number;
local_id?: string;
device_environment_variable: ReverseNavigationResource<
DeviceEnvironmentVariable
>;
device_tag: ReverseNavigationResource<DeviceTag>;
}
interface LogMessage {
message: string;
isSystem: boolean;
timestamp: number | null;
serviceId: number | null;
}
interface LogsSubscription extends EventEmitter {
unsubscribe(): void;
}
interface SSHKey {
title: string;
public_key: string;
id: number;
created_at: string;
}
type ImgConfigOptions = {
network?: 'ethernet' | 'wifi';
appUpdatePollInterval?: number;
wifiKey?: string;
wifiSsid?: string;
ip?: string;
gateway?: string;
netmask?: string;
version?: string;
};
type OsVersions = {
latest: string;
recommended: string;
default: string;
versions: string[];
};
interface EnvironmentVariableBase {
id: number;
name: string;
value: string;
}
interface EnvironmentVariable extends EnvironmentVariableBase {
application: NavigationResource<Application>;
}
interface DeviceEnvironmentVariable extends EnvironmentVariableBase {
env_var_name?: string;
device: NavigationResource<Device>;
}
interface ResourceTagBase {
id: number;
tag_key: string;
value: string;
}
interface ApplicationTag extends ResourceTagBase {
application: NavigationResource<Application>;
}
interface DeviceTag extends ResourceTagBase {
device: NavigationResource<Device>;
}
type LogsPromise = Promise<LogMessage[]>;
interface ResinSDK {
auth: {
register: (
credentials: { email: string; password: string },
) => Promise<string>;
authenticate: (
credentials: { email: string; password: string },
) => Promise<string>;
login: (
credentials: { email: string; password: string },
) => Promise<void>;
loginWithToken: (authToken: string) => Promise<void>;
logout: () => Promise<void>;
getToken: () => Promise<string>;
whoami: () => Promise<string | undefined>;
isLoggedIn: () => Promise<boolean>;
getUserId: () => Promise<number>;
getEmail: () => Promise<string>;
twoFactor: {
isEnabled: () => Promise<boolean>;
isPassed: () => Promise<boolean>;
challenge: (code: string) => Promise<void>;
};
};
settings: {
get(key: string): Promise<string>;
getAll(): Promise<{ [key: string]: string }>;
};
request: ResinRequest.ResinRequest;
errors: {
ResinAmbiguousApplication: ResinErrors.ResinAmbiguousApplication;
ResinAmbiguousDevice: ResinErrors.ResinAmbiguousDevice;
ResinApplicationNotFound: ResinErrors.ResinApplicationNotFound;
ResinBuildNotFound: ResinErrors.ResinBuildNotFound;
ResinDeviceNotFound: ResinErrors.ResinDeviceNotFound;
ResinExpiredToken: ResinErrors.ResinExpiredToken;
ResinInvalidDeviceType: ResinErrors.ResinInvalidDeviceType;
ResinInvalidParameterError: ResinErrors.ResinInvalidParameterError;
ResinKeyNotFound: ResinErrors.ResinKeyNotFound;
ResinMalformedToken: ResinErrors.ResinMalformedToken;
ResinNotLoggedIn: ResinErrors.ResinNotLoggedIn;
ResinRequestError: ResinErrors.ResinRequestError;
ResinSupervisorLockedError: ResinErrors.ResinSupervisorLockedError;
};
models: {
application: {
create(
name: string,
deviceType: string,
parentNameOrId?: number | string,
): Promise<Application>;
get(
nameOrId: string | number,
options?: PineOptionsFor<Application>,
): Promise<Application>;
getAppByOwner(
appName: string,
owner: string,
options?: PineOptionsFor<Application>,
): Promise<Application>;
getAll(options?: PineOptionsFor<Application>): Promise<Application[]>;
has(name: string): Promise<boolean>;
hasAny(): Promise<boolean>;
remove(nameOrId: string | number): Promise<void>;
restart(nameOrId: string | number): Promise<void>;
enableDeviceUrls(nameOrId: string | number): Promise<void>;
disableDeviceUrls(nameOrId: string | number): Promise<void>;
grantSupportAccess(
nameOrId: string | number,
expiryTimestamp: number,
): Promise<void>;
revokeSupportAccess(nameOrId: string | number): Promise<void>;
reboot(appId: number, { force }: { force?: boolean }): Promise<void>;
shutdown(
appId: number,
{ force }: { force?: boolean },
): Promise<void>;
purge(appId: number): Promise<void>;
generateApiKey(nameOrId: string | number): Promise<string>;
generateProvisioningKey(nameOrId: string | number): Promise<string>;
tags: {
getAllByApplication(
nameOrId: string | number,
options?: PineOptionsFor<ApplicationTag>,
): Promise<ApplicationTag[]>;
getAll(
options?: PineOptionsFor<ApplicationTag>,
): Promise<ApplicationTag[]>;
set(
nameOrId: string | number,
tagKey: string,
value: string,
): Promise<void>;
remove(nameOrId: string | number, tagKey: string): Promise<void>;
};
};
build: {
get(id: number, options?: PineOptionsFor<Build>): Promise<Build>;
getAllByApplication(
nameOrId: string | number,
options?: PineOptionsFor<Build>,
): Promise<Build[]>;
};
billing: {
getAccount(): Promise<BillingAccountInfo>;
getPlan(): Promise<BillingPlanInfo>;
getBillingInfo(): Promise<BillingInfo>;
updateBillingInfo(
billingInfo: TokenBillingSubmitInfo,
): Promise<BillingInfo>;
getInvoices(): Promise<InvoiceInfo[]>;
downloadInvoice(invoiceNumber: string): Promise<Blob>;
};
device: {
get(
uuidOrId: string | number,
options?: PineOptionsFor<Device>,
): Promise<Device>;
getByName(
nameOrId: string | number,
options?: PineOptionsFor<Device>,
): Promise<Device[]>;
getAll(options?: PineOptionsFor<Device>): Promise<Device[]>;
getAllByApplication(
nameOrId: string | number,
options?: PineOptionsFor<Device>,
): Promise<Device[]>;
getAllByParentDevice(
parentUuidOrId: string | number,
options?: PineOptionsFor<Device>,
): Promise<Device[]>;
getName(uuidOrId: string | number): Promise<string>;
getApplicationName(uuidOrId: string | number): Promise<string>;
getApplicationInfo(
uuidOrId: string | number,
): Promise<{
appId: string;
commit: string;
containerId: string;
env: { [key: string]: string | number };
imageId: string;
}>;
has(uuidOrId: string | number): Promise<boolean>;
isOnline(uuidOrId: string | number): Promise<boolean>;
getLocalIPAddressess(uuidOrId: string | number): Promise<string[]>;
getDashboardUrl(uuid: string): string;
getSupportedDeviceTypes(): Promise<string[]>;
getManifestBySlug(slugOrName: string): Promise<DeviceType>;
getManifestByApplication(
nameOrId: string | number,
): Promise<DeviceType>;
move(
uuidOrId: string | number,
applicationNameOrId: string | number,
): Promise<void>;
note(uuidOrId: string | number, note: string): Promise<void>;
remove(uuidOrId: string | number): Promise<void>;
rename(uuidOrId: string | number, newName: string): Promise<void>;
setCustomLocation(
uuidOrId: string | number,
location: { latitude: number; longitude: number },
): Promise<void>;
unsetCustomLocation(uuidOrId: string | number): Promise<void>;
identify(uuidOrId: string | number): Promise<void>;
startApplication(uuidOrId: string | number): Promise<void>;
stopApplication(uuidOrId: string | number): Promise<void>;
restartApplication(uuidOrId: string | number): Promise<void>;
grantSupportAccess(
uuidOrId: string | number,
expiryTimestamp: number,
): Promise<void>;
revokeSupportAccess(uuidOrId: string | number): Promise<void>;
reboot(
uuidOrId: string | number,
{ force }?: { force?: boolean },
): Promise<void>;
shutdown(
uuidOrId: string | number,
{ force }?: { force?: boolean },
): Promise<void>;
purge(uuidOrId: string | number): Promise<void>;
update(
uuidOrId: string | number,
{ force }?: { force?: boolean },
): Promise<void>;
getDisplayName(deviceTypeName: string): string;
getDeviceSlug(deviceTypeName: string): string;
generateUniqueKey(): string;
register(
applicationNameOrId: string | number,
uuid?: string,
): Promise<object>;
generateDeviceKey(uuidOrId: string | number): Promise<string>;
enableDeviceUrl(uuidOrId: string | number): Promise<void>;
disableDeviceUrl(uuidOrId: string | number): Promise<void>;
hasDeviceUrl(uuidOrId: string | number): Promise<boolean>;
getDeviceUrl(uuidOrId: string | number): Promise<string>;
enableTcpPing(uuidOrId: string | number): Promise<void>;
disableTcpPing(uuidOrId: string | number): Promise<void>;
ping(uuidOrId: string | number): Promise<void>;
getStatus(device: object): string;
lastOnline(device: Device): string;
tags: {
getAllByApplication(
nameOrId: string | number,
options?: PineOptionsFor<DeviceTag>,
): Promise<DeviceTag[]>;
getAllByDevice(
uuidOrId: string | number,
options?: PineOptionsFor<DeviceTag>,
): Promise<DeviceTag[]>;
getAll(options?: PineOptionsFor<DeviceTag>): Promise<DeviceTag[]>;
set(
uuidOrId: string | number,
tagKey: string,
value: string,
): Promise<void>;
remove(uuidOrId: string | number, tagKey: string): Promise<void>;
};
};
environmentVariables: {
device: {
getAll(id: number): Promise<DeviceEnvironmentVariable[]>;
getAllByApplication(
applicationNameOrId: number | string,
): Promise<DeviceEnvironmentVariable[]>;
update(id: number, value: string): Promise<void>;
create(
uuidOrId: number | string,
name: string,
value: string,
): Promise<void>;
remove(id: number): Promise<void>;
};
getAllByApplication(
applicationNameOrId: number | string,
): Promise<EnvironmentVariable[]>;
update(id: number, value: string): Promise<void>;
create(
applicationNameOrId: number | string,
name: string,
value: string,
): Promise<void>;
remove(id: number): Promise<void>;
isSystemVariable(variable: { name: string }): boolean;
};
config: {
getAll: () => Promise<Config>;
getDeviceTypes: () => Promise<DeviceType[]>;
getDeviceOptions(
deviceType: string,
): Promise<Array<DeviceTypeOptions | DeviceInitializationOptions>>;
};
key: {
getAll(options?: PineOptionsFor<SSHKey>): Promise<SSHKey[]>;
get(id: string | number): Promise<SSHKey>;
remove(id: string | number): Promise<void>;
create(title: string, key: string): Promise<SSHKey>;
};
os: {
getConfig(
nameOrId: string | number,
options?: ImgConfigOptions,
): Promise<object>;
getDownloadSize(slug: string, version?: string): Promise<number>;
getSupportedVersions(slug: string): Promise<OsVersions>;
getMaxSatisfyingVersion(
deviceType: string,
versionOrRange: string,
): string;
getLastModified(deviceType: string, version?: string): Promise<Date>;
download(deviceType: string, version?: string): Promise<Readable>;
};
};
logs: {
history(uuid: string): LogsPromise;
historySinceLastClear(uuid: string): LogsPromise;
subscribe(uuid: string): Promise<LogsSubscription>;
clear(uuid: string): void;
};
pine: {
delete<T>(
params: PineParamsWithIdFor<T> | PineParamsFor<T>,
): Promise<string>;
get<T>(params: PineParamsWithIdFor<T>): Promise<T>;
get<T>(params: PineParamsFor<T>): Promise<T[]>;
get<T, Result>(params: PineParamsFor<T>): Promise<Result>;
post<T>(params: PineParams): Promise<T>;
patch<T>(params: PineParamsWithIdFor<T>): Promise<T>;
};
interceptors: Interceptor[];
}
}
interface SdkOptions {
apiUrl?: string;
/**
* @deprecated Use resin.auth.loginWithToken(apiKey) instead
*/
apiKey?: string;
imageMakerUrl?: string;
dataDirectory?: string;
isBrowser?: boolean;
debug?: boolean;
}
interface SdkConstructor {
(options?: SdkOptions): ResinSdk.ResinSDK;
setSharedOptions(options: SdkOptions): void;
fromSharedOptions: () => ResinSdk.ResinSDK;
}
const ResinSdk: ResinSdk.ResinSDK;
export = ResinSdk;
}