hi all :wave: has anyone on the pulumi team given ...
# pulumiverse
m
hi all 👋 has anyone on the pulumi team given thought to adding decorators for TS? I've written a few of my own for pulumi for both of my startups and they have notably improved Dev QOL. The ones that, for example, inject / replace .interpolate, .apply and .all have been ...let's just say widely appreciated. They're also fairly easy to maintain and we've gone through a few breaking changes on them but due to the fundamental nature of decorators in TS as applied functions, any AST editor or codemod can refactor to compensate for breaking changes with relative ease.
e
I don't think I've heard this suggested before. Have you got a concrete example of using a decorator? I'm struggling to see what exactly you mean by replacing interpolate/apply.
m
interpolate is actually a decent example. before:
Copy code
const someInput = pulumi.interpolate(`string${var}`)
const someArg = 'someWeirdStringOrAnyTypeThatNeedsToBeWrappedInaPulumi_Output<T>'
after:
Copy code
const someInput = @input`string${var}`
const someArg = @input['a','b','c']
etc
a generic decorator that does automatic type wrapping/conversion if the expected target is a pulumi.inputT or pulumi.outputT ...as long as its safe obvi
it bridges the gap between "lol string can't be a pulumi.output<string>" etc
this is especially useful for inline-typed objects like maps and dictionaries
instead of casting the entire world into { [key: string]: string } etc
injection is also really useful to get around importing 10,000 things if you have, for example:
Copy code
const engineeringOu = @input importOrganizationalUnit('Engineering', 'SDLC Environments', 'ou-5r4w-q04lmr3c', @inject rootOuId);
const tenantEnvOu = @input importOrganizationalUnit('Environments', 'Tenant Accounts', 'ou-5r4w-6ui019no', @inject engineeringOu.id);

// AWS Users (Accounts)
const rootAwsAccount = @input importAwsAccount(@inject rootAccountName, 'REDACTED', @inject rootAccountId);
const sharedAwsAccount = @input importAwsAccount(@inject sharedAccountName, 'REDACTED', @inject sharedAccountId);
const tenantAwsAccounts = @input activeTenants.map(tenant => createAwsAccount(`tenant_${tenant}`, @inject tenantEnvOu.id));
const allAwsAccounts = @input tenantAwsAccounts.concat(rootAwsAccount).concat(@inject sharedAwsAccount);

// GitHub CICD Setup
export const crossAccountPermissionPolicyTargets = allAwsAccounts.map(account => {
    return account.id.apply(appliedAccountId => {
        const provider = createAccountPulumiProvider(appliedAccountId);

        const oidcGithubProvider = createGithubOidcProvider(appliedAccountId, @inject provider);
        const cicdIamRole = createGithubCicdRole(appliedAccountId, @inject provider);
        const cicdIamRoleAttachment = createGithubCicdRoleAttachment(appliedAccountId, @inject cicdIamRole, @inject provider);

		// IAM Users
        const cicdIamUser = createGithubCicdUser(appliedAccountId, @inject provider);
        const cicdIamUserAttachment = createGithubCicdUserAttachment(appliedAccountId, @inject cicdIamUser, @inject provider);

        return pulumi.all([cicdIamRole.arn, cicdIamUser.arn]).apply(([arnOne, arnTwo]) => {
            return [arnOne, arnTwo];
        });
    });
}).reduce((accumulator, current)  => {
        return pulumi.all([accumulator, current]).apply(([accArr, currArr]) => accArr.concat(currArr));
    },
    pulumi.output([])
);

export const activeTenantInfo = pulumi.all(tenantAwsAccounts.map(account => pulumi.all([account.id, account.name]).apply(([id, name]) => {
	return { [`${name.match(/_(.*)/)![1]}`] : {
			awsAccountId: id,
			awsAccountName: name,
			iamRoleArn: getGithubCicdRole(id),
			iamUserArn: getGithubCicdUser(id)
		} 
	}
}))).apply(accounts => accounts.reduce((accumulator, current) => {
	accumulator[Object.keys(current)[0]] = current[Object.keys(current)[0]]
	return accumulator;
}));

