@fairmint/canton-node-sdk
Version:
Canton Node SDK
191 lines (175 loc) • 6.88 kB
text/typescript
// Copyright (c) 2024 Digital Asset (Switzerland) GmbH and/or its affiliates. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
import * as automation from '@pulumi/pulumi/automation';
import { config } from '@lfdecentralizedtrust/splice-pulumi-common/src/config';
import {
CLUSTER_BASENAME,
PULUMI_STACKS_DIR,
} from '@lfdecentralizedtrust/splice-pulumi-common/src/utils';
import fs from 'fs';
import os from 'os';
import path from 'path';
import {spliceEnvConfig} from "@lfdecentralizedtrust/splice-pulumi-common/src/config/envConfig";
import {allowDowngrade} from "@lfdecentralizedtrust/splice-pulumi-common";
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pulumi-'));
/**
* This is literally dumb:
* - Selecting the stack through the workspace will for some reason create a `workspaces` directory in the pulumi home direcotry
* - There`s no way to disable this behavior
* - There's no way to skip selecting the stack using the automation api
* - The default pulumi home directory is in nix for us so it's read only
* - Changing the pulumi home for the preview would cause all the plguins to be reinstalled
* - The only way to get around this is to symlink the plugins and bin directories to a temporary directory
* */
const pulumiHome = config.requireEnv('PULUMI_HOME');
const binDir = path.join(pulumiHome, 'bin');
const pluginsDir = path.join(pulumiHome, 'plugins');
const tempBinDir = path.join(tempDir, 'bin');
const tempPluginsDir = path.join(tempDir, 'plugins');
fs.symlinkSync(binDir, tempBinDir, 'dir');
fs.symlinkSync(pluginsDir, tempPluginsDir, 'dir');
const commandPromise = automation.PulumiCommand.get({
// eslint-disable-next-line no-process-env
root: process.env.PULUMI_HOME,
// enforce typescript sdk version to match the pulumi cli version
skipVersionCheck: false,
});
export function pulumiOptsWithPrefix(
prefix: string,
abortSignal: AbortSignal
): {
parallel: 128;
onOutput: (output: string) => void;
signal: AbortSignal;
color: 'always';
policyPacks: string[];
} {
return {
parallel: 128,
onOutput: (output: string) => {
// do not output empty lines or lines containing just '.'
if (output.trim().length > 1) {
console.log(`${prefix}${output.trim()}`);
}
},
signal: abortSignal,
color: 'always',
policyPacks: allowDowngrade ? [] : [`${spliceEnvConfig.context.splicePath}/cluster/pulumi/policies`],
};
}
function getSecretsProvider() {
return `gcpkms://projects/${config.requireEnv(
'PULUMI_BACKEND_GCPKMS_PROJECT'
)}/locations/${config.requireEnv(
'CLOUDSDK_COMPUTE_REGION'
)}/keyRings/pulumi/cryptoKeys/${config.requireEnv('PULUMI_BACKEND_GCPKMS_NAME')}`;
}
export async function stack(
project: string,
stackName: string,
requiresExistingStack: boolean,
envVars: {
[key: string]: string;
}
): Promise<automation.Stack> {
const fullStackName = `organization/${project}/${stackName}.${CLUSTER_BASENAME}`;
const command = await commandPromise;
const projectDirectory = `${PULUMI_STACKS_DIR}/${project}`;
const stackOpts: automation.LocalProgramArgs = {
workDir: projectDirectory,
stackName: fullStackName,
};
const workspaceOpts: automation.LocalWorkspaceOptions = {
secretsProvider: getSecretsProvider(),
envVars: envVars,
workDir: projectDirectory,
pulumiCommand: command,
pulumiHome: tempDir,
};
return requiresExistingStack
? await automation.LocalWorkspace.selectStack(stackOpts, workspaceOpts)
: await automation.LocalWorkspace.createOrSelectStack(stackOpts, workspaceOpts);
}
export async function ensureStackSettingsAreUpToDate(stack: automation.Stack): Promise<void> {
// This nice API ensures that the local stack file is updated with the latest settings stored in the actual state file
// if not done, pulumi automation will sometimes complain that the secrets passphrase is not set
const settings = await stack.workspace.stackSettings(stack.name);
await stack.workspace.saveStackSettings(stack.name, {
...settings,
secretsProvider: getSecretsProvider(),
});
}
// An AbortController that:
// 1. Also listens for SIGINT and SIGTERM signals
// 2. Guarantees it will signal only once because aborting pulumi is not idempotent, if we signal twice
// pulumi will abort without cleanup.
// 3. Waits a few seconds before actually signalling, see https://github.com/DACH-NY/canton-network-node/issues/15519
// for the reason (the gist is: aborting pulumi actions too early causes pulumi to terminate without releasing the lock)
export class PulumiAbortController {
constructor() {
['SIGINT', 'SIGTERM'].forEach(signal =>
// We assume here that an external abort signal will not come immediately, and do not
// wait before sending the actual signal to Pulumi. This is because we do not want to
// add delays to cleaning up when CCI terminates us, to try to avoid CCI timing out and
// hard-killing us.
process.on(signal, () => {
this.abort('Aborting due to caught signal');
})
);
}
private controller = new AbortController();
private aborted = false;
private sentAbort = false;
private WAIT_BEFORE_ABORT = 10000;
public abort(reason?: unknown): void {
if (!this.aborted) {
console.error(`Aborting after the wait time: ${reason}`);
const c = this.controller;
setTimeout(
() => {
console.error(`Aborting: ${reason}`);
if (!this.sentAbort) {
this.sentAbort = true;
c.abort(reason);
}
},
// some randomness to prevent double execution
Math.random() * 1000 + this.WAIT_BEFORE_ABORT
);
}
this.aborted = true;
}
public get signal(): AbortSignal {
return this.controller.signal;
}
}
export interface Operation {
name: string;
promise: Promise<void>;
}
export async function awaitAllOrThrowAllExceptions(operations: Operation[]): Promise<void> {
const data = await Promise.allSettled(
operations.map(op => {
console.error(`Running operation ${op.name}`);
return op.promise.then(
() => console.error(`Operation ${op.name} succeeded.`),
err => {
if (err instanceof automation.CommandError) {
console.error(`Operation ${op.name} failed.`);
} else {
console.error(`Operation ${op.name} failed with an unknown error.`);
}
throw err;
}
);
})
);
const rejectionReasons = (
data.filter(res => res.status === 'rejected') as PromiseRejectedResult[]
).map(res => res.reason);
if (rejectionReasons.length > 0) {
const message = `Ran ${operations.length} operations. ${rejectionReasons.length} failed. Reasons of rejections: ${rejectionReasons}`;
console.error(message);
throw new Error(message);
}
}