micronaut-projects / micronaut-core

Micronaut Application Framework
http://micronaut.io
Apache License 2.0
6.03k stars 1.05k forks source link

Configuration Client not able to resolve custom source #10994

Open RajeevMasseyTR opened 1 month ago

RajeevMasseyTR commented 1 month ago

Issue description

Hi I have been dealing with this issue for a long time and need a solution. This code is in the common module

@BootstrapContextCompatible
@Singleton
@Requires(beans = {SSMSecretManager.class, JsonMapper.class})
@Requires(property = "aws.ssm.config.path")
@Requires(property = "aws.ssm.config.description", defaultValue = "AWS Configuration Client")
public class AwsParameterStoreConfigurationLoader implements ConfigurationClient {

    private static final Logger LOG = LoggerFactory.getLogger(AwsParameterStoreConfigurationLoader.class);
    public static final String RPC_AWS_SSM_CONFIGURATION_NAME = "aws-env";

    @Property(name = "aws.ssm.config.description", defaultValue = "AWS Configuration Client")
    private String description;

    @Property(name = "aws.ssm.config.path")
    private String configurationParameterStorePath;

    private final SSMSecretManager ssmSecretManager;
    private final JsonMapper jsonMapper;
    private final ApplicationContext context;
    private final ConcurrentHashMap<String, Object> cache;

    AwsParameterStoreConfigurationLoader(SSMSecretManager ssmSecretManager,
                                                JsonMapper jsonMapper,
                                                ApplicationContext context) {
        this.ssmSecretManager = ssmSecretManager;
        this.jsonMapper = jsonMapper;
        this.context = context;
        this.cache = new ConcurrentHashMap<>();
    }

    @PostConstruct
    private void init() {
        loadConfiguration();
    }
    @Override
    public Publisher<PropertySource> getPropertySources(Environment environment) {
        return Mono.just(getCustomPropertySource());
    }

    @Override
    public @NonNull String getDescription() {
        return description;
    }

    private Object getProperty(String key) {
        return cache.computeIfAbsent(key, k -> {
            try {
                loadConfiguration();
                return cache.get(k);
            } catch (Exception e) {
                LOG.error("Error fetching configuration from SSM for key: " + key, e);
                throw new RuntimeException("Error fetching configuration from SSM for key: " + key, e);
            }
        });
    }

    private void loadConfiguration() {
        try {
            if (cache.isEmpty()) {
                String jsonConfig = ssmSecretManager.getSecretValue(configurationParameterStorePath);
                Map<String, Object> configMap = jsonMapper.readValue(jsonConfig, Map.class);

                configMap.forEach(cache::put);

                PropertySource resolvedPropertySource = PropertySource.of(AWS_SSM_CONFIGURATION_NAME, configMap, PropertySource.PropertyConvention.ENVIRONMENT_VARIABLE);
                context.getEnvironment().addPropertySource(resolvedPropertySource);

                context.getEnvironment().refresh();

                verifyConfiguration(configMap.keySet());
            }
        } catch (Exception e) {
            throw new RuntimeException("Error loading configuration from SSM", e);
        }
    }

    private void verifyConfiguration(Set<String> requiredProperties) {
        var missingProperties = requiredProperties.stream().filter(property -> !context.getEnvironment().containsProperty(property)).collect(Collectors.toList());

        if (!missingProperties.isEmpty()) {
            String errorMessage = "The following required properties are missing: " + String.join(", ", missingProperties);
            throw new IllegalStateException(errorMessage);
        }

        LOG.info("All required properties have been successfully loaded and verified.");
    }

    private PropertySource getCustomPropertySource() {
        if (cache.isEmpty()) {
            loadConfiguration();
            verifyConfiguration(cache.keySet());
        }
        return PropertySource.of(AWS_SSM_CONFIGURATION_NAME, cache.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)), PropertySource.PropertyConvention.ENVIRONMENT_VARIABLE);
    }
}

also have this dependency implementation "io.micronaut.aws:micronaut-aws-distributed-configuration" in the common module. Now another module called the api-module implements the common project and has the bootstrap.yml with configurations :

micronaut:
  config-client:
    enabled: true

But even then, No Distributed Property Source has been identified. No property has been injected