export const activeSharedInfo = activeSharedInfoConfig;
// TODO - SSO Users. Note: SSO Users may need a policy allowing them to assume an IAM cicd role or user depending on the permission group.
// Here we should stack-create SSO accounts for myself, brett and harris and then state import the existing ones

//TL;DR SSO Permission Sets link a list of AWS Accounts with a list of AWS IAM Policies and have SSO Groups as principals, which in turn link with SSO Users.
mapPermissionDictionaryToAccounts(PERMISSIONS_DICT_BREAKGLASS, allAwsAccounts);
mapPermissionDictionaryToAccounts(PERMISSIONS_DICT_DEVOPS, tenantAwsAccounts.concat(sharedAwsAccount));
mapPermissionDictionaryToAccounts(PERMISSIONS_DICT_ENGINEERS, tenantAwsAccounts);
here is the injection decorator source:
Copy code
import { Abstract } from '../index';
import { INJECTION } from '../symbols';

export type Injectable<T> = T & { [INJECTION]?: InjectionMeta };

export type InjectionMeta = {
	property: PropertyInjectionMeta[];
	constructor: ConstructorInjectionMeta[];
};

export type PropertyInjectionMeta = {
	params: any;
	abstract: any;
	propertyKey: string | symbol;
};

export type ConstructorInjectionMeta = PropertyInjectionMeta & {
	argumentIndex: number;
};

export const inject = <T>(
	abstract: Abstract,
	...params: ConstructorParameters<
        T extends new (...args: any) => any ? T : any
    >
): any => {
	return (
		target: any,
		propertyKey: string | symbol,
		argumentIndex: number,
	): void => {
		const isConstructable =
            typeof target === 'function' && target.prototype !== undefined;

		const metaTarget = isConstructable ? target : target.constructor;
		const injections: InjectionMeta = {
			property: [...(metaTarget[INJECTION]?.property ?? [])],
			constructor: [...(metaTarget[INJECTION]?.constructor ?? [])],
		};

		if (isConstructable) {
			injections.constructor.push({
				params,
				abstract,
				propertyKey,
				argumentIndex,
			});
		} else {
			injections.property.push({ params, abstract, propertyKey });
		}

		Object.defineProperty(metaTarget, INJECTION, {
			value: injections,
			writable: false,
			enumerable: false,
			configurable: false,
		});
	};
};

export default inject;
along with the file it references:
Copy code
/* eslint-disable complexity */
/* eslint-disable max-lines-per-function */
import { inspect } from 'util';
import { Injectable, InjectionMeta } from './annotations/inject';
import Mockery, { Mocks, TemporaryMock } from './mockery';
import * as Symbols from './symbols';

export { default as inject, InjectionMeta } from './annotations/inject';
export { Symbols };
export type Alias = string;
export type Aliases = { [key: string]: Abstract };

export type Abstract = Alias | symbol;

export type Concretion = any;
export type ConstructorType<T = any> = new (...args: any[]) => T;

export type Binding = {
	shared: boolean;
	target: BindTarget;
	onActivation?: any;
};

export type BindTarget = StaticBinding | DynamicBinding;

// eslint-disable-next-line @typescript-eslint/ban-types
export type StaticBinding = Injectable<Function>;
export type DynamicBinding = (container: Container) => Concretion;

export default class Container {

	protected mocks = new Mockery(this);
	protected aliases: Map<Alias, Abstract> = new Map();
	protected bindings: Map<Abstract, Binding> = new Map();
	protected resolving: Set<Abstract | StaticBinding> = new Set();
	protected instances: Map<Abstract, Concretion> = new Map();

	constructor() {
		this.instance(Symbols.CONTAINER, this);
	}

	/**
	 * Adds an already constructed value to the container
	 * @param id Abstract
	 * @param concrete Conretion
	 */
	public instance(id: Abstract, concrete: Concretion): void {
		const abstractId = this.toAbstract(id);

		this.forget(abstractId);
		this.instances.set(abstractId, concrete);
	}

	/**
	 * Adds a singleton to the container
	 * @param id Abstract
	 * @param target BindTarget
	 */
	public singleton<T>(id: Abstract, target: BindTarget): { onActivation: any } {
		return this.bind<T>(id, target, true);
	}

