feat: add standalone pod module scaffolder

This commit is contained in:
jade
2026-09-09 17:49:34 +09:00
parent d45720d3e4
commit e1908b4b7a
8 changed files with 839 additions and 0 deletions

View File

@@ -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);
}
});
}
}
}

View File

@@ -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"));
}
}