diff --git a/gxcloudstorage-azureblob-latest/pom.xml b/gxcloudstorage-azureblob-latest/pom.xml new file mode 100644 index 000000000..6adf571f3 --- /dev/null +++ b/gxcloudstorage-azureblob-latest/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + com.genexus + parent + ${revision}${changelist} + + + gxcloudstorage-azureblob-latest + GeneXus Azure Blob Cloud Storage + + + + ${project.groupId} + gxclassR + ${project.version} + test + + + com.genexus + gxcloudstorage-common + ${project.version} + + + com.azure + azure-storage-blob + 12.32.0 + + + com.azure + azure-identity + 1.18.1 + + + diff --git a/gxcloudstorage-azureblob-latest/src/main/java/com/genexus/db/driver/ExternalProviderAzureStorageLatest.java b/gxcloudstorage-azureblob-latest/src/main/java/com/genexus/db/driver/ExternalProviderAzureStorageLatest.java new file mode 100644 index 000000000..21e732638 --- /dev/null +++ b/gxcloudstorage-azureblob-latest/src/main/java/com/genexus/db/driver/ExternalProviderAzureStorageLatest.java @@ -0,0 +1,632 @@ +package com.genexus.db.driver; + +import com.azure.core.exception.ClientAuthenticationException; +import com.azure.core.exception.HttpRequestException; +import com.azure.core.util.Context; +import com.azure.identity.DefaultAzureCredential; +import com.azure.identity.DefaultAzureCredentialBuilder; +import com.azure.storage.blob.BlobClient; +import com.azure.storage.blob.BlobContainerClient; +import com.azure.storage.blob.BlobServiceClient; +import com.azure.storage.blob.BlobServiceClientBuilder; +import com.azure.storage.blob.models.*; +import com.azure.storage.blob.sas.BlobSasPermission; +import com.azure.storage.blob.sas.BlobServiceSasSignatureValues; +import com.azure.storage.blob.specialized.BlockBlobClient; +import com.azure.storage.common.StorageSharedKeyCredential; +import com.genexus.StructSdtMessages_Message; +import com.genexus.util.GXService; +import com.genexus.util.StorageUtils; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URISyntaxException; +import java.time.OffsetDateTime; +import java.util.*; + +public class ExternalProviderAzureStorageLatest extends ExternalProviderBase implements ExternalProvider { + private static Logger logger = LogManager.getLogger(ExternalProviderAzureStorageLatest.class); + + static final String NAME = "AZUREBS"; //Azure Blob Storage + + static final String ACCOUNT = "ACCOUNT_NAME"; + static final String ACCESS_KEY = "ACCESS_KEY"; + static final String PUBLIC_CONTAINER = "PUBLIC_CONTAINER_NAME"; + static final String PRIVATE_CONTAINER = "PRIVATE_CONTAINER_NAME"; + + private boolean useManagedIdentity; + + @Deprecated + static final String ACCOUNT_DEPRECATED = "ACCOUNT_NAME"; + @Deprecated + static final String KEY_DEPRECATED = "ACCESS_KEY"; + @Deprecated + static final String PUBLIC_CONTAINER_DEPRECATED = "PUBLIC_CONTAINER_NAME"; + @Deprecated + static final String PRIVATE_CONTAINER_DEPRECATED = "PRIVATE_CONTAINER_NAME"; + + private String account; + private String key; + + private BlobServiceClient blobServiceClient; + private BlobContainerClient publicContainerClient; + private BlobContainerClient privateContainerClient; + + private int defaultExpirationMinutes = DEFAULT_EXPIRATION_MINUTES; + + private String privateContainerName; + private String publicContainerName; + + private void init() throws Exception { + try { + account = getEncryptedPropertyValue(ACCOUNT, ACCOUNT_DEPRECATED); + } catch (Exception ex) { + logger.error("Error initializing Azure Storage: unable to get account", ex); + throw ex; + } + + try { + key = getEncryptedPropertyValue(ACCESS_KEY, KEY_DEPRECATED); + if (key.isEmpty()) { + logger.info("ACCESS_KEY empty — using Managed Identity"); + useManagedIdentity = true; + } + } catch (Exception ex) { + if (key == null) { + logger.info("ACCESS_KEY null — using Managed Identity"); + useManagedIdentity = true; + } + } + + try { + String privateContainerNameValue = getEncryptedPropertyValue(PRIVATE_CONTAINER, PRIVATE_CONTAINER_DEPRECATED); + String publicContainerNameValue = getEncryptedPropertyValue(PUBLIC_CONTAINER, PUBLIC_CONTAINER_DEPRECATED); + + privateContainerName = privateContainerNameValue.toLowerCase(); + publicContainerName = publicContainerNameValue.toLowerCase(); + + if (useManagedIdentity) { + initWithManagedIdentity(); + } else { + initWithAccountKey(); + } + } + catch (Exception ex) { + handleAndLogException("Initialization error", ex); + } + } + + private void initWithAccountKey() { + // Create BlobServiceClient with account key + StorageSharedKeyCredential credential = new StorageSharedKeyCredential(account, key); + blobServiceClient = new BlobServiceClientBuilder() + .endpoint(String.format("https://%s.blob.core.windows.net", account)) + .credential(credential) + .buildClient(); + + initContainerClients(); + } + + private void initWithManagedIdentity() { + // Create a DefaultAzureCredential + DefaultAzureCredential credential = new DefaultAzureCredentialBuilder().build(); + + // Create BlobServiceClient using the credential + blobServiceClient = new BlobServiceClientBuilder() + .endpoint(String.format("https://%s.blob.core.windows.net", account)) + .credential(credential) + .buildClient(); + + initContainerClients(); + } + + private void initContainerClients() { + // Create container clients and ensure containers exist + publicContainerClient = blobServiceClient.getBlobContainerClient(publicContainerName); + if (!publicContainerClient.exists()) { + publicContainerClient = blobServiceClient.createBlobContainer(publicContainerName); + publicContainerClient.setAccessPolicy(PublicAccessType.BLOB, null); + } + + privateContainerClient = blobServiceClient.getBlobContainerClient(privateContainerName); + if (!privateContainerClient.exists()) { + privateContainerClient = blobServiceClient.createBlobContainer(privateContainerName); + } + } + + public ExternalProviderAzureStorageLatest() throws Exception { + super(); + init(); + } + + public ExternalProviderAzureStorageLatest(GXService providerService) throws Exception { + super(providerService); + init(); + } + + public String getName() { + return NAME; + } + + public void download(String externalFileName, String localFile, ResourceAccessControlList acl) { + try { + BlobClient blobClient = getBlobClient(externalFileName, acl); + blobClient.downloadToFile(localFile, true); + } catch (Exception ex) { + handleAndLogException("Invalid URI or error downloading file", ex); + } + } + + private BlobClient getBlobClient(String fileName, ResourceAccessControlList acl) { + BlobClient blobClient; + if (isPrivateAcl(acl)) { + blobClient = privateContainerClient.getBlobClient(fileName); + } else { + blobClient = publicContainerClient.getBlobClient(fileName); + } + return blobClient; + } + + private boolean isPrivateAcl(ResourceAccessControlList acl) { + // If default ACL is private, use always private. + return this.defaultAcl == ResourceAccessControlList.Private || acl == ResourceAccessControlList.Private; + } + + public String upload(String localFile, String externalFileName, ResourceAccessControlList acl) { + try { + externalFileName = getExternalFileName(externalFileName); + BlobClient blobClient = getBlobClient(externalFileName, acl); + blobClient.uploadFromFile(localFile, true); + return getResourceUrl(externalFileName, acl, defaultExpirationMinutes); + } catch (Exception ex) { + handleAndLogException("Error uploading file", ex); + return ""; + } + } + + public String upload(String externalFileName, InputStream input, ResourceAccessControlList acl) { + //https://docs.azure.cn/en-us/storage/blobs/storage-blob-upload-java + try (ExternalProviderHelper.InputStreamWithLength streamInfo = + ExternalProviderHelper.getInputStreamContentLength(input)) { + externalFileName = getExternalFileName(externalFileName); + BlockBlobClient blobClient = + getBlobClient(externalFileName, acl).getBlockBlobClient(); + String contentType = + (externalFileName.endsWith(".tmp") && + "application/octet-stream".equals(streamInfo.detectedContentType)) + ? "image/jpeg" + : streamInfo.detectedContentType; + + BlobHttpHeaders headers = new BlobHttpHeaders().setContentType(contentType); + blobClient.uploadWithResponse( + streamInfo.inputStream, + streamInfo.contentLength, + headers, + null, + null, + null, + null, + null, + Context.NONE + ); + + return getResourceUrl(externalFileName, acl, DEFAULT_EXPIRATION_MINUTES); + } + catch (Exception ex) { + handleAndLogException("Error uploading file", ex); + return ""; + } + } + + public String get(String externalFileName, ResourceAccessControlList acl, int expirationMinutes) { + try { + externalFileName = getExternalFileName(externalFileName); + if (exists(externalFileName, acl)) { + return getResourceUrl(externalFileName, acl, expirationMinutes); + } + } catch (Exception ex) { + handleAndLogException("Error getting file", ex); + return ""; + } + return ""; + } + + private String getResourceUrl(String externalFileName, ResourceAccessControlList acl, int expirationMinutes) { + + externalFileName = getExternalFileName(externalFileName); + if (isPrivateAcl(acl)) { + return getPrivate(externalFileName, expirationMinutes); + } else { + BlobClient blobClient = publicContainerClient.getBlobClient(externalFileName); + //getBlobClient method returns URL encoded + //https://azuresdkdocs.z19.web.core.windows.net/java/azure-storage-blob/12.30.0/com/azure/storage/blob/BlobContainerClient.html#getBlobClient(java.lang.String) + ////https://github.com/Azure/azure-sdk-for-java/issues/21610 + return blobClient.getBlobUrl(); + } + } + + private String getPrivate(String externalFileName, int expirationMinutes) { + try { + externalFileName = getExternalFileName(externalFileName); + BlobClient blobClient = privateContainerClient.getBlobClient(externalFileName); + expirationMinutes = expirationMinutes > 0 ? expirationMinutes : defaultExpirationMinutes; + OffsetDateTime expiryTime = OffsetDateTime.now().plusMinutes(expirationMinutes); + // Permissions (read) + BlobSasPermission permission = new BlobSasPermission().setReadPermission(true); + BlobServiceSasSignatureValues values = + new BlobServiceSasSignatureValues(expiryTime, permission); + String sasToken; + if (!useManagedIdentity) { + sasToken = blobClient.generateSas(values); + } else { + BlobServiceClient blobServiceClient = privateContainerClient.getServiceClient(); + OffsetDateTime start = OffsetDateTime.now().minusMinutes(5); + //https://learn.microsoft.com/en-us/azure/storage/blobs/storage-blob-user-delegation-sas-create-java?tabs=container + UserDelegationKey userDelegationKey = + blobServiceClient.getUserDelegationKey(start, expiryTime); + sasToken = blobClient.generateUserDelegationSas(values, userDelegationKey); + } + return blobClient.getBlobUrl() + "?" + sasToken; + } catch (Exception ex) { + handleAndLogException("Error getting private file", ex); + return ""; + } + } + + public void delete(String objectName, ResourceAccessControlList acl) { + try { + BlobClient blobClient = getBlobClient(objectName, acl); + blobClient.deleteIfExists(); + } catch (Exception ex) { + handleAndLogException("Error deleting file", ex); + } + } + + public String rename(String objectName, String newName, ResourceAccessControlList acl) { + String ret = copy(objectName, newName, acl); + delete(objectName, acl); + return ret; + } + + private String resolveObjectName(String urlOrObjectName, ResourceAccessControlList acl) { + String objectName = getObjectNameFromURL(urlOrObjectName); + return (objectName == null || objectName.length() == 0)? urlOrObjectName: objectName; + } + + public String copy(String objectName, String newName, ResourceAccessControlList acl) { + objectName = resolveObjectName(objectName, acl); + try { + BlobClient sourceBlob = getBlobClient(objectName, acl); + BlobClient targetBlob = getBlobClient(newName, acl); + String sourceBlobUrl; + if (useManagedIdentity) { + //Needs RBAC permissions: https://learn.microsoft.com/en-us/azure/storage/blobs/authorize-access-azure-active-directory + sourceBlobUrl = sourceBlob.getBlobUrl(); + } else { + if (isPrivateAcl(acl)) { + BlobSasPermission permission = new BlobSasPermission().setReadPermission(true); + BlobServiceSasSignatureValues values = + new BlobServiceSasSignatureValues( + OffsetDateTime.now().plusMinutes(5), + permission + ); + + sourceBlobUrl = sourceBlob.getBlobUrl() + "?" + sourceBlob.generateSas(values); + } else { + sourceBlobUrl = sourceBlob.getBlobUrl(); + } + } + targetBlob.beginCopy(sourceBlobUrl, null); + return getResourceUrl(newName, acl, defaultExpirationMinutes); + } catch (Exception ex) { + handleAndLogException("Error copying file", ex); + return ""; + } + } + + public String copy(String objectUrl, String newName, String tableName, String fieldName, ResourceAccessControlList acl) { + + objectUrl = objectUrl.replace(getUrl(), ""); + newName = tableName + "/" + fieldName + "/" + newName; + + try { + // Source is always in the private container + BlobClient sourceBlob = privateContainerClient.getBlobClient(objectUrl); + BlobClient targetBlob = getBlobClient(newName, acl); + + Map metadata = + createObjectMetadata(tableName, fieldName, StorageUtils.encodeName(newName)); + + String sourceBlobUrl; + if (useManagedIdentity) { + sourceBlobUrl = sourceBlob.getBlobUrl(); + } else { + + BlobSasPermission permission = new BlobSasPermission().setReadPermission(true); + BlobServiceSasSignatureValues values = new BlobServiceSasSignatureValues( + OffsetDateTime.now().plusMinutes(5), permission); + + sourceBlobUrl = sourceBlob.getBlobUrl() + "?" + sourceBlob.generateSas(values); + } + targetBlob.beginCopy(sourceBlobUrl, null); + targetBlob.setMetadata(metadata); + return getResourceUrl(newName, acl, defaultExpirationMinutes); + + } catch (Exception ex) { + handleAndLogException("Error copying file", ex); + return ""; + } + } + + private HashMap createObjectMetadata(String table, String field, String name) { + HashMap metadata = new HashMap(); + metadata.put("Table", table); + metadata.put("Field", field); + metadata.put("KeyValue", name); + return metadata; + } + + public long getLength(String objectName, ResourceAccessControlList acl) { + try { + BlobClient blobClient = getBlobClient(objectName, acl); + BlobProperties properties = blobClient.getProperties(); + return properties.getBlobSize(); + } catch (Exception ex) { + handleAndLogException("Error getting file length", ex); + return 0; + } + } + + public Date getLastModified(String objectName, ResourceAccessControlList acl) { + try { + BlobClient blobClient = getBlobClient(objectName, acl); + BlobProperties properties = blobClient.getProperties(); + return Date.from(properties.getLastModified().toInstant()); + } catch (Exception ex) { + handleAndLogException("Error getting last modified date", ex); + return new Date(); + } + } + + public boolean exists(String objectName, ResourceAccessControlList acl) { + try { + BlobClient blobClient = getBlobClient(objectName, acl); + return blobClient.exists(); + } catch (Exception ex) { + handleAndLogException("Error checking if file exists", ex); + return false; + } + } + + public String getDirectory(String directoryName) { + directoryName = StorageUtils.normalizeDirectoryName(directoryName); + if (existsDirectory(directoryName)) { + return publicContainerClient.getBlobContainerName() + StorageUtils.DELIMITER + directoryName; + } else { + return ""; + } + } + + public boolean existsDirectory(String directoryName) { + directoryName = StorageUtils.normalizeDirectoryName(directoryName); + try { + // List all blobs with the directory prefix + ListBlobsOptions options = new ListBlobsOptions().setPrefix(directoryName); + // Check if there are any blobs with this prefix + boolean exists = false; + for (BlobItem blobItem : publicContainerClient.listBlobs(options, null)) { + String name = blobItem.getName(); + if (!name.equals(directoryName)) { + // If we found any blob that isn't just the directory marker itself + exists = true; + break; + } + } + return exists; + } catch (Exception ex) { + handleAndLogException("Error checking if directory exists", ex); + return false; + } + } + + public void createDirectory(String directoryName) { + directoryName = StorageUtils.normalizeDirectoryName(directoryName); + try { + // Create a blob with empty content to mark the directory + BlobClient blobClient = publicContainerClient.getBlobClient(directoryName); + byte[] emptyContent = new byte[0]; + blobClient.upload(new ByteArrayInputStream(emptyContent), emptyContent.length, true); + } catch (Exception ex) { + handleAndLogException("Error creating directory", ex); + } + } + + public void deleteDirectory(String directoryName) { + ResourceAccessControlList acl = null; + directoryName = StorageUtils.normalizeDirectoryName(directoryName); + try { + // List all blobs with the directory prefix + ListBlobsOptions options = new ListBlobsOptions().setPrefix(directoryName); + + // Delete all blobs in the directory + for (BlobItem blobItem : publicContainerClient.listBlobs(options, null)) { + String name = blobItem.getName(); + if (name.startsWith(directoryName)) { + if (name.endsWith(StorageUtils.DELIMITER)) { + // This is a "subdirectory" + if (!name.equals(directoryName)) { + deleteDirectory(name); + } + } else { + // This is a file + delete(name, acl); + } + } + } + } catch (Exception ex) { + handleAndLogException("Error deleting directory", ex); + } + } + + public void renameDirectory(String directoryName, String newDirectoryName) { + ResourceAccessControlList acl = null; + if (!existsDirectory(newDirectoryName)) { + createDirectory(newDirectoryName); + } + directoryName = StorageUtils.normalizeDirectoryName(directoryName); + newDirectoryName = StorageUtils.normalizeDirectoryName(newDirectoryName); + try { + // List all blobs with the directory prefix + ListBlobsOptions options = new ListBlobsOptions().setPrefix(directoryName); + + // Copy and rename all blobs in the directory + for (BlobItem blobItem : publicContainerClient.listBlobs(options, null)) { + String name = blobItem.getName(); + if (name.startsWith(directoryName)) { + if (name.endsWith(StorageUtils.DELIMITER)) { + // This is a "subdirectory" + if (!name.equals(directoryName)) { + String subdirName = name.substring(directoryName.length()); + renameDirectory(name, newDirectoryName + subdirName); + } + } else { + // This is a file, rename it + String newName = name.replace(directoryName, newDirectoryName); + rename(name, newName, acl); + } + } + } + // Delete the original directory + deleteDirectory(directoryName); + } catch (Exception ex) { + handleAndLogException("Error renaming directory", ex); + } + } + + public List getFiles(String directoryName, String filter) { + List files = new ArrayList(); + directoryName = StorageUtils.normalizeDirectoryName(directoryName); + try { + // List all blobs with the directory prefix + ListBlobsOptions options = new ListBlobsOptions().setPrefix(directoryName); + + // Add all file names to the list + for (BlobItem blobItem : publicContainerClient.listBlobs(options, null)) { + String name = blobItem.getName(); + if (name.startsWith(directoryName) && !name.endsWith(StorageUtils.DELIMITER)) { + // This is a file, add it to the list + files.add(name); + } + } + } catch (Exception ex) { + handleAndLogException("Error getting files", ex); + } + return files; + } + + public List getFiles(String directoryName) { + return getFiles(directoryName, ""); + } + + public List getSubDirectories(String directoryName) { + List directories = new ArrayList(); + directoryName = StorageUtils.normalizeDirectoryName(directoryName); + try { + // List all blobs with the directory prefix + ListBlobsOptions options = new ListBlobsOptions().setPrefix(directoryName); + + // Get all subdirectory names + Set dirSet = new HashSet(); + for (BlobItem blobItem : publicContainerClient.listBlobs(options, null)) { + String name = blobItem.getName(); + if (name.startsWith(directoryName) && !name.equals(directoryName)) { + // Get the subdirectory name + String remainingPath = name.substring(directoryName.length()); + int slashIndex = remainingPath.indexOf(StorageUtils.DELIMITER); + + if (slashIndex >= 0) { + // This is a subdirectory or a file in a subdirectory + String subdirName = directoryName + remainingPath.substring(0, slashIndex + 1); + dirSet.add(subdirName); + } + } + } + directories.addAll(dirSet); + } catch (Exception ex) { + handleAndLogException("Error getting subdirectories", ex); + } + return directories; + } + + public InputStream getStream(String objectName, ResourceAccessControlList acl) { + try { + BlobClient blobClient = getBlobClient(objectName, acl); + return blobClient.openInputStream(); + } catch (Exception ex) { + handleAndLogException("Error getting stream", ex); + return null; + } + } + + public boolean getMessageFromException(Exception ex, StructSdtMessages_Message msg) { + try { + // Extract error information from the SDK exceptions + String errorMessage = ex.getMessage(); + if (errorMessage != null) { + msg.setId("AzureError"); + msg.setDescription(errorMessage); + return true; + } + return false; + } catch (Exception e) { + return false; + } + } + + private String getUrl() { + return "https://" + account + ".blob.core.windows.net/"; + } + + public String getObjectNameFromURL(String url) { + String objectName = null; + String baseUrl = this.getUrl(); + String publicContainerUrl = String.format("%s%s/", baseUrl , publicContainerName); + String privateContainerUrl = String.format("%s%s/", baseUrl , privateContainerName); + if (url.startsWith(publicContainerUrl)) { + objectName = url.replace(publicContainerUrl, ""); + } + if (url.startsWith(privateContainerUrl)) { + objectName = url.replace(privateContainerUrl, ""); + } + return objectName; + } + + private String getExternalFileName(String externalFileName) + { + //Defensive code, as externalFileName may have a leading / and this causes a double / at blob uri + //The latest Azure SDK is strict at uri format and encodes special characters + if (externalFileName == "") + return externalFileName; + return externalFileName.startsWith("/") ? externalFileName.substring(1) : externalFileName; + } + + private void handleAndLogException(String message, Exception ex) { + if (ex instanceof BlobStorageException) { + logger.error("Azure Storage error: {} (Status: {}, Code: {})", + ((BlobStorageException) ex).getServiceMessage(), ((BlobStorageException) ex).getStatusCode(), ((BlobStorageException) ex).getErrorCode()); + } else if (ex instanceof ClientAuthenticationException) { + logger.error("Authentication error: {}", ex.getMessage()); + } else if (ex instanceof HttpRequestException) { + logger.error("Connection error: {}", ex.getMessage()); + } else if (ex instanceof URISyntaxException) { + logger.error("Invalid URI: {}", ex.getMessage()); + } else if (ex instanceof IOException) { + logger.error(message, ex); + } else { + logger.error("Unexpected storage error", ex); + } + } +} diff --git a/gxcloudstorage-tests/resources/test/text.txt b/gxcloudstorage-tests/resources/test/text.txt index fc976593f..a21acf5cc 100644 --- a/gxcloudstorage-tests/resources/test/text.txt +++ b/gxcloudstorage-tests/resources/test/text.txt @@ -1 +1 @@ -test upload IBM \ No newline at end of file +This is a Test File for Standard Classes Java diff --git a/java/src/main/java/com/genexus/GXDbFile.java b/java/src/main/java/com/genexus/GXDbFile.java index b93ca607e..7e2291638 100644 --- a/java/src/main/java/com/genexus/GXDbFile.java +++ b/java/src/main/java/com/genexus/GXDbFile.java @@ -5,6 +5,8 @@ import com.genexus.util.GXServices; import java.io.File; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -26,6 +28,7 @@ public static String getFileName(String uri) { try { + uri = safeDecodeUrl(uri); return CommonUtil.getFileName(uri); } catch (Exception e) @@ -235,4 +238,20 @@ public static String pathToUrl(String path, boolean forceAbsPath) return pathToUrl(path, webContext, forceAbsPath); } + private static String safeDecodeUrl(String uri) { + if (uri == null || uri.isEmpty()) { + return uri; + } + boolean hasEncodedSegments = + uri.matches(".*%[0-9A-Fa-f]{2}.*"); + + if (!hasEncodedSegments) { + return uri; + } + try { + return URLDecoder.decode(uri); + } catch (IllegalArgumentException e) { + return uri; + } + } } \ No newline at end of file diff --git a/pom.xml b/pom.xml index 8b6b4da87..2410efb69 100644 --- a/pom.xml +++ b/pom.xml @@ -101,8 +101,8 @@ gxwebsocket gxwebsocketjakarta gxawsserverless - gxserverlesscommon - gxazureserverless + gxserverlesscommon + gxazureserverless androidreports gxqueue gxqueue-awssqs @@ -115,19 +115,20 @@ gxcloudstorage-azureblob gxcloudstorage-ibmcos gxcloudstorage-tests - gxobservability - gxcloudstorage-awss3-v2 - gxcompress - securityapicommons - gamsaml20 - gxjwt - gxcryptography - gxxmlsignature - gxsftp - gxftps - gamutils - gamtotp - + gxobservability + gxcloudstorage-awss3-v2 + gxcompress + securityapicommons + gamsaml20 + gxjwt + gxcryptography + gxxmlsignature + gxsftp + gxftps + gamutils + gamtotp + gxcloudstorage-azureblob-latest +