UNPKG

@fairmint/canton-node-sdk

Version:
191 lines (175 loc) 6.88 kB
// 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); } }