From fca1db880253ca26115f0cbbe4a49ae001f4a0d3 Mon Sep 17 00:00:00 2001 From: Logan Gorence Date: Fri, 24 Dec 2021 00:43:44 +0000 Subject: [PATCH] Add S3 support to backups, fixes #7. --- .../core/abstraction/FoundationPlugin.kt | 8 +++- .../core/features/backup/BackupCommand.kt | 45 +++++++++++------ .../core/features/backup/BackupConfig.kt | 18 +++++++ .../core/features/backup/BackupFeature.kt | 48 ++++++++++++++++++- .../src/main/resources/backup.yaml | 23 +++++++++ 5 files changed, 126 insertions(+), 16 deletions(-) create mode 100644 foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupConfig.kt create mode 100644 foundation-core/src/main/resources/backup.yaml diff --git a/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/abstraction/FoundationPlugin.kt b/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/abstraction/FoundationPlugin.kt index 60da082..c3b178e 100644 --- a/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/abstraction/FoundationPlugin.kt +++ b/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/abstraction/FoundationPlugin.kt @@ -24,13 +24,19 @@ abstract class FoundationPlugin : JavaPlugin() { features = createFeatures() module = createModule() - // TODO: If we have another plugin using this class, we may need to use context isolation. + // TODO: If we have another plugin using Koin, we may need to use context isolation and ensure + // it uses the same context so they can fetch stuff from us. // https://insert-koin.io/docs/reference/koin-core/context-isolation pluginApplication = startKoin { modules(pluginModule) modules(module) } + // This is probably a bit of a hack. + pluginApplication.modules(module { + single { pluginApplication } + }) + features.forEach { pluginApplication.modules(it.module()) } diff --git a/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupCommand.kt b/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupCommand.kt index 5d88e37..b20ba68 100644 --- a/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupCommand.kt +++ b/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupCommand.kt @@ -8,6 +8,8 @@ import org.bukkit.Server import org.bukkit.command.Command import org.bukkit.command.CommandExecutor import org.bukkit.command.CommandSender +import software.amazon.awssdk.services.s3.S3Client +import software.amazon.awssdk.services.s3.model.PutObjectRequest import java.io.BufferedOutputStream import java.io.FileInputStream import java.io.FileOutputStream @@ -19,9 +21,12 @@ import java.util.concurrent.atomic.AtomicBoolean import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream +// TODO: Clean up dependency injection. class BackupCommand( private val plugin: FoundationCorePlugin, - private val backupPath: Path + private val backupsPath: Path, + private val config: BackupConfig, + private val s3Client: S3Client, ) : CommandExecutor { override fun onCommand( sender: CommandSender, command: Command, label: String, args: Array @@ -47,25 +52,37 @@ class BackupCommand( return true } - private fun runBackup(server: Server) { + // TODO: Pull backup creation code into a separate service. + private fun runBackup(server: Server) = try { RUNNING.set(true) server.sendMessage(Util.formatSystemMessage("Backup started.")) - val backupFile = - backupPath.resolve(String.format("backup-%s.zip", Instant.now().toString())).toFile() + val backupFileName = String.format("backup-%s.zip", Instant.now().toString()) + val backupPath = backupsPath.resolve(backupFileName) + val backupFile = backupPath.toFile() - try { - FileOutputStream(backupFile).use { zipFileStream -> - ZipOutputStream(BufferedOutputStream(zipFileStream)).use { zipStream -> - backupPlugins(server, zipStream) - backupWorlds(server, zipStream) - } + FileOutputStream(backupFile).use { zipFileStream -> + ZipOutputStream(BufferedOutputStream(zipFileStream)).use { zipStream -> + backupPlugins(server, zipStream) + backupWorlds(server, zipStream) } - } finally { - RUNNING.set(false) - server.sendMessage(Util.formatSystemMessage("Backup finished.")) } + + // TODO: Pull upload code out into a separate service. + if (config.s3.accessKeyId.isNotEmpty()) { + s3Client.putObject( + PutObjectRequest.builder().apply { + bucket(config.s3.bucket) + key("${config.s3.baseDirectory}/$backupFileName") + }.build(), + backupPath + ) + } + Unit + } finally { + RUNNING.set(false) + server.sendMessage(Util.formatSystemMessage("Backup finished.")) } private fun backupPlugins(server: Server, zipStream: ZipOutputStream) { @@ -106,7 +123,7 @@ class BackupCommand( .filter { path: Path? -> Files.isRegularFile(path) } .toList() val buffer = ByteArray(1024) - val backupsPath = backupPath.toRealPath() + val backupsPath = backupsPath.toRealPath() for (path in paths) { val realPath = path.toRealPath() diff --git a/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupConfig.kt b/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupConfig.kt new file mode 100644 index 0000000..203d51b --- /dev/null +++ b/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupConfig.kt @@ -0,0 +1,18 @@ +package cloud.kubelet.foundation.core.features.backup + +import kotlinx.serialization.Serializable + +@Serializable +data class BackupConfig( + val s3: S3Config = S3Config(), +) + +@Serializable +data class S3Config( + val accessKeyId: String = "", + val secretAccessKey: String = "", + val region: String = "", + val endpointOverride: String = "", + val bucket: String = "", + val baseDirectory: String = "", +) diff --git a/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupFeature.kt b/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupFeature.kt index e9bd42e..ff07d42 100644 --- a/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupFeature.kt +++ b/foundation-core/src/main/kotlin/cloud/kubelet/foundation/core/features/backup/BackupFeature.kt @@ -1,18 +1,64 @@ package cloud.kubelet.foundation.core.features.backup import cloud.kubelet.foundation.core.FoundationCorePlugin +import cloud.kubelet.foundation.core.Util import cloud.kubelet.foundation.core.abstraction.Feature +import com.charleskorn.kaml.Yaml +import org.koin.core.KoinApplication import org.koin.core.component.inject +import org.koin.dsl.module +import software.amazon.awssdk.auth.credentials.AwsSessionCredentials +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider +import software.amazon.awssdk.regions.Region +import software.amazon.awssdk.services.s3.S3Client +import java.net.URI +import kotlin.io.path.inputStream class BackupFeature : Feature() { private val plugin by inject() + private val s3Client by inject() + private val config by inject() override fun enable() { // Create backup directory. val backupPath = plugin.pluginDataPath.resolve(BACKUPS_DIRECTORY) backupPath.toFile().mkdir() - registerCommandExecutor("fbackup", BackupCommand(plugin, backupPath)) + registerCommandExecutor("fbackup", BackupCommand(plugin, backupPath, config, s3Client)) + } + + override fun module() = module { + single { + val configPath = Util.copyDefaultConfig( + plugin.slF4JLogger, + plugin.pluginDataPath, + "backup.yaml", + ) + return@single Yaml.default.decodeFromStream( + BackupConfig.serializer(), + configPath.inputStream() + ) + } + single { + val config = get() + + val creds = StaticCredentialsProvider.create( + AwsSessionCredentials.create(config.s3.accessKeyId, config.s3.secretAccessKey, "") + ) + val builder = S3Client.builder().credentialsProvider(creds) + + if (config.s3.endpointOverride.isNotEmpty()) { + builder.endpointOverride(URI.create(config.s3.endpointOverride)) + } + + if (config.s3.region.isNotEmpty()) { + builder.region(Region.of(config.s3.region)) + } else { + builder.region(Region.US_WEST_1) + } + + builder.build() + } } companion object { diff --git a/foundation-core/src/main/resources/backup.yaml b/foundation-core/src/main/resources/backup.yaml new file mode 100644 index 0000000..d043b8e --- /dev/null +++ b/foundation-core/src/main/resources/backup.yaml @@ -0,0 +1,23 @@ +# Configuration of S3 service to upload back-ups to. +s3: + # The access key ID from your S3-compliant storage provider. + # If empty, backups will not be uploaded to S3. + accessKeyId: "" + + # The secret access key from your S3-compliant storage provider. + secretAccessKey: "" + + # The region the bucket is located in. If using something other than AWS, this field can be set to + # any valid region (us-west-1, etc.), or blank which defaults to us-west-1. + region: "" + + # An endpoint override, this is typically used for S3-compatible services like Backblaze B2. + # If not specified, it will use the AWS region specified. + endpointOverride: "" + + # Name of the bucket to upload to. + bucket: "" + + # Base directory to store backups in. Value being set to "my-server" will store backups with a + # path like bucket-name/my-server/backup-2021-12-21T00:06:41.760568Z.zip + baseDirectory: ""