Skip to content

Commit 23fb76a

Browse files
committed
Make dev image work with dependency injection.
That is, run groovy scrips in dev image without prior compilation, overcoming the issue that Micronaut's dependency injection relies on statically compiled class files with seems incompatible with groovy scripting/interpretation (without prior compilation). The Micronaut Classloader relies on compiled classes being loaded by java classloader. ApplicationContext.run() .. SoftServiceLocader.collectDynamicServices() Class.forName() This uses the Java classloader which does not know the Groovy-only classes when interpreting (scripting) without prior compilation. This can be overcome by keeping the $xyz classes created by Micronaut's annotation processor inside the dev image and setting the Groovy classloader like so: ApplicationContext.run(Thread.currentThread().contextClassLoader). However, with TRACE logs enabled for "io.micronaut.context" we can then see stacktraces like this in the log. NoClassDefFoundError: com/cloudogu/gitops/jenkins/GlobalPropertyManager at com.cloudogu.gitops.jenkins.$GlobalPropertyManager$Definition$Reference.getBeanType(Unknown Source) Eventually we fail with No bean of type [com.cloudogu.gitops.config.ApplicationConfigurator] exists. Make sure the bean is not disabled by bean requirements (enable trace logging for 'io.micronaut.context.condition' to check) and if the bean is enabled then ensure the class is declared a bean and annotation processing is enabled (for Java and Kotlin the 'micronaut-inject-java' dependency should be configured as an annotation processor). at io.micronaut.context.DefaultBeanContext.newNoSuchBeanException(DefaultBeanContext.java:2773) at io.micronaut.context.DefaultApplicationContext.newNoSuchBeanException(DefaultApplicationContext.java:292) at io.micronaut.context.DefaultBeanContext.resolveBeanRegistration(DefaultBeanContext.java:2735) So we choose the pragmatic workaround of instantiating all classes manually when running the dev image. Harder to maintain, but at least a working solution.
1 parent 742ecab commit 23fb76a

File tree

9 files changed

+278
-25
lines changed

9 files changed

+278
-25
lines changed

Dockerfile

Lines changed: 21 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ COPY src/main /app/src/main
2727
COPY compiler.groovy /app
2828

2929
WORKDIR /app
30+
# Exclude code not needed in productive image
31+
RUN cd /app/src/main/groovy/com/cloudogu/gitops/cli/ \
32+
&& rm GenerateJsonSchema.groovy \
33+
&& rm GitopsPlaygroundCliMainScripted.groovy
3034
# Build native image without micronaut
3135
RUN ./mvnw package -DskipTests
3236
# Use simple name for largest jar file -> Easier reuse in later stages
@@ -50,7 +54,7 @@ RUN apk add --no-cache \
5054
gnupg \
5155
outils-sha256 \
5256
git \
53-
bash curl unzip
57+
bash curl unzip zip
5458

5559
RUN mkdir -p /dist/usr/local/bin
5660
RUN mkdir -p /dist/home/.config
@@ -107,9 +111,19 @@ RUN rm -r /dist/app/.mvn
107111
RUN rm /dist/app/mvnw
108112
RUN rm /dist/app/pom.xml
109113
RUN rm /dist/app/compiler.groovy
114+
RUN rm -r /dist/app/src/test
110115
RUN cd /dist/app/scripts && rm downloadHelmCharts.sh apply-ng.sh
111116
# For dev image
112-
RUN mv /dist/app/src /src-without-graal && rm -r /src-without-graal/main/groovy/com/cloudogu/gitops/graal
117+
RUN mkdir /dist-dev
118+
# Remove uncessary code and allow changing code in dev mode, less secure, but the intention of the dev image
119+
# Execute bit is required to allow listing of dirs to everyone
120+
RUN mv /dist/app/src /dist-dev/src && \
121+
chmod a=rwx -R /dist-dev/src && \
122+
rm -r /dist-dev/src/main/groovy/com/cloudogu/gitops/graal
123+
# Remove compiled GOP code from jar to avoid duplicate in dev image, allowing for scripting
124+
COPY --from=maven-build /app/gitops-playground.jar /dist-dev/gitops-playground.jar
125+
RUN zip -d /dist-dev/gitops-playground.jar 'com/cloudogu/gitops/*'
126+
113127
# Required to prevent Java exceptions resulting from AccessDeniedException by jgit when running arbitrary user
114128
RUN mkdir -p /dist/root/.config/jgit
115129
RUN touch /dist/root/.config/jgit/config
@@ -201,9 +215,10 @@ ENTRYPOINT ["/app/apply-ng"]
201215
FROM eclipse-temurin:${JDK_VERSION}-jre-alpine as dev
202216

