Skip to content

🎉 introduce automatic migration at the startup of server for docker environment #3980

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 57 commits into from
Jun 29, 2021
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
9d7d21c
introduce automatic migration at the startup of server
subodh1810 Jun 9, 2021
403964b
handle versions with non-zero patch
subodh1810 Jun 9, 2021
2879348
it works!!!
subodh1810 Jun 10, 2021
1f72c01
add dummy data
subodh1810 Jun 10, 2021
47d4cea
cleanup orphan configs
subodh1810 Jun 10, 2021
8ad8c45
add more assertions
subodh1810 Jun 10, 2021
6a618a0
format + add comments
subodh1810 Jun 10, 2021
153955b
move migration acceptance test to acceptance test directory
subodh1810 Jun 11, 2021
bfbf077
add automatic migration test to the build
subodh1810 Jun 11, 2021
1951f01
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 14, 2021
e5ea8f9
address review comments
subodh1810 Jun 14, 2021
e95f76e
missed out on these
subodh1810 Jun 14, 2021
217de6d
format
subodh1810 Jun 14, 2021
8d7a028
add more assertions
subodh1810 Jun 14, 2021
b18b68e
format
subodh1810 Jun 14, 2021
d125c56
fix test
subodh1810 Jun 14, 2021
4ffa193
format
subodh1810 Jun 14, 2021
4063cad
use default port for temporal
subodh1810 Jun 14, 2021
f24b720
move seed to server + introduce atomice replacement for config
subodh1810 Jun 16, 2021
97ed4f7
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 16, 2021
0da61dd
make tests better
subodh1810 Jun 16, 2021
0ea3d4d
remove unwanted changes
subodh1810 Jun 16, 2021
5df5906
move atomic replacement logic behind persistence + pass path to lates…
subodh1810 Jun 17, 2021
b7bc8a3
format
subodh1810 Jun 17, 2021
444a437
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 17, 2021
d469814
update seeds
subodh1810 Jun 17, 2021
8d46a8d
review comments
subodh1810 Jun 18, 2021
d1af88e
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 18, 2021
a37ae68
update seeds
subodh1810 Jun 18, 2021
94a9460
merge latest seeds with configs
subodh1810 Jun 21, 2021
4fb77cb
fix bug around latest seed
subodh1810 Jun 21, 2021
ba15561
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 21, 2021
d33dba6
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 21, 2021
24128c1
update seed
subodh1810 Jun 21, 2021
e7b63e4
update seed
subodh1810 Jun 21, 2021
fa53018
seeds should be populated by separate container
subodh1810 Jun 23, 2021
b40b3d9
address review comment + change latest definition url
subodh1810 Jun 23, 2021
d2967a0
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 23, 2021
cd39b92
update seeds
subodh1810 Jun 23, 2021
3df18e4
format
subodh1810 Jun 23, 2021
86ae0cb
update seed references
subodh1810 Jun 23, 2021
4108203
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 23, 2021
90aa45d
update seed
subodh1810 Jun 23, 2021
6b48f43
update seed
subodh1810 Jun 23, 2021
9856eac
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 24, 2021
aa5736a
update seed
subodh1810 Jun 24, 2021
f766e75
update seed references
subodh1810 Jun 24, 2021
83a0008
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 29, 2021
26694ec
update seed references + add Migration Acceptance Test
subodh1810 Jun 29, 2021
9757571
update seed container in kube + disable automatic migration for kube …
subodh1810 Jun 29, 2021
31d3282
update docs
subodh1810 Jun 29, 2021
d177356
address review comments from Michel
subodh1810 Jun 29, 2021
ff22b6a
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 29, 2021
fa4e0a8
update doc
subodh1810 Jun 29, 2021
0717e80
temporary commmit to see if build becomes green
subodh1810 Jun 29, 2021
9c7c7d3
Merge branch 'master' into automatic-migration-via-server
subodh1810 Jun 29, 2021
5f2894a
delete seeds from airbyte config + undo temp commit
subodh1810 Jun 29, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,10 @@ jobs:
- name: Run Docker End-to-End Acceptance Tests
run: |
./tools/bin/acceptance_test.sh

- name: Automatic Migration Acceptance Test
run: |
./tools/bin/automatic_migration_acceptance_test.sh
# In case of self-hosted EC2 errors, remove the `stop-build-runner` block.
stop-acceptance-test-runner:
name: Stop Acceptance Test EC2 Runner
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
package io.airbyte.migrate;

