diff --git a/gradle/jacoco.gradle b/gradle/jacoco.gradle index 7a738f17ef..43bf9075a8 100644 --- a/gradle/jacoco.gradle +++ b/gradle/jacoco.gradle @@ -13,6 +13,7 @@ dependencies { jacocoAggregation project(':temporal-spring-boot-starter') jacocoAggregation project(':temporal-test-server') jacocoAggregation project(':temporal-testing') + jacocoAggregation project(':temporal-envconfig') } def jacocoExclusions = [ diff --git a/settings.gradle b/settings.gradle index cca3ef0b82..cecb189451 100644 --- a/settings.gradle +++ b/settings.gradle @@ -9,4 +9,5 @@ include 'temporal-kotlin' include 'temporal-spring-boot-autoconfigure' include 'temporal-spring-boot-starter' include 'temporal-remote-data-encoder' -include 'temporal-shaded' \ No newline at end of file +include 'temporal-shaded' +include 'temporal-envconfig' \ No newline at end of file diff --git a/temporal-bom/build.gradle b/temporal-bom/build.gradle index bc4d5b0500..8f5a8971d2 100644 --- a/temporal-bom/build.gradle +++ b/temporal-bom/build.gradle @@ -16,5 +16,6 @@ dependencies { api project(':temporal-spring-boot-starter') api project(':temporal-test-server') api project(':temporal-testing') + api project(':temporal-envconfig') } } diff --git a/temporal-envconfig/build.gradle b/temporal-envconfig/build.gradle new file mode 100644 index 0000000000..2adfdcfc2d --- /dev/null +++ b/temporal-envconfig/build.gradle @@ -0,0 +1,13 @@ +description = '''Temporal Java SDK Environment Config Module''' + +dependencies { + // this module shouldn't carry temporal-sdk with it, especially for situations when users may be using a shaded artifact + compileOnly project(':temporal-sdk') + + implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-toml:${jacksonVersion}" + testImplementation project(":temporal-testing") + testImplementation "junit:junit:${junitVersion}" + testImplementation "org.mockito:mockito-core:${mockitoVersion}" + + testRuntimeOnly group: 'ch.qos.logback', name: 'logback-classic', version: "${logbackVersion}" +} \ No newline at end of file diff --git a/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfig.java b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfig.java new file mode 100644 index 0000000000..c39fe86612 --- /dev/null +++ b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfig.java @@ -0,0 +1,201 @@ +package io.temporal.envconfig; + +import com.fasterxml.jackson.databind.ObjectReader; +import com.fasterxml.jackson.databind.ObjectWriter; +import com.fasterxml.jackson.dataformat.toml.TomlMapper; +import io.temporal.common.Experimental; +import java.io.*; +import java.util.HashMap; +import java.util.Map; +import java.util.Objects; + +/** ClientConfig represents a client config file. */ +@Experimental +public class ClientConfig { + /** Creates a new builder to build a {@link ClientConfig}. */ + public static Builder newBuilder() { + return new Builder(); + } + + /** + * Creates a new builder to build a {@link ClientConfig} based on an existing config. + * + * @param profile the existing profile to base the builder on + * @return a new Builder instance + */ + public static Builder newBuilder(ClientConfig profile) { + return new Builder(profile); + } + + /** Returns a default instance of {@link ClientConfig} with all fields unset. */ + public static ClientConfig getDefaultInstance() { + return new ClientConfig.Builder().build(); + } + + /** Get the default config file path: $HOME/.config/temporal/temporal.toml */ + private static String getDefaultConfigFilePath() { + String userDir = System.getProperty("user.home"); + if (userDir == null || userDir.isEmpty()) { + throw new RuntimeException("failed getting user home directory"); + } + return userDir + "/.config/temporal/temporal.toml"; + } + + /** + * Load all client profiles from given sources. + * + *

This does not apply environment variable overrides to the profiles, it only uses an + * environment variable to find the default config file path (TEMPORAL_CONFIG_FILE). To get a + * single profile with environment variables applied, use {@link ClientConfigProfile#load}. + */ + public static ClientConfig load() throws IOException { + return load(LoadClientConfigOptions.newBuilder().build()); + } + + /** + * Load all client profiles from given sources. + * + *

This does not apply environment variable overrides to the profiles, it only uses an + * environment variable to find the default config file path (TEMPORAL_CONFIG_FILE). To get a + * single profile with environment variables applied, use {@link ClientConfigProfile#load}. + * + * @param options options to control loading the config + * @throws IOException if the config file cannot be read or parsed + */ + public static ClientConfig load(LoadClientConfigOptions options) throws IOException { + ObjectReader reader = new TomlMapper().readerFor(ClientConfigToml.TomlClientConfig.class); + if (options.isStrictConfigFile()) { + reader = + reader.withFeatures( + com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } else { + reader = + reader.withoutFeatures( + com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } + if (options.getConfigFileData() != null && options.getConfigFileData().length > 0) { + if (options.getConfigFilePath() != null && !options.getConfigFilePath().isEmpty()) { + throw new IllegalArgumentException( + "Cannot have both ConfigFileData and ConfigFilePath set"); + } + ClientConfigToml.TomlClientConfig result = reader.readValue(options.getConfigFileData()); + return new ClientConfig(ClientConfigToml.getClientProfiles(result)); + } else { + // Get file name which is either set value, env var, or default path + String file = options.getConfigFilePath(); + if (file == null || file.isEmpty()) { + Map env = options.getEnvOverrides(); + if (env == null) { + env = System.getenv(); + } + // Unlike env vars for the config values, empty and unset env var + // for config file path are both treated as unset + file = env.get("TEMPORAL_CONFIG_FILE"); + } + if (file == null || file.isEmpty()) { + file = getDefaultConfigFilePath(); + } + ClientConfigToml.TomlClientConfig result = reader.readValue(new File(file)); + return new ClientConfig(ClientConfigToml.getClientProfiles(result)); + } + } + + /** + * Load client config from given TOML data. + * + * @param tomlData TOML data to parse + * @return the parsed client config + * @throws IOException if the TOML data cannot be parsed + */ + public static ClientConfig fromToml(byte[] tomlData) throws IOException { + return fromToml(tomlData, ClientConfigFromTomlOptions.getDefaultInstance()); + } + + /** + * Load client config from given TOML data. + * + * @param tomlData TOML data to parse + * @param options options to control parsing the TOML data + * @return the parsed client config + * @throws IOException if the TOML data cannot be parsed + */ + public static ClientConfig fromToml(byte[] tomlData, ClientConfigFromTomlOptions options) + throws IOException { + ObjectReader reader = new TomlMapper().readerFor(ClientConfigToml.TomlClientConfig.class); + if (options.isStrictConfigFile()) { + reader = + reader.withFeatures( + com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } else { + reader = + reader.withoutFeatures( + com.fasterxml.jackson.databind.DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES); + } + ClientConfigToml.TomlClientConfig result = reader.readValue(tomlData); + return new ClientConfig(ClientConfigToml.getClientProfiles(result)); + } + + /** + * Convert the client config to TOML data. Encoding is UTF-8. + * + * @param config the client config to convert + * @return the TOML data as bytes + * @apiNote The output will not be identical to the input if the config was loaded from a file + * because comments and formatting are not preserved. + */ + public static byte[] toTomlAsBytes(ClientConfig config) throws IOException { + ObjectWriter writer = new TomlMapper().writerFor(ClientConfigToml.TomlClientConfig.class); + return writer.writeValueAsBytes( + new ClientConfigToml.TomlClientConfig( + ClientConfigToml.fromClientProfiles(config.getProfiles()))); + } + + private ClientConfig(Map profiles) { + this.profiles = profiles; + } + + private final Map profiles; + + /** All profiles loaded from the config file, may be empty but never null. */ + public Map getProfiles() { + return new HashMap<>(profiles); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + ClientConfig that = (ClientConfig) o; + return Objects.equals(profiles, that.profiles); + } + + @Override + public int hashCode() { + return Objects.hashCode(profiles); + } + + @Override + public String toString() { + return "ClientConfig{" + "profiles=" + profiles + '}'; + } + + public static final class Builder { + private final Map profiles; + + public Builder(ClientConfig config) { + this.profiles = config.getProfiles(); + } + + public Builder() { + this.profiles = new HashMap<>(); + } + + public Builder putProfile(String name, ClientConfigProfile profile) { + profiles.put(name, profile); + return this; + } + + public ClientConfig build() { + return new ClientConfig(profiles); + } + } +} diff --git a/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigFromTomlOptions.java b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigFromTomlOptions.java new file mode 100644 index 0000000000..fb833e47a6 --- /dev/null +++ b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigFromTomlOptions.java @@ -0,0 +1,54 @@ +package io.temporal.envconfig; + +/** + * Options for parsing a client config toml file in {@link ClientConfig#fromToml(byte[], + * ClientConfigFromTomlOptions)} + */ +public class ClientConfigFromTomlOptions { + /** Create a builder for {@link ClientConfigFromTomlOptions}. */ + public static Builder newBuilder() { + return new ClientConfigFromTomlOptions.Builder(); + } + + /** Create a builder from an existing {@link ClientConfigFromTomlOptions}. */ + public static Builder newBuilder(ClientConfigFromTomlOptions options) { + return new Builder(options); + } + + /** Returns a default instance of {@link ClientConfigFromTomlOptions} with all fields unset. */ + public static ClientConfigFromTomlOptions getDefaultInstance() { + return new ClientConfigFromTomlOptions.Builder().build(); + } + + private final Boolean strictConfigFile; + + private ClientConfigFromTomlOptions(Boolean strictConfigFile) { + this.strictConfigFile = strictConfigFile; + } + + public Boolean isStrictConfigFile() { + return strictConfigFile; + } + + public static class Builder { + private Boolean strictConfigFile = false; + + private Builder() {} + + private Builder(ClientConfigFromTomlOptions options) { + this.strictConfigFile = options.strictConfigFile; + } + + /** + * When true, the parser will fail if the config file contains unknown fields. Default is false. + */ + public Builder setStrictConfigFile(Boolean strictConfigFile) { + this.strictConfigFile = strictConfigFile; + return this; + } + + public ClientConfigFromTomlOptions build() { + return new ClientConfigFromTomlOptions(strictConfigFile); + } + } +} diff --git a/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigProfile.java b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigProfile.java new file mode 100644 index 0000000000..b051602791 --- /dev/null +++ b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigProfile.java @@ -0,0 +1,483 @@ +package io.temporal.envconfig; + +import io.grpc.Metadata; +import io.grpc.netty.shaded.io.grpc.netty.GrpcSslContexts; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContextBuilder; +import io.grpc.netty.shaded.io.netty.handler.ssl.util.InsecureTrustManagerFactory; +import io.temporal.client.WorkflowClientOptions; +import io.temporal.common.Experimental; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.util.Map; +import java.util.Objects; + +/** ClientConfigProfile is profile-level configuration for a client. */ +@Experimental +public class ClientConfigProfile { + /** Creates a new builder to build a {@link ClientConfigProfile}. */ + public static Builder newBuilder() { + return new Builder(); + } + + /** + * Creates a new builder to build a {@link ClientConfigProfile} based on an existing profile. + * + * @param profile the existing profile to base the builder on + * @return a new Builder instance + */ + public static Builder newBuilder(ClientConfigProfile profile) { + return new Builder(profile); + } + + /** Returns a default instance of {@link ClientConfigProfile} with all fields unset. */ + public static ClientConfigProfile getDefaultInstance() { + return new Builder().build(); + } + + /** + * Load a client profile from given sources, applying environment variable overrides. + * + *

This is a convenience method for {@link #load(LoadClientConfigProfileOptions)} with default + * options. + * + * @throws IOException if the config file cannot be read or parsed + */ + public static ClientConfigProfile load() throws IOException { + return load(LoadClientConfigProfileOptions.newBuilder().build()); + } + + /** + * Load a client profile from given sources, applying environment variable overrides. + * + * @param options options to control loading the profile + * @throws IOException if the config file cannot be read or parsed + */ + public static ClientConfigProfile load(LoadClientConfigProfileOptions options) + throws IOException { + if (options.isDisableFile() && options.isDisableEnv()) { + throw new IllegalArgumentException("Cannot have both DisableFile and DisableEnv set to true"); + } + Map env = System.getenv(); + if (options.getEnvOverrides() != null) { + env = options.getEnvOverrides(); + } + ClientConfigProfile clientConfigProfile = ClientConfigProfile.getDefaultInstance(); + // If file is enabled, load it and find just the profile + if (!options.isDisableFile()) { + ClientConfig conf = + ClientConfig.load( + LoadClientConfigOptions.newBuilder() + .setConfigFilePath(options.getConfigFilePath()) + .setConfigFileData(options.getConfigFileData()) + .setStrictConfigFile(options.isConfigFileStrict()) + .setEnvOverrides(options.getEnvOverrides()) + .build()); + String profile = options.getConfigFileProfile(); + boolean profileUnset = false; + if (profile == null || profile.isEmpty()) { + profile = env.get("TEMPORAL_PROFILE"); + } + if (profile == null || profile.isEmpty()) { + profile = "default"; + profileUnset = true; + } + ClientConfigProfile fileClientConfigProfile = conf.getProfiles().get(profile); + if (fileClientConfigProfile != null) { + clientConfigProfile = fileClientConfigProfile; + } else if (!profileUnset) { + throw new IllegalArgumentException("Unable to find profile " + profile + " in config data"); + } + } + // If env is enabled, apply it on top of an empty profile + if (!options.isDisableEnv()) { + clientConfigProfile.applyEnvOverrides(options.getEnvOverrides()); + } + return clientConfigProfile; + } + + private String namespace; + private String address; + private String apiKey; + private Metadata metadata; + private ClientConfigTLS tls; + + private ClientConfigProfile( + String namespace, String address, String apiKey, Metadata metadata, ClientConfigTLS tls) { + this.namespace = namespace; + this.address = address; + this.apiKey = apiKey; + this.metadata = metadata; + this.tls = tls; + } + + public Builder toBuilder() { + return new Builder(this); + } + + /** + * Converts this profile to WorkflowServiceStubsOptions. Note that not all fields are converted, + * only those relevant to service stubs. + */ + public WorkflowServiceStubsOptions toWorkflowServiceStubsOptions() { + WorkflowServiceStubsOptions.Builder builder = WorkflowServiceStubsOptions.newBuilder(); + if (this.address != null && !this.address.isEmpty()) { + builder.setTarget(this.address); + } + if (this.apiKey != null && !this.apiKey.isEmpty()) { + builder.addApiKey(() -> this.apiKey); + } + if (this.metadata != null) { + builder.addGrpcMetadataProvider(() -> this.metadata); + } + if (this.tls != null && (this.tls.isDisabled() == null || !this.tls.isDisabled())) { + InputStream clientCertStream = null; + InputStream keyFileStream = null; + InputStream trustCertCollectionInputStream = null; + try { + if (this.tls.getClientCertPath() != null && !this.tls.getClientCertPath().isEmpty()) { + clientCertStream = Files.newInputStream(Paths.get(this.tls.getClientCertPath())); + } else if (this.tls.getClientCertData() != null + && this.tls.getClientCertData().length > 0) { + clientCertStream = new ByteArrayInputStream(this.tls.getClientCertData()); + } + + if (this.tls.getClientKeyPath() != null && !this.tls.getClientKeyPath().isEmpty()) { + keyFileStream = Files.newInputStream(Paths.get(this.tls.getClientKeyPath())); + } else if (this.tls.getClientKeyData() != null && this.tls.getClientKeyData().length > 0) { + keyFileStream = new ByteArrayInputStream(this.tls.getClientKeyData()); + } + + if (this.tls.getServerCACertPath() != null && !this.tls.getServerCACertPath().isEmpty()) { + trustCertCollectionInputStream = + Files.newInputStream(Paths.get(this.tls.getServerCACertPath())); + } else if (this.tls.getServerCACertData() != null + && this.tls.getServerCACertData().length > 0) { + trustCertCollectionInputStream = new ByteArrayInputStream(this.tls.getServerCACertData()); + } + + SslContextBuilder sslContextBuilder = GrpcSslContexts.forClient(); + if (trustCertCollectionInputStream != null) { + sslContextBuilder.trustManager(trustCertCollectionInputStream); + } else if (Boolean.TRUE.equals(this.tls.isDisableHostVerification())) { + sslContextBuilder.trustManager(InsecureTrustManagerFactory.INSTANCE); + } + sslContextBuilder.keyManager(clientCertStream, keyFileStream); + builder.setSslContext(sslContextBuilder.build()); + } catch (IOException e) { + throw new RuntimeException("Unable to create SSL context", e); + } finally { + if (clientCertStream != null) { + try { + clientCertStream.close(); + } catch (IOException e) { + // Ignore + } + } + if (keyFileStream != null) { + try { + keyFileStream.close(); + } catch (IOException e) { + // Ignore + } + } + if (trustCertCollectionInputStream != null) { + try { + trustCertCollectionInputStream.close(); + } catch (IOException e) { + // Ignore + } + } + } + if (this.tls.getServerName() != null && !this.tls.getServerName().isEmpty()) { + builder.setChannelInitializer(c -> c.overrideAuthority(this.tls.getServerName())); + } + } else if (this.apiKey != null && !this.apiKey.isEmpty()) { + if (this.getTls() == null + || this.tls.isDisabled() == null + || (this.tls.isDisabled() != null && !this.tls.isDisabled())) { + // If API key is set, TLS is required, so enable it with defaults + builder.setEnableHttps(true); + } + } + + return builder.build(); + } + + /** + * Converts this profile to WorkflowClientOptions. Note that not all fields are converted, only + * those relevant to client options. + */ + public WorkflowClientOptions toWorkflowClientOptions() { + WorkflowClientOptions.Builder builder = WorkflowClientOptions.newBuilder(); + if (this.namespace != null && !this.namespace.isEmpty()) { + builder.setNamespace(this.namespace); + } + + return builder.build(); + } + + private void applyEnvOverrides(Map overrideEnvVars) { + Map env = System.getenv(); + if (overrideEnvVars != null) { + env = overrideEnvVars; + } + if (env.containsKey("TEMPORAL_ADDRESS")) { + this.address = env.get("TEMPORAL_ADDRESS"); + } + if (env.containsKey("TEMPORAL_NAMESPACE")) { + this.namespace = env.get("TEMPORAL_NAMESPACE"); + } + if (env.containsKey("TEMPORAL_API_KEY")) { + this.apiKey = env.get("TEMPORAL_API_KEY"); + } + // TLS settings + ClientConfigTLS.Builder tlsBuilder = this.tls != null ? this.tls.toBuilder() : null; + if (env.containsKey("TEMPORAL_TLS")) { + String s = env.get("TEMPORAL_TLS"); + if (s != null && !s.isEmpty()) { + boolean v = Boolean.parseBoolean(s); + if (tlsBuilder == null) { + tlsBuilder = ClientConfigTLS.newBuilder(); + } + tlsBuilder.setDisabled(!v); + } + } + if (env.containsKey("TEMPORAL_TLS_CLIENT_CERT_PATH")) { + String s = env.get("TEMPORAL_TLS_CLIENT_CERT_PATH"); + if (s != null && !s.isEmpty()) { + if (tlsBuilder == null) { + tlsBuilder = ClientConfigTLS.newBuilder(); + } + tlsBuilder.setClientCertPath(s); + } + } + if (env.containsKey("TEMPORAL_TLS_CLIENT_CERT_DATA")) { + String s = env.get("TEMPORAL_TLS_CLIENT_CERT_DATA"); + if (s != null && !s.isEmpty()) { + if (tlsBuilder == null) { + tlsBuilder = ClientConfigTLS.newBuilder(); + } + tlsBuilder.setClientCertData(s.getBytes()); + } + } + if (env.containsKey("TEMPORAL_TLS_CLIENT_KEY_PATH")) { + String s = env.get("TEMPORAL_TLS_CLIENT_KEY_PATH"); + if (s != null && !s.isEmpty()) { + if (tlsBuilder == null) { + tlsBuilder = ClientConfigTLS.newBuilder(); + } + tlsBuilder.setClientKeyPath(s); + } + } + if (env.containsKey("TEMPORAL_TLS_CLIENT_KEY_DATA")) { + String s = env.get("TEMPORAL_TLS_CLIENT_KEY_DATA"); + if (s != null && !s.isEmpty()) { + if (tlsBuilder == null) { + tlsBuilder = ClientConfigTLS.newBuilder(); + } + tlsBuilder.setClientKeyData(s.getBytes()); + } + } + if (env.containsKey("TEMPORAL_TLS_SERVER_CA_CERT_PATH")) { + String s = env.get("TEMPORAL_TLS_SERVER_CA_CERT_PATH"); + if (s != null && !s.isEmpty()) { + if (tlsBuilder == null) { + tlsBuilder = ClientConfigTLS.newBuilder(); + } + tlsBuilder.setServerCACertPath(s); + } + } + if (env.containsKey("TEMPORAL_TLS_SERVER_CA_CERT_DATA")) { + String s = env.get("TEMPORAL_TLS_SERVER_CA_CERT_DATA"); + if (s != null && !s.isEmpty()) { + if (tlsBuilder == null) { + tlsBuilder = ClientConfigTLS.newBuilder(); + } + tlsBuilder.setServerCACertData(s.getBytes()); + } + } + if (env.containsKey("TEMPORAL_TLS_SERVER_NAME")) { + String s = env.get("TEMPORAL_TLS_SERVER_NAME"); + if (tlsBuilder == null) { + tlsBuilder = ClientConfigTLS.newBuilder(); + } + tlsBuilder.setServerName(s); + } + if (env.containsKey("TEMPORAL_TLS_DISABLE_HOST_VERIFICATION")) { + String s = env.get("TEMPORAL_TLS_DISABLE_HOST_VERIFICATION"); + if (s != null && !s.isEmpty()) { + boolean v = Boolean.parseBoolean(s); + if (tlsBuilder == null) { + tlsBuilder = ClientConfigTLS.newBuilder(); + } + tlsBuilder.setDisableHostVerification(v); + } + } + // Apply the TLS changes if any + if (tlsBuilder != null) { + this.tls = tlsBuilder.build(); + } + // GRPC meta requires crawling the envs to find + for (Map.Entry entry : env.entrySet()) { + String v = entry.getValue(); + if (entry.getKey().startsWith("TEMPORAL_GRPC_META_")) { + String key = entry.getKey().substring("TEMPORAL_GRPC_META_".length()); + key = key.replace('_', '-').toLowerCase(); + if (this.metadata == null) { + this.metadata = new Metadata(); + } + Metadata.Key metaKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + // Empty env vars are not the same as unset. Unset will leave the + // meta key unchanged, but empty removes it. + if (v.isEmpty()) { + this.metadata.removeAll(metaKey); + } else { + this.metadata.put(metaKey, v); + } + } + } + } + + public String getNamespace() { + return namespace; + } + + public String getAddress() { + return address; + } + + public String getApiKey() { + return apiKey; + } + + public Metadata getMetadata() { + return metadata; + } + + public ClientConfigTLS getTls() { + return tls; + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + ClientConfigProfile profile = (ClientConfigProfile) o; + // Compare metadata properly + if (this.metadata == null) { + if (profile.metadata != null && profile.metadata.keys() != null) { + return false; + } + } else if (profile.metadata == null) { + if (this.metadata.keys() != null) { + return false; + } + } else { + // Both non-null, compare keys and values + if (!Objects.equals(this.metadata.keys(), profile.metadata.keys())) { + return false; + } + for (String key : this.metadata.keys()) { + if (!Objects.equals( + this.metadata.get(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)), + profile.metadata.get(Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER)))) { + return false; + } + } + } + return Objects.equals(namespace, profile.namespace) + && Objects.equals(address, profile.address) + && Objects.equals(apiKey, profile.apiKey) + && Objects.equals(tls, profile.tls); + } + + @Override + public int hashCode() { + return Objects.hash(namespace, address, apiKey, metadata, tls); + } + + @Override + public String toString() { + return "ClientConfigProfile{" + + "namespace='" + + namespace + + '\'' + + ", address='" + + address + + '\'' + + ", apiKey='" + + apiKey + + '\'' + + ", metadata=" + + metadata + + ", tls=" + + tls + + '}'; + } + + public static final class Builder { + private String namespace; + private String address; + private String apiKey; + private Metadata metadata; + private ClientConfigTLS tls; + + private Builder() {} + + private Builder(ClientConfigProfile profile) { + this.namespace = profile.namespace; + this.address = profile.address; + this.apiKey = profile.apiKey; + this.metadata = profile.metadata; + this.tls = profile.tls; + } + + /** + * Sets the namespace for the client. This is optional; if not set, the default namespace will + * be used. + */ + public Builder setNamespace(String namespace) { + this.namespace = namespace; + return this; + } + + /** + * Sets the address of the Temporal service endpoint. This is optional; if not set, the default + * address will be used. + */ + public Builder setAddress(String address) { + this.address = address; + return this; + } + + /** Sets the API key for the client. This is optional; if not set, no API key will be used. */ + public Builder setApiKey(String apiKey) { + this.apiKey = apiKey; + return this; + } + + /** + * Sets the gRPC metadata to be sent with each request. This is optional; if not set, no + * metadata will be sent. + */ + public Builder setMetadata(Metadata metadata) { + this.metadata = metadata; + return this; + } + + /** + * Sets the TLS configuration for the client. This is optional; if not set, no TLS will be used. + */ + public Builder setTls(ClientConfigTLS tls) { + this.tls = tls; + return this; + } + + public ClientConfigProfile build() { + return new ClientConfigProfile(namespace, address, apiKey, metadata, tls); + } + } +} diff --git a/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigTLS.java b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigTLS.java new file mode 100644 index 0000000000..5f7f1954f7 --- /dev/null +++ b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigTLS.java @@ -0,0 +1,248 @@ +package io.temporal.envconfig; + +import io.temporal.common.Experimental; +import java.util.Arrays; +import java.util.Objects; + +/** TLS configuration for a client. */ +@Experimental +public class ClientConfigTLS { + /** Create a builder for {@link ClientConfigTLS}. */ + public static Builder newBuilder() { + return new Builder(); + } + + /** Create a builder from an existing {@link ClientConfigTLS}. */ + public static Builder newBuilder(ClientConfigTLS config) { + return new Builder(config); + } + + /** Returns a default instance of {@link ClientConfigTLS} with all fields unset. */ + public static ClientConfigTLS getDefaultInstance() { + return new Builder().build(); + } + + private final Boolean disabled; + private final String clientCertPath; + private final byte[] clientCertData; + private final String clientKeyPath; + private final byte[] clientKeyData; + private final String serverCACertPath; + private final byte[] serverCACertData; + private final String serverName; + private final Boolean disableHostVerification; + + private ClientConfigTLS( + Boolean disabled, + String clientCertPath, + byte[] clientCertData, + String clientKeyPath, + byte[] clientKeyData, + String serverCACertPath, + byte[] serverCACertData, + String serverName, + Boolean disableHostVerification) { + this.disabled = disabled; + this.clientCertPath = clientCertPath; + this.clientCertData = clientCertData; + this.clientKeyPath = clientKeyPath; + this.clientKeyData = clientKeyData; + this.serverCACertPath = serverCACertPath; + this.serverCACertData = serverCACertData; + this.serverName = serverName; + this.disableHostVerification = disableHostVerification; + } + + public Boolean isDisabled() { + return disabled; + } + + public String getClientCertPath() { + return clientCertPath; + } + + public byte[] getClientCertData() { + return clientCertData; + } + + public String getClientKeyPath() { + return clientKeyPath; + } + + public byte[] getClientKeyData() { + return clientKeyData; + } + + public String getServerCACertPath() { + return serverCACertPath; + } + + public byte[] getServerCACertData() { + return serverCACertData; + } + + public String getServerName() { + return serverName; + } + + public Boolean isDisableHostVerification() { + return disableHostVerification; + } + + public Builder toBuilder() { + return new Builder(this); + } + + @Override + public boolean equals(Object o) { + if (o == null || getClass() != o.getClass()) return false; + ClientConfigTLS that = (ClientConfigTLS) o; + return Objects.equals(disabled, that.disabled) + && Objects.equals(clientCertPath, that.clientCertPath) + && Objects.deepEquals(clientCertData, that.clientCertData) + && Objects.equals(clientKeyPath, that.clientKeyPath) + && Objects.deepEquals(clientKeyData, that.clientKeyData) + && Objects.equals(serverCACertPath, that.serverCACertPath) + && Objects.deepEquals(serverCACertData, that.serverCACertData) + && Objects.equals(serverName, that.serverName) + && Objects.equals(disableHostVerification, that.disableHostVerification); + } + + @Override + public int hashCode() { + return Objects.hash( + disabled, + clientCertPath, + Arrays.hashCode(clientCertData), + clientKeyPath, + Arrays.hashCode(clientKeyData), + serverCACertPath, + Arrays.hashCode(serverCACertData), + serverName, + disableHostVerification); + } + + @Override + public String toString() { + return "ClientConfigTLS{" + + "disabled=" + + disabled + + ", clientCertPath='" + + clientCertPath + + '\'' + + ", clientCertData=" + + Arrays.toString(clientCertData) + + ", clientKeyPath='" + + clientKeyPath + + '\'' + + ", clientKeyData=" + + Arrays.toString(clientKeyData) + + ", serverCACertPath='" + + serverCACertPath + + '\'' + + ", serverCACertData=" + + Arrays.toString(serverCACertData) + + ", serverName='" + + serverName + + '\'' + + ", disableHostVerification=" + + disableHostVerification + + '}'; + } + + public static class Builder { + private String clientCertPath; + private byte[] clientCertData; + private String clientKeyPath; + private byte[] clientKeyData; + private String serverCACertPath; + private byte[] serverCACertData; + private Boolean disabled; + private String serverName; + private Boolean disableHostVerification; + + private Builder() {} + + private Builder(ClientConfigTLS clientConfigTLS) { + this.disabled = clientConfigTLS.disabled; + this.serverName = clientConfigTLS.serverName; + this.clientCertPath = clientConfigTLS.clientCertPath; + this.clientCertData = clientConfigTLS.clientCertData; + this.clientKeyPath = clientConfigTLS.clientKeyPath; + this.clientKeyData = clientConfigTLS.clientKeyData; + this.serverCACertPath = clientConfigTLS.serverCACertPath; + this.serverCACertData = clientConfigTLS.serverCACertData; + this.disableHostVerification = clientConfigTLS.disableHostVerification; + } + + /** Disable TLS. Default: false. */ + public Builder setDisabled(Boolean disabled) { + this.disabled = disabled; + return this; + } + + /** + * Server name for TLS verification. If not set, the hostname from the target endpoint will be + * used. + */ + public Builder setServerName(String serverName) { + this.serverName = serverName; + return this; + } + + /** Path to client mTLS certificate. Mutually exclusive with ClientCertData. */ + public Builder setClientCertPath(String clientCertPath) { + this.clientCertPath = clientCertPath; + return this; + } + + /** PEM bytes for client mTLS certificate. Mutually exclusive with ClientCertPath. */ + public Builder setClientCertData(byte[] bytes) { + this.clientCertData = bytes; + return this; + } + + /** Path to client mTLS key. Mutually exclusive with ClientKeyData. */ + public Builder setClientKeyPath(String clientKeyPath) { + this.clientKeyPath = clientKeyPath; + return this; + } + + /** PEM bytes for client mTLS key. Mutually exclusive with ClientKeyPath. */ + public Builder setClientKeyData(byte[] clientKeyData) { + this.clientKeyData = clientKeyData; + return this; + } + + /** Path to server CA cert override. Mutually exclusive with ServerCACertData. */ + public Builder setServerCACertPath(String serverCACertPath) { + this.serverCACertPath = serverCACertPath; + return this; + } + + /** PEM bytes for server CA cert override. Mutually exclusive with ServerCACertPath. */ + public Builder setServerCACertData(byte[] serverCACertData) { + this.serverCACertData = serverCACertData; + return this; + } + + /** Disable server host verification. Default: false */ + public Builder setDisableHostVerification(Boolean disableHostVerification) { + this.disableHostVerification = disableHostVerification; + return this; + } + + public ClientConfigTLS build() { + return new ClientConfigTLS( + disabled, + clientCertPath, + clientCertData, + clientKeyPath, + clientKeyData, + serverCACertPath, + serverCACertData, + serverName, + disableHostVerification); + } + } +} diff --git a/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigToml.java b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigToml.java new file mode 100644 index 0000000000..d4ead9baed --- /dev/null +++ b/temporal-envconfig/src/main/java/io/temporal/envconfig/ClientConfigToml.java @@ -0,0 +1,230 @@ +package io.temporal.envconfig; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.grpc.Metadata; +import io.temporal.common.Experimental; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import javax.annotation.Nullable; + +/** + * Classes for parsing the client config toml file. + * + *

These are package private, use {@link ClientConfig} and {@link ClientConfigProfile} to load + * and work with client configs. + */ +@Experimental +class ClientConfigToml { + @JsonInclude(JsonInclude.Include.NON_EMPTY) + static class TomlClientConfig { + @JsonProperty("profile") + public Map profiles; + + protected TomlClientConfig() {} + + TomlClientConfig(Map profiles) { + this.profiles = profiles; + } + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + static class TomlClientConfigProfile { + @JsonProperty("address") + public String address; + + @JsonProperty("namespace") + public String namespace; + + @JsonProperty("api_key") + public String apiKey; + + @JsonProperty("tls") + public TomlClientConfigTLS tls; + + @JsonProperty("grpc_meta") + public Map grpcMeta; + + protected TomlClientConfigProfile() {} + + TomlClientConfigProfile( + String address, + String namespace, + String apiKey, + TomlClientConfigTLS tls, + Map grpcMeta) { + this.address = address; + this.namespace = namespace; + this.apiKey = apiKey; + this.tls = tls; + this.grpcMeta = grpcMeta; + } + } + + @JsonInclude(JsonInclude.Include.NON_EMPTY) + static class TomlClientConfigTLS { + @JsonProperty("disabled") + public Boolean disabled; + + @JsonProperty("client_cert_path") + public String clientCertPath; + + @JsonProperty("client_cert_data") + public String clientCertData; + + @JsonProperty("client_key_path") + public String clientKeyPath; + + @JsonProperty("client_key_data") + public String clientKeyData; + + @JsonProperty("server_ca_cert_path") + public String serverCACertPath; + + @JsonProperty("server_ca_cert_data") + public String serverCACertData; + + @JsonProperty("server_name") + public String serverName; + + @JsonProperty("disable_host_verification") + public Boolean disableHostVerification; + + protected TomlClientConfigTLS() {} + + TomlClientConfigTLS( + Boolean disabled, + String clientCertPath, + String clientCertData, + String clientKeyPath, + String clientKeyData, + String serverCACertPath, + String serverCACertData, + String serverName, + Boolean disableHostVerification) { + this.disabled = disabled; + this.clientCertPath = clientCertPath; + this.clientCertData = clientCertData; + this.clientKeyPath = clientKeyPath; + this.clientKeyData = clientKeyData; + this.serverCACertPath = serverCACertPath; + this.serverCACertData = serverCACertData; + this.serverName = serverName; + this.disableHostVerification = disableHostVerification; + } + } + + @Nullable + static ClientConfigTLS getClientConfigTLS(ClientConfigToml.TomlClientConfigProfile tomlProfile) { + ClientConfigTLS tls = null; + if (tomlProfile.tls != null) { + tls = + ClientConfigTLS.newBuilder() + .setClientCertData( + tomlProfile.tls.clientCertData != null + ? tomlProfile.tls.clientCertData.getBytes(StandardCharsets.UTF_8) + : null) + .setClientCertPath(tomlProfile.tls.clientCertPath) + .setClientKeyData( + tomlProfile.tls.clientKeyData != null + ? tomlProfile.tls.clientKeyData.getBytes(StandardCharsets.UTF_8) + : null) + .setClientKeyPath(tomlProfile.tls.clientKeyPath) + .setServerCACertData( + tomlProfile.tls.serverCACertData != null + ? tomlProfile.tls.serverCACertData.getBytes(StandardCharsets.UTF_8) + : null) + .setServerCACertPath(tomlProfile.tls.serverCACertPath) + .setDisabled(tomlProfile.tls.disabled) + .setServerName(tomlProfile.tls.serverName) + .setDisableHostVerification(tomlProfile.tls.disableHostVerification) + .build(); + } + return tls; + } + + private static String normalizeGrpcMetaKey(String key) { + return key.toLowerCase().replace('_', '-'); + } + + static Map getClientProfiles( + ClientConfigToml.TomlClientConfig clientConfig) { + Map profiles = new HashMap<>(clientConfig.profiles.size()); + for (Map.Entry entry : + clientConfig.profiles.entrySet()) { + String profileName = entry.getKey(); + ClientConfigToml.TomlClientConfigProfile tomlProfile = entry.getValue(); + ClientConfigTLS tls = getClientConfigTLS(tomlProfile); + Metadata metadata = null; + if (tomlProfile.grpcMeta != null) { + for (Map.Entry metaEntry : tomlProfile.grpcMeta.entrySet()) { + if (metadata == null) { + metadata = new Metadata(); + } + Metadata.Key key = + Metadata.Key.of( + normalizeGrpcMetaKey(metaEntry.getKey()), Metadata.ASCII_STRING_MARSHALLER); + metadata.put(key, metaEntry.getValue()); + } + } + ClientConfigProfile profile = + ClientConfigProfile.newBuilder() + .setAddress(tomlProfile.address) + .setNamespace(tomlProfile.namespace) + .setApiKey(tomlProfile.apiKey) + .setMetadata(metadata) + .setTls(tls) + .build(); + profiles.put(profileName, profile); + } + return profiles; + } + + public static Map fromClientProfiles( + Map profiles) { + Map tomlProfiles = new HashMap<>(profiles.size()); + for (Map.Entry entry : profiles.entrySet()) { + String profileName = entry.getKey(); + ClientConfigProfile profile = entry.getValue(); + TomlClientConfigTLS tls = null; + if (profile.getTls() != null) { + tls = + new TomlClientConfigTLS( + profile.getTls().isDisabled(), + profile.getTls().getClientCertPath(), + profile.getTls().getClientCertData() != null + ? new String(profile.getTls().getClientCertData(), StandardCharsets.UTF_8) + : null, + profile.getTls().getClientKeyPath(), + profile.getTls().getClientKeyData() != null + ? new String(profile.getTls().getClientKeyData(), StandardCharsets.UTF_8) + : null, + profile.getTls().getServerCACertPath(), + profile.getTls().getServerCACertData() != null + ? new String(profile.getTls().getServerCACertData(), StandardCharsets.UTF_8) + : null, + profile.getTls().getServerName(), + profile.getTls().isDisableHostVerification()); + } + Map grpcMeta = null; + if (profile.getMetadata() != null) { + grpcMeta = new HashMap<>(); + for (String key : profile.getMetadata().keys()) { + Metadata.Key metaKey = Metadata.Key.of(key, Metadata.ASCII_STRING_MARSHALLER); + Iterable values = profile.getMetadata().getAll(metaKey); + if (values != null) { + // Join multiple values with comma + String joinedValues = String.join(",", values); + grpcMeta.put(key, joinedValues); + } + } + } + TomlClientConfigProfile tomlProfile = + new TomlClientConfigProfile( + profile.getAddress(), profile.getNamespace(), profile.getApiKey(), tls, grpcMeta); + tomlProfiles.put(profileName, tomlProfile); + } + return tomlProfiles; + } +} diff --git a/temporal-envconfig/src/main/java/io/temporal/envconfig/LoadClientConfigOptions.java b/temporal-envconfig/src/main/java/io/temporal/envconfig/LoadClientConfigOptions.java new file mode 100644 index 0000000000..c7271ef864 --- /dev/null +++ b/temporal-envconfig/src/main/java/io/temporal/envconfig/LoadClientConfigOptions.java @@ -0,0 +1,111 @@ +package io.temporal.envconfig; + +import io.temporal.common.Experimental; +import java.util.Map; + +/** Options for loading a client config via {@link ClientConfig#load(LoadClientConfigOptions)} */ +@Experimental +public class LoadClientConfigOptions { + /** Create a builder for {@link LoadClientConfigOptions}. */ + public static Builder newBuilder() { + return new Builder(); + } + + /** Create a builder from an existing {@link LoadClientConfigOptions}. */ + public static Builder newBuilder(LoadClientConfigOptions options) { + return new Builder(options); + } + + /** Returns a default instance of {@link LoadClientConfigOptions} with all fields unset. */ + public static LoadClientConfigOptions getDefaultInstance() { + return new Builder().build(); + } + + private final String configFilePath; + private final byte[] configFileData; + private final boolean strictConfigFile; + private final Map envOverrides; + + private LoadClientConfigOptions( + String configFilePath, + byte[] configFileData, + boolean strictConfigFile, + Map envOverrides) { + this.configFilePath = configFilePath; + this.configFileData = configFileData; + this.strictConfigFile = strictConfigFile; + this.envOverrides = envOverrides; + } + + public String getConfigFilePath() { + return configFilePath; + } + + public byte[] getConfigFileData() { + return configFileData; + } + + public boolean isStrictConfigFile() { + return strictConfigFile; + } + + public Map getEnvOverrides() { + return envOverrides; + } + + public static class Builder { + private String configFilePath; + private byte[] configFileData; + private boolean strictConfigFile; + private Map envOverrides; + + private Builder() {} + + private Builder(LoadClientConfigOptions options) { + this.configFilePath = options.configFilePath; + this.configFileData = options.configFileData; + this.strictConfigFile = options.strictConfigFile; + this.envOverrides = options.envOverrides; + } + + /** If true, will error if there are unrecognized keys. Defaults to false. */ + public Builder setStrictConfigFile(boolean configFileStrict) { + this.strictConfigFile = configFileStrict; + return this; + } + + /** + * Set environment variable overrides. If set, these will be used instead of the actual + * environment variables. If not set, the actual environment variables will be used. + */ + public Builder setEnvOverrides(Map envOverrides) { + this.envOverrides = envOverrides; + return this; + } + + /** + * TOML data to load for config. If set, this overrides any file loading. Cannot be set if + * ConfigFilePath is set. Ignored if DisableFile is true. + */ + public Builder setConfigFileData(byte[] bytes) { + this.configFileData = bytes; + return this; + } + + /** + * Override the file path to use to load the TOML file for config. Defaults to + * TEMPORAL_CONFIG_FILE environment variable or if that is unset/empty, defaults to + * [os.UserConfigDir]/temporal/temporal.toml. If ConfigFileData is set, this cannot be set and + * no file loading from disk occurs. Ignored if DisableFile is true. + */ + public Builder setConfigFilePath(String configFilePath) { + this.configFilePath = configFilePath; + return this; + } + + public LoadClientConfigOptions build() { + return new LoadClientConfigOptions( + configFilePath, configFileData, strictConfigFile, envOverrides); + } + } +} diff --git a/temporal-envconfig/src/main/java/io/temporal/envconfig/LoadClientConfigProfileOptions.java b/temporal-envconfig/src/main/java/io/temporal/envconfig/LoadClientConfigProfileOptions.java new file mode 100644 index 0000000000..4b0d345004 --- /dev/null +++ b/temporal-envconfig/src/main/java/io/temporal/envconfig/LoadClientConfigProfileOptions.java @@ -0,0 +1,177 @@ +package io.temporal.envconfig; + +import io.temporal.common.Experimental; +import java.util.Map; + +/** + * Options for loading a client config profile via {@link + * ClientConfigProfile#load(LoadClientConfigProfileOptions)} + */ +@Experimental +public class LoadClientConfigProfileOptions { + /** Create a builder for {@link LoadClientConfigProfileOptions}. */ + public static Builder newBuilder() { + return new Builder(); + } + + /** Create a builder from an existing {@link LoadClientConfigProfileOptions}. */ + public static Builder newBuilder(LoadClientConfigProfileOptions options) { + return new Builder(options); + } + + /** Returns a default instance of {@link LoadClientConfigProfileOptions} with all fields unset. */ + public static LoadClientConfigProfileOptions getDefaultInstance() { + return new Builder().build(); + } + + private final String configFileProfile; + private final String configFilePath; + private final byte[] configFileData; + private final boolean configFileStrict; + private final boolean disableFile; + private final boolean disableEnv; + private final Map envOverrides; + + private LoadClientConfigProfileOptions( + String configFileProfile, + String configFilePath, + byte[] configFileData, + boolean configFileStrict, + boolean disableFile, + boolean disableEnv, + Map envOverrides) { + this.configFileProfile = configFileProfile; + this.configFilePath = configFilePath; + this.configFileData = configFileData; + this.configFileStrict = configFileStrict; + this.disableFile = disableFile; + this.disableEnv = disableEnv; + this.envOverrides = envOverrides; + } + + public String getConfigFileProfile() { + return configFileProfile; + } + + public byte[] getConfigFileData() { + return configFileData; + } + + public boolean isConfigFileStrict() { + return configFileStrict; + } + + public boolean isDisableFile() { + return disableFile; + } + + public boolean isDisableEnv() { + return disableEnv; + } + + public Map getEnvOverrides() { + return envOverrides; + } + + public String getConfigFilePath() { + return configFilePath; + } + + public static class Builder { + private String configFileProfile; + private String configFilePath; + private byte[] configFileData; + private boolean configFileStrict; + private boolean disableFile; + private boolean disableEnv; + private Map envOverrides; + + private Builder() {} + + private Builder(LoadClientConfigProfileOptions options) { + this.configFileProfile = options.configFileProfile; + this.configFilePath = options.configFilePath; + this.configFileData = options.configFileData; + this.configFileStrict = options.configFileStrict; + this.disableFile = options.disableFile; + this.disableEnv = options.disableEnv; + this.envOverrides = options.envOverrides; + } + + /** If true, will error if there are unrecognized keys. Defaults to false. */ + public Builder setConfigFileStrict(boolean configFileStrict) { + this.configFileStrict = configFileStrict; + return this; + } + + /** + * If true, will not do any TOML loading from file or data. This and DisableEnv cannot both be + * true. Defaults to false. + */ + public Builder setDisableFile(boolean disableFile) { + this.disableFile = disableFile; + return this; + } + + /** + * If true, will not apply environment variables on top of file config for the client options, + * but TEMPORAL_CONFIG_FILE and TEMPORAL_PROFILE environment variables may still by used to + * populate defaults in this options structure. Defaults to false. + */ + public Builder setDisableEnv(boolean disableEnv) { + this.disableEnv = disableEnv; + return this; + } + + /** + * Override the file path to use to load the TOML file for config. Defaults to + * TEMPORAL_CONFIG_FILE environment variable or if that is unset/empty, defaults to + * [os.UserConfigDir]/temporal/temporal.toml. If ConfigFileData is set, this cannot be set and + * no file loading from disk occurs. Ignored if DisableFile is true. + */ + public Builder setConfigFilePath(String configFilePath) { + this.configFilePath = configFilePath; + return this; + } + + /** + * TOML data to load for config. If set, this overrides any file loading. Cannot be set if + * ConfigFilePath is set. Ignored if DisableFile is true. + */ + public Builder setConfigFileData(byte[] bytes) { + this.configFileData = bytes; + return this; + } + + /** + * Set specific profile to use after file is loaded. Defaults to TEMPORAL_PROFILE environment + * variable or if that is unset/empty, defaults to "default". If either this or the environment + * variable are set, load will fail if the profile isn't present in the config. Ignored if + * DisableFile is true. + */ + public Builder setConfigFileProfile(String foo) { + this.configFileProfile = foo; + return this; + } + + /** + * Set environment variable overrides. If set, these will be used instead of the actual + * environment variables. If not set, the actual environment variables will be used. + */ + public Builder setEnvOverrides(Map envOverrides) { + this.envOverrides = envOverrides; + return this; + } + + public LoadClientConfigProfileOptions build() { + return new LoadClientConfigProfileOptions( + configFileProfile, + configFilePath, + configFileData, + configFileStrict, + disableFile, + disableEnv, + envOverrides); + } + } +} diff --git a/temporal-envconfig/src/test/java/io/temporal/envconfig/ClientConfigProfileTest.java b/temporal-envconfig/src/test/java/io/temporal/envconfig/ClientConfigProfileTest.java new file mode 100644 index 0000000000..fcb1ced662 --- /dev/null +++ b/temporal-envconfig/src/test/java/io/temporal/envconfig/ClientConfigProfileTest.java @@ -0,0 +1,325 @@ +package io.temporal.envconfig; + +import com.google.common.base.Charsets; +import com.google.common.collect.ImmutableMap; +import com.google.common.io.Files; +import io.grpc.Metadata; +import io.grpc.netty.shaded.io.netty.handler.ssl.SslContext; +import io.temporal.serviceclient.WorkflowServiceStubsOptions; +import java.io.*; +import java.util.Collections; +import org.junit.Assert; +import org.junit.Test; + +public class ClientConfigProfileTest { + @Test + public void loadProfileApplyEnv() throws IOException { + String toml = + "[profile.foo]\n" + + "address = \"my-address\"\n" + + "namespace = \"my-namespace\"\n" + + "api_key = \"my-api-key\"\n" + + "grpc_meta = { some-heAder1 = \"some-value1\", some-header2 = \"some-value2\", some_heaDer3 = \"some-value3\" }\n" + + "some_future_key = \"some future value not handled\"\n" + + "\n" + + "[profile.foo.tls]\n" + + "disabled = true\n" + + "client_cert_path = \"my-client-cert-path\"\n" + + "client_cert_data = \"my-client-cert-data\"\n" + + "client_key_path = \"my-client-key-path\"\n" + + "client_key_data = \"my-client-key-data\"\n" + + "server_ca_cert_path = \"my-server-ca-cert-path\"\n" + + "server_ca_cert_data = \"my-server-ca-cert-data\"\n" + + "server_name = \"my-server-name\"\n" + + "disable_host_verification = true"; + ClientConfigProfile profile = + ClientConfigProfile.load( + LoadClientConfigProfileOptions.newBuilder() + .setConfigFileData(toml.getBytes()) + .setConfigFileProfile("foo") + .setEnvOverrides(Collections.emptyMap()) + .build()); + Assert.assertEquals("my-address", profile.getAddress()); + Assert.assertEquals("my-namespace", profile.getNamespace()); + Assert.assertEquals("my-api-key", profile.getApiKey()); + Assert.assertEquals(3, profile.getMetadata().keys().size()); + Assert.assertNotNull(profile.getTls()); + Assert.assertEquals( + "some-value1", + profile + .getMetadata() + .get(Metadata.Key.of("some-header1", Metadata.ASCII_STRING_MARSHALLER))); + Assert.assertEquals( + "some-value2", + profile + .getMetadata() + .get(Metadata.Key.of("some-header2", Metadata.ASCII_STRING_MARSHALLER))); + Assert.assertEquals( + "some-value3", + profile + .getMetadata() + .get(Metadata.Key.of("some-header3", Metadata.ASCII_STRING_MARSHALLER))); + // With env + profile = + ClientConfigProfile.load( + LoadClientConfigProfileOptions.newBuilder() + .setConfigFileData(toml.getBytes()) + .setEnvOverrides( + new ImmutableMap.Builder() + .put("TEMPORAL_PROFILE", "foo") + .put("TEMPORAL_ADDRESS", "my-address-new") + .put("TEMPORAL_NAMESPACE", "my-namespace-new") + .put("TEMPORAL_API_KEY", "my-api-key-new") + .put("TEMPORAL_TLS", "false") + .put("TEMPORAL_TLS_CLIENT_CERT_PATH", "my-client-cert-path-new") + .put("TEMPORAL_TLS_CLIENT_CERT_DATA", "my-client-cert-data-new") + .put("TEMPORAL_TLS_CLIENT_KEY_PATH", "my-client-key-path-new") + .put("TEMPORAL_TLS_CLIENT_KEY_DATA", "my-client-key-data-new") + .put("TEMPORAL_TLS_SERVER_CA_CERT_PATH", "my-server-ca-cert-path-new") + .put("TEMPORAL_TLS_SERVER_CA_CERT_DATA", "my-server-ca-cert-data-new") + .put("TEMPORAL_TLS_SERVER_NAME", "my-server-name-new") + .put("TEMPORAL_TLS_DISABLE_HOST_VERIFICATION", "false") + .put("TEMPORAL_GRPC_META_SOME_HEADER2", "some-value2-new") + .put("TEMPORAL_GRPC_META_SOME_HEADER3", "") + .put("TEMPORAL_GRPC_META_SOME_HEADER4", "some-value4-new") + .build()) + .build()); + Assert.assertEquals("my-address-new", profile.getAddress()); + Assert.assertEquals("my-namespace-new", profile.getNamespace()); + Assert.assertEquals("my-api-key-new", profile.getApiKey()); + Assert.assertEquals(3, profile.getMetadata().keys().size()); + Assert.assertNotNull(profile.getTls()); + Assert.assertEquals( + "some-value1", + profile + .getMetadata() + .get(Metadata.Key.of("some-header1", Metadata.ASCII_STRING_MARSHALLER))); + Assert.assertEquals( + "some-value2-new", + profile + .getMetadata() + .get(Metadata.Key.of("some-header2", Metadata.ASCII_STRING_MARSHALLER))); + Assert.assertNull( + profile + .getMetadata() + .get(Metadata.Key.of("some-header3", Metadata.ASCII_STRING_MARSHALLER))); + Assert.assertEquals( + "some-value4-new", + profile + .getMetadata() + .get(Metadata.Key.of("some-header4", Metadata.ASCII_STRING_MARSHALLER))); + } + + @Test + public void loadClientConfigProfileFile() throws IOException { + // Put some data in temp file and set the env var to use that file + File temp = File.createTempFile("envConfigTest", ""); + temp.deleteOnExit(); + Files.asCharSink(temp, Charsets.UTF_8) + .write( + "[profile.default]\n" + "address = \"my-address\"\n" + "namespace = \"my-namespace\""); + // Explicitly set + ClientConfigProfile profile = + ClientConfigProfile.load( + LoadClientConfigProfileOptions.newBuilder() + .setConfigFilePath(temp.getAbsolutePath()) + .setEnvOverrides(Collections.emptyMap()) + .build()); + Assert.assertEquals("my-address", profile.getAddress()); + Assert.assertEquals("my-namespace", profile.getNamespace()); + // From env + profile = + ClientConfigProfile.load( + LoadClientConfigProfileOptions.newBuilder() + .setEnvOverrides( + Collections.singletonMap("TEMPORAL_CONFIG_FILE", temp.getAbsolutePath())) + .build()); + Assert.assertEquals("my-address", profile.getAddress()); + Assert.assertEquals("my-namespace", profile.getNamespace()); + } + + @Test + public void loadClientOptionsAPIKeyTLS() throws IOException { + // Since API key is present, TLS defaults to present + ClientConfigProfile profile = + ClientConfigProfile.load( + LoadClientConfigProfileOptions.newBuilder() + .setConfigFileData(("[profile.default]\n" + "api_key = \"my-api-key\"").getBytes()) + .setEnvOverrides(Collections.emptyMap()) + .build()); + WorkflowServiceStubsOptions stubsOptions = profile.toWorkflowServiceStubsOptions(); + Assert.assertEquals(1, stubsOptions.getGrpcMetadataProviders().size()); + Assert.assertTrue(stubsOptions.getEnableHttps()); + + // But when API key is not present, neither should TLS be + profile = + ClientConfigProfile.load( + LoadClientConfigProfileOptions.newBuilder() + .setConfigFileData(("[profile.default]\n" + "address = \"whatever\"").getBytes()) + .setEnvOverrides(Collections.emptyMap()) + .build()); + stubsOptions = profile.toWorkflowServiceStubsOptions(); + Assert.assertNull(stubsOptions.getGrpcMetadataProviders()); + Assert.assertFalse(stubsOptions.getEnableHttps()); + } + + @Test + public void loadClientOptionsTLS() throws IOException { + String clientCertData = + "-----BEGIN CERTIFICATE-----\n" + + "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\n" + + "-----END CERTIFICATE-----"; + + String clientKeyData = + "-----BEGIN PRIVATE KEY-----\n" + + "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\n" + + "YymQE1T0jkQc7f+tsDiNuDn91NTlCeQ=\n" + + "-----END PRIVATE KEY-----\n"; + + // Since API key is present, TLS defaults to present + ClientConfigProfile profile = + ClientConfigProfile.load( + LoadClientConfigProfileOptions.newBuilder() + .setConfigFileData( + ("[profile.default]\n" + + "[profile.default.tls]\n" + + "client_cert_data = \"\"\"" + + clientCertData + + "\"\"\"\n" + + "client_key_data = \"\"\"" + + clientKeyData + + "\"\"\"") + .getBytes()) + .setEnvOverrides(Collections.emptyMap()) + .build()); + WorkflowServiceStubsOptions stubsOptions = profile.toWorkflowServiceStubsOptions(); + Assert.assertNotNull(stubsOptions.getSslContext()); + SslContext context = stubsOptions.getSslContext(); + Assert.assertTrue(context.isClient()); + } + + @Test + public void parseToml() throws IOException { + String toml = + "[profile.foo]\n" + + "address = \"my-address\"\n" + + "namespace = \"my-namespace\"\n" + + "api_key = \"my-api-key\"\n" + + "grpc_meta = { some-heAder1 = \"some-value1\", some-header2 = \"some-value2\", some_heaDer3 = \"some-value3\" }\n" + + "some_future_key = \"some future value not handled\"\n" + + "\n" + + "[profile.foo.tls]\n" + + "disabled = true\n" + + "client_cert_path = \"my-client-cert-path\"\n" + + "client_cert_data = \"my-client-cert-data\"\n" + + "client_key_path = \"my-client-key-path\"\n" + + "client_key_data = \"my-client-key-data\"\n" + + "server_ca_cert_path = \"my-server-ca-cert-path\"\n" + + "server_ca_cert_data = \"my-server-ca-cert-data\"\n" + + "server_name = \"my-server-name\"\n" + + "disable_host_verification = true"; + ClientConfig clientConfig = ClientConfig.fromToml(toml.getBytes()); + Assert.assertEquals(1, clientConfig.getProfiles().size()); + ClientConfigProfile profile = clientConfig.getProfiles().get("foo"); + Assert.assertNotNull(profile); + Assert.assertEquals("my-address", profile.getAddress()); + Assert.assertEquals("my-namespace", profile.getNamespace()); + Assert.assertEquals("my-api-key", profile.getApiKey()); + Assert.assertEquals(3, profile.getMetadata().keys().size()); + Assert.assertNotNull(profile.getTls()); + Assert.assertEquals( + "some-value1", + profile + .getMetadata() + .get(Metadata.Key.of("some-header1", Metadata.ASCII_STRING_MARSHALLER))); + Assert.assertEquals( + "some-value2", + profile + .getMetadata() + .get(Metadata.Key.of("some-header2", Metadata.ASCII_STRING_MARSHALLER))); + Assert.assertEquals( + "some-value3", + profile + .getMetadata() + .get(Metadata.Key.of("some-header3", Metadata.ASCII_STRING_MARSHALLER))); + String generatedTomlString = new String(ClientConfig.toTomlAsBytes(clientConfig)); + ClientConfig clientConfig2 = ClientConfig.fromToml(generatedTomlString.getBytes()); + Assert.assertEquals(clientConfig, clientConfig2); + } +}