Merge pull request #1981 from balena-os/sv-base-image

Refactor supervisor Dockerfile to remove custom dependencies
This commit is contained in:
bulldozer-balena[bot] 2022-07-18 18:33:18 +00:00 committed by GitHub
commit 6945f61a24
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 233 additions and 170 deletions

View File

@ -1,70 +1,125 @@
ARG ARCH=%%BALENA_ARCH%% ARG ARCH=%%BALENA_ARCH%%
ARG NODE_VERSION=12.16.2
FROM balenalib/$ARCH-alpine-supervisor-base:3.11 as BUILD # Used by livepush to support multi arch images in older
# balenaOS with buggy platform support
# see https://github.com/balena-os/balena-engine/issues/269
ARG PREFIX=library
ARG ARCH ###################################################
ARG NODE_VERSION # Build the supervisor dependencies
ARG NODE_ARCHIVE="node-no-intl-v${NODE_VERSION}-linux-alpine-${ARCH}.tar.gz" ###################################################
ARG S3_BASE="https://resin-packages.s3.amazonaws.com" FROM balenalib/${ARCH}-alpine-node:12-run as build-base
ARG NODE_LOCATION="${S3_BASE}/node/v${NODE_VERSION}/${NODE_ARCHIVE}"
# DO NOT REMOVE THE cross-build-* COMMANDS ARG PREFIX
# The following commands are absolutely needed. When we # Sanity check to prevent a prefix for a non-official docker image being
# build for ARM architectures, we run this Dockerfile # inserted. Only 'library' and 'arm32v6' are allowed right now
# through sed, which uncomments these lines. There were RUN for allowed in "library" "arm32v6"; do [ "${PREFIX}" = "${allowed}" ] && break; done
# other options for achieving the same setup, but this seems
# to be the least intrusive. The commands start commented
# out because the default build for balenaCI is amd64 (and
# we can't run any sed preprocessing on it there)
# RUN ["cross-build-start"]
WORKDIR /usr/src/app WORKDIR /usr/src/app
RUN apk add --no-cache \ RUN apk add --no-cache \
g++ \ g++ \
git \
make \ make \
python \ python3 \
curl \
binutils \
libgcc \ libgcc \
libstdc++ \
libuv \ libuv \
sqlite-libs \
sqlite-dev \ sqlite-dev \
dmidecode \ dbus-dev
dbus-dev \
procmail
# procmail is installed for the lockfile binary
COPY build-utils/node-sums.txt .
# Install node from balena's prebuilt cache
RUN curl -SLO "${NODE_LOCATION}" \
&& grep "${NODE_ARCHIVE}" node-sums.txt | sha256sum -c - \
&& tar -xzf "${NODE_ARCHIVE}" -C /usr/local --strip-components=1 \
&& rm -f "${NODE_ARCHIVE}" \
&& strip /usr/local/bin/node
COPY package*.json ./ COPY package*.json ./
RUN strip /usr/local/bin/node
# Just install dev dependencies first
RUN npm ci --build-from-source --sqlite=/usr/lib RUN npm ci --build-from-source --sqlite=/usr/lib
# We only run these commands when executing through ###################################################
# livepush, so they are presented as livepush directives # Extra dependencies. This uses alpine 3.11 as the
#dev-run=apk add --no-cache ip6tables iptables # procmail package was removed on 3.12
###################################################
FROM ${PREFIX}/alpine:3.11 as extra
RUN apk add --update --no-cache procmail
###################################################
# Image with the final production dependencies.
# This image will also be be used for testing
###################################################
FROM ${PREFIX}/alpine:3.16 as runtime-base
WORKDIR /usr/src/app
# We just need the node binary in the final image
COPY --from=build-base /usr/local/bin/node /usr/local/bin/node
# Similarly, from the procmail package we just need the lockfile binary
COPY --from=extra /usr/bin/lockfile /usr/bin/lockfile
# Runtime dependencies
RUN apk add --no-cache \
ca-certificates \
iptables \
ip6tables \
rsync \
dbus \
libstdc++ \
dmidecode \
sqlite-libs
ARG ARCH
ARG VERSION=master
ARG DEFAULT_MIXPANEL_TOKEN=bananasbananas
ENV CONFIG_MOUNT_POINT=/boot/config.json \
LED_FILE=/dev/null \
SUPERVISOR_IMAGE=balena/$ARCH-supervisor \
VERSION=$VERSION \
DEFAULT_MIXPANEL_TOKEN=$DEFAULT_MIXPANEL_TOKEN
###################################################
# Use the base image to run the tests as livepush
###################################################
FROM runtime-base as test
WORKDIR /usr/src/app
# Copy node install from the build folder
COPY --from=build-base /usr/local/bin /usr/local/bin
COPY --from=build-base /usr/local/lib/node_modules /usr/local/lib/node_modules
# Copy build dependencies
COPY --from=build-base /usr/src/app/package.json ./
COPY --from=build-base /usr/src/app/node_modules ./node_modules
# Run livepush here
#dev-copy=entry.sh . #dev-copy=entry.sh .
#dev-cmd-live=LIVEPUSH=1 ./entry.sh #dev-cmd-live=LIVEPUSH=1 ./entry.sh
# Copy build files
COPY build-utils ./build-utils COPY build-utils ./build-utils
COPY webpack.config.js tsconfig.json tsconfig.release.json ./ COPY webpack.config.js tsconfig.json tsconfig.release.json ./
COPY src ./src COPY src ./src
COPY test ./test COPY test ./test
COPY typings ./typings COPY typings ./typings
RUN npm run test-nolint \ # Run the tests
&& npm run build RUN npm run test-nolint
###################################################
# Build the production package
###################################################
FROM build-base as build-prod
WORKDIR /usr/src/app
# Copy build files
COPY build-utils ./build-utils
COPY webpack.config.js tsconfig.json tsconfig.release.json ./
COPY src ./src
COPY typings ./typings
# Compile the sources using the dev
# dependencies
RUN npm run build
# Run the production install here, to avoid the npm dependency on # Run the production install here, to avoid the npm dependency on
# the later stage # the later stage
@ -86,52 +141,21 @@ RUN npm ci --production --no-optional --unsafe-perm --build-from-source --sqlite
&& find . -type f -path '*/node_modules/knex/build*' -delete \ && find . -type f -path '*/node_modules/knex/build*' -delete \
&& rm -rf node_modules/sqlite3/node.dtps && rm -rf node_modules/sqlite3/node.dtps
###################################################
# RUN ["cross-build-end"] # Build the production image
###################################################
FROM balenalib/$ARCH-alpine-supervisor-base:3.11 FROM runtime-base
# RUN ["cross-build-start"]
RUN apk add --no-cache \
ca-certificates \
kmod \
iptables \
ip6tables \
rsync \
avahi \
dbus \
libstdc++ \
dmidecode \
sqlite-libs
WORKDIR /usr/src/app WORKDIR /usr/src/app
COPY --from=BUILD /usr/local/bin/node /usr/local/bin/node COPY --from=build-prod /usr/src/app/dist ./dist
COPY --from=BUILD /usr/bin/lockfile /usr/bin/lockfile COPY --from=build-prod /usr/src/app/package.json ./
COPY --from=BUILD /usr/src/app/dist ./dist COPY --from=build-prod /usr/src/app/node_modules ./node_modules
COPY --from=BUILD /usr/src/app/package.json ./
COPY --from=BUILD /usr/src/app/node_modules ./node_modules
COPY entry.sh . COPY entry.sh .
RUN mkdir -p rootfs-overlay && \
(([ ! -d rootfs-overlay/lib64 ] && ln -s /lib rootfs-overlay/lib64) || true)
ARG ARCH
ARG VERSION=master
ARG DEFAULT_MIXPANEL_TOKEN=bananasbananas
ENV CONFIG_MOUNT_POINT=/boot/config.json \
LED_FILE=/dev/null \
SUPERVISOR_IMAGE=balena/$ARCH-supervisor \
VERSION=$VERSION \
DEFAULT_MIXPANEL_TOKEN=$DEFAULT_MIXPANEL_TOKEN
COPY avahi-daemon.conf /etc/avahi/avahi-daemon.conf
VOLUME /data VOLUME /data
HEALTHCHECK --interval=5m --start-period=1m --timeout=30s --retries=3 \ HEALTHCHECK --interval=5m --start-period=1m --timeout=30s --retries=3 \
CMD wget http://127.0.0.1:${LISTEN_PORT:-48484}/v1/healthy -O - -q CMD wget http://127.0.0.1:${LISTEN_PORT:-48484}/v1/healthy -O - -q
# RUN ["cross-build-end"]
CMD ["/usr/src/app/entry.sh"] CMD ["/usr/src/app/entry.sh"]