	/**
	 * Adds an abstract ID to the supplied target
	 * @param id Abstract
	 * @param target BindTarget
	 */
	public bind<T>(id: Abstract, target: BindTarget, shared = false): { onActivation: any } {
		if (typeof target !== 'function') {
			throw new Error('Target must be a function or arrow function.');
		}

		id = this.toAbstract(id);

		this.bindings.set(id, { shared, target });

		return {
			onActivation: (callback: (arg: T) => T): void => {
				(this.bindings.get(id) as Binding).onActivation = callback;
			},
		};
	}

	/**
	 * For Testing: allow runtime overrides of items in the container.
	 * @param id Abstract
	 * @param target BindTarget
	 */
	public async mock(mocks: Mocks, callback?: TemporaryMock<this>): Promise<void> {
		this.mocks.addMock(mocks);
		if (typeof callback === 'function') {
			try {
				// eslint-disable-next-line callback-return
				await callback(this);
			} finally {
				this.mocks.deleteMock(mocks);
			}
		}
	}

	public make<T = any>(target: Abstract | StaticBinding, strict = true): T {
		return this.makeWithArgs(target, [], strict);
	}

	/**
	 * Fetches and/or builds the supplied target from the container. When supplied with an ID, the binding
	 * associated to it will be built according to its configuration in the container. When supplied with
	 * a static binding (callable), it will build as a part of the container, resolving any dependencies.
	 * @param target Abstract | StaticBinding
	 * @param args Any. Arguments
	 * @param strict TODODESC
	 */
	public makeWithArgs<T = any>(
		target: Abstract | StaticBinding,
		args: any[],
		strict = true,
	): T {
		let instance;

		switch (typeof target) {
			case 'string':
				const abstract = this.toAbstract(target);
				return this.make(abstract, strict);
			case 'function':
				return this.construct(target as ConstructorType);
			case 'symbol':
				if (this.mocks.has(target)) {
					return this.mocks.get(target);
				}
				if (this.instances.has(target)) {
					return this.instances.get(target);
				}
				if (this.resolving.has(target)) {
					const current = Array.from(this.resolving).pop();
					const error = new Error(
						`Circular dependency detected in [${String(
							current,
						)}] while building [${String(target)}]`,
					);
					Error.captureStackTrace(error, this.makeWithArgs);
					throw error;
				}
				if (this.isBound(target)) {
					this.resolving.add(target);
					try {
						const shared = this.isShared(target);
						const binding = this.bindings.get(target) as Binding;

						if (strict && shared && args.length > 0) {
							console.log({ target, args });
							const error = new Error(
								'Arguments are not supported with singleton construction.',
							);
							Error.captureStackTrace(error, this.makeWithArgs);
							throw error;
						}

						instance = this.build(binding, args);

						if (binding.onActivation) {
							instance = binding.onActivation(instance);
						}
						if (shared) {
							this.instance(target, instance);
						}
					} finally {
						this.resolving.delete(target);
					}
				}
				break;
			default: break;
		}
		if (strict && !instance) {
			const error = new Error(`Failed to resolve abstract: ${String(target)}`);
			Error.captureStackTrace(error, this.makeWithArgs);
			throw error;
		}
		return instance;
	}

	public construct<T extends ConstructorType<any>>(
		target: T,
		args?: ConstructorParameters<T>,
	): InstanceType<T> {
		return this.build({ target, shared: false }, args);
	}

	/**
	 * Assigns an easy to remember string value to fetch things from the container.
	 * @param alias Alias
	 * @param target Abstract
	 */
	public alias(alias: Alias, target: Abstract): void;

	/**
	 * Assigns multiple easy to remember string values to fetch things from the container. Ease intensifies.
	 * @param alias Alias
	 * @param target Abstract
	 */
	public alias(aliases: Aliases): void;

	public alias(...args: any[]): void {
		if (args.length === 1 && typeof args[0] === 'object') {
			const aliases = args[0] as Aliases;
			for (const alias in aliases) {
				this.alias(alias, aliases[alias]);
			}
		} else {
			const alias = this.normalize(args[0]);
			const target = this.toAbstract(args[1]);
			this.aliases.set(alias, target);
		}
	}