203217
# apply-ng.sh is part of the dev image and allows trying changing groovy code inside the image for debugging
204-
COPY scripts/apply-ng.sh /app/scripts/
205-
COPY --from=maven-build /app/gitops-playground.jar /app/
206-
COPY --from=downloader /src-without-graal /app/src
218+
# Allow changing code in dev mode, less secure, but the intention of the dev image
219+
COPY --chmod=777 scripts/apply-ng.sh /app/scripts/
220+
COPY --from=downloader /dist-dev /app
221+
207222
# Allow initialization in final FROM ${ENV} stage
208223
USER 0
209224
# Avoids ERROR org.eclipse.jgit.util.FS - Cannot save config file 'FileBasedConfig[/app/?/.config/jgit/config]'
@@ -216,7 +231,7 @@ ENTRYPOINT [ "java", \
216231
"org.codehaus.groovy.tools.GroovyStarter", \
217232
"--main", "groovy.ui.GroovyMain", \
218233
"--classpath", "/app/src/main/groovy", \
219-
"/app/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMain.groovy" ]
234+
"/app/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy" ]
220235

221236
# Pick final image according to build-arg
222237
FROM ${ENV}

scripts/apply-ng.sh

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ PLAYGROUND_DIR="$(cd ${ABSOLUTE_BASEDIR} && cd .. && pwd)"
66

77
function apply-ng() {
88
groovy --classpath "$PLAYGROUND_DIR"/src/main/groovy \
9-
"$PLAYGROUND_DIR"/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMain.groovy "$@"
9+
"$PLAYGROUND_DIR"/src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMainScripted.groovy "$@"
1010
}
1111

1212
# Runs groovy files without needing groovy

