balena-supervisor/sync/device.ts
Felipe Lalanne b207c01988 Fix livepush to work with node 16
This also improves the memory efficiency of the sync mechanism by
calculating the stage ids on the fly instead of storing the full
build output in memory and then parsing the string.
2022-09-20 14:23:21 -03:00

245 lines
6.3 KiB
TypeScript

import * as Docker from 'dockerode';
import { Dockerfile } from 'livepush';
import * as _ from 'lodash';
import { Builder } from 'resin-docker-build';
import { promises as fs } from 'fs';
import * as Path from 'path';
import { Readable } from 'stream';
import * as tar from 'tar-stream';
import * as readline from 'readline';
import { exec } from '../src/lib/fs-utils';
export function getDocker(deviceAddress: string): Docker {
return new Docker({
host: deviceAddress,
// TODO: Make this configurable
port: 2375,
});
}
export async function getSupervisorContainer(
docker: Docker,
requireRunning: boolean = false,
): Promise<Docker.ContainerInfo> {
// First get the supervisor container id
const containers = await docker.listContainers({
filters: { name: ['balena_supervisor', 'resin_supervisor'] },
all: !requireRunning,
});
if (containers.length !== 1) {
throw new Error('supervisor container not found');
}
return containers[0];
}
export async function getDeviceArch(docker: Docker): Promise<string> {
try {
const supervisorContainer = await getSupervisorContainer(docker);
const arch = supervisorContainer.Labels?.['io.balena.architecture'];
if (arch == null) {
// We can try to inspect the image for the
// architecture if this fails
const match = /(amd64|i386|aarch64|armv7hf|rpi)/.exec(
supervisorContainer.Image,
);
if (match != null) {
return match[1];
}
throw new Error('supervisor container does not have architecture label');
}
return arch.trim();
} catch (e: any) {
throw new Error(
`Unable to get device architecture: ${e.message}.\nTry specifying the architecture with -a.`,
);
}
}
export async function getCacheFrom(docker: Docker): Promise<string[]> {
try {
const container = await getSupervisorContainer(docker);
return [container.Image];
} catch {
return [];
}
}
// 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];
}
}
export async function performBuild(
docker: Docker,
dockerfile: Dockerfile,
dockerOpts: { [key: string]: any },
): Promise<string[]> {
const builder = Builder.fromDockerode(docker);
// tar the directory, but replace the dockerfile with the
// livepush generated one
const tarStream = await tarDirectory(Path.join(__dirname, '..'), dockerfile);
return new Promise((resolve, reject) => {
// Store the stage ids for caching
const ids = [] as string[];
builder.createBuildStream(dockerOpts, {
buildSuccess: () => {
// Return the image ids
resolve(ids);
},
buildFailure: reject,
buildStream: (input: NodeJS.ReadWriteStream) => {
// Parse the build output to get stage ids and
// for logging
let lastArrowMessage: string | undefined;
readline.createInterface({ input }).on('line', (line) => {
// If this was a FROM line, take the last found
// image id and save it as a stage id
// Source: https://github.com/balena-io/balena-cli/blob/f6d668684a6f5ea8102a964ca1942b242eaa7ae2/lib/utils/device/live.ts#L300-L325
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;
}
}
// Log the build line
console.info(line);
});
// stream.pipe(bufStream);
tarStream.pipe(input);
},
});
});
}
async function tarDirectory(
dir: string,
dockerfile: Dockerfile,
): Promise<Readable> {
const pack = tar.pack();
const add = async (path: string) => {
const entries = await fs.readdir(path);
for (const entry of entries) {
const newPath = Path.resolve(path, entry);
// Here we filter the things we don't want
if (
newPath.includes('node_modules/') ||
newPath.includes('.git/') ||
newPath.includes('build/') ||
newPath.includes('coverage/')
) {
continue;
}
// We use lstat here, otherwise an error will be
// thrown on a symbolic link
const stat = await fs.lstat(newPath);
if (stat.isDirectory()) {
await add(newPath);
} else {
if (newPath.endsWith('Dockerfile.template')) {
pack.entry(
{ name: 'Dockerfile', mode: stat.mode, size: stat.size },
dockerfile.generateLiveDockerfile(),
);
continue;
}
pack.entry(
{
name: Path.relative(dir, newPath),
mode: stat.mode,
size: stat.size,
},
await fs.readFile(newPath),
);
}
}
};
await add(dir);
pack.finalize();
return pack;
}
// Absolutely no escaping in this function, just be careful
async function runSshCommand(address: string, command: string) {
// TODO: Make the port configurable
const { stdout } = await exec(
'ssh -p 22222 -o LogLevel=ERROR ' +
'-o StrictHostKeyChecking=no ' +
'-o UserKnownHostsFile=/dev/null ' +
`root@${address} ` +
`"${command}"`,
);
return stdout;
}
export async function stopSupervisor(address: string) {
try {
await runSshCommand(address, 'systemctl stop balena-supervisor');
} catch {
await runSshCommand(address, 'systemctl stop resin-supervisor');
}
}
export async function startSupervisor(address: string) {
try {
await runSshCommand(address, 'systemctl start balena-supervisor');
} catch {
await runSshCommand(address, 'systemctl start resin-supervisor');
}
}
export async function replaceSupervisorImage(
address: string,
imageName: string,
imageTag: string,
) {
// TODO: Maybe don't overwrite the LED file?
const fileStr = `#This file was edited by livepush
SUPERVISOR_IMAGE=${imageName}
SUPERVISOR_TAG=${imageTag}
LED_FILE=/dev/null
`;
return runSshCommand(
address,
`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`,
);
}