View File

@ -1,67 +0,0 @@
# This file is part of avahi.
#
# avahi is free software; you can redistribute it and/or modify it
# under the terms of the GNU Lesser General Public License as
# published by the Free Software Foundation; either version 2 of the
# License, or (at your option) any later version.
#
# avahi is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
# or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public
# License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with avahi; if not, write to the Free Software
# Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307
# USA.
# See avahi-daemon.conf(5) for more information on this configuration
# file!
[server]
#host-name=foo
#domain-name=local
#browse-domains=0pointer.de, zeroconf.org
use-ipv4=yes
use-ipv6=yes
#allow-interfaces=eth0
#deny-interfaces=eth1
#check-response-ttl=no
#use-iff-running=no
#enable-dbus=yes
#disallow-other-stacks=no
#allow-point-to-point=no
#cache-entries-max=4096
#clients-max=4096
#objects-per-client-max=1024
#entries-per-entry-group-max=32
ratelimit-interval-usec=1000000
ratelimit-burst=1000
[wide-area]
enable-wide-area=yes
[publish]
#disable-publishing=no
#disable-user-service-publishing=no
#add-service-cookie=no
#publish-addresses=yes
publish-hinfo=no
publish-workstation=no
#publish-domain=yes
#publish-dns-servers=192.168.50.1, 192.168.50.2
#publish-resolv-conf-dns-servers=yes
#publish-aaaa-on-ipv4=yes
#publish-a-on-ipv6=no
[reflector]
#enable-reflector=no
#reflect-ipv=no
[rlimits]
#rlimit-as=
rlimit-core=0
rlimit-data=4194304
rlimit-fsize=0
rlimit-nofile=768
rlimit-stack=4194304

