Merge remote-tracking branch 'balena/master' into instantly-apply-update-on-api-request

This commit is contained in:
Michel Wohlert 2019-07-09 13:10:15 +02:00
commit 39c9810bbc
23 changed files with 888 additions and 455 deletions

View File

@ -7,4 +7,10 @@
"resin-lint --typescript --no-prettier",
"git add"
],
"test/**/*.coffee": [
"resin-lint --tests"
],
"test/**/*.ts": [
"resin-lint --typescript --no-prettier --tests"
],
}

View File

@ -1,3 +1,142 @@
- commits:
- subject: Use resin-lint for mocha tests check
hash: bea97f47a7c2b5748ca50632a7de93f7f18af5fd
body: ''
footer:
Change-type: patch
change-type: patch
Signed-off-by: Roman Mazur <roman@balena.io>
signed-off-by: Roman Mazur <roman@balena.io>
author: Roman Mazur
version: 9.18.5
date: 2019-07-08T16:23:26.845Z
- commits:
- subject: 'Fix network composition parsing, and correctly report compose config'
hash: 89807c21fa599d952b0fd5f61302f94539f3b3ea
body: ''
footer:
Change-type: patch
change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
signed-off-by: Cameron Diver <cameron@balena.io>
author: Cameron Diver
- subject: Keep the network and volume models consistent across usage
hash: e5d7379b74861c31b0cd2b6a94b4fdecf23cd35a
body: ''
footer:
Change-type: patch
change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
signed-off-by: Cameron Diver <cameron@balena.io>
author: Cameron Diver
- subject: Pass around instantiated Network objects when comparing state
hash: eaff3a2ee574a404c0ebf610867556f4afead176
body: ''
footer:
Change-type: patch
change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
signed-off-by: Cameron Diver <cameron@balena.io>
author: Cameron Diver
- subject: Seperate volume handling into manager and object classes
hash: 23e564389daaa29feb231d1b091e07df82a7a300
body: |
This change also makes sure that in the application-manager workflow we
pass around instances of the Volume class, rather than just the config.
footer:
Change-type: patch
change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
signed-off-by: Cameron Diver <cameron@balena.io>
author: Cameron Diver
- subject: >-
fix: When reporting initial config, avoid creating a "VPN enabled"
unnecessary override
hash: 743c8406c973af7ef85ff31ea8bb0bc99d4592fb
body: >
Since we were comparing the VPN's value before adding the explicit
"true", there were cases
were the VPN is off, and therefore "value" didn't match the default, so
the supervisor would
create a device specific SUPERVISOR_VPN_CONTROL = true, which is
unnecessary and causes issues if
users don't expect this and move the device to an app that has VPN
disabled. The correct behavior
is to compare "varValue" and only create a device config var if this
value differs from the default.
(This was the behavior before the TS conversion in
01ed7bb103b4df8fb0679cf858220db42d4a0b92 )
footer:
Change-type: patch
change-type: patch
Signed-off-by: Pablo Carranza Velez <pablo@balena.io>
signed-off-by: Pablo Carranza Velez <pablo@balena.io>
author: Pablo Carranza Velez
version: 9.18.4
date: 2019-07-04T10:49:36.786Z
- commits:
- subject: Add mocha tests linter
hash: 36fa23ab7dcd76e7eb207e59b2ebcca8560a015f
body: >
A simple script that detects presence of .only statements in tests.
The idea is to avoid comitting them, so that all the tests are run with
npm test.
footer:
Change-type: patch
change-type: patch
Signed-off-by: Roman Mazur <roman@balena.io>
signed-off-by: Roman Mazur <roman@balena.io>
author: Roman Mazur
version: 9.18.3
date: 2019-06-29T13:44:31.994Z
- commits:
- subject: Use jessie base images for i386-nlp
hash: 92aa9b40ab77316f052533cf7d0b12ec96000d0b
body: |
This fixes potential SIGILL errors with the supervisor on i386-nlp.
footer:
Change-type: patch
change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
signed-off-by: Cameron Diver <cameron@balena.io>
author: Cameron Diver
- subject: Ensure that all architectures use a node version that supports ci
hash: 557f88965a8f076cf7a1a02ad965264f1c5beab9
body: |
We do this by using the standalone installer pinned at v6.9.0. We use
the standalone installer because npm itself fails to upgrade on the
i386-nlp build (npm v3). The standalone installer is installed in /tmp,
and then to avoid mysterious CI errors, we use the original npm to run
the tests.
footer:
Change-type: patch
change-type: patch
Signed-off-by: Cameron Diver <cameron@balena.io>
signed-off-by: Cameron Diver <cameron@balena.io>
author: Cameron Diver
version: 9.18.2
date: 2019-06-27T16:13:21.666Z
- commits:
- subject: Fix tests run removing .only
hash: aad1129f2619f3de48d577f107fa17be7589e556
body: >
The issue was introduced with one of the recent changes related to local
mode cleanup.
footer:
Change-type: patch
change-type: patch
Signed-off-by: Roman Mazur <roman@balena.io>
signed-off-by: Roman Mazur <roman@balena.io>
author: Roman Mazur
version: 9.18.1
date: 2019-06-27T12:01:21.922Z
- commits:
- subject: Ensure we get input on parsing errors
hash: 645bc6c185b804df0e6d2088c5239eabcfa4ce80

View File

@ -4,6 +4,31 @@ 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/).
## 9.18.5 - 2019-07-08
* Use resin-lint for mocha tests check [Roman Mazur]
## 9.18.4 - 2019-07-04
* Fix network composition parsing, and correctly report compose config [Cameron Diver]
* Keep the network and volume models consistent across usage [Cameron Diver]
* Pass around instantiated Network objects when comparing state [Cameron Diver]
* Seperate volume handling into manager and object classes [Cameron Diver]
* Fix: When reporting initial config, avoid creating a "VPN enabled" unnecessary override [Pablo Carranza Velez]
## 9.18.3 - 2019-06-29
* Add mocha tests linter [Roman Mazur]
## 9.18.2 - 2019-06-27
* Use jessie base images for i386-nlp [Cameron Diver]
* Ensure that all architectures use a node version that supports ci [Cameron Diver]
## 9.18.1 - 2019-06-27
* Fix tests run removing .only [Roman Mazur]
## 9.18.0 - 2019-06-26
* Ensure we get input on parsing errors [Roman Mazur]

View File

