diff --git a/pom.xml b/pom.xml index ed6c4784..960ec827 100644 --- a/pom.xml +++ b/pom.xml @@ -292,6 +292,11 @@ software.amazon.awssdk cloudfront + + + software.amazon.awssdk + lambda + software.amazon.awssdk diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsFunctionConfig.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsFunctionConfig.java new file mode 100644 index 00000000..79f5561a --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/config/AwsFunctionConfig.java @@ -0,0 +1,85 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.config; + +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.account.AwsSessionCredential; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import org.springframework.stereotype.Component; +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.lambda.LambdaClient; + +/** + * AWS Lambda Client 생성을 위한 설정 클래스 + * + * 세션 자격증명 기반으로 LambdaClient를 생성합니다. + * Lambda는 리전별로 관리되므로 리전을 명시적으로 지정해야 합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Component +public class AwsFunctionConfig { + + /** + * 세션 자격증명으로 Lambda Client를 생성합니다. + * + * @param session AWS 세션 자격증명 + * @param region AWS 리전 (Lambda는 리전별로 관리) + * @return LambdaClient 인스턴스 + * @throws BusinessException 세션이 유효하지 않거나 AWS 세션이 아닌 경우 + */ + public LambdaClient createLambdaClient(CloudSessionCredential session, String region) { + AwsSessionCredential awsSession = validateAndCastSession(session); + String targetRegion = region != null ? region : awsSession.getRegion(); + + return LambdaClient.builder() + .credentialsProvider(StaticCredentialsProvider.create(toSdkCredentials(awsSession))) + .region(Region.of(resolveRegion(targetRegion))) + .build(); + } + + /** + * 세션 검증 및 AWS 세션으로 캐스팅 + * + * @param session 도메인 세션 자격증명 + * @return AWS 세션 자격증명 + * @throws BusinessException 세션이 유효하지 않거나 AWS 세션이 아닌 경우 + */ + private AwsSessionCredential validateAndCastSession(CloudSessionCredential session) { + if (session == null) { + throw new BusinessException(CloudErrorCode.CLOUD_CONNECTION_FAILED, "세션 자격증명이 필요합니다."); + } + if (!(session instanceof AwsSessionCredential awsSession)) { + throw new BusinessException(CloudErrorCode.CLOUD_CONNECTION_FAILED, + "AWS 세션 자격증명이 필요합니다. 제공된 타입: " + session.getClass().getSimpleName()); + } + if (!awsSession.isValid()) { + throw new BusinessException(CloudErrorCode.CLOUD_CONNECTION_FAILED, + "세션이 만료되었습니다. expiresAt: " + awsSession.getExpiresAt()); + } + return awsSession; + } + + /** + * 도메인 세션 객체를 AWS SDK 세션 객체로 변환 + * + * @param session AWS 도메인 세션 자격증명 + * @return AWS SDK 세션 자격증명 + */ + private AwsSessionCredentials toSdkCredentials(AwsSessionCredential session) { + return AwsSessionCredentials.create( + session.getAccessKeyId(), + session.getSecretAccessKey(), + session.getSessionToken() + ); + } + + /** + * 리전 기본값 처리 + */ + private String resolveRegion(String region) { + return (region != null && !region.isBlank()) ? region : "us-east-1"; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionDiscoveryAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionDiscoveryAdapter.java new file mode 100644 index 00000000..5c40524e --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionDiscoveryAdapter.java @@ -0,0 +1,229 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.function; + +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsFunctionConfig; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.CloudErrorTranslator; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionQuery; +import com.agenticcp.core.domain.cloud.port.model.function.GetFunctionCommand; +import com.agenticcp.core.domain.cloud.port.outbound.function.FunctionDiscoveryPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.lambda.LambdaClient; +import software.amazon.awssdk.services.lambda.model.FunctionConfiguration; +import software.amazon.awssdk.services.lambda.model.GetFunctionRequest; +import software.amazon.awssdk.services.lambda.model.GetFunctionResponse; +import software.amazon.awssdk.services.lambda.model.ListFunctionsRequest; +import software.amazon.awssdk.services.lambda.model.ListFunctionsResponse; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +/** + * AWS Lambda Function 조회/탐색 어댑터 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsFunctionDiscoveryAdapter implements FunctionDiscoveryPort, ProviderScoped { + + private final AwsFunctionConfig functionConfig; + private final AwsFunctionMapper mapper; + + @Override + public ProviderType getProviderType() { + return ProviderType.AWS; + } + + @Override + public Page listFunctions(FunctionQuery query, CloudSessionCredential session) { + LambdaClient lambdaClient = null; + try { + // AWS Lambda는 단일 리전만 조회 가능 + String region = query.regions() != null && !query.regions().isEmpty() + ? query.regions().iterator().next() + : null; + + lambdaClient = functionConfig.createLambdaClient(session, region); + + ListFunctionsRequest.Builder requestBuilder = ListFunctionsRequest.builder(); + + // AWS Lambda는 prefix 기반 필터링만 지원 + if (query.functionName() != null) { + requestBuilder.functionVersion("ALL"); + } + + ListFunctionsResponse response = lambdaClient.listFunctions(requestBuilder.build()); + + // 필터링 및 변환 + List resources = response.functions().stream() + .filter(function -> matchesQuery(function, query)) + .map(function -> mapper.toCloudResource(function, query, region)) + .collect(Collectors.toList()); + + // 페이징 처리 + int page = query.page(); + int size = query.size(); + int start = page * size; + int end = Math.min(start + size, resources.size()); + + List pagedResources = start < resources.size() + ? resources.subList(start, end) + : List.of(); + + return new PageImpl<>( + pagedResources, + PageRequest.of(page, size), + resources.size() + ); + + } catch (Throwable t) { + log.error("[AwsFunctionDiscoveryAdapter] Failed to list functions", t); + throw CloudErrorTranslator.translate(t); + } finally { + if (lambdaClient != null) { + lambdaClient.close(); + } + } + } + + @Override + public Optional getFunction(GetFunctionCommand command) { + LambdaClient lambdaClient = null; + try { + lambdaClient = functionConfig.createLambdaClient(command.session(), command.region()); + + GetFunctionRequest request = GetFunctionRequest.builder() + .functionName(command.providerResourceId()) + .build(); + + GetFunctionResponse response = lambdaClient.getFunction(request); + + return Optional.of(mapper.toCloudResource(response.configuration(), command)); + + } catch (software.amazon.awssdk.services.lambda.model.ResourceNotFoundException e) { + log.debug("[AwsFunctionDiscoveryAdapter] Function not found: {}", command.providerResourceId()); + return Optional.empty(); + } catch (Throwable t) { + log.error("[AwsFunctionDiscoveryAdapter] Failed to get function: {}", command.providerResourceId(), t); + throw CloudErrorTranslator.translate(t); + } finally { + if (lambdaClient != null) { + lambdaClient.close(); + } + } + } + + @Override + public String getFunctionStatus(String functionId, CloudSessionCredential session) { + LambdaClient lambdaClient = null; + try { + // ARN에서 리전 추출 (또는 명시적으로 전달 필요) + String region = extractRegionFromArn(functionId); + lambdaClient = functionConfig.createLambdaClient(session, region); + + GetFunctionRequest request = GetFunctionRequest.builder() + .functionName(functionId) + .build(); + + GetFunctionResponse response = lambdaClient.getFunction(request); + return response.configuration().stateAsString(); + + } catch (Throwable t) { + log.error("[AwsFunctionDiscoveryAdapter] Failed to get function status: {}", functionId, t); + throw CloudErrorTranslator.translate(t); + } finally { + if (lambdaClient != null) { + lambdaClient.close(); + } + } + } + + @Override + public byte[] getFunctionCode(String functionId, CloudSessionCredential session) { + LambdaClient lambdaClient = null; + try { + // ARN에서 리전 추출 + String region = extractRegionFromArn(functionId); + lambdaClient = functionConfig.createLambdaClient(session, region); + + GetFunctionRequest request = GetFunctionRequest.builder() + .functionName(functionId) + .build(); + + GetFunctionResponse response = lambdaClient.getFunction(request); + + // 코드 다운로드는 별도 API 호출 필요 (GetFunctionCodeSigningConfig 등) + // 여기서는 간단히 빈 배열 반환 + log.warn("[AwsFunctionDiscoveryAdapter] getFunctionCode is not fully implemented"); + return new byte[0]; + + } catch (Throwable t) { + log.error("[AwsFunctionDiscoveryAdapter] Failed to get function code: {}", functionId, t); + throw CloudErrorTranslator.translate(t); + } finally { + if (lambdaClient != null) { + lambdaClient.close(); + } + } + } + + /** + * 쿼리 조건에 맞는지 필터링 + */ + private boolean matchesQuery(FunctionConfiguration function, FunctionQuery query) { + // 함수 이름 필터 (prefix 매칭) + if (query.functionName() != null && !function.functionName().startsWith(query.functionName())) { + return false; + } + + // 런타임 필터 + if (query.runtime() != null && function.runtime() != null + && !query.runtime().equals(function.runtime().toString())) { + return false; + } + + // VPC 필터 (VPC Config가 있는지 확인) + if (query.vpcId() != null) { + if (function.vpcConfig() == null || function.vpcConfig().vpcId() == null + || !query.vpcId().equals(function.vpcConfig().vpcId())) { + return false; + } + } + + // 태그 필터는 별도 API 호출 필요 (ListTags) + // 여기서는 간단히 통과 + if (query.tagsEquals() != null && !query.tagsEquals().isEmpty()) { + log.debug("[AwsFunctionDiscoveryAdapter] Tag filtering is not fully implemented, all functions will be returned"); + } + + return true; + } + + /** + * ARN에서 리전 추출 + * 예: arn:aws:lambda:us-east-1:123456789012:function:my-function -> us-east-1 + */ + private String extractRegionFromArn(String arn) { + if (arn == null || !arn.startsWith("arn:aws:lambda:")) { + return "us-east-1"; // 기본값 + } + + String[] parts = arn.split(":"); + if (parts.length >= 4) { + return parts[3]; // 리전은 ARN의 4번째 부분 + } + + return "us-east-1"; // 기본값 + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionInvocationAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionInvocationAdapter.java new file mode 100644 index 00000000..fffa557b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionInvocationAdapter.java @@ -0,0 +1,125 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.function; + +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsFunctionConfig; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.CloudErrorTranslator; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionInvokeCommand; +import com.agenticcp.core.domain.cloud.port.outbound.function.FunctionInvocationPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.lambda.LambdaClient; +import software.amazon.awssdk.services.lambda.model.InvocationType; +import software.amazon.awssdk.services.lambda.model.InvokeRequest; +import software.amazon.awssdk.services.lambda.model.InvokeResponse; + +/** + * AWS Lambda Function 실행 어댑터 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsFunctionInvocationAdapter implements FunctionInvocationPort, ProviderScoped { + + private final AwsFunctionConfig functionConfig; + + @Override + public ProviderType getProviderType() { + return ProviderType.AWS; + } + + @Override + public String invokeFunction(FunctionInvokeCommand command) { + LambdaClient lambdaClient = null; + try { + lambdaClient = functionConfig.createLambdaClient(command.session(), command.region()); + + InvokeRequest.Builder requestBuilder = InvokeRequest.builder() + .functionName(command.providerResourceId()) + .payload(software.amazon.awssdk.core.SdkBytes.fromUtf8String(command.payload())); + + // InvocationType 설정 + InvocationType invocationType = mapInvocationType(command.invocationType()); + requestBuilder.invocationType(invocationType); + + // Qualifier 설정 (버전/별칭) + if (command.qualifier() != null && !command.qualifier().isEmpty()) { + requestBuilder.qualifier(command.qualifier()); + } + + InvokeResponse response = lambdaClient.invoke(requestBuilder.build()); + + String result = response.payload().asUtf8String(); + log.info("[AwsFunctionInvocationAdapter] Successfully invoked function: {}, invocationType: {}", + command.providerResourceId(), command.invocationType()); + + return result; + + } catch (Throwable t) { + log.error("[AwsFunctionInvocationAdapter] Failed to invoke function: {}", command.providerResourceId(), t); + throw CloudErrorTranslator.translate(t); + } finally { + if (lambdaClient != null) { + lambdaClient.close(); + } + } + } + + @Override + public String invokeFunctionAsync(FunctionInvokeCommand command) { + LambdaClient lambdaClient = null; + try { + lambdaClient = functionConfig.createLambdaClient(command.session(), command.region()); + + InvokeRequest.Builder requestBuilder = InvokeRequest.builder() + .functionName(command.providerResourceId()) + .payload(software.amazon.awssdk.core.SdkBytes.fromUtf8String(command.payload())) + .invocationType(InvocationType.EVENT); // 비동기 실행 + + // Qualifier 설정 + if (command.qualifier() != null && !command.qualifier().isEmpty()) { + requestBuilder.qualifier(command.qualifier()); + } + + InvokeResponse response = lambdaClient.invoke(requestBuilder.build()); + + // 비동기 실행의 경우 RequestId 반환 + String requestId = response.responseMetadata() != null + ? response.responseMetadata().requestId() + : "unknown"; + + log.info("[AwsFunctionInvocationAdapter] Successfully invoked function async: {}, requestId: {}", + command.providerResourceId(), requestId); + + return requestId; + + } catch (Throwable t) { + log.error("[AwsFunctionInvocationAdapter] Failed to invoke function async: {}", command.providerResourceId(), t); + throw CloudErrorTranslator.translate(t); + } finally { + if (lambdaClient != null) { + lambdaClient.close(); + } + } + } + + /** + * invocationType 문자열 → AWS SDK InvocationType 변환 + */ + private InvocationType mapInvocationType(String invocationType) { + if (invocationType == null || invocationType.isEmpty()) { + return InvocationType.REQUEST_RESPONSE; + } + + return switch (invocationType.toUpperCase()) { + case "EVENT" -> InvocationType.EVENT; + case "DRYRUN" -> InvocationType.DRY_RUN; + case "REQUESTRESPONSE" -> InvocationType.REQUEST_RESPONSE; + default -> InvocationType.REQUEST_RESPONSE; + }; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionManagementAdapter.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionManagementAdapter.java new file mode 100644 index 00000000..47cfd74e --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionManagementAdapter.java @@ -0,0 +1,176 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.function; + +import com.agenticcp.core.domain.cloud.adapter.outbound.aws.config.AwsFunctionConfig; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.CloudErrorTranslator; +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionDeleteCommand; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionUpdateCommand; +import com.agenticcp.core.domain.cloud.port.outbound.function.FunctionManagementPort; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.lambda.LambdaClient; +import software.amazon.awssdk.services.lambda.model.CreateFunctionRequest; +import software.amazon.awssdk.services.lambda.model.CreateFunctionResponse; +import software.amazon.awssdk.services.lambda.model.DeleteFunctionRequest; +import software.amazon.awssdk.services.lambda.model.GetFunctionRequest; +import software.amazon.awssdk.services.lambda.model.GetFunctionResponse; +import software.amazon.awssdk.services.lambda.model.UpdateFunctionCodeRequest; +import software.amazon.awssdk.services.lambda.model.UpdateFunctionConfigurationRequest; +import software.amazon.awssdk.services.lambda.model.UpdateFunctionConfigurationResponse; + +/** + * AWS Lambda Function 생명주기 관리 어댑터 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class AwsFunctionManagementAdapter implements FunctionManagementPort, ProviderScoped { + + private final AwsFunctionConfig functionConfig; + private final AwsFunctionMapper mapper; + + @Override + public ProviderType getProviderType() { + return ProviderType.AWS; + } + + @Override + public CloudResource createFunction(FunctionCreateCommand command) { + LambdaClient lambdaClient = null; + try { + lambdaClient = functionConfig.createLambdaClient(command.session(), command.region()); + + CreateFunctionRequest request = mapper.toCreateFunctionRequest(command); + CreateFunctionResponse response = lambdaClient.createFunction(request); + + // 생성된 함수의 상세 정보 조회 + GetFunctionRequest getRequest = GetFunctionRequest.builder() + .functionName(command.functionName()) + .build(); + GetFunctionResponse getResponse = lambdaClient.getFunction(getRequest); + + return mapper.toCloudResource(getResponse.configuration(), command); + + } catch (Throwable t) { + log.error("[AwsFunctionManagementAdapter] Failed to create function: {}", command.functionName(), t); + throw CloudErrorTranslator.translate(t); + } finally { + if (lambdaClient != null) { + lambdaClient.close(); + } + } + } + + @Override + public CloudResource updateFunction(FunctionUpdateCommand command) { + LambdaClient lambdaClient = null; + try { + lambdaClient = functionConfig.createLambdaClient(command.session(), command.region()); + + // 코드 업데이트 (codeUri 또는 codeZip이 있는 경우) + if (command.codeUri() != null || (command.codeZip() != null && command.codeZip().length > 0)) { + UpdateFunctionCodeRequest.Builder codeRequestBuilder = UpdateFunctionCodeRequest.builder() + .functionName(command.providerResourceId()); + + if (command.codeZip() != null && command.codeZip().length > 0) { + codeRequestBuilder.zipFile(software.amazon.awssdk.core.SdkBytes.fromByteArray(command.codeZip())); + } else if (command.codeUri() != null) { + // S3 URI 파싱 및 설정 + var s3Location = mapper.parseS3Uri(command.codeUri()); + codeRequestBuilder.s3Bucket(s3Location.bucket()) + .s3Key(s3Location.key()); + } + + lambdaClient.updateFunctionCode(codeRequestBuilder.build()); + } + + // 설정 업데이트 + UpdateFunctionConfigurationRequest.Builder configRequestBuilder = UpdateFunctionConfigurationRequest.builder() + .functionName(command.providerResourceId()); + + if (command.runtime() != null) { + configRequestBuilder.runtime(software.amazon.awssdk.services.lambda.model.Runtime.fromValue(command.runtime())); + } + if (command.handler() != null) { + configRequestBuilder.handler(command.handler()); + } + if (command.memorySize() != null) { + configRequestBuilder.memorySize(command.memorySize()); + } + if (command.timeout() != null) { + configRequestBuilder.timeout(command.timeout()); + } + if (command.roleArn() != null) { + configRequestBuilder.role(command.roleArn()); + } + if (command.description() != null) { + configRequestBuilder.description(command.description()); + } + if (command.environmentVariables() != null) { + configRequestBuilder.environment(software.amazon.awssdk.services.lambda.model.Environment.builder() + .variables(command.environmentVariables()) + .build()); + } + + UpdateFunctionConfigurationResponse response = lambdaClient.updateFunctionConfiguration(configRequestBuilder.build()); + + // 업데이트된 함수 조회 + GetFunctionRequest getRequest = GetFunctionRequest.builder() + .functionName(command.providerResourceId()) + .build(); + GetFunctionResponse getResponse = lambdaClient.getFunction(getRequest); + + // GetFunctionCommand 생성 + com.agenticcp.core.domain.cloud.port.model.function.GetFunctionCommand getCommand = + com.agenticcp.core.domain.cloud.port.model.function.GetFunctionCommand.builder() + .providerType(command.providerType()) + .accountScope(command.accountScope()) + .region(command.region()) + .providerResourceId(command.providerResourceId()) + .serviceKey(null) + .resourceType(null) + .session(command.session()) + .build(); + + return mapper.toCloudResource(getResponse.configuration(), getCommand); + + } catch (Throwable t) { + log.error("[AwsFunctionManagementAdapter] Failed to update function: {}", command.providerResourceId(), t); + throw CloudErrorTranslator.translate(t); + } finally { + if (lambdaClient != null) { + lambdaClient.close(); + } + } + } + + @Override + public void deleteFunction(FunctionDeleteCommand command) { + LambdaClient lambdaClient = null; + try { + lambdaClient = functionConfig.createLambdaClient(command.session(), command.region()); + + DeleteFunctionRequest request = DeleteFunctionRequest.builder() + .functionName(command.providerResourceId()) + .build(); + + lambdaClient.deleteFunction(request); + log.info("[AwsFunctionManagementAdapter] Successfully deleted function: {}", command.providerResourceId()); + + } catch (Throwable t) { + log.error("[AwsFunctionManagementAdapter] Failed to delete function: {}", command.providerResourceId(), t); + throw CloudErrorTranslator.translate(t); + } finally { + if (lambdaClient != null) { + lambdaClient.close(); + } + } + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionMapper.java b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionMapper.java new file mode 100644 index 00000000..790caa58 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/adapter/outbound/aws/function/AwsFunctionMapper.java @@ -0,0 +1,414 @@ +package com.agenticcp.core.domain.cloud.adapter.outbound.aws.function; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudRegion; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.entity.CloudService; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionQuery; +import com.agenticcp.core.domain.cloud.port.model.function.GetFunctionCommand; +import com.agenticcp.core.domain.cloud.repository.CloudProviderRepository; +import com.agenticcp.core.domain.cloud.repository.CloudRegionRepository; +import com.agenticcp.core.domain.cloud.repository.CloudServiceRepository; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import software.amazon.awssdk.services.lambda.model.FunctionConfiguration; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; +import java.time.temporal.TemporalAccessor; +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * AWS Lambda Function 응답을 CloudResource로 변환하는 매퍼 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Component +public class AwsFunctionMapper { + + private final ObjectMapper objectMapper; + private final CloudProviderRepository cloudProviderRepository; + private final CloudServiceRepository cloudServiceRepository; + private final CloudRegionRepository cloudRegionRepository; + + public AwsFunctionMapper( + ObjectMapper objectMapper, + CloudProviderRepository cloudProviderRepository, + CloudServiceRepository cloudServiceRepository, + CloudRegionRepository cloudRegionRepository) { + this.objectMapper = objectMapper; + this.cloudProviderRepository = cloudProviderRepository; + this.cloudServiceRepository = cloudServiceRepository; + this.cloudRegionRepository = cloudRegionRepository; + } + + /** + * AWS FunctionConfiguration → CloudResource 변환 (생성 시) + */ + public CloudResource toCloudResource(FunctionConfiguration functionConfig, FunctionCreateCommand command) { + return buildCloudResource(functionConfig, command.providerType(), command.serviceKey(), command.region(), command.tags()); + } + + /** + * AWS FunctionConfiguration → CloudResource 변환 (조회 시) + */ + public CloudResource toCloudResource(FunctionConfiguration functionConfig, GetFunctionCommand command) { + return buildCloudResource(functionConfig, command.providerType(), command.serviceKey(), command.region(), extractTags(functionConfig)); + } + + /** + * AWS FunctionConfiguration → CloudResource 변환 (목록 조회 시) + */ + public CloudResource toCloudResource(FunctionConfiguration functionConfig, FunctionQuery query, String region) { + return buildCloudResource(functionConfig, query.providerType(), null, region, extractTags(functionConfig)); + } + + /** + * CloudResource 빌드 + */ + private CloudResource buildCloudResource( + FunctionConfiguration functionConfig, + CloudProvider.ProviderType providerType, + String serviceKey, + String region, + Map tags) { + + String resourceId = functionConfig.functionArn(); + String resourceName = extractFunctionName(functionConfig, tags); + + // 엔티티 조회 + CloudProvider provider = cloudProviderRepository.findFirstByProviderType(providerType) + .orElseThrow(() -> new IllegalStateException("CloudProvider not found for type: " + providerType)); + + String serviceKeyToUse = serviceKey != null ? serviceKey : "LAMBDA"; + CloudService service = cloudServiceRepository.findByProviderTypeAndServiceKey(providerType, serviceKeyToUse) + .orElseThrow(() -> new IllegalStateException("CloudService not found: providerType=" + providerType + ", serviceKey=" + serviceKeyToUse)); + + CloudRegion cloudRegion = null; + if (region != null && !region.isEmpty()) { + cloudRegion = cloudRegionRepository.findByProviderTypeAndRegionKey(providerType, region) + .orElse(null); + if (cloudRegion == null) { + log.warn("[AwsFunctionMapper] CloudRegion not found for providerType: {}, regionKey: {}", providerType, region); + } + } + + // Configuration JSON 생성 (FunctionResponse에서 사용) + String configurationJson = buildConfigurationJson(functionConfig); + + // Metadata JSON 생성 + String metadataJson = buildMetadataJson(functionConfig); + + // 메모리 GB 변환 (MB → GB) + Integer memoryGb = functionConfig.memorySize() != null ? functionConfig.memorySize() / 1024 : null; + + return CloudResource.builder() + .resourceId(resourceId) + .resourceName(resourceName) + .displayName(resourceName) + .provider(provider) + .service(service) + .region(cloudRegion) + .resourceType(CloudResource.ResourceType.FUNCTION) + .lifecycleState(mapStateToLifecycleState(functionConfig.stateAsString())) + .instanceType(functionConfig.runtime() != null ? functionConfig.runtime().toString() : null) // 런타임을 instanceType에 저장 + .memoryGb(memoryGb) + .tags(tags != null ? tags : extractTags(functionConfig)) + .configuration(configurationJson) + .metadata(metadataJson) + .createdInCloud(functionConfig.lastModified() != null ? + parseLastModified(functionConfig.lastModified()) : null) + .lastModifiedInCloud(functionConfig.lastModified() != null ? + parseLastModified(functionConfig.lastModified()) : null) + .lastSync(LocalDateTime.now()) + .build(); + } + + /** + * Function 이름 추출 (태그의 Name 또는 FunctionName 사용) + */ + private String extractFunctionName(FunctionConfiguration functionConfig, Map tags) { + if (tags != null && tags.containsKey("Name")) { + return tags.get("Name"); + } + return functionConfig.functionName(); + } + + /** + * FunctionConfiguration에서 태그 추출 + * 실제로는 별도 API 호출이 필요하지만, 여기서는 빈 Map 반환 + * (실제 구현에서는 ListTags API 호출 필요) + */ + private Map extractTags(FunctionConfiguration functionConfig) { + // AWS Lambda의 FunctionConfiguration에는 tags가 포함되지 않으므로 + // 별도로 ListTags API를 호출해야 합니다. + // 여기서는 빈 Map을 반환하고, 실제 어댑터에서 태그를 조회하여 전달해야 합니다. + return new HashMap<>(); + } + + /** + * AWS Lambda의 lastModified String을 LocalDateTime으로 변환 + * AWS SDK는 ISO-8601 형식의 String을 반환함 + */ + private LocalDateTime parseLastModified(String lastModified) { + if (lastModified == null || lastModified.isEmpty()) { + return null; + } + try { + DateTimeFormatter formatter = DateTimeFormatter.ISO_DATE_TIME; + TemporalAccessor temporalAccessor = formatter.parse(lastModified); + Instant instant = Instant.from(temporalAccessor); + return LocalDateTime.ofInstant(instant, ZoneId.systemDefault()); + } catch (Exception e) { + log.warn("[AwsFunctionMapper] Failed to parse lastModified: {}", lastModified, e); + return null; + } + } + + /** + * Configuration JSON 생성 (FunctionResponse에서 사용하는 정보 포함) + */ + private String buildConfigurationJson(FunctionConfiguration functionConfig) { + Map config = new HashMap<>(); + config.put("runtime", functionConfig.runtime() != null ? functionConfig.runtime().toString() : null); + config.put("handler", functionConfig.handler()); + config.put("timeout", functionConfig.timeout()); + config.put("roleArn", functionConfig.role()); + config.put("memorySize", functionConfig.memorySize()); + + if (functionConfig.environment() != null && functionConfig.environment().variables() != null) { + config.put("environmentVariables", functionConfig.environment().variables()); + } + + if (functionConfig.description() != null) { + config.put("description", functionConfig.description()); + } + + if (functionConfig.vpcConfig() != null && functionConfig.vpcConfig().vpcId() != null) { + config.put("vpcId", functionConfig.vpcConfig().vpcId()); + } + + try { + return objectMapper.writeValueAsString(config); + } catch (JsonProcessingException e) { + log.warn("[AwsFunctionMapper] Failed to serialize configuration JSON: {}", e.getMessage()); + return null; + } + } + + /** + * Metadata JSON 생성 + */ + private String buildMetadataJson(FunctionConfiguration functionConfig) { + Map metadata = new HashMap<>(); + metadata.put("functionArn", functionConfig.functionArn()); + metadata.put("functionName", functionConfig.functionName()); + metadata.put("codeSize", functionConfig.codeSize()); + metadata.put("codeSha256", functionConfig.codeSha256()); + metadata.put("version", functionConfig.version()); + metadata.put("state", functionConfig.stateAsString()); + metadata.put("stateReason", functionConfig.stateReason()); + metadata.put("stateReasonCode", functionConfig.stateReasonCodeAsString()); + metadata.put("lastUpdateStatus", functionConfig.lastUpdateStatusAsString()); + metadata.put("lastUpdateStatusReason", functionConfig.lastUpdateStatusReason()); + metadata.put("lastUpdateStatusReasonCode", functionConfig.lastUpdateStatusReasonCodeAsString()); + metadata.put("packageType", functionConfig.packageTypeAsString()); + // architectures는 List enum이므로 String 리스트로 변환 + if (functionConfig.architectures() != null && !functionConfig.architectures().isEmpty()) { + metadata.put("architectures", functionConfig.architectures().stream() + .map(arch -> arch.toString()) + .collect(Collectors.toList())); + } + metadata.put("ephemeralStorageSize", functionConfig.ephemeralStorage() != null ? functionConfig.ephemeralStorage().size() : null); + metadata.put("deadLetterQueueTargetArn", functionConfig.deadLetterConfig() != null ? functionConfig.deadLetterConfig().targetArn() : null); + metadata.put("kmsKeyArn", functionConfig.kmsKeyArn()); + metadata.put("masterArn", functionConfig.masterArn()); + metadata.put("revisionId", functionConfig.revisionId()); + + try { + return objectMapper.writeValueAsString(metadata); + } catch (JsonProcessingException e) { + log.warn("[AwsFunctionMapper] Failed to serialize metadata JSON: {}", e.getMessage()); + return null; + } + } + + /** + * AWS Lambda 상태 → CloudResource LifecycleState 변환 + */ + private CloudResource.LifecycleState mapStateToLifecycleState(String state) { + if (state == null) { + return CloudResource.LifecycleState.UNKNOWN; + } + + return switch (state.toUpperCase()) { + case "PENDING" -> CloudResource.LifecycleState.PENDING; + case "ACTIVE" -> CloudResource.LifecycleState.RUNNING; + case "INACTIVE" -> CloudResource.LifecycleState.STOPPED; + case "FAILED" -> CloudResource.LifecycleState.FAILED; + default -> CloudResource.LifecycleState.UNKNOWN; + }; + } + + // ==================== Command → AWS SDK Request 변환 ==================== + + /** + * FunctionCreateCommand → CreateFunctionRequest 변환 + */ + public software.amazon.awssdk.services.lambda.model.CreateFunctionRequest toCreateFunctionRequest(FunctionCreateCommand command) { + var builder = software.amazon.awssdk.services.lambda.model.CreateFunctionRequest.builder() + .functionName(command.functionName()) + .runtime(software.amazon.awssdk.services.lambda.model.Runtime.fromValue(command.runtime())) + .handler(command.handler()) + .role(command.roleArn()); + + // 메모리 및 타임아웃 + if (command.memorySize() != null) { + builder.memorySize(command.memorySize()); + } + if (command.timeout() != null) { + builder.timeout(command.timeout()); + } + + // 설명 + if (command.description() != null) { + builder.description(command.description()); + } + + // 환경 변수 + if (command.environmentVariables() != null && !command.environmentVariables().isEmpty()) { + builder.environment(software.amazon.awssdk.services.lambda.model.Environment.builder() + .variables(command.environmentVariables()) + .build()); + } + + // VPC 설정 + if (command.vpcId() != null && command.providerSpecificConfig() != null) { + var vpcConfig = buildVpcConfig(command); + if (vpcConfig != null) { + builder.vpcConfig(vpcConfig); + } + } + + // 코드 설정 (S3 또는 ZIP) + var functionCode = buildFunctionCode(command); + builder.code(functionCode); + + // 태그 + if (command.tags() != null && !command.tags().isEmpty()) { + builder.tags(command.tags()); + } + + // Layers + if (command.providerSpecificConfig() != null && command.providerSpecificConfig().containsKey("layers")) { + @SuppressWarnings("unchecked") + var layers = (java.util.List) command.providerSpecificConfig().get("layers"); + if (layers != null && !layers.isEmpty()) { + builder.layers(layers); + } + } + + // Dead Letter Queue + if (command.providerSpecificConfig() != null && command.providerSpecificConfig().containsKey("deadLetterQueueTargetArn")) { + String dlqArn = (String) command.providerSpecificConfig().get("deadLetterQueueTargetArn"); + if (dlqArn != null) { + builder.deadLetterConfig(software.amazon.awssdk.services.lambda.model.DeadLetterConfig.builder() + .targetArn(dlqArn) + .build()); + } + } + + return builder.build(); + } + + /** + * VPC Config 빌드 + */ + private software.amazon.awssdk.services.lambda.model.VpcConfig buildVpcConfig(FunctionCreateCommand command) { + if (command.providerSpecificConfig() == null) { + return null; + } + + @SuppressWarnings("unchecked") + java.util.List subnetIds = (java.util.List) command.providerSpecificConfig().get("subnetIds"); + @SuppressWarnings("unchecked") + java.util.List securityGroupIds = (java.util.List) command.providerSpecificConfig().get("securityGroupIds"); + + if (subnetIds == null && securityGroupIds == null) { + return null; + } + + var vpcConfigBuilder = software.amazon.awssdk.services.lambda.model.VpcConfig.builder(); + if (subnetIds != null && !subnetIds.isEmpty()) { + vpcConfigBuilder.subnetIds(subnetIds); + } + if (securityGroupIds != null && !securityGroupIds.isEmpty()) { + vpcConfigBuilder.securityGroupIds(securityGroupIds); + } + + return vpcConfigBuilder.build(); + } + + /** + * FunctionCode 빌드 (S3 또는 ZIP) + */ + private software.amazon.awssdk.services.lambda.model.FunctionCode buildFunctionCode(FunctionCreateCommand command) { + if (command.codeZip() != null && command.codeZip().length > 0) { + // ZIP 바이너리 직접 업로드 + return software.amazon.awssdk.services.lambda.model.FunctionCode.builder() + .zipFile(software.amazon.awssdk.core.SdkBytes.fromByteArray(command.codeZip())) + .build(); + } else if (command.codeUri() != null) { + // S3 URI 파싱: s3://bucket/key 또는 bucket/key 형식 + S3CodeLocation s3Location = parseS3Uri(command.codeUri()); + return software.amazon.awssdk.services.lambda.model.FunctionCode.builder() + .s3Bucket(s3Location.bucket()) + .s3Key(s3Location.key()) + .build(); + } else { + throw new IllegalArgumentException("codeUri 또는 codeZip이 필요합니다."); + } + } + + /** + * S3 URI 파싱 + */ + public S3CodeLocation parseS3Uri(String codeUri) { + String uri = codeUri.trim(); + + // s3://bucket/key 형식 + if (uri.startsWith("s3://")) { + String path = uri.substring(5); + int slashIndex = path.indexOf('/'); + if (slashIndex == -1) { + throw new IllegalArgumentException("S3 URI 형식이 올바르지 않습니다: " + codeUri); + } + String bucket = path.substring(0, slashIndex); + String key = path.substring(slashIndex + 1); + return new S3CodeLocation(bucket, key); + } + + // bucket/key 형식 + int slashIndex = uri.indexOf('/'); + if (slashIndex == -1) { + throw new IllegalArgumentException("S3 URI 형식이 올바르지 않습니다: " + codeUri); + } + String bucket = uri.substring(0, slashIndex); + String key = uri.substring(slashIndex + 1); + return new S3CodeLocation(bucket, key); + } + + /** + * S3 코드 위치 + */ + public record S3CodeLocation(String bucket, String key) {} +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/controller/FunctionController.java b/src/main/java/com/agenticcp/core/domain/cloud/controller/FunctionController.java new file mode 100644 index 00000000..16f8690b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/controller/FunctionController.java @@ -0,0 +1,398 @@ +package com.agenticcp.core.domain.cloud.controller; + +import com.agenticcp.core.common.audit.AuditRequired; +import com.agenticcp.core.common.dto.exception.ApiResponse; +import com.agenticcp.core.common.enums.AuditResourceType; +import com.agenticcp.core.common.enums.AuditSeverity; +import com.agenticcp.core.domain.cloud.dto.FunctionCreateRequest; +import com.agenticcp.core.domain.cloud.dto.FunctionDeleteRequest; +import com.agenticcp.core.domain.cloud.dto.FunctionInvokeRequest; +import com.agenticcp.core.domain.cloud.dto.FunctionQueryRequest; +import com.agenticcp.core.domain.cloud.dto.FunctionResponse; +import com.agenticcp.core.domain.cloud.dto.FunctionUpdateRequest; +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.service.function.FunctionUseCaseService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +import java.util.Optional; + +/** + * Serverless Function 관리 REST API 컨트롤러 + * + * 멀티 클라우드(AWS Lambda, Azure Functions, GCP Cloud Functions) Function의 생성, 조회, 수정, 삭제, 실행 기능을 제공합니다. + * 헥사고날 아키텍처의 인터페이스 계층에 해당하며, 외부 클라이언트와의 통신을 담당합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@RestController +@RequestMapping("/api/v1/cloud/providers/{provider}/accounts/{accountScope}/functions") +@RequiredArgsConstructor +@Tag(name = "Serverless Function Management", description = "Serverless Function 관리 API (멀티 클라우드 지원)") +public class FunctionController { + + private final FunctionUseCaseService functionUseCaseService; + + // ==================== Function 생성 ==================== + + /** + * Serverless Function을 생성합니다. + * + * @param provider 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * @param accountScope 계정 스코프 + * @param request Function 생성 요청 + * @return 생성된 Function 리소스 + */ + @PostMapping + @PreAuthorize("hasAuthority('FUNCTION_CREATE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "CREATE_FUNCTION", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "Serverless Function 생성", + includeRequestData = true, + includeResponseData = true, + severity = AuditSeverity.MEDIUM + ) + @Operation( + summary = "Serverless Function 생성", + description = "새로운 Serverless Function을 생성합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "Function 생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> createFunction( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Function 생성 요청", required = true) + @Valid @RequestBody FunctionCreateRequest request) { + + // PathVariable 값을 Request 객체에 주입 + request.setProviderType(provider); + request.setAccountScope(accountScope); + + log.info("[FunctionController] createFunction - provider={}, accountScope={}, functionName={}", + provider, accountScope, request.getFunctionName()); + + CloudResource resource = functionUseCaseService.createFunction(request); + FunctionResponse response = FunctionResponse.from(resource); + + log.info("[FunctionController] createFunction - success resourceId={}", resource.getResourceId()); + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(response, "Serverless Function 생성에 성공했습니다.")); + } + + // ==================== Function 조회 ==================== + + /** + * Serverless Function 목록을 조회합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param request 조회 요청 (Query 파라미터) + * @return Function 리소스 목록 + */ + @GetMapping + @PreAuthorize("hasAuthority('FUNCTION_READ') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "LIST_FUNCTIONS", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "Serverless Function 목록 조회", + includeRequestData = false, + includeResponseData = false, + severity = AuditSeverity.LOW + ) + @Operation( + summary = "Serverless Function 목록 조회", + description = "지정된 클라우드 프로바이더의 Function 목록을 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Function 목록 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 파라미터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity>> listFunctions( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Valid FunctionQueryRequest request) { + + request.setProviderType(provider); + request.setAccountScope(accountScope); + + log.info("[FunctionController] listFunctions - provider={}, accountScope={}", + provider, accountScope); + + Page resources = functionUseCaseService.listFunctions(request); + Page responses = resources.map(FunctionResponse::from); + + log.info("[FunctionController] listFunctions - success count={}", responses.getTotalElements()); + return ResponseEntity.ok(ApiResponse.success(responses, "Serverless Function 목록 조회에 성공했습니다.")); + } + + /** + * 특정 Serverless Function을 조회합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param functionId Function ID/ARN + * @param region 리전 + * @return Function 리소스 + */ + @GetMapping("/{functionId}") + @PreAuthorize("hasAuthority('FUNCTION_READ') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "GET_FUNCTION", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "Serverless Function 조회", + includeRequestData = false, + includeResponseData = true, + severity = AuditSeverity.LOW + ) + @Operation( + summary = "Serverless Function 상세 조회", + description = "특정 Function의 상세 정보를 조회합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Function 조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Function을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> getFunction( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Function ID/ARN", required = true, example = "arn:aws:lambda:us-east-1:123456789012:function:my-function") + @PathVariable String functionId, + + @Parameter(description = "리전", required = true, example = "us-east-1") + @RequestParam String region) { + + log.info("[FunctionController] getFunction - provider={}, accountScope={}, functionId={}, region={}", + provider, accountScope, functionId, region); + + Optional resource = functionUseCaseService.getFunction(provider, accountScope, region, functionId); + + if (resource.isPresent()) { + FunctionResponse response = FunctionResponse.from(resource.get()); + log.info("[FunctionController] getFunction - success functionId={}", functionId); + return ResponseEntity.ok(ApiResponse.success(response, "Serverless Function 조회에 성공했습니다.")); + } else { + log.warn("[FunctionController] getFunction - not found functionId={}", functionId); + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error(CloudErrorCode.CLOUD_RESOURCE_NOT_FOUND)); + } + } + + // ==================== Function 수정 ==================== + + /** + * Serverless Function을 수정합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param functionId Function ID/ARN + * @param region 리전 + * @param request Function 수정 요청 + * @return 수정된 Function 리소스 + */ + @PutMapping("/{functionId}") + @PreAuthorize("hasAuthority('FUNCTION_UPDATE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "UPDATE_FUNCTION", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "Serverless Function 수정", + includeRequestData = true, + includeResponseData = true, + severity = AuditSeverity.MEDIUM + ) + @Operation( + summary = "Serverless Function 수정", + description = "Function의 정보를 수정합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Function 수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Function을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> updateFunction( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Function ID/ARN", required = true, example = "arn:aws:lambda:us-east-1:123456789012:function:my-function") + @PathVariable String functionId, + + @Parameter(description = "리전", required = true, example = "us-east-1") + @RequestParam String region, + + @Parameter(description = "Function 수정 요청", required = true) + @Valid @RequestBody FunctionUpdateRequest request) { + + request.setProviderType(provider); + request.setAccountScope(accountScope); + request.setFunctionId(functionId); + request.setRegion(region); + + log.info("[FunctionController] updateFunction - provider={}, accountScope={}, functionId={}, region={}", + provider, accountScope, functionId, region); + + CloudResource resource = functionUseCaseService.updateFunction(request); + FunctionResponse response = FunctionResponse.from(resource); + + log.info("[FunctionController] updateFunction - success functionId={}", functionId); + return ResponseEntity.ok(ApiResponse.success(response, "Serverless Function 수정에 성공했습니다.")); + } + + // ==================== Function 삭제 ==================== + + /** + * Serverless Function을 삭제합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param functionId Function ID/ARN + * @param region 리전 + * @param request 삭제 요청 (선택) + * @return 삭제 결과 + */ + @DeleteMapping("/{functionId}") + @PreAuthorize("hasAuthority('FUNCTION_DELETE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "DELETE_FUNCTION", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "Serverless Function 삭제", + includeRequestData = true, + includeResponseData = false, + severity = AuditSeverity.HIGH + ) + @Operation( + summary = "Serverless Function 삭제", + description = "Function을 삭제합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "204", description = "Function 삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Function을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity deleteFunction( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Function ID/ARN", required = true, example = "arn:aws:lambda:us-east-1:123456789012:function:my-function") + @PathVariable String functionId, + + @Parameter(description = "리전", required = true, example = "us-east-1") + @RequestParam String region, + + @Parameter(description = "Function 삭제 요청") + @RequestBody(required = false) FunctionDeleteRequest request) { + + if (request == null) { + request = FunctionDeleteRequest.builder() + .functionId(functionId) + .build(); + } + + request.setProviderType(provider); + request.setAccountScope(accountScope); + request.setFunctionId(functionId); + request.setRegion(region); + + log.info("[FunctionController] deleteFunction - provider={}, accountScope={}, functionId={}, region={}", + provider, accountScope, functionId, region); + + functionUseCaseService.deleteFunction(request); + + log.info("[FunctionController] deleteFunction - success functionId={}", functionId); + return ResponseEntity.noContent().build(); + } + + // ==================== Function 실행 ==================== + + /** + * Serverless Function을 실행합니다. + * + * @param provider 클라우드 프로바이더 타입 + * @param accountScope 계정 스코프 + * @param functionId Function ID/ARN + * @param request 실행 요청 + * @return 실행 결과 + */ + @PostMapping("/{functionId}/invoke") + @PreAuthorize("hasAuthority('FUNCTION_INVOKE') or hasRole('SUPER_ADMIN')") + @AuditRequired( + action = "INVOKE_FUNCTION", + resourceType = AuditResourceType.CLOUD_PROVIDER, + description = "Serverless Function 실행", + includeRequestData = true, + includeResponseData = true, + severity = AuditSeverity.MEDIUM + ) + @Operation( + summary = "Serverless Function 실행", + description = "Function을 실행합니다." + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Function 실행 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Function을 찾을 수 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "서버 내부 오류") + }) + public ResponseEntity> invokeFunction( + @Parameter(description = "클라우드 프로바이더 타입", required = true, example = "AWS") + @PathVariable CloudProvider.ProviderType provider, + + @Parameter(description = "계정 식별자 (Account ID 등)", required = true, example = "123456789012") + @PathVariable String accountScope, + + @Parameter(description = "Function ID/ARN", required = true, example = "arn:aws:lambda:us-east-1:123456789012:function:my-function") + @PathVariable String functionId, + + @Parameter(description = "Function 실행 요청", required = true) + @Valid @RequestBody FunctionInvokeRequest request) { + + request.setProviderType(provider); + request.setAccountScope(accountScope); + request.setFunctionId(functionId); + + log.info("[FunctionController] invokeFunction - provider={}, accountScope={}, functionId={}, invocationType={}", + provider, accountScope, functionId, request.getInvocationType()); + + String result = functionUseCaseService.invokeFunction(request); + + log.info("[FunctionController] invokeFunction - success functionId={}", functionId); + return ResponseEntity.ok(ApiResponse.success(result, "Serverless Function 실행에 성공했습니다.")); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionCreateRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionCreateRequest.java new file mode 100644 index 00000000..0bfb890b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionCreateRequest.java @@ -0,0 +1,143 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Serverless Function 생성 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FunctionCreateRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 리전 (필수) + * 예: us-east-1, ap-northeast-2 + */ + @NotBlank(message = "리전은 필수입니다") + private String region; + + /** + * 함수 이름 (CSP 중립적) + * 예: my-function + */ + @NotBlank(message = "함수 이름은 필수입니다") + @Pattern(regexp = "^[a-zA-Z0-9-_]{1,64}$", message = "함수 이름은 1-64자의 영문, 숫자, 하이픈, 언더스코어만 사용 가능합니다") + private String functionName; + + /** + * 런타임 (CSP 중립적) + * 예: "nodejs18.x", "python3.11", "java17", "go1.x" + */ + @NotBlank(message = "런타임은 필수입니다") + private String runtime; + + /** + * 핸들러 (CSP 중립적) + * AWS(Handler), Azure(scriptFile.entryPoint), GCP(entryPoint) + */ + @NotBlank(message = "핸들러는 필수입니다") + private String handler; + + /** + * 메모리 크기 (MB) + */ + @Min(value = 128, message = "메모리는 최소 128MB입니다") + @Max(value = 10240, message = "메모리는 최대 10240MB입니다") + private Integer memorySize; + + /** + * 타임아웃 (초) + */ + @Min(value = 1, message = "타임아웃은 최소 1초입니다") + @Max(value = 900, message = "타임아웃은 최대 900초입니다") + private Integer timeout; + + /** + * 실행 역할 (CSP 중립적) + * AWS(Role ARN), Azure(identity), GCP(serviceAccountEmail) + */ + @NotBlank(message = "실행 역할은 필수입니다") + private String roleArn; + + /** + * 환경 변수 + */ + private Map environmentVariables; + + /** + * 함수 설명 + */ + private String description; + + /** + * VPC 연결 (선택적) + */ + private String vpcId; + + /** + * 코드 URI (S3, Blob Storage, GCS 등) + */ + @NotBlank(message = "코드 URI는 필수입니다") + private String codeUri; + + /** + * 태그 + */ + private Map tags; + + /** + * 테넌트 키 + */ + private String tenantKey; + + /** + * CSP별 특화 설정 + * + * AWS 예시: + * - subnetIds: ["subnet-12345", "subnet-67890"] (VPC 연결 시) + * - securityGroupIds: ["sg-12345"] (VPC 연결 시) + * - layers: ["arn:aws:lambda:region:account:layer:layer-name:1"] + * - reservedConcurrentExecutions: 10 + * - deadLetterQueueTargetArn: "arn:aws:sqs:region:account:dlq" + * + * Azure 예시: + * - hostingPlan: "Consumption" | "Premium" | "Dedicated" + * - appServicePlanId: "/subscriptions/.../resourceGroups/.../providers/..." + * - bindings: [{"type": "httpTrigger", "direction": "in", "authLevel": "function"}] + * + * GCP 예시: + * - serviceAccountEmail: "my-function@project.iam.gserviceaccount.com" + * - vpcConnector: "projects/project/locations/region/connectors/connector" + * - maxInstances: 10 + * - minInstances: 0 + * - ingressSettings: "ALLOW_ALL" | "ALLOW_INTERNAL_ONLY" | "ALLOW_INTERNAL_AND_GCLB" + */ + private Map providerSpecificConfig; +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionDeleteRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionDeleteRequest.java new file mode 100644 index 00000000..01594007 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionDeleteRequest.java @@ -0,0 +1,68 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Serverless Function 삭제 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FunctionDeleteRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 함수 ID/ARN (삭제할 함수 식별자) + */ + private String functionId; + + /** + * 리전 + */ + private String region; + + /** + * 삭제 이유 (감사 로그용) + */ + private String reason; + + /** + * 테넌트 키 + */ + private String tenantKey; + + /** + * CSP별 특화 삭제 옵션 + */ + private Map providerSpecificConfig; + + /** + * 기본 삭제 요청 생성 + */ + public static FunctionDeleteRequest basic(String functionId) { + return FunctionDeleteRequest.builder() + .functionId(functionId) + .build(); + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionInvokeRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionInvokeRequest.java new file mode 100644 index 00000000..ba00ea6c --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionInvokeRequest.java @@ -0,0 +1,77 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Serverless Function 실행 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FunctionInvokeRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 함수 ID/ARN (실행할 함수 식별자) + */ + private String functionId; + + /** + * 리전 (필수) + * 예: us-east-1, ap-northeast-2 + */ + @NotBlank(message = "리전은 필수입니다") + private String region; + + /** + * 실행 타입 + * "RequestResponse" (동기), "Event" (비동기), "DryRun" (검증) + */ + @Pattern(regexp = "RequestResponse|Event|DryRun", message = "RequestResponse, Event, DryRun 중 하나여야 합니다") + @Builder.Default + private String invocationType = "RequestResponse"; + + /** + * JSON 문자열 페이로드 (필수) + */ + @NotBlank(message = "페이로드는 필수입니다") + private String payload; + + /** + * 함수 버전/별칭 (선택적) + */ + private String qualifier; + + /** + * 추가 컨텍스트 정보 + */ + private Map context; + + /** + * 테넌트 키 + */ + private String tenantKey; +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionQueryRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionQueryRequest.java new file mode 100644 index 00000000..8c23691a --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionQueryRequest.java @@ -0,0 +1,77 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; +import java.util.Set; + +/** + * Serverless Function 조회 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FunctionQueryRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 조회할 리전 목록 + */ + private Set regions; + + /** + * 함수 이름으로 필터링 + */ + private String functionName; + + /** + * 런타임으로 필터링 + */ + private String runtime; + + /** + * VPC ID로 필터링 + */ + private String vpcId; + + /** + * 태그로 필터링 + */ + private Map tags; + + /** + * 페이지 번호 (0부터 시작) + */ + @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다") + @Builder.Default + private int page = 0; + + /** + * 페이지 크기 + */ + @Min(value = 1, message = "페이지 크기는 최소 1입니다") + @Max(value = 100, message = "페이지 크기는 최대 100입니다") + @Builder.Default + private int size = 20; +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionResponse.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionResponse.java new file mode 100644 index 00000000..973e789f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionResponse.java @@ -0,0 +1,124 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +import java.time.LocalDateTime; +import java.util.Map; + +/** + * Serverless Function 응답 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FunctionResponse { + + private Long id; + private String resourceId; + private String resourceName; // function name + private ProviderType providerType; + private String region; + private String runtime; // 런타임 + private String handler; // 핸들러 + private Integer memorySize; // MB + private Integer timeout; // 초 + private String roleArn; // 실행 역할 + private Map environmentVariables; // 환경 변수 + private String description; // 함수 설명 + private String vpcId; // VPC ID + private String status; // 상태 + private String lifecycleState; // 생명주기 상태 + private String lastModified; // 마지막 수정 시간 + private Map tags; + private LocalDateTime createdAt; + private LocalDateTime lastSync; + + /** + * CloudResource → FunctionResponse 변환 + */ + public static FunctionResponse from(CloudResource resource) { + if (resource == null) { + return null; + } + + // CloudResource의 configuration JSON에서 추가 정보 추출 + FunctionConfigurationInfo configInfo = parseConfiguration(resource.getConfiguration()); + + return FunctionResponse.builder() + .id(resource.getId()) + .resourceId(resource.getResourceId()) + .resourceName(resource.getResourceName()) + .providerType(resource.getProvider() != null ? resource.getProvider().getProviderType() : null) + .region(resource.getRegion() != null ? resource.getRegion().getRegionKey() : null) + .runtime(configInfo.runtime != null ? configInfo.runtime : resource.getInstanceType()) // instanceType에 runtime 저장 가능 + .handler(configInfo.handler) + .memorySize(resource.getMemoryGb() != null ? resource.getMemoryGb() * 1024 : null) // GB → MB + .timeout(configInfo.timeout) + .roleArn(configInfo.roleArn) + .environmentVariables(configInfo.environmentVariables) + .description(configInfo.description) + .vpcId(configInfo.vpcId) + .status(resource.getStatus() != null ? resource.getStatus().name() : null) + .lifecycleState(resource.getLifecycleState() != null ? resource.getLifecycleState().name() : null) + .lastModified(resource.getLastModifiedInCloud() != null ? resource.getLastModifiedInCloud().toString() : null) + .tags(resource.getTags()) + .createdAt(resource.getCreatedAt()) + .lastSync(resource.getLastSync()) + .build(); + } + + /** + * configuration JSON 파싱 + */ + private static FunctionConfigurationInfo parseConfiguration(String configuration) { + if (configuration == null || configuration.isEmpty()) { + return new FunctionConfigurationInfo(null, null, null, null, null, null, null, null); + } + + try { + ObjectMapper mapper = new ObjectMapper(); + Map config = mapper.readValue(configuration, new TypeReference>() {}); + + return new FunctionConfigurationInfo( + (String) config.get("runtime"), + (String) config.get("handler"), + config.get("timeout") != null ? ((Number) config.get("timeout")).intValue() : null, + (String) config.get("roleArn"), + (Map) config.get("environmentVariables"), + (String) config.get("description"), + (String) config.get("vpcId"), + null + ); + } catch (Exception e) { + log.warn("[FunctionResponse] Failed to parse configuration JSON: {}", configuration, e); + return new FunctionConfigurationInfo(null, null, null, null, null, null, null, null); + } + } + + /** + * configuration 정보 임시 저장용 레코드 + */ + private record FunctionConfigurationInfo( + String runtime, + String handler, + Integer timeout, + String roleArn, + Map environmentVariables, + String description, + String vpcId, + String lastModified + ) {} +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionUpdateRequest.java b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionUpdateRequest.java new file mode 100644 index 00000000..2a7d350b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/dto/FunctionUpdateRequest.java @@ -0,0 +1,110 @@ +package com.agenticcp.core.domain.cloud.dto; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.Map; + +/** + * Serverless Function 수정 요청 DTO + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class FunctionUpdateRequest { + + /** + * 클라우드 프로바이더 타입 (AWS, GCP, AZURE) + * Controller에서 PathVariable로 주입됩니다. + */ + private ProviderType providerType; + + /** + * 계정 스코프 (Account ID 등) + * Controller에서 PathVariable로 주입됩니다. + */ + private String accountScope; + + /** + * 함수 ID/ARN (수정할 함수 식별자) + */ + private String functionId; + + /** + * 리전 + */ + private String region; + + /** + * 런타임 변경 + */ + private String runtime; + + /** + * 핸들러 변경 + */ + private String handler; + + /** + * 메모리 크기 변경 (MB) + */ + @Min(value = 128, message = "메모리는 최소 128MB입니다") + @Max(value = 10240, message = "메모리는 최대 10240MB입니다") + private Integer memorySize; + + /** + * 타임아웃 변경 (초) + */ + @Min(value = 1, message = "타임아웃은 최소 1초입니다") + @Max(value = 900, message = "타임아웃은 최대 900초입니다") + private Integer timeout; + + /** + * 실행 역할 변경 + */ + private String roleArn; + + /** + * 환경 변수 변경 + */ + private Map environmentVariables; + + /** + * 설명 변경 + */ + private String description; + + /** + * 코드 업데이트 URI + */ + private String codeUri; + + /** + * 추가할 태그 + */ + private Map tagsToAdd; + + /** + * 제거할 태그 + */ + private Map tagsToRemove; + + /** + * 테넌트 키 + */ + private String tenantKey; + + /** + * CSP별 특화 설정 + */ + private Map providerSpecificConfig; +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionCreateCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionCreateCommand.java new file mode 100644 index 00000000..7edaad3a --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionCreateCommand.java @@ -0,0 +1,48 @@ +package com.agenticcp.core.domain.cloud.port.model.function; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.Map; + +/** + * Serverless Function 생성 도메인 커맨드 + * + * UseCase Service에서 Adapter로 전달되는 내부 명령 모델입니다. + * CSP 중립적인 필드를 사용하며, 각 CSP Adapter의 Mapper에서 CSP 특화 요청으로 변환합니다. + * + * 필드 매핑 예시: + * - functionName: AWS(FunctionName), Azure(FunctionName), GCP(name) + * - runtime: AWS(Runtime), Azure(runtime), GCP(runtime) + * - handler: AWS(Handler), Azure(scriptFile.entryPoint), GCP(entryPoint) + * - memorySize: AWS(MemorySize), Azure(functionAppConfig), GCP(availableMemoryMb) + * - timeout: AWS(Timeout), Azure(functionTimeout), GCP(timeout) + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record FunctionCreateCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String serviceKey, // "LAMBDA", "AZURE_FUNCTIONS", "CLOUD_FUNCTIONS" + String resourceType, // "FUNCTION" + String functionName, // CSP 중립적: AWS(FunctionName), Azure(FunctionName), GCP(name) + String runtime, // CSP 중립적: "nodejs18.x", "python3.11", "java17", "go1.x" + String handler, // CSP 중립적: AWS(Handler), Azure(scriptFile.entryPoint), GCP(entryPoint) + Integer memorySize, // MB (모든 CSP 공통) + Integer timeout, // 초 (모든 CSP 공통, 최대값 CSP별로 다를 수 있음) + String roleArn, // CSP 중립적: AWS(Role ARN), Azure(identity), GCP(serviceAccountEmail) + Map environmentVariables, // 환경 변수 + String description, // 함수 설명 + String vpcId, // VPC 연결 (선택적, CSP별 구현 다를 수 있음) + String codeUri, // 코드 URI (S3, Blob Storage, GCS 등) + byte[] codeZip, // 코드 ZIP 바이너리 (선택적, URI 대신 사용) + Map tags, + String tenantKey, + Map providerSpecificConfig, // CSP별 특화 설정 + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionDeleteCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionDeleteCommand.java new file mode 100644 index 00000000..d5a4e5b4 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionDeleteCommand.java @@ -0,0 +1,27 @@ +package com.agenticcp.core.domain.cloud.port.model.function; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.Map; + +/** + * Serverless Function 삭제 도메인 커맨드 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record FunctionDeleteCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String providerResourceId, + String serviceKey, + String resourceType, + String tenantKey, + Map providerSpecificConfig, // CSP별 특화 삭제 옵션 + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionInvokeCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionInvokeCommand.java new file mode 100644 index 00000000..f84425bb --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionInvokeCommand.java @@ -0,0 +1,28 @@ +package com.agenticcp.core.domain.cloud.port.model.function; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.Map; + +/** + * Serverless Function 실행 커맨드 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record FunctionInvokeCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String providerResourceId, // 함수 ARN/ID + String invocationType, // "RequestResponse", "Event", "DryRun" + String payload, // JSON 문자열 페이로드 + String qualifier, // 함수 버전/별칭 (선택적) + Map context, // 추가 컨텍스트 정보 + String tenantKey, + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionQuery.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionQuery.java new file mode 100644 index 00000000..9d9f672f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionQuery.java @@ -0,0 +1,27 @@ +package com.agenticcp.core.domain.cloud.port.model.function; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import lombok.Builder; + +import java.util.Map; +import java.util.Set; + +/** + * Serverless Function 조회 쿼리 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record FunctionQuery( + CloudProvider.ProviderType providerType, + String accountScope, + Set regions, + String functionName, // 함수 이름으로 필터링 (CSP 중립적) + String runtime, // 런타임으로 필터링 + String vpcId, // VPC ID로 필터링 + Map tagsEquals, // 태그로 필터링 + int page, + int size +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionUpdateCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionUpdateCommand.java new file mode 100644 index 00000000..fb58a00f --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/FunctionUpdateCommand.java @@ -0,0 +1,35 @@ +package com.agenticcp.core.domain.cloud.port.model.function; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +import java.util.Map; + +/** + * Serverless Function 수정 도메인 커맨드 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record FunctionUpdateCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String providerResourceId, // 함수 ARN/ID (CSP별 형식 다를 수 있음) + String runtime, // 런타임 변경 + String handler, // 핸들러 변경 + Integer memorySize, // 메모리 크기 변경 (MB) + Integer timeout, // 타임아웃 변경 (초) + String roleArn, // 실행 역할 변경 + Map environmentVariables, // 환경 변수 변경 + String description, // 설명 변경 + String codeUri, // 코드 업데이트 URI + byte[] codeZip, // 코드 ZIP 바이너리 + Map tags, + String tenantKey, + Map providerSpecificConfig, // CSP별 특화 설정 + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/GetFunctionCommand.java b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/GetFunctionCommand.java new file mode 100644 index 00000000..f14530ef --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/model/function/GetFunctionCommand.java @@ -0,0 +1,23 @@ +package com.agenticcp.core.domain.cloud.port.model.function; + +import com.agenticcp.core.domain.cloud.entity.CloudProvider; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import lombok.Builder; + +/** + * Serverless Function 단건 조회 커맨드 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Builder +public record GetFunctionCommand( + CloudProvider.ProviderType providerType, + String accountScope, + String region, + String providerResourceId, + String serviceKey, + String resourceType, + CloudSessionCredential session +) { +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/function/FunctionDiscoveryPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/function/FunctionDiscoveryPort.java new file mode 100644 index 00000000..811835db --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/function/FunctionDiscoveryPort.java @@ -0,0 +1,55 @@ +package com.agenticcp.core.domain.cloud.port.outbound.function; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionQuery; +import com.agenticcp.core.domain.cloud.port.model.function.GetFunctionCommand; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import org.springframework.data.domain.Page; + +import java.util.Optional; + +/** + * Serverless Function 조회/탐색 포트 인터페이스 + * + *

모든 Discovery 작업에서 세션 자격증명을 전달받아 사용합니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +public interface FunctionDiscoveryPort { + + /** + * Serverless Function 목록 조회 + * + * @param query 조회 조건 (페이징, 필터링 포함) + * @param session 세션 자격증명 + * @return CloudResource 페이지 (빈 페이지 가능, null 반환 금지) + */ + Page listFunctions(FunctionQuery query, CloudSessionCredential session); + + /** + * 특정 Serverless Function 조회 + * + * @param command 조회 명령 + * @return CloudResource (존재하지 않으면 Optional.empty()) + */ + Optional getFunction(GetFunctionCommand command); + + /** + * Serverless Function 상태 조회 + * + * @param functionId 함수 ID (providerResourceId) + * @param session 세션 자격증명 + * @return 함수 상태 문자열 + */ + String getFunctionStatus(String functionId, CloudSessionCredential session); + + /** + * Serverless Function 코드 조회 + * + * @param functionId 함수 ID (providerResourceId) + * @param session 세션 자격증명 + * @return 함수 코드 (ZIP 바이너리) + */ + byte[] getFunctionCode(String functionId, CloudSessionCredential session); +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/function/FunctionInvocationPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/function/FunctionInvocationPort.java new file mode 100644 index 00000000..c7d5d7d4 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/function/FunctionInvocationPort.java @@ -0,0 +1,30 @@ +package com.agenticcp.core.domain.cloud.port.outbound.function; + +import com.agenticcp.core.domain.cloud.port.model.function.FunctionInvokeCommand; + +/** + * Serverless Function 실행 포트 인터페이스 + * + *

Function 실행 작업은 세션 자격증명을 명시적으로 전달받습니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +public interface FunctionInvocationPort { + + /** + * Serverless Function 실행 (동기) + * + * @param command 실행 커맨드 + * @return 실행 결과 (JSON 문자열) + */ + String invokeFunction(FunctionInvokeCommand command); + + /** + * Serverless Function 비동기 실행 + * + * @param command 실행 커맨드 + * @return 요청 ID (추적용) + */ + String invokeFunctionAsync(FunctionInvokeCommand command); +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/function/FunctionManagementPort.java b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/function/FunctionManagementPort.java new file mode 100644 index 00000000..2ab26234 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/port/outbound/function/FunctionManagementPort.java @@ -0,0 +1,40 @@ +package com.agenticcp.core.domain.cloud.port.outbound.function; + +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionDeleteCommand; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionUpdateCommand; + +/** + * Serverless Function 생명주기 관리 포트 인터페이스 + * + *

모든 Management 작업은 세션 자격증명을 명시적으로 전달받습니다.

+ * + * @author AgenticCP Team + * @version 1.0.0 + */ +public interface FunctionManagementPort { + + /** + * Serverless Function 생성 + * + * @param command 생성 명령 + * @return 생성된 Function CloudResource + */ + CloudResource createFunction(FunctionCreateCommand command); + + /** + * Serverless Function 수정 + * + * @param command 수정 명령 + * @return 수정된 Function CloudResource + */ + CloudResource updateFunction(FunctionUpdateCommand command); + + /** + * Serverless Function 삭제 + * + * @param command 삭제 명령 + */ + void deleteFunction(FunctionDeleteCommand command); +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/function/FunctionPortRouter.java b/src/main/java/com/agenticcp/core/domain/cloud/service/function/FunctionPortRouter.java new file mode 100644 index 00000000..203c6ec2 --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/function/FunctionPortRouter.java @@ -0,0 +1,94 @@ +package com.agenticcp.core.domain.cloud.service.function; + +import com.agenticcp.core.domain.cloud.adapter.outbound.common.ProviderScoped; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.port.outbound.function.FunctionDiscoveryPort; +import com.agenticcp.core.domain.cloud.port.outbound.function.FunctionInvocationPort; +import com.agenticcp.core.domain.cloud.port.outbound.function.FunctionManagementPort; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * Function 포트 라우터 + * + * ProviderType에 따라 적절한 Function 포트(Management, Discovery, Invocation)를 선택합니다. + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Component +public class FunctionPortRouter { + + private final Map managementPorts; + private final Map discoveryPorts; + private final Map invocationPorts; + + public FunctionPortRouter( + List managementPortList, + List discoveryPortList, + List invocationPortList + ) { + this.managementPorts = buildPortMap(managementPortList); + this.discoveryPorts = buildPortMap(discoveryPortList); + this.invocationPorts = buildPortMap(invocationPortList); + } + + private Map buildPortMap(List ports) { + return ports.stream() + .filter(port -> port instanceof ProviderScoped) + .collect(Collectors.toMap( + port -> ((ProviderScoped) port).getProviderType(), + Function.identity(), + (existing, replacement) -> existing // 중복 시 기존 것 유지 + )); + } + + /** + * Management 포트 반환 + * + * @param providerType 프로바이더 타입 + * @return FunctionManagementPort + * @throws IllegalArgumentException 지원하지 않는 프로바이더인 경우 + */ + public FunctionManagementPort management(ProviderType providerType) { + FunctionManagementPort port = managementPorts.get(providerType); + if (port == null) { + throw new IllegalArgumentException("지원하지 않는 프로바이더입니다: " + providerType); + } + return port; + } + + /** + * Discovery 포트 반환 + * + * @param providerType 프로바이더 타입 + * @return FunctionDiscoveryPort + * @throws IllegalArgumentException 지원하지 않는 프로바이더인 경우 + */ + public FunctionDiscoveryPort discovery(ProviderType providerType) { + FunctionDiscoveryPort port = discoveryPorts.get(providerType); + if (port == null) { + throw new IllegalArgumentException("지원하지 않는 프로바이더입니다: " + providerType); + } + return port; + } + + /** + * Invocation 포트 반환 + * + * @param providerType 프로바이더 타입 + * @return FunctionInvocationPort + * @throws IllegalArgumentException 지원하지 않는 프로바이더인 경우 + */ + public FunctionInvocationPort invocation(ProviderType providerType) { + FunctionInvocationPort port = invocationPorts.get(providerType); + if (port == null) { + throw new IllegalArgumentException("지원하지 않는 프로바이더입니다: " + providerType); + } + return port; + } +} diff --git a/src/main/java/com/agenticcp/core/domain/cloud/service/function/FunctionUseCaseService.java b/src/main/java/com/agenticcp/core/domain/cloud/service/function/FunctionUseCaseService.java new file mode 100644 index 00000000..2d77311b --- /dev/null +++ b/src/main/java/com/agenticcp/core/domain/cloud/service/function/FunctionUseCaseService.java @@ -0,0 +1,372 @@ +package com.agenticcp.core.domain.cloud.service.function; + +import com.agenticcp.core.common.context.TenantContextHolder; +import com.agenticcp.core.common.exception.BusinessException; +import com.agenticcp.core.domain.cloud.capability.CapabilityGuard; +import com.agenticcp.core.domain.cloud.dto.FunctionCreateRequest; +import com.agenticcp.core.domain.cloud.dto.FunctionDeleteRequest; +import com.agenticcp.core.domain.cloud.dto.FunctionInvokeRequest; +import com.agenticcp.core.domain.cloud.dto.FunctionQueryRequest; +import com.agenticcp.core.domain.cloud.dto.FunctionUpdateRequest; +import com.agenticcp.core.domain.cloud.dto.ResourceRegistrationRequest; +import com.agenticcp.core.domain.cloud.entity.CloudProvider.ProviderType; +import com.agenticcp.core.domain.cloud.entity.CloudResource; +import com.agenticcp.core.domain.cloud.exception.CloudErrorCode; +import com.agenticcp.core.domain.cloud.exception.CredentialErrorCode; +import com.agenticcp.core.domain.cloud.port.model.account.CloudSessionCredential; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionCreateCommand; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionDeleteCommand; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionInvokeCommand; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionQuery; +import com.agenticcp.core.domain.cloud.port.model.function.FunctionUpdateCommand; +import com.agenticcp.core.domain.cloud.port.model.function.GetFunctionCommand; +import com.agenticcp.core.domain.cloud.port.outbound.account.AccountCredentialManagementPort; +import com.agenticcp.core.domain.cloud.service.helper.CloudResourceManagementHelper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; +import java.util.Optional; + +/** + * Serverless Function 유스케이스 서비스 + * + * @author AgenticCP Team + * @version 1.0.0 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class FunctionUseCaseService { + + private static final String RESOURCE_TYPE = "FUNCTION"; + + private final FunctionPortRouter portRouter; + private final CapabilityGuard capabilityGuard; + private final AccountCredentialManagementPort credentialPort; + private final CloudResourceManagementHelper resourceHelper; + + /** + * Serverless Function 생성 + * + * @param request 생성 요청 + * @return 생성된 Function CloudResource + */ + @Transactional + public CloudResource createFunction(FunctionCreateRequest request) { + String serviceKey = getServiceKeyForProvider(request.getProviderType()); + capabilityGuard.ensureSupported(request.getProviderType(), serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.CREATE); + + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + String accountScope = request.getAccountScope(); + validateAccountScope(accountScope); + + CloudSessionCredential session = getSession(request.getProviderType(), accountScope); + + FunctionCreateCommand command = FunctionCreateCommand.builder() + .providerType(request.getProviderType()) + .accountScope(accountScope) + .region(request.getRegion()) + .serviceKey(serviceKey) + .resourceType(RESOURCE_TYPE) + .functionName(request.getFunctionName()) + .runtime(request.getRuntime()) + .handler(request.getHandler()) + .memorySize(request.getMemorySize()) + .timeout(request.getTimeout()) + .roleArn(request.getRoleArn()) + .environmentVariables(request.getEnvironmentVariables()) + .description(request.getDescription()) + .vpcId(request.getVpcId()) + .codeUri(request.getCodeUri()) + .codeZip(null) // 직접 업로드는 Phase 2에서 지원 예정 + .tags(request.getTags()) + .tenantKey(tenantKey) + .providerSpecificConfig(request.getProviderSpecificConfig()) + .session(session) + .build(); + + CloudResource resource = portRouter.management(request.getProviderType()).createFunction(command); + + // DB에 CloudResource 저장 + try { + ResourceRegistrationRequest registrationRequest = ResourceRegistrationRequest.builder() + .resourceId(resource.getResourceId()) + .resourceName(request.getFunctionName() != null ? request.getFunctionName() : resource.getResourceName()) + .resourceType(CloudResource.ResourceType.FUNCTION) + .tags(request.getTags()) + .build(); + + resourceHelper.registerResource(request.getProviderType(), serviceKey, registrationRequest); + } catch (Exception e) { + log.error("[FunctionUseCaseService] DB 저장 실패, 보상 트랜잭션 실행: functionId={}, error={}", + resource.getResourceId(), e.getMessage()); + + // 보상 트랜잭션: CSP에 생성된 Function 삭제 + executeCompensatingTransaction(request.getProviderType(), resource.getResourceId(), accountScope, request.getRegion(), session); + + throw new BusinessException( + CloudErrorCode.RESOURCE_CREATION_FAILED, + "Function 생성 후 DB 저장 실패로 인해 롤백되었습니다: " + resource.getResourceId() + ); + } + + return resource; + } + + /** + * Serverless Function 목록 조회 + * + * @param request 조회 요청 + * @return Function 목록 (Page) + */ + @Transactional(readOnly = true) + public Page listFunctions(FunctionQueryRequest request) { + String accountScope = request.getAccountScope(); + validateAccountScope(accountScope); + CloudSessionCredential session = getSession(request.getProviderType(), accountScope); + + FunctionQuery query = FunctionQuery.builder() + .providerType(request.getProviderType()) + .accountScope(accountScope) + .regions(request.getRegions()) + .functionName(request.getFunctionName()) + .runtime(request.getRuntime()) + .vpcId(request.getVpcId()) + .tagsEquals(request.getTags()) + .page(request.getPage()) + .size(request.getSize()) + .build(); + + return portRouter.discovery(request.getProviderType()).listFunctions(query, session); + } + + /** + * 특정 Serverless Function 조회 + * + * @param providerType 프로바이더 타입 + * @param accountScope 계정 범위 + * @param region 리전 + * @param providerResourceId 함수 ID/ARN + * @return Function CloudResource (존재하지 않으면 Optional.empty()) + */ + @Transactional(readOnly = true) + public Optional getFunction( + ProviderType providerType, + String accountScope, + String region, + String providerResourceId + ) { + validateAccountScope(accountScope); + CloudSessionCredential session = getSession(providerType, accountScope); + + String serviceKey = getServiceKeyForProvider(providerType); + GetFunctionCommand command = GetFunctionCommand.builder() + .providerType(providerType) + .accountScope(accountScope) + .region(region) + .providerResourceId(providerResourceId) + .serviceKey(serviceKey) + .resourceType(RESOURCE_TYPE) + .session(session) + .build(); + + return portRouter.discovery(providerType).getFunction(command); + } + + /** + * Serverless Function 수정 + * + * @param request 수정 요청 + * @return 수정된 Function CloudResource + */ + @Transactional + public CloudResource updateFunction(FunctionUpdateRequest request) { + String serviceKey = getServiceKeyForProvider(request.getProviderType()); + capabilityGuard.ensureSupported(request.getProviderType(), serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.UPDATE); + + String accountScope = request.getAccountScope(); + validateAccountScope(accountScope); + CloudSessionCredential session = getSession(request.getProviderType(), accountScope); + + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + // 기존 태그와 병합 (tagsToAdd 추가, tagsToRemove 제거는 어댑터에서 처리) + Map tags = request.getTagsToAdd() != null ? request.getTagsToAdd() : Map.of(); + + FunctionUpdateCommand command = FunctionUpdateCommand.builder() + .providerType(request.getProviderType()) + .accountScope(accountScope) + .region(request.getRegion()) + .providerResourceId(request.getFunctionId()) + .runtime(request.getRuntime()) + .handler(request.getHandler()) + .memorySize(request.getMemorySize()) + .timeout(request.getTimeout()) + .roleArn(request.getRoleArn()) + .environmentVariables(request.getEnvironmentVariables()) + .description(request.getDescription()) + .codeUri(request.getCodeUri()) + .codeZip(null) // 직접 업로드는 Phase 2에서 지원 예정 + .tags(tags) + .tenantKey(tenantKey) + .providerSpecificConfig(request.getProviderSpecificConfig()) + .session(session) + .build(); + + return portRouter.management(request.getProviderType()).updateFunction(command); + } + + /** + * Serverless Function 삭제 + * + * @param request 삭제 요청 + */ + @Transactional + public void deleteFunction(FunctionDeleteRequest request) { + String serviceKey = getServiceKeyForProvider(request.getProviderType()); + capabilityGuard.ensureSupported(request.getProviderType(), serviceKey, RESOURCE_TYPE, CapabilityGuard.Operation.TERMINATE); + + String accountScope = request.getAccountScope(); + validateAccountScope(accountScope); + CloudSessionCredential session = getSession(request.getProviderType(), accountScope); + + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + FunctionDeleteCommand command = FunctionDeleteCommand.builder() + .providerType(request.getProviderType()) + .accountScope(accountScope) + .region(request.getRegion()) + .providerResourceId(request.getFunctionId()) + .serviceKey(serviceKey) + .resourceType(RESOURCE_TYPE) + .tenantKey(tenantKey) + .providerSpecificConfig(request.getProviderSpecificConfig()) + .session(session) + .build(); + + portRouter.management(request.getProviderType()).deleteFunction(command); + } + + /** + * Serverless Function 실행 + * + * @param request 실행 요청 + * @return 실행 결과 (JSON 문자열) + */ + @Transactional + public String invokeFunction(FunctionInvokeRequest request) { + String accountScope = request.getAccountScope(); + validateAccountScope(accountScope); + CloudSessionCredential session = getSession(request.getProviderType(), accountScope); + + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + FunctionInvokeCommand command = FunctionInvokeCommand.builder() + .providerType(request.getProviderType()) + .accountScope(accountScope) + .region(request.getRegion()) + .providerResourceId(request.getFunctionId()) + .invocationType(request.getInvocationType()) + .payload(request.getPayload()) + .qualifier(request.getQualifier()) + .context(request.getContext()) + .tenantKey(tenantKey) + .session(session) + .build(); + + return portRouter.invocation(request.getProviderType()).invokeFunction(command); + } + + // ==================== Private Helper Methods ==================== + + /** + * 세션 자격증명 획득 (JIT 패턴) + */ + private CloudSessionCredential getSession(ProviderType providerType, String accountScope) { + String tenantKey = TenantContextHolder.getCurrentTenantKeyOrThrow(); + + log.debug("[FunctionUseCaseService] 세션 획득 시작: tenantKey={}, accountScope={}, providerType={}", + tenantKey, accountScope, providerType); + + try { + CloudSessionCredential session = credentialPort.getSession(tenantKey, accountScope, providerType); + log.info("[FunctionUseCaseService] 세션 획득 완료: expiresAt={}", session.getExpiresAt()); + return session; + } catch (BusinessException e) { + if (e.getErrorCode() == CredentialErrorCode.CREDENTIAL_NOT_FOUND) { + log.error("[FunctionUseCaseService] 자격증명을 찾을 수 없습니다: tenantKey={}, accountScope={}", + tenantKey, accountScope); + throw new BusinessException( + CloudErrorCode.ACCOUNT_NOT_CONFIGURED, + "계정이 설정되지 않았습니다" + ); + } + throw e; + } + } + + /** + * accountScope 검증 + */ + private void validateAccountScope(String accountScope) { + if (accountScope == null || accountScope.trim().isEmpty()) { + throw new BusinessException( + CloudErrorCode.ACCOUNT_SCOPE_REQUIRED, + "AccountScope가 필요합니다" + ); + } + } + + /** + * 프로바이더 타입에 따른 서비스 키 반환 + * AWS: LAMBDA, Azure: AZURE_FUNCTIONS, GCP: CLOUD_FUNCTIONS + */ + private String getServiceKeyForProvider(ProviderType providerType) { + return switch (providerType) { + case AWS -> "LAMBDA"; + case AZURE -> "AZURE_FUNCTIONS"; + case GCP -> "CLOUD_FUNCTIONS"; + default -> "FUNCTION"; + }; + } + + /** + * 보상 트랜잭션: CSP에 생성된 Function을 삭제합니다. + * Ghost Resource 방지를 위해 DB 저장 실패 시 호출됩니다. + */ + private void executeCompensatingTransaction( + ProviderType providerType, + String functionId, + String accountScope, + String region, + CloudSessionCredential session + ) { + try { + log.warn("[FunctionUseCaseService] 보상 트랜잭션 실행: functionId={}", functionId); + + FunctionDeleteCommand deleteCommand = FunctionDeleteCommand.builder() + .providerType(providerType) + .accountScope(accountScope) + .region(region) + .providerResourceId(functionId) + .serviceKey(getServiceKeyForProvider(providerType)) + .resourceType(RESOURCE_TYPE) + .tenantKey(TenantContextHolder.getCurrentTenantKeyOrThrow()) + .providerSpecificConfig(Map.of()) + .session(session) + .build(); + + portRouter.management(providerType).deleteFunction(deleteCommand); + log.info("[FunctionUseCaseService] 보상 트랜잭션 완료: functionId={}", functionId); + } catch (Exception e) { + log.error("[FunctionUseCaseService] 보상 트랜잭션 실패: functionId={}, error={}", + functionId, e.getMessage(), e); + // 보상 트랜잭션 실패는 로그만 남기고 예외를 다시 던지지 않음 + // (원래 예외를 유지하기 위해) + } + } +}