diff --git a/package-lock.json b/package-lock.json index de55bd189..78bddae32 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "packages/common", "packages/core-bridge", "packages/create-project", + "packages/envconfig", "packages/interceptors-opentelemetry", "packages/nexus", "packages/nyc-test-coverage", @@ -27,6 +28,7 @@ "@temporalio/cloud": "file:packages/cloud", "@temporalio/common": "file:packages/common", "@temporalio/create": "file:packages/create-project", + "@temporalio/envconfig": "file:packages/envconfig", "@temporalio/interceptors-opentelemetry": "file:packages/interceptors-opentelemetry", "@temporalio/nexus": "file:packages/nexus", "@temporalio/nyc-test-coverage": "file:packages/nyc-test-coverage", @@ -2785,6 +2787,10 @@ "resolved": "packages/create-project", "link": true }, + "node_modules/@temporalio/envconfig": { + "resolved": "packages/envconfig", + "link": true + }, "node_modules/@temporalio/interceptors-opentelemetry": { "resolved": "packages/interceptors-opentelemetry", "link": true @@ -15871,6 +15877,18 @@ "npm": ">= 3.0.0" } }, + "node_modules/smol-toml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz", + "integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==", + "license": "BSD-3-Clause", + "engines": { + "node": ">= 18" + }, + "funding": { + "url": "https://github.com/sponsors/cyyynthia" + } + }, "node_modules/socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", @@ -18392,6 +18410,19 @@ "typedoc-plugin-markdown": "^3.17.1" } }, + "packages/envconfig": { + "name": "@temporalio/envconfig", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@temporalio/client": "file:../client", + "@temporalio/common": "file:../common", + "smol-toml": "1.4.2" + }, + "engines": { + "node": ">= 18.0.0" + } + }, "packages/interceptors-opentelemetry": { "name": "@temporalio/interceptors-opentelemetry", "version": "1.13.1", @@ -18514,6 +18545,7 @@ "@temporalio/cloud": "file:../cloud", "@temporalio/common": "file:../common", "@temporalio/core-bridge": "file:../core-bridge", + "@temporalio/envconfig": "file:../envconfig", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", "@temporalio/nexus": "file:../nexus", "@temporalio/nyc-test-coverage": "file:../nyc-test-coverage", @@ -20590,6 +20622,14 @@ "validate-npm-package-name": "^5.0.0" } }, + "@temporalio/envconfig": { + "version": "file:packages/envconfig", + "requires": { + "@temporalio/client": "file:../client", + "@temporalio/common": "file:../common", + "smol-toml": "1.4.2" + } + }, "@temporalio/interceptors-opentelemetry": { "version": "file:packages/interceptors-opentelemetry", "requires": { @@ -20656,6 +20696,7 @@ "@temporalio/cloud": "file:../cloud", "@temporalio/common": "file:../common", "@temporalio/core-bridge": "file:../core-bridge", + "@temporalio/envconfig": "file:../envconfig", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", "@temporalio/nexus": "file:../nexus", "@temporalio/nyc-test-coverage": "file:../nyc-test-coverage", @@ -29950,6 +29991,11 @@ "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "dev": true }, + "smol-toml": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.4.2.tgz", + "integrity": "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g==" + }, "socks": { "version": "2.8.7", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", diff --git a/package.json b/package.json index 93d406579..c7a9c5ff7 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "@temporalio/cloud": "file:packages/cloud", "@temporalio/common": "file:packages/common", "@temporalio/create": "file:packages/create-project", + "@temporalio/envconfig": "file:packages/envconfig", "@temporalio/interceptors-opentelemetry": "file:packages/interceptors-opentelemetry", "@temporalio/nexus": "file:packages/nexus", "@temporalio/nyc-test-coverage": "file:packages/nyc-test-coverage", @@ -87,6 +88,7 @@ "packages/common", "packages/core-bridge", "packages/create-project", + "packages/envconfig", "packages/interceptors-opentelemetry", "packages/nexus", "packages/nyc-test-coverage", diff --git a/packages/client/src/connection.ts b/packages/client/src/connection.ts index 4e18e430b..96b14fca9 100644 --- a/packages/client/src/connection.ts +++ b/packages/client/src/connection.ts @@ -204,10 +204,13 @@ function normalizeGRPCConfig(options?: ConnectionOptions): ConnectionOptions { if (credentials) { throw new TypeError('Both `tls` and `credentials` ConnectionOptions were provided'); } + const serverRootCert = tls.serverRootCACertificate && Buffer.from(tls.serverRootCACertificate); + const clientCertKey = tls.clientCertPair?.key && Buffer.from(tls.clientCertPair?.key); + const clientCertCrt = tls.clientCertPair?.crt && Buffer.from(tls.clientCertPair?.crt); return { ...rest, credentials: grpc.credentials.combineChannelCredentials( - grpc.credentials.createSsl(tls.serverRootCACertificate, tls.clientCertPair?.key, tls.clientCertPair?.crt), + grpc.credentials.createSsl(serverRootCert, clientCertKey, clientCertCrt), ...(callCredentials ?? []) ), channelArgs: { diff --git a/packages/common/src/internal-non-workflow/tls-config.ts b/packages/common/src/internal-non-workflow/tls-config.ts index 1a88aba5e..d499af652 100644 --- a/packages/common/src/internal-non-workflow/tls-config.ts +++ b/packages/common/src/internal-non-workflow/tls-config.ts @@ -12,13 +12,13 @@ export interface TLSConfig { * Root CA certificate used by the server. If not set, and the server's * cert is issued by someone the operating system trusts, verification will still work (ex: Cloud offering). */ - serverRootCACertificate?: Buffer; + serverRootCACertificate?: Uint8Array; /** Sets the client certificate and key for connecting with mTLS */ clientCertPair?: { /** The certificate for this client */ - crt: Buffer; + crt: Uint8Array; /** The private key for this client */ - key: Buffer; + key: Uint8Array; }; } diff --git a/packages/envconfig/README.md b/packages/envconfig/README.md new file mode 100644 index 000000000..0c0cd9400 --- /dev/null +++ b/packages/envconfig/README.md @@ -0,0 +1,5 @@ +# `@temporalio/envconfig` + +[![NPM](https://img.shields.io/npm/v/@temporalio/envconfig?style=for-the-badge)](https://www.npmjs.com/package/@temporalio/envconfig) + +Part of [Temporal](https://temporal.io)'s [TypeScript SDK](https://docs.temporal.io/typescript/introduction/). diff --git a/packages/envconfig/package.json b/packages/envconfig/package.json new file mode 100644 index 000000000..741b34d6b --- /dev/null +++ b/packages/envconfig/package.json @@ -0,0 +1,42 @@ +{ + "name": "@temporalio/envconfig", + "version": "0.0.1", + "description": "Temporal.io SDK Environment Configuration sub-package", + "main": "lib/index.js", + "types": "./lib/index.d.ts", + "scripts": {}, + "keywords": [ + "temporal", + "environment", + "configuration", + "client" + ], + "author": "Temporal Technologies Inc. ", + "license": "MIT", + "dependencies": { + "@temporalio/common": "file:../common", + "smol-toml": "1.4.2" + }, + "devDependencies": { + "@temporalio/worker": "file:../worker" + }, + "engines": { + "node": ">= 18.0.0" + }, + "bugs": { + "url": "https://github.com/temporalio/sdk-typescript/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/temporalio/sdk-typescript.git", + "directory": "packages/envconfig" + }, + "homepage": "https://github.com/temporalio/sdk-typescript/tree/main/packages/envconfig", + "publishConfig": { + "access": "public" + }, + "files": [ + "src", + "lib" + ] +} diff --git a/packages/envconfig/src/envconfig-toml.ts b/packages/envconfig/src/envconfig-toml.ts new file mode 100644 index 000000000..c96dd118a --- /dev/null +++ b/packages/envconfig/src/envconfig-toml.ts @@ -0,0 +1,341 @@ +import { readFileSync } from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import { parse, stringify, TomlTable } from 'smol-toml'; +import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow/objects-helpers'; +import { decode, encode } from '@temporalio/common/lib/encoding'; +import { ConfigDataSource, LoadClientConfigOptions, LoadClientProfileOptions } from './types'; + +export function normalizeGrpcMetaKey(key: string): string { + return key.toLocaleLowerCase().replace('_', '-'); +} + +/** + * Raw TOML structure representing the client configuration file. + * + * @internal + * @experimental Environment configuration is new feature and subject to change. + */ +export interface TomlClientConfig { + profile: Record; +} + +/** + * Raw TOML structure for a client configuration profile. + * Note: field names use snake_case to match TOML file fields for correct parser deserialization. + * + * @internal + * @experimental Environment configuration is new feature and subject to change. + */ +export interface TomlClientConfigProfile { + address?: string; + namespace?: string; + api_key?: string; + tls?: TomlClientConfigTLS; + codec?: TomlClientConfigCodec; + grpc_meta?: Record; +} + +/** + * Raw TOML structure for client configuration TLS. + * + * @internal + * @experimental Environment configuration is new feature and subject to change. + */ +export interface TomlClientConfigTLS { + disabled?: boolean; + client_cert_path?: string; + client_cert_data?: string; + client_key_path?: string; + client_key_data?: string; + server_ca_cert_path?: string; + server_ca_cert_data?: string; + server_name?: string; + disable_host_verification?: boolean; +} + +/** + * Raw TOML structure for client configuration codec. + * + * @internal + * @experimental Environment configuration is new feature and subject to change. + */ +export interface TomlClientConfigCodec { + endpoint?: string; + auth?: string; +} + +function sourceToStringData(source: ConfigDataSource | undefined): string | undefined { + if (source === undefined) { + return undefined; + } + if ('path' in source) { + return readFileSync(source.path, { encoding: 'utf-8' }); + } + + if (typeof source.data === 'string') { + return source.data; + } + + return decode(source.data); +} + +export function tomlLoadClientConfig(options: LoadClientConfigOptions): TomlClientConfig { + const envProvider: Record = options.overrideEnvVars ?? process.env; + + let configData = undefined; + try { + configData = sourceToStringData(options.configSource) ?? getFallbackConfigData(envProvider); + } catch (error) { + const isFileNotFound = (error as NodeJS.ErrnoException)?.code === 'ENOENT'; + if (!isFileNotFound) { + throw error; + } + // File not found is ok + } + if (configData !== undefined) { + return loadFromTomlData(configData, options.configFileStrict ?? false); + } + return { profile: {} }; // default ClientConfig +} + +export function loadFromTomlData(tomlData: string, isStrict: boolean): TomlClientConfig { + const parsed = parse(tomlData); + if (isStrict) { + strictValidateTomlStructure(parsed); + } + + return parsed as unknown as TomlClientConfig; +} + +export function configToTomlData(config: TomlClientConfig): Uint8Array { + return encode(stringify(config)); +} + +function strictValidateTomlStructure(parsed: TomlTable): void { + const allowedTopLevel = new Set(['profile']); + const allowedProfile = new Set(['address', 'namespace', 'api_key', 'tls', 'codec', 'grpc_meta']); + const allowedTLS = new Set([ + 'disabled', + 'client_cert_path', + 'client_key_path', + 'client_cert_data', + 'client_key_data', + 'server_ca_cert_path', + 'server_ca_cert_data', + 'server_name', + ]); + const allowedCodec = new Set(['endpoint', 'auth']); + + // Check top-level keys + const unknownTopLevel = Object.keys(parsed).filter((k) => !allowedTopLevel.has(k)); + if (unknownTopLevel.length > 0) { + throw new Error(`Validation error: key(s) unrecognized: ${unknownTopLevel.join(', ')}`); + } + + const profiles = parsed.profile; + if (profiles === undefined) return; + + // Ensure it's a TomlTable (not a primitive or array) + if (typeof profiles !== 'object' || Array.isArray(profiles)) { + throw new Error('Validation error: profile must be a table'); + } + + for (const [profileName, profileData] of Object.entries(profiles)) { + // Ensure profile is a table + if (typeof profileData !== 'object' || Array.isArray(profileData)) { + throw new Error(`Validation error: profile.${profileName} must be a table`); + } + + const unknownProfile = Object.keys(profileData).filter((k) => !allowedProfile.has(k)); + if (unknownProfile.length > 0) { + throw new Error(`Validation error: key(s) unrecognized in profile.${profileName}: ${unknownProfile.join(', ')}`); + } + + // Validate TLS + const tls = profileData.tls; + if (tls !== undefined) { + if (typeof tls !== 'object' || Array.isArray(tls)) { + throw new Error(`Validation error: profile.${profileName}.tls must be a table`); + } + const unknownTLS = Object.keys(tls).filter((k) => !allowedTLS.has(k)); + if (unknownTLS.length > 0) { + throw new Error( + `Validation error: key(s) unrecognized in profile.${profileName}.tls: ${unknownTLS.join(', ')}` + ); + } + } + + // Validate codec + const codec = profileData.codec; + if (codec !== undefined) { + if (typeof codec !== 'object' || Array.isArray(codec)) { + throw new Error(`Validation error: profile.${profileName}.codec must be a table`); + } + const unknownCodec = Object.keys(codec).filter((k) => !allowedCodec.has(k)); + if (unknownCodec.length > 0) { + throw new Error( + `Validation error: key(s) unrecognized in profile.${profileName}.codec: ${unknownCodec.join(', ')}` + ); + } + } + } +} + +function getFallbackConfigData(envProvider: Record): string | undefined { + // configSource was not set - fallback to TEMPORAL_CONFIG_FILE, then the default file path + let filePath = envProvider['TEMPORAL_CONFIG_FILE']; + if (filePath === undefined) { + filePath = getDefaultConfigFilePath(); + } + return readFileSync(filePath, { encoding: 'utf-8' }); +} + +export function tomlLoadClientConfigProfile(options: LoadClientProfileOptions): TomlClientConfigProfile { + if (options.disableEnv && options.disableFile) { + throw new Error('Cannot disable both file and environment loading'); + } + + const envProvider: Record = options.overrideEnvVars ?? process.env; + + let profile: TomlClientConfigProfile = {}; + + if (!options.disableFile) { + const tomlClientConfig = tomlLoadClientConfig({ + configSource: options.configSource, + configFileStrict: options.configFileStrict, + overrideEnvVars: options.overrideEnvVars, + }); + // If profile name not provided, fallback to env variable. + const profileName = options.profile ?? envProvider['TEMPORAL_PROFILE']; + // If env var also not provided, fallback to default profile name. + const tomlProfile = tomlClientConfig.profile[profileName ?? DEFAULT_CONFIG_FILE_PROFILE]; + // If toml profile does not exist and an explicit profile was requested, error. + if (tomlProfile === undefined && profileName) { + throw new Error(`Profile '${profileName}' not found in config data`); + } + // Use loaded profile if exists, otherwise fallback to default profile. + profile = tomlProfile ?? {}; + } + + if (!options.disableEnv) { + applyProfileEnvVars(profile, envProvider); + } + return profile; +} + +function applyProfileEnvVars(profile: TomlClientConfigProfile, envProvider: Record) { + profile.address = envProvider['TEMPORAL_ADDRESS'] ?? profile.address; + profile.namespace = envProvider['TEMPORAL_NAMESPACE'] ?? profile.namespace; + profile.api_key = envProvider['TEMPORAL_API_KEY'] ?? profile.api_key; + const tlsFromEnv = getTLSFromEnvVars(envProvider); + profile.tls = profile.tls || tlsFromEnv ? { ...profile.tls, ...tlsFromEnv } : undefined; + const codecFromEnv = getCodecFromEnvVars(envProvider); + profile.codec = profile.codec || codecFromEnv ? { ...profile.codec, ...codecFromEnv } : undefined; + applyGrpcMetaFromEnvVars(profile, envProvider); +} + +function getTLSFromEnvVars(envProvider: Record): TomlClientConfigTLS | undefined { + const tlsConfig: TomlClientConfigTLS = filterNullAndUndefined({ + disabled: envVarToBool(envProvider['TEMPORAL_TLS']), + client_cert_path: envProvider['TEMPORAL_TLS_CLIENT_CERT_PATH'], + client_cert_data: envProvider['TEMPORAL_TLS_CLIENT_CERT_DATA'], + client_key_path: envProvider['TEMPORAL_TLS_CLIENT_KEY_PATH'], + client_key_data: envProvider['TEMPORAL_TLS_CLIENT_KEY_DATA'], + server_ca_cert_path: envProvider['TEMPORAL_TLS_SERVER_CA_CERT_PATH'], + server_ca_cert_data: envProvider['TEMPORAL_TLS_SERVER_CA_CERT_DATA'], + server_name: envProvider['TEMPORAL_TLS_SERVER_NAME'], + disable_host_verification: envVarToBool(envProvider['TEMPORAL_TLS_DISABLE_HOST_VERIFICATION']), + }); + + // If no properties were added, return undefined + return Object.keys(tlsConfig).length > 0 ? tlsConfig : undefined; +} + +function getCodecFromEnvVars(envProvider: Record): TomlClientConfigCodec | undefined { + const codec: TomlClientConfigCodec = {}; + const endpoint = envProvider['TEMPORAL_CODEC_ENDPOINT']; + if (endpoint !== undefined) { + codec.endpoint = endpoint; + } + const auth = envProvider['TEMPORAL_CODEC_AUTH']; + if (auth !== undefined) { + codec.auth = auth; + } + // If no properties were added, return undefined + return Object.keys(codec).length > 0 ? codec : undefined; +} + +function applyGrpcMetaFromEnvVars(profile: TomlClientConfigProfile, envProvider: Record) { + const PREFIX = 'TEMPORAL_GRPC_META_'; + + for (const [key, value] of Object.entries(envProvider)) { + if (key.startsWith(PREFIX)) { + const headerName = key.slice(PREFIX.length); + const normalizedKey = normalizeGrpcMetaKey(headerName); + if (profile.grpc_meta === undefined) { + profile.grpc_meta = {}; + } + + // Empty values remove the key, non-empty values set it + if (value === '' || value == null) { + delete profile.grpc_meta[normalizedKey]; + } else { + profile.grpc_meta[normalizedKey] = value; + } + } + } +} + +function envVarToBool(envVar?: string): boolean | undefined { + if (envVar === undefined) { + return undefined; + } + return envVar === '1' || envVar === 'true'; +} + +const DEFAULT_CONFIG_FILE_PROFILE = 'default'; +const DEFAULT_CONFIG_FILE = 'config.toml'; + +function getDefaultConfigFilePath(): string { + const configDir = getUserConfigDir(); + const configPath = path.join(configDir, 'temporalio', DEFAULT_CONFIG_FILE); + return configPath; +} + +function getUserConfigDir(): string { + const platform = os.platform(); + + switch (platform) { + case 'win32': { + const dir = process.env.APPDATA; + if (!dir) { + throw new Error('%AppData% is not defined'); + } + return dir; + } + + case 'darwin': { + const dir = process.env.HOME; + if (!dir) { + throw new Error('$HOME is not defined'); + } + return path.join(dir, 'Library', 'Application Support'); + } + + default: { + // Unix/Linux + let dir = process.env.XDG_CONFIG_HOME; + if (!dir) { + const home = process.env.HOME; + if (!home) { + throw new Error('neither $XDG_CONFIG_HOME nor $HOME are defined'); + } + dir = path.join(home, '.config'); + } else if (!path.isAbsolute(dir)) { + throw new Error('path in $XDG_CONFIG_HOME is relative'); + } + return dir; + } + } +} diff --git a/packages/envconfig/src/envconfig.ts b/packages/envconfig/src/envconfig.ts new file mode 100644 index 000000000..57c6d547e --- /dev/null +++ b/packages/envconfig/src/envconfig.ts @@ -0,0 +1,78 @@ +import type { TLSConfig } from '@temporalio/common/lib/internal-non-workflow'; +import { decode } from '@temporalio/common/lib/encoding'; +import { + configToTomlData, + loadFromTomlData, + tomlLoadClientConfig, + tomlLoadClientConfigProfile, +} from './envconfig-toml'; +import { + ClientConfig, + ClientConfigFromTomlOptions, + ClientConfigProfile, + ClientConfigTLS, + ClientConnectConfig, + LoadClientConfigOptions, + LoadClientProfileOptions, +} from './types'; +import { fromTomlConfig, fromTomlProfile, loadConfigData, toTomlConfig } from './utils'; + +export function loadClientConfig(options: LoadClientConfigOptions): ClientConfig { + return fromTomlConfig(tomlLoadClientConfig(options)); +} + +export function loadClientConfigFromToml(tomlData: Uint8Array, options: ClientConfigFromTomlOptions): ClientConfig { + return fromTomlConfig(loadFromTomlData(decode(tomlData), options.strict)); +} + +export function clientConfigToToml(config: ClientConfig): Uint8Array { + return configToTomlData(toTomlConfig(config)); +} + +export function loadClientConfigProfile(options: LoadClientProfileOptions = {}): ClientConfigProfile { + return fromTomlProfile(tomlLoadClientConfigProfile(options)); +} + +export function loadClientConnectConfig(options: LoadClientProfileOptions = {}): ClientConnectConfig { + return toClientOptions(loadClientConfigProfile(options)); +} + +export function toClientOptions(profile: ClientConfigProfile): ClientConnectConfig { + // TLS is enabled if we have an explicit TLS config, or if an api key is provided. + const tls = toTLSConfig(profile.tls) ?? (profile.apiKey !== undefined ? true : undefined); + return { + namespace: profile.namespace, + connectionOptions: { + address: profile.address, + apiKey: profile.apiKey, + tls, + metadata: profile.grpcMeta, + }, + }; +} + +export function toTLSConfig(config?: ClientConfigTLS): TLSConfig | boolean | undefined { + if (config === undefined) { + return undefined; + } + if (config.disabled === true) { + return false; + } + + const serverRootCACert = loadConfigData(config.serverCACert); + const crtBuffer = loadConfigData(config.clientCert); + const keyBuffer = loadConfigData(config.clientKey); + + const tlsConfig: TLSConfig = { + serverNameOverride: config.serverName, + serverRootCACertificate: serverRootCACert, + clientCertPair: + crtBuffer && keyBuffer + ? { + crt: crtBuffer, + key: keyBuffer, + } + : undefined, + }; + return tlsConfig; +} diff --git a/packages/envconfig/src/index.ts b/packages/envconfig/src/index.ts new file mode 100644 index 000000000..eb47b0da0 --- /dev/null +++ b/packages/envconfig/src/index.ts @@ -0,0 +1,20 @@ +export { + loadClientConfig, + loadClientConfigProfile, + loadClientConnectConfig, + loadClientConfigFromToml, + clientConfigToToml, + toClientOptions, +} from './envconfig'; + +export { + ClientConfig, + ClientConfigProfile, + ClientConfigTLS, + LoadClientConfigOptions, + LoadClientProfileOptions, + ClientConfigFromTomlOptions, + ConfigDataSource, +} from './types'; + +export { fromTomlConfig, fromTomlProfile, toTomlConfig, toTomlProfile, loadConfigData } from './utils'; diff --git a/packages/envconfig/src/types.ts b/packages/envconfig/src/types.ts new file mode 100644 index 000000000..61f91e879 --- /dev/null +++ b/packages/envconfig/src/types.ts @@ -0,0 +1,116 @@ +import type { NativeConnectionOptions } from '@temporalio/worker'; + +/** + * A data source for configuration, which can be a path to a file, + * the string contents of a file, or raw bytes. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export type ConfigDataSource = { path: string } | { data: string | Uint8Array }; + +/** + * TLS configuration as specified as part of client configuration. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export interface ClientConfigTLS { + disabled?: boolean; + serverName?: string; + clientCert?: ConfigDataSource; + clientKey?: ConfigDataSource; + serverCACert?: ConfigDataSource; +} + +/** + * Configuration for connecting to a Temporal client, including connection options and namespace. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export interface ClientConnectConfig { + connectionOptions: NativeConnectionOptions; + namespace?: string; +} + +/** + * Options for loading a client configuration profile. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export interface LoadClientProfileOptions { + /** The name of the profile to load from the config. Defaults to "default". */ + profile?: string; + /** + * If present, this is used as the configuration source instead of default + * file locations. This can be a path to the file or the string/byte + * contents of the file. + */ + configSource?: ConfigDataSource; + /** + * If true, file loading is disabled. This is only used when `configSource` + * is not present. + */ + disableFile?: boolean; + /** If true, environment variable loading and overriding is disabled. */ + disableEnv?: boolean; + /** If true, will error on unrecognized keys in the TOML file. */ + configFileStrict?: boolean; + /** + * A dictionary of environment variables to use for loading and overrides. + * If not provided, the current process's environment is used. + */ + overrideEnvVars?: Record; +} + +/** + * A client configuration profile with connection settings for a Temporal client. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export interface ClientConfigProfile { + address?: string; + namespace?: string; + apiKey?: string; + tls?: ClientConfigTLS; + grpcMeta?: Record; +} + +/** + * Options for loading client configuration. + * @experimental Environment configuration is new feature and subject to change. + */ +export interface LoadClientConfigOptions { + /** + * If present, this is used as the configuration source instead of default + * file locations. This can be a path or the string/byte contents of the + * configuration file. + */ + configSource?: ConfigDataSource; + /** If true, will error on unrecognized keys in the TOML file. */ + configFileStrict?: boolean; + /** + * The environment variables to use for locating the + * default config file. If not provided, the current process's + * environment is used to check for `TEMPORAL_CONFIG_FILE`. + */ + overrideEnvVars?: Record; +} + +/** + * Client configuration represents a client config file. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export interface ClientConfig { + /** Map of profile name to its corresponding ClientConfigProfile. */ + profiles: Record; +} + +/** + * Options for parsing client configuration from TOML format. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export interface ClientConfigFromTomlOptions { + // If true, will error if there are unrecognized keys. + strict: boolean; +} diff --git a/packages/envconfig/src/utils.ts b/packages/envconfig/src/utils.ts new file mode 100644 index 000000000..ea5d4e63e --- /dev/null +++ b/packages/envconfig/src/utils.ts @@ -0,0 +1,172 @@ +import { readFileSync } from 'fs'; +import { filterNullAndUndefined } from '@temporalio/common/lib/internal-workflow'; +import { encode, decode } from '@temporalio/common/lib/encoding'; +import { ClientConfigProfile, ClientConfigTLS, ClientConfig, ConfigDataSource } from './types'; +import { normalizeGrpcMetaKey, TomlClientConfig, TomlClientConfigProfile, TomlClientConfigTLS } from './envconfig-toml'; + +/** + * Loads configuration data from a {@link ConfigDataSource} and returns it as a Uint8Array. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export function loadConfigData(source?: ConfigDataSource): Uint8Array | undefined { + if (!source) return undefined; + + if ('path' in source) { + return Uint8Array.from(readFileSync(source.path)); + } + + return typeof source.data === 'string' ? encode(source.data) : source.data; +} + +/** + * Converts a TOML profile structure to a {@link ClientConfigProfile}. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export function fromTomlProfile(tomlProfile: TomlClientConfigProfile): ClientConfigProfile { + let grpcMeta: Record | undefined = undefined; + if (tomlProfile.grpc_meta !== undefined) { + grpcMeta = {}; + // Normalize GRPC meta keys. + for (const [key, value] of Object.entries(tomlProfile.grpc_meta)) { + grpcMeta[normalizeGrpcMetaKey(key)] = value; + } + } + const profile: ClientConfigProfile = { + address: tomlProfile.address, + namespace: tomlProfile.namespace, + apiKey: tomlProfile.api_key, + tls: fromTomlTLS(tomlProfile.tls), + grpcMeta, + }; + return filterNullAndUndefined(profile); +} + +/** + * Converts a {@link ClientConfigProfile} to a TOML profile structure. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export function toTomlProfile(profile: ClientConfigProfile): TomlClientConfigProfile { + let grpc_meta: Record | undefined = undefined; + if (profile.grpcMeta !== undefined) { + grpc_meta = {}; + // Normalize GRPC meta keys. + for (const [key, value] of Object.entries(profile.grpcMeta)) { + grpc_meta[normalizeGrpcMetaKey(key)] = value; + } + } + const tomlProfile = { + address: profile.address, + namespace: profile.namespace, + api_key: profile.apiKey, + tls: toTomlTLS(profile.tls), + grpc_meta, + }; + return filterNullAndUndefined(tomlProfile); +} + +/** + * Converts a TOML TLS configuration structure to a {@link ClientConfigTLS}. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export function fromTomlTLS(tomlTLS?: TomlClientConfigTLS): ClientConfigTLS | undefined { + if (tomlTLS === undefined) { + return undefined; + } + const clientConfigTLS: ClientConfigTLS = { + disabled: tomlTLS.disabled, + serverName: tomlTLS.server_name, + clientCert: toConfigDataSource(tomlTLS.client_cert_path, tomlTLS.client_cert_data, 'client_cert'), + clientKey: toConfigDataSource(tomlTLS.client_key_path, tomlTLS.client_key_data, 'client_key'), + serverCACert: toConfigDataSource(tomlTLS.server_ca_cert_path, tomlTLS.server_ca_cert_data, 'server_ca_cert'), + }; + return filterNullAndUndefined(clientConfigTLS); +} + +/** + * Converts a {@link ClientConfigTLS} to a TOML TLS configuration structure. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export function toTomlTLS(tlsConfig?: ClientConfigTLS): TomlClientConfigTLS | undefined { + if (tlsConfig === undefined) { + return undefined; + } + const clientCert = toPathAndData(tlsConfig.clientCert); + const clientKey = toPathAndData(tlsConfig.clientKey); + const serverCACert = toPathAndData(tlsConfig.serverCACert); + const tomlConfigTLS = { + disabled: tlsConfig.disabled, + server_name: tlsConfig.serverName, + client_cert_path: clientCert?.path, + client_cert_data: clientCert?.data && decode(clientCert?.data), + client_key_path: clientKey?.path, + client_key_data: clientKey?.data && decode(clientKey?.data), + server_ca_cert_path: serverCACert?.path, + server_ca_cert_data: serverCACert?.data && decode(serverCACert?.data), + }; + return filterNullAndUndefined(tomlConfigTLS); +} + +/** + * Converts a TOML client configuration structure to a {@link ClientConfig}. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export function fromTomlConfig(tomlConfig: TomlClientConfig): ClientConfig { + const profiles: Record = {}; + + for (const [profileName, profile] of Object.entries(tomlConfig.profile)) { + profiles[profileName] = fromTomlProfile(profile); + } + + return { profiles }; +} + +/** + * Converts a {@link ClientConfig} to a TOML client configuration structure. + * + * @experimental Environment configuration is new feature and subject to change. + */ +export function toTomlConfig(config: ClientConfig): TomlClientConfig { + const profile: Record = {}; + + for (const [profileName, configProfile] of Object.entries(config.profiles)) { + profile[profileName] = toTomlProfile(configProfile); + } + + return { profile }; +} + +export function toPathAndData(source?: ConfigDataSource): { path?: string; data?: Uint8Array } | undefined { + if (source === undefined) { + return undefined; + } + if ('path' in source) { + return { path: source.path }; + } + if (typeof source.data === 'string') { + return { data: encode(source.data) }; + } + return { data: source.data }; +} + +function toConfigDataSource( + path: string | undefined, + data: string | undefined, + fieldName: string +): ConfigDataSource | undefined { + if (path !== undefined && data !== undefined) { + throw new Error(`Cannot specify both ${fieldName}_path and ${fieldName}_data`); + } + if (data !== undefined) { + return { data: encode(data) }; + } + if (path !== undefined) { + return { path }; + } + return undefined; +} diff --git a/packages/envconfig/tsconfig.json b/packages/envconfig/tsconfig.json new file mode 100644 index 000000000..557f4afc1 --- /dev/null +++ b/packages/envconfig/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "outDir": "./lib", + "rootDir": "./src" + }, + "references": [{ "path": "../common" }, { "path": "../worker" }], + "include": ["./src/**/*.ts"] +} diff --git a/packages/meta/package.json b/packages/meta/package.json index c2e000a58..fc5c381c4 100644 --- a/packages/meta/package.json +++ b/packages/meta/package.json @@ -5,6 +5,7 @@ "@temporalio/activity": "file:../activity", "@temporalio/client": "file:../client", "@temporalio/common": "file:../common", + "@temporalio/envconfig": "file:../envconfig", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", "@temporalio/nexus": "file:../nexus", "@temporalio/proto": "file:../proto", diff --git a/packages/meta/src/index.ts b/packages/meta/src/index.ts index c4ed0a6ef..45009335a 100644 --- a/packages/meta/src/index.ts +++ b/packages/meta/src/index.ts @@ -10,3 +10,4 @@ export * as client from '@temporalio/client'; export * as nexus from '@temporalio/nexus'; export * as testing from '@temporalio/testing'; export * as opentelemetry from '@temporalio/interceptors-opentelemetry'; +export * as envconfig from '@temporalio/envconfig'; diff --git a/packages/test/package.json b/packages/test/package.json index f93adc615..023d01308 100644 --- a/packages/test/package.json +++ b/packages/test/package.json @@ -37,6 +37,7 @@ "@temporalio/cloud": "file:../cloud", "@temporalio/common": "file:../common", "@temporalio/core-bridge": "file:../core-bridge", + "@temporalio/envconfig": "file:../envconfig", "@temporalio/interceptors-opentelemetry": "file:../interceptors-opentelemetry", "@temporalio/nexus": "file:../nexus", "@temporalio/nyc-test-coverage": "file:../nyc-test-coverage", diff --git a/packages/test/src/test-envconfig.ts b/packages/test/src/test-envconfig.ts new file mode 100644 index 000000000..db18d1647 --- /dev/null +++ b/packages/test/src/test-envconfig.ts @@ -0,0 +1,1044 @@ +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import test from 'ava'; +import dedent from 'dedent'; +import { Connection, Client } from '@temporalio/client'; +import { TestWorkflowEnvironment } from '@temporalio/testing'; +import { + ClientConfig, + ClientConfigProfile, + ConfigDataSource, + fromTomlConfig, + fromTomlProfile, + loadClientConfig, + loadClientConfigProfile, + loadClientConnectConfig, + toClientOptions, + toTomlConfig, + toTomlProfile, +} from '@temporalio/envconfig'; +import { toPathAndData } from '@temporalio/envconfig/lib/utils'; +import { NativeConnection } from '@temporalio/worker'; +import { encode } from '@temporalio/common/lib/encoding'; + +// Focused TOML fixtures +const TOML_CONFIG_BASE = dedent` + [profile.default] + address = "default-address" + namespace = "default-namespace" + + [profile.custom] + address = "custom-address" + namespace = "custom-namespace" + api_key = "custom-api-key" + [profile.custom.tls] + server_name = "custom-server-name" + [profile.custom.grpc_meta] + custom-header = "custom-value" +`; + +const TOML_CONFIG_STRICT_FAIL = dedent` + [profile.default] + address = "default-address" + unrecognized_field = "should-fail" +`; + +const TOML_CONFIG_TLS_DETAILED = dedent` + [profile.tls_disabled] + address = "localhost:1234" + [profile.tls_disabled.tls] + disabled = true + server_name = "should-be-ignored" + + [profile.tls_with_certs] + address = "localhost:5678" + [profile.tls_with_certs.tls] + server_name = "custom-server" + server_ca_cert_data = "ca-pem-data" + client_cert_data = "client-crt-data" + client_key_data = "client-key-data" +`; + +function withTempFile(content: string, fn: (filepath: string) => void): void { + const tempDir = os.tmpdir(); + const filepath = path.join(tempDir, `temporal-test-config-${Date.now()}-${Math.random()}.toml`); + fs.writeFileSync(filepath, content); + try { + fn(filepath); + } finally { + fs.unlinkSync(filepath); + } +} + +function pathSource(p: string): ConfigDataSource { + return { path: p }; +} +function dataSource(d: Uint8Array | string): ConfigDataSource { + return { data: typeof d === 'string' ? encode(d) : d }; +} + +// ============================================================================= +// 🔧 PROFILE LOADING +// ============================================================================= + +test('Load default profile from file', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const profile = loadClientConfigProfile({ configSource: pathSource(filepath) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + t.is(profile.apiKey, undefined); + t.is(profile.tls, undefined); + t.is(profile.grpcMeta, undefined); + + const { connectionOptions, namespace } = toClientOptions(profile); + t.is(connectionOptions.address, 'default-address'); + t.is(namespace, 'default-namespace'); + t.is(connectionOptions.apiKey, undefined); + t.is(connectionOptions.tls, undefined); + t.is(connectionOptions.metadata, undefined); + }); +}); + +test('Load custom profile from file', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const profile = loadClientConfigProfile({ profile: 'custom', configSource: pathSource(filepath) }); + t.is(profile.address, 'custom-address'); + t.is(profile.namespace, 'custom-namespace'); + t.is(profile.apiKey, 'custom-api-key'); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'custom-server-name'); + t.is(profile.grpcMeta?.['custom-header'], 'custom-value'); + + const { connectionOptions, namespace } = toClientOptions(profile); + t.is(connectionOptions.address, 'custom-address'); + t.is(namespace, 'custom-namespace'); + t.is(connectionOptions.apiKey, 'custom-api-key'); + const tls1 = connectionOptions.tls; + if (tls1 && typeof tls1 === 'object') { + t.is(tls1.serverNameOverride, 'custom-server-name'); + } else { + t.fail('expected TLS config object'); + } + t.is(connectionOptions.metadata?.['custom-header'], 'custom-value'); + }); +}); + +test('Load default profile from data', (t) => { + const profile = loadClientConfigProfile({ configSource: dataSource(TOML_CONFIG_BASE) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + t.is(profile.tls, undefined); +}); + +test('Load custom profile from data', (t) => { + const profile = loadClientConfigProfile({ + profile: 'custom', + configSource: dataSource(TOML_CONFIG_BASE), + }); + t.is(profile.address, 'custom-address'); + t.is(profile.namespace, 'custom-namespace'); + t.is(profile.apiKey, 'custom-api-key'); + t.is(profile.tls?.serverName, 'custom-server-name'); +}); + +test('Load profile from data with env overrides', (t) => { + const env = { + TEMPORAL_ADDRESS: 'env-address', + TEMPORAL_NAMESPACE: 'env-namespace', + }; + const profile = loadClientConfigProfile({ + configSource: dataSource(TOML_CONFIG_BASE), + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); +}); + +test('Load custom profile with env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { + TEMPORAL_ADDRESS: 'env-address', + TEMPORAL_NAMESPACE: 'env-namespace', + TEMPORAL_API_KEY: 'env-api-key', + TEMPORAL_TLS: 'true', + TEMPORAL_TLS_SERVER_NAME: 'env-server-name', + TEMPORAL_GRPC_META_CUSTOM_HEADER: 'env-value', + TEMPORAL_GRPC_META_ANOTHER_HEADER: 'another-value', + }; + const profile = loadClientConfigProfile({ + profile: 'custom', + configSource: pathSource(filepath), + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); + t.is(profile.apiKey, 'env-api-key'); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'env-server-name'); + t.is(profile.grpcMeta?.['custom-header'], 'env-value'); + t.is(profile.grpcMeta?.['another-header'], 'another-value'); + }); +}); + +test('Load profiles with string content', (t) => { + const stringContent = TOML_CONFIG_BASE; + const profile = loadClientConfigProfile({ configSource: dataSource(stringContent) }); + t.is(profile.address, 'default-address'); + t.is(profile.namespace, 'default-namespace'); + + // Test with custom profile from string + const profileCustom = loadClientConfigProfile({ + profile: 'custom', + configSource: dataSource(stringContent), + }); + t.is(profileCustom.address, 'custom-address'); + t.is(profileCustom.apiKey, 'custom-api-key'); +}); + +test('loadClientConnectConfig works with file path and env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + // From file + let cc = loadClientConnectConfig({ configSource: pathSource(filepath) }); + t.is(cc.connectionOptions.address, 'default-address'); + t.is(cc.namespace, 'default-namespace'); + + // With env overrides + cc = loadClientConnectConfig({ + configSource: pathSource(filepath), + overrideEnvVars: { TEMPORAL_NAMESPACE: 'env-namespace-override' }, + }); + t.is(cc.namespace, 'env-namespace-override'); + }); +}); + +// ============================================================================= +// 🌍 ENVIRONMENT VARIABLES +// ============================================================================= + +test('Load profile with grpc metadata env overrides', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.grpc_meta] + original-header = "original-value" + `; + const env = { + TEMPORAL_GRPC_META_NEW_HEADER: 'new-value', + TEMPORAL_GRPC_META_OVERRIDE_HEADER: 'overridden-value', + }; + const profile = loadClientConfigProfile({ + configSource: dataSource(toml), + overrideEnvVars: env, + }); + t.is(profile.grpcMeta?.['original-header'], 'original-value'); + t.is(profile.grpcMeta?.['new-header'], 'new-value'); + t.is(profile.grpcMeta?.['override-header'], 'overridden-value'); +}); + +test('gRPC metadata normalization from TOML', (t) => { + const toml = dedent` + [profile.foo] + address = "addr" + [profile.foo.grpc_meta] + sOme-hEader_key = "some-value" + `; + const conf = loadClientConfig({ configSource: dataSource(toml) }); + const prof = conf.profiles['foo']; + t.truthy(prof); + t.is(prof.grpcMeta?.['some-header-key'], 'some-value'); +}); + +test('gRPC metadata deletion via empty env value', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.grpc_meta] + some-header = "keep" + remove-me = "to-be-removed" + `; + const env = { + TEMPORAL_GRPC_META_REMOVE_ME: '', + TEMPORAL_GRPC_META_NEW_HEADER: 'added', + }; + const prof = loadClientConfigProfile({ configSource: dataSource(toml), overrideEnvVars: env }); + t.is(prof.grpcMeta?.['some-header'], 'keep'); + t.is(prof.grpcMeta?.['new-header'], 'added'); + t.false(Object.prototype.hasOwnProperty.call(prof.grpcMeta, 'remove-me')); +}); + +test('Load profile with disable env flag', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { TEMPORAL_ADDRESS: 'env-address' }; + const profile = loadClientConfigProfile({ + configSource: pathSource(filepath), + overrideEnvVars: env, + disableEnv: true, + }); + t.is(profile.address, 'default-address'); + }); +}); + +// ============================================================================= +// 🎛️ CONTROL FLAGS +// ============================================================================= + +test('Load profile with disabled file flag', (t) => { + const env = { TEMPORAL_ADDRESS: 'env-address', TEMPORAL_NAMESPACE: 'env-namespace' }; + const profile = loadClientConfigProfile({ + configSource: pathSource('/non_existent_file.toml'), + disableFile: true, + overrideEnvVars: env, + }); + t.is(profile.address, 'env-address'); + t.is(profile.namespace, 'env-namespace'); +}); + +test('Load profiles without profile-level env overrides', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const env = { TEMPORAL_ADDRESS: 'should-be-ignored' }; + // loadClientConfig doesn't apply env overrides, so we test it loads correctly + const conf = loadClientConfig({ + configSource: pathSource(filepath), + overrideEnvVars: env, + }); + t.is(conf.profiles['default'].address, 'default-address'); + + // Test that profile-level loading with disableEnv ignores environment + const profile = loadClientConfigProfile({ + configSource: pathSource(filepath), + overrideEnvVars: env, + disableEnv: true, + }); + t.is(profile.address, 'default-address'); + }); +}); + +test('Cannot disable both file and env override flags', (t) => { + const err = t.throws(() => + loadClientConfigProfile({ + configSource: pathSource('/non_existent_file.toml'), + disableFile: true, + disableEnv: true, + }) + ); + t.truthy(err); + t.true(String(err?.message).includes('Cannot disable both')); +}); + +// ============================================================================= +// 📁 CONFIG DISCOVERY +// ============================================================================= + +test('Load all profiles from file', (t) => { + const conf = loadClientConfig({ configSource: dataSource(TOML_CONFIG_BASE) }); + t.truthy(conf.profiles['default']); + t.truthy(conf.profiles['custom']); + t.is(conf.profiles['default'].address, 'default-address'); + t.is(conf.profiles['custom'].apiKey, 'custom-api-key'); +}); + +test('Load all profiles from data', (t) => { + const configData = dedent` + [profile.alpha] + address = "alpha-address" + namespace = "alpha-namespace" + + [profile.beta] + address = "beta-address" + api_key = "beta-key" + `; + const conf = loadClientConfig({ configSource: dataSource(configData) }); + t.truthy(conf.profiles['alpha']); + t.truthy(conf.profiles['beta']); + t.is(conf.profiles['alpha'].address, 'alpha-address'); + t.is(conf.profiles['beta'].apiKey, 'beta-key'); +}); + +test('Load profiles from non-existent file', (t) => { + const conf = loadClientConfig({ + configSource: pathSource('/non_existent_file.toml'), + }); + t.deepEqual(conf.profiles, {}); +}); + +test('Load all profiles with overridden file path', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const conf = loadClientConfig({ overrideEnvVars: { TEMPORAL_CONFIG_FILE: filepath } }); + t.truthy(conf.profiles['default']); + t.is(conf.profiles['default'].address, 'default-address'); + }); +}); + +test('Default profile not found returns empty profile', (t) => { + const toml = dedent` + [profile.existing] + address = "my-address" + `; + const prof = loadClientConfigProfile({ configSource: dataSource(toml) }); + t.is(prof.address, undefined); + t.is(prof.namespace, undefined); + t.is(prof.apiKey, undefined); + t.is(prof.grpcMeta, undefined); + t.is(prof.tls, undefined); + t.deepEqual(prof, {}); +}); + +// ============================================================================= +// 🔐 TLS CONFIGURATION +// ============================================================================= + +test('Load profile with api key (enables TLS)', (t) => { + const toml = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + `; + const profile = loadClientConfigProfile({ configSource: dataSource(toml) }); + t.is(profile.tls, undefined); + t.is(profile.tls?.disabled, undefined); + const { connectionOptions } = toClientOptions(profile); + t.true(connectionOptions.tls); +}); + +test('Load profile with TLS options', (t) => { + const configSource = dataSource(TOML_CONFIG_TLS_DETAILED); + + const profileDisabled = loadClientConfigProfile({ configSource, profile: 'tls_disabled' }); + t.truthy(profileDisabled.tls); + t.true(profileDisabled.tls?.disabled); + t.is(profileDisabled.tls?.serverName, 'should-be-ignored'); + const { connectionOptions: connOptsDisabled } = toClientOptions(profileDisabled); + t.is(connOptsDisabled.tls, false); + + const profileCerts = loadClientConfigProfile({ configSource, profile: 'tls_with_certs' }); + t.truthy(profileCerts.tls); + t.is(profileCerts.tls?.serverName, 'custom-server'); + + const serverCACert = toPathAndData(profileCerts.tls?.serverCACert); + t.deepEqual(serverCACert?.data, encode('ca-pem-data')); + t.is(serverCACert?.path, undefined); + + const clientCert = toPathAndData(profileCerts.tls?.clientCert); + t.deepEqual(clientCert?.data, encode('client-crt-data')); + t.is(clientCert?.path, undefined); + + const clientKey = toPathAndData(profileCerts.tls?.clientKey); + t.deepEqual(clientKey?.data, encode('client-key-data')); + t.is(clientKey?.path, undefined); + + const { connectionOptions: connOptsCerts } = toClientOptions(profileCerts); + const tls2 = connOptsCerts.tls; + if (tls2 && typeof tls2 === 'object') { + t.is(tls2.serverNameOverride, 'custom-server'); + t.deepEqual(tls2.serverRootCACertificate, encode('ca-pem-data')); + t.deepEqual(tls2.clientCertPair?.crt, encode('client-crt-data')); + t.deepEqual(tls2.clientCertPair?.key, encode('client-key-data')); + } else { + t.fail('expected TLS config object'); + } +}); + +test('Load profile with TLS options as file paths', (t) => { + withTempFile('ca-pem-data', (caPath) => { + withTempFile('client-crt-data', (certPath) => { + withTempFile('client-key-data', (keyPath) => { + // Normalize paths to use forward slashes for TOML compatibility (Windows uses backslashes) + const normalizedCaPath = caPath.replace(/\\/g, '/'); + const normalizedCertPath = certPath.replace(/\\/g, '/'); + const normalizedKeyPath = keyPath.replace(/\\/g, '/'); + + const tomlConfig = dedent` + [profile.default] + address = "localhost:5678" + [profile.default.tls] + server_name = "custom-server" + server_ca_cert_path = "${normalizedCaPath}" + client_cert_path = "${normalizedCertPath}" + client_key_path = "${normalizedKeyPath}" + `; + const profile = loadClientConfigProfile({ configSource: dataSource(tomlConfig) }); + t.truthy(profile.tls); + t.is(profile.tls?.serverName, 'custom-server'); + + const serverCACert = toPathAndData(profile.tls?.serverCACert); + t.is(serverCACert?.data, undefined); + t.deepEqual(serverCACert?.path, normalizedCaPath); + + const clientCert = toPathAndData(profile.tls?.clientCert); + t.is(clientCert?.data, undefined); + t.deepEqual(clientCert?.path, normalizedCertPath); + + const clientKey = toPathAndData(profile.tls?.clientKey); + t.is(clientKey?.data, undefined); + t.deepEqual(clientKey?.path, normalizedKeyPath); + + const { connectionOptions: connOpts } = toClientOptions(profile); + const tls3 = connOpts.tls; + if (tls3 && typeof tls3 === 'object') { + t.is(tls3.serverNameOverride, 'custom-server'); + t.deepEqual(tls3.serverRootCACertificate, encode('ca-pem-data')); + t.deepEqual(tls3.clientCertPair?.crt, encode('client-crt-data')); + t.deepEqual(tls3.clientCertPair?.key, encode('client-key-data')); + } else { + t.fail('expected TLS config object'); + } + }); + }); + }); +}); + +test('Load profile with conflicting cert source fails', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.tls] + client_cert_path = "some-path" + client_cert_data = "some-data" + `; + const err = t.throws(() => loadClientConfigProfile({ configSource: dataSource(toml) })); + t.truthy(err); + t.true(String(err?.message).includes('Cannot specify both')); +}); + +test('TLS conflict across sources: path in TOML, data in env should error', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.tls] + client_cert_path = "some-path" + `; + const env = { TEMPORAL_TLS_CLIENT_CERT_DATA: 'some-data' }; + const err = t.throws(() => loadClientConfigProfile({ configSource: dataSource(toml), overrideEnvVars: env })); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('path') + ); +}); + +test('TLS conflict across sources: data in TOML, path in env should error', (t) => { + const toml = dedent` + [profile.default] + address = "addr" + [profile.default.tls] + client_cert_data = "some-data" + `; + const env = { TEMPORAL_TLS_CLIENT_CERT_PATH: 'some-path' }; + const err = t.throws(() => loadClientConfigProfile({ configSource: dataSource(toml), overrideEnvVars: env })); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('data') + ); +}); + +test('TLS disabled tri-state behavior', (t) => { + // Test 1: disabled=null (unset) with API key -> TLS enabled + const tomlNull = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + [profile.default.tls] + server_name = "my-server" + `; + const profileNull = loadClientConfigProfile({ configSource: dataSource(tomlNull) }); + t.truthy(profileNull.tls); + t.is(profileNull.tls?.disabled, undefined); // disabled is null (unset) + const configNull = toClientOptions(profileNull); + t.truthy(configNull.connectionOptions.tls); // TLS enabled + + // Test 2: disabled=false (explicitly enabled) -> TLS enabled + const tomlFalse = dedent` + [profile.default] + address = "my-address" + [profile.default.tls] + disabled = false + server_name = "my-server" + `; + const profileFalse = loadClientConfigProfile({ configSource: dataSource(tomlFalse) }); + t.truthy(profileFalse.tls); + t.is(profileFalse.tls?.disabled, false); // explicitly disabled=false + const configFalse = toClientOptions(profileFalse); + t.truthy(configFalse.connectionOptions.tls); // TLS enabled + + // Test 3: disabled=true (explicitly disabled) -> TLS disabled even with API key + const tomlTrue = dedent` + [profile.default] + address = "my-address" + api_key = "my-api-key" + [profile.default.tls] + disabled = true + server_name = "should-be-ignored" + `; + const profileTrue = loadClientConfigProfile({ configSource: dataSource(tomlTrue) }); + t.truthy(profileTrue.tls); + t.is(profileTrue.tls?.disabled, true); // explicitly disabled=true + const configTrue = toClientOptions(profileTrue); + t.is(configTrue.connectionOptions.tls, false); // TLS explicitly disabled even with API key +}); + +// ============================================================================= +// 🚫 ERROR HANDLING +// ============================================================================= + +test('Load non-existent profile', (t) => { + withTempFile(TOML_CONFIG_BASE, (filepath) => { + const err = t.throws(() => loadClientConfigProfile({ configSource: pathSource(filepath), profile: 'nonexistent' })); + t.truthy(err); + t.true(String(err?.message).includes("Profile 'nonexistent' not found")); + }); +}); + +test('Load invalid config with strict mode enabled', (t) => { + const toml = dedent` + [unrecognized_table] + foo = "bar" + `; + const err = t.throws(() => loadClientConfig({ configSource: dataSource(toml), configFileStrict: true })); + t.truthy(err); + t.true(String(err?.message).includes('unrecognized_table')); +}); + +test('Load invalid profile with strict mode enabled', (t) => { + withTempFile(TOML_CONFIG_STRICT_FAIL, (filepath) => { + const err = t.throws(() => loadClientConfigProfile({ configSource: pathSource(filepath), configFileStrict: true })); + t.truthy(err); + t.true(String(err?.message).includes('unrecognized_field')); + }); +}); + +test('Load profiles with malformed TOML', (t) => { + const err = t.throws(() => loadClientConfig({ configSource: dataSource('this is not valid toml') })); + t.truthy(err); + t.true( + String(err?.message) + .toLowerCase() + .includes('toml') + ); +}); + +// ============================================================================= +// 🔄 SERIALIZATION +// ============================================================================= + +test('Client config profile to/from TOML round-trip', (t) => { + const profile: ClientConfigProfile = { + address: 'some-address', + namespace: 'some-namespace', + apiKey: 'some-api-key', + tls: { + serverName: 'some-server', + serverCACert: { data: encode('ca') }, + clientCert: { path: '/path/to/client.crt' }, + clientKey: { data: encode('key') }, + }, + grpcMeta: { 'some-header': 'some-value' }, + }; + const tomlProfile = toTomlProfile(profile); + const back = fromTomlProfile(tomlProfile); + t.is(back.address, 'some-address'); + t.is(back.namespace, 'some-namespace'); + t.is(back.apiKey, 'some-api-key'); + t.truthy(back.tls); + t.is(back.tls?.serverName, 'some-server'); + + const serverCACert = toPathAndData(back.tls?.serverCACert); + t.deepEqual(serverCACert?.data, encode('ca')); + t.is(serverCACert?.path, undefined); + + const clientCert = toPathAndData(back.tls?.clientCert); + t.is(clientCert?.data, undefined); + t.deepEqual(clientCert?.path, '/path/to/client.crt'); + + const clientKey = toPathAndData(back.tls?.clientKey); + t.deepEqual(clientKey?.data, encode('key')); + t.is(clientKey?.path, undefined); + + t.is(back.grpcMeta?.['some-header'], 'some-value'); +}); + +test('Client config to/from TOML round-trip', (t) => { + const conf: ClientConfig = { + profiles: { + default: { address: 'addr', namespace: 'ns', grpcMeta: {} }, + custom: { address: 'addr2', apiKey: 'key2', grpcMeta: { h: 'v' } }, + }, + }; + const tomlConfig = toTomlConfig(conf); + const back = fromTomlConfig(tomlConfig); + t.is(back.profiles['default'].address, 'addr'); + t.is(back.profiles['default'].namespace, 'ns'); + t.is(back.profiles['custom'].address, 'addr2'); + t.is(back.profiles['custom'].apiKey, 'key2'); + t.is(back.profiles['custom'].grpcMeta?.['h'], 'v'); +}); + +// ============================================================================= +// 🎯 INTEGRATION/E2E +// ============================================================================= + +test('Create client with default profile, no config', async (t) => { + // Start a local test server + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + // Load config via envconfig + const { connectionOptions, namespace } = loadClientConnectConfig(); + // Override address with test env address. + connectionOptions.address = address; + + // Create connection and client with loaded config + const connection = await Connection.connect(connectionOptions); + const client = new Client({ + connection, + namespace, + }); + + // If we got here without throwing, the connection is working + t.truthy(client); + t.truthy(client.connection); + + // Clean up + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from default profile', async (t) => { + // Start a local test server + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create TOML config with test server address + const toml = dedent` + [profile.default] + address = "${address}" + namespace = "default" + `; + + // Load config via envconfig + const { connectionOptions, namespace } = loadClientConnectConfig({ + configSource: dataSource(toml), + }); + + // Verify loaded values + t.is(connectionOptions.address, address); + t.is(namespace, 'default'); + + // Create connection and client with loaded config + const connection = await Connection.connect(connectionOptions); + const client = new Client({ + connection, + namespace: namespace || 'default', + }); + + // If we got here without throwing, the connection is working + t.truthy(client); + t.truthy(client.connection); + + // Clean up + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client with NativeConnection from default profile', async (t) => { + // Start a local test server + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create TOML config with test server address + const toml = dedent` + [profile.default] + address = "${address}" + namespace = "default" + `; + + // Load config via envconfig + const { connectionOptions, namespace } = loadClientConnectConfig({ + configSource: dataSource(toml), + }); + + // Verify loaded values + t.is(connectionOptions.address, address); + t.is(namespace, 'default'); + + // Create connection and client with loaded config + const connection = await NativeConnection.connect(connectionOptions); + const client = new Client({ + connection, + namespace: namespace || 'default', + }); + + // If we got here without throwing, the connection is working + t.truthy(client); + t.truthy(client.connection); + + // Clean up + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from custom profile', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create basic development profile configuration + const toml = dedent` + [profile.development] + address = "${address}" + namespace = "development-namespace" + `; + + // Load profile and create connection + const profile = loadClientConfigProfile({ + profile: 'development', + configSource: dataSource(toml), + }); + + t.is(profile.address, address); + t.is(profile.namespace, 'development-namespace'); + t.is(profile.apiKey, undefined); + t.is(profile.tls, undefined); + + const { connectionOptions, namespace } = toClientOptions(profile); + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + // Verify the client can perform basic operations + t.truthy(client); + t.truthy(client.connection); + t.is(client.options.namespace, 'development-namespace'); + + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from custom profile with TLS options', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Create production profile with API key (auto-enables TLS but disabled for local test) + const toml = dedent` + [profile.production] + address = "${address}" + namespace = "production-namespace" + api_key = "prod-api-key-12345" + [profile.production.tls] + disabled = true + `; + + // Load profile and verify TLS/API key handling + const profile = loadClientConfigProfile({ + profile: 'production', + configSource: dataSource(toml), + }); + + t.is(profile.address, address); + t.is(profile.namespace, 'production-namespace'); + t.is(profile.apiKey, 'prod-api-key-12345'); + t.truthy(profile.tls); + t.true(!!profile.tls?.disabled); + + const { connectionOptions, namespace } = toClientOptions(profile); + + // Verify API key is present but TLS is disabled for local testing + t.is(connectionOptions.apiKey, 'prod-api-key-12345'); + t.is(connectionOptions.tls, false); // disabled = true results in tls being false + + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + t.truthy(client); + t.is(client.options.namespace, 'production-namespace'); + + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create client from default profile with env overrides', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Base config that will be overridden by environment + const toml = dedent` + [profile.default] + address = "original-address" + namespace = "original-namespace" + `; + + // Environment overrides + const envOverrides = { + TEMPORAL_ADDRESS: address, // Override with test server address + TEMPORAL_NAMESPACE: 'env-override-namespace', + TEMPORAL_GRPC_META_CUSTOM_HEADER: 'env-header-value', + }; + + // Load profile with environment overrides + const profile = loadClientConfigProfile({ + configSource: dataSource(toml), + overrideEnvVars: envOverrides, + }); + + // Verify environment variables took precedence + t.is(profile.address, address); + t.is(profile.namespace, 'env-override-namespace'); + t.is(profile.grpcMeta?.['custom-header'], 'env-header-value'); + + const { connectionOptions, namespace } = toClientOptions(profile); + const connection = await Connection.connect(connectionOptions); + const client = new Client({ connection, namespace: namespace || 'default' }); + + // Verify client uses overridden values + t.truthy(client); + t.is(client.options.namespace, 'env-override-namespace'); + t.is(connectionOptions.metadata?.['custom-header'], 'env-header-value'); + + await connection.close(); + } finally { + await env.teardown(); + } +}); + +test('Create clients from multi-profile config', async (t) => { + const env = await TestWorkflowEnvironment.createLocal(); + + try { + const { address } = env.connection.options; + + // Multi-profile configuration + const toml = dedent` + [profile.service-a] + address = "${address}" + namespace = "service-a-namespace" + [profile.service-a.grpc_meta] + service-name = "service-a" + + [profile.service-b] + address = "${address}" + namespace = "service-b-namespace" + [profile.service-b.grpc_meta] + service-name = "service-b" + priority = "high" + `; + + // Load different profiles and create separate clients + const profileA = loadClientConfigProfile({ + profile: 'service-a', + configSource: dataSource(toml), + }); + + const profileB = loadClientConfigProfile({ + profile: 'service-b', + configSource: dataSource(toml), + }); + + // Verify profiles are distinct + t.is(profileA.namespace, 'service-a-namespace'); + t.is(profileA.grpcMeta?.['service-name'], 'service-a'); + t.false('priority' in (profileA.grpcMeta ?? {})); + + t.is(profileB.namespace, 'service-b-namespace'); + t.is(profileB.grpcMeta?.['service-name'], 'service-b'); + t.is(profileB.grpcMeta?.['priority'], 'high'); + + // Create separate client connections + const configA = toClientOptions(profileA); + const configB = toClientOptions(profileB); + + const connectionA = await Connection.connect(configA.connectionOptions); + const connectionB = await Connection.connect(configB.connectionOptions); + + const clientA = new Client({ connection: connectionA, namespace: configA.namespace || 'default' }); + const clientB = new Client({ connection: connectionB, namespace: configB.namespace || 'default' }); + + // Verify both clients work with their respective configurations + t.truthy(clientA); + t.truthy(clientB); + t.is(clientA.options.namespace, 'service-a-namespace'); + t.is(clientB.options.namespace, 'service-b-namespace'); + + // Verify metadata is correctly set for each connection + t.is(configA.connectionOptions.metadata?.['service-name'], 'service-a'); + t.is(configB.connectionOptions.metadata?.['service-name'], 'service-b'); + t.is(configB.connectionOptions.metadata?.['priority'], 'high'); + + await connectionA.close(); + await connectionB.close(); + } finally { + await env.teardown(); + } +}); + +test('Comprehensive E2E validation test', (t) => { + // Test comprehensive end-to-end configuration loading with all features + const tomlContent = dedent` + [profile.production] + address = "prod.temporal.com:443" + namespace = "production-ns" + api_key = "prod-api-key" + + [profile.production.tls] + server_name = "prod.temporal.com" + server_ca_cert_data = "prod-ca-cert" + + [profile.production.grpc_meta] + authorization = "Bearer prod-token" + "x-custom-header" = "prod-value" + `; + + const envOverrides = { + TEMPORAL_GRPC_META_X_ENVIRONMENT: 'production', + TEMPORAL_TLS_SERVER_NAME: 'override.temporal.com', + }; + + const { connectionOptions, namespace } = loadClientConnectConfig({ + profile: 'production', + configSource: dataSource(tomlContent), + overrideEnvVars: envOverrides, + }); + + // Validate all configuration aspects + t.is(connectionOptions.address, 'prod.temporal.com:443'); + t.is(namespace, 'production-ns'); + t.is(connectionOptions.apiKey, 'prod-api-key'); + + // TLS configuration (API key should auto-enable TLS) + t.truthy(connectionOptions.tls); + const tls = connectionOptions.tls; + if (tls && typeof tls === 'object') { + t.is(tls.serverNameOverride, 'override.temporal.com'); // Env override + t.deepEqual(tls.serverRootCACertificate, encode('prod-ca-cert')); + } else { + t.fail('expected TLS config object'); + } + + // gRPC metadata with normalization and env overrides + t.truthy(connectionOptions.metadata); + const metadata = connectionOptions.metadata!; + t.is(metadata['authorization'], 'Bearer prod-token'); + t.is(metadata['x-custom-header'], 'prod-value'); + t.is(metadata['x-environment'], 'production'); // From env +}); diff --git a/packages/test/tsconfig.json b/packages/test/tsconfig.json index 3e55850d4..f9985819b 100644 --- a/packages/test/tsconfig.json +++ b/packages/test/tsconfig.json @@ -12,6 +12,7 @@ { "path": "../client" }, { "path": "../cloud" }, { "path": "../common" }, + { "path": "../envconfig" }, { "path": "../interceptors-opentelemetry" }, { "path": "../nexus" }, { "path": "../testing" }, diff --git a/packages/worker/src/connection-options.ts b/packages/worker/src/connection-options.ts index 41bdb80b4..0b81dd12a 100644 --- a/packages/worker/src/connection-options.ts +++ b/packages/worker/src/connection-options.ts @@ -71,11 +71,11 @@ export function toNativeClientOptions(options: NativeConnectionOptions): native. const tls: native.TLSConfig | null = tlsInput ? { domain: tlsInput.serverNameOverride ?? null, - serverRootCaCert: tlsInput.serverRootCACertificate ?? null, + serverRootCaCert: tlsInput.serverRootCACertificate ? Buffer.from(tlsInput.serverRootCACertificate) : null, clientTlsConfig: tlsInput.clientCertPair ? { - clientCert: tlsInput.clientCertPair.crt, - clientPrivateKey: tlsInput.clientCertPair.key, + clientCert: tlsInput.clientCertPair.crt && Buffer.from(tlsInput.clientCertPair.crt), + clientPrivateKey: tlsInput.clientCertPair.key && Buffer.from(tlsInput.clientCertPair.key), } : null, } diff --git a/tsconfig.prune.json b/tsconfig.prune.json index e531d3546..edb96b374 100644 --- a/tsconfig.prune.json +++ b/tsconfig.prune.json @@ -12,6 +12,7 @@ "./packages/common/src/index.ts", "./packages/common/src/type-helpers.ts", "./packages/create-project/src/index.ts", + "./packages/envconfig/src/index.ts", "./packages/interceptors-opentelemetry/src/index.ts", "./packages/nyc-test-coverage/src/index.ts", "./packages/meta/src/index.ts",