@ -1,4 +1,5 @@
ARG ARCH=amd64
ARG NPM_VERSION=6.9.0
# The node version here should match the version of the runtime image which is
# specified in the base-image subdirectory in the project
@ -17,7 +18,7 @@ FROM balenalib/i386-node:10-run as i386-node-base
RUN echo '#!/bin/sh\nexit 0' > /usr/bin/cross-build-start && chmod +x /usr/bin/cross-build-start \
&& echo '#!/bin/sh\nexit 0' > /usr/bin/cross-build-end && chmod +x /usr/bin/cross-build-end
FROM balenalib/i386-nlp-node:6-run as i386-nlp-node-base
FROM balenalib/i386-nlp-node:6-jessie as i386-nlp-node-base
RUN echo '#!/bin/sh\nexit 0' > /usr/bin/cross-build-start && chmod +x /usr/bin/cross-build-start \
&& echo '#!/bin/sh\nexit 0' > /usr/bin/cross-build-end && chmod +x /usr/bin/cross-build-end
@ -33,6 +34,8 @@ FROM balenalib/amd64-node:6-build as i386-nlp-node-build
FROM $ARCH-node-build as node-build
ARG NPM_VERSION
WORKDIR /usr/src/app
RUN apt-get update \
@ -43,12 +46,20 @@ RUN apt-get update \
make \
python \
rsync \
wget \
curl \
&& rm -rf /var/lib/apt/lists/
COPY package.json package-lock.json /usr/src/app/
RUN JOBS=MAX npm ci --no-optional --unsafe-perm || JOBS=MAX npm install --no-optional --unsafe-perm
# We first ensure that every architecture has an npm version
# which can do an npm ci, then we perform the ci using this
# temporary version
RUN curl -LOJ https://www.npmjs.com/install.sh && \
# This is required to avoid a bug in uid-number
# https://github.com/npm/uid-number/issues/7
npm config set unsafe-perm true && \
npm_install="#{NPM_VERSION}" npm_config_prefix=/tmp sh ./install.sh && \
JOBS=MAX /tmp/bin/npm ci --no-optional --unsafe-perm
COPY webpack.config.js fix-jsonstream.js hardcode-migrations.js tsconfig.json tsconfig.release.json /usr/src/app/
COPY src /usr/src/app/src
@ -63,6 +74,7 @@ RUN npm test \
# Build nodejs dependencies
FROM $ARCH-node-base as node-deps
ARG ARCH
ARG NPM_VERSION
RUN [ "cross-build-start" ]
@ -76,7 +88,7 @@ RUN apt-get update \
make \
python \
rsync \
wget \
curl \
&& rm -rf /var/lib/apt/lists/
RUN mkdir -p rootfs-overlay && \
@ -85,11 +97,10 @@ RUN mkdir -p rootfs-overlay && \
COPY package.json package-lock.json /usr/src/app/
# Install only the production modules that have C extensions
RUN (if [ $ARCH = "i386-nlp" ]; then \
JOBS=MAX npm install --no-optional --unsafe-perm --production; \
else \
JOBS=MAX npm ci --no-optional --unsafe-perm --production; \
fi) && npm dedupe
RUN curl -LOJ https://www.npmjs.com/install.sh && \
npm config set unsafe-perm true && \
npm_install="${NPM_VERSION}" npm_config_prefix=/tmp sh ./install.sh && \
JOBS=MAX /tmp/bin/npm ci --no-optional --unsafe-perm --production
# Remove various uneeded filetypes in order to reduce space
# We also remove the spurious node.dtps, see https://github.com/mapbox/node-sqlite3/issues/861

View File

@ -1,4 +1,5 @@
ARG ARCH=amd64
ARG NPM_VERSION=6.9.0
# The node version here should match the version of the runtime image which is
# specified in the base-image subdirectory in the project
@ -17,11 +18,9 @@ FROM balenalib/i386-node:10-run as i386-base
RUN echo '#!/bin/sh\nexit 0' > /usr/bin/cross-build-start && chmod +x /usr/bin/cross-build-start \
&& echo '#!/bin/sh\nexit 0' > /usr/bin/cross-build-end && chmod +x /usr/bin/cross-build-end
FROM resin/i386-node:6.13.1-slim as i386-nlp-base
FROM balenalib/i386-nlp-node:6-jessie as i386-nlp-base
RUN echo '#!/bin/sh\nexit 0' > /usr/bin/cross-build-start && chmod +x /usr/bin/cross-build-start \
&& echo '#!/bin/sh\nexit 0' > /usr/bin/cross-build-end && chmod +x /usr/bin/cross-build-end \
# TODO: Move this to a balenalib image so this isn't necessary
&& sed -i '/jessie-updates/{s/^/#/}' /etc/apt/sources.list
&& echo '#!/bin/sh\nexit 0' > /usr/bin/cross-build-end && chmod +x /usr/bin/cross-build-end
# A little hack to make this work with the makefile
FROM $ARCH-base AS node-build
@ -36,17 +35,21 @@ RUN [ "cross-build-start" ]
WORKDIR /usr/src/app
RUN apt-get update && apt-get install ca-certificates \
iptables libnss-mdns nodejs rsync git python make wget g++ \
iptables libnss-mdns nodejs rsync git python make curl g++ \
kmod vim
COPY package*.json ./
# i386-nlp doesn't have an npm version which supports ci
RUN if [ $ARCH = "i386-nlp" ]; then \
JOBS=MAX npm install --no-optional --unsafe-perm; \
else \
JOBS=MAX npm ci --no-optional --unsafe-perm; \
fi
# We first ensure that every architecture has an npm version
# which can do an npm ci, then we perform the ci using this
# temporary version
RUN curl -LOJ https://www.npmjs.com/install.sh && \
# This is required to avoid a bug in uid-number
# https://github.com/npm/uid-number/issues/7
npm config set unsafe-perm true && \
npm_install="${NPM_VERSION}" npm_config_prefix=/tmp sh ./install.sh && \
JOBS=MAX /tmp/bin/npm ci --no-optional --unsafe-perm
COPY src src/
COPY typings typings/

97
package-lock.json generated
View File

