Skip to content

Commit f9e469f

Browse files
authored
Merge pull request #789 from particle-iot/feature/sc-133461/allow-caching-downloaded-os-files
implement cache limit validation
2 parents 5795d84 + e34514f commit f9e469f

File tree

5 files changed

+127
-29
lines changed

5 files changed

+127
-29
lines changed

settings.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,8 @@ let settings = {
4141
'tinker': true
4242
},
4343

44-
tachyonMeta: 'https://tachyon-ci.particle.io/meta'
44+
tachyonMeta: 'https://tachyon-ci.particle.io/meta',
45+
tachyonCacheLimitGB: 10
4546
};
4647

4748
function envValue(varName, defaultValue) {

src/cmd/download-tachyon-package.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ module.exports = class DownloadTachyonPackageCommand extends CLICommandBase {
3636
const answer = await this.ui.prompt(question);
3737
return answer.version;
3838
}
39-
async download ({ region, version }) {
39+
async download ({ region, version, alwaysCleanCache = false }) {
4040
// prompt for region and version if not provided
4141
if (!region) {
4242
region = await this._selectRegion();
@@ -52,7 +52,7 @@ module.exports = class DownloadTachyonPackageCommand extends CLICommandBase {
5252
}
5353
const { artifact_url: url, sha256_checksum: expectedChecksum } = build.artifacts[0];
5454
const outputFileName = url.replace(/.*\//, '');
55-
const filePath = await manager.download({ url, outputFileName, expectedChecksum });
55+
const filePath = await manager.download({ url, outputFileName, expectedChecksum, options: { alwaysCleanCache } });
5656
this.ui.write(`Downloaded package to: ${filePath}`);
5757

5858
return filePath;

src/cmd/setup-tachyon.js

Lines changed: 30 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -31,27 +31,7 @@ module.exports = class SetupTachyonCommands extends CLICommandBase {
3131

3232
async setup({ skip_flashing_os: skipFlashingOs, version, load_config: loadConfig, save_config: saveConfig }) {
3333
try {
34-
this.ui.write(`
35-
===========================================================
36-
Particle Tachyon Setup Command
37-
===========================================================
38-
39-
Welcome to the Particle Tachyon setup! This interactive command:
40-
41-
- Flashes your Tachyon device
42-
- Configures it (password, WiFi credentials etc...)
43-
- Connects it to the internet and the Particle Cloud!
44-
45-
**What you'll need:**
46-
47-
1. Your Tachyon device
48-
2. The Tachyon battery
49-
3. A USB-C cable
50-
51-
**Important:**
52-
- This tool requires you to be logged into your Particle account.
53-
- For more details, check out the documentation at: https://part.cl/setup-tachyon`);
54-
34+
this._showWelcomeMessage();
5535
this._formatAndDisplaySteps("Okay—first up! Checking if you're logged in...", 1);
5636

5737
await this._verifyLogin();
@@ -66,6 +46,7 @@ Welcome to the Particle Tachyon setup! This interactive command:
6646
}
6747

6848
let config = { systemPassword: null, wifi: null, sshPublicKey: null };
49+
let alwaysCleanCache = false;
6950

7051
if ( !loadConfig ) {
7152
config = await this._runStepWithTiming(
@@ -81,6 +62,7 @@ Welcome to the Particle Tachyon setup! This interactive command:
8162
0
8263
);
8364
} else {
65+
alwaysCleanCache = true;
8466
this.ui.write(
8567
`${os.EOL}${os.EOL}Skipping to Step 3 - Using configuration file: ` + loadConfig + `${os.EOL}`
8668
);
@@ -100,7 +82,7 @@ Welcome to the Particle Tachyon setup! This interactive command:
10082
`if it's interrupted. If you have to kill the CLI, it will pick up where it left. You can also${os.EOL}` +
10183
"just let it run in the background. We'll wait for you to be ready when its time to flash the device.",
10284
4,
103-
() => this._download({ region, version })
85+
() => this._download({ region, version, alwaysCleanCache })
10486
);
10587

10688
const registrationCode = await this._runStepWithTiming(
@@ -168,6 +150,29 @@ Welcome to the Particle Tachyon setup! This interactive command:
168150
}
169151
}
170152

153+
async _showWelcomeMessage() {
154+
this.ui.write(`
155+
===================================================================================
156+
Particle Tachyon Setup Command
157+
===================================================================================
158+
159+
Welcome to the Particle Tachyon setup! This interactive command:
160+
161+
- Flashes your Tachyon device
162+
- Configures it (password, WiFi credentials etc...)
163+
- Connects it to the internet and the Particle Cloud!
164+
165+
**What you'll need:**
166+
167+
1. Your Tachyon device
168+
2. The Tachyon battery
169+
3. A USB-C cable
170+
171+
**Important:**
172+
- This tool requires you to be logged into your Particle account.
173+
- For more details, check out the documentation at: https://part.cl/setup-tachyon`);
174+
}
175+
171176
async _runStepWithTiming(stepDesc, stepNumber, asyncTask, minDuration = 2000) {
172177
this._formatAndDisplaySteps(stepDesc, stepNumber);
173178

@@ -189,7 +194,7 @@ Welcome to the Particle Tachyon setup! This interactive command:
189194

190195
async _formatAndDisplaySteps(text, step) {
191196
// Display the formatted step
192-
this.ui.write(`${os.EOL}===========================================================${os.EOL}`);
197+
this.ui.write(`${os.EOL}===================================================================================${os.EOL}`);
193198
this.ui.write(`Step ${step}:${os.EOL}`);
194199
this.ui.write(`${text}${os.EOL}`);
195200
}
@@ -325,7 +330,7 @@ Welcome to the Particle Tachyon setup! This interactive command:
325330
return { systemPassword, wifi, sshPublicKey };
326331
}
327332

328-
async _download({ region, version }) {
333+
async _download({ region, version, alwaysCleanCache }) {
329334

330335
//before downloading a file, we need to check if 'version' is a local file or directory
331336
//if it is a local file or directory, we need to return the path to the file
@@ -344,7 +349,7 @@ Welcome to the Particle Tachyon setup! This interactive command:
344349
const outputFileName = url.replace(/.*\//, '');
345350
const expectedChecksum = artifact.sha256_checksum;
346351

347-
return manager.download({ url, outputFileName, expectedChecksum });
352+
return manager.download({ url, outputFileName, expectedChecksum, options: { alwaysCleanCache } });
348353
}
349354

350355
async _getSystemPassword() {

src/lib/download-manager.js

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,11 +54,13 @@ class DownloadManager {
5454

5555
async download({ url, outputFileName, expectedChecksum, options = {} }) {
5656
// Check cache
57+
const { alwaysCleanCache = false } = options;
5758
const cachedFile = await this._getCachedFile(outputFileName, expectedChecksum);
5859
if (cachedFile) {
5960
this.ui.write(`Using cached file: ${cachedFile}`);
6061
return cachedFile;
6162
}
63+
await this._validateCacheLimit({ url, currentFileName: outputFileName, alwaysCleanCache });
6264
const filePath = await this._downloadFile(url, outputFileName, options);
6365
// Validate checksum after download completes
6466
// Validate checksum after download completes
@@ -96,6 +98,73 @@ class DownloadManager {
9698
}
9799
}
98100

101+
async _validateCacheLimit({ url, currentFileName, alwaysCleanCache = false }) {
102+
const maxCacheSizeGB = parseFloat(settings.tachyonCacheLimitGB);
103+
const fileHeaders = await fetch(url, { method: 'HEAD' });
104+
105+
const contentLength = parseInt(fileHeaders.headers.get('content-length') || '0', 10);
106+
// get the size of the download directory
107+
const downloadDirStats = await this.getDownloadedFilesStats(currentFileName);
108+
const totalSizeGB = (downloadDirStats.totalSize + contentLength) / (1024 * 1024 * 1024); // convert to GB
109+
const downloadedCacheSizeGB = downloadDirStats.totalSize / (1024 * 1024 * 1024); // convert to GB
110+
const shouldCleanCache = await this._shouldCleanCache({
111+
downloadedCacheSizeGB,
112+
alwaysCleanCache,
113+
maxCacheSizeGB,
114+
totalSizeGB,
115+
downloadDirStats
116+
});
117+
if (shouldCleanCache) {
118+
await this._freeCacheSpace(downloadDirStats);
119+
}
120+
return;
121+
}
122+
123+
async getDownloadedFilesStats(currentFile) {
124+
const files = await fs.readdir(this.downloadDir);
125+
// use just the zip files and progress files
126+
const packageFiles = files.filter(file => file.endsWith('.zip') || file.endsWith('.progress'));
127+
// exclude the current file from the list
128+
const filteredFiles = currentFile ? packageFiles.filter(file => file !== currentFile && file !== `${currentFile}.progress`) : packageFiles;
129+
// stat to get size, and mtime to sort by date modified
130+
const fileStats = await Promise.all(filteredFiles.map(async (file) => {
131+
const filePath = path.join(this.downloadDir, file);
132+
const stats = await fs.stat(filePath);
133+
return { filePath, size: stats.size, mtime: stats.mtime };
134+
}));
135+
// sort files by date modified
136+
const sortedFileStats = fileStats.sort((a, b) => a.mtime - b.mtime);
137+
138+
return {
139+
totalSize: sortedFileStats.reduce((sum, file) => sum + file.size, 0),
140+
fileStats
141+
};
142+
}
143+
144+
async _shouldCleanCache({ downloadedCacheSizeGB, alwaysCleanCache, maxCacheSizeGB, totalSizeGB, downloadDirStats }) {
145+
if (maxCacheSizeGB === 0 || alwaysCleanCache) {
146+
return true;
147+
}
148+
if (maxCacheSizeGB === -1) {
149+
return false;
150+
}
151+
152+
if (totalSizeGB < maxCacheSizeGB || downloadDirStats.fileStats.length === 0) {
153+
return false;
154+
}
155+
const question = {
156+
type: 'confirm',
157+
name: 'cleanCache',
158+
message: `Do you want to delete previously downloaded versions to free up ${downloadedCacheSizeGB.toFixed(1)} GB of space?`,
159+
default: true
160+
};
161+
const answer = await this.ui.prompt(question);
162+
if (!answer.cleanCache) {
163+
this.ui.write('Cache cleanup skipped. Remove files manually to free up space.');
164+
}
165+
return answer.cleanCache;
166+
}
167+
99168
async _attemptDownload(url, outputFileName, progressFilePath, finalFilePath, timeout) {
100169
const progressBar = this.ui.createProgressBar();
101170
let downloadedBytes = 0;
@@ -118,7 +187,6 @@ class DownloadManager {
118187
}
119188
await this._streamToFile(response.body, progressFilePath, progressBar, downloadedBytes, timeout, controller);
120189
fs.renameSync(progressFilePath, finalFilePath);
121-
this.ui.write(`Download completed: ${finalFilePath}`);
122190
return finalFilePath;
123191
} finally {
124192
if (progressBar) {
@@ -219,6 +287,14 @@ class DownloadManager {
219287
throw error;
220288
}
221289
}
290+
291+
async _freeCacheSpace(downloadDirStats) {
292+
const { fileStats } = downloadDirStats;
293+
for (const file of fileStats) {
294+
const { filePath } = file;
295+
await fs.remove(filePath);
296+
}
297+
}
222298
}
223299

224300
module.exports = DownloadManager;

src/lib/download-manager.test.js

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ describe('DownloadManager', () => {
1818

1919
afterEach(async () => {
2020
sinon.restore();
21+
nock.cleanAll();
2122
process.env = originalEnv;
2223
await fs.remove(path.join(PATH_TMP_DIR, '.particle/downloads'));
2324
});
@@ -90,6 +91,9 @@ describe('DownloadManager', () => {
9091
const fileContent = 'This is a test file.';
9192

9293
// Mock the HTTP response
94+
nock(url)
95+
.head(`/${outputFileName}`)
96+
.reply(200);
9397
nock(url)
9498
.get(`/${outputFileName}`)
9599
.reply(200, fileContent);
@@ -113,6 +117,9 @@ describe('DownloadManager', () => {
113117
fs.writeFileSync(tempFilePath, initialContent);
114118

115119
// Mock the HTTP response with a range
120+
nock(url)
121+
.head(`/${outputFileName}`)
122+
.reply(200);
116123
nock(url, { reqheaders: { Range: 'bytes=10-' } })
117124
.get(`/${outputFileName}`)
118125
.reply(206, remainingContent);
@@ -130,6 +137,9 @@ describe('DownloadManager', () => {
130137
let error;
131138

132139
// Mock the HTTP response to simulate a failure
140+
nock(url)
141+
.head(`/${outputFileName}`)
142+
.reply(200);
133143
nock(url)
134144
.get(`/${outputFileName}`)
135145
.reply(500);
@@ -155,6 +165,9 @@ describe('DownloadManager', () => {
155165
let error;
156166

157167
// Mock the HTTP response
168+
nock(url)
169+
.head(`/${outputFileName}`)
170+
.reply(200);
158171
nock(url)
159172
.get(`/${outputFileName}`)
160173
.reply(200, fileContent);
@@ -177,6 +190,9 @@ describe('DownloadManager', () => {
177190
const checksum = 'f29bc64a9d3732b4b9035125fdb3285f5b6455778edca72414671e0ca3b2e0de';
178191

179192
// Mock the HTTP response
193+
nock(url)
194+
.head(`/${outputFileName}`)
195+
.reply(200);
180196
nock(url)
181197
.get(`/${outputFileName}`)
182198
.reply(200, fileContent);

0 commit comments

Comments
 (0)