Skip to content

Commit 6dda24c

Browse files
pditommasoclaudemunishchouhan
authored
Upgrade to Micronaut 4.10.6 (#951)
* Add custom LocalStorageOperations to replace deprecated Micronaut implementation Micronaut 4.10 deprecated the LocalStorageOperations constructor making it impossible to programmatically create local file system ObjectStorageOperations. This change: - Adds custom LocalStorageOperations class in io.seqera.wave.service.localfs - Implements ObjectStorageOperations interface with upload, retrieve, delete, exists, listObjects, and copy operations - Includes path traversal attack prevention - Removes micronaut-object-storage-local dependency from build.gradle - Updates ObjectStorageOperationsFactory to use the new implementation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Signed-off-by: Paolo Di Tommaso <paolo.ditommaso@gmail.com> Signed-off-by: munishchouhan <hrma017@gmail.com> Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com> Co-authored-by: munishchouhan <hrma017@gmail.com>
1 parent 54f9851 commit 6dda24c

File tree

6 files changed

+520
-10
lines changed

6 files changed

+520
-10
lines changed

CLAUDE.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ Wave requires several environment variables for registry authentication:
5151
- Uses Micronaut's configuration system with property injection
5252

5353
## Technology Stack
54-
- **Framework**: Micronaut 4.x with Netty runtime
54+
- **Framework**: Micronaut 4.10.6 with Netty runtime
5555
- **Language**: Groovy with Java 21+
5656
- **Build Tool**: Gradle with custom conventions
5757
- **Container**: JIB for multi-platform builds (AMD64/ARM64)

build.gradle

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,6 @@ dependencies {
9292
implementation 'org.postgresql:postgresql:42.7.7' // PostgreSQL Driver
9393
//object storage dependency
9494
implementation 'io.micronaut.objectstorage:micronaut-object-storage-aws'
95-
implementation 'io.micronaut.objectstorage:micronaut-object-storage-local'
9695
// include sts to allow the use of service account role - https://stackoverflow.com/a/73306570
9796
// this sts dependency is require by micronaut-aws-parameter-store,
9897
// not directly used by the app, for this reason keeping `runtimeOnly`

gradle.properties

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,5 +16,5 @@
1616
# along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
#
1818

19-
micronautVersion=4.9.4
19+
micronautVersion=4.10.6
2020
micronautEnvs=local,mail,aws-ses

src/main/groovy/io/seqera/wave/service/aws/ObjectStorageOperationsFactory.groovy

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -25,16 +25,16 @@ import groovy.util.logging.Slf4j
2525
import io.micronaut.context.ApplicationContext
2626
import io.micronaut.context.annotation.Factory
2727
import io.micronaut.context.annotation.Requires
28+
import io.micronaut.context.env.Environment
2829
import io.micronaut.inject.qualifiers.Qualifiers
2930
import io.micronaut.objectstorage.InputStreamMapper
3031
import io.micronaut.objectstorage.ObjectStorageOperations
3132
import io.micronaut.objectstorage.aws.AwsS3Configuration
3233
import io.micronaut.objectstorage.aws.AwsS3Operations
33-
import io.micronaut.objectstorage.local.LocalStorageConfiguration
34-
import io.micronaut.objectstorage.local.LocalStorageOperations
34+
import io.seqera.wave.service.localfs.LocalStorageOperations
3535
import io.seqera.wave.configuration.BuildConfig
36-
import io.seqera.wave.configuration.ScanConfig
3736
import io.seqera.wave.configuration.BuildEnabled
37+
import io.seqera.wave.configuration.ScanConfig
3838
import io.seqera.wave.configuration.ScanEnabled
3939
import io.seqera.wave.util.BucketTokenizer
4040
import jakarta.annotation.Nullable
@@ -114,17 +114,16 @@ class ObjectStorageOperationsFactory {
114114
protected ObjectStorageOperations<?, ?, ?> localFactory(String scope, String storageBucket) {
115115
log.debug "Using local ObjectStorageOperations scope='${scope}'; storageBucket='${storageBucket}'"
116116
final localPath = Path.of(storageBucket)
117-
LocalStorageConfiguration configuration = new LocalStorageConfiguration(scope)
118-
configuration.setPath(localPath)
119-
return new LocalStorageOperations(configuration)
117+
return new LocalStorageOperations(localPath)
120118
}
121119

122120
protected ObjectStorageOperations<?, ?, ?> awsFactory(String scope, String storageBucket) {
123121
log.debug "Using AWS S3 ObjectStorageOperations scope='${scope}'; storageBucket='${storageBucket}'"
124122
final s3Client = context.getBean(S3Client, Qualifiers.byName("DefaultS3Client"))
125123
final inputStreamMapper = context.getBean(InputStreamMapper)
124+
final environment = context.findBean(Environment).orElse(null)
126125
AwsS3Configuration configuration = new AwsS3Configuration(scope)
127126
configuration.setBucket(storageBucket)
128-
return new AwsS3Operations(configuration, s3Client, inputStreamMapper)
127+
return new AwsS3Operations(configuration, s3Client, inputStreamMapper, environment)
129128
}
130129
}
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
/*
2+
* Wave, containers provisioning service
3+
* Copyright (c) 2024, Seqera Labs
4+
*
5+
* This program is free software: you can redistribute it and/or modify
6+
* it under the terms of the GNU Affero General Public License as published by
7+
* the Free Software Foundation, either version 3 of the License, or
8+
* (at your option) any later version.
9+
*
10+
* This program is distributed in the hope that it will be useful,
11+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13+
* GNU Affero General Public License for more details.
14+
*
15+
* You should have received a copy of the GNU Affero General Public License
16+
* along with this program. If not, see <https://www.gnu.org/licenses/>.
17+
*/
18+
19+
package io.seqera.wave.service.localfs
20+
21+
import java.io.InputStream
22+
import java.io.OutputStream
23+
import java.net.URLConnection
24+
import java.nio.file.Files
25+
import java.nio.file.Path
26+
import java.nio.file.StandardCopyOption
27+
import java.util.function.Consumer
28+
import java.util.stream.Collectors
29+
import java.util.stream.Stream
30+
31+
import groovy.transform.CompileStatic
32+
import io.micronaut.objectstorage.ObjectStorageEntry
33+
import io.micronaut.objectstorage.ObjectStorageException
34+
import io.micronaut.objectstorage.ObjectStorageOperations
35+
import io.micronaut.objectstorage.request.UploadRequest
36+
import io.micronaut.objectstorage.response.UploadResponse
37+
38+
/**
39+
* Custom implementation of {@link ObjectStorageOperations} for local file system storage.
40+
* This bypasses Micronaut's {@code LocalStorageOperations} which requires bean injection
41+
* and cannot be instantiated programmatically.
42+
*
43+
* @author Paolo Di Tommaso <paolo.ditommaso@gmail.com>
44+
*/
45+
@CompileStatic
46+
class LocalStorageOperations implements ObjectStorageOperations<Path, Path, Boolean> {
47+
48+
/**
49+
* The root directory where all objects are stored.
50+
*/
51+
private final Path basePath
52+
53+
/**
54+
* Creates a new {@code LocalStorageOperations} instance with the specified base path.
55+
* The base directory will be created if it does not exist.
56+
*
57+
* @param basePath the root directory for storing objects
58+
* @throws ObjectStorageException if the base directory cannot be created
59+
*/
60+
LocalStorageOperations(Path basePath) {
61+
this.basePath = basePath
62+
try {
63+
Files.createDirectories(basePath)
64+
}
65+
catch (IOException e) {
66+
throw new ObjectStorageException("Error creating base directory: " + basePath, e)
67+
}
68+
}
69+
70+
/**
71+
* Uploads an object to local storage.
72+
*
73+
* @param request the upload request containing the key and input stream
74+
* @return an {@link UploadResponse} containing the key, a generated ETag, and the file path
75+
* @throws ObjectStorageException if an I/O error occurs during upload
76+
*/
77+
@Override
78+
UploadResponse<Path> upload(UploadRequest request) {
79+
return upload(request, { Path p -> })
80+
}
81+
82+
/**
83+
* Uploads an object to local storage with a custom consumer for post-processing.
84+
* Parent directories are created automatically if they do not exist.
85+
*
86+
* @param request the upload request containing the key and input stream
87+
* @param requestConsumer a consumer that receives the created file path after upload
88+
* @return an {@link UploadResponse} containing the key, a generated ETag, and the file path
89+
* @throws ObjectStorageException if an I/O error occurs during upload
90+
*/
91+
@Override
92+
UploadResponse<Path> upload(UploadRequest request, Consumer<Path> requestConsumer) {
93+
final Path file = resolveSafe(basePath, request.getKey())
94+
try {
95+
Files.createDirectories(file.getParent())
96+
try (OutputStream out = Files.newOutputStream(file)) {
97+
request.getInputStream().transferTo(out)
98+
}
99+
requestConsumer.accept(file)
100+
return UploadResponse.of(request.getKey(), UUID.randomUUID().toString(), file)
101+
}
102+
catch (IOException e) {
103+
throw new ObjectStorageException("Error uploading file: " + file, e)
104+
}
105+
}
106+
107+
/**
108+
* Retrieves an object from local storage.
109+
*
110+
* @param key the object key (relative path within the base directory)
111+
* @return an {@link Optional} containing the {@link LocalStorageEntry} if the object exists,
112+
* or an empty {@link Optional} if not found
113+
* @throws IllegalArgumentException if the key resolves to a path outside the base directory
114+
*/
115+
@Override
116+
Optional<LocalStorageEntry> retrieve(String key) {
117+
final Path file = resolveSafe(basePath, key)
118+
if (Files.exists(file)) {
119+
return Optional.of(new LocalStorageEntry(key, file))
120+
}
121+
return Optional.empty()
122+
}
123+
124+
/**
125+
* Deletes an object from local storage.
126+
*
127+
* @param key the object key (relative path within the base directory)
128+
* @return {@code true} if the object was deleted, {@code false} if it did not exist
129+
* @throws ObjectStorageException if an I/O error occurs during deletion
130+
* @throws IllegalArgumentException if the key resolves to a path outside the base directory
131+
*/
132+
@Override
133+
Boolean delete(String key) {
134+
final Path file = resolveSafe(basePath, key)
135+
try {
136+
if (Files.exists(file)) {
137+
Files.delete(file)
138+
return true
139+
}
140+
return false
141+
}
142+
catch (IOException e) {
143+
throw new ObjectStorageException("Error deleting file: " + file, e)
144+
}
145+
}
146+
147+
/**
148+
* Checks whether an object exists in local storage.
149+
*
150+
* @param key the object key (relative path within the base directory)
151+
* @return {@code true} if the object exists, {@code false} otherwise
152+
* @throws IllegalArgumentException if the key resolves to a path outside the base directory
153+
*/
154+
@Override
155+
boolean exists(String key) {
156+
return Files.exists(resolveSafe(basePath, key))
157+
}
158+
159+
/**
160+
* Lists all objects stored in local storage.
161+
* Recursively traverses all subdirectories and returns the relative paths of all regular files.
162+
*
163+
* @return a {@link Set} of object keys (relative paths) for all stored objects
164+
* @throws ObjectStorageException if an I/O error occurs while listing objects
165+
*/
166+
@Override
167+
Set<String> listObjects() {
168+
try (Stream<Path> stream = Files.find(basePath, Integer.MAX_VALUE, { path, attrs -> attrs.isRegularFile() })) {
169+
return stream
170+
.map { p -> basePath.relativize(p) }
171+
.map { p -> p.toString() }
172+
.collect(Collectors.toSet())
173+
}
174+
catch (IOException e) {
175+
throw new ObjectStorageException("Error listing objects", e)
176+
}
177+
}
178+
179+
/**
180+
* Copies an object within local storage from one key to another.
181+
* Parent directories for the destination are created automatically if they do not exist.
182+
* If the destination already exists, it will be replaced.
183+
*
184+
* @param sourceKey the key of the source object
185+
* @param destinationKey the key for the destination object
186+
* @throws ObjectStorageException if an I/O error occurs during the copy operation
187+
* @throws IllegalArgumentException if either key resolves to a path outside the base directory
188+
*/
189+
@Override
190+
void copy(String sourceKey, String destinationKey) {
191+
final Path source = resolveSafe(basePath, sourceKey)
192+
final Path dest = resolveSafe(basePath, destinationKey)
193+
try {
194+
Files.createDirectories(dest.getParent())
195+
Files.copy(source, dest, StandardCopyOption.REPLACE_EXISTING)
196+
}
197+
catch (IOException e) {
198+
throw new ObjectStorageException("Error copying file: " + source + " to " + dest, e)
199+
}
200+
}
201+
202+
/**
203+
* Resolve a key safely within the base path, preventing path traversal attacks.
204+
*
205+
* @param parent the base path
206+
* @param key the key to resolve
207+
* @return the resolved path
208+
* @throws IllegalArgumentException if the resolved path lies outside the base path
209+
*/
210+
private static Path resolveSafe(Path parent, String key) {
211+
final Path file = parent.resolve(key).normalize()
212+
if (!file.startsWith(parent)) {
213+
throw new IllegalArgumentException("Path lies outside the configured bucket")
214+
}
215+
return file
216+
}
217+
218+
/**
219+
* Implementation of {@link ObjectStorageEntry} for local file system storage.
220+
* Wraps a file on the local file system and provides access to its contents and metadata.
221+
*/
222+
static class LocalStorageEntry implements ObjectStorageEntry<Path> {
223+
224+
/**
225+
* The object key (relative path within the storage).
226+
*/
227+
private final String key
228+
229+
/**
230+
* The absolute path to the file on the local file system.
231+
*/
232+
private final Path file
233+
234+
/**
235+
* Creates a new {@code LocalStorageEntry} for the specified key and file path.
236+
*
237+
* @param key the object key (relative path within the storage)
238+
* @param file the absolute path to the file on the local file system
239+
*/
240+
LocalStorageEntry(String key, Path file) {
241+
this.key = key
242+
this.file = file
243+
}
244+
245+
/**
246+
* Returns the object key.
247+
*
248+
* @return the object key (relative path within the storage)
249+
*/
250+
@Override
251+
String getKey() {
252+
return key
253+
}
254+
255+
/**
256+
* Opens and returns an input stream for reading the file contents.
257+
* The caller is responsible for closing the returned stream.
258+
*
259+
* @return an {@link InputStream} for reading the file contents
260+
* @throws ObjectStorageException if the file cannot be opened for reading
261+
*/
262+
@Override
263+
InputStream getInputStream() {
264+
try {
265+
return Files.newInputStream(file)
266+
}
267+
catch (IOException e) {
268+
throw new ObjectStorageException("Error opening input stream for file: " + file, e)
269+
}
270+
}
271+
272+
/**
273+
* Returns the native file system path to the stored object.
274+
*
275+
* @return the {@link Path} to the file on the local file system
276+
*/
277+
@Override
278+
Path getNativeEntry() {
279+
return file
280+
}
281+
282+
/**
283+
* Attempts to determine the content type of the file based on its name.
284+
* Uses {@link URLConnection#guessContentTypeFromName(String)} for detection.
285+
*
286+
* @return an {@link Optional} containing the MIME type if detected, or empty if unknown
287+
*/
288+
@Override
289+
Optional<String> getContentType() {
290+
return Optional.ofNullable(URLConnection.guessContentTypeFromName(file.getFileName().toString()))
291+
}
292+
}
293+
}

0 commit comments

Comments
 (0)