@ -1,6 +1,6 @@
{
"name": "balena-supervisor",
"version": "9.18.0",
"version": "9.18.5",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@ -1222,7 +1222,7 @@
"fetch-ponyfill": "^4.0.0",
"fetch-readablestream": "^0.2.0",
"lodash": "^4.6.1",
"node-web-streams": "github:resin-io-modules/node-web-streams#emit-errors",
"node-web-streams": "github:resin-io-modules/node-web-streams#46f98300b69090bde3f6b4983877ccfe283a892c",
"progress-stream": "^2.0.0",
"qs": "^6.3.0",
"rindle": "^1.3.1"
@ -1358,7 +1358,7 @@
"lodash": "^4.13.1",
"resin-cli-form": "^1.4.1",
"resin-cli-visuals": "^1.2.8",
"resin-discoverable-services": "git+https://github.com/resin-io-modules/resin-discoverable-services.git#find-on-all-interfaces",
"resin-discoverable-services": "git+https://github.com/resin-io-modules/resin-discoverable-services.git#afca9e4700ec5ef82aa897f14bd5a46f06518061",
"resin-semver": "^1.4.0",
"revalidator": "^0.3.1",
"rindle": "^1.3.0",
@ -1609,7 +1609,7 @@
"deep-equal": "^1.0.1",
"dns-equal": "^1.0.0",
"dns-txt": "^2.0.2",
"multicast-dns": "git+https://github.com/resin-io-modules/multicast-dns.git#listen-on-all-interfaces",
"multicast-dns": "git+https://github.com/resin-io-modules/multicast-dns.git#a15c63464eb43e8925b187ed5cb9de6892e8aacc",
"multicast-dns-service-types": "^1.1.0"
}
},
@ -1811,7 +1811,7 @@
},
"cacache": {
"version": "10.0.4",
"resolved": "https://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz",
"resolved": "http://registry.npmjs.org/cacache/-/cacache-10.0.4.tgz",
"integrity": "sha512-Dph0MzuH+rTQzGPNT9fAnrPmMmjKfST6trxJeK7NQuHRaVw24VzPRWTmg9MpcwOVQZO0E1FBICUlFeNaKPIfHA==",
"dev": true,
"requires": {
@ -2499,7 +2499,7 @@
},
"dbus-native": {
"version": "0.2.5",
"resolved": "https://registry.npmjs.org/dbus-native/-/dbus-native-0.2.5.tgz",
"resolved": "http://registry.npmjs.org/dbus-native/-/dbus-native-0.2.5.tgz",
"integrity": "sha512-ocxMKCV7QdiNhzhFSeEMhj258OGtvpANSb3oWGiotmI5h1ZIse0TMPcSLiXSpqvbYvQz2Y5RsYPMNYLWhg9eBw==",
"dev": true,
"requires": {
@ -2655,7 +2655,7 @@
"dependencies": {
"globby": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/globby/-/globby-6.1.0.tgz",
"resolved": "http://registry.npmjs.org/globby/-/globby-6.1.0.tgz",
"integrity": "sha1-9abXDoOV4hyFj7BInWTfAkJNUGw=",
"dev": true,
"requires": {
@ -3025,7 +3025,7 @@
},
"string_decoder": {
"version": "0.10.31",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
"integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=",
"dev": true
}
@ -4049,8 +4049,7 @@
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"core-util-is": {
"version": "1.0.2",
@ -4210,7 +4209,6 @@
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -4229,7 +4227,6 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -4323,7 +4320,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -4409,8 +4405,7 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"safer-buffer": {
"version": "2.1.2",
@ -4446,7 +4441,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -4510,14 +4504,12 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
}
}
},
@ -6638,9 +6630,9 @@
}
},
"livepush": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/livepush/-/livepush-2.0.1.tgz",
"integrity": "sha512-0UWr6T/AR4NpkcdStfOs1Ii3K2yBoX5Ipo25b56Xfuj/ytyNgByd+UUk2SB0uZEHj/QONwgbhmE64mE3oYFoOw==",
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/livepush/-/livepush-2.0.2.tgz",
"integrity": "sha512-esIy95BpYr5EUXtJhntu6lrRZEF7rhSebmbQnnSrouQAFIvIhk7USOEky2VOXs49ot9/KG5grxtWoiTyUlg8bg==",
"dev": true,
"requires": {
"bluebird": "^3.5.1",
@ -7454,7 +7446,7 @@
},
"next-tick": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
"resolved": "http://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz",
"integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=",
"dev": true
},
@ -7843,7 +7835,7 @@
},
"os-homedir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
"resolved": "http://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz",
"integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M="
},
"os-locale": {
@ -7893,7 +7885,7 @@
},
"os-tmpdir": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"resolved": "http://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz",
"integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ="
},
"osenv": {
@ -8048,7 +8040,7 @@
},
"path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"resolved": "http://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18="
},
"path-is-inside": {
@ -8678,7 +8670,7 @@
},
"require-npm4-to-publish": {
"version": "1.0.0",
"resolved": "http://registry.npmjs.org/require-npm4-to-publish/-/require-npm4-to-publish-1.0.0.tgz",
"resolved": "https://registry.npmjs.org/require-npm4-to-publish/-/require-npm4-to-publish-1.0.0.tgz",
"integrity": "sha1-5Z7D5ikQFT3Fu90MpA20IrLE2ec=",
"dev": true,
"requires": {
@ -8785,15 +8777,15 @@
"dev": true,
"requires": {
"bluebird": "^3.0.0",
"bonjour": "git+https://github.com/resin-io/bonjour.git#fixed-mdns",
"bonjour": "git+https://github.com/resin-io/bonjour.git#e018851dc823b4b3f670f658f71d0c1c7f3e637c",
"ip": "^1.1.4",
"lodash": "^4.17.4"
}
},
"resin-lint": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/resin-lint/-/resin-lint-3.0.4.tgz",
"integrity": "sha512-TVxY7SaJqQRZcLubJn5yO49db/M4eRXRr7FbA4xwqSYxQSqujNql8ThMoNMoRrx+1F7NrfSdhIsLEaMqCea4VA==",
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/resin-lint/-/resin-lint-3.1.0.tgz",
"integrity": "sha512-bipsVrhMBtoegrBdJf/6NMQke4g8xmZENSu0fBU1KvxLXNhGPQkmobY7vVmP47BeD0m0Zdv9yrEc43w2S+kRWA==",
"dev": true,
"requires": {
"@types/bluebird": "^3.5.26",
@ -9444,7 +9436,7 @@
},
"source-map": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz",
"resolved": "http://registry.npmjs.org/source-map/-/source-map-0.2.0.tgz",
"integrity": "sha1-2rc/vPwrqBm03gO9b26qSBZLP50=",
"dev": true,
"optional": true,
@ -9669,7 +9661,7 @@
},
"stream-browserify": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz",
"resolved": "http://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz",
"integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=",
"dev": true,
"requires": {
@ -9765,7 +9757,7 @@
},
"string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"resolved": "http://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"requires": {
"safe-buffer": "~5.1.0"
@ -10084,7 +10076,7 @@
},
"thunky": {
"version": "0.1.0",
"resolved": "http://registry.npmjs.org/thunky/-/thunky-0.1.0.tgz",
"resolved": "https://registry.npmjs.org/thunky/-/thunky-0.1.0.tgz",
"integrity": "sha1-vzAUaCTituZ7Dy16Ssi+smkIaE4=",
"dev": true
},
@ -10262,9 +10254,9 @@
}
},
"ts-node": {
"version": "8.2.0",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.2.0.tgz",
"integrity": "sha512-m8XQwUurkbYqXrKqr3WHCW310utRNvV5OnRVeISeea7LoCWVcdfeB/Ntl8JYWFh+WRoUAdBgESrzKochQt7sMw==",
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-8.3.0.tgz",
"integrity": "sha512-dyNS/RqyVTDcmNM4NIBAeDMpsAdaQ+ojdf0GOLqE6nwJOgzEkdRNzJywhDfwnuvB10oa6NLVG1rUJQCpRN7qoQ==",
"dev": true,
"requires": {
"arg": "^4.1.0",
@ -10797,8 +10789,7 @@
"ansi-regex": {
"version": "2.1.1",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"aproba": {
"version": "1.2.0",
@ -10841,8 +10832,7 @@
"code-point-at": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"concat-map": {
"version": "0.0.1",
@ -10853,8 +10843,7 @@
"console-control-strings": {
"version": "1.1.0",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"core-util-is": {
"version": "1.0.2",
@ -10971,8 +10960,7 @@
"inherits": {
"version": "2.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"ini": {
"version": "1.3.5",
@ -10984,7 +10972,6 @@
"version": "1.0.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"number-is-nan": "^1.0.0"
}
@ -11014,7 +11001,6 @@
"version": "2.3.5",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"safe-buffer": "^5.1.2",
"yallist": "^3.0.0"
@ -11033,7 +11019,6 @@
"version": "0.5.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"minimist": "0.0.8"
}
@ -11127,7 +11112,6 @@
"version": "1.4.0",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"wrappy": "1"
}
@ -11213,8 +11197,7 @@
"safe-buffer": {
"version": "5.1.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"safer-buffer": {
"version": "2.1.2",
@ -11250,7 +11233,6 @@
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"code-point-at": "^1.0.0",
"is-fullwidth-code-point": "^1.0.0",
@ -11270,7 +11252,6 @@
"version": "3.0.1",
"bundled": true,
"dev": true,
"optional": true,
"requires": {
"ansi-regex": "^2.0.0"
}
@ -11314,14 +11295,12 @@
"wrappy": {
"version": "1.0.2",
"bundled": true,
"dev": true,
"optional": true
"dev": true
},
"yallist": {
"version": "3.0.3",
"bundled": true,
"dev": true,
"optional": true
"dev": true
}
}
},

View File

@ -1,7 +1,7 @@
{
"name": "balena-supervisor",
"description": "This is balena's Supervisor, a program that runs on IoT devices and has the task of running user Apps (which are Docker containers), and updating them as the balena API informs it to.",
"version": "9.18.0",
"version": "9.18.5",
"license": "Apache-2.0",
"repository": {
"type": "git",
@ -15,9 +15,8 @@
"test": "npm run lint && npm run test:build && JUNIT_REPORT_PATH=report.xml istanbul cover _mocha && npm run coverage",
"test:build": "npm run typescript:test-build && npm run coffeescript:test && npm run testitems:copy && npm run migrations:copy-test && npm run packagejson:copy",
"coverage": "istanbul report text && istanbul report html",
"test:fast": "npm run test:build && mocha",
"test:fast": "mocha --opts test/fast-mocha.opts",
"test:debug": "npm run test:build && mocha --inspect-brk",
"precommit": "lint-staged",
"prettify": "prettier --config ./node_modules/resin-lint/config/.prettierrc --write \"{src,test,typings}/**/*.ts\"",
"typescript:test-build": "tsc --project tsconfig.json",
"typescript:release": "tsc --project tsconfig.release.json && cp -r build/src/* build && rm -rf build/src",
@ -86,7 +85,7 @@
"json-mask": "^0.3.8",
"knex": "~0.15.2",
"lint-staged": "^8.1.0",
"livepush": "^2.0.1",
"livepush": "^2.0.2",
"lockfile": "^1.0.1",
"lodash": "^4.17.5",
"log-timestamp": "^0.1.2",
@ -101,7 +100,7 @@
"prettier": "1.17.0",
"pretty-ms": "^4.0.0",
"request": "^2.51.0",
"resin-lint": "^3.0.0",
"resin-lint": "^3.1.0",
"resin-register-device": "^3.0.0",
"resumable-request": "^2.0.0",
"rimraf": "^2.6.2",
@ -113,10 +112,16 @@
"terser": "^3.14.1",
"tmp": "^0.1.0",
"ts-loader": "^5.3.0",
"ts-node": "^8.3.0",
"typed-error": "^2.0.0",
"typescript": "^3.5.1",
"webpack": "^4.25.0",
"webpack-cli": "^3.1.2",
"winston": "^3.2.1"
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
}
}

View File

@ -708,7 +708,7 @@ export class APIBinder {
varValue = 'true';
}
if (targetConfig[key] == null && value !== defaultConfig[key]) {
if (targetConfig[key] == null && varValue !== defaultConfig[key]) {
const envVar = {
value: varValue,
device: deviceId,

View File

@ -7,6 +7,7 @@ fs = Promise.promisifyAll(require('fs'))
path = require 'path'
constants = require './lib/constants'
{ log } = require './lib/supervisor-console'
{ DockerUtils: Docker } = require './lib/docker-utils'
{ LocalModeManager } = require './local-mode'
@ -20,7 +21,8 @@ updateLock = require './lib/update-lock'
{ Images } = require './compose/images'
{ NetworkManager } = require './compose/network-manager'
{ Network } = require './compose/network'
{ Volumes } = require './compose/volumes'
{ VolumeManager } = require './compose/volume-manager'
{ Volume } = require './compose/volume'
Proxyvisor = require './proxyvisor'
@ -68,7 +70,7 @@ module.exports = class ApplicationManager extends EventEmitter
@images = new Images({ @docker, @logger, @db, @config })
@services = new ServiceManager({ @docker, @logger, @images, @config })
@networks = new NetworkManager({ @docker, @logger })
@volumes = new Volumes({ @docker, @logger })
@volumes = new VolumeManager({ @docker, @logger })
@proxyvisor = new Proxyvisor({ @config, @logger, @db, @docker, @images, applications: this })
@localModeManager = new LocalModeManager(@config, @docker, @logger, @db)
@timeSpentFetching = 0
@ -151,22 +153,12 @@ module.exports = class ApplicationManager extends EventEmitter
@images.cleanup()
createNetworkOrVolume: (step) =>
if step.model is 'network'
# TODO: These step targets should be the actual compose objects,
# rather than recreating them
Network.fromComposeObject({ @docker, @logger },
step.target.name,
step.appId,
step.target.config
).create()
@networks.create(step.target)
else
@volumes.create(step.target)
removeNetworkOrVolume: (step) =>
if step.model is 'network'
Network.fromComposeObject({ @docker, @logger },
step.current.name,
step.appId,
step.current.config
).remove()
@networks.remove(step.current)
else
@volumes.remove(step.current)
ensureSupervisorNetwork: =>
@ -283,12 +275,12 @@ module.exports = class ApplicationManager extends EventEmitter
for network in networks
appId = network.appId
apps[appId] ?= { appId, services: [], volumes: {}, networks: {} }
apps[appId].networks[network.name] = network.config
apps[appId].networks[network.name] = network
for volume in volumes
appId = volume.appId
apps[appId] ?= { appId, services: [], volumes: {}, networks: {} }
apps[appId].volumes[volume.name] = volume.config
apps[appId].volumes[volume.name] = volume
# multi-app warning!
# This is just wrong on every level
@ -396,60 +388,23 @@ module.exports = class ApplicationManager extends EventEmitter
outputPairs = []
currentNames = _.keys(current)
targetNames = _.keys(target)
toBeRemoved = _.difference(currentNames, targetNames)
for name in toBeRemoved
outputPairs.push({
current: {
name
appId
config: current[name]
}
target: null
})
outputPairs.push({ current: current[name], target: null })
toBeInstalled = _.difference(targetNames, currentNames)
for name in toBeInstalled
outputPairs.push({
current: null
target: {
name
appId
config: target[name]
}
})
toBeUpdated = _.filter _.intersection(targetNames, currentNames), (name) =>
# While we're in this in-between state of a network-manager, but not
# a volume-manager, we'll have to inspect the object to detect a
# network-manager
if model instanceof NetworkManager
opts = docker: @docker, logger: @logger
currentNet = Network.fromComposeObject(
opts,
name,
appId,
current[name]
)
targetNet = Network.fromComposeObject(
opts,
name,
appId,
target[name]
)
return !currentNet.isEqualConfig(targetNet)
else
return !model.isEqualConfig(current[name], target[name])
outputPairs.push({ current: null, target: target[name] })
toBeUpdated = _.filter _.intersection(targetNames, currentNames), (name) ->
return !current[name].isEqualConfig(target[name])
for name in toBeUpdated
outputPairs.push({
current: {
name
appId
config: current[name]
}
target: {
name
appId
config: target[name]
}
current: current[name],
target: target[name]
})
return outputPairs
compareNetworksForUpdate: ({ current, target }, appId) =>
@ -628,7 +583,7 @@ module.exports = class ApplicationManager extends EventEmitter
targetApp = emptyApp
else
# Create the default network for the target app
targetApp.networks['default'] ?= {}
targetApp.networks['default'] ?= @createTargetNetwork('default', targetApp.appId, {})
currentApp ?= emptyApp
if currentApp.services?.length == 1 and targetApp.services?.length == 1 and
targetApp.services[0].serviceName == currentApp.services[0].serviceName and
@ -715,6 +670,22 @@ module.exports = class ApplicationManager extends EventEmitter
service.image = imageInfo.Id
return Service.fromComposeObject(service, serviceOpts)
createTargetVolume: (name, appId, volume) ->
return Volume.fromComposeObject(
name,
appId,
volume,
{ @docker, @logger }
)
createTargetNetwork: (name, appId, network) ->
return Network.fromComposeObject(
name,
appId,
network
{ @docker, @logger },
)
normaliseAndExtendAppFromDB: (app) =>
Promise.join(
@config.get('extendedEnvOptions')
@ -732,26 +703,33 @@ module.exports = class ApplicationManager extends EventEmitter
hostnameOnHost
}
_.assign(configOpts, opts)
volumes = JSON.parse(app.volumes)
volumes = _.mapValues volumes, (volumeConfig) ->
volumes = _.mapValues volumes, (volumeConfig, volumeName) =>
volumeConfig ?= {}
volumeConfig.labels ?= {}
return volumeConfig
@createTargetVolume(volumeName, app.appId, volumeConfig)
networks = JSON.parse(app.networks)
networks = _.mapValues networks, (networkConfig, networkName) =>
networkConfig ?= {}
@createTargetNetwork(networkName, app.appId, networkConfig)
Promise.map(JSON.parse(app.services), (service) => @createTargetService(service, configOpts))
.then (services) ->
.then (services) =>
# If a named volume is defined in a service, we add it app-wide so that we can track it and purge it
for s in services
serviceNamedVolumes = s.getNamedVolumes()
for name in serviceNamedVolumes
volumes[name] ?= { labels: {} }
volumes[name] = @createTargetVolume(name, app.appId, { labels: {} })
outApp = {
appId: app.appId
name: app.name
commit: app.commit
releaseId: app.releaseId
services: services
networks: JSON.parse(app.networks)
volumes: volumes
networks
volumes
}
return outApp
)
@ -984,5 +962,4 @@ module.exports = class ApplicationManager extends EventEmitter
svc.serviceId == serviceId
.get('serviceName')
localModeSwitchCompletion: => @localModeManager.switchCompletion()

View File

@ -5,10 +5,12 @@ import { fs } from 'mz';
import * as constants from '../lib/constants';
import Docker from '../lib/docker-utils';
import { ENOENT, NotFoundError } from '../lib/errors';
import logTypes = require('../lib/log-types');
import { Logger } from '../logger';
import { Network, NetworkOptions } from './network';
import log from '../lib/supervisor-console';
import { ResourceRecreationAttemptError } from './errors';
export class NetworkManager {
private docker: Docker;
@ -40,17 +42,51 @@ export class NetworkManager {
return this.getAll().filter((network: Network) => network.appId === appId);
}
public get(network: { name: string; appId: number }): Bluebird<Network> {
return Network.fromNameAndAppId(
{
logger: this.logger,
docker: this.docker,
},
network.name,
network.appId,
public async get(network: {
name: string;
appId: number;
}): Bluebird<Network> {
const dockerNet = await this.docker
.getNetwork(Network.generateDockerName(network.appId, network.name))
.inspect();
return Network.fromDockerNetwork(
{ docker: this.docker, logger: this.logger },
dockerNet,
);
}
public async create(network: Network) {
try {
const existing = await this.get({
name: network.name,
appId: network.appId,
});
if (!network.isEqualConfig(existing)) {
throw new ResourceRecreationAttemptError('network', network.name);
}
// We have a network with the same config and name
// already created, we can skip this
} catch (e) {
if (!NotFoundError(e)) {
this.logger.logSystemEvent(logTypes.createNetworkError, {
network: { name: network.name, appId: network.appId },
error: e,
});
throw e;
}
// If we got a not found error, create the network
await network.create();
}
}
public async remove(network: Network) {
// We simply forward this to the network object, but we
// add this method to provide a consistent interface
await network.remove();
}
public supervisorNetworkReady(): Bluebird<boolean> {
return Bluebird.resolve(
fs.stat(`/sys/class/net/${constants.supervisorNetworkInterface}`),

View File

@ -2,13 +2,14 @@ import * as Bluebird from 'bluebird';
import * as _ from 'lodash';
import Docker from '../lib/docker-utils';
import { InvalidAppIdError, NotFoundError } from '../lib/errors';
import { InvalidAppIdError } from '../lib/errors';
import logTypes = require('../lib/log-types');
import { checkInt } from '../lib/validation';
import { Logger } from '../logger';
import * as ComposeUtils from './utils';
import {
ComposeNetworkConfig,
DockerIPAMConfig,
DockerNetworkConfig,
NetworkConfig,
@ -18,7 +19,6 @@ import {
import {
InvalidNetworkConfigurationError,
InvalidNetworkNameError,
ResourceRecreationAttemptError,
} from './errors';
export interface NetworkOptions {
@ -33,12 +33,10 @@ export class Network {
private docker: Docker;
private logger: Logger;
private networkOpts: NetworkOptions;
private constructor(opts: NetworkOptions) {
this.docker = opts.docker;
this.logger = opts.logger;
this.networkOpts = opts;
}
public static fromDockerNetwork(
@ -93,20 +91,11 @@ export class Network {
return ret;
}
public static async fromNameAndAppId(
opts: NetworkOptions,
name: string,
appId: number,
): Bluebird<Network> {
const network = await opts.docker.getNetwork(`${appId}_${name}`).inspect();
return Network.fromDockerNetwork(opts, network);
}
public static fromComposeObject(
opts: NetworkOptions,
name: string,
appId: number,
network: NetworkConfig,
network: Partial<ComposeNetworkConfig>,
opts: NetworkOptions,
): Network {
const net = new Network(opts);
net.name = name;
@ -114,53 +103,56 @@ export class Network {
Network.validateComposeConfig(network);
// Assign the default values for a network inspect,
// so when we come to compare, it will match
net.config = _.defaultsDeep(network, {
driver: 'bridge',
ipam: {
driver: 'default',
config: [],
options: {},
},
enableIPv6: false,
internal: false,
labels: {},
options: {},
});
const ipam =
network.ipam != null
? network.ipam
: {
driver: 'default',
config: [],
options: {},
};
if (ipam.config == null) {
ipam.config = [];
}
if (ipam.options == null) {
ipam.options = {};
}
net.config = {
driver: network.driver || 'bridge',
ipam,
enableIPv6: network.enable_ipv6 || false,
internal: network.internal || false,
labels: network.labels || {},
options: network.driver_opts || {},
};
net.config.labels = ComposeUtils.normalizeLabels(net.config.labels);
return net;
}
public create(): Bluebird<void> {
public toComposeObject(): ComposeNetworkConfig {
return {
driver: this.config.driver,
driver_opts: this.config.options,
enable_ipv6: this.config.enableIPv6,
internal: this.config.internal,
ipam: this.config.ipam,
labels: this.config.labels,
};
}
public async create(): Promise<void> {
this.logger.logSystemEvent(logTypes.createNetwork, {
network: { name: this.name },
});
return Network.fromNameAndAppId(this.networkOpts, this.name, this.appId)
.then(current => {
if (!this.isEqualConfig(current)) {
throw new ResourceRecreationAttemptError('network', this.name);
}
// We have a network with the same config and name already created -
// we can skip this.
})
.catch(NotFoundError, () => {
return this.docker.createNetwork(this.toDockerConfig());
})
.tapCatch(err => {
this.logger.logSystemEvent(logTypes.createNetworkError, {
network: { name: this.name, appId: this.appId },
error: err,
});
});
return await this.docker.createNetwork(this.toDockerConfig());
}
public toDockerConfig(): DockerNetworkConfig {
return {
Name: this.getDockerName(),
Name: Network.generateDockerName(this.appId, this.name),
Driver: this.config.driver,
CheckDuplicate: true,
IPAM: {
@ -201,7 +193,9 @@ export class Network {
});
return Bluebird.resolve(
this.docker.getNetwork(this.getDockerName()).remove(),
this.docker
.getNetwork(Network.generateDockerName(this.appId, this.name))
.remove(),
).tapCatch(error => {
this.logger.logSystemEvent(logTypes.createNetworkError, {
network: { name: this.name, appId: this.appId },
@ -224,11 +218,9 @@ export class Network {
return _.isEqual(configToCompare, network.config);
}
public getDockerName(): string {
return `${this.appId}_${this.name}`;
}
private static validateComposeConfig(config: NetworkConfig): void {
private static validateComposeConfig(
config: Partial<ComposeNetworkConfig>,
): void {
// Check if every ipam config entry has both a subnet and a gateway
_.each(_.get(config, 'config.ipam.config', []), ({ subnet, gateway }) => {
if (subnet == null || gateway == null) {
@ -238,4 +230,8 @@ export class Network {
}
});
}
public static generateDockerName(appId: number, name: string) {
return `${appId}_${name}`;
}
}

View File

@ -542,6 +542,13 @@ export class Service {
return svc;
}
public toComposeObject(): ServiceConfig {
// This isn't techinically correct as we do some changes
// to the configuration which we cannot reverse. We also
// represent the ports as a class, which isn't ideal
return this.config;
}
public toDockerContainer(opts: {
deviceName: string;
}): Dockerode.ContainerCreateOptions {

View File

@ -34,6 +34,25 @@ export interface NetworkInspect {
Labels: { [labelName: string]: string };
}
export interface ComposeNetworkConfig {
driver: string;
driver_opts: Dictionary<string>;
ipam: {
driver: string;
config: Array<
Partial<{
subnet: string;
ip_range: string;
gateway: string;
aux_addresses: Dictionary<string>;
}>
>;
options: Dictionary<string>;
};
enable_ipv6: boolean;
internal: boolean;
labels: Dictionary<string>;
}
export interface NetworkConfig {
driver: string;
ipam: {

View File

@ -0,0 +1,150 @@
import * as Docker from 'dockerode';
import filter = require('lodash/filter');
import get = require('lodash/get');
import unionBy = require('lodash/unionBy');
import * as Path from 'path';
import constants = require('../lib/constants');
import { NotFoundError } from '../lib/errors';
import { safeRename } from '../lib/fs-utils';
import * as LogTypes from '../lib/log-types';
import { defaultLegacyVolume } from '../lib/migration';
import Logger from '../logger';
import { ResourceRecreationAttemptError } from './errors';
import Volume, { VolumeConfig } from './volume';
export interface VolumeMangerConstructOpts {
docker: Docker;
logger: Logger;
}
export interface VolumeNameOpts {
name: string;
appId: number;
}
export class VolumeManager {
private docker: Docker;
private logger: Logger;
public constructor(opts: VolumeMangerConstructOpts) {
this.docker = opts.docker;
this.logger = opts.logger;
}
public async get({ name, appId }: VolumeNameOpts): Promise<Volume> {
return Volume.fromDockerVolume(
{ docker: this.docker, logger: this.logger },
await this.docker
.getVolume(Volume.generateDockerName(appId, name))
.inspect(),
);
}
public async getAll(): Promise<Volume[]> {
const volumeInspect = await this.listWithBothLabels();
return volumeInspect.map(inspect =>
Volume.fromDockerVolume(
{ logger: this.logger, docker: this.docker },
inspect,
),
);
}
public async getAllByAppId(appId: number): Promise<Volume[]> {
const all = await this.getAll();
return filter(all, { appId });
}
public async create(volume: Volume): Promise<void> {
// First we check that we're not trying to recreate a
// volume
try {
const existing = await this.get({
name: volume.name,
appId: volume.appId,
});
if (!volume.isEqualConfig(existing)) {
throw new ResourceRecreationAttemptError('volume', volume.name);
}
} catch (e) {
if (!NotFoundError(e)) {
this.logger.logSystemEvent(LogTypes.createVolumeError, {
volume: { name: volume.name },
error: e,
});
throw e;
}
await volume.create();
}
}
// We simply forward this to the volume object, but we
// add this method to provide a consistent interface
public async remove(volume: Volume) {
await volume.remove();
}
public async createFromLegacy(appId: number): Promise<Volume | void> {
const name = defaultLegacyVolume();
const legacyPath = Path.join(
constants.rootMountPoint,
'mnt/data/resin-data',
appId.toString(),
);
try {
return await this.createFromPath({ name, appId }, {}, legacyPath);
} catch (e) {
this.logger.logSystemMessage(
`Warning: could not migrate legacy /data volume: ${e.message}`,
{ error: e },
'Volume migration error',
);
}
}
public async createFromPath(
{ name, appId }: VolumeNameOpts,
config: Partial<VolumeConfig>,
oldPath: string,
): Promise<Volume> {
const volume = Volume.fromComposeObject(name, appId, config, {
logger: this.logger,
docker: this.docker,
});
await this.create(volume);
const inspect = await this.docker
.getVolume(Volume.generateDockerName(volume.appId, volume.name))
.inspect();
const volumePath = Path.join(
constants.rootMountPoint,
'mnt/data',
...inspect.Mountpoint.split(Path.sep).slice(3),
);
await safeRename(oldPath, volumePath);
return volume;
}
private async listWithBothLabels(): Promise<Docker.VolumeInspectInfo[]> {
const [legacyResponse, currentResponse] = await Promise.all([
this.docker.listVolumes({
filters: { label: ['io.resin.supervised'] },
}),
this.docker.listVolumes({
filters: { label: ['io.balena.supervised'] },
}),
]);
const legacyVolumes = get(legacyResponse, 'Volumes', []);
const currentVolumes = get(currentResponse, 'Volumes', []);
return unionBy(legacyVolumes, currentVolumes, 'Name');
}
}
export default VolumeManager;

153
src/compose/volume.ts Normal file
View File

@ -0,0 +1,153 @@
import * as Docker from 'dockerode';
import assign = require('lodash/assign');
import isEqual = require('lodash/isEqual');
import constants = require('../lib/constants');
import { InternalInconsistencyError } from '../lib/errors';
import * as LogTypes from '../lib/log-types';
import { LabelObject } from '../lib/types';
import Logger from '../logger';
import * as ComposeUtils from './utils';
export interface VolumeConstructOpts {
logger: Logger;
docker: Docker;
}
export interface VolumeConfig {
labels: LabelObject;
driverOpts: Docker.VolumeInspectInfo['Options'];
}
export interface ComposeVolumeConfig {
driver_opts: Dictionary<string>;
labels: LabelObject;
}
export class Volume {
public appId: number;
public name: string;
public config: VolumeConfig;
private logger: Logger;
private docker: Docker;
private constructor(
name: string,
appId: number,
config: VolumeConfig,
opts: VolumeConstructOpts,
) {
this.name = name;
this.appId = appId;
this.logger = opts.logger;
this.docker = opts.docker;
this.config = config;
}
public static fromDockerVolume(
opts: VolumeConstructOpts,
inspect: Docker.VolumeInspectInfo,
): Volume {
// Convert the docker inspect to the config
const config: VolumeConfig = {
labels: inspect.Labels || {},
driverOpts: inspect.Options || {},
};
// Detect the name and appId from the inspect data
const { name, appId } = this.deconstructDockerName(inspect.Name);
return new Volume(name, appId, config, opts);
}
public static fromComposeObject(
name: string,
appId: number,
config: Partial<ComposeVolumeConfig>,
opts: VolumeConstructOpts,
) {
const filledConfig: VolumeConfig = {
driverOpts: config.driver_opts || {},
labels: ComposeUtils.normalizeLabels(config.labels || {}),
};
// We only need to assign the labels here, as when we
// get it from the daemon, they should already be there
assign(filledConfig.labels, constants.defaultVolumeLabels);
return new Volume(name, appId, filledConfig, opts);
}
public toComposeObject(): ComposeVolumeConfig {
return {
driver_opts: this.config.driverOpts!,
labels: this.config.labels,
};
}
public isEqualConfig(volume: Volume): boolean {
return (
isEqual(this.config.driverOpts, volume.config.driverOpts) &&
isEqual(this.config.labels, volume.config.labels)
);
}
public async create(): Promise<void> {
this.logger.logSystemEvent(LogTypes.createVolume, {
volume: { name: this.name },
});
await this.docker.createVolume({
Name: Volume.generateDockerName(this.appId, this.name),
Labels: this.config.labels,
DriverOpts: this.config.driverOpts,
});
}
public async remove(): Promise<void> {
this.logger.logSystemEvent(LogTypes.removeVolume, {
volume: { name: this.name },
});
try {
await this.docker
.getVolume(Volume.generateDockerName(this.appId, this.name))
.remove();
} catch (e) {
this.logger.logSystemEvent(LogTypes.removeVolumeError, {
volume: { name: this.name, appId: this.appId },
error: e,
});
}
}
public static generateDockerName(appId: number, name: string) {
return `${appId}_${name}`;
}
private static deconstructDockerName(
name: string,
): { name: string; appId: number } {
const match = name.match(/(\d+)_(\S+)/);
if (match == null) {
throw new InternalInconsistencyError(
`Could not detect volume data from docker name: ${name}`,
);
}
const appId = parseInt(match[1], 10);
if (isNaN(appId)) {
throw new InternalInconsistencyError(
`Could not detect application id from docker name: ${match[1]}`,
);
}
return {
appId,
name: match[2],
};
}
}
export default Volume;

View File

@ -1,225 +0,0 @@
import * as Dockerode from 'dockerode';
import * as _ from 'lodash';
import * as path from 'path';
import Docker from '../lib/docker-utils';
import Logger from '../logger';
import constants = require('../lib/constants');
import { InternalInconsistencyError, NotFoundError } from '../lib/errors';
import { safeRename } from '../lib/fs-utils';
import * as LogTypes from '../lib/log-types';
import { defaultLegacyVolume } from '../lib/migration';
import { LabelObject } from '../lib/types';
import { checkInt } from '../lib/validation';
import * as ComposeUtils from './utils';
interface VolumeConstructOpts {
docker: Docker;
logger: Logger;
}
export interface ComposeVolume {
name: string;
appId: number;
config: {
labels: LabelObject;
driverOpts: Dockerode.VolumeInspectInfo['Options'];
};
dockerVolume: Dockerode.VolumeInspectInfo;
}
interface VolumeNameOpts {
name: string;
appId: number;
}
// This weird type is currently needed because the create function (and helpers)
// accept either a docker volume or a compose volume (or an empty object too apparently).
// If we instead split the tasks into createFromCompose and createFromDocker, we will no
// longer have this issue (and weird typing)
type VolumeConfig = ComposeVolume['config'] | Dockerode.VolumeInspectInfo | {};
type VolumeCreateOpts = VolumeNameOpts & {
config?: VolumeConfig;
};
export class Volumes {
private docker: Docker;
private logger: Logger;
public constructor(opts: VolumeConstructOpts) {
this.docker = opts.docker;
this.logger = opts.logger;
}
public async getAll(): Promise<ComposeVolume[]> {
const volumes = await this.listWithBothLabels();
return volumes.map(Volumes.format);
}
public async getAllByAppId(appId: number): Promise<ComposeVolume[]> {
const all = await this.getAll();
return _.filter(all, { appId });
}
public async get({ name, appId }: VolumeNameOpts): Promise<ComposeVolume> {
const volume = await this.docker.getVolume(`${appId}_${name}`).inspect();
return Volumes.format(volume);
}
public async create(opts: VolumeCreateOpts): Promise<ComposeVolume> {
const { name, config = {}, appId } = opts;
const camelCaseConfig: Dictionary<unknown> = _.mapKeys(config, (_v, k) =>
_.camelCase(k),
);
this.logger.logSystemEvent(LogTypes.createVolume, { volume: { name } });
const labels = _.clone(camelCaseConfig.labels as LabelObject) || {};
_.assign(labels, constants.defaultVolumeLabels);
const driverOpts: Dictionary<unknown> =
camelCaseConfig.driverOpts != null
? (camelCaseConfig.driverOpts as Dictionary<unknown>)
: {};
try {
const volume = await this.get({ name, appId });
if (!this.isEqualConfig(volume.config, config)) {
throw new InternalInconsistencyError(
`Trying to create volume '${name}', but a volume with the same name and different configuration exists`,
);
}
return volume;
} catch (e) {
if (!NotFoundError(e)) {
this.logger.logSystemEvent(LogTypes.createVolumeError, {
volume: { name },
error: e,
});
throw e;
}
const volume = await this.docker.createVolume({
Name: Volumes.generateVolumeName({ name, appId }),
Labels: labels,
DriverOpts: driverOpts,
});
return Volumes.format(await volume.inspect());
}
}
public async createFromLegacy(appId: number): Promise<ComposeVolume | void> {
const name = defaultLegacyVolume();
const legacyPath = path.join(
constants.rootMountPoint,
'mnt/data/resin-data',
appId.toString(),
);
try {
return await this.createFromPath({ name, appId }, legacyPath);
} catch (e) {
this.logger.logSystemMessage(
`Warning: could not migrate legacy /data volume: ${e.message}`,
{ error: e },
'Volume migration error',
);
}
}
// oldPath must be a path inside /mnt/data
public async createFromPath(
opts: VolumeCreateOpts,
oldPath: string,
): Promise<void> {
const volume = await this.create(opts);
const handle = volume.dockerVolume;
// Convert the path to be of the same mountpoint so that rename can work
const volumePath = path.join(
constants.rootMountPoint,
'mnt/data',
...handle.Mountpoint.split(path.sep).slice(3),
);
await safeRename(oldPath, volumePath);
}
public async remove({ name, appId }: VolumeNameOpts) {
this.logger.logSystemEvent(LogTypes.removeVolume, { volume: { name } });
try {
await this.docker
.getVolume(Volumes.generateVolumeName({ name, appId }))
.remove();
} catch (e) {
this.logger.logSystemEvent(LogTypes.removeVolumeError, {
volume: { name, appId },
error: e,
});
}
}
public isEqualConfig(current: VolumeConfig, target: VolumeConfig): boolean {
const currentConfig = (_.mapKeys(current, (_v, k) =>
_.camelCase(k),
) as unknown) as ComposeVolume['config'];
const targetConfig = (_.mapKeys(target, (_v, k) =>
_.camelCase(k),
) as unknown) as ComposeVolume['config'];
const currentOpts = currentConfig.driverOpts || {};
const targetOpts = targetConfig.driverOpts || {};
const currentLabels = currentConfig.labels || {};
const targetLabels = targetConfig.labels || {};
return (
_.isEqual(currentOpts, targetOpts) &&
_.isEqual(currentLabels, targetLabels)
);
}
private static format(volume: Dockerode.VolumeInspectInfo): ComposeVolume {
const match = volume.Name.match(/^([0-9]+)_(.+)$/);
if (match == null) {
throw new Error('Malformed volume name in Volume.format');
}
const appId = checkInt(match[1]);
const name = match[2];
return {
name,
// We know this cast is fine due to the regex
appId: appId as number,
config: {
labels: _.omit(
ComposeUtils.normalizeLabels(volume.Labels),
_.keys(constants.defaultVolumeLabels),
),
driverOpts: volume.Options,
},
dockerVolume: volume,
};
}
private async listWithBothLabels(): Promise<Dockerode.VolumeInspectInfo[]> {
const [legacyResponse, currentResponse] = await Promise.all([
this.docker.listVolumes({
filters: { label: ['io.resin.supervised'] },
}),
this.docker.listVolumes({
filters: { label: ['io.balena.supervised'] },
}),
]);
const legacyVolumes = _.get(legacyResponse, 'Volumes', []);
const currentVolumes = _.get(currentResponse, 'Volumes', []);
return _.unionBy(legacyVolumes, currentVolumes, 'Name');
}
private static generateVolumeName({ name, appId }: VolumeNameOpts) {
return `${appId}_${name}`;
}
}
export default Volumes;

View File

@ -244,9 +244,26 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
});
}
// TODO: This should really return the config as it
// is returned from the api, but currently that's not
// the easiest thing due to the way their stored and
// retrieved from the db - when all of the application
// manager is strongly typed, revisit this. The best
// thing to do would be to represent the input with
// io-ts and make sure the below conforms to it
const target = _.cloneDeep(await deviceState.getTarget());
if (target.local != null && !_.isEmpty(target.local.apps)) {
target.local.apps = _.mapValues(target.local.apps, app => {
app.services = _.map(app.services, s => s.toComposeObject());
app.volumes = _.mapValues(app.volumes, v => v.toComposeObject());
app.networks = _.mapValues(app.networks, n => n.toComposeObject());
return app;
});
}
res.status(200).json({
status: 'success',
state: await deviceState.getTarget(),
state: target,
});
} catch (err) {
res.status(503).send({
@ -259,8 +276,6 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
router.post('/v2/local/target-state', async (req, res) => {
// let's first ensure that we're in local mode, otherwise
// this function should not do anything
// TODO: We really should refactor the config module to provide bools
// as bools etc
try {
const localMode = await deviceState.config.get('localMode');
if (!localMode) {

View File

@ -449,7 +449,8 @@ module.exports = class DeviceState extends EventEmitter
# If the volume exists (from a previous incomplete run of this restoreBackup), we delete it first
@applications.volumes.get({ appId, name: volumeName })
.then =>
@applications.volumes.remove({ appId, name: volumeName })
@applications.volumes.get({ appId, name: volumeName }).then (volume) ->
volume.remove()
.catch(NotFoundError, _.noop)
.then =>
@applications.volumes.createFromPath({ appId, name: volumeName, config: volumes[volumeName] }, path.join(backupPath, volumeName))

View File

@ -11,6 +11,7 @@ DeviceState = require '../src/device-state'
{ DB } = require('../src/db')
{ Config } = require('../src/config')
{ Service } = require '../src/compose/service'
{ Network } = require '../src/compose/network'
appDBFormatNormalised = {
appId: 1234
@ -143,12 +144,19 @@ describe 'ApplicationManager', ->
env['ADDITIONAL_ENV_VAR'] = 'foo'
return env
@normaliseCurrent = (current) ->
Promise.map current.local.apps, (app) ->
Promise.map current.local.apps, (app) =>
Promise.map app.services, (service) ->
Service.fromComposeObject(service, { appName: 'test' })
.then (normalisedServices) ->
.then (normalisedServices) =>
appCloned = _.cloneDeep(app)
appCloned.services = normalisedServices
appCloned.networks = _.mapValues appCloned.networks, (config, name) =>
Network.fromComposeObject(
name,
app.appId,
config
{ docker: @applications.docker, @logger }
)
return appCloned
.then (normalisedApps) ->
currentCloned = _.cloneDeep(current)

View File

@ -8,7 +8,7 @@ describe 'compose/network', ->
it 'should convert a compose configuration to an internal representation', ->
network = Network.fromComposeObject({ logger: null, docker: null }, 'test', 123, {
network = Network.fromComposeObject('test', 123, {
'driver': 'bridge',
'ipam': {
'driver': 'default',
@ -19,7 +19,7 @@ describe 'compose/network', ->
}
]
}
})
}, { logger: null, docker: null })
expect(network.config).to.deep.equal({
driver: 'bridge'
@ -41,7 +41,7 @@ describe 'compose/network', ->
it 'should convert an internal representation to a docker representation', ->
network = Network.fromComposeObject({ logger: null, docker: null }, 'test', 123, {
network = Network.fromComposeObject('test', 123, {
'driver': 'bridge',
'ipam': {
'driver': 'default',
@ -52,7 +52,7 @@ describe 'compose/network', ->
}
]
}
})
}, { logger: null, docker: null })
expect(network.toDockerConfig()).to.deep.equal({
Name: '123_test',

123
test/20-compose-volume.ts Normal file
View File

@ -0,0 +1,123 @@
import { expect } from 'chai';
import { stub } from 'sinon';
import Volume from '../src/compose/volume';
import logTypes = require('../src/lib/log-types');
const fakeLogger = {
logSystemMessage: stub(),
logSystemEvent: stub(),
};
const fakeDocker = {
createVolume: stub(),
};
const opts: any = { logger: fakeLogger, docker: fakeDocker };
describe('Compose volumes', () => {
describe('Parsing volumes', () => {
it('should correctly parse docker volumes', () => {
const volume = Volume.fromDockerVolume(opts, {
Driver: 'local',
Labels: {
'io.balena.supervised': 'true',
},
Mountpoint: '/var/lib/docker/volumes/1032480_one_volume/_data',
Name: '1032480_one_volume',
Options: {},
Scope: 'local',
});
expect(volume)
.to.have.property('appId')
.that.equals(1032480);
expect(volume)
.to.have.property('name')
.that.equals('one_volume');
expect(volume)
.to.have.property('config')
.that.has.property('labels')
.that.deep.equals({
'io.balena.supervised': 'true',
});
expect(volume)
.to.have.property('config')
.that.has.property('driverOpts')
.that.deep.equals({});
});
it('should correctly parse compose volumes', () => {
const volume = Volume.fromComposeObject(
'one_volume',
1032480,
{
driver_opts: {
opt1: 'test',
},
labels: {
'my-label': 'test-label',
},
},
opts,
);
expect(volume)
.to.have.property('appId')
.that.equals(1032480);
expect(volume)
.to.have.property('name')
.that.equals('one_volume');
expect(volume)
.to.have.property('config')
.that.has.property('labels')
.that.deep.equals({
'io.balena.supervised': 'true',
'my-label': 'test-label',
});
expect(volume)
.to.have.property('config')
.that.has.property('driverOpts')
.that.deep.equals({
opt1: 'test',
});
});
});
describe('Generating docker options', () => {
afterEach(() => {
fakeDocker.createVolume.reset();
fakeLogger.logSystemEvent.reset();
fakeLogger.logSystemMessage.reset();
});
it('should correctly generate docker options', async () => {
const volume = Volume.fromComposeObject(
'one_volume',
1032480,
{
driver_opts: {
opt1: 'test',
},
labels: {
'my-label': 'test-label',
},
},
opts,
);
await volume.create();
expect(
fakeDocker.createVolume.calledWith({
Labels: {
'my-label': 'test-label',
'io.balena.supervised': 'true',
},
Options: {
opt1: 'test',
},
}),
);
expect(fakeLogger.logSystemEvent.calledWith(logTypes.createVolume));
});
});
});

5
test/fast-mocha.opts Normal file
View File

@ -0,0 +1,5 @@
--exit
--require ts-node/register/transpile-only
--require coffeescript/register
--timeout 30000
test/*.{ts,coffee}

View File

@ -279,7 +279,7 @@ describe('LocalModeManager', () => {
removeStubs.forEach(s => expect(s.remove.callCount).to.be.equal(2));
});
it.only('skips cleanup in case of data corruption', async () => {
it('skips cleanup in case of data corruption', async () => {
const removeStubs = stubRemoveMethods(false);
await db.models('engineSnapshot').insert({