{"timestamp":"2024-07-18 22:48:43.942","level":"INFO","thread":"main","logger":"io.micronaut.context.DefaultApplicationContext$RuntimeConfiguredEnvironment","message":"Established active environments: [ec2]","context":"default"}
{"timestamp":"2024-07-18 22:48:43.995","level":"INFO","thread":"main","logger":"io.micronaut.context.DefaultApplicationContext$BootstrapEnvironment","message":"Established active environments: [ec2]","context":"default"}
{"timestamp":"2024-07-18 22:48:54.942","level":"INFO","thread":"main","logger":"io.micronaut.context.DefaultBeanContext","message":"Reading bootstrap environment configuration","context":"default"}
{"timestamp":"2024-07-18 22:50:47.033","level":"INFO","thread":"main","logger":"io.micronaut.discovery.client.config.DistributedPropertySourceLocator","message":"Resolved 0 configuration sources from client: compositeConfigurationClient()","context":"default"}
{"timestamp":"2024-07-18 22:50:50.440","level":"ERROR","thread":"main","logger":"io.micronaut.runtime.Micronaut","message":"Error starting Micronaut server: Failed to inject value for parameter
RajeevMasseyTR commented 1 month ago

@pgressa , @sdelamo , @graemerocher

graemerocher commented 1 month ago

attach a full example that reproduces the issue with steps to reproduce

RajeevMasseyTR commented 1 month ago

Sure @graemerocher , let's walk through setting up a multi-module project where the api-module depends on the common-module. We will also ensure that the api-module correctly implements the common-module and has the required bootstrap.yml configuration.

Project Structure

Your project structure will look like this:

root-project │ ├── common-module │ └── src/main/java/com/example/common │ └── AwsParameterStoreConfigurationLoader.java │ └── build.gradle │ ├── api-module │ └── src/main/java/com/example/api │ └── Application.java │ └── src/main/resources │ └── bootstrap.yml │ └── build.gradle │ ├── settings.gradle └── build.gradle

Step-by-Step Guide

1. Root Project Configuration

settings.gradle


rootProject.name = 'root-project'
include 'common-module', 'api-module'

build.gradle

plugins {
    id 'java'
}

subprojects {
    apply plugin: 'java'
    sourceCompatibility = '17'
    targetCompatibility = '17'

    repositories {
        mavenCentral()
    }
}

2. Common Module Configuration

common-module/build.gradle


dependencies {
    implementation "io.micronaut:micronaut-runtime"
    implementation "io.micronaut.aws:micronaut-aws-distributed-configuration:4.2.1"

    annotationProcessor "io.micronaut:micronaut-inject-java"
    annotationProcessor "io.micronaut:micronaut-validation"
}

compileJava {
    options.annotationProcessorPath = configurations.annotationProcessor
}

common-module/src/main/java/com/example/common/AwsParameterStoreConfigurationLoader.java

@BootstrapContextCompatible
@Singleton
@Requires(beans = {SSMSecretManager.class, JsonMapper.class})
@Requires(property = "aws.ssm.config.path")
@Requires(property = "aws.ssm.config.description", defaultValue = "AWS Configuration Client")
public class AwsParameterStoreConfigurationLoader implements ConfigurationClient {

    private static final Logger LOG = LoggerFactory.getLogger(AwsParameterStoreConfigurationLoader.class);
    public static final String RPC_AWS_SSM_CONFIGURATION_NAME = "aws-env";

    @Property(name = "aws.ssm.config.description", defaultValue = "AWS Configuration Client")
    private String description;

    @Property(name = "aws.ssm.config.path")
    private String configurationParameterStorePath;

    private final SSMSecretManager ssmSecretManager;
    private final JsonMapper jsonMapper;
    private final ApplicationContext context;
    private final ConcurrentHashMap<String, Object> cache;

    AwsParameterStoreConfigurationLoader(SSMSecretManager ssmSecretManager,
                                                JsonMapper jsonMapper,
                                                ApplicationContext context) {
        this.ssmSecretManager = ssmSecretManager;
        this.jsonMapper = jsonMapper;
        this.context = context;
        this.cache = new ConcurrentHashMap<>();
    }

    @PostConstruct
    private void init() {
        loadConfiguration();
    }
    @Override
    public Publisher<PropertySource> getPropertySources(Environment environment) {
        return Mono.just(getCustomPropertySource());
    }

    @Override
    public @NonNull String getDescription() {
        return description;
    }

    private Object getProperty(String key) {
        return cache.computeIfAbsent(key, k -> {
            try {
                loadConfiguration();
                return cache.get(k);
            } catch (Exception e) {
                LOG.error("Error fetching configuration from SSM for key: " + key, e);
                throw new RuntimeException("Error fetching configuration from SSM for key: " + key, e);
            }
        });
    }

    private void loadConfiguration() {
        try {
            if (cache.isEmpty()) {
                String jsonConfig = ssmSecretManager.getSecretValue(configurationParameterStorePath);
                Map<String, Object> configMap = jsonMapper.readValue(jsonConfig, Map.class);

                configMap.forEach(cache::put);

                PropertySource resolvedPropertySource = PropertySource.of(AWS_SSM_CONFIGURATION_NAME, configMap, PropertySource.PropertyConvention.ENVIRONMENT_VARIABLE);
                context.getEnvironment().addPropertySource(resolvedPropertySource);

                context.getEnvironment().refresh();

                verifyConfiguration(configMap.keySet());
            }
        } catch (Exception e) {
            throw new RuntimeException("Error loading configuration from SSM", e);
        }
    }

    private void verifyConfiguration(Set<String> requiredProperties) {
        var missingProperties = requiredProperties.stream().filter(property -> !context.getEnvironment().containsProperty(property)).collect(Collectors.toList());

        if (!missingProperties.isEmpty()) {
            String errorMessage = "The following required properties are missing: " + String.join(", ", missingProperties);
            throw new IllegalStateException(errorMessage);
        }

        LOG.info("All required properties have been successfully loaded and verified.");
    }

    private PropertySource getCustomPropertySource() {
        if (cache.isEmpty()) {
            loadConfiguration();
            verifyConfiguration(cache.keySet());
        }
        return PropertySource.of(AWS_SSM_CONFIGURATION_NAME, cache.entrySet().stream().collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)), PropertySource.PropertyConvention.ENVIRONMENT_VARIABLE);
    }
}

