Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package kr.proxia.domain.container.domain.enums

enum class ContainerStatus {
BUILDING,
STARTING,
RUNNING,
STOPPED,
FAILED,
REMOVING,
}
Original file line number Diff line number Diff line change
@@ -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<ContainerEntity, UUID>
Original file line number Diff line number Diff line change
@@ -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<String, String> {
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,
)
}
Original file line number Diff line number Diff line change
@@ -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)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package kr.proxia.domain.deployment.presentation.request

data class DeployRequest(
val branch: String?,
)
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.proxia.domain.monitoring.application.service

import org.springframework.stereotype.Service

@Service
class LogStreamingService
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package kr.proxia.domain.monitoring.presentation.docs

interface LogStreamingDocs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package kr.proxia.domain.monitoring.presentation.docs

interface MonitoringDocs
Loading