feat: add standalone pod module scaffolder
This commit is contained in:
@@ -0,0 +1,284 @@
|
||||
package io.shinhanlife.dat.lib.util;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.AtomicMoveNotSupportedException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.util.Comparator;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Creates a standalone Tool Pod repository without changing the legacy PodScaffolder flow. */
|
||||
public final class NewPodProjectScaffolder {
|
||||
|
||||
private static final Pattern MODULE_NAME = Pattern.compile("^dat-was-[a-z0-9]+(?:-[a-z0-9]+)*$");
|
||||
|
||||
private NewPodProjectScaffolder() {
|
||||
}
|
||||
|
||||
public static String scaffold(Path workspaceRoot, String moduleName, int port, String author, String createdDate)
|
||||
throws IOException {
|
||||
validateModuleName(moduleName);
|
||||
validatePort(port);
|
||||
if (workspaceRoot == null) {
|
||||
throw new IllegalArgumentException("Workspace directory does not exist: null");
|
||||
}
|
||||
Path normalizedWorkspace = workspaceRoot.toAbsolutePath().normalize();
|
||||
if (!Files.isDirectory(normalizedWorkspace)) {
|
||||
throw new IllegalArgumentException("Workspace directory does not exist: " + workspaceRoot);
|
||||
}
|
||||
Path target = normalizedWorkspace.resolve(moduleName).normalize();
|
||||
if (!target.getParent().equals(normalizedWorkspace)) {
|
||||
throw new IllegalArgumentException("Module path must be directly below the workspace");
|
||||
}
|
||||
if (Files.exists(target)) {
|
||||
throw new IllegalStateException("Project already exists: " + moduleName);
|
||||
}
|
||||
Path libraryProject = normalizedWorkspace.resolve("dat-lib-datmt");
|
||||
if (!Files.isDirectory(libraryProject)) {
|
||||
throw new IllegalArgumentException("dat-lib-datmt project does not exist below the workspace: " + libraryProject);
|
||||
}
|
||||
|
||||
Path temporary = Files.createTempDirectory(normalizedWorkspace, ".new-pod-");
|
||||
try {
|
||||
writeProject(temporary, moduleName, port, blankToDefault(author, System.getProperty("user.name")),
|
||||
blankToDefault(createdDate, "unknown"), libraryProject);
|
||||
moveIntoPlace(temporary, target);
|
||||
return "독립 Pod 프로젝트 생성 완료: " + target;
|
||||
} catch (IOException | RuntimeException error) {
|
||||
deleteRecursively(temporary);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
public static void validateModuleName(String moduleName) {
|
||||
if (moduleName == null || !MODULE_NAME.matcher(moduleName).matches() || "dat-was-lib".equals(moduleName)) {
|
||||
throw new IllegalArgumentException("Module name must match dat-was-<lowercase-name>: " + moduleName);
|
||||
}
|
||||
}
|
||||
|
||||
public static void validatePort(int port) {
|
||||
if (port < 1 || port > 65535) {
|
||||
throw new IllegalArgumentException("Port must be between 1 and 65535: " + port);
|
||||
}
|
||||
}
|
||||
|
||||
private static void writeProject(Path root, String moduleName, int port, String author, String createdDate,
|
||||
Path libraryProject)
|
||||
throws IOException {
|
||||
String shortName = moduleName.substring("dat-was-".length());
|
||||
String packageName = shortName.replace("-", "");
|
||||
String className = "DatWas" + pascalCase(shortName) + "Application";
|
||||
String serviceName = "was-" + shortName;
|
||||
write(root.resolve("settings.gradle"), """
|
||||
rootProject.name = '%s'
|
||||
|
||||
includeBuild('../dat-lib-datmt') {
|
||||
dependencySubstitution {
|
||||
substitute module('io.shinhanlife:dat-lib-datmt') using project(':dat-was-lib')
|
||||
}
|
||||
}
|
||||
""".formatted(moduleName));
|
||||
write(root.resolve("build.gradle"), """
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot' version '3.5.11'
|
||||
id 'io.spring.dependency-management' version '1.1.7'
|
||||
}
|
||||
|
||||
group = 'io.shinhanlife'
|
||||
version = '0.0.1-SNAPSHOT'
|
||||
|
||||
java {
|
||||
toolchain { languageVersion = JavaLanguageVersion.of(21) }
|
||||
}
|
||||
|
||||
repositories { mavenCentral() }
|
||||
|
||||
dependencies {
|
||||
implementation 'io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT'
|
||||
}
|
||||
|
||||
tasks.withType(Test).configureEach { useJUnitPlatform() }
|
||||
""");
|
||||
copyGradleWrapper(libraryProject, root);
|
||||
write(root.resolve("src/main/java/io/shinhanlife/dat/mcc/" + packageName + "/" + className + ".java"), """
|
||||
package io.shinhanlife.dat.mcc.%s;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
|
||||
|
||||
/** Generated by Pod Module New for %s on %s. */
|
||||
@SpringBootApplication(scanBasePackages = {"io.shinhanlife.dat.mcc", "io.shinhanlife.dat.lib"})
|
||||
@ConfigurationPropertiesScan(basePackages = {"io.shinhanlife.dat.mcc", "io.shinhanlife.dat.lib"})
|
||||
public class %s {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(%s.class, args);
|
||||
}
|
||||
}
|
||||
""".formatted(packageName, author, createdDate, className, className));
|
||||
write(root.resolve("src/main/resources/application.yml"), """
|
||||
server:
|
||||
port: ${PORT:%d}
|
||||
spring:
|
||||
application:
|
||||
name: %s
|
||||
config:
|
||||
import: optional:classpath:tool-service-manifest.yml
|
||||
profiles:
|
||||
active: local
|
||||
mcp:
|
||||
namespace: %s
|
||||
manifest:
|
||||
bundle-id: %s
|
||||
name-prefix: ""
|
||||
security:
|
||||
api-key: ${TOOL_SERVER_API_KEY:tool-server-key}
|
||||
tenant-domains:
|
||||
TESTER-DEV: ALL
|
||||
""".formatted(port, moduleName, shortName, serviceName));
|
||||
for (String profile : new String[] {"local", "dev", "test", "prod"}) {
|
||||
write(root.resolve("src/main/resources/application-" + profile + ".yml"), """
|
||||
spring:
|
||||
config:
|
||||
activate:
|
||||
on-profile: %s
|
||||
""".formatted(profile));
|
||||
}
|
||||
write(root.resolve("src/main/resources/tool-service-manifest.yml"), """
|
||||
mcp:
|
||||
manifest:
|
||||
routing-functions:
|
||||
- name: route_to_%s
|
||||
server-id: %s
|
||||
category-key: %s
|
||||
product-boundary: "업무 범위를 입력하세요."
|
||||
business-domain: "업무 도메인을 입력하세요."
|
||||
business-outcome: "업무 결과를 입력하세요."
|
||||
primary-entities: []
|
||||
capabilities: []
|
||||
select-if: "이 Pod의 업무 요청인 경우"
|
||||
reject-if: "다른 업무 Pod 요청인 경우"
|
||||
confusable-servers: []
|
||||
decision-policy: "업무 도메인을 기준으로 선택합니다."
|
||||
""".formatted(moduleName, moduleName, shortName));
|
||||
write(root.resolve("Dockerfile"), """
|
||||
FROM eclipse-temurin:21-jre-alpine
|
||||
WORKDIR /app
|
||||
COPY build/libs/*-SNAPSHOT.jar app.jar
|
||||
EXPOSE %d
|
||||
ENTRYPOINT ["java", "-jar", "app.jar"]
|
||||
""".formatted(port));
|
||||
write(root.resolve("docker-compose.yml"), """
|
||||
services:
|
||||
%s:
|
||||
build: .
|
||||
ports:
|
||||
- "${HOST_PORT:%d}:${PORT:%d}"
|
||||
environment:
|
||||
- PORT=${PORT:%d}
|
||||
- AXHUB_TOOL_URL=http://%s:${PORT:%d}
|
||||
- SPRING_PROFILES_ACTIVE=${ACTIVE_PROFILE:-local}
|
||||
""".formatted(serviceName, port, port, port, serviceName, port));
|
||||
write(root.resolve("k8s/base/deployment.yaml"), """
|
||||
apiVersion: apps/v1
|
||||
kind: Deployment
|
||||
metadata:
|
||||
name: %s
|
||||
spec:
|
||||
replicas: 1
|
||||
selector:
|
||||
matchLabels: { app: %s }
|
||||
template:
|
||||
metadata:
|
||||
labels: { app: %s }
|
||||
spec:
|
||||
containers:
|
||||
- name: %s
|
||||
image: %s:latest
|
||||
ports:
|
||||
- containerPort: %d
|
||||
""".formatted(serviceName, serviceName, serviceName, serviceName, serviceName, port));
|
||||
write(root.resolve("k8s/base/service.yaml"), """
|
||||
apiVersion: v1
|
||||
kind: Service
|
||||
metadata:
|
||||
name: %s
|
||||
spec:
|
||||
selector: { app: %s }
|
||||
ports:
|
||||
- port: %d
|
||||
targetPort: %d
|
||||
""".formatted(serviceName, serviceName, port, port));
|
||||
write(root.resolve("README.md"), """
|
||||
# %s
|
||||
|
||||
Pod Module New로 생성된 독립 DATMT Tool Pod입니다.
|
||||
|
||||
## 공통 라이브러리
|
||||
|
||||
상위 workspace의 `dat-lib-datmt`를 Gradle composite build로 참조합니다.
|
||||
|
||||
## 빌드
|
||||
|
||||
```powershell
|
||||
.\\gradlew.bat compileJava
|
||||
```
|
||||
|
||||
기본 포트는 %d이며, 실행 시 `PORT` 환경변수로 변경할 수 있습니다.
|
||||
""".formatted(moduleName, port));
|
||||
}
|
||||
|
||||
private static void copyGradleWrapper(Path libraryProject, Path targetProject) throws IOException {
|
||||
for (String relativePath : new String[] {
|
||||
"gradlew", "gradlew.bat", "gradle/wrapper/gradle-wrapper.jar", "gradle/wrapper/gradle-wrapper.properties"}) {
|
||||
Path source = libraryProject.resolve(relativePath);
|
||||
if (!Files.isRegularFile(source)) {
|
||||
throw new IllegalArgumentException("dat-lib-datmt Gradle wrapper file is missing: " + source);
|
||||
}
|
||||
Path target = targetProject.resolve(relativePath);
|
||||
Files.createDirectories(target.getParent());
|
||||
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
}
|
||||
}
|
||||
|
||||
private static void write(Path path, String content) throws IOException {
|
||||
Files.createDirectories(path.getParent());
|
||||
Files.writeString(path, content, StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static String pascalCase(String value) {
|
||||
StringBuilder result = new StringBuilder();
|
||||
for (String part : value.split("-")) {
|
||||
result.append(Character.toUpperCase(part.charAt(0))).append(part.substring(1));
|
||||
}
|
||||
return result.toString();
|
||||
}
|
||||
|
||||
private static String blankToDefault(String value, String fallback) {
|
||||
return value == null || value.isBlank() ? fallback : value;
|
||||
}
|
||||
|
||||
private static void moveIntoPlace(Path source, Path target) throws IOException {
|
||||
try {
|
||||
Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (AtomicMoveNotSupportedException ignored) {
|
||||
Files.move(source, target);
|
||||
}
|
||||
}
|
||||
|
||||
private static void deleteRecursively(Path root) throws IOException {
|
||||
if (!Files.exists(root)) return;
|
||||
try (var paths = Files.walk(root)) {
|
||||
paths.sorted(Comparator.reverseOrder()).forEach(path -> {
|
||||
try {
|
||||
Files.deleteIfExists(path);
|
||||
} catch (IOException error) {
|
||||
throw new IllegalStateException(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
package io.shinhanlife.dat.lib.util;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
class NewPodProjectScaffolderTest {
|
||||
|
||||
@TempDir
|
||||
Path workspace;
|
||||
|
||||
@Test
|
||||
void createsStandaloneProjectWithCompositeBuild() throws Exception {
|
||||
createLibraryProject();
|
||||
String result = NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099,
|
||||
"tester", "2026.09.09");
|
||||
|
||||
Path project = workspace.resolve("dat-was-payment");
|
||||
assertTrue(Files.exists(project.resolve("settings.gradle")));
|
||||
assertTrue(Files.exists(project.resolve("build.gradle")));
|
||||
assertTrue(Files.exists(project.resolve("src/main/resources/application.yml")));
|
||||
assertTrue(Files.exists(project.resolve("docker-compose.yml")));
|
||||
assertTrue(Files.exists(project.resolve("k8s/base/deployment.yaml")));
|
||||
assertTrue(Files.exists(project.resolve("gradlew.bat")));
|
||||
assertTrue(Files.exists(project.resolve("gradle/wrapper/gradle-wrapper.jar")));
|
||||
assertTrue(Files.readString(project.resolve("settings.gradle"))
|
||||
.contains("includeBuild('../dat-lib-datmt')"));
|
||||
assertTrue(Files.readString(project.resolve("build.gradle"))
|
||||
.contains("io.shinhanlife:dat-lib-datmt:0.0.1-SNAPSHOT"));
|
||||
assertTrue(Files.readString(project.resolve("src/main/resources/application.yml"))
|
||||
.contains("port: ${PORT:8099}"));
|
||||
assertTrue(Files.readString(project.resolve("src/main/resources/application.yml"))
|
||||
.contains("import: optional:classpath:tool-service-manifest.yml"));
|
||||
assertTrue(result.contains("dat-was-payment"));
|
||||
}
|
||||
|
||||
private void createLibraryProject() throws Exception {
|
||||
Path library = workspace.resolve("dat-lib-datmt");
|
||||
Files.createDirectories(library.resolve("gradle/wrapper"));
|
||||
Files.writeString(library.resolve("gradlew"), "#!/bin/sh\n");
|
||||
Files.writeString(library.resolve("gradlew.bat"), "@echo off\n");
|
||||
Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.jar"), "wrapper");
|
||||
Files.writeString(library.resolve("gradle/wrapper/gradle-wrapper.properties"), "distributionUrl=test\n");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidNamesPortsAndExistingProjects() throws Exception {
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NewPodProjectScaffolder.scaffold(workspace, "payment", 8099, "tester", "2026.09.09"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-lib", 8099, "tester", "2026.09.09"));
|
||||
assertThrows(IllegalArgumentException.class,
|
||||
() -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 0, "tester", "2026.09.09"));
|
||||
Files.createDirectories(workspace.resolve("dat-was-payment"));
|
||||
assertThrows(IllegalStateException.class,
|
||||
() -> NewPodProjectScaffolder.scaffold(workspace, "dat-was-payment", 8099, "tester", "2026.09.09"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user