View File

@ -1,5 +0,0 @@
6d4ab189ece76bed2f40cffe1f1b4dd2d2a805e6fe10c577ae5ce89fce2ad53b node-no-intl-v12.16.2-linux-alpine-amd64.tar.gz
6d81db43dc1285656f6d95807e237e51d33774bed5daafad7f706a4e68b6b546 node-no-intl-v12.16.2-linux-alpine-i386.tar.gz
d964c84be94d0cf3f30f4c4d61d3f2e0d5439b43137c938d0a0df1b6860961fb node-no-intl-v12.16.2-linux-alpine-aarch64.tar.gz
00ac31e6e43319cc3786c0eb97784dad91b5152c5f8be31889215801dcab7712 node-no-intl-v12.16.2-linux-alpine-armv7hf.tar.gz
8c1cfa75a1523a5b21060201178f4a8cd51f492c93693ecfbd67e8b0a49b23f1 node-no-intl-v12.16.2-linux-alpine-rpi.tar.gz

View File

@ -5,7 +5,7 @@ import { Builder } from 'resin-docker-build';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import * as Path from 'path'; import * as Path from 'path';
import { Duplex, Readable } from 'stream'; import { Duplex, Readable, PassThrough, Stream } from 'stream';
import * as tar from 'tar-stream'; import * as tar from 'tar-stream';
import { exec } from '../src/lib/fs-utils'; import { exec } from '../src/lib/fs-utils';
@ -68,26 +68,30 @@ export async function getCacheFrom(docker: Docker): Promise<string[]> {
} }
} }
// perform the build and return the image id
export async function performBuild( export async function performBuild(
docker: Docker, docker: Docker,
dockerfile: Dockerfile, dockerfile: Dockerfile,
dockerOpts: { [key: string]: any }, dockerOpts: { [key: string]: any },
): Promise<void> { ): Promise<string> {
const builder = Builder.fromDockerode(docker); const builder = Builder.fromDockerode(docker);
// tar the directory, but replace the dockerfile with the // tar the directory, but replace the dockerfile with the
// livepush generated one // livepush generated one
const tarStream = await tarDirectory(Path.join(__dirname, '..'), dockerfile); const tarStream = await tarDirectory(Path.join(__dirname, '..'), dockerfile);
const bufStream = new PassThrough();
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const chunks = [] as Buffer[];
bufStream.on('data', (chunk) => chunks.push(Buffer.from(chunk)));
builder.createBuildStream(dockerOpts, { builder.createBuildStream(dockerOpts, {
buildSuccess: () => { buildSuccess: () => {
resolve(); // Return the build logs
resolve(Buffer.concat(chunks).toString('utf8'));
}, },
buildFailure: reject, buildFailure: reject,
buildStream: (stream: Duplex) => { buildStream: (stream: Duplex) => {
stream.pipe(process.stdout); stream.pipe(process.stdout);
stream.pipe(bufStream);
tarStream.pipe(stream); tarStream.pipe(stream);
}, },
}); });
@ -190,3 +194,20 @@ LED_FILE=/dev/null
`echo '${fileStr}' > /tmp/update-supervisor.conf`, `echo '${fileStr}' > /tmp/update-supervisor.conf`,
); );
} }
export async function readBuildCache(address: string): Promise<string[]> {
const cache = await runSshCommand(
address,
`cat /tmp/livepush-cache.json || true`,
);
return JSON.parse(cache || '[]');
}
export async function writeBuildCache(address: string, stageImages: string[]) {
// Convert the list to JSON with escaped quotes
const contents = JSON.stringify(stageImages).replace(/["]/g, '\\"');
return runSshCommand(
address,
`echo '${contents}' > /tmp/livepush-cache.json`,
);
}

View File

@ -14,24 +14,92 @@ interface Opts {
arch?: string; arch?: string;
} }
// Source: https://github.com/balena-io/balena-cli/blob/f6d668684a6f5ea8102a964ca1942b242eaa7ae2/lib/utils/device/live.ts#L539-L547
function extractDockerArrowMessage(outputLine: string): string | undefined {
const arrowTest = /^.*\s*-+>\s*(.+)/i;
const match = arrowTest.exec(outputLine);
if (match != null) {
return match[1];
}
}
// Source: https://github.com/balena-io/balena-cli/blob/f6d668684a6f5ea8102a964ca1942b242eaa7ae2/lib/utils/device/live.ts#L300-L325
function getMultiStateImageIDs(buildLog: string): string[] {
const ids = [] as string[];
const lines = buildLog.split(/\r?\n/);
let lastArrowMessage: string | undefined;
for (const line of lines) {
// If this was a from line, take the last found
// image id and save it
if (
/step \d+(?:\/\d+)?\s*:\s*FROM/i.test(line) &&
lastArrowMessage != null
) {
ids.push(lastArrowMessage);
} else {
const msg = extractDockerArrowMessage(line);
if (msg != null) {
lastArrowMessage = msg;
}
}
}
return ids;
}
function getPathPrefix(arch: string) {
switch (arch) {
/**
* Proper paths are
* - armv6 - arm32v6
* - armv7hf - arm32v7
* - aarch64 - arm64v8
* - amd64 - amd64
* - i386 - i386
*
* We only set the prefix for v6 images since rpi devices are
* the only ones that seem to have the issue
* https://github.com/balena-os/balena-engine/issues/269
*/
case 'rpi':
return 'arm32v6';
default:
return 'library';
}
}
export async function initDevice(opts: Opts) { export async function initDevice(opts: Opts) {
const arch = opts.arch ?? (await device.getDeviceArch(opts.docker)); const arch = opts.arch ?? (await device.getDeviceArch(opts.docker));
const image = `${opts.imageName}-${opts.imageTag}`; const image = `${opts.imageName}:${opts.imageTag}`;
await device.performBuild(opts.docker, opts.dockerfile, { const buildCache = await device.readBuildCache(opts.address);
buildargs: { ARCH: arch },
const buildLog = await device.performBuild(opts.docker, opts.dockerfile, {
buildargs: { ARCH: arch, PREFIX: getPathPrefix(arch) },
t: image, t: image,
labels: { 'io.balena.livepush-image': '1', 'io.balena.architecture': arch }, labels: { 'io.balena.livepush-image': '1', 'io.balena.architecture': arch },
cachefrom: (await device.getCacheFrom(opts.docker)).concat(image), cachefrom: (await device.getCacheFrom(opts.docker))
.concat(image)
.concat(buildCache),
nocache: opts.nocache, nocache: opts.nocache,
}); });
const stageImages = getMultiStateImageIDs(buildLog);
// Store the list of stage images for the next time the sync
// command is called. This will only live until the device is rebooted
await device.writeBuildCache(opts.address, stageImages);
// Now that we have our new image on the device, we need // Now that we have our new image on the device, we need
// to stop the supervisor, update // to stop the supervisor, update
// /tmp/update-supervisor.conf with our version, and // /tmp/update-supervisor.conf with our version, and
// restart the supervisor // restart the supervisor
await device.stopSupervisor(opts.address); await device.stopSupervisor(opts.address);
await device.replaceSupervisorImage(opts.address, image, 'latest'); await device.replaceSupervisorImage(
opts.address,
opts.imageName,
opts.imageTag,
);
await device.startSupervisor(opts.address); await device.startSupervisor(opts.address);
let supervisorContainer: undefined | Docker.ContainerInfo; let supervisorContainer: undefined | Docker.ContainerInfo;
@ -45,5 +113,5 @@ export async function initDevice(opts: Opts) {
await Bluebird.delay(500); await Bluebird.delay(500);
} }
} }
return supervisorContainer.Id; return { containerId: supervisorContainer.Id, stageImages };
} }

