Skip to content

Commit 40cd21c

Browse files
committed
initial import of .etch / preloading lib
w/ forked gunzip-maybe and @types/gunzip-maybe so we don't need esinterop change-type: minor
1 parent 7a1a73b commit 40cd21c

17 files changed

Lines changed: 1902 additions & 29 deletions

lib/dotetch-preloading/appsJson.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
/**
2+
* `Apps.json` is the file that will inform the supervisor of what's has been preloaded, which services should be started and with which config.
3+
*
4+
* `Apps.json` content is a subset of the `target state` for a device in a fleet running a given release.
5+
* Once we have that target fleeet, we need to go down one level to `apps` and keep only that element.
6+
*
7+
* In Apps.json we have the list of all the images that makes up a release.
8+
*/
9+
10+
import axios from "axios";
11+
12+
/**
13+
* Derives Apps.json from target state obtained from the api
14+
*
15+
* This requires merge of https://github.com/balena-io/open-balena-api/pull/1081 in open-balena-api
16+
*
17+
* @param {string} app_id - app_id
18+
* @param {string} release_id - release_id
19+
* @param {string} app_id - app_id === fleet_uuid
20+
* @param {string} api - api server url
21+
* @param {string} apiToken - token to access api
22+
* @returns {json} - apps.json object
23+
*/
24+
const getAppsJson = async ({ app_id, release_id, api, apiToken }: any) => {
25+
// fleetUUID equals app_id
26+
const options = {
27+
url: `https://${api}/device/v3/fleet-state/${app_id}/release/${release_id}`,
28+
headers: {
29+
"Content-Type": "application/json",
30+
Authorization: `Bearer ${apiToken}`,
31+
},
32+
};
33+
34+
try {
35+
const { data } = await axios(options);
36+
return await data;
37+
} catch (error) {
38+
console.error("\n\n==> getAppsJson error:", error);
39+
}
40+
};
41+
42+
/**
43+
* Takes a apps.json and returns the list of images for an app & release.
44+
* If apps_id and/or release_id is unkown it will return first.
45+
* // TODO: return all instead of first when no app or release is specified.
46+
*/
47+
interface ImageIdsInput {
48+
appsJson: any; //TODO: get propertype for appsJson V3
49+
app_id: string;
50+
release_id: string;
51+
}
52+
53+
interface Image {
54+
image_name: string;
55+
image_hash: string;
56+
}
57+
58+
const getImageIds = ({
59+
appsJson,
60+
app_id,
61+
release_id,
62+
}: ImageIdsInput): Image[] => {
63+
const appId = app_id ?? Object.keys(appsJson.apps)[0];
64+
const releaseId =
65+
release_id ?? Object.keys(appsJson.apps?.[appId]?.releases)[0];
66+
console.log(`==> appId: ${appId} & releaseId: ${releaseId}`);
67+
const imageKeys = Object.keys(
68+
appsJson.apps?.[appId]?.releases?.[releaseId]?.services
69+
);
70+
const imageNames = imageKeys.map(
71+
(key) => appsJson.apps?.[appId]?.releases?.[releaseId]?.services[key].image
72+
);
73+
return imageNames.map((image) => {
74+
const [image_name, image_hash] = image.split("@");
75+
return { image_name, image_hash };
76+
});
77+
};
78+
79+
export { getAppsJson, getImageIds };
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
/**
2+
* Get the base image we're going to preload assets in (balenaos.img)
3+
* */
4+
5+
interface StreamBaseImageIn {
6+
pipeStreamFrom: NodeJS.ReadableStream
7+
pipeStreamTo: NodeJS.WritableStream
8+
}
9+
10+
/**
11+
* Awaitable pipe stream from input to output
12+
*/
13+
const streamBaseImage = ({ pipeStreamFrom, pipeStreamTo }: StreamBaseImageIn): Promise<boolean> =>
14+
new Promise((resolve, reject) => {
15+
console.log("== Start streaming base image (balenaOs) @streamBaseImage ==")
16+
17+
pipeStreamFrom.pipe(pipeStreamTo)
18+
19+
pipeStreamFrom.on("end", function () {
20+
// we're good we can continue the process
21+
console.log("== End of base image streaming (balenaOs) @streamBaseImage ==")
22+
resolve(true)
23+
})
24+
25+
pipeStreamFrom.on("error", function (error) {
26+
// something went wrong
27+
reject(error)
28+
})
29+
})
30+
31+
export { streamBaseImage }
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import { Transform } from "stream"
2+
import { createHash } from "crypto"
3+
4+
// minimal typescript reimplementation of https://github.com/jeffbski/digest-stream/blob/master/lib/digest-stream.js
5+
6+
const digestStream = (exfiltrate: Function): Transform => {
7+
const digester = createHash("sha256")
8+
let length = 0
9+
10+
const hashThrough = new Transform({
11+
transform(chunk: Buffer, _, callback) {
12+
digester.update(chunk)
13+
length += chunk.length
14+
this.push(chunk)
15+
callback()
16+
},
17+
})
18+
19+
hashThrough.on("end", () => {
20+
exfiltrate(digester.digest("hex"), length)
21+
})
22+
23+
return hashThrough
24+
}
25+
26+
export { digestStream }
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import fs from 'fs';
2+
import path from 'path';
3+
import { spawn, spawnSync } from 'child_process';
4+
// import pLimit from 'p-limit';
5+
// import gunzip from "extract-zip";
6+
import gunzip from "gunzip-maybe"
7+
import { promisify } from "util";
8+
9+
const IMAGES_BASE = path.resolve('images-base');
10+
const IMAGES_EXPANDED = path.resolve('images-expanded');
11+
console.log(`IMAGES_BASE: ${IMAGES_BASE}`);
12+
console.log(`IMAGES_EXPANDED: ${IMAGES_EXPANDED}`);
13+
14+
// In bytes:
15+
const SECTOR_SIZE = 512
16+
const MBR_SIZE = 512
17+
const GPT_SIZE = SECTOR_SIZE * 34
18+
const MBR_BOOTSTRAP_CODE_SIZE = 446
19+
20+
/**
21+
* createDDArgs
22+
* dd helper
23+
* @param {string} partitionTableDiskImage
24+
* @param {string} nameImage
25+
* @param {int} resizeMultiplier
26+
* return {Array} argsList
27+
* // obs is the output block size and ibs is the input block size. If you specify bs without ibs or obs this is used for both.
28+
// Seek will just "inflate" the output file.
29+
// Seek=7 means that at the beginning of the output file,
30+
// 7 "empty" blocks with output block size=obs=4096bytes will be inserted.
31+
// This is a way to create very big files quickly.
32+
// Or to skip over data at the start which you do not want to alter.
33+
// Empty blocks only result if the output file initially did not have that much data.
34+
*/
35+
function createDDArgs(inImageName, outImageName, resizeMultiplier, partitionStartBytes) {
36+
const partitionTableLabel = 'GPT' || 'DOS';
37+
const argsListMore = {}
38+
argsListMore.sizing = [`count=${MBR_BOOTSTRAP_CODE_SIZE}`, 'seek=5'];
39+
if (partitionTableLabel === 'DOS') {
40+
argsListMore.sizing = [ `skip=${MBR_SIZE}`, `seek=${MBR_SIZE}`, `count=${partitionStartBytes - MBR_SIZE}`];
41+
}
42+
if (partitionTableLabel === 'GPT') {
43+
argsListMore.sizing = [ `skip=${GPT_SIZE}`, `seek=${GPT_SIZE}`, `count=${partitionStartBytes - GPT_SIZE}`];
44+
}
45+
console.log(partitionTableLabel,'partitionTableLabel', argsListMore.sizing, 'partitionStartBytes', partitionStartBytes);
46+
47+
const argsList = [
48+
`if=${inImageName}`,
49+
`of=${outImageName}`,
50+
51+
`ibs=${1024 * resizeMultiplier}`,
52+
// `bs=${resizeMultiplier}M`, // one MiB * resizeMultiplier
53+
`obs=1024`,
54+
'conv=notrunc',
55+
'status=progress',
56+
// `iflag=count_bytes, skip_bytes`, // count and skip in bytes
57+
// `oflag=seek_bytes`// seek in bytes
58+
...argsListMore.sizing
59+
];
60+
return argsList;
61+
}
62+
63+
// fork() exec() spawn() spawnSync()
64+
//https://github.com/adriano-di-giovanni/node-df/blob/master/lib/index.js
65+
const getPartitions = async (image) => {
66+
// const diskutilResults = await spawn('diskutil', ['list']);
67+
// console.log('diskutil', await diskutilResults);
68+
const partitions = spawn('df', ['-hkP'], {
69+
// cwd: '/',
70+
// windowsHide: true,
71+
stdio: [
72+
/* Standard: stdin, stdout, stderr */
73+
// 'inherit',
74+
'ignore',
75+
/* Custom: pipe:3, pipe:4, pipe:5 */
76+
'pipe', process.stderr
77+
]});
78+
const partitionsResults = {partitions: [], partitionsLength: 0};
79+
80+
partitions.stdout.on('data', data => {
81+
const parsedDf = parseDf(data);
82+
// const strData = splitDf(data);
83+
// partitionsResults.partitionArrayLength = strData.length;
84+
// // console.log('strData.length', strData.length);
85+
// const columnHeaders = strData.shift();
86+
// console.log('columnHeaders', columnHeaders);
87+
// const formatted = formatDf(strData, columnHeaders);
88+
partitionsResults.partitions = parsedDf.partitions;
89+
partitionsResults.partitionsLength = parsedDf.partitionsLength;
90+
return partitionsResults;
91+
});
92+
93+
// partitions.stderr.on('data', data => {
94+
// assert(false, 'NOPE stderr');
95+
// });
96+
97+
partitions.on('close', code => {
98+
console.log('Child exited with', code, 'and stdout has been saved');
99+
console.log('partitionsResults', partitionsResults);
100+
return partitionsResults;
101+
});
102+
return partitionsResults;
103+
}
104+
105+
const parseDf = (data) => {
106+
const strData = splitDf(data);
107+
const columnHeaders = strData.shift();
108+
const formatted = formatDf(strData, columnHeaders);
109+
return {partitions: formatted, partitionsLength: formatted.length};
110+
}
111+
112+
const splitDf = (data) => {
113+
return data.toString()
114+
.replace(/ +(?= )/g,'') //replace multiple spaces between device parameters with one space
115+
.split('\n') //split by newline
116+
.map((line) => line.split(' ')); //split each device by one space
117+
}
118+
119+
const formatDf = (strData, columnHeaders) => {
120+
return strData.map((devDisk) => {
121+
const partitionObj = {};
122+
for ( const [index,value] of devDisk.entries()) {
123+
partitionObj[columnHeaders[index]] = value
124+
}
125+
return partitionObj;
126+
});
127+
}
128+
129+
export const expandImg = async (img, partitionSizeStart = 1) => {
130+
if (!img) {
131+
throw new Error(`No img: "${img}"`);
132+
}
133+
const unzippedPath = `${IMAGES_BASE}/unzipped/`
134+
if (img.includes("zip")) {
135+
// await gunzip(img, {dir: `${IMAGES_BASE}/unzipped/`});
136+
await gunzip(`${IMAGES_BASE}/zipped/${img}`, {dir: unzippedPath});
137+
}
138+
// else {
139+
140+
// const diskutilResults = await spawn('diskutil', ['list']);
141+
// console.log('diskutil', await diskutilResults);
142+
const generateRandomName = Math.random().toString(36).substring(2, 15);
143+
144+
const inImageName = `${unzippedPath}${img.split('.').slice(0, -1).join('.')}`;
145+
const outImageName = `${IMAGES_EXPANDED}/${generateRandomName}.img`;
146+
const argsList = await createDDArgs(inImageName, outImageName, 7, partitionSizeStart);
147+
await spawn('dd', argsList, {
148+
cwd: '/',
149+
windowsHide: true,
150+
stdio: [
151+
/* Standard: stdin, stdout, stderr */
152+
'ignore',
153+
/* Custom: pipe:3, pipe:4, pipe:5 */
154+
'pipe', process.stderr
155+
]});
156+
return generateRandomName;
157+
};
158+
159+
// strace dd if=/dev/disk5 of=./images-expanded/tuckers.img bs=4M conv=notrunc
160+
// dd if=/dev/disk5 of=./images-expanded/tuckers.img bs=4M conv="notrunc"
161+
// bs=4M
162+
163+
const getImages = async () => {
164+
const image = 'balena-cloud-preloaded-raspberrypi4-64-2022.1.1-v12.11.0.img.zip'
165+
const {partitions, partitionsLength} = await getPartitions(image);
166+
console.log('partitions', await partitions, 'partitionsLength', partitionsLength);
167+
const imageName = await expandImg(image)
168+
console.log('imageName', await imageName);
169+
}
170+
getImages()

lib/dotetch-preloading/images.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
/** Prepare injectable files for all images */
2+
3+
const getImagesConfigurationFiles = (manifests: any) => {
4+
const dockerImageOverlay2Imagedb = "docker/image/overlay2/imagedb"
5+
return manifests
6+
.map(({ configManifestV2, image_id }: any) => {
7+
const shortImage_id = image_id.split(":")[1]
8+
return [
9+
{
10+
header: { name: `${dockerImageOverlay2Imagedb}/content/sha256/${shortImage_id}`, mode: 644 },
11+
content: JSON.stringify(configManifestV2),
12+
},
13+
{
14+
header: { name: `${dockerImageOverlay2Imagedb}/metadata/sha256/${shortImage_id}/lastUpdated`, mode: 644 },
15+
content: new Date().toISOString(),
16+
},
17+
]
18+
})
19+
.flat()
20+
}
21+
22+
export { getImagesConfigurationFiles }

lib/dotetch-preloading/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import { streamPreloadingAssets } from "./streamPreloadingAssets";
2+
3+
export { streamPreloadingAssets };

0 commit comments

Comments
 (0)