packages/@alicloud/ros-cdk-cxapi/lib/cloud-assembly.ts (213 lines of code) (raw):
import * as cxschema from "@alicloud/ros-cdk-assembly-schema";
import * as fs from "fs";
import * as os from "os";
import * as path from "path";
import { RosStackArtifact } from "./artifacts/ros-stack-artifact";
import { NestedCloudAssemblyArtifact } from "./artifacts/nested-cloud-assembly-artifact";
import { TreeCloudArtifact } from "./artifacts/tree-cloud-artifact";
import { CloudArtifact } from "./cloud-artifact";
import { topologicalSort } from "./toposort";
/**
* The name of the root manifest file of the assembly.
*/
const MANIFEST_FILE = "manifest.json";
/**
* Represents a deployable cloud application.
*/
export class CloudAssembly {
/**
* The root directory of the cloud assembly.
*/
public readonly directory: string;
/**
* The schema version of the assembly manifest.
*/
public readonly version: string;
/**
* All artifacts included in this assembly.
*/
public readonly artifacts: CloudArtifact[];
/**
* Runtime information such as module versions used to synthesize this assembly.
*/
public readonly runtime: cxschema.RuntimeInfo;
/**
* The raw assembly manifest.
*/
public readonly manifest: cxschema.AssemblyManifest;
/**
* Reads a cloud assembly from the specified directory.
* @param directory The root directory of the assembly.
*/
constructor(directory: string) {
this.directory = directory;
this.manifest = cxschema.Manifest.loadAssemblyManifest(path.join(directory, MANIFEST_FILE));
this.version = this.manifest.version;
this.artifacts = this.renderArtifacts();
this.runtime = this.manifest.runtime || { libraries: {} };
// force validation of deps by accessing 'depends' on all artifacts
this.validateDeps();
}
/**
* Attempts to find an artifact with a specific identity.
* Returns A 'CloudArtifact' object or 'undefined' if the artifact does not exist in this assembly.
* Param id The artifact ID
*/
public tryGetArtifact(id: string): CloudArtifact | undefined {
return this.artifacts.find((a) => a.id === id);
}
/**
* Returns a ROS stack artifact from this assembly.
*
* Will only search the current assembly.
*
* Param stackName the name of the ROS stack.
* Throws if there is no stack artifact by that name
* Throws if there is more than one stack with the same stack name. You can
* use 'getStackArtifact - stack.artifactId' instead.
* Returns a 'RosStackArtifact' object.
*/
public getStackByName(stackName: string): RosStackArtifact {
const artifacts = this.artifacts.filter(
(a) => a instanceof RosStackArtifact && a.stackName === stackName
);
if (!artifacts || artifacts.length === 0) {
throw new Error(`Unable to find stack with stack name "${stackName}"`);
}
if (artifacts.length > 1) {
throw new Error(
`There are multiple stacks with the stack name "${stackName}" (${artifacts
.map((a) => a.id)
.join(",")}). Use "getStackArtifact(id)" instead`
);
}
return artifacts[0] as RosStackArtifact;
}
/**
* Returns a ROS stack artifact by name from this assembly.
* Deprecated renamed to 'getStackByName' (or 'getStackArtifact(id)')
*/
public getStack(stackName: string) {
return this.getStackByName(stackName);
}
/**
* Returns a ROS stack artifact from this assembly.
*
* Param artifactId the artifact id of the stack (can be obtained through 'stack.artifactId').
* Throws if there is no stack artifact with that id
* Returns a 'RosStackArtifact' object.
*/
public getStackArtifact(artifactId: string): RosStackArtifact {
const artifact = this.tryGetArtifact(artifactId);
if (!artifact) {
throw new Error(`Unable to find artifact with id "${artifactId}"`);
}
if (!(artifact instanceof RosStackArtifact)) {
throw new Error(`Artifact ${artifactId} is not a ROS stack`);
}
return artifact;
}
/**
* Returns a nested assembly artifact.
*
* @param artifactId The artifact ID of the nested assembly
*/
public getNestedAssemblyArtifact(
artifactId: string
): NestedCloudAssemblyArtifact {
const artifact = this.tryGetArtifact(artifactId);
if (!artifact) {
throw new Error(`Unable to find artifact with id "${artifactId}"`);
}
if (!(artifact instanceof NestedCloudAssemblyArtifact)) {
throw new Error(
`Found artifact '${artifactId}' but it's not a nested cloud assembly`
);
}
return artifact;
}
/**
* Returns a nested assembly.
*
* @param artifactId The artifact ID of the nested assembly
*/
public getNestedAssembly(artifactId: string): CloudAssembly {
return this.getNestedAssemblyArtifact(artifactId).nestedAssembly;
}
/**
* Returns the tree metadata artifact from this assembly.
* Throws if there is no metadata artifact by that name
* Returns a 'TreeCloudArtifact' object if there is one defined in the manifest, 'undefined' otherwise.
*/
public tree(): TreeCloudArtifact | undefined {
const trees = this.artifacts.filter(
(a) => a.manifest.type === cxschema.ArtifactType.CDK_TREE
);
if (trees.length === 0) {
return undefined;
} else if (trees.length > 1) {
throw new Error(
`Multiple artifacts of type ${cxschema.ArtifactType.CDK_TREE} found in manifest`
);
}
const tree = trees[0];
if (!(tree instanceof TreeCloudArtifact)) {
throw new Error('"Tree" artifact is not of expected type');
}
return tree;
}
/**
* @returns all the ROS stack artifacts that are included in this assembly.
*/
public get stacks(): RosStackArtifact[] {
const result = new Array<RosStackArtifact>();
for (const a of this.artifacts) {
if (a instanceof RosStackArtifact) {
result.push(a);
}
}
return result;
}
private validateDeps() {
for (const artifact of this.artifacts) {
ignore(artifact.dependencies);
}
}
private renderArtifacts() {
const result = new Array<CloudArtifact>();
for (const [name, artifact] of Object.entries(
this.manifest.artifacts || {}
)) {
const cloudartifact = CloudArtifact.fromManifest(this, name, artifact);
if (cloudartifact) {
result.push(cloudartifact);
}
}
return topologicalSort(
result,
(x) => x.id,
(x) => x._dependencyIDs
);
}
}
/**
* Construction properties for CloudAssemblyBuilder
*/
export interface CloudAssemblyBuilderProps {
/**
* Use the given asset output directory
*
* @default - Same as the manifest outdir
*/
readonly assetOutdir?: string;
/**
* If this builder is for a nested assembly, the parent assembly builder
*
* @default - This is a root assembly
*/
readonly parentBuilder?: CloudAssemblyBuilder;
}
/**
* Can be used to build a cloud assembly.
*/
export class CloudAssemblyBuilder {
/**
* The root directory of the resulting cloud assembly.
*/
public readonly outdir: string;
/**
* The directory where assets of this Cloud Assembly should be stored
*/
public readonly assetOutdir: string;
private readonly artifacts: { [id: string]: cxschema.ArtifactManifest } = {};
private readonly missing = new Array<cxschema.MissingContext>();
/**
* Initializes a cloud assembly builder.
* @param outdir The output directory, uses temporary directory if undefined
*/
constructor(outdir?: string, props: CloudAssemblyBuilderProps = {}) {
this.outdir = determineOutputDirectory(outdir);
this.assetOutdir = props.assetOutdir ?? this.outdir;
// we leverage the fact that outdir is long-lived to avoid staging assets into it
// that were already staged (copying can be expensive). this is achieved by the fact
// that assets use a source hash as their name. other artifacts, and the manifest itself,
// will overwrite existing files as needed.
if (fs.existsSync(this.outdir)) {
if (!fs.statSync(this.outdir).isDirectory()) {
throw new Error(`${this.outdir} must be a directory`);
}
} else {
fs.mkdirSync(this.outdir, { recursive: true });
}
}
/**
* Adds an artifact into the cloud assembly.
* @param id The ID of the artifact.
* @param manifest The artifact manifest
*/
public addArtifact(id: string, manifest: cxschema.ArtifactManifest) {
this.artifacts[id] = filterUndefined(manifest);
}
/**
* Reports that some context is missing in order for this cloud assembly to be fully synthesized.
* @param missing Missing context information.
*/
public addMissing(missing: cxschema.MissingContext) {
if (this.missing.every((m) => m.key !== missing.key)) {
this.missing.push(missing);
}
}
/**
* Finalizes the cloud assembly into the output directory returns a
* 'CloudAssembly' object that can be used to inspect the assembly.
* @param options
*/
public buildAssembly(options: AssemblyBuildOptions = {}): CloudAssembly {
// explicitly initializing this type will help us detect
// breaking changes. (For example adding a required property will break compilation).
let manifest: cxschema.AssemblyManifest = {
version: cxschema.Manifest.version(),
artifacts: this.artifacts,
runtime: options.runtimeInfo,
missing: this.missing.length > 0 ? this.missing : undefined,
};
// now we can filter
manifest = filterUndefined(manifest);
const manifestFilePath = path.join(this.outdir, MANIFEST_FILE);
cxschema.Manifest.save(manifest, manifestFilePath);
// "backwards compatibility": in order for the old CLI to tell the user they
// need a new version, we'll emit the legacy manifest with only "version".
// this will result in an error "CDK Toolkit >= CLOUD_ASSEMBLY_VERSION is required in order to interact with this program."
fs.writeFileSync(
path.join(this.outdir, "cdk.out"),
JSON.stringify({ version: manifest.version })
);
return new CloudAssembly(this.outdir);
}
/**
* Creates a nested cloud assembly
*/
public createNestedAssembly(artifactId: string, displayName: string) {
const directoryName = artifactId;
const innerAsmDir = path.join(this.outdir, directoryName);
this.addArtifact(artifactId, {
type: cxschema.ArtifactType.NESTED_CLOUD_ASSEMBLY,
properties: {
directoryName,
displayName,
} as cxschema.NestedCloudAssemblyProperties,
});
return new CloudAssemblyBuilder(innerAsmDir, {
// Reuse the same asset output directory as the current Casm builder
assetOutdir: this.assetOutdir,
parentBuilder: this,
});
}
}
/**
* Backwards compatibility for when 'RuntimeInfo'
* was defined here. This is necessary because its used as an input in the stable
* @alicloud/ros-cdk-core library.
*
* @deprecated moved to package 'ros-assembly-schema'
* @see core.ConstructNode.synth
*/
export interface RuntimeInfo extends cxschema.RuntimeInfo {}
/**
* Backwards compatibility for when 'MetadataEntry'
* was defined here. This is necessary because its used as an input in the stable
* @alicloud/ros-cdk-core library.
*
* @deprecated moved to package 'ros-assembly-schema'
* @see core.ConstructNode.metadata
*/
export interface MetadataEntry extends cxschema.MetadataEntry {}
/**
* Backwards compatibility for when 'MissingContext'
* was defined here. This is necessary because its used as an input in the stable
* @alicloud/ros-cdk-core library.
*
* @deprecated moved to package 'ros-assembly-schema'
* @see core.Stack.reportMissingContext
*/
export interface MissingContext {
/**
* The missing context key.
*/
readonly key: string;
/**
* The provider from which we expect this context key to be obtained.
*
* (This is the old untyped definition, which is necessary for backwards compatibility.
* See cxschema for a type definition.)
*/
readonly provider: string;
/**
* A set of provider-specific options.
*
* (This is the old untyped definition, which is necessary for backwards compatibility.
* See cxschema for a type definition.)
*/
readonly props: Record<string, any>;
}
export interface AssemblyBuildOptions {
/**
* Include the specified runtime information (module versions) in manifest.
* @default - if this option is not specified, runtime info will not be included
*/
readonly runtimeInfo?: RuntimeInfo;
}
/**
* Returns a copy of 'obj' without undefined values in maps or arrays.
*/
function filterUndefined(obj: any): any {
if (Array.isArray(obj)) {
return obj.filter((x) => x !== undefined).map((x) => filterUndefined(x));
}
if (typeof obj === "object") {
const ret: any = {};
for (const [key, value] of Object.entries(obj)) {
if (value === undefined) {
continue;
}
ret[key] = filterUndefined(value);
}
return ret;
}
return obj;
}
function ignore(_x: any) {
return;
}
/**
* Turn the given optional output directory into a fixed output directory
*/
function determineOutputDirectory(outdir?: string) {
return outdir ?? fs.mkdtempSync(path.join(os.tmpdir(), "cdk.out"));
}