Skip to content

Commit

Permalink
feat(deploy): Init volumes (#735)
Browse files Browse the repository at this point in the history
Add support for seeding volumes on kubernetes

Co-authored-by: Michael Muesch <michael.muesch@architect.io>
Co-authored-by: TJ Higgins <tj@architect.io>
  • Loading branch information
3 people authored Oct 26, 2022
1 parent b548dec commit 711abe7
Show file tree
Hide file tree
Showing 11 changed files with 782 additions and 87 deletions.
549 changes: 497 additions & 52 deletions package-lock.json

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
"@sentry/node": "^7.1.1",
"@sentry/tracing": "^7.1.1",
"acorn-loose": "^8.2.1",
"adm-zip": "^0.5.9",
"ajv": "^8.6.3",
"ajv-errors": "^3.0.0",
"ajv-formats": "^2.1.1",
"archiver": "^5.3.1",
"axios": "^0.21.4",
"class-transformer": "0.4.0",
"class-validator": "^0.13.2",
Expand Down Expand Up @@ -48,6 +50,7 @@
"semver": "^7.3.7",
"simple-oauth2": "^4.2.0",
"string-argv": "^0.3.1",
"tar": "^6.1.11",
"tmp": "^0.2.1",
"tslib": "2.3.1",
"typescript-json": "^3.3.10",
Expand All @@ -66,6 +69,8 @@
"@semantic-release/github": "^8.0.4",
"@semantic-release/npm": "^9.0.1",
"@semantic-release/release-notes-generator": "^10.0.3",
"@types/adm-zip": "^0.5.0",
"@types/archiver": "^5.3.1",
"@types/chai": "4.2.15",
"@types/diff": "^5.0.1",
"@types/estraverse": "^5.1.1",
Expand All @@ -82,6 +87,7 @@
"@types/semver": "^7.3.8",
"@types/simple-oauth2": "^4.1.0",
"@types/sinon": "9.0.10",
"@types/tar": "^6.1.3",
"@types/tmp": "^0.2.0",
"@types/validator": "^13.7.1",
"@types/which": "^2.0.1",
Expand Down
4 changes: 4 additions & 0 deletions src/app-config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@ export default class AppConfig {
return this.account === '' ? null : this.account;
}

getPluginDirectory(): string {
return path.join(this.config_dir, '/plugins');
}

getConfigDir(): string {
return this.config_dir;
}
Expand Down
50 changes: 49 additions & 1 deletion src/commands/register.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CliUx, Flags, Interfaces } from '@oclif/core';
import archiver from 'archiver';
import axios from 'axios';
import chalk from 'chalk';
import { classToClass, classToPlain } from 'class-transformer';
Expand All @@ -8,7 +9,8 @@ import yaml from 'js-yaml';
import path from 'path';
import tmp from 'tmp';
import untildify from 'untildify';
import { ArchitectError, buildSpecFromPath, ComponentSlugUtils, Dictionary, dumpToYml, resourceRefToNodeRef, ResourceSlugUtils, ServiceNode, Slugs, TaskNode, validateInterpolation } from '../';
import { ArchitectError, buildSpecFromPath, ComponentSlugUtils, Dictionary, dumpToYml, resourceRefToNodeRef, ResourceSlugUtils, ServiceNode, Slugs, TaskNode, validateInterpolation, VolumeSpec } from '../';
import Account from '../architect/account/account.entity';
import AccountUtils from '../architect/account/account.utils';
import { EnvironmentUtils } from '../architect/environment/environment.utils';
import BaseCommand from '../base-command';
Expand All @@ -17,6 +19,9 @@ import { DockerComposeUtils } from '../common/docker-compose';
import DockerComposeTemplate from '../common/docker-compose/template';
import DockerBuildXUtils from '../common/docker/buildx.utils';
import { RequiresDocker, stripTagFromImage } from '../common/docker/helper';
import OrasPlugin from '../common/plugins/oras-plugin';
import PluginManager from '../common/plugins/plugin-manager';
import { transformVolumeSpec } from '../dependency-manager/spec/transform/common-transform';
import { IF_EXPRESSION_REGEX } from '../dependency-manager/spec/utils/interpolation';

