diff --git a/packages/cli/build.gradle.kts b/packages/cli/build.gradle.kts index 69782acfe5..e0af6279d8 100644 --- a/packages/cli/build.gradle.kts +++ b/packages/cli/build.gradle.kts @@ -657,6 +657,9 @@ dependencies { implementation(libs.jib.core) implementation(libs.jib.extension.commons) + // Mock S3 + implementation(libs.locals3) + // Tests testImplementation(libs.kotlin.test.junit5) testImplementation(projects.packages.test) diff --git a/packages/cli/src/main/kotlin/elide/tool/cli/Elide.kt b/packages/cli/src/main/kotlin/elide/tool/cli/Elide.kt index a42b623449..79d662e112 100644 --- a/packages/cli/src/main/kotlin/elide/tool/cli/Elide.kt +++ b/packages/cli/src/main/kotlin/elide/tool/cli/Elide.kt @@ -56,6 +56,7 @@ import elide.tool.cli.cmd.pkl.ToolPklCommand import elide.tool.cli.cmd.project.ToolProjectCommand import elide.tool.cli.cmd.tool.ToolInvokeCommand import elide.tool.cli.cmd.repl.ToolShellCommand +import elide.tool.cli.cmd.s3.ToolS3Command import elide.tool.cli.cmd.secrets.ToolSecretsCommand import elide.tool.cli.cmd.tool.jar.JarToolAdapter import elide.tool.cli.cmd.tool.javac.JavaCompilerAdapter @@ -132,6 +133,7 @@ internal const val ELIDE_HEADER = ("@|bold,fg(magenta)%n" + McpCommand::class, Elide.Completions::class, ToolSecretsCommand::class, + ToolS3Command::class, ], customSynopsis = [ "", diff --git a/packages/cli/src/main/kotlin/elide/tool/cli/cmd/s3/ToolS3Command.kt b/packages/cli/src/main/kotlin/elide/tool/cli/cmd/s3/ToolS3Command.kt new file mode 100644 index 0000000000..86c387229c --- /dev/null +++ b/packages/cli/src/main/kotlin/elide/tool/cli/cmd/s3/ToolS3Command.kt @@ -0,0 +1,90 @@ +/* + * Copyright (c) 2024-2025 Elide Technologies, Inc. + * + * Licensed under the MIT license (the "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://opensource.org/license/mit/ + * + * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on + * an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the + * License for the specific language governing permissions and limitations under the License. + */ +package elide.tool.cli.cmd.s3 + +import com.robothy.s3.rest.LocalS3 +import com.robothy.s3.rest.bootstrap.LocalS3Mode +import elide.tool.cli.CommandContext +import elide.tool.cli.CommandResult +import elide.tool.cli.ProjectAwareSubcommand +import elide.tool.cli.ToolState +import io.micronaut.core.annotation.Introspected +import io.micronaut.core.annotation.ReflectiveAccess +import kotlin.coroutines.cancellation.CancellationException +import kotlin.time.Duration.Companion.seconds +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.delay +import picocli.CommandLine +import picocli.CommandLine.Command + +/** TBD. */ +@Command( + name = "s3", + description = ["Run a local S3 server"], + mixinStandardHelpOptions = true, +) +@Introspected +@ReflectiveAccess +internal class ToolS3Command : ProjectAwareSubcommand() { + /** Specifies the directory where files should be served. */ + @CommandLine.Option( + names = ["--directory", "--dir", "-d"], + description = ["Root directory of the server. Current directory will be used if omitted."], + ) + internal var directory: String? = null + + /** Specifies the port of the server. */ + @CommandLine.Option( + names = ["--port", "-P"], + description = ["Port of the server."], + defaultValue = "8080", + ) + internal var port: Int = 8080 + + @CommandLine.Option( + names = ["--in-memory", "--memory", "-m"], + description = ["Run the server in memory. Files will be read from the directory, but will not be modified."], + defaultValue = "false", + ) + internal var memory: Boolean = false + + @CommandLine.Option( + names = ["--shutdown-after"], + description = ["Automatically shut down the server after this many seconds."], + defaultValue = "-1", + ) + internal var timeout: Int = -1 + + /** @inheritDoc */ + override suspend fun CommandContext.invoke(state: ToolContext): CommandResult { + val mode = if (memory) LocalS3Mode.IN_MEMORY else LocalS3Mode.PERSISTENCE + val userDir = System.getProperty("user.dir") + val dataPath = directory?.let { if (it.isCharAt(0, '/') || it.isCharAt(1, ':')) it else "$userDir/$it" } ?: userDir + val server = LocalS3.builder().port(port).mode(mode).dataPath(dataPath).build() + server.start() + if (timeout > 0) { + delay(timeout.seconds) + server.shutdown() + return CommandResult.success() + } + try { + awaitCancellation() + } catch (t: Throwable) { + return if (t is CancellationException) CommandResult.success() else CommandResult.err(1, exc = t) + } finally { + server.shutdown() + } + } + + private fun String.isCharAt(index: Int, char: Char): Boolean = length > index && this[index] == char +} diff --git a/packages/cli/src/main/resources/META-INF/native-image/dev/elide/elide-cli/reachability-metadata.json b/packages/cli/src/main/resources/META-INF/native-image/dev/elide/elide-cli/reachability-metadata.json index 7474afc0d4..08d7ee71bc 100644 --- a/packages/cli/src/main/resources/META-INF/native-image/dev/elide/elide-cli/reachability-metadata.json +++ b/packages/cli/src/main/resources/META-INF/native-image/dev/elide/elide-cli/reachability-metadata.json @@ -947,6 +947,394 @@ { "type": "com.oracle.truffle.tools.profiler.impl.MemoryTracerInstrumentProvider" }, + { + "type": "com.robothy.s3.core.model.answers.CompleteMultipartUploadAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.CopyObjectAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.DeleteObjectAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.GetObjectAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.GetObjectTaggingAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.HeadObjectAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.ListMultipartUploadAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.ListObjectsAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.ListObjectsV2Ans", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.ListObjectsVersionsAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.ListPartsAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.PutObjectAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.answers.UploadPartAns", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.internal.BucketMetadata", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.internal.LocalS3Metadata", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.internal.ObjectMetadata", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.internal.UploadMetadata", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.internal.UploadPartMetadata", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.internal.VersionedObjectMetadata", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.request.CompleteMultipartUploadPartOption", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.request.CopyObjectOptions", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.request.CreateMultipartUploadOptions", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.request.GetObjectOptions", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.request.ListObjectVersionsOptions", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.request.PutObjectOptions", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.core.model.request.UploadPartOptions", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.AccessControlPolicy", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.Grant", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.Grantee", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.ObjectIdentifier", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.Owner", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.PolicyStatus", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.PublicAccessBlockConfiguration", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.Tagging", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.VersioningConfiguration", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.converter.AmazonInstantConverter", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.enums.CheckSumAlgorithm", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.enums.StorageClass", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.request.CreateBucketConfiguration", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.request.DeleteObjectsRequest", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.response.CreateBucketResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.response.DeleteMarkerEntry", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.response.DeleteResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.response.GetBucketResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.response.LocationConstraint", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.response.ObjectVersion", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.response.S3Error", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.response.S3Object", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.datatypes.serializer.GranteeSerializer", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.LocalS3", + "allDeclaredFields": true + }, + { + "type": "com.robothy.s3.rest.model.request.BucketRegion", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.request.CompletedPart", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.request.CompleteMultipartUpload", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.request.DecodedAmzRequestBody", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.CommonPrefix", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.CompleteMultipartUploadResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.CopyObjectResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.InitiateMultipartUploadResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.ListAllMyBucketsResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.ListBucketResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.ListBucketV2Result", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.ListMultipartUploadsResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.ListPartsResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.ListVersionsResult", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, + { + "type": "com.robothy.s3.rest.model.response.S3Bucket", + "allDeclaredFields": true, + "allDeclaredMethods": true, + "allDeclaredConstructors": true + }, { "type": "com.sun.crypto.provider.AESCipher$General", "methods": [ @@ -16188,4 +16576,4 @@ } ] } -} \ No newline at end of file +} diff --git a/packages/cli/src/test/kotlin/elide/tool/cli/ElideToolTest.kt b/packages/cli/src/test/kotlin/elide/tool/cli/ElideToolTest.kt index fad83d1311..23f1bf3446 100644 --- a/packages/cli/src/test/kotlin/elide/tool/cli/ElideToolTest.kt +++ b/packages/cli/src/test/kotlin/elide/tool/cli/ElideToolTest.kt @@ -261,4 +261,15 @@ import elide.testing.annotations.TestCase ) } } + + @Test fun testEntrypointS3() { + assertDoesNotThrow { + assertToolRunsWith( + "s3", + "--in-memory", + "--shutdown-after", + "5" + ) + } + } }