feat: v2
This commit is contained in:
@@ -0,0 +1,677 @@
|
||||
import type { KubernetesObject, V1Deployment } from "@kubernetes/client-node";
|
||||
import type { ComposeSpecification } from "../schema/docker.d";
|
||||
import { LABELS } from "../const";
|
||||
import {
|
||||
applyResource,
|
||||
getStaleResources,
|
||||
listManagedResources,
|
||||
sortResources,
|
||||
} from "../lib/apply";
|
||||
import {
|
||||
DATABASE_NAMESPACE,
|
||||
getComposePostgresClaims,
|
||||
getRoleCredentials,
|
||||
listManagedDatabaseResources,
|
||||
reconcilePostgresClaims,
|
||||
type PostgresClaim,
|
||||
type RoleCredentials,
|
||||
} from "../lib/database";
|
||||
import {
|
||||
buildNamespaceGraphs,
|
||||
fetchNamespaceObjects,
|
||||
type NamespaceGraph,
|
||||
} from "../lib/graph";
|
||||
import {
|
||||
planRollback,
|
||||
rollbackDeployment,
|
||||
type RollbackCandidate,
|
||||
} from "../lib/rollback";
|
||||
import {
|
||||
listManagedDeployments,
|
||||
restartDeployment,
|
||||
scaleDeployment,
|
||||
waitForDeploymentRollout,
|
||||
} from "../lib/shared";
|
||||
import {
|
||||
getComposeS3Claims,
|
||||
getS3Credentials,
|
||||
listManagedStorageResources,
|
||||
reconcileS3Claims,
|
||||
STORAGE_NAMESPACE,
|
||||
type S3Claim,
|
||||
} from "../lib/storage";
|
||||
|
||||
export const WORKSPACE_UID_LABEL = "kuber.dev/workspace-uid";
|
||||
export const WORKSPACE_PROJECT_LABEL = "kuber.dev/project";
|
||||
|
||||
export const RESERVED_NAMESPACES = new Set([
|
||||
"default",
|
||||
"kube-system",
|
||||
"kube-public",
|
||||
"kube-node-lease",
|
||||
"kuber-system",
|
||||
DATABASE_NAMESPACE,
|
||||
STORAGE_NAMESPACE,
|
||||
]);
|
||||
|
||||
export type Workspace = {
|
||||
project: string;
|
||||
uid: string;
|
||||
};
|
||||
|
||||
export type NamespaceRecord = {
|
||||
uid?: string;
|
||||
labels?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type NamespaceSafety = {
|
||||
project: string;
|
||||
status: "missing" | "owned" | "external" | "different-workspace";
|
||||
namespaceUid?: string;
|
||||
};
|
||||
|
||||
export type ResourceIdentity = {
|
||||
apiVersion: string;
|
||||
kind: string;
|
||||
name: string;
|
||||
namespace?: string;
|
||||
uid: string;
|
||||
workspaceUid: string;
|
||||
};
|
||||
|
||||
export type ResourcePlan = {
|
||||
desired: KubernetesObject[];
|
||||
stale: ResourceIdentity[];
|
||||
};
|
||||
|
||||
export type DownPlan = {
|
||||
full: boolean;
|
||||
retained: ResourceIdentity[];
|
||||
delete: ResourceIdentity[];
|
||||
};
|
||||
|
||||
export type CredentialMetadata = {
|
||||
service: string;
|
||||
provider: "postgres" | "s3";
|
||||
principal: string;
|
||||
resource: string;
|
||||
secretNamespace: string;
|
||||
secretName?: string;
|
||||
};
|
||||
|
||||
export type ManagementDependencies = {
|
||||
readNamespace(project: string): Promise<NamespaceRecord | undefined>;
|
||||
listDeployments(project: string): Promise<V1Deployment[]>;
|
||||
scaleDeployment(
|
||||
project: string,
|
||||
name: string,
|
||||
replicas: number,
|
||||
): Promise<unknown>;
|
||||
restartDeployment(project: string, name: string): Promise<unknown>;
|
||||
waitForDeployment(
|
||||
project: string,
|
||||
name: string,
|
||||
timeoutMs?: number,
|
||||
): Promise<void>;
|
||||
fetchGraphObjects(project: string): Promise<KubernetesObject[]>;
|
||||
planRollback(project: string, names?: string[]): Promise<RollbackCandidate[]>;
|
||||
rollbackDeployment(
|
||||
project: string,
|
||||
candidate: RollbackCandidate,
|
||||
): Promise<unknown>;
|
||||
listProjectResources(project: string): Promise<KubernetesObject[]>;
|
||||
listDatabaseResources(project: string): Promise<KubernetesObject[]>;
|
||||
listStorageResources(project: string): Promise<KubernetesObject[]>;
|
||||
findStaleResources(
|
||||
project: string,
|
||||
desired: KubernetesObject[],
|
||||
): Promise<KubernetesObject[]>;
|
||||
applyResource(resource: KubernetesObject): Promise<KubernetesObject>;
|
||||
deleteResource(identity: ResourceIdentity): Promise<void>;
|
||||
reconcileDatabases(
|
||||
project: string,
|
||||
compose: ComposeSpecification,
|
||||
): Promise<Record<string, Record<string, string>>>;
|
||||
getDatabaseCredentials(username: string): Promise<RoleCredentials>;
|
||||
reconcileStorage(
|
||||
project: string,
|
||||
compose: ComposeSpecification,
|
||||
): Promise<Record<string, Record<string, string>>>;
|
||||
getStorageCredentials(claim: S3Claim): Promise<Record<string, string>>;
|
||||
};
|
||||
|
||||
export type ManagementService = ReturnType<typeof createManagementService>;
|
||||
|
||||
const defaultOperations: Omit<
|
||||
ManagementDependencies,
|
||||
"readNamespace" | "deleteResource"
|
||||
> = {
|
||||
listDeployments: async () => listManagedDeployments(),
|
||||
scaleDeployment: async (_project, name, replicas) =>
|
||||
scaleDeployment(name, replicas),
|
||||
restartDeployment: async (_project, name) => restartDeployment(name),
|
||||
waitForDeployment: async (_project, name, timeoutMs) =>
|
||||
waitForDeploymentRollout(name, timeoutMs),
|
||||
fetchGraphObjects: fetchNamespaceObjects,
|
||||
planRollback: async (_project, names) => planRollback(names),
|
||||
rollbackDeployment: async (_project, candidate) =>
|
||||
rollbackDeployment(candidate),
|
||||
listProjectResources: listManagedResources,
|
||||
listDatabaseResources: listManagedDatabaseResources,
|
||||
listStorageResources: listManagedStorageResources,
|
||||
findStaleResources: getStaleResources,
|
||||
applyResource,
|
||||
reconcileDatabases: reconcilePostgresClaims,
|
||||
getDatabaseCredentials: getRoleCredentials,
|
||||
reconcileStorage: reconcileS3Claims,
|
||||
getStorageCredentials: getS3Credentials,
|
||||
};
|
||||
|
||||
export function managementDependencies(
|
||||
infrastructure: Pick<
|
||||
ManagementDependencies,
|
||||
"readNamespace" | "deleteResource"
|
||||
>,
|
||||
overrides: Partial<ManagementDependencies> = {},
|
||||
): ManagementDependencies {
|
||||
return { ...defaultOperations, ...infrastructure, ...overrides };
|
||||
}
|
||||
|
||||
function validateWorkspace(workspace: Workspace): void {
|
||||
if (!workspace.uid.trim()) throw new Error("Workspace UID is required");
|
||||
if (
|
||||
!workspace.project ||
|
||||
workspace.project.length > 63 ||
|
||||
!/^[a-z0-9](?:[-a-z0-9]*[a-z0-9])?$/.test(workspace.project)
|
||||
) {
|
||||
throw new Error(`Invalid project namespace ${workspace.project}`);
|
||||
}
|
||||
if (RESERVED_NAMESPACES.has(workspace.project)) {
|
||||
throw new Error(`Namespace ${workspace.project} is reserved`);
|
||||
}
|
||||
}
|
||||
|
||||
function resourceName(resource: KubernetesObject): string {
|
||||
const name = resource.metadata?.name;
|
||||
if (!resource.apiVersion || !resource.kind || !name) {
|
||||
throw new Error("Resources require apiVersion, kind, and metadata.name");
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function assertResourceOwnership(
|
||||
workspace: Workspace,
|
||||
resource: KubernetesObject,
|
||||
): void {
|
||||
const owner = resource.metadata?.labels?.[WORKSPACE_UID_LABEL];
|
||||
if (owner !== workspace.uid) {
|
||||
throw new Error(
|
||||
`${resource.kind}/${resourceName(resource)} is not owned by workspace ${workspace.uid}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function identity(
|
||||
workspace: Workspace,
|
||||
resource: KubernetesObject,
|
||||
): ResourceIdentity {
|
||||
assertResourceOwnership(workspace, resource);
|
||||
const uid = resource.metadata?.uid;
|
||||
if (!uid) {
|
||||
throw new Error(
|
||||
`${resource.kind}/${resourceName(resource)} has no UID deletion precondition`,
|
||||
);
|
||||
}
|
||||
return {
|
||||
apiVersion: resource.apiVersion!,
|
||||
kind: resource.kind!,
|
||||
name: resourceName(resource),
|
||||
namespace: resource.metadata?.namespace,
|
||||
uid,
|
||||
workspaceUid: workspace.uid,
|
||||
};
|
||||
}
|
||||
|
||||
function labelDesired(
|
||||
workspace: Workspace,
|
||||
resource: KubernetesObject,
|
||||
): KubernetesObject {
|
||||
const name = resourceName(resource);
|
||||
const namespace =
|
||||
resource.kind === "Namespace"
|
||||
? undefined
|
||||
: (resource.metadata?.namespace ?? workspace.project);
|
||||
if (resource.kind === "Namespace" && name !== workspace.project) {
|
||||
throw new Error(
|
||||
`Cannot manage namespace ${name} from project ${workspace.project}`,
|
||||
);
|
||||
}
|
||||
if (namespace && namespace !== workspace.project) {
|
||||
throw new Error(
|
||||
`Cannot manage ${resource.kind}/${name} in namespace ${namespace}`,
|
||||
);
|
||||
}
|
||||
const existingOwner = resource.metadata?.labels?.[WORKSPACE_UID_LABEL];
|
||||
if (existingOwner && existingOwner !== workspace.uid) {
|
||||
throw new Error(`${resource.kind}/${name} belongs to another workspace`);
|
||||
}
|
||||
return {
|
||||
...resource,
|
||||
metadata: {
|
||||
...resource.metadata,
|
||||
name,
|
||||
namespace,
|
||||
labels: {
|
||||
...resource.metadata?.labels,
|
||||
...LABELS,
|
||||
[WORKSPACE_PROJECT_LABEL]: workspace.project,
|
||||
[WORKSPACE_UID_LABEL]: workspace.uid,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function labelExternal(
|
||||
workspace: Workspace,
|
||||
resource: KubernetesObject,
|
||||
namespace: string,
|
||||
): KubernetesObject {
|
||||
const name = resourceName(resource);
|
||||
if (resource.metadata?.namespace !== namespace) {
|
||||
throw new Error(
|
||||
`${resource.kind}/${name} is outside expected namespace ${namespace}`,
|
||||
);
|
||||
}
|
||||
const existingOwner = resource.metadata?.labels?.[WORKSPACE_UID_LABEL];
|
||||
if (existingOwner && existingOwner !== workspace.uid) {
|
||||
throw new Error(`${resource.kind}/${name} belongs to another workspace`);
|
||||
}
|
||||
const { status: _status, ...body } = resource as KubernetesObject & {
|
||||
status?: unknown;
|
||||
};
|
||||
return {
|
||||
...body,
|
||||
metadata: {
|
||||
...body.metadata,
|
||||
labels: {
|
||||
...body.metadata?.labels,
|
||||
[WORKSPACE_PROJECT_LABEL]: workspace.project,
|
||||
[WORKSPACE_UID_LABEL]: workspace.uid,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function deploymentNames(deployments: V1Deployment[]): string[] {
|
||||
return deployments
|
||||
.map((deployment) => deployment.metadata?.name)
|
||||
.filter((name): name is string => Boolean(name));
|
||||
}
|
||||
|
||||
function selectTargets(all: string[], requested?: string[]): string[] {
|
||||
if (!requested) return all;
|
||||
const unique = [...new Set(requested)];
|
||||
const available = new Set(all);
|
||||
for (const name of unique) {
|
||||
if (!available.has(name))
|
||||
throw new Error(`No managed deployment named ${name}`);
|
||||
}
|
||||
return unique;
|
||||
}
|
||||
|
||||
export function createManagementService(dependencies: ManagementDependencies) {
|
||||
async function namespaceSafety(
|
||||
workspace: Workspace,
|
||||
): Promise<NamespaceSafety> {
|
||||
validateWorkspace(workspace);
|
||||
const namespace = await dependencies.readNamespace(workspace.project);
|
||||
if (!namespace) return { project: workspace.project, status: "missing" };
|
||||
const managed =
|
||||
namespace.labels?.["app.kubernetes.io/managed-by"] ===
|
||||
LABELS["app.kubernetes.io/managed-by"];
|
||||
if (!managed) {
|
||||
return {
|
||||
project: workspace.project,
|
||||
status: "external",
|
||||
namespaceUid: namespace.uid,
|
||||
};
|
||||
}
|
||||
if (namespace.labels?.[WORKSPACE_UID_LABEL] !== workspace.uid) {
|
||||
return {
|
||||
project: workspace.project,
|
||||
status: "different-workspace",
|
||||
namespaceUid: namespace.uid,
|
||||
};
|
||||
}
|
||||
return {
|
||||
project: workspace.project,
|
||||
status: "owned",
|
||||
namespaceUid: namespace.uid,
|
||||
};
|
||||
}
|
||||
|
||||
async function assertSafe(
|
||||
workspace: Workspace,
|
||||
allowMissing = false,
|
||||
): Promise<NamespaceSafety> {
|
||||
const safety = await namespaceSafety(workspace);
|
||||
if (
|
||||
safety.status === "owned" ||
|
||||
(allowMissing && safety.status === "missing")
|
||||
) {
|
||||
return safety;
|
||||
}
|
||||
throw new Error(
|
||||
`Namespace ${workspace.project} is ${safety.status}; refusing workspace mutation`,
|
||||
);
|
||||
}
|
||||
|
||||
async function targets(workspace: Workspace, names?: string[]) {
|
||||
await assertSafe(workspace);
|
||||
return selectTargets(
|
||||
deploymentNames(await dependencies.listDeployments(workspace.project)),
|
||||
names,
|
||||
);
|
||||
}
|
||||
|
||||
async function ownExternalResources(
|
||||
workspace: Workspace,
|
||||
namespace: string,
|
||||
resources: KubernetesObject[],
|
||||
): Promise<void> {
|
||||
for (const resource of resources) {
|
||||
await dependencies.applyResource(
|
||||
labelExternal(workspace, resource, namespace),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteResources(
|
||||
workspace: Workspace,
|
||||
resources: ResourceIdentity[],
|
||||
): Promise<void> {
|
||||
await assertSafe(workspace);
|
||||
for (const resource of resources) {
|
||||
if (!resource.uid || resource.workspaceUid !== workspace.uid) {
|
||||
throw new Error(
|
||||
`${resource.kind}/${resource.name} has an invalid workspace deletion identity`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const resource of resources) {
|
||||
await dependencies.deleteResource(resource);
|
||||
}
|
||||
}
|
||||
|
||||
async function planDown(
|
||||
workspace: Workspace,
|
||||
full = false,
|
||||
): Promise<DownPlan> {
|
||||
const safety = await assertSafe(workspace);
|
||||
const projectResources = sortResources(
|
||||
await dependencies.listProjectResources(workspace.project),
|
||||
);
|
||||
const retainedResources = full
|
||||
? []
|
||||
: projectResources.filter(
|
||||
(resource) =>
|
||||
resource.kind === "Ingress" ||
|
||||
resource.kind === "PersistentVolumeClaim",
|
||||
);
|
||||
const deleteResources = full
|
||||
? [...projectResources]
|
||||
: projectResources.filter(
|
||||
(resource) => !retainedResources.includes(resource),
|
||||
);
|
||||
|
||||
if (full) {
|
||||
deleteResources.push(
|
||||
...(await dependencies.listDatabaseResources(workspace.project)),
|
||||
...(await dependencies.listStorageResources(workspace.project)),
|
||||
);
|
||||
if (!safety.namespaceUid) {
|
||||
throw new Error(
|
||||
`Namespace ${workspace.project} has no UID deletion precondition`,
|
||||
);
|
||||
}
|
||||
deleteResources.push({
|
||||
apiVersion: "v1",
|
||||
kind: "Namespace",
|
||||
metadata: {
|
||||
name: workspace.project,
|
||||
uid: safety.namespaceUid,
|
||||
labels: {
|
||||
...LABELS,
|
||||
[WORKSPACE_PROJECT_LABEL]: workspace.project,
|
||||
[WORKSPACE_UID_LABEL]: workspace.uid,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
full,
|
||||
retained: retainedResources.map((item) => identity(workspace, item)),
|
||||
delete: sortResources(deleteResources)
|
||||
.reverse()
|
||||
.map((item) => identity(workspace, item)),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
namespaceSafety,
|
||||
|
||||
async graphStatus(
|
||||
workspace: Workspace,
|
||||
options: { includeIdle?: boolean } = {},
|
||||
): Promise<NamespaceGraph[]> {
|
||||
await assertSafe(workspace);
|
||||
return buildNamespaceGraphs(
|
||||
await dependencies.fetchGraphObjects(workspace.project),
|
||||
options,
|
||||
);
|
||||
},
|
||||
|
||||
async stop(workspace: Workspace, names?: string[]): Promise<string[]> {
|
||||
const selected = await targets(workspace, names);
|
||||
for (const name of selected) {
|
||||
await dependencies.scaleDeployment(workspace.project, name, 0);
|
||||
}
|
||||
return selected;
|
||||
},
|
||||
|
||||
async restart(workspace: Workspace, names?: string[]): Promise<string[]> {
|
||||
const selected = await targets(workspace, names);
|
||||
for (const name of selected) {
|
||||
await dependencies.restartDeployment(workspace.project, name);
|
||||
}
|
||||
return selected;
|
||||
},
|
||||
|
||||
async rollback(
|
||||
workspace: Workspace,
|
||||
names?: string[],
|
||||
timeoutMs?: number,
|
||||
): Promise<RollbackCandidate[]> {
|
||||
await assertSafe(workspace);
|
||||
const candidates = await dependencies.planRollback(
|
||||
workspace.project,
|
||||
names,
|
||||
);
|
||||
for (const candidate of candidates) {
|
||||
await dependencies.rollbackDeployment(workspace.project, candidate);
|
||||
}
|
||||
for (const candidate of candidates) {
|
||||
await dependencies.waitForDeployment(
|
||||
workspace.project,
|
||||
candidate.name,
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
return candidates;
|
||||
},
|
||||
|
||||
databaseCredentialsMetadata(
|
||||
compose: ComposeSpecification,
|
||||
): CredentialMetadata[] {
|
||||
return getComposePostgresClaims(compose).map((claim: PostgresClaim) => ({
|
||||
service: claim.service,
|
||||
provider: "postgres",
|
||||
principal: claim.username,
|
||||
resource: claim.database,
|
||||
secretNamespace: DATABASE_NAMESPACE,
|
||||
secretName: claim.secretName,
|
||||
}));
|
||||
},
|
||||
|
||||
async reconcileDatabases(
|
||||
workspace: Workspace,
|
||||
compose: ComposeSpecification,
|
||||
) {
|
||||
await assertSafe(workspace, true);
|
||||
const environment = await dependencies.reconcileDatabases(
|
||||
workspace.project,
|
||||
compose,
|
||||
);
|
||||
await ownExternalResources(
|
||||
workspace,
|
||||
DATABASE_NAMESPACE,
|
||||
await dependencies.listDatabaseResources(workspace.project),
|
||||
);
|
||||
return environment;
|
||||
},
|
||||
|
||||
async getDatabaseCredentials(workspace: Workspace, username: string) {
|
||||
await assertSafe(workspace);
|
||||
const databases = await dependencies.listDatabaseResources(
|
||||
workspace.project,
|
||||
);
|
||||
const database = databases.find(
|
||||
(resource) =>
|
||||
resource.kind === "Database" &&
|
||||
(resource as KubernetesObject & { spec?: { owner?: string } }).spec
|
||||
?.owner === username,
|
||||
);
|
||||
if (!database) {
|
||||
throw new Error(
|
||||
`Database role ${username} is not managed by this workspace`,
|
||||
);
|
||||
}
|
||||
assertResourceOwnership(workspace, database);
|
||||
return dependencies.getDatabaseCredentials(username);
|
||||
},
|
||||
|
||||
storageCredentialsMetadata(
|
||||
compose: ComposeSpecification,
|
||||
): CredentialMetadata[] {
|
||||
return getComposeS3Claims(compose).map((claim) => ({
|
||||
service: claim.service,
|
||||
provider: "s3",
|
||||
principal: claim.key,
|
||||
resource: claim.bucket,
|
||||
secretNamespace: STORAGE_NAMESPACE,
|
||||
}));
|
||||
},
|
||||
|
||||
async reconcileStorage(
|
||||
workspace: Workspace,
|
||||
compose: ComposeSpecification,
|
||||
) {
|
||||
await assertSafe(workspace, true);
|
||||
const environment = await dependencies.reconcileStorage(
|
||||
workspace.project,
|
||||
compose,
|
||||
);
|
||||
await ownExternalResources(
|
||||
workspace,
|
||||
STORAGE_NAMESPACE,
|
||||
await dependencies.listStorageResources(workspace.project),
|
||||
);
|
||||
return environment;
|
||||
},
|
||||
|
||||
async getStorageCredentials(workspace: Workspace, claim: S3Claim) {
|
||||
await assertSafe(workspace);
|
||||
const resources = await dependencies.listStorageResources(
|
||||
workspace.project,
|
||||
);
|
||||
const key = resources.find(
|
||||
(resource) =>
|
||||
resource.kind === "GarageKey" &&
|
||||
resource.metadata?.name === claim.key,
|
||||
);
|
||||
const bucket = resources.find(
|
||||
(resource) =>
|
||||
resource.kind === "GarageBucket" &&
|
||||
resource.metadata?.name === claim.bucket,
|
||||
);
|
||||
if (!key || !bucket) {
|
||||
throw new Error(
|
||||
`S3 claim ${claim.key}/${claim.bucket} is not managed by this workspace`,
|
||||
);
|
||||
}
|
||||
assertResourceOwnership(workspace, key);
|
||||
assertResourceOwnership(workspace, bucket);
|
||||
return dependencies.getStorageCredentials(claim);
|
||||
},
|
||||
|
||||
async planResources(
|
||||
workspace: Workspace,
|
||||
desired: KubernetesObject[],
|
||||
): Promise<ResourcePlan> {
|
||||
await assertSafe(workspace, true);
|
||||
const labeled = sortResources(
|
||||
desired.map((item) => labelDesired(workspace, item)),
|
||||
);
|
||||
const stale = await dependencies.findStaleResources(
|
||||
workspace.project,
|
||||
labeled,
|
||||
);
|
||||
return {
|
||||
desired: labeled,
|
||||
stale: sortResources(stale)
|
||||
.reverse()
|
||||
.map((item) => identity(workspace, item)),
|
||||
};
|
||||
},
|
||||
|
||||
async applyResources(
|
||||
workspace: Workspace,
|
||||
resources: KubernetesObject[],
|
||||
): Promise<KubernetesObject[]> {
|
||||
await assertSafe(workspace, true);
|
||||
const applied: KubernetesObject[] = [];
|
||||
for (const resource of sortResources(
|
||||
resources.map((item) => labelDesired(workspace, item)),
|
||||
)) {
|
||||
applied.push(await dependencies.applyResource(resource));
|
||||
}
|
||||
return applied;
|
||||
},
|
||||
|
||||
async waitForResources(
|
||||
workspace: Workspace,
|
||||
deploymentTargets: string[],
|
||||
timeoutMs?: number,
|
||||
): Promise<void> {
|
||||
const selected = await targets(workspace, deploymentTargets);
|
||||
for (const name of selected) {
|
||||
await dependencies.waitForDeployment(
|
||||
workspace.project,
|
||||
name,
|
||||
timeoutMs,
|
||||
);
|
||||
}
|
||||
},
|
||||
|
||||
deleteResources,
|
||||
|
||||
planDown,
|
||||
|
||||
async down(workspace: Workspace, full = false): Promise<DownPlan> {
|
||||
const plan = await planDown(workspace, full);
|
||||
await deleteResources(workspace, plan.delete);
|
||||
return plan;
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user