	/**
	 * Removes an instance concretion from the container.
	 * @param id Abstract
	 */
	public forget(id: Abstract): void {
		this.instances.delete(this.toAbstract(id));
	}

	/**
	 * Removes all instances (but not bindings) from the container.
	 */
	public forgetAll(): void {
		this.instances.clear();
	}

	/**
	 * Removes a binding and its associated instance, if it exists.
	 * @param id Abstract
	 */
	public unbind(id: Abstract): void {
		const abstract = this.toAbstract(id);
		this.forget(abstract);
		this.bindings.delete(abstract);
	}

	/**
	 * Removes all bindings and associated instances from the container.
	 */
	public unbindAll(): void {
		for (const [id] of this.bindings) {
			this.forget(id);
			this.unbind(id);
		}
		this.bindings.clear();
	}

	/**
	 * Empties the container, except for configured aliases.
	 * @param id Abstract
	 */
	public flush(): void {
		this.forgetAll();
		this.unbindAll();
	}

	/**
	 * Checks whether a configured binding is a singleton (shared).
	 * @param id Abstract
	 */
	public isShared(id: Abstract): boolean {
		const abstract = this.toAbstract(id);
		if (this.isBound(abstract)) {
			const { shared }: Binding =
				this.instances.get(abstract) ?? this.bindings.get(abstract);
			return shared;
		}
		return false;
	}

	/**
	 * Checks if the supplied ID exists within the container
	 * @param id Abstract
	 */
	public isBound(id: Abstract): boolean {
		const abstract = this.toAbstract(id);
		return this.instances.has(abstract) || this.bindings.has(abstract);
	}

	/**
	 * Converts a supplied ID to an abstract which can be used to reference items in the container.
	 * @param id string | symbol
	 */
	public toAbstract(id: string | symbol): Abstract {
		if (typeof id !== 'string') {
			return id;
		}
		const abstract: Abstract = this.aliases.has(id) && this.aliases.get(id) || Symbol.for(`pmbAbstract;${this.normalize(id)}`);
		return abstract;
	}

	/**
	 * Normalizes a string ID for abstract resolution.
	 * @protected
	 */
	protected normalize(id: string): string {
		return id.trim().toLowerCase();
	}

	/**
	 * Calls or constructs a supplied binding, resolving any dependencies in the process.
	 * @protected
	 */
	protected build(binding: Binding, args: unknown[] = []): Concretion {
		//WARNING: If any of this is modified by a linter, abort the commit and add ignores. This code is critical, do not touch it.
		if (binding.target.prototype === undefined) {
			return (binding.target as DynamicBinding)(this);
		}
		const propertyInjections: Concretion[] = [];
		const constructorInjections: Concretion[] = [];
		if (binding.target && Symbols.INJECTION in binding.target) {
			const meta = (binding.target as StaticBinding)[Symbols.INJECTION] as InjectionMeta;

			meta.property.forEach(({ abstract, propertyKey }) =>
				propertyInjections.push([propertyKey, this.make(abstract)]),
			);
			meta.constructor.forEach(({ abstract, argumentIndex }) => {
				constructorInjections[argumentIndex] = this.make(abstract);
			});
		}

		const constructorArgs: unknown[] = args;

		for (const [idx, constructorInjection] of constructorInjections.entries()) {
			constructorArgs[idx] = constructorInjection;
		}

		const instance = Reflect.construct(binding.target, constructorArgs);

		for (const [propertyKey, concrete] of propertyInjections) {
			instance[propertyKey] = concrete;
		}
		return instance;
	}

	/**
	 * Debug: this is for debugging the container
	 * @protected
	 */
	protected [inspect.custom](): {
		aliases: Map<Alias, Abstract>;
		bindings: Map<Abstract, Binding>;
		instances: Map<Abstract, Concretion>;
	} {
		const { aliases, bindings, instances } = this;
		return { aliases, bindings, instances };
	}

}
its basic reflect-metadata cookie cutter stuff w some fancy type inference and casting
but, I'm not really sure it warrants being integrated / supported as a feature. I can tell you that its convenient and in our use cases safe / beneficial
as to whether it even fits into the overall design principles/patterns of pulumi... much better question
its a lot like when you inject a JS object's prototype with a get interceptor if you want to trace it throughout an async series of calls