UNPKG

particle-cli

Version:

Simple Node commandline application for working with your Particle devices and using the Particle Cloud

543 lines (467 loc) 18.6 kB
const fs = require('fs-extra'); const path = require('path'); const os = require('os'); const yaml = require('yaml'); const fetch = require('node-fetch'); const execa = require('execa'); const { v4: uuidv4 } = require('uuid'); const CLICommandBase = require('./base'); const settings = require('../../settings'); const ParticleApi = require('./api'); const { UnauthorizedError } = require('./api'); const Table = require('cli-table'); const { platformForId } = require('../lib/platform'); const _ = require('lodash'); const DOCKER_CONFIG_URL = 'https://linux-dist.particle.io/alpha-assets/2ea71ce0afce170affb38d162a1e3460.json'; const PARTICLE_ENV_FILE = '.particle_env.yaml'; module.exports = class AppCommands extends CLICommandBase { constructor() { super(); const auth = settings.access_token; this.api = new ParticleApi(settings.apiUrl, { accessToken: auth }); } async run({ blueprintDir = '.' }) { const instance = Math.random().toString(36).substring(2, 8); const appName = await this._getAppName(blueprintDir); const appInstance = `${appName}_${instance}`; this.ui.write(`Running application ${appInstance}...${os.EOL}`); const composeDir = path.join(blueprintDir, appName); if (!await fs.pathExists(composeDir)) { throw new Error(`Application directory ${composeDir} not found.`); } const dockerConfigDir = await this._getDockerConfig(); await this._configureDockerContext(dockerConfigDir); let dockerComposePath = path.join(composeDir, 'docker-compose.yaml'); if (!await fs.pathExists(dockerComposePath)) { dockerComposePath = path.join(composeDir, 'docker-compose.yml'); if (!await fs.pathExists(dockerComposePath)) { throw new Error(`docker-compose.yaml not found in ${composeDir}.`); } } // provide access to the X server to Docker try { await execa('xhost', ['+local:root']); await execa('xhost', ['+local:particle']); await execa('xhost', ['+SI:localuser:root']); } catch { // ignore errors on non-Linux systems } try { // Executing docker-compose up await execa('docker', ['--config', dockerConfigDir, 'compose', '-p', appInstance, 'up', '--build'], { stdio: 'inherit', cwd: composeDir }); } catch (error) { throw new Error(`Failed to run Docker Compose: ${error.message}`); } } async push({ deviceId, instance, blueprintDir = '.' }) { try { const doc = await this._loadFromEnv(blueprintDir); deviceId ||= doc.get('deviceId'); const device = await this._getDevice(deviceId); deviceId = device.id; instance ||= doc.get('instance') || Math.random().toString(36).substring(2, 8); await this.validateApplicationsHaveBeenMigrated(device); const appName = await this._getAppName(blueprintDir); const appInstance = `${appName}_${instance}`; doc.set('deviceId', deviceId); doc.set('instance', instance); await this._saveToEnv(doc, blueprintDir); this.ui.write(`Pushing application ${appInstance} to device ${deviceId}...${os.EOL}`); this.ui.write('Building application...'); const composeDir = path.join(blueprintDir, appName); const uuid = uuidv4(); const dockerConfigDir = await this._getDockerConfig(); await this._configureDockerContext(dockerConfigDir); // read ${appName}/docker-compose.yaml, parse it and look in the services section for containers with a build key // For each container with a build key, build the container and tag it with a uuid, and push it to the registry // Then remove the build key from the docker-compose.yaml and replace it by the image key with the serviceTag const dockerCompose = await this._getDockerCompose(composeDir); const services = dockerCompose.get('services'); if (services) { for (const { key: { value: service }, value: serviceConfig } of services.items) { const buildDir = serviceConfig.get('build'); if (buildDir) { const serviceTag = `particleapp/${service}:${uuid}`; await this._buildContainer(dockerConfigDir, path.join(composeDir, buildDir), serviceTag); await this._pushContainer(dockerConfigDir, serviceTag); this._updateDockerCompose(serviceConfig, serviceTag); } } } this.ui.write(`${os.EOL}Successfully built ${appInstance}${os.EOL}`); await this._pushApp(device, appInstance, dockerCompose.toString()); this.ui.write(`Successfully pushed ${appInstance} to device ${deviceId}${os.EOL}`); } catch (error) { if (error instanceof UnauthorizedError) { throw new Error('You must be logged in to push an application to a device.'); } throw error; } } async _getAppName(blueprintDir) { const blueprintPath = path.resolve(blueprintDir, 'blueprint.yaml'); if (!await fs.pathExists(blueprintPath)) { throw new Error('blueprint.yaml not found. Run this command inside a directory with a project blueprint.'); } let doc; try { const blueprintContent = await fs.readFile(blueprintPath, 'utf8'); doc = yaml.parseDocument(blueprintContent); } catch (error) { throw new Error(`Failed to parse blueprint.yaml: ${error.message}`); } const appName = doc.get('containers'); if (!appName || typeof appName !== 'string') { throw new Error('Invalid blueprint configuration: containers directory is missing.'); } return appName; } async _getDockerConfig() { const particleDir = settings.ensureFolder(); const dockerConfigDir = path.join(particleDir, 'docker'); await fs.ensureDir(dockerConfigDir); try { const response = await fetch(DOCKER_CONFIG_URL); const data = await response.buffer(); await fs.writeFile(path.join(dockerConfigDir, 'config.json'), data); } catch (error) { throw new Error(`Failed to fetch docker config: ${error.message}`); } return dockerConfigDir; } async _getDockerCompose(composeDir) { let dockerComposePath = path.join(composeDir, 'docker-compose.yaml'); try { if (!await fs.exists(dockerComposePath)) { dockerComposePath = path.join(composeDir, 'docker-compose.yml'); } const composeData = await fs.readFile(dockerComposePath, 'utf8'); return yaml.parseDocument(composeData); } catch (error) { throw new Error(`Failed to read ${dockerComposePath}: ${error.message}`); } } async _checkDockerVersion() { const { stdout: dockerVersion } = await execa('docker', ['--version']); const versionMatch = dockerVersion.match(/Docker version (\d+\.\d+\.\d+)/); if (!versionMatch) { throw new Error('Docker version 27 or later is required.'); } else if (parseInt(versionMatch[1].split('.')[0]) < 27) { throw new Error(`Docker version 27 or later is required. Version ${versionMatch[1]} detected.`); } } async _copySystemDockerContext(dockerConfigDir) { // Get the name of the current system docker context const dockerContext = (await execa('docker', ['context', 'show'])).stdout; // Export the system context and import it as 'particle' context in the docker config directory const exportContext = execa('docker', ['context', 'export', dockerContext, '-']); const importContext = execa('docker', ['--config', dockerConfigDir, 'context', 'import', 'particle', '-']); exportContext.stdout.pipe(importContext.stdin); await importContext; } async _configureDockerContext(dockerConfigDir) { try { // Check if the 'particle' context exists in the docker config directory await this._checkDockerVersion(); const particleDockerContextsList = (await execa('docker', ['--config', dockerConfigDir, 'context', 'ls', '--format', '{{.Name}}'])).stdout; if (!particleDockerContextsList.includes('particle')) { await this._copySystemDockerContext(dockerConfigDir); } else { // We have a context, does it need to be updated? const systemDockerContextInspect = (await execa('docker', ['context', 'inspect'])); const particleDockerContextInspect = (await execa('docker', ['--config', dockerConfigDir, 'context', 'inspect', 'particle'])); const systemDockerContext = JSON.parse(systemDockerContextInspect.stdout); const particleDockerContext = JSON.parse(particleDockerContextInspect.stdout); const extractEndpoints = (contexts) => contexts.map(ctx => ctx.Endpoints || {}); if (!_.isEqual(extractEndpoints(systemDockerContext), extractEndpoints(particleDockerContext))) { // Update the context await execa('docker', ['--config', dockerConfigDir, 'context', 'rm', '-f', 'particle']); await this._copySystemDockerContext(dockerConfigDir); } } const currentContext = (await execa('docker', ['--config', dockerConfigDir, 'context', 'show'])).stdout; if (currentContext !== 'particle') { await execa('docker', ['--config', dockerConfigDir, 'context', 'use', 'particle']); } } catch (error) { throw new Error(`Failed to configure docker. Make sure Docker is installed and running on your machine: ${error.message}`); } } async _buildContainer(dockerConfigDir, buildDir, serviceTag) { try { await execa('docker', ['--config', dockerConfigDir, 'build', buildDir, '--platform', 'linux/arm64', '--tag', serviceTag], { stdio: 'inherit' }); } catch (error) { throw new Error(`Failed to build container ${serviceTag}. See the Docker output for details: ${error.message}`); } } async _pushContainer(dockerConfigDir, serviceTag) { try { await execa('docker', ['--config', dockerConfigDir, 'push', serviceTag], { stdio: 'inherit' }); } catch (error) { throw new Error(`Failed to push the container ${serviceTag}. See the Docker output for details: ${error.message}`); } } _updateDockerCompose(serviceConfig, serviceTag) { serviceConfig.delete('build'); serviceConfig.set('image', serviceTag); } async _pushApp(device, name, composeFile) { try { // Use PATCH method to update the device document await this.api.patchDocument({ productId: device.product_id, deviceId: device.id, docName: 'system', patchOps: { action: 'upsert', path: ['features', 'applications', 'desiredProperties', 'apps'], key: 'name', value: { name, composeFile } } }); } catch (error) { if (error.statusCode === 404) { throw new Error(`Connect ${device.id} to the cloud before pushing an application. Run particle login and try again.`); } console.error('Error pushing application to the device:', error); throw error; } } async list({ deviceId, blueprintDir = '.' }) { const doc = await this._loadFromEnv(blueprintDir); deviceId ||= doc.get('deviceId'); const device = await this._getDevice(deviceId); deviceId = device.id; doc.set('deviceId', deviceId); await this._saveToEnv(doc, blueprintDir); try { const deviceDoc = await this.validateApplicationsHaveBeenMigrated(device); const desiredApps = _.get(deviceDoc, 'features.applications.desiredProperties.apps'); if (!desiredApps || desiredApps.length === 0) { this.ui.write(`No applications desired for device ${deviceId}.${os.EOL}`); } else { // exists and is an array with length this.ui.write(`Applications desired for device ${deviceId}:`); for (const app of desiredApps) { this.ui.write(app.name); } this.ui.write(os.EOL); } // We can assume at this point that apps is an array since we migrate both at the same time on device and we return above const apps = _.get(deviceDoc, 'features.applications.properties.apps'); if (!apps || apps.length === 0) { return this.ui.write(`No applications running on device ${deviceId}.${os.EOL}`); } this.ui.write(`Applications running on device ${deviceId}:${os.EOL}`); for (const app of apps) { this.ui.write(`App name: ${app.name}`); // Create a table with headers const cols = (process.stdout.columns || 80) - 35; const table = new Table({ head: ['Container', 'Details'], colWidths: [30, cols], style: { head: ['white'] } }); if (app.containers) { for (const { name, ...details } of app.containers) { table.push([name, JSON.stringify(details, null, 2)]); } } else { table.push(['No containers for app', '']); } this.ui.write(table.toString() + os.EOL); } } catch (error) { if (error instanceof UnauthorizedError) { throw new Error('You must be logged in to list applications. Run particle login and try again.'); } if (error.statusCode === 404) { throw new Error(`${device.id} has no cloud application.`); } console.error('Error getting application from the device:', error); throw error; } } async remove({ deviceId, appInstance, blueprintDir = '.' }) { if (!appInstance) { throw new Error('Application instance is required.'); } const doc = await this._loadFromEnv(blueprintDir); deviceId ||= doc.get('deviceId'); const device = await this._getDevice(deviceId); deviceId = device.id; doc.set('deviceId', deviceId); await this._saveToEnv(doc, blueprintDir); try { const deviceDoc = await this.validateApplicationsHaveBeenMigrated(device); const apps = _.get(deviceDoc, 'features.applications.desiredProperties.apps'); const foundApp = apps?.find((app) => app.name === appInstance); if (foundApp) { await this.api.patchDocument({ productId: device.product_id, deviceId: device.id, docName: 'system', patchOps: { action: 'remove', path: ['features', 'applications', 'desiredProperties', 'apps'], predicate: { name: appInstance } } }); this.ui.write(`Successfully removed ${appInstance} from device ${deviceId}.${os.EOL}`); } else { this.ui.write(`Application ${appInstance} not found on device ${deviceId}.${os.EOL}`); } } catch (error) { if (error instanceof UnauthorizedError) { throw new Error('You must be logged in to remove an application. Run particle login and try again.'); } if (error.statusCode === 404) { throw new Error(`${device.id} has no cloud application.`); } console.error(`Error removing application ${appInstance} from device ${deviceId}:`, error); throw error; } } async _loadFromEnv(blueprintDir) { try { const envPath = path.join(blueprintDir, PARTICLE_ENV_FILE); const envContent = await fs.readFile(envPath, 'utf8'); return yaml.parseDocument(envContent); } catch { return new yaml.Document(); } } async _getDevice(deviceId) { if (deviceId) { return this._getDeviceAttributes(deviceId); } else { this.ui.write('Select a device for this operation from one of your existing products.\nThis device will be remembered for future operations.'); return this._selectDevice(); } } async _getDeviceAttributes(deviceId) { try { return await this.api.getDeviceAttributes(deviceId); } catch (error) { throw new Error(`You do not have access to the ${deviceId}: ${error.message}`); } } async _loadDeviceFromEnv(blueprintDir) { try { const envPath = path.join(blueprintDir, PARTICLE_ENV_FILE); const envContent = await fs.readFile(envPath, 'utf8'); let doc = yaml.parseDocument(envContent); return await this._getDeviceAttributes(doc.get('device_id')); } catch { return null; } } async _saveToEnv(doc, blueprintDir) { // load existing env file and parse as yaml doc const envPath = path.join(blueprintDir, PARTICLE_ENV_FILE); try { await fs.writeFile(envPath, doc.toString()); } catch (error) { this.ui.write(`Warning: Failed to save ${envPath}: ${error.message}`); } } async _selectDevice() { const { orgSlug } = await this._getOrg(); let productId = await this._getProduct(orgSlug); if (!productId) { throw new Error('You do not have any Linux/Tachyon products available. Create a new product in the Console and try again.'); } let device = await this._getDeviceProduct(productId); if (!device) { throw new Error('You do not have any Linux/Tachyon devices in this product. Setup a device and try again.'); } return device; } async _getOrg() { const orgsResp = await this.api.getOrgs(); const orgs = orgsResp.organizations; const orgName = orgs.length ? await this._promptForOrg([...orgs.map(org => org.name), 'Sandbox']) : 'Sandbox'; const orgSlug = orgName !== 'Sandbox' ? orgs.find(org => org.name === orgName).slug : null; return { orgName, orgSlug }; } async _promptForOrg(choices) { const question = [ { type: 'list', name: 'org', message: 'Select an organization:', choices, }, ]; const { org } = await this.ui.prompt(question); return org; } async _getProduct(orgSlug) { const productsResp = await this.api.getProducts(orgSlug); let products = productsResp?.products || []; products = products.filter((product) => platformForId(product.platform_id)?.features?.includes('linux')); if (!products.length) { return null; // No Linux/Tachyon products available } const selectedProductName = await this._promptForProduct(products.map(product => product.name)); return products.find(p => p.name === selectedProductName)?.id; } async _promptForProduct(choices) { const question = [ { type: 'list', name: 'product', message: 'Select a product:', choices, }, ]; const { product } = await this.ui.prompt(question); return product; } async _getDeviceProduct(productId) { const devicesResp = await this.api.listDevices({ product: productId }); const devices = devicesResp?.devices || []; if (!devices.length) { return null; // No devices in product } const choices = devices.map(device => { const displayName = device.name ? `${device.name} (${device.id})` : `${device.id}`; return { name: displayName, value: device }; }); return this._promptForDevice(choices); } async _promptForDevice(choices) { const question = [ { type: 'list', name: 'device', message: 'Select a device:', choices, }, ]; const { device } = await this.ui.prompt(question); return device; } /** * Gets the device doc and validates that applications have been migrated to array format, throwing if not. * If all is good, returns the device doc to reduce api calls * @param {{ id: string, product_id: number }} device * @returns object */ async validateApplicationsHaveBeenMigrated(device) { const { data: doc } = await this.api.getDocument({ deviceId: device.id, productId: device.product_id, docName: 'system' }); const apps = _.get(doc, 'features.applications.desiredProperties.apps'); if (apps && !Array.isArray(apps)) { throw new Error('There has been an update to applications data format. Please update your particle-linux version to the latest.'); } return doc; } };