src/main/groovy/com/cloudogu/gitops/Application.groovy

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import jakarta.inject.Singleton
88
@Singleton
99
class Application {
1010

11-
private final List<Feature> features
11+
final List<Feature> features
1212

1313
Application(
1414
List<Feature> features

src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCli.groovy

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -229,7 +229,8 @@ class GitopsPlaygroundCli implements Runnable {
229229
}
230230

231231
def config = getConfig(context, false)
232-
context = context.registerSingleton(new Configuration(config))
232+
register(context, new Configuration(config))
233+
233234
K8sClient k8sClient = context.getBean(K8sClient)
234235

235236
if (config['application']['destroy']) {
@@ -247,6 +248,10 @@ class GitopsPlaygroundCli implements Runnable {
247248
}
248249
}
249250

251+
protected void register(ApplicationContext context, Configuration configuration) {
252+
context.registerSingleton(configuration)
253+
}
254+
250255
private void confirmOrExit(String message, Map config) {
251256
if (config['application']['yes']) {
252257
return

src/main/groovy/com/cloudogu/gitops/cli/GitopsPlaygroundCliMain.groovy

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,12 @@ import picocli.CommandLine
77
@Slf4j
88
class GitopsPlaygroundCliMain {
99

10-
Class<?> commandClass = GitopsPlaygroundCli.class
11-
1210
static void main(String[] args) throws Exception {
13-
new GitopsPlaygroundCliMain().exec(args)
11+
new GitopsPlaygroundCliMain().exec(args, GitopsPlaygroundCli.class)
1412
}
1513

16-
@SuppressWarnings('GrMethodMayBeStatic') // Non-static for easier testing
17-
void exec(String[] args) {
14+
@SuppressWarnings('GrMethodMayBeStatic') // Non-static for easier testing and reuse
15+
void exec(String[] args, Class<?> commandClass) {
1816
// log levels can be set via picocli.trace sys env - defaults to 'WARN'
1917
if (args.contains('--trace') || args.contains('-x'))
2018
System.setProperty("picocli.trace", "DEBUG")
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package com.cloudogu.gitops.cli
2+
3+
import com.cloudogu.gitops.Application
4+
import com.cloudogu.gitops.config.ApplicationConfigurator
5+
import com.cloudogu.gitops.config.ConfigToConfigFileConverter
6+
import com.cloudogu.gitops.config.Configuration
7+
import com.cloudogu.gitops.config.schema.JsonSchemaGenerator
8+
import com.cloudogu.gitops.config.schema.JsonSchemaValidator
9+
import com.cloudogu.gitops.dependencyinjection.HttpClientFactory
10+
import com.cloudogu.gitops.dependencyinjection.JenkinsFactory
11+
import com.cloudogu.gitops.dependencyinjection.RetrofitFactory
12+
import com.cloudogu.gitops.destroy.ArgoCDDestructionHandler
13+
import com.cloudogu.gitops.destroy.Destroyer
14+
import com.cloudogu.gitops.destroy.JenkinsDestructionHandler
15+
import com.cloudogu.gitops.destroy.ScmmDestructionHandler
16+
import com.cloudogu.gitops.features.*
17+
import com.cloudogu.gitops.features.argocd.ArgoCD
18+
import com.cloudogu.gitops.features.deployment.ArgoCdApplicationStrategy
19+
import com.cloudogu.gitops.features.deployment.Deployer
20+
import com.cloudogu.gitops.features.deployment.HelmStrategy
21+
import com.cloudogu.gitops.jenkins.*
22+
import com.cloudogu.gitops.scmm.ScmmRepoProvider
23+
import com.cloudogu.gitops.utils.*
24+
import groovy.util.logging.Slf4j
25+
import io.micronaut.context.ApplicationContext
26+
import jakarta.inject.Provider
27+
/**
28+
* Micronaut's dependency injection relies on statically compiled class files with seems incompatible with groovy
29+
* scripting/interpretation (without prior compilation).
30+
* The purpose of our -dev image is exactly that: allow groovy scripting inside the image, to shorten the dev cycle on
31+
* air-gapped customer envs.
32+
*
33+
* To make this work the dev image gets it's own main() method that explicitly creates instances of the groovy classes.
34+
* Yes, redundant and not beautiful, but not using dependency injection is worse.
35+
*/
36+
@Slf4j
37+
class GitopsPlaygroundCliMainScripted {
38+
39+
static void main(String[] args) throws Exception {
40+
new GitopsPlaygroundCliMain().exec(args, GitopsPlaygroundCliScripted.class)
41+
}
42+
43+
static class GitopsPlaygroundCliScripted extends GitopsPlaygroundCli {
44+
45+
@Override
46+
protected ApplicationContext createApplicationContext() {
47+
ApplicationContext context = super.createApplicationContext()
48+
49+
// Create ApplicationConfigurator to get started
50+
context.registerSingleton(
51+
new ApplicationConfigurator(
52+
new NetworkingUtils(new K8sClient(new CommandExecutor(), new FileSystemUtils(), null), new CommandExecutor()),
53+
new FileSystemUtils(),
54+
new JsonSchemaValidator(new JsonSchemaGenerator())))
55+
context.registerSingleton(new ConfigToConfigFileConverter())
56+
return context
57+
}
58+
59+
@Override
60+
protected void register(ApplicationContext context, Configuration config) {
61+
super.register(context, config)
62+
63+
// After config is set, create all other beans
64+
65+
def fileSystemUtils = new FileSystemUtils()
66+
def executor = new CommandExecutor()
67+
def k8sClient = new K8sClient(executor, fileSystemUtils, new Provider<Configuration>() {
68+
@Override
69+
Configuration get() {
70+
return config
71+
}
72+
})
73+
def helmClient = new HelmClient(executor)
74+
75+
def httpClientFactory = new HttpClientFactory()
76+
77+
def scmmRepoProvider = new ScmmRepoProvider(config, fileSystemUtils)
78+
def retrofitFactory = new RetrofitFactory()
79+
80+
def insecureSslContextProvider = new Provider<HttpClientFactory.InsecureSslContext>() {
81+
@Override
82+
HttpClientFactory.InsecureSslContext get() {
83+
return httpClientFactory.insecureSslContext()
84+
}
85+
}
86+
def httpClientScmm = retrofitFactory.okHttpClient(httpClientFactory.createLoggingInterceptor(), config, insecureSslContextProvider)
87+
def retrofit = retrofitFactory.retrofit(config, httpClientScmm)
88+
def repoApi = retrofitFactory.repositoryApi(retrofit)
89+
90+
def jenkinsConfiguration = new JenkinsConfigurationAdapter(config)
91+
JenkinsFactory jenkinsFactory = new JenkinsFactory(jenkinsConfiguration)
92+
def jenkinsApiClient = jenkinsFactory.jenkinsApiClient(
93+
httpClientFactory.okHttpClient(httpClientFactory.createLoggingInterceptor(), jenkinsConfiguration, insecureSslContextProvider))
94+
95+
context.registerSingleton(k8sClient)
96+
97+
if (config.config['application']['destroy']) {
98+
context.registerSingleton(new Destroyer([
99+
new ArgoCDDestructionHandler(config, k8sClient, scmmRepoProvider, helmClient, fileSystemUtils),
100+
new ScmmDestructionHandler(config, retrofitFactory.usersApi(retrofit), retrofitFactory.repositoryApi(retrofit)),
101+
new JenkinsDestructionHandler(new JobManager(jenkinsApiClient), config, new GlobalPropertyManager(jenkinsApiClient))
102+
]))
103+
} else {
104+
def helmStrategy = new HelmStrategy(config, helmClient)
105+
106+
def deployer = new Deployer(config, new ArgoCdApplicationStrategy(config, fileSystemUtils, scmmRepoProvider), helmStrategy)
107+
108+
def airGappedUtils = new AirGappedUtils(config, scmmRepoProvider, repoApi, fileSystemUtils, helmClient)
109+
110+
context.registerSingleton(new Application([
111+
new Registry(config, fileSystemUtils, k8sClient, helmStrategy),
112+
new ScmManager(config, executor, fileSystemUtils, helmStrategy),
113+
new Jenkins(config, executor, fileSystemUtils, new GlobalPropertyManager(jenkinsApiClient),
114+
new JobManager(jenkinsApiClient), new UserManager(jenkinsApiClient),
115+
new PrometheusConfigurator(jenkinsApiClient)),
116+
new ArgoCD(config, k8sClient, helmClient, fileSystemUtils, scmmRepoProvider),
117+
new IngressNginx(config, fileSystemUtils, deployer, k8sClient, airGappedUtils),
118+
new Mailhog(config, fileSystemUtils, deployer, k8sClient, airGappedUtils),
119+
new PrometheusStack(config, fileSystemUtils, deployer, k8sClient, airGappedUtils),
120+
new ExternalSecretsOperator(config, fileSystemUtils, deployer, k8sClient, airGappedUtils),
121+
new Vault(config, fileSystemUtils, k8sClient, deployer, airGappedUtils)
122+
]))
123+
}
124+
}
125+
}
126+
}

src/main/groovy/com/cloudogu/gitops/destroy/Destroyer.groovy

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,14 @@ import jakarta.inject.Singleton
55

66
@Singleton
77
@Slf4j
8-
class Destroyer implements DestructionHandler {
8+
class Destroyer {
99

10-
private final List<DestructionHandler> destructionHandlers
10+
final List<DestructionHandler> destructionHandlers
1111

1212
Destroyer(List<DestructionHandler> destructionHandlers) {
1313
this.destructionHandlers = destructionHandlers
1414
}
1515

16-
@Override
1716
void destroy() {
1817
log.info("Start destroying")
1918
for (def handler in destructionHandlers) {
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
package com.cloudogu.gitops.cli
2+
3+
import com.cloudogu.gitops.Application
4+
import com.cloudogu.gitops.Feature
5+
import com.cloudogu.gitops.config.Configuration
6+
import com.cloudogu.gitops.destroy.Destroyer
7+
import com.cloudogu.gitops.destroy.DestructionHandler
8+
import io.github.classgraph.ClassGraph
9+
import io.github.classgraph.ClassInfo
10+
import io.micronaut.context.ApplicationContext
11+
import io.micronaut.core.annotation.Order
12+
import org.junit.jupiter.api.Test
13+
14+
import static org.assertj.core.api.Assertions.assertThat
15+
import static org.assertj.core.api.Fail.fail
16+
/**
17+
* It is difficult to test if *all* classes are instantiated.
18+
* Except for edge cases like outputConfigFile or delete,
19+
* the core logic of the application is to install all {@link Feature}s in the proper {@link Order}.
20+
* At least this we can test!
21+
*/
22+
class GitopsPlaygroundCliMainScriptedTest {
23+
24+
ApplicationContext applicationContext
25+
GitopsPlaygroundCliScriptedForTest gitopsPlaygroundCliScripted = new GitopsPlaygroundCliScriptedForTest()
26+
Configuration config = new Configuration(
27+
application: [
28+
baseUrl: 'http://localhost',
29+
],
30+
jenkins: [
31+
url: 'http://jenkins',
32+
],
33+
registry: [:],
34+
scmm: [
35+
url: 'http://scmm',
36+
],
37+
features: [
38+
argocd: [:]
39+
],
40+
)
41+
42+
/**
43+
* This test makes sure that we don't forget to add new {@link Feature} classes to
44+
* {@link GitopsPlaygroundCliMainScripted.GitopsPlaygroundCliScripted#register(io.micronaut.context.ApplicationContext, com.cloudogu.gitops.config.Configuration)}
45+
* so they also work in the dev image.
46+
*/
47+
@Test
48+
void 'all Feature classes are instantiated in the correct order'() {
49+
gitopsPlaygroundCliScripted.createApplicationContext()
50+
gitopsPlaygroundCliScripted.register(applicationContext, config)
51+
52+
List<String> actualClasses = applicationContext.getBean(Application).features
53+
.collect { it.class.simpleName }
54+
55+
def expectedClasses = findAllChildClasses(Feature)
56+
57+
assertThat(actualClasses).containsExactlyElementsOf(expectedClasses)
58+
}
59+
60+
@Test
61+
void 'all DestructionHandlers are instantiated in the correct order'() {
62+
config.config['application']['destroy'] = true
63+
64+
gitopsPlaygroundCliScripted.createApplicationContext()
65+
gitopsPlaygroundCliScripted.register(applicationContext, config)
66+
67+
List<String> actualClasses = applicationContext.getBean(Destroyer).destructionHandlers
68+
.collect { it.class.simpleName }
69+
70+
def expectedClasses = findAllChildClasses(DestructionHandler)
71+
72+
assertThat(actualClasses).containsExactlyElementsOf(expectedClasses)
73+
}
74+
75+
protected List<String> findAllChildClasses(Class<?> parentClass) {
76+
boolean parentIsInterface = parentClass.isInterface()
77+
78+
def featureClasses = []
79+
80+
new ClassGraph()
81+
.acceptPackages("com.cloudogu")
82+
.enableClassInfo()
83+
.enableAnnotationInfo()
84+
.scan().withCloseable { scanResult ->
85+
scanResult.getAllClasses().each { ClassInfo classInfo ->
86+
if (classInfo.name.endsWith("Test")) {
87+
return
88+
}
89+
90+
if (classInfo.extendsSuperclass(parentClass) ||
91+
(parentIsInterface && classInfo.implementsInterface(parentClass))) {
92+
def orderAnnotation = classInfo.getAnnotationInfo(Order)
93+
if (orderAnnotation) {
94+
def orderValue = orderAnnotation.getParameterValues().getValue('value') as int
95+
def clazz = classInfo.loadClass()
96+
featureClasses << [clazz: clazz, orderValue: orderValue]
97+
} else {
98+
fail("Class ${classInfo.name} does not have @Order annotation")
99+
}
100+
}
101+
}
102+
}
103+
104+
return featureClasses.sort { a, b ->
105+
Integer.compare(a['orderValue'] as Integer, b['orderValue'] as Integer)
106+
}.collect { it['clazz']['simpleName'] } as List<String>
107+
}
108+
109+
class GitopsPlaygroundCliScriptedForTest extends GitopsPlaygroundCliMainScripted.GitopsPlaygroundCliScripted {
110+
@Override
111+
protected ApplicationContext createApplicationContext() {
112+
applicationContext = super.createApplicationContext()
113+
}
114+
}
115+
}

0 commit comments

Comments
 (0)