diff --git a/build.gradle.kts b/build.gradle.kts index 664130e..d61aeca 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -35,6 +35,10 @@ dependencies { runtimeOnly("io.jsonwebtoken:jjwt-impl:0.12.6") runtimeOnly("io.jsonwebtoken:jjwt-jackson:0.12.6") + implementation("com.github.docker-java:docker-java-core:3.3.6") + implementation("com.github.docker-java:docker-java-transport-httpclient5:3.3.6") + implementation("org.eclipse.jgit:org.eclipse.jgit:6.10.0.202406032230-r") + runtimeOnly("io.micrometer:micrometer-registry-prometheus") testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.jetbrains.kotlin:kotlin-test-junit5") diff --git a/src/main/kotlin/kr/proxia/domain/container/domain/entity/ContainerEntity.kt b/src/main/kotlin/kr/proxia/domain/container/domain/entity/ContainerEntity.kt new file mode 100644 index 0000000..b5006ea --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/container/domain/entity/ContainerEntity.kt @@ -0,0 +1,20 @@ +package kr.proxia.domain.container.domain.entity + +import jakarta.persistence.Entity +import jakarta.persistence.Table +import kr.proxia.domain.container.domain.enums.ContainerStatus +import kr.proxia.global.jpa.common.BaseEntity +import java.util.UUID + +@Entity +@Table(name = "containers") +class ContainerEntity( + val serviceId: UUID, + val nodeId: UUID, + val containerId: String, + val imageId: String, + val imageName: String, + val status: ContainerStatus = ContainerStatus.RUNNING, + val port: Int?, + val internalPort: Int, +) : BaseEntity() diff --git a/src/main/kotlin/kr/proxia/domain/container/domain/enums/ContainerStatus.kt b/src/main/kotlin/kr/proxia/domain/container/domain/enums/ContainerStatus.kt new file mode 100644 index 0000000..e53265a --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/container/domain/enums/ContainerStatus.kt @@ -0,0 +1,10 @@ +package kr.proxia.domain.container.domain.enums + +enum class ContainerStatus { + BUILDING, + STARTING, + RUNNING, + STOPPED, + FAILED, + REMOVING, +} diff --git a/src/main/kotlin/kr/proxia/domain/container/domain/repository/ContainerRepository.kt b/src/main/kotlin/kr/proxia/domain/container/domain/repository/ContainerRepository.kt new file mode 100644 index 0000000..0dd2eed --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/container/domain/repository/ContainerRepository.kt @@ -0,0 +1,9 @@ +package kr.proxia.domain.container.domain.repository + +import kr.proxia.domain.container.domain.entity.ContainerEntity +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +interface ContainerRepository : JpaRepository diff --git a/src/main/kotlin/kr/proxia/domain/deployment/application/service/DeploymentService.kt b/src/main/kotlin/kr/proxia/domain/deployment/application/service/DeploymentService.kt new file mode 100644 index 0000000..43843ba --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/deployment/application/service/DeploymentService.kt @@ -0,0 +1,261 @@ +package kr.proxia.domain.deployment.application.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import jakarta.transaction.Transactional +import kr.proxia.domain.container.domain.entity.ContainerEntity +import kr.proxia.domain.container.domain.repository.ContainerRepository +import kr.proxia.domain.node.application.scheduler.NodeScheduler +import kr.proxia.domain.resource.domain.entity.AppResourceEntity +import kr.proxia.domain.resource.domain.error.ResourceError +import kr.proxia.domain.resource.domain.repository.AppResourceRepository +import kr.proxia.domain.service.domain.enums.AppFramework +import kr.proxia.domain.service.domain.error.ServiceError +import kr.proxia.domain.service.domain.repository.ServiceRepository +import kr.proxia.global.container.ContainerOrchestrator +import kr.proxia.global.container.ContainerSpec +import kr.proxia.global.container.DockerfileGenerator +import kr.proxia.global.error.BusinessException +import kr.proxia.global.image.ImageBuilder +import kr.proxia.global.reverseproxy.ReverseProxyAdapter +import org.eclipse.jgit.api.Git +import org.springframework.beans.factory.annotation.Qualifier +import org.springframework.scheduling.annotation.Async +import org.springframework.stereotype.Service +import java.io.File +import java.util.UUID + +private val logger = KotlinLogging.logger {} + +@Service +class DeploymentService( + private val dockerfileGenerator: DockerfileGenerator, + private val serviceRepository: ServiceRepository, + private val appResourceRepository: AppResourceRepository, + private val containerRepository: ContainerRepository, + private val nodeScheduler: NodeScheduler, + private val containerOrchestrator: ContainerOrchestrator, + private val imageBuilder: ImageBuilder, + @param:Qualifier("nginxAdapter") private val reverseProxyAdapter: ReverseProxyAdapter, +) { + private val workDir = File(System.getProperty("java.io.tmpdir", "proxia-deployments")) + + @Async + @Transactional + fun deploy(serviceId: UUID) { + val service = + serviceRepository.findByIdAndDeletedAtIsNull(serviceId) + ?: throw BusinessException(ServiceError.SERVICE_NOT_FOUND) + + val node = nodeScheduler.chooseNode() + + logger.info { "Selected node '${node.name}' for service $serviceId" } + + val appResource = + appResourceRepository + .findById(service.targetId!!) + .orElseThrow { BusinessException(ResourceError.RESOURCE_NOT_FOUND) } + + val repoDir = cloneRepository(appResource.repositoryUrl!!, appResource.branch ?: "main", service.id) + val commitInfo = getLatestCommitInfo(repoDir) + val dockerfile = findOrCreateDockerfile(repoDir, appResource) + + val imageName = "proxia-service-${service.id}" + + val imageId = + imageBuilder.buildImage( + endpoint = node.endpoint, + contextDir = repoDir, + dockerfile = dockerfile, + imageName = imageName, + ) + + val env = parseEnvVariables(appResource.envVariables) + + val containerSpec = + ContainerSpec( + name = "svc-${service.id}-${System.currentTimeMillis()}", + image = "$imageName:latest", + env = env, + ports = + listOf( + ContainerSpec.PortMapping( + internal = 8080, + host = null, + ), + ), + ) + + val endpoint = node.endpoint + val containerId = containerOrchestrator.createContainer(endpoint, containerSpec) + + containerOrchestrator.startContainer(endpoint, containerId) + + val assignedPort = containerOrchestrator.getAssignedPort(endpoint, containerId, 8080) + + containerRepository.save( + ContainerEntity( + serviceId = serviceId, + nodeId = node.id, + containerId = containerId, + imageId = imageId, + port = assignedPort, + internalPort = 8080, + ), + ) + + val domain = createOrResolveDomain(serviceId) + + reverseProxyAdapter.createMapping(domain, containerSpec.name, 8080) + + logger.info { "Deployment complete: service=$serviceId domain=$domain" } + } + + private fun cloneRepository( + url: String, + branch: String, + serviceId: UUID, + ): File { + val repoDir = File(workDir, "service-$serviceId-${System.currentTimeMillis()}") + + try { + Git + .cloneRepository() + .setURI(url) + .setBranch(branch) + .setDirectory(repoDir) + .call() + + logger.info { "Cloned repository: $url (branch: $branch)" } + + return repoDir + } catch (e: Exception) { + logger.error(e) { "Failed to clone repository: $url" } + + throw e + } + } + + private fun getLatestCommitInfo(repoDir: File): CommitInfo { + Git.open(repoDir).use { git -> + val commit = + git + .log() + .setMaxCount(1) + .call() + .first() + + return CommitInfo( + sha = commit.name, + message = commit.shortMessage, + author = commit.authorIdent.name, + ) + } + } + + private fun findOrCreateDockerfile( + repoDir: File, + appResource: AppResourceEntity, + ): File { + val rootDir = + if (appResource.rootDirectory.isNullOrBlank()) { + repoDir + } else { + File(repoDir, appResource.rootDirectory!!) + } + + val existingDockerfile = File(rootDir, "Dockerfile") + + if (existingDockerfile.exists()) { + logger.info { "Using existing Dockerfile" } + + return existingDockerfile + } + + logger.info { "Generating Dockerfile based on framework detection " } + val detectedFramework = appResource.framework ?: detectFramework(rootDir) + + return generateDockerfile(rootDir, detectedFramework, appResource) + } + + private fun detectFramework(dir: File): AppFramework = + when { + File(dir, "package.json").exists() -> AppFramework.NODE_JS + File(dir, "pom.xml").exists() || + File(dir, "build.gradle").exists() || + File( + dir, + "build.gradle.kts", + ).exists() -> AppFramework.SPRING_BOOT + + File(dir, "requirements.txt").exists() -> AppFramework.PYTHON + File(dir, "go.mod").exists() -> AppFramework.GO + else -> AppFramework.OTHER + } + + private fun generateDockerfile( + rootDir: File, + framework: AppFramework, + appResource: AppResourceEntity, + ): File { + val dockerfileContent = + when (framework) { + AppFramework.SPRING_BOOT -> + dockerfileGenerator.generateSpringBootDockerfile( + appResource.buildCommand, + appResource.startCommand, + ) + + AppFramework.NODE_JS -> + dockerfileGenerator.generateNodeJsDockerfile( + appResource.installCommand, + appResource.buildCommand, + appResource.startCommand, + ) + + AppFramework.PYTHON -> + dockerfileGenerator.generatePythonDockerfile( + appResource.installCommand, + appResource.startCommand, + ) + + AppFramework.GO -> + dockerfileGenerator.generateGoDockerfile( + appResource.buildCommand, + appResource.startCommand, + ) + + else -> dockerfileGenerator.generateGenericDockerfile(appResource.startCommand) + } + + val dockerfile = File(rootDir, "Dockerfile") + + dockerfile.writeText(dockerfileContent) + logger.info { "Generated Dockerfile for framework: $framework" } + + return dockerfile + } + + private fun parseEnvVariables(json: String?): Map { + if (json.isNullOrBlank()) return emptyMap() + + return try { + json + .split("\n") + .filter { it.contains("=") } + .associate { + val (key, value) = it.split("=", limit = 2) + key.trim() to value.trim() + } + } catch (e: Exception) { + logger.warn(e) { "Failed to parse environment variables" } + + emptyMap() + } + } + + private data class CommitInfo( + val sha: String, + val message: String, + val author: String, + ) +} diff --git a/src/main/kotlin/kr/proxia/domain/deployment/presentation/controller/DeploymentController.kt b/src/main/kotlin/kr/proxia/domain/deployment/presentation/controller/DeploymentController.kt new file mode 100644 index 0000000..054cd9b --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/deployment/presentation/controller/DeploymentController.kt @@ -0,0 +1,22 @@ +package kr.proxia.domain.deployment.presentation.controller + +import kr.proxia.domain.deployment.application.service.DeploymentService +import kr.proxia.domain.deployment.presentation.request.DeployRequest +import org.springframework.web.bind.annotation.PathVariable +import org.springframework.web.bind.annotation.PostMapping +import org.springframework.web.bind.annotation.RequestBody +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController +import java.util.UUID + +@RestController +@RequestMapping("/api/services/{serviceId}/deployments") +class DeploymentController( + private val deploymentService: DeploymentService, +) { + @PostMapping + fun deployService( + @PathVariable("serviceId") serviceId: UUID, + @RequestBody request: DeployRequest, + ) = deploymentService.deploy(serviceId, request) +} diff --git a/src/main/kotlin/kr/proxia/domain/deployment/presentation/request/DeployRequest.kt b/src/main/kotlin/kr/proxia/domain/deployment/presentation/request/DeployRequest.kt new file mode 100644 index 0000000..0839296 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/deployment/presentation/request/DeployRequest.kt @@ -0,0 +1,5 @@ +package kr.proxia.domain.deployment.presentation.request + +data class DeployRequest( + val branch: String?, +) diff --git a/src/main/kotlin/kr/proxia/domain/monitoring/application/service/ContainerMetricsService.kt b/src/main/kotlin/kr/proxia/domain/monitoring/application/service/ContainerMetricsService.kt new file mode 100644 index 0000000..256cb37 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/monitoring/application/service/ContainerMetricsService.kt @@ -0,0 +1,9 @@ +package kr.proxia.domain.monitoring.application.service + +import io.github.oshai.kotlinlogging.KotlinLogging +import org.springframework.stereotype.Service + +private val logger = KotlinLogging.logger {} + +@Service +class ContainerMetricsService diff --git a/src/main/kotlin/kr/proxia/domain/monitoring/application/service/LogStreamingService.kt b/src/main/kotlin/kr/proxia/domain/monitoring/application/service/LogStreamingService.kt new file mode 100644 index 0000000..3d91b8b --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/monitoring/application/service/LogStreamingService.kt @@ -0,0 +1,6 @@ +package kr.proxia.domain.monitoring.application.service + +import org.springframework.stereotype.Service + +@Service +class LogStreamingService diff --git a/src/main/kotlin/kr/proxia/domain/monitoring/presentation/controller/LogStreamingController.kt b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/controller/LogStreamingController.kt new file mode 100644 index 0000000..5932a0f --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/controller/LogStreamingController.kt @@ -0,0 +1,7 @@ +package kr.proxia.domain.monitoring.presentation.controller + +import kr.proxia.domain.monitoring.presentation.docs.LogStreamingDocs +import org.springframework.web.bind.annotation.RestController + +@RestController +class LogStreamingController : LogStreamingDocs diff --git a/src/main/kotlin/kr/proxia/domain/monitoring/presentation/controller/MonitoringController.kt b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/controller/MonitoringController.kt new file mode 100644 index 0000000..11c4976 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/controller/MonitoringController.kt @@ -0,0 +1,14 @@ +package kr.proxia.domain.monitoring.presentation.controller + +import kr.proxia.domain.monitoring.application.service.ContainerMetricsService +import kr.proxia.domain.monitoring.presentation.docs.MonitoringDocs +import kr.proxia.domain.node.application.service.NodeHealthCheckService +import org.springframework.web.bind.annotation.RequestMapping +import org.springframework.web.bind.annotation.RestController + +@RestController +@RequestMapping("/api/services/{serviceId}") +class MonitoringController( + private val containerMetricsService: ContainerMetricsService, + private val healthCheckService: NodeHealthCheckService, +) : MonitoringDocs diff --git a/src/main/kotlin/kr/proxia/domain/monitoring/presentation/docs/LogStreamingDocs.kt b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/docs/LogStreamingDocs.kt new file mode 100644 index 0000000..f358543 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/docs/LogStreamingDocs.kt @@ -0,0 +1,3 @@ +package kr.proxia.domain.monitoring.presentation.docs + +interface LogStreamingDocs diff --git a/src/main/kotlin/kr/proxia/domain/monitoring/presentation/docs/MonitoringDocs.kt b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/docs/MonitoringDocs.kt new file mode 100644 index 0000000..2dc575d --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/docs/MonitoringDocs.kt @@ -0,0 +1,3 @@ +package kr.proxia.domain.monitoring.presentation.docs + +interface MonitoringDocs diff --git a/src/main/kotlin/kr/proxia/domain/monitoring/presentation/response/ContainerMetricsResponse.kt b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/response/ContainerMetricsResponse.kt new file mode 100644 index 0000000..e169cb6 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/response/ContainerMetricsResponse.kt @@ -0,0 +1,10 @@ +package kr.proxia.domain.monitoring.presentation.response + +data class ContainerMetricsResponse( + val cpuUsagePercent: Double, + val memoryUsageBytes: Long, + val memoryLimitBytes: Long, + val memoryUsagePercent: Double, + val networkRxBytes: Long, + val networkTxBytes: Long, +) diff --git a/src/main/kotlin/kr/proxia/domain/monitoring/presentation/response/ServiceHealthResponse.kt b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/response/ServiceHealthResponse.kt new file mode 100644 index 0000000..dc12602 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/monitoring/presentation/response/ServiceHealthResponse.kt @@ -0,0 +1,11 @@ +package kr.proxia.domain.monitoring.presentation.response + +import java.util.UUID + +data class ServiceHealthResponse( + val serviceId: UUID, + val containerId: String?, + val status: String, + val isRunning: Boolean, + val metrics: ContainerMetricsResponse?, +) diff --git a/src/main/kotlin/kr/proxia/domain/node/application/scheduler/NodeScheduler.kt b/src/main/kotlin/kr/proxia/domain/node/application/scheduler/NodeScheduler.kt new file mode 100644 index 0000000..41e80b5 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/node/application/scheduler/NodeScheduler.kt @@ -0,0 +1,32 @@ +package kr.proxia.domain.node.application.scheduler + +import kr.proxia.domain.node.domain.entity.NodeEntity +import kr.proxia.domain.node.domain.enums.NodeStatus +import kr.proxia.domain.node.domain.error.NodeError +import kr.proxia.domain.node.domain.repository.NodeRepository +import kr.proxia.global.error.BusinessException +import org.springframework.stereotype.Service +import java.util.concurrent.atomic.AtomicInteger + +@Service +class NodeScheduler( + private val nodeRepository: NodeRepository, +) { + private val counter = AtomicInteger(0) + + fun chooseNode(): NodeEntity { + val activeNodes = nodeRepository.findByStatus(NodeStatus.ACTIVE) + + if (activeNodes.isEmpty()) { + throw BusinessException(NodeError.NO_ACTIVE_NODE_AVAILABLE) + } + + val minCount = activeNodes.minOf { it.containerCount } + + val leastLoadedNodes = activeNodes.filter { it.containerCount == minCount } + + val index = counter.getAndIncrement().mod(leastLoadedNodes.size) + + return leastLoadedNodes[index] + } +} diff --git a/src/main/kotlin/kr/proxia/domain/node/application/service/NodeHealthCheckService.kt b/src/main/kotlin/kr/proxia/domain/node/application/service/NodeHealthCheckService.kt new file mode 100644 index 0000000..a16deed --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/node/application/service/NodeHealthCheckService.kt @@ -0,0 +1,6 @@ +package kr.proxia.domain.node.application.service + +import org.springframework.stereotype.Service + +@Service +class NodeHealthCheckService diff --git a/src/main/kotlin/kr/proxia/domain/node/domain/entity/NodeEntity.kt b/src/main/kotlin/kr/proxia/domain/node/domain/entity/NodeEntity.kt new file mode 100644 index 0000000..d089ec3 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/node/domain/entity/NodeEntity.kt @@ -0,0 +1,40 @@ +package kr.proxia.domain.node.domain.entity + +import jakarta.persistence.Entity +import jakarta.persistence.EnumType +import jakarta.persistence.Enumerated +import jakarta.persistence.Table +import kr.proxia.domain.node.domain.enums.NodeStatus +import kr.proxia.global.container.enums.ContainerRuntimeType +import kr.proxia.global.jpa.common.BaseEntity + +@Entity +@Table(name = "nodes") +class NodeEntity( + val name: String, + val endpoint: String, + runtimeType: ContainerRuntimeType, + status: NodeStatus = NodeStatus.ACTIVE, + containerCount: Int = 0, +) : BaseEntity() { + @Enumerated(EnumType.STRING) + var runtimeType: ContainerRuntimeType = runtimeType + protected set + + @Enumerated(EnumType.STRING) + var status: NodeStatus = status + protected set + + var containerCount: Int = containerCount + protected set + + fun update( + runtimeType: ContainerRuntimeType = this.runtimeType, + status: NodeStatus = this.status, + containerCount: Int = this.containerCount, + ) { + this.runtimeType = runtimeType + this.status = status + this.containerCount = containerCount + } +} diff --git a/src/main/kotlin/kr/proxia/domain/node/domain/enums/NodeStatus.kt b/src/main/kotlin/kr/proxia/domain/node/domain/enums/NodeStatus.kt new file mode 100644 index 0000000..724748d --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/node/domain/enums/NodeStatus.kt @@ -0,0 +1,6 @@ +package kr.proxia.domain.node.domain.enums + +enum class NodeStatus { + ACTIVE, + INACTIVE, +} diff --git a/src/main/kotlin/kr/proxia/domain/node/domain/error/NodeError.kt b/src/main/kotlin/kr/proxia/domain/node/domain/error/NodeError.kt new file mode 100644 index 0000000..ea1f7fe --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/node/domain/error/NodeError.kt @@ -0,0 +1,12 @@ +package kr.proxia.domain.node.domain.error + +import kr.proxia.global.error.BaseError +import org.springframework.http.HttpStatus + +enum class NodeError( + override val status: HttpStatus, + override val message: String, +) : BaseError { + NODE_NOT_FOUND(HttpStatus.NOT_FOUND, "Node not found"), + NO_ACTIVE_NODE_AVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "No active nodes available for deployment"), +} diff --git a/src/main/kotlin/kr/proxia/domain/node/domain/repository/NodeRepository.kt b/src/main/kotlin/kr/proxia/domain/node/domain/repository/NodeRepository.kt new file mode 100644 index 0000000..83aa9ba --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/node/domain/repository/NodeRepository.kt @@ -0,0 +1,12 @@ +package kr.proxia.domain.node.domain.repository + +import kr.proxia.domain.node.domain.entity.NodeEntity +import kr.proxia.domain.node.domain.enums.NodeStatus +import org.springframework.data.jpa.repository.JpaRepository +import org.springframework.stereotype.Repository +import java.util.UUID + +@Repository +interface NodeRepository : JpaRepository { + fun findByStatus(status: NodeStatus): List +} diff --git a/src/main/kotlin/kr/proxia/domain/project/application/service/ProjectService.kt b/src/main/kotlin/kr/proxia/domain/project/application/service/ProjectService.kt index 2dd84b1..c1a6238 100644 --- a/src/main/kotlin/kr/proxia/domain/project/application/service/ProjectService.kt +++ b/src/main/kotlin/kr/proxia/domain/project/application/service/ProjectService.kt @@ -96,7 +96,8 @@ class ProjectService( fun deleteProject(projectId: UUID) { val userId = securityHolder.getUserId() val project = - projectRepository.findByIdAndDeletedAtIsNull(projectId) ?: throw BusinessException(ProjectError.PROJECT_NOT_FOUND) + projectRepository.findByIdAndDeletedAtIsNull(projectId) + ?: throw BusinessException(ProjectError.PROJECT_NOT_FOUND) if (project.userId != userId) { throw BusinessException(ProjectError.PROJECT_ACCESS_DENIED) @@ -120,7 +121,9 @@ class ProjectService( fun getProjectCanvas(projectId: UUID): ProjectCanvasResponse { val userId = securityHolder.getUserId() - val project = projectRepository.findByIdAndDeletedAtIsNull(projectId) ?: throw BusinessException(ProjectError.PROJECT_NOT_FOUND) + val project = + projectRepository.findByIdAndDeletedAtIsNull(projectId) + ?: throw BusinessException(ProjectError.PROJECT_NOT_FOUND) if (project.userId != userId) { throw BusinessException(ProjectError.PROJECT_ACCESS_DENIED) @@ -129,7 +132,7 @@ class ProjectService( val services = serviceRepository .findAllByProjectIdAndDeletedAtIsNull(projectId) - .map { ServiceResponse.of(it) } + .map { ServiceResponse.from(it) } val connections = connectionRepository diff --git a/src/main/kotlin/kr/proxia/domain/resource/application/service/DomainService.kt b/src/main/kotlin/kr/proxia/domain/resource/application/service/DomainService.kt new file mode 100644 index 0000000..ed37d4a --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/resource/application/service/DomainService.kt @@ -0,0 +1,9 @@ +package kr.proxia.domain.resource.application.service + +import kr.proxia.domain.resource.domain.repository.DomainResourceRepository +import org.springframework.stereotype.Service + +@Service +class DomainService( + private val domainResourceRepository: DomainResourceRepository, +) diff --git a/src/main/kotlin/kr/proxia/domain/resource/domain/enums/SslStatus.kt b/src/main/kotlin/kr/proxia/domain/resource/domain/enums/SslStatus.kt new file mode 100644 index 0000000..eb3b3a8 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/resource/domain/enums/SslStatus.kt @@ -0,0 +1,8 @@ +package kr.proxia.domain.resource.domain.enums + +enum class SslStatus { + PENDING, + ACTIVE, + EXPIRED, + FAILED, +} diff --git a/src/main/kotlin/kr/proxia/domain/resource/domain/error/ResourceError.kt b/src/main/kotlin/kr/proxia/domain/resource/domain/error/ResourceError.kt new file mode 100644 index 0000000..a920015 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/resource/domain/error/ResourceError.kt @@ -0,0 +1,11 @@ +package kr.proxia.domain.resource.domain.error + +import kr.proxia.global.error.BaseError +import org.springframework.http.HttpStatus + +enum class ResourceError( + override val status: HttpStatus, + override val message: String, +) : BaseError { + RESOURCE_NOT_FOUND(HttpStatus.NOT_FOUND, "Resource not found"), +} diff --git a/src/main/kotlin/kr/proxia/domain/resource/presentation/controller/DomainController.kt b/src/main/kotlin/kr/proxia/domain/resource/presentation/controller/DomainController.kt new file mode 100644 index 0000000..692dbaa --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/resource/presentation/controller/DomainController.kt @@ -0,0 +1,7 @@ +package kr.proxia.domain.resource.presentation.controller + +import kr.proxia.domain.resource.presentation.docs.DomainDocs +import org.springframework.web.bind.annotation.RestController + +@RestController +class DomainController : DomainDocs diff --git a/src/main/kotlin/kr/proxia/domain/resource/presentation/docs/DomainDocs.kt b/src/main/kotlin/kr/proxia/domain/resource/presentation/docs/DomainDocs.kt new file mode 100644 index 0000000..3684d04 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/resource/presentation/docs/DomainDocs.kt @@ -0,0 +1,3 @@ +package kr.proxia.domain.resource.presentation.docs + +interface DomainDocs diff --git a/src/main/kotlin/kr/proxia/domain/resource/presentation/request/VerifyDomainRequest.kt b/src/main/kotlin/kr/proxia/domain/resource/presentation/request/VerifyDomainRequest.kt new file mode 100644 index 0000000..e660559 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/resource/presentation/request/VerifyDomainRequest.kt @@ -0,0 +1,5 @@ +package kr.proxia.domain.resource.presentation.request + +data class VerifyDomainRequest( + val token: String, +) diff --git a/src/main/kotlin/kr/proxia/domain/resource/presentation/response/VerificationTokenResponse.kt b/src/main/kotlin/kr/proxia/domain/resource/presentation/response/VerificationTokenResponse.kt new file mode 100644 index 0000000..64c5425 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/resource/presentation/response/VerificationTokenResponse.kt @@ -0,0 +1,7 @@ +package kr.proxia.domain.resource.presentation.response + +data class VerificationTokenResponse( + val token: String, + val txtRecord: String, + val verified: Boolean, +) diff --git a/src/main/kotlin/kr/proxia/domain/service/application/event/ServiceCreatedEvent.kt b/src/main/kotlin/kr/proxia/domain/service/application/event/ServiceCreatedEvent.kt new file mode 100644 index 0000000..e642d28 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/service/application/event/ServiceCreatedEvent.kt @@ -0,0 +1,7 @@ +package kr.proxia.domain.service.application.event + +import java.util.UUID + +data class ServiceCreatedEvent( + val serviceId: UUID, +) diff --git a/src/main/kotlin/kr/proxia/domain/service/application/event/ServiceEventHandler.kt b/src/main/kotlin/kr/proxia/domain/service/application/event/ServiceEventHandler.kt new file mode 100644 index 0000000..b118f03 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/service/application/event/ServiceEventHandler.kt @@ -0,0 +1,15 @@ +package kr.proxia.domain.service.application.event + +import kr.proxia.domain.deployment.application.service.DeploymentService +import org.springframework.stereotype.Component +import org.springframework.transaction.event.TransactionPhase +import org.springframework.transaction.event.TransactionalEventListener + +@Component +class ServiceEventHandler( + private val deploymentService: DeploymentService, +) { + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + fun handle(event: ServiceCreatedEvent) { + } +} diff --git a/src/main/kotlin/kr/proxia/domain/service/application/service/ServiceService.kt b/src/main/kotlin/kr/proxia/domain/service/application/service/ServiceService.kt index 2b70a64..a2070b6 100644 --- a/src/main/kotlin/kr/proxia/domain/service/application/service/ServiceService.kt +++ b/src/main/kotlin/kr/proxia/domain/service/application/service/ServiceService.kt @@ -12,6 +12,7 @@ import kr.proxia.domain.resource.domain.repository.DomainResourceRepository import kr.proxia.domain.resource.presentation.response.AppResourceResponse import kr.proxia.domain.resource.presentation.response.DatabaseResourceResponse import kr.proxia.domain.resource.presentation.response.DomainResourceResponse +import kr.proxia.domain.service.application.event.ServiceCreatedEvent import kr.proxia.domain.service.domain.entity.ServiceEntity import kr.proxia.domain.service.domain.enums.ServiceType import kr.proxia.domain.service.domain.error.ServiceError @@ -21,8 +22,9 @@ import kr.proxia.domain.service.presentation.request.UpdateServicePositionReques import kr.proxia.domain.service.presentation.request.UpdateServiceRequest import kr.proxia.domain.service.presentation.response.ServiceResponse import kr.proxia.global.error.BusinessException -import kr.proxia.global.security.encryption.EncryptionService +import kr.proxia.global.security.encryption.EncryptionUtil import kr.proxia.global.security.holder.SecurityHolder +import org.springframework.context.ApplicationEventPublisher import org.springframework.data.repository.findByIdOrNull import org.springframework.stereotype.Service import org.springframework.transaction.annotation.Transactional @@ -36,8 +38,9 @@ class ServiceService( private val appResourceRepository: AppResourceRepository, private val databaseResourceRepository: DatabaseResourceRepository, private val domainResourceRepository: DomainResourceRepository, - private val encryptionService: EncryptionService, + private val encryptionUtil: EncryptionUtil, private val securityHolder: SecurityHolder, + private val publisher: ApplicationEventPublisher, ) { @Transactional fun createService( @@ -66,6 +69,7 @@ class ServiceService( ), ).id } + ServiceType.DATABASE -> request.databaseResource?.let { databaseResourceRepository @@ -75,10 +79,11 @@ class ServiceService( type = it.type, database = it.database, username = it.username, - password = encryptionService.encrypt(it.password), + password = encryptionUtil.encrypt(it.password), ), ).id } + ServiceType.DOMAIN -> request.domainResource?.let { domainResourceRepository @@ -90,21 +95,25 @@ class ServiceService( ), ).id } + else -> null } - serviceRepository.save( - ServiceEntity( - projectId = projectId, - userId = userId, - name = request.name, - description = request.description, - type = request.type, - x = request.x, - y = request.y, - targetId = targetId, - ), - ) + val service = + serviceRepository.save( + ServiceEntity( + projectId = projectId, + userId = userId, + name = request.name, + description = request.description, + type = request.type, + x = request.x, + y = request.y, + targetId = targetId, + ), + ) + + publisher.publishEvent(ServiceCreatedEvent(service.id)) } fun getServices(projectId: UUID): List { @@ -113,9 +122,12 @@ class ServiceService( val services = serviceRepository.findAllByProjectIdAndDeletedAtIsNull(projectId) - val appResourceIds = services.filter { it.type == ServiceType.APP && it.targetId != null }.mapNotNull { it.targetId } - val databaseResourceIds = services.filter { it.type == ServiceType.DATABASE && it.targetId != null }.mapNotNull { it.targetId } - val domainResourceIds = services.filter { it.type == ServiceType.DOMAIN && it.targetId != null }.mapNotNull { it.targetId } + val appResourceIds = + services.filter { it.type == ServiceType.APP && it.targetId != null }.mapNotNull { it.targetId } + val databaseResourceIds = + services.filter { it.type == ServiceType.DATABASE && it.targetId != null }.mapNotNull { it.targetId } + val domainResourceIds = + services.filter { it.type == ServiceType.DOMAIN && it.targetId != null }.mapNotNull { it.targetId } val appResources: Map = if (appResourceIds.isNotEmpty()) { @@ -140,15 +152,18 @@ class ServiceService( return services.map { service -> val appResource = service.targetId?.let { appResources[it] }?.let { AppResourceResponse.of(it) } - val databaseResource = service.targetId?.let { databaseResources[it] }?.let { DatabaseResourceResponse.of(it) } + val databaseResource = + service.targetId?.let { databaseResources[it] }?.let { DatabaseResourceResponse.of(it) } val domainResource = service.targetId?.let { domainResources[it] }?.let { DomainResourceResponse.of(it) } - ServiceResponse.of(service, appResource, databaseResource, domainResource) + ServiceResponse.from(service, appResource, databaseResource, domainResource) } } fun getService(serviceId: UUID): ServiceResponse { val userId = securityHolder.getUserId() - val service = serviceRepository.findByIdAndDeletedAtIsNull(serviceId) ?: throw BusinessException(ServiceError.SERVICE_NOT_FOUND) + val service = + serviceRepository.findByIdAndDeletedAtIsNull(serviceId) + ?: throw BusinessException(ServiceError.SERVICE_NOT_FOUND) if (service.userId != userId) { throw BusinessException(ServiceError.SERVICE_ACCESS_DENIED) @@ -176,7 +191,7 @@ class ServiceService( null } - return ServiceResponse.of(service, appResource, databaseResource, domainResource) + return ServiceResponse.from(service, appResource, databaseResource, domainResource) } @Transactional @@ -185,7 +200,9 @@ class ServiceService( request: UpdateServiceRequest, ) { val userId = securityHolder.getUserId() - val service = serviceRepository.findByIdAndDeletedAtIsNull(serviceId) ?: throw BusinessException(ServiceError.SERVICE_NOT_FOUND) + val service = + serviceRepository.findByIdAndDeletedAtIsNull(serviceId) + ?: throw BusinessException(ServiceError.SERVICE_NOT_FOUND) if (service.userId != userId) { throw BusinessException(ServiceError.SERVICE_ACCESS_DENIED) @@ -225,6 +242,7 @@ class ServiceService( ).id } } ?: service.targetId + ServiceType.DATABASE -> request.databaseResource?.let { val currentTargetId = service.targetId @@ -233,7 +251,7 @@ class ServiceService( type = it.type, database = it.database, username = it.username, - password = encryptionService.encrypt(it.password), + password = encryptionUtil.encrypt(it.password), ) currentTargetId } else { @@ -244,11 +262,12 @@ class ServiceService( type = it.type, database = it.database, username = it.username, - password = encryptionService.encrypt(it.password), + password = encryptionUtil.encrypt(it.password), ), ).id } } ?: service.targetId + ServiceType.DOMAIN -> request.domainResource?.let { val currentTargetId = service.targetId @@ -269,6 +288,7 @@ class ServiceService( ).id } } ?: service.targetId + else -> service.targetId } @@ -286,7 +306,9 @@ class ServiceService( request: UpdateServicePositionRequest, ) { val userId = securityHolder.getUserId() - val service = serviceRepository.findByIdAndDeletedAtIsNull(serviceId) ?: throw BusinessException(ServiceError.SERVICE_NOT_FOUND) + val service = + serviceRepository.findByIdAndDeletedAtIsNull(serviceId) + ?: throw BusinessException(ServiceError.SERVICE_NOT_FOUND) if (service.userId != userId) { throw BusinessException(ServiceError.SERVICE_ACCESS_DENIED) @@ -301,7 +323,9 @@ class ServiceService( @Transactional fun deleteService(serviceId: UUID) { val userId = securityHolder.getUserId() - val service = serviceRepository.findByIdAndDeletedAtIsNull(serviceId) ?: throw BusinessException(ServiceError.SERVICE_NOT_FOUND) + val service = + serviceRepository.findByIdAndDeletedAtIsNull(serviceId) + ?: throw BusinessException(ServiceError.SERVICE_NOT_FOUND) if (service.userId != userId) { throw BusinessException(ServiceError.SERVICE_ACCESS_DENIED) @@ -328,7 +352,9 @@ class ServiceService( projectId: UUID, userId: UUID, ) { - val project = projectRepository.findByIdAndDeletedAtIsNull(projectId) ?: throw BusinessException(ProjectError.PROJECT_NOT_FOUND) + val project = + projectRepository.findByIdAndDeletedAtIsNull(projectId) + ?: throw BusinessException(ProjectError.PROJECT_NOT_FOUND) if (project.userId != userId) { throw BusinessException(ProjectError.PROJECT_ACCESS_DENIED) diff --git a/src/main/kotlin/kr/proxia/domain/service/domain/enums/AppFramework.kt b/src/main/kotlin/kr/proxia/domain/service/domain/enums/AppFramework.kt index 8509766..b864e71 100644 --- a/src/main/kotlin/kr/proxia/domain/service/domain/enums/AppFramework.kt +++ b/src/main/kotlin/kr/proxia/domain/service/domain/enums/AppFramework.kt @@ -2,13 +2,16 @@ package kr.proxia.domain.service.domain.enums enum class AppFramework { SPRING_BOOT, + NODE_JS, EXPRESS, NEST_JS, + PYTHON, DJANGO, FLASK, FASTAPI, LARAVEL, RAILS, + GO, GO_GIN, REACT, VUE, diff --git a/src/main/kotlin/kr/proxia/domain/service/domain/enums/DatabaseType.kt b/src/main/kotlin/kr/proxia/domain/service/domain/enums/DatabaseType.kt index 5be6384..4dd6455 100644 --- a/src/main/kotlin/kr/proxia/domain/service/domain/enums/DatabaseType.kt +++ b/src/main/kotlin/kr/proxia/domain/service/domain/enums/DatabaseType.kt @@ -1,13 +1,13 @@ package kr.proxia.domain.service.domain.enums enum class DatabaseType { - POSTGRESQL, // PostgreSQL - MYSQL, // MySQL - MARIADB, // MariaDB - MONGODB, // MongoDB - REDIS, // Redis - SQLITE, // SQLite - ORACLE, // Oracle - MSSQL, // Microsoft SQL Server - OTHER, // 기타 + POSTGRESQL, + MYSQL, + MARIADB, + MONGODB, + REDIS, + SQLITE, + ORACLE, + MSSQL, + OTHER, } diff --git a/src/main/kotlin/kr/proxia/domain/service/presentation/response/ServiceResponse.kt b/src/main/kotlin/kr/proxia/domain/service/presentation/response/ServiceResponse.kt index 161e1d1..8095e23 100644 --- a/src/main/kotlin/kr/proxia/domain/service/presentation/response/ServiceResponse.kt +++ b/src/main/kotlin/kr/proxia/domain/service/presentation/response/ServiceResponse.kt @@ -24,7 +24,7 @@ data class ServiceResponse( val updatedAt: LocalDateTime, ) { companion object { - fun of( + fun from( service: ServiceEntity, appResource: AppResourceResponse? = null, databaseResource: DatabaseResourceResponse? = null, diff --git a/src/main/kotlin/kr/proxia/domain/webhook/application/service/WebhookService.kt b/src/main/kotlin/kr/proxia/domain/webhook/application/service/WebhookService.kt new file mode 100644 index 0000000..499f604 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/webhook/application/service/WebhookService.kt @@ -0,0 +1,6 @@ +package kr.proxia.domain.webhook.application.service + +import org.springframework.stereotype.Service + +@Service +class WebhookService diff --git a/src/main/kotlin/kr/proxia/domain/webhook/domain/entity/WebhookEventEntity.kt b/src/main/kotlin/kr/proxia/domain/webhook/domain/entity/WebhookEventEntity.kt new file mode 100644 index 0000000..043f4c3 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/webhook/domain/entity/WebhookEventEntity.kt @@ -0,0 +1,37 @@ +package kr.proxia.domain.webhook.domain.entity + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Index +import jakarta.persistence.Table +import kr.proxia.global.jpa.common.BaseEntity + +@Entity +@Table( + name = "webhook_events", + indexes = [ + Index(name = "idx_webhook_events_service_deleted", columnList = "serviceId, deletedAt"), + Index(name = "idx_webhook_events_delivery_id", columnList = "deliveryId"), + ], +) +class WebhookEventEntity( + val serviceId: Long?, + val event: String, + @Column(columnDefinition = "TEXT") + val payload: String, + val deliveryId: String?, + success: Boolean = true, + errorMessage: String? = null, +) : BaseEntity() { + var success: Boolean = success + protected set + + @Column(columnDefinition = "TEXT") + var errorMessage: String? = errorMessage + protected set + + fun markAsFailed(errorMessage: String) { + this.success = false + this.errorMessage = errorMessage + } +} diff --git a/src/main/kotlin/kr/proxia/domain/webhook/domain/repository/WebhookEventRepository.kt b/src/main/kotlin/kr/proxia/domain/webhook/domain/repository/WebhookEventRepository.kt new file mode 100644 index 0000000..adf4e65 --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/webhook/domain/repository/WebhookEventRepository.kt @@ -0,0 +1,7 @@ +package kr.proxia.domain.webhook.domain.repository + +import kr.proxia.domain.webhook.domain.entity.WebhookEventEntity +import org.springframework.data.jpa.repository.JpaRepository +import java.util.UUID + +interface WebhookEventRepository : JpaRepository diff --git a/src/main/kotlin/kr/proxia/domain/webhook/presentation/controller/WebhookController.kt b/src/main/kotlin/kr/proxia/domain/webhook/presentation/controller/WebhookController.kt new file mode 100644 index 0000000..819650c --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/webhook/presentation/controller/WebhookController.kt @@ -0,0 +1,7 @@ +package kr.proxia.domain.webhook.presentation.controller + +import kr.proxia.domain.webhook.presentation.docs.WebhookDocs +import org.springframework.web.bind.annotation.RestController + +@RestController +class WebhookController : WebhookDocs diff --git a/src/main/kotlin/kr/proxia/domain/webhook/presentation/docs/WebhookDocs.kt b/src/main/kotlin/kr/proxia/domain/webhook/presentation/docs/WebhookDocs.kt new file mode 100644 index 0000000..61028bc --- /dev/null +++ b/src/main/kotlin/kr/proxia/domain/webhook/presentation/docs/WebhookDocs.kt @@ -0,0 +1,3 @@ +package kr.proxia.domain.webhook.presentation.docs + +interface WebhookDocs diff --git a/src/main/kotlin/kr/proxia/global/async/config/AsyncConfig.kt b/src/main/kotlin/kr/proxia/global/async/config/AsyncConfig.kt new file mode 100644 index 0000000..cca592a --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/async/config/AsyncConfig.kt @@ -0,0 +1,8 @@ +package kr.proxia.global.async.config + +import org.springframework.context.annotation.Configuration +import org.springframework.scheduling.annotation.EnableAsync + +@Configuration +@EnableAsync +class AsyncConfig diff --git a/src/main/kotlin/kr/proxia/global/container/ContainerOrchestrator.kt b/src/main/kotlin/kr/proxia/global/container/ContainerOrchestrator.kt new file mode 100644 index 0000000..e68eeb9 --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/container/ContainerOrchestrator.kt @@ -0,0 +1,40 @@ +package kr.proxia.global.container + +interface ContainerOrchestrator { + fun createContainer( + endpoint: String, + spec: ContainerSpec, + ): String + + fun startContainer( + endpoint: String, + containerId: String, + ) + + fun stopContainer( + endpoint: String, + containerId: String, + ) + + fun deleteContainer( + endpoint: String, + containerId: String, + ) + + fun getLogs( + endpoint: String, + containerId: String, + tail: Int = 100, + ): String + + fun isRunning( + endpoint: String, + containerId: String, + ): Boolean + + fun getAssignedPort( + endpoint: String, + containerId: String, + internalPort: Int, + ): Int? +} diff --git a/src/main/kotlin/kr/proxia/global/container/ContainerRuntime.kt b/src/main/kotlin/kr/proxia/global/container/ContainerRuntime.kt new file mode 100644 index 0000000..87d859a --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/container/ContainerRuntime.kt @@ -0,0 +1,8 @@ +package kr.proxia.global.container + +interface ContainerRuntime { + fun exec( + containerName: String, + command: List, + ) +} diff --git a/src/main/kotlin/kr/proxia/global/container/ContainerSpec.kt b/src/main/kotlin/kr/proxia/global/container/ContainerSpec.kt new file mode 100644 index 0000000..e3b5bc1 --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/container/ContainerSpec.kt @@ -0,0 +1,20 @@ +package kr.proxia.global.container + +data class ContainerSpec( + val name: String, + val image: String, + val ports: List = emptyList(), + val env: Map = emptyMap(), + val volumes: List = emptyList(), + val command: List? = null, +) { + data class PortMapping( + val internal: Int, + val host: Int? = null, + ) + + data class VolumeMapping( + val hostPath: String, + val containerPath: String, + ) +} diff --git a/src/main/kotlin/kr/proxia/global/container/DockerfileGenerator.kt b/src/main/kotlin/kr/proxia/global/container/DockerfileGenerator.kt new file mode 100644 index 0000000..3684c06 --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/container/DockerfileGenerator.kt @@ -0,0 +1,98 @@ +package kr.proxia.global.container + +import org.springframework.stereotype.Component + +@Component +class DockerfileGenerator { + fun generateSpringBootDockerfile( + buildCommand: String?, + startCommand: String?, + ): String { + val buildCommand = buildCommand ?: "./gradlew build" + val startCommand = startCommand ?: "java -jar build/libs/*.jar" + + return """ + FROM eclipse-temurin:21-jdk AS builder + WORKDIR /app + COPY . . + RUN $buildCommand + + FROM eclipse-temurin:21-jre + WORKDIR /app + COPY --from=builder /app/build/libs/*.jar app.jar + EXPOSE 8080 + CMD $startCommand + """.trimIndent() + } + + fun generateNodeJsDockerfile( + installCommand: String?, + buildCommand: String?, + startCommand: String?, + ): String { + val installCommand = installCommand ?: "npm install" + val buildCommand = buildCommand ?: "npm run build" + val startCommand = startCommand ?: "npm start" + + return """ + FROM node:20-alpine + WORKDIR /app + COPY package*.json ./ + RUN $installCommand + COPY . . + RUN $buildCommand + EXPOSE 8080 + CMD $startCommand + """.trimIndent() + } + + fun generatePythonDockerfile( + installCommand: String?, + startCommand: String?, + ): String { + val installCommand = installCommand ?: "pip install -r requirements.txt" + val startCommand = startCommand ?: "python app.py" + + return """ + FROM python:3.11-slim + WORKDIR /app + COPY requirements.txt . + RUN $installCommand + COPY . . + EXPOSE 8080 + CMD $startCommand + """.trimIndent() + } + + fun generateGoDockerfile( + buildCommand: String?, + startCommand: String?, + ): String { + val buildCommand = buildCommand ?: "go build -o app" + val startCommand = startCommand ?: "./app" + + return """ + FROM golang:1.21-alpine AS builder + WORKDIR /app + COPY . . + RUN $buildCommand + FROM alpine:latest + WORKDIR /app + COPY --from=builder /app/app . + EXPOSE 8080 + CMD $startCommand + """.trimIndent() + } + + fun generateGenericDockerfile(startCommand: String?): String { + val startCommand = startCommand ?: "echo 'No start command provided' && exit 1" + + return """ + FROM ubuntu:22.04 + WORKDIR /app + COPY . . + EXPOSE 8080 + CMD $startCommand + """.trimIndent() + } +} diff --git a/src/main/kotlin/kr/proxia/global/container/docker/factory/DockerClientFactory.kt b/src/main/kotlin/kr/proxia/global/container/docker/factory/DockerClientFactory.kt new file mode 100644 index 0000000..186ade4 --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/container/docker/factory/DockerClientFactory.kt @@ -0,0 +1,40 @@ +package kr.proxia.global.container.docker.factory + +import com.github.dockerjava.api.DockerClient +import com.github.dockerjava.core.DefaultDockerClientConfig +import com.github.dockerjava.core.DockerClientImpl +import com.github.dockerjava.httpclient5.ApacheDockerHttpClient +import org.springframework.stereotype.Component +import java.time.Duration +import java.util.concurrent.ConcurrentHashMap + +@Component +class DockerClientFactory { + private val clients = ConcurrentHashMap() + + fun getClient(endpoint: String): DockerClient = + clients.computeIfAbsent(endpoint) { + createClient(endpoint) + } + + private fun createClient(endpoint: String): DockerClient { + val config = + DefaultDockerClientConfig + .createDefaultConfigBuilder() + .withDockerHost(endpoint) + .withDockerTlsVerify(false) + .build() + + val httpClient = + ApacheDockerHttpClient + .Builder() + .dockerHost(config.dockerHost) + .sslConfig(config.sslConfig) + .maxConnections(20) + .connectionTimeout(Duration.ofSeconds(20)) + .responseTimeout(Duration.ofSeconds(30)) + .build() + + return DockerClientImpl.getInstance(config, httpClient) + } +} diff --git a/src/main/kotlin/kr/proxia/global/container/docker/orchestrator/DockerOrchestrator.kt b/src/main/kotlin/kr/proxia/global/container/docker/orchestrator/DockerOrchestrator.kt new file mode 100644 index 0000000..7860db5 --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/container/docker/orchestrator/DockerOrchestrator.kt @@ -0,0 +1,156 @@ +package kr.proxia.global.container.docker.orchestrator + +import com.github.dockerjava.api.async.ResultCallback +import com.github.dockerjava.api.model.Bind +import com.github.dockerjava.api.model.ExposedPort +import com.github.dockerjava.api.model.Frame +import com.github.dockerjava.api.model.HostConfig +import com.github.dockerjava.api.model.Ports +import com.github.dockerjava.api.model.Volume +import io.github.oshai.kotlinlogging.KotlinLogging +import kr.proxia.global.container.ContainerOrchestrator +import kr.proxia.global.container.ContainerSpec +import kr.proxia.global.container.docker.factory.DockerClientFactory +import org.springframework.stereotype.Component + +private val logger = KotlinLogging.logger {} + +@Component +class DockerOrchestrator( + private val dockerClientFactory: DockerClientFactory, +) : ContainerOrchestrator { + override fun createContainer( + endpoint: String, + spec: ContainerSpec, + ): String { + val client = dockerClientFactory.getClient(endpoint) + + val exposedPorts = spec.ports.map { ExposedPort.tcp(it.internal) } + + val portBindings = + Ports().apply { + spec.ports.forEach { + val exposed = ExposedPort.tcp(it.internal) + val binding = + it.host?.let { hostPort -> + Ports.Binding.bindPort(hostPort) + } ?: Ports.Binding.empty() + + bind(exposed, binding) + } + } + + val volumeBinds = + spec.volumes.map { + Bind(it.hostPath, Volume(it.containerPath)) + } + + val hostConfig = + HostConfig + .newHostConfig() + .withPortBindings(portBindings) + .withBinds(volumeBinds) + .apply { + spec.network?.let { withNetworkMode(it) } + } + + val cmd = + client + .createContainerCmd(spec.image) + .withName(spec.name) + .withExposedPorts(exposedPorts) + .withHostConfig(hostConfig) + .withEnv(spec.env.map { "${it.key}=${it.value}" }) + + spec.command?.let { cmd.withCmd(it) } + + val container = cmd.exec() + return container.id + } + + override fun startContainer( + endpoint: String, + containerId: String, + ) { + dockerClientFactory + .getClient(endpoint) + .startContainerCmd(containerId) + .exec() + } + + override fun stopContainer( + endpoint: String, + containerId: String, + ) { + dockerClientFactory + .getClient(endpoint) + .stopContainerCmd(containerId) + .exec() + } + + override fun deleteContainer( + endpoint: String, + containerId: String, + ) { + dockerClientFactory + .getClient(endpoint) + .removeContainerCmd(containerId) + .withForce(true) + .exec() + } + + override fun getLogs( + endpoint: String, + containerId: String, + tail: Int, + ): String { + val client = dockerClientFactory.getClient(endpoint) + val buffer = StringBuilder() + + client + .logContainerCmd(containerId) + .withStdOut(true) + .withStdErr(true) + .withTail(tail) + .exec( + object : ResultCallback.Adapter() { + override fun onNext(frame: Frame) { + buffer.append(String(frame.payload)) + } + }, + ).awaitCompletion() + + return buffer.toString() + } + + override fun isRunning( + endpoint: String, + containerId: String, + ): Boolean = + dockerClientFactory + .getClient(endpoint) + .inspectContainerCmd(containerId) + .exec() + .state + .running ?: false + + override fun getAssignedPort( + endpoint: String, + containerId: String, + internalPort: Int, + ): Int? { + val container = + dockerClientFactory + .getClient(endpoint) + .inspectContainerCmd(containerId) + .exec() + + val bindings = container.networkSettings.ports.bindings + val exposed = ExposedPort.tcp(internalPort) + + return bindings[exposed] + ?.firstOrNull() + ?.hostPortSpec + ?.toIntOrNull() + } +} diff --git a/src/main/kotlin/kr/proxia/global/container/docker/properties/DockerProperties.kt b/src/main/kotlin/kr/proxia/global/container/docker/properties/DockerProperties.kt new file mode 100644 index 0000000..b09a35a --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/container/docker/properties/DockerProperties.kt @@ -0,0 +1,29 @@ +package kr.proxia.global.container.docker.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "container.docker") +data class DockerProperties( + val host: String = "unix:///var/run/docker.sock", + val tls: TlsProperties = TlsProperties(), + val network: NetworkProperties = NetworkProperties(), + val registry: RegistryProperties = RegistryProperties(), +) { + data class TlsProperties( + val enabled: Boolean = false, + val verify: Boolean = true, + val certPath: String? = null, + val caCertPath: String? = null, + val keyPath: String? = null, + ) + + data class NetworkProperties( + val name: String = "proxia-network", + ) + + data class RegistryProperties( + val url: String? = null, + val username: String? = null, + val password: String? = null, + ) +} diff --git a/src/main/kotlin/kr/proxia/global/container/docker/runtime/DockerRuntime.kt b/src/main/kotlin/kr/proxia/global/container/docker/runtime/DockerRuntime.kt new file mode 100644 index 0000000..2987e2e --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/container/docker/runtime/DockerRuntime.kt @@ -0,0 +1,28 @@ +package kr.proxia.global.container.docker.runtime + +import com.github.dockerjava.api.DockerClient +import com.github.dockerjava.api.async.ResultCallback +import com.github.dockerjava.api.model.Frame +import kr.proxia.global.container.ContainerRuntime +import org.springframework.stereotype.Component + +@Component +class DockerRuntime( + private val dockerClient: DockerClient, +) : ContainerRuntime { + override fun exec( + containerName: String, + command: List, + ) { + val exec = + dockerClient + .execCreateCmd(containerName) + .withCmd(*command.toTypedArray()) + .exec() + + dockerClient + .execStartCmd(exec.id) + .exec(object : ResultCallback.Adapter() {}) + .awaitCompletion() + } +} diff --git a/src/main/kotlin/kr/proxia/global/container/enums/ContainerRuntimeType.kt b/src/main/kotlin/kr/proxia/global/container/enums/ContainerRuntimeType.kt new file mode 100644 index 0000000..18e171d --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/container/enums/ContainerRuntimeType.kt @@ -0,0 +1,5 @@ +package kr.proxia.global.container.enums + +enum class ContainerRuntimeType { + DOCKER, +} diff --git a/src/main/kotlin/kr/proxia/global/image/ImageBuilder.kt b/src/main/kotlin/kr/proxia/global/image/ImageBuilder.kt new file mode 100644 index 0000000..c316a7e --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/image/ImageBuilder.kt @@ -0,0 +1,13 @@ +package kr.proxia.global.image + +import java.io.File + +interface ImageBuilder { + fun buildImage( + endpoint: String, + contextDir: File, + dockerfile: File, + imageName: String, + tags: Set = setOf("latest"), + ): String +} diff --git a/src/main/kotlin/kr/proxia/global/image/docker/DockerImageBuilder.kt b/src/main/kotlin/kr/proxia/global/image/docker/DockerImageBuilder.kt new file mode 100644 index 0000000..721dd2f --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/image/docker/DockerImageBuilder.kt @@ -0,0 +1,44 @@ +package kr.proxia.global.image.docker + +import com.github.dockerjava.api.command.BuildImageResultCallback +import io.github.oshai.kotlinlogging.KotlinLogging +import kr.proxia.global.container.docker.factory.DockerClientFactory +import kr.proxia.global.image.ImageBuilder +import org.springframework.stereotype.Component +import java.io.File + +private val logger = KotlinLogging.logger {} + +@Component +class DockerImageBuilder( + private val dockerClientFactory: DockerClientFactory, +) : ImageBuilder { + override fun buildImage( + endpoint: String, + contextDir: File, + dockerfile: File, + imageName: String, + tags: Set, + ): String { + try { + val client = dockerClientFactory.getClient(endpoint) + + val fullTags = tags.map { "$imageName:$it" }.toSet() + + val imageId = + client + .buildImageCmd() + .withDockerfile(dockerfile) + .withBaseDirectory(contextDir) + .withTags(fullTags) + .exec(BuildImageResultCallback()) + .awaitImageId() + + logger.info { "Built Docker image: $imageName with ID: $imageId" } + return imageId + } catch (e: Exception) { + logger.error(e) { "Failed to build image: $imageName" } + throw e + } + } +} diff --git a/src/main/kotlin/kr/proxia/global/reverseproxy/ReverseProxyAdapter.kt b/src/main/kotlin/kr/proxia/global/reverseproxy/ReverseProxyAdapter.kt new file mode 100644 index 0000000..4410fdc --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/reverseproxy/ReverseProxyAdapter.kt @@ -0,0 +1,13 @@ +package kr.proxia.global.reverseproxy + +interface ReverseProxyAdapter { + val isEnabled: Boolean + + fun createMapping( + domain: String, + nodeEndpoint: String, + hostPort: Int, + ) + + fun deleteMapping(domain: String) +} diff --git a/src/main/kotlin/kr/proxia/global/reverseproxy/cloudflare/adapter/CloudflareAdapter.kt b/src/main/kotlin/kr/proxia/global/reverseproxy/cloudflare/adapter/CloudflareAdapter.kt new file mode 100644 index 0000000..2666721 --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/reverseproxy/cloudflare/adapter/CloudflareAdapter.kt @@ -0,0 +1,172 @@ +package kr.proxia.global.reverseproxy.cloudflare.adapter + +import io.github.oshai.kotlinlogging.KotlinLogging +import kr.proxia.global.reverseproxy.ReverseProxyAdapter +import kr.proxia.global.reverseproxy.cloudflare.properties.CloudflareProperties +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component +import org.springframework.web.reactive.function.client.WebClient +import org.springframework.web.reactive.function.client.bodyToMono +import java.io.File + +private val logger = KotlinLogging.logger {} + +@Component +@ConditionalOnProperty( + prefix = "reverse-proxy", + name = ["type"], + havingValue = "cloudflare_tunnel", +) +class CloudflareAdapter( + private val cloudflareProperties: CloudflareProperties, + private val webClient: WebClient, +) : ReverseProxyAdapter { + private val cfApiBase = "https://api.cloudflare.com/client/v4" + + override val isEnabled: Boolean + get() = cloudflareProperties.enabled + + override fun createMapping( + domain: String, + nodeEndpoint: String, + hostPort: Int, + ) { + if (!isEnabled) { + logger.info { "Cloudflare disabled: skip mapping" } + return + } + + val tunnelId = + cloudflareProperties.tunnel.id + ?: error("Cloudflare tunnel ID is missing") + + val apiToken = + cloudflareProperties.apiToken + ?: error("Cloudflare API token is missing") + + try { + val configPath = File(cloudflareProperties.tunnel.configPath, "config.yml") + updateTunnelConfig(configPath, domain, nodeEndpoint, hostPort) + + createDnsRecord( + domain = domain, + tunnelId = tunnelId, + apiToken = apiToken, + zoneId = cloudflareProperties.zoneId, + ) + + logger.info { "Cloudflare mapping created: $domain: http://$nodeEndpoint:$hostPort" } + } catch (e: Exception) { + logger.error(e) { "Failed creating mapping for $domain" } + + throw e + } + } + + override fun deleteMapping(domain: String) { + if (!isEnabled) return + + try { + val configPath = File(cloudflareProperties.tunnel.configPath, "config.yml") + removeTunnelConfig(configPath, domain) + + logger.info { "Cloudflare mapping deleted: $domain" } + } catch (e: Exception) { + logger.error(e) { "Failed deleting mapping for $domain" } + } + } + + private fun updateTunnelConfig( + configFile: File, + domain: String, + nodeEndpoint: String, + hostPort: Int, + ) { + val existing = readFile(configFile) + + val newIngress = + """ + - hostname: $domain + service: http://$nodeEndpoint:$hostPort + """.trimIndent() + + val updatedYaml = + if (existing.contains("ingress:")) { + val lines = existing.lines().toMutableList() + val catchAllIndex = lines.indexOfFirst { it.contains("http_status:404") } + + if (catchAllIndex != -1) { + lines.add(catchAllIndex, newIngress) + } else { + lines.add(newIngress) + lines.add(" - service: http_status:404") + } + + lines.joinToString("\n") + } else { + """ + tunnel: ${cloudflareProperties.tunnel.id} + credentials-file: ${cloudflareProperties.tunnel.configPath}/credentials.json + + ingress: + $newIngress + - service: http_status:404 + """.trimIndent() + } + + configFile.parentFile?.mkdirs() + configFile.writeText(updatedYaml) + } + + private fun removeTunnelConfig( + configFile: File, + domain: String, + ) { + if (!configFile.exists()) return + + val lines = configFile.readLines().toMutableList() + val index = lines.indexOfFirst { it.contains("hostname: $domain") } + + if (index != -1) { + lines.removeAt(index) + if (index < lines.size && lines[index].contains("service:")) { + lines.removeAt(index) + } + } + + configFile.writeText(lines.joinToString("\n")) + } + + private fun readFile(file: File): String = if (file.exists()) file.readText() else "" + + private fun createDnsRecord( + domain: String, + tunnelId: String, + apiToken: String, + zoneId: String, + ) { + val body = + mapOf( + "type" to "CNAME", + "name" to domain, + "content" to "$tunnelId.cfargotunnel.com", + "ttl" to 1, + "proxied" to true, + ) + + try { + webClient + .post() + .uri("$cfApiBase/zones/$zoneId/dns_records") + .header("Authorization", "Bearer $apiToken") + .bodyValue(body) + .retrieve() + .bodyToMono() + .block() + + logger.info { "DNS record created: $domain (CNAME β†’ tunnel)" } + } catch (e: Exception) { + logger.warn(e) { "DNS record exists or failed for $domain (ignored)" } + } + } +} diff --git a/src/main/kotlin/kr/proxia/global/reverseproxy/cloudflare/properties/CloudflareProperties.kt b/src/main/kotlin/kr/proxia/global/reverseproxy/cloudflare/properties/CloudflareProperties.kt new file mode 100644 index 0000000..55d2f66 --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/reverseproxy/cloudflare/properties/CloudflareProperties.kt @@ -0,0 +1,19 @@ +package kr.proxia.global.reverseproxy.cloudflare.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "reverse-proxy.cloudflare") +data class CloudflareProperties( + val enabled: Boolean = false, + val apiToken: String? = null, + val baseDomain: String = "proxia.kr", + val zoneId: String, + val tunnel: TunnelProperties = TunnelProperties(), +) { + data class TunnelProperties( + val id: String? = null, + val name: String = "proxia-tunnel", + val configPath: String = "/etc/cloudflared", + val credentialsFile: String = "/etc/cloudflared/credentials.json", + ) +} diff --git a/src/main/kotlin/kr/proxia/global/reverseproxy/enums/ReverseProxyType.kt b/src/main/kotlin/kr/proxia/global/reverseproxy/enums/ReverseProxyType.kt new file mode 100644 index 0000000..a770f6c --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/reverseproxy/enums/ReverseProxyType.kt @@ -0,0 +1,6 @@ +package kr.proxia.global.reverseproxy.enums + +enum class ReverseProxyType { + NGINX, + CLOUDFLARE_TUNNEL, +} diff --git a/src/main/kotlin/kr/proxia/global/reverseproxy/nginx/adapter/NginxAdapter.kt b/src/main/kotlin/kr/proxia/global/reverseproxy/nginx/adapter/NginxAdapter.kt new file mode 100644 index 0000000..20a635c --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/reverseproxy/nginx/adapter/NginxAdapter.kt @@ -0,0 +1,127 @@ +package kr.proxia.global.reverseproxy.nginx.adapter + +import io.github.oshai.kotlinlogging.KotlinLogging +import kr.proxia.global.reverseproxy.ReverseProxyAdapter +import kr.proxia.global.reverseproxy.nginx.properties.NginxProperties +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.stereotype.Component +import java.io.File + +private val logger = KotlinLogging.logger {} + +@Component +@ConditionalOnProperty(prefix = "reverse-proxy", name = ["type"], havingValue = "nginx", matchIfMissing = true) +class NginxAdapter( + private val nginxProperties: NginxProperties, +) : ReverseProxyAdapter { + override val isEnabled: Boolean + get() = nginxProperties.enabled + + override fun createMapping( + domain: String, + nodeEndpoint: String, + hostPort: Int, + ) { + if (!nginxProperties.enabled) { + logger.info { "Nginx disabled, skipping mapping setup" } + return + } + + val config = generateConfig(domain, nodeEndpoint, hostPort) + val configFile = File("${nginxProperties.configDir}/$domain.conf") + + try { + configFile.parentFile?.mkdirs() + configFile.writeText(config) + + logger.info { "Created nginx mapping: $domain β†’ http://$nodeEndpoint:$hostPort" } + + reloadNginx() + } catch (e: Exception) { + logger.error(e) { "Failed to create nginx config for $domain" } + throw e + } + } + + override fun deleteMapping(domain: String) { + val configFile = File("${nginxProperties.configDir}/$domain.conf") + + try { + if (configFile.exists()) { + configFile.delete() + logger.info { "Deleted nginx config for $domain" } + reloadNginx() + } + } catch (e: Exception) { + logger.error(e) { "Failed to delete nginx config for $domain" } + } + } + + private fun generateConfig( + domain: String, + nodeEndpoint: String, + hostPort: Int, + ): String { + val sslBlock = + if (nginxProperties.ssl.enabled) { + """ + listen 443 ssl http2; + ssl_certificate ${nginxProperties.ssl.certDir}/$domain/fullchain.pem; + ssl_certificate_key ${nginxProperties.ssl.certDir}/$domain/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + """.trimIndent() + } else { + "listen 80;" + } + + val redirectHttp = + if (nginxProperties.ssl.enabled) { + """ + server { + listen 80; + server_name $domain; + return 301 https://$domain${'$'}request_uri; + } + """.trimIndent() + } else { + "" + } + + return """ + $redirectHttp + server { + $sslBlock + server_name $domain; + + location / { + proxy_pass http://$nodeEndpoint:$hostPort; + + proxy_set_header Host ${'$'}host; + proxy_set_header X-Real-IP ${'$'}remote_addr; + proxy_set_header X-Forwarded-For ${'$'}proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto ${'$'}scheme; + + proxy_http_version 1.1; + proxy_set_header Upgrade ${'$'}http_upgrade; + proxy_set_header Connection "upgrade"; + } + } + """.trimIndent() + } + + private fun reloadNginx() { + try { + val process = + ProcessBuilder("nginx", "-s", "reload") + .redirectErrorStream(true) + .start() + + process.waitFor() + + logger.info { "Reloaded system nginx successfully" } + } catch (e: Exception) { + logger.warn(e) { "Failed to reload nginx. Config will apply on next restart." } + } + } +} diff --git a/src/main/kotlin/kr/proxia/global/reverseproxy/nginx/properties/NginxProperties.kt b/src/main/kotlin/kr/proxia/global/reverseproxy/nginx/properties/NginxProperties.kt new file mode 100644 index 0000000..1aa3afc --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/reverseproxy/nginx/properties/NginxProperties.kt @@ -0,0 +1,15 @@ +package kr.proxia.global.reverseproxy.nginx.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "reverse-proxy.nginx") +data class NginxProperties( + val enabled: Boolean = true, + val configDir: String = "/etc/nginx/conf.d", + val ssl: SslProperties = SslProperties(), +) { + data class SslProperties( + val enabled: Boolean = false, + val certDir: String = "/etc/letsencrypt/live", + ) +} diff --git a/src/main/kotlin/kr/proxia/global/security/encryption/EncryptionService.kt b/src/main/kotlin/kr/proxia/global/security/encryption/EncryptionUtil.kt similarity index 86% rename from src/main/kotlin/kr/proxia/global/security/encryption/EncryptionService.kt rename to src/main/kotlin/kr/proxia/global/security/encryption/EncryptionUtil.kt index 9afc0a4..f390a7a 100644 --- a/src/main/kotlin/kr/proxia/global/security/encryption/EncryptionService.kt +++ b/src/main/kotlin/kr/proxia/global/security/encryption/EncryptionUtil.kt @@ -7,28 +7,36 @@ import javax.crypto.Cipher import javax.crypto.spec.SecretKeySpec @Component -class EncryptionService( - @Value("\${encryption.secret-key}") private val secretKey: String, +class EncryptionUtil( + @param:Value("\${encryption.secret-key}") private val secretKey: String, ) { private val algorithm = "AES" - private val transformation = "AES/ECB/PKCS5Padding" + private val transformation = "$algorithm/ECB/PKCS5Padding" fun encrypt(value: String?): String? { if (value == null) return null + val cipher = Cipher.getInstance(transformation) val keySpec = SecretKeySpec(secretKey.toByteArray().copyOf(16), algorithm) + cipher.init(Cipher.ENCRYPT_MODE, keySpec) + val encrypted = cipher.doFinal(value.toByteArray()) + return Base64.getEncoder().encodeToString(encrypted) } fun decrypt(encryptedValue: String?): String? { if (encryptedValue == null) return null + val cipher = Cipher.getInstance(transformation) val keySpec = SecretKeySpec(secretKey.toByteArray().copyOf(16), algorithm) + cipher.init(Cipher.DECRYPT_MODE, keySpec) + val decoded = Base64.getDecoder().decode(encryptedValue) val decrypted = cipher.doFinal(decoded) + return String(decrypted) } } diff --git a/src/main/kotlin/kr/proxia/global/webhook/properties/WebhookProperties.kt b/src/main/kotlin/kr/proxia/global/webhook/properties/WebhookProperties.kt new file mode 100644 index 0000000..aadbb93 --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/webhook/properties/WebhookProperties.kt @@ -0,0 +1,12 @@ +package kr.proxia.global.webhook.properties + +import org.springframework.boot.context.properties.ConfigurationProperties + +@ConfigurationProperties(prefix = "webhook") +data class WebhookProperties( + val github: GithubWebhookProperties = GithubWebhookProperties(), +) { + data class GithubWebhookProperties( + val secret: String? = null, + ) +} diff --git a/src/main/kotlin/kr/proxia/global/webhook/util/GithubWebhookValidator.kt b/src/main/kotlin/kr/proxia/global/webhook/util/GithubWebhookValidator.kt new file mode 100644 index 0000000..93c933a --- /dev/null +++ b/src/main/kotlin/kr/proxia/global/webhook/util/GithubWebhookValidator.kt @@ -0,0 +1,56 @@ +package kr.proxia.global.webhook.util + +import io.github.oshai.kotlinlogging.KotlinLogging +import javax.crypto.Mac +import javax.crypto.spec.SecretKeySpec + +private val logger = KotlinLogging.logger {} + +object GithubWebhookValidator { + fun validateSignature( + payload: String, + signature: String, + secret: String, + ): Boolean { + if (secret.isBlank()) { + logger.warn { "GitHub webhook secret is not configured, skipping signature validation" } + return true + } + + try { + val expectedSignature = calculateSignature(payload, secret) + return secureCompare(signature, expectedSignature) + } catch (e: Exception) { + logger.error(e) { "Failed to validate webhook signature" } + return false + } + } + + private fun calculateSignature( + payload: String, + secret: String, + ): String { + val hmacSha256 = Mac.getInstance("HmacSHA256") + val secretKey = SecretKeySpec(secret.toByteArray(), "HmacSHA256") + hmacSha256.init(secretKey) + + val hash = hmacSha256.doFinal(payload.toByteArray()) + return "sha256=" + hash.joinToString("") { "%02x".format(it) } + } + + private fun secureCompare( + a: String, + b: String, + ): Boolean { + if (a.length != b.length) { + return false + } + + var result = 0 + for (i in a.indices) { + result = result or (a[i].code xor b[i].code) + } + + return result == 0 + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9f2a9c1..7d21da3 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -24,6 +24,42 @@ git: client-id: ${GIT_INTEGRATION_GITHUB_CLIENT_ID} client-secret: ${GIT_INTEGRATION_GITHUB_CLIENT_SECRET} +reverse-proxy: + type: ${REVERSE_PROXY_TYPE:nginx} + nginx: + enabled: ${NGINX_ENABLED:true} + config-dir: ${NGINX_CONFIG_DIR:/etc/nginx/conf.d} + ssl: + enabled: ${NGINX_SSL_ENABLED:false} + cert-dir: ${NGINX_SSL_CERT_DIR:/etc/letsencrypt/live} + cloudflare: + enabled: ${CLOUDFLARE_ENABLED:false} + api-token: ${CLOUDFLARE_API_TOKEN:} + base-domain: ${BASE_DOMAIN:proxia.kr} + zone-id: ${CLOUDFLARE_ZONE_ID:} + tunnel: + id: ${CLOUDFLARE_TUNNEL_ID:} + name: ${CLOUDFLARE_TUNNEL_NAME:proxia-tunnel} + config-path: ${CLOUDFLARE_TUNNEL_CONFIG_PATH:/etc/cloudflared} + +container: + docker: + host: ${DOCKER_HOST:unix:///var/run/docker.sock} + tls: + enabled: ${DOCKER_TLS_ENABLED:false} + verify: ${DOCKER_TLS_VERIFY:true} + cert-path: ${DOCKER_CERT_PATH:} + network: + name: ${DOCKER_NETWORK_NAME:proxia-network} + registry: + url: ${DOCKER_REGISTRY_URL:} + username: ${DOCKER_REGISTRY_USERNAME:} + password: ${DOCKER_REGISTRY_PASSWORD:} + +webhook: + github: + secret: ${GITHUB_WEBHOOK_SECRET:} + --- spring.config.activate.on-profile: local diff --git a/src/test/kotlin/kr/proxia/domain/connection/application/service/ConnectionServiceTest.kt b/src/test/kotlin/kr/proxia/domain/connection/application/service/ConnectionServiceTest.kt index 6f2078d..c239ead 100644 --- a/src/test/kotlin/kr/proxia/domain/connection/application/service/ConnectionServiceTest.kt +++ b/src/test/kotlin/kr/proxia/domain/connection/application/service/ConnectionServiceTest.kt @@ -21,7 +21,6 @@ import kr.proxia.domain.service.domain.entity.ServiceEntity import kr.proxia.domain.service.domain.repository.ServiceRepository import kr.proxia.global.error.BusinessException import kr.proxia.global.security.holder.SecurityHolder -import org.springframework.data.repository.findByIdOrNull import java.util.UUID class ConnectionServiceTest : diff --git a/src/test/kotlin/kr/proxia/domain/service/application/service/ServiceServiceTest.kt b/src/test/kotlin/kr/proxia/domain/service/application/service/ServiceServiceTest.kt index 4396fd1..cae8392 100644 --- a/src/test/kotlin/kr/proxia/domain/service/application/service/ServiceServiceTest.kt +++ b/src/test/kotlin/kr/proxia/domain/service/application/service/ServiceServiceTest.kt @@ -16,7 +16,6 @@ import kr.proxia.domain.project.domain.repository.ProjectRepository import kr.proxia.domain.resource.domain.repository.AppResourceRepository import kr.proxia.domain.resource.domain.repository.DatabaseResourceRepository import kr.proxia.domain.resource.domain.repository.DomainResourceRepository -import kr.proxia.global.security.encryption.EncryptionService import kr.proxia.domain.service.domain.entity.ServiceEntity import kr.proxia.domain.service.domain.enums.ServiceType import kr.proxia.domain.service.domain.error.ServiceError @@ -25,8 +24,8 @@ import kr.proxia.domain.service.presentation.request.CreateServiceRequest import kr.proxia.domain.service.presentation.request.UpdateServicePositionRequest import kr.proxia.domain.service.presentation.request.UpdateServiceRequest import kr.proxia.global.error.BusinessException +import kr.proxia.global.security.encryption.EncryptionUtil import kr.proxia.global.security.holder.SecurityHolder -import org.springframework.data.repository.findByIdOrNull import java.util.UUID class ServiceServiceTest : @@ -39,7 +38,7 @@ class ServiceServiceTest : val securityHolder = mockk() val domainResourceRepository = mockk() - val encryptionService = mockk() + val encryptionUtil = mockk() val serviceService = ServiceService( @@ -49,7 +48,7 @@ class ServiceServiceTest : appResourceRepository, databaseResourceRepository, domainResourceRepository, - encryptionService, + encryptionUtil, securityHolder, )