tmp.setGracefulCleanup();
Expand Down Expand Up @@ -109,6 +114,38 @@ export default class ComponentRegister extends BaseCommand {
}
}

// eslint-disable-next-line max-params
private async uploadVolume(component_path: string, file_name: string, tag: string, volume: VolumeSpec, account: Account): Promise<VolumeSpec> {
const oras_plugin = await PluginManager.getPlugin<OrasPlugin>(this.app.config.getPluginDirectory(), OrasPlugin);

if (!volume.host_path) {
return classToClass(volume);
}

const tmp_dir = tmp.dirSync();
const base_folder = path.join(tmp_dir.name, `/${account.name}`);
fs.mkdirpSync(base_folder);
const registry_url = new URL(`/${account.name}/${file_name}:${tag}`, 'http://' + this.app.config.registry_host);

const updated_volume = classToClass(volume);
const component_folder = fs.lstatSync(component_path).isFile() ? path.dirname(component_path) : component_path;
const host_path = path.resolve(component_folder, untildify(updated_volume.host_path!));
updated_volume.host_path = registry_url.href.replace('http://', '');

const output_file_location = path.join(base_folder, `${file_name}.tar`);

const output = fs.createWriteStream(output_file_location);
const archive = archiver('tar', {
zlib: { level: 9 }, // Sets the compression level.
});
archive.directory(host_path, false).pipe(output);

await archive.finalize();
await oras_plugin.push(updated_volume.host_path, `${file_name}.tar`, base_folder);

return updated_volume;
}