View File

@ -11,11 +11,12 @@ export async function startLivepush(opts: {
containerId: string; containerId: string;
docker: Docker; docker: Docker;
noinit: boolean; noinit: boolean;
stageImages?: string[];
}) { }) {
const livepush = await Livepush.init({ const livepush = await Livepush.init({
stageImages: [],
...opts, ...opts,
context: Path.join(__dirname, '..'), context: Path.join(__dirname, '..'),
stageImages: [],
}); });
livepush.addListener('commandExecute', ({ command }) => { livepush.addListener('commandExecute', ({ command }) => {
@ -34,8 +35,7 @@ export async function startLivepush(opts: {
}); });
const livepushExecutor = getExecutor(livepush); const livepushExecutor = getExecutor(livepush);
const watcher = chokidar
chokidar
.watch('.', { .watch('.', {
ignored: /((^|[\/\\])\..|(node_modules|sync|test)\/.*)/, ignored: /((^|[\/\\])\..|(node_modules|sync|test)\/.*)/,
ignoreInitial: opts.noinit, ignoreInitial: opts.noinit,
@ -43,6 +43,11 @@ export async function startLivepush(opts: {
.on('add', (path) => livepushExecutor(path)) .on('add', (path) => livepushExecutor(path))
.on('change', (path) => livepushExecutor(path)) .on('change', (path) => livepushExecutor(path))
.on('unlink', (path) => livepushExecutor(undefined, path)); .on('unlink', (path) => livepushExecutor(undefined, path));
return async () => {
await watcher.close();
await livepush.cleanupIntermediateContainers();
};
} }
const getExecutor = (livepush: Livepush) => { const getExecutor = (livepush: Livepush) => {

View File

@ -35,13 +35,15 @@ const argv = yargs
alias: 'i', alias: 'i',
type: 'string', type: 'string',
description: 'Specify the name to use for the supervisor image on device', description: 'Specify the name to use for the supervisor image on device',
default: 'livepush-supervisor', default: `livepush-supervisor-${packageJson.version}`,
}) })
.options('image-tag', { .options('image-tag', {
alias: 't', alias: 't',
type: 'string', type: 'string',
description: 'Specify the tag to use for the supervisor image on device', description:
default: packageJson.version, 'Specify the tag to use for the supervisor image on device. It will not have any effect on balenaOS >= v2.89.0',
default: 'latest',
deprecated: true,
}) })
.options('nocache', { .options('nocache', {
description: 'Run the intial build without cache', description: 'Run the intial build without cache',
@ -59,9 +61,14 @@ const argv = yargs
await fs.readFile('Dockerfile.template'), await fs.readFile('Dockerfile.template'),
); );
let cleanup = () => Promise.resolve();
let sigint = () => {
/** ignore empty */
};
try { try {
const docker = device.getDocker(address); const docker = device.getDocker(address);
const containerId = await init.initDevice({ const { containerId, stageImages } = await init.initDevice({
address, address,
docker, docker,
dockerfile, dockerfile,
@ -74,14 +81,24 @@ const argv = yargs
console.log(`Supervisor container: ${containerId}\n`); console.log(`Supervisor container: ${containerId}\n`);
await setupLogs(docker, containerId); await setupLogs(docker, containerId);
await startLivepush({ cleanup = await startLivepush({
dockerfile, dockerfile,
containerId, containerId,
docker, docker,
noinit: true, noinit: true,
stageImages,
});
await new Promise((_, reject) => {
sigint = () => reject(new Error('User interrupt (Ctrl+C) received'));
process.on('SIGINT', sigint);
}); });
} catch (e) { } catch (e) {
console.error('Error:'); console.error('Error:', e.message);
console.error(e.message); } finally {
console.info('Cleaning up. Please wait ...');
await cleanup();
process.removeListener('SIGINT', sigint);
process.exit(1);
} }
})(); })();