3. API Module Configuration

api-module/build.gradle


dependencies {
    implementation project(':common-module')
    implementation "io.micronaut:micronaut-runtime"

    annotationProcessor "io.micronaut:micronaut-inject-java"
    annotationProcessor "io.micronaut:micronaut-validation"
}

compileJava {
    options.annotationProcessorPath = configurations.annotationProcessor
}

api-module/src/main/java/com/example/api/Application.java


package com.example.api;

import io.micronaut.runtime.Micronaut;

public class Application {
    public static void main(String[] args) {
        Micronaut.run(Application.class);
    }
}

api-module/src/main/resources/bootstrap.yml


yaml
micronaut:
  config-client:
    enabled: true
aws:
  ssm:
    config:
      path: "/your/ssm/parameter/path"
      description: "AWS Configuration Client"

Verifying the logs to ensure that the AwsParameterStoreConfigurationLoader is invoked and the configuration is loaded from AWS SS. It showed that DistributedPropertySourceLocator was not able to find AwsParameterStoreConfigurationLoader configration client


.{"timestamp":"2024-07-18 22:48:43.942","level":"INFO","thread":"main","logger":"io.micronaut.context.DefaultApplicationContext$RuntimeConfiguredEnvironment","message":"Established active environments: [ec2]","context":"default"}
{"timestamp":"2024-07-18 22:48:43.995","level":"INFO","thread":"main","logger":"io.micronaut.context.DefaultApplicationContext$BootstrapEnvironment","message":"Established active environments: [ec2]","context":"default"}
{"timestamp":"2024-07-18 22:48:54.942","level":"INFO","thread":"main","logger":"io.micronaut.context.DefaultBeanContext","message":"Reading bootstrap environment configuration","context":"default"}
{"timestamp":"2024-07-18 22:50:47.033","level":"INFO","thread":"main","logger":"io.micronaut.discovery.client.config.DistributedPropertySourceLocator","message":"Resolved 0 configuration sources from client: compositeConfigurationClient()","context":"default"}
graemerocher commented 1 month ago

I'm not asking for a walkthrough, I am asking you to upload an example with steps to reproduce

rorueda commented 1 month ago

Is the snakeyaml dependency declared anywhere?

I think this https://docs.micronaut.io/4.5.3/guide/#config also applies for the bootstrap config.

rajeev-massey commented 1 month ago

@graemerocher https://github.com/rajeev-massey/demo-micronaut . Almost the same steps followed minus linking it to the SSM instead manually putting the JSON

graemerocher commented 1 month ago

the common module needs to declare:

implementation("io.micronaut.discovery:micronaut-discovery-client")
graemerocher commented 1 month ago

we should probably document this better.