import io.airbyte.commons.io.Archives;
import io.airbyte.commons.version.AirbyteVersion;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
Expand All @@ -41,11 +42,22 @@ public class MigrationRunner {
private static final Logger LOGGER = LoggerFactory.getLogger(MigrationRunner.class);

public static void run(String[] args) throws IOException {

final Path workspaceRoot = Files.createTempDirectory(Path.of("/tmp"), "airbyte_migrate");

MigrateConfig migrateConfig = parse(args);
run(migrateConfig);
}

public static void run(MigrateConfig migrateConfig) throws IOException {
final Path workspaceRoot = Files.createTempDirectory(Path.of("/tmp"), "airbyte_migrate");
AirbyteVersion airbyteVersion = new AirbyteVersion(migrateConfig.getTargetVersion());
if (!airbyteVersion.getPatchVersion().equals("0")) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yuck. but a good call would it make sense to move this logic into its own method? maybe a static on AirbyteVersion?

String targetVersionWithoutPatch = "" + airbyteVersion.getMajorVersion()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: a String.format would make things easier to read.

+ "."
+ airbyteVersion.getMinorVersion()
+ ".0-"
+ airbyteVersion.getVersion().replace("\n", "").strip().split("-")[1];
migrateConfig = new MigrateConfig(migrateConfig.getInputPath(), migrateConfig.getOutputPath(),
targetVersionWithoutPatch);
}
if (migrateConfig.getInputPath().toString().endsWith(".gz")) {
LOGGER.info("Unpacking tarball");
final Path uncompressedInputPath = Files.createDirectories(workspaceRoot.resolve("uncompressed"));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ required:
- catalog
additionalProperties: false
properties:
prefix:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this file changing?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We didn't introduce this change back when we wrote this migration. Thats why its required

description: Prefix that will be prepended to the name of each stream when it is written to the destination.
type: string
sourceId:
type: string
format: uuid
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,8 @@ public static void main(String[] args) throws IOException, InterruptedException

Optional<String> airbyteDatabaseVersion = jobPersistence.getVersion();
int loopCount = 0;
while (airbyteDatabaseVersion.isEmpty() && loopCount < 300) {
while ((airbyteDatabaseVersion.isEmpty() || !AirbyteVersion.isCompatible(configs.getAirbyteVersion(), airbyteDatabaseVersion.get()))
&& loopCount < 300) {
LOGGER.warn("Waiting for Server to start...");
TimeUnit.SECONDS.sleep(1);
airbyteDatabaseVersion = jobPersistence.getVersion();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -439,10 +439,10 @@ public Optional<String> getVersion() throws IOException {
@Override
public void setVersion(String airbyteVersion) throws IOException {
database.query(ctx -> ctx.execute(String.format(
"INSERT INTO %s VALUES('%s', '%s'), ('%s_init_db', '%s');",
"INSERT INTO %s VALUES('%s', '%s'), ('%s_init_db', '%s') ON CONFLICT (key) DO UPDATE SET value = '%s'",
AIRBYTE_METADATA_TABLE,
AirbyteVersion.AIRBYTE_VERSION_KEY_NAME, airbyteVersion,
current_timestamp(), airbyteVersion)));
current_timestamp(), airbyteVersion, airbyteVersion)));
}

private static String current_timestamp() {
Expand All @@ -454,6 +454,22 @@ public Map<DatabaseSchema, Stream<JsonNode>> exportDatabase() throws IOException
return exportDatabase(DEFAULT_SCHEMA);
}

@Override
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

putting this comment here because the line of code i want isn't in the diff.

should the import do an atomic replace like we do for the config persistence?

public Map<String, Stream<JsonNode>> exportEverythingInDefaultSchema() throws IOException {
return exportEverything(DEFAULT_SCHEMA);
}

private Map<String, Stream<JsonNode>> exportEverything(final String schema) throws IOException {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this seems very similar to exportDatabase. is this implementation different because we don't want to reference DatabaseSchema.java since that might be at a later version? if so, I would leave a comment explaining this.

final List<String> tables = listTables(schema);
final Map<String, Stream<JsonNode>> result = new HashMap<>();

for (final String table : tables) {
result.put(table.toUpperCase(), exportTable(schema, table));
}

return result;
}

private Map<DatabaseSchema, Stream<JsonNode>> exportDatabase(final String schema) throws IOException {
final List<String> tables = listTables(schema);
final Map<DatabaseSchema, Stream<JsonNode>> result = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,8 @@ public interface JobPersistence {
*/
Map<DatabaseSchema, Stream<JsonNode>> exportDatabase() throws IOException;

Map<String, Stream<JsonNode>> exportEverythingInDefaultSchema() throws IOException;
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why is this name scoped by "default schema". I think the contract we are looking for here is that the JobPersistence is that we can ask it to give us all of its data as an archive. I'm not sure why mentioning the schema is important.

Keep in mind we may want to allow using different databases in the future and hide them behind this interface. I mention that case only because it may make it clearer that mentioning the PG specific schema shouldn't be the caller's concern.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

also i know in our conversations i kept saying exportEverything but probably the more common name fot this method would just be dump or export.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

+1 for dump()

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually what is the difference between dump and exportDatabase? they seem to be performing the same thing under the hood. related to my comment in DefaultJobPersistence.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main difference between dump and exportDatabase is that dump downloads all the tables of all the non-system schemas available in the database while exportDatabase only downloads the tables available in DatabaseSchema.java


/**
* Import all SQL tables from streams of JsonNode objects.
*
Expand Down
1 change: 1 addition & 0 deletions airbyte-server/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ dependencies {
implementation project(':airbyte-scheduler:models')
implementation project(':airbyte-scheduler:persistence')
implementation project(':airbyte-workers')
implementation project(':airbyte-migration')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sort.


testImplementation "org.postgresql:postgresql:42.2.18"

Expand Down
152 changes: 152 additions & 0 deletions airbyte-server/src/main/java/io/airbyte/server/ConfigDumpExport.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
/*
* MIT License
*
* Copyright (c) 2020 Airbyte
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

package io.airbyte.server;

import com.fasterxml.jackson.databind.JsonNode;
import io.airbyte.commons.io.Archives;
import io.airbyte.commons.lang.CloseableConsumer;
import io.airbyte.commons.lang.Exceptions;
import io.airbyte.commons.yaml.Yamls;
import io.airbyte.scheduler.persistence.JobPersistence;
import java.io.BufferedWriter;
import java.io.File;
import java.io.FileWriter;
import java.io.IOException;
import java.nio.charset.Charset;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.io.FileUtils;

// TODO: Write a test case which compares the output dump with the output of ArchiveHandler export
// for the same data
public class ConfigDumpExport {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: add a doc string here to explain why this exist separate from the ArchiveHandler's export function

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can this just replace the archive handler's export function? it seems like we should just prefer this implementation to the existing one in the ArchiveHandler.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some users reported they've been storing their own tables/data in the Airbyte Postgres database too.

So this dump would also export their custom tables/schema too? is that going to be a problem in such a use case?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

woof. i don't think it'll be a problem for now. but probably something we need to be careful about when we add our postgres version?


private static final String ARCHIVE_FILE_NAME = "airbyte_config_dump";
private static final String CONFIG_FOLDER_NAME = "airbyte_config";
private static final String DB_FOLDER_NAME = "airbyte_db";
private static final String VERSION_FILE_NAME = "VERSION";

private final ConfigDumpUtil configDumpUtil;
private final JobPersistence jobPersistence;
private final String version;

public ConfigDumpExport(Path storageRoot, JobPersistence jobPersistence, String version) {
this.configDumpUtil = new ConfigDumpUtil(storageRoot);
this.jobPersistence = jobPersistence;
this.version = version;
}

public File dump() {
try {
final Path tempFolder = Files.createTempDirectory(Path.of("/tmp"), ARCHIVE_FILE_NAME);
final File dump = Files.createTempFile(ARCHIVE_FILE_NAME, ".tar.gz").toFile();
exportVersionFile(tempFolder);
dumpConfigs(tempFolder);
dumpDatabase(tempFolder);

Archives.createArchive(tempFolder, dump.toPath());
return dump;
} catch (Exception e) {
throw new RuntimeException(e);
}
}

public void deleteOrphanDirectories() {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

per the conversation we had, i think that the atomic replace in config persistence should handle this for us. ideally we shouldn't need to call this. the db should just handle it.

try {
configDumpUtil.orphanDirectories();
} catch (Exception e) {
throw new RuntimeException(e);
}
}

private void exportVersionFile(Path tempFolder) throws IOException {
final File versionFile = Files.createFile(tempFolder.resolve(VERSION_FILE_NAME)).toFile();
FileUtils.writeStringToFile(versionFile, version, Charset.defaultCharset());
}

private void dumpDatabase(Path parentFolder) throws Exception {
final Map<String, Stream<JsonNode>> tables = jobPersistence.exportEverythingInDefaultSchema();
Files.createDirectories(parentFolder.resolve(DB_FOLDER_NAME));
for (Map.Entry<String, Stream<JsonNode>> table : tables.entrySet()) {
final Path tablePath = buildTablePath(parentFolder, table.getKey());
writeTableToArchive(tablePath, table.getValue());
}
}

private void writeTableToArchive(final Path tablePath, final Stream<JsonNode> tableStream)
throws Exception {
Files.createDirectories(tablePath.getParent());
final BufferedWriter recordOutputWriter = new BufferedWriter(
new FileWriter(tablePath.toFile()));
final CloseableConsumer<JsonNode> recordConsumer = Yamls.listWriter(recordOutputWriter);
tableStream.forEach(row -> Exceptions.toRuntime(() -> {
recordConsumer.accept(row);
}));
recordConsumer.close();
}

protected static Path buildTablePath(final Path storageRoot, final String tableName) {
return storageRoot
.resolve(DB_FOLDER_NAME)
.resolve(String.format("%s.yaml", tableName.toUpperCase()));
}

public void dumpConfigs(Path parentFolder) throws IOException {
List<String> directories = configDumpUtil.listDirectories();
for (String directory : directories) {
List<JsonNode> configList = configDumpUtil.listConfig(directory);

writeConfigsToArchive(parentFolder, directory, configList);
}
}

private void writeConfigsToArchive(final Path storageRoot,
final String schemaType,
final List<JsonNode> configList)
throws IOException {
final Path configPath = buildConfigPath(storageRoot, schemaType);
Files.createDirectories(configPath.getParent());
if (!configList.isEmpty()) {
final List<JsonNode> sortedConfigs = configList.stream()
.sorted(Comparator.comparing(JsonNode::toString)).collect(
Collectors.toList());
Files.writeString(configPath, Yamls.serialize(sortedConfigs));
} else {
// Create empty file
Files.createFile(configPath);
}
}

private static Path buildConfigPath(final Path storageRoot, final String schemaType) {
return storageRoot.resolve(CONFIG_FOLDER_NAME)
.resolve(String.format("%s.yaml", schemaType));
}

}
110 changes: 110 additions & 0 deletions airbyte-server/src/main/java/io/airbyte/server/ConfigDumpUtil.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
/*
* MIT License
*
* Copyright (c) 2020 Airbyte
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/

package io.airbyte.server;

import com.fasterxml.jackson.databind.JsonNode;
import com.google.common.collect.Lists;
import io.airbyte.commons.json.Jsons;
import io.airbyte.config.ConfigSchema;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.commons.io.FileUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class ConfigDumpUtil {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can we put this behind the ConfigPersistence interface? remember we are trying to add support for postgres as a ConfigPersistence. If this isn't behind that interface then we'll have to add a switch statement to handle different database types when exporting. Ideally we'd like to hide this behind the ConfigPersistence iface so that we don't have to deal with that concern.


private static final Logger LOGGER = LoggerFactory.getLogger(ConfigDumpUtil.class);
private final Path storageRoot;

private static final String CONFIG_DIR = "config";

public ConfigDumpUtil(Path storageRoot) {
this.storageRoot = storageRoot.resolve(CONFIG_DIR);
}

public List<String> listDirectories() throws IOException {
try (Stream<Path> files = Files.list(storageRoot)) {
List<String> directoryName = files.map(c -> c.getFileName().toString())
.collect(Collectors.toList());
return directoryName;

}
}

public void orphanDirectories() throws IOException {
Set<String> configSchemas = Arrays.asList(ConfigSchema.values()).stream().map(c -> c.toString())
.collect(
Collectors.toSet());
for (String directory : listDirectories()) {
if (!configSchemas.contains(directory)) {
File file = storageRoot.resolve(directory).toFile();
LOGGER.info("Deleting directory " + file);
if (!FileUtils.deleteQuietly(file)) {
LOGGER.warn("Could not delete directory " + file);
}
}
}
}

public List<JsonNode> listConfig(String configType) throws IOException {
final Path configTypePath = storageRoot.resolve(configType);
if (!Files.exists(configTypePath)) {
return Collections.emptyList();
}
try (Stream<Path> files = Files.list(configTypePath)) {
final List<String> ids = files
.filter(p -> !p.endsWith(".json"))
.map(p -> p.getFileName().toString().replace(".json", ""))
.collect(Collectors.toList());

final List<JsonNode> configs = Lists.newArrayList();
for (String id : ids) {
try {
final Path configPath = storageRoot.resolve(configType).resolve(String.format("%s.json", id));
if (!Files.exists(configPath)) {
throw new RuntimeException("Config NotFound");
}

final JsonNode config = Jsons.deserialize(Files.readString(configPath), JsonNode.class);
configs.add(config);
} catch (RuntimeException e) {
throw new IOException(e);
}
}

return configs;
}
}

}
Loading