private async registerComponent(config_path: string, tag: string) {
const { flags } = await this.parse(ComponentRegister);
console.time('Time');
Expand Down Expand Up @@ -268,6 +305,17 @@ export default class ComponentRegister extends BaseCommand {
}

const new_spec = classToClass(component_spec);

for (const [service_name, service] of Object.entries(new_spec.services || {})) {
if (IF_EXPRESSION_REGEX.test(service_name)) {
continue;
}
for (const [volume_name, volume] of Object.entries(service.volumes || {})) {
const volume_config = transformVolumeSpec(volume_name, volume);
(service?.volumes as Dictionary<VolumeSpec>)[volume_name] = await this.uploadVolume(config_path, `${component_name}.services.${service_name}.volumes.${volume_name}`, tag, volume_config, selected_account);
}
}

for (const [service_name, service] of Object.entries(new_spec.services || {})) {
if (IF_EXPRESSION_REGEX.test(service_name)) {
continue;
Expand Down
72 changes: 72 additions & 0 deletions src/common/plugins/oras-plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import execa from 'execa';
import path from 'path';
import { ArchitectPlugin, PluginArchitecture, PluginBinary, PluginBundleType, PluginOptions, PluginPlatform } from './plugin-types';

export default class OrasPlugin implements ArchitectPlugin {
private plugin_directory = '';
private binary?: PluginBinary;

version = '0.15.0';
name: string = OrasPlugin.name;
binaries: PluginBinary[] = [
{
platform: PluginPlatform.WINDOWS,
architecture: PluginArchitecture.AMD64,
bundle_type: PluginBundleType.ZIP,
executable_path: 'oras.exe',
url: 'https://github.com/oras-project/oras/releases/download/v0.15.0/oras_0.15.0_windows_amd64.zip',
sha256: 'f8a43b8f3b1caf0a3c3a2204a7eab597d3a9241b1e0673c4d8a23ad439cd652a',
},
{
platform: PluginPlatform.LINUX,
architecture: PluginArchitecture.AMD64,
bundle_type: PluginBundleType.TAR_GZ,
executable_path: 'oras',
url: 'https://github.com/oras-project/oras/releases/download/v0.15.0/oras_0.15.0_linux_amd64.tar.gz',
sha256: '529c9d567f212093bc01c508b71b922fc6c6cbc74767d3b2eb7f9f79d534e718',
},
{
platform: PluginPlatform.DARWIN,
architecture: PluginArchitecture.AMD64,
bundle_type: PluginBundleType.TAR_GZ,
executable_path: 'oras',
url: 'https://github.com/oras-project/oras/releases/download/v0.15.0/oras_0.15.0_darwin_amd64.tar.gz',
sha256: '0724f64f38f9389497da71795751e5f1b48fd4fc43aa752241b020c0772d5cd8',
},
{
platform: PluginPlatform.DARWIN,
architecture: PluginArchitecture.ARM64,
bundle_type: PluginBundleType.TAR_GZ,
executable_path: 'oras',
url: 'https://github.com/oras-project/oras/releases/download/v0.15.0/oras_0.15.0_darwin_arm64.tar.gz',
sha256: '7889cee33ba2147678642cbd909be81ec9996f62c57c53b417f7c21c8d282334',
},
];

async setup(pluginDirectory: string, binary: PluginBinary): Promise<void> {
this.binary = binary;
this.plugin_directory = pluginDirectory;
}

async exec(args: string[], opts: PluginOptions): Promise<execa.ExecaChildProcess<string> | undefined> {
if (process.env.TEST === '1') {
return undefined;
}

const cmd = execa(path.join(this.plugin_directory, `/${this.binary?.executable_path}`), args, opts.execa_options);
if (opts.stdout) {
cmd.stdout?.pipe(process.stdout);
cmd.stderr?.pipe(process.stderr);
}
return cmd;
}

async push(url: string, tarFile: string, cwd: string): Promise<void> {
await this.exec(['push', url, tarFile], {
stdout: true,
execa_options: {
cwd: cwd,
},
});
}
}
67 changes: 67 additions & 0 deletions src/common/plugins/plugin-manager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import * as fs from 'fs-extra';
import path from 'path';
import { Dictionary } from '../..';
import { ArchitectPlugin, PluginArchitecture, PluginBundleType, PluginPlatform } from './plugin-types';
import PluginUtils from './plugin-utils';

export default class PluginManager {
private static readonly plugins: Dictionary<ArchitectPlugin> = {};

private static readonly ARCHITECTURE_MAP: Dictionary<PluginArchitecture> = {
'x64': PluginArchitecture.AMD64,
'arm64': PluginArchitecture.ARM64,
};

private static readonly OPERATIN_SYSTEM_MAP: Dictionary<PluginPlatform> = {
'win32': PluginPlatform.WINDOWS,
'darwin': PluginPlatform.DARWIN,
'linux': PluginPlatform.LINUX,
};

private static getPlatform(): PluginPlatform {
return this.OPERATIN_SYSTEM_MAP[process.platform];
}

private static getArchitecture(): PluginArchitecture {
return this.ARCHITECTURE_MAP[process.arch];
}

private static async removeOldPluginVersions(pluginDirectory: string, version: string) {
if (!(await fs.pathExists(pluginDirectory))) {
return;
}
const downloaded_versions = await fs.readdir(pluginDirectory);
for (const downloaded_version of downloaded_versions) {
if (downloaded_version === version) {
continue;
}
await fs.remove(path.join(pluginDirectory, downloaded_version));
}
}

static async getPlugin<T extends ArchitectPlugin>(pluginDirectory: string, ctor: { new(): T; }): Promise<T> {
if (this.plugins[ctor.name]) {
return this.plugins[ctor.name] as T;
}
const plugin = new ctor();
const current_plugin_directory = path.join(pluginDirectory, `/${plugin.name}`);
const version_path = path.join(current_plugin_directory, `/${plugin.version}`);

await this.removeOldPluginVersions(current_plugin_directory, plugin.version);
await fs.mkdirp(version_path);

const binary = PluginUtils.getBinary(plugin.binaries, this.getPlatform(), this.getArchitecture());
const downloaded_file_path = path.join(version_path, `/${plugin.name}.${binary.bundle_type === PluginBundleType.ZIP ? 'zip' : 'tar.gz'}`);

if (!(await fs.pathExists(path.join(version_path, `/${binary.executable_path}`)))) {
await PluginUtils.downloadFile(binary.url, downloaded_file_path, binary.sha256);
await PluginUtils.extractFile(downloaded_file_path, version_path, binary.bundle_type);
await fs.remove(downloaded_file_path);
}

await plugin.setup(version_path, PluginUtils.getBinary(plugin.binaries, this.getPlatform(), this.getArchitecture()));

this.plugins[ctor.name] = plugin;
return plugin as T;
}
}
35 changes: 35 additions & 0 deletions src/common/plugins/plugin-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import execa, { Options } from 'execa';

export enum PluginArchitecture {
AMD64, ARM64
}

export enum PluginPlatform {
LINUX, DARWIN, WINDOWS
}

export enum PluginBundleType {
ZIP, TAR_GZ
}

export interface PluginOptions {
stdout: boolean;
execa_options?: Options<string>;
}

export interface PluginBinary {
url: string;
architecture: PluginArchitecture;
platform: PluginPlatform;
sha256: string;
bundle_type: PluginBundleType;
executable_path: string;
}

export interface ArchitectPlugin {
version: string;
name: string;
binaries: PluginBinary[];
setup(pluginDirectory: string, binary: PluginBinary): Promise<void>;
exec(args: string[], opts: PluginOptions): Promise<execa.ExecaChildProcess<string> | undefined>;
}
49 changes: 49 additions & 0 deletions src/common/plugins/plugin-utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import AdmZip from 'adm-zip';
import axios from 'axios';
import * as crypto from 'crypto';
import * as fs from 'fs-extra';
import { createWriteStream } from 'fs-extra';
import { finished } from 'stream';
import * as tar from 'tar';
import { promisify } from 'util';
import { PluginArchitecture, PluginBinary, PluginBundleType, PluginPlatform } from './plugin-types';

export default class PluginUtils {
static async downloadFile(url: string, location: string, sha256: string): Promise<void> {
const writer = createWriteStream(location);
return axios({
method: 'get',
url: url,
responseType: 'stream',
}).then(async response => {
response.data.pipe(writer);
await promisify(finished)(writer);
const fileBuffer = fs.readFileSync(location);
const hashSum = crypto.createHash('sha256');
hashSum.update(fileBuffer);
const hex = hashSum.digest('hex');
if (hex !== sha256) {
throw new Error(`Unable to verify ${url}. Please contact Architect support for help.`);
}
});
}

static async extractFile(file: string, location: string, bundleType: PluginBundleType): Promise<void> {
if (bundleType === PluginBundleType.TAR_GZ) {
await tar.extract({ file, C: location });
} else if (bundleType === PluginBundleType.ZIP) {
const zip = new AdmZip(file);
zip.extractAllTo(location);
}
}

static getBinary(binaries: PluginBinary[], platform: PluginPlatform, architecture: PluginArchitecture): PluginBinary {
for (const binary of binaries) {
if (binary.platform === platform && binary.architecture === architecture) {
return binary;
}
}
throw new Error(`Unable to find proper binary for ${PluginPlatform[platform]}:${PluginArchitecture[architecture]}. Please contact Architect support for help.`);
}
}

18 changes: 0 additions & 18 deletions src/dependency-manager/spec/utils/spec-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -227,24 +227,6 @@ export const validateOrRejectSpec = (parsed_yml: ParsedYaml, metadata?: Componen
instance_date: new Date(),
};

if (!metadata?.interpolated) {
// Don't allow host_path outside of debug block
for (const [service_name, service_spec] of Object.entries(component_spec.services || {})) {
for (const [volume_name, volume_spec] of Object.entries(service_spec.volumes || {})) {
if (volume_spec instanceof Object && volume_spec.host_path) {
const error = new ValidationError({
component: component_spec.name,
path: `services.${service_name}.volumes.${volume_name}.host_path`,
message: `services.${service_name}.volumes.${volume_name}.host_path cannot be defined outside of a debug block. https://docs.architect.io/components/local-configuration/#when-is-the-debug-block-used`,
value: volume_spec.host_path,
invalid_key: true,
});
errors.push(error);
}
}
}
}

for (const [service_name, service_spec] of Object.entries(component_spec.services || {})) {
if (service_spec.deploy && service_spec.deploy.kubernetes.deployment) {
// Only works if transpileOnly=false in ./bin/dev
Expand Down
Loading

0 comments on commit 711abe7

Please sign in to comment.