fix: force merge all remaining patched updates regardless of timestamps

feature/apply-patched-updates
bjkim 4 weeks ago
parent 074aea2b33
commit abb2afcda0

@ -1,10 +1,10 @@
FROM openjdk:17-jdk-alpine FROM openjdk:17-jdk-alpine
MAINTAINER [AutoFlow] MAINTAINER [AutoFlow]
RUN apk --no-cache add tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime RUN apk --no-cache add tzdata && cp /usr/share/zoneinfo/Asia/Seoul /etc/localtime
RUN mkdir /server RUN mkdir /server
COPY build/libs/*.jar /server/app.jar ADD build/libs/autoflow-0.0.1-SNAPSHOT.jar /server/autoflow-0.0.1-SNAPSHOT.jar
WORKDIR /server WORKDIR /server
ENTRYPOINT ["java", "-jar", "app.jar"] ENTRYPOINT ["java", "-jar", "autoflow-0.0.1-SNAPSHOT.jar"]

@ -1,116 +1,202 @@
# AutoFlow Server Management (autoflow-server-mgmt) # Spring Security Refresh Token with JWT in Spring Boot example
이 프로젝트는 AutoFlow 시스템의 핵심 관리 서버로, 머신러닝 파이프라인 관리, 데이터셋 관리, 그리고 외부 시스템(Kubeflow, MLflow, OTA 등)과의 통합을 담당합니다. Spring Boot 기반의 견고한 아키텍처를 통해 데이터 기반의 워크플로우를 효율적으로 관리합니다. Build JWT Refresh Token with Spring Security in the Spring Boot Application. You can know how to expire the JWT Token, then renew the Access Token with Refresh Token in HttpOnly Cookie.
--- The instruction can be found at:
[Spring Security Refresh Token with JWT](https://www.bezkoder.com/spring-security-refresh-token/)
## 🚀 주요 기능 ## User Registration, User Login and Authorization process.
The diagram shows flow of how we implement User Registration, User Login and Authorization process.
### 1. 인증 및 권한 관리 (Authentication & Security) ![spring-security-jwt-auth-spring-boot-flow](spring-security-jwt-auth-spring-boot-flow.png)
- **JWT (JSON Web Token)** 기반 인증 지원
- **Refresh Token**을 통한 보안성 및 사용자 편의성 강화
- 쿠키 기반의 토큰 저장 방식 (`cuuva-jwt`, `cuuva-jwt-refresh`)
- 프로젝트 및 작업 단위별 세부 권한 제어
### 2. 프로젝트 및 데이터 관리 (Project & Data Management) And this is for Refresh Token:
- **프로젝트(Project)** 생성, 수정, 삭제 및 멤버별 권한 관리
- **데이터 그룹(Data Group)** 및 **데이터셋(Dataset)**의 계층적 관리
- 외부 데이터셋 연동 및 관리 기능
### 3. ML 파이프라인 및 워크플로우 (ML Pipeline & Workflow) ![spring-security-refresh-token-jwt-spring-boot-flow](spring-security-refresh-token-jwt-spring-boot-flow.png)
- **Kubeflow** 통합: Experiments 및 Runs 관리, 파이프라인 업로드
- **MLflow** 통합: 실험 결과 및 메트릭 추적
- 워크플로우 생성 및 실행 관리
### 4. 파일 관리 (File Management) ## Configure Spring Datasource, JPA, App properties
- **AWS S3** 연동을 통한 대용량 파일 저장 및 관리 Open `src/main/resources/application.properties`
- 멀티파트(Multipart) 파일 업로드 지원 (최대 500MB)
- 동적 AWS S3 첨부 파일 관리 시스템
### 5. 외부 시스템 연동 (External Integrations) ```properties
- **OTA (Over-The-Air)** 연동: 외부 인증 및 패키지 검색 API 연동 지원 spring.datasource.url= jdbc:mysql://localhost:3306/testdb?useSSL=false
- **Spring Batch**를 활용한 대용량 데이터 처리 및 통계 수집 spring.datasource.username= root
spring.datasource.password= 123456
--- spring.jpa.properties.hibernate.dialect= org.hibernate.dialect.MySQLDialect
spring.jpa.hibernate.ddl-auto= update
## 🛠 기술 스택 (Tech Stack) # App Properties
bezkoder.app.jwtSecret= bezKoderSecretKey
bezkoder.app.jwtExpirationMs= 3600000
bezkoder.app.jwtRefreshExpirationMs= 86400000
```
- **Language:** Java 17 ## Run Spring Boot application
- **Framework:** Spring Boot 3.5.6 ```
- **Build Tool:** Gradle mvn spring-boot:run
- **Database:** MariaDB (JPA / Hibernate) ```
- **Security:** Spring Security, JWT (jjwt 0.11.5)
- **Storage:** AWS S3
- **Batch Processing:** Spring Batch
- **API Documentation:** Springdoc OpenAPI (Swagger UI)
- **Etc:** Lombok, Jsoup, Caffeine Cache, WebFlux
--- ## Run following SQL insert statements
```
INSERT INTO roles(name) VALUES('ROLE_USER');
INSERT INTO roles(name) VALUES('ROLE_MODERATOR');
INSERT INTO roles(name) VALUES('ROLE_ADMIN');
```
## ⚙️ 설정 방법 (Configuration) Related Posts:
> [Spring Boot, Spring Security: JWT Authentication & Authorization example](https://www.bezkoder.com/spring-boot-security-login-jwt/)
### 사전 요구 사항 > [For MySQL/PostgreSQL](https://www.bezkoder.com/spring-boot-login-example-mysql/)
- Java 17 이상 설치
- MariaDB 설치 및 데이터베이스 생성 (`autoflow`)
- AWS S3 접근 권한 필요
### 환경 설정 (`application.properties`) > [For MongoDB](https://www.bezkoder.com/spring-boot-mongodb-login-example/)
`src/main/resources/application.properties` 파일에서 다음 항목들을 설정해야 합니다:
```properties ## More Practice:
# 데이터베이스 설정 > [Spring Boot File upload example with Multipart File](https://bezkoder.com/spring-boot-file-upload/)
spring.datasource.url=jdbc:mariadb://{DB_HOST}:3306/autoflow
spring.datasource.username={USER}
spring.datasource.password={PASSWORD}
# JWT 보안 설정
cuuva.app.jwtSecret={YOUR_SECRET_KEY}
# AWS S3 설정
cloud.aws.s3.endpoint={AWS_S3_ENDPOINT}
cloud.aws.credentials.access-key={AWS_ACCESS_KEY}
cloud.aws.credentials.secret-key={AWS_SECRET_KEY}
cloud.aws.region.static={AWS_REGION}
# 외부 서비스 연동
kubeflow.url={KUBEFLOW_URL}
mlflow.url={MLFLOW_URL}
```
--- > [Exception handling: @RestControllerAdvice example in Spring Boot](https://bezkoder.com/spring-boot-restcontrolleradvice/)
## 🏃 실행 방법 (Getting Started) > [Spring Boot Repository Unit Test with @DataJpaTest](https://bezkoder.com/spring-boot-unit-test-jpa-repo-datajpatest/)
### Gradle을 이용한 실행 > [Spring Boot Rest Controller Unit Test with @WebMvcTest](https://www.bezkoder.com/spring-boot-webmvctest/)
```bash
./gradlew bootRun
```
### 소스 빌드 및 배포 (JAR) > [Spring Boot Pagination & Sorting example](https://www.bezkoder.com/spring-boot-pagination-sorting-example/)
```bash
./gradlew build
# 빌드된 파일 위치: build/libs/autoflow-server-mgmt-0.0.1-SNAPSHOT.jar
java -jar build/libs/autoflow-server-mgmt-0.0.1-SNAPSHOT.jar
```
### API 문서 (Swagger) > Validation: [Spring Boot Validate Request Body](https://www.bezkoder.com/spring-boot-validate-request-body/)
서버 실행 후 아래 주소에서 API 명세를 확인할 수 있습니다:
- `http://localhost:8080/swagger-ui/index.html`
--- > Documentation: [Spring Boot and Swagger 3 example](https://www.bezkoder.com/spring-boot-swagger-3/)
## 📁 프로젝트 구조 (Project Structure) > Caching: [Spring Boot Redis Cache example](https://www.bezkoder.com/spring-boot-redis-cache-example/)
``` Associations:
kr.re.etri.autoflow > [Spring Boot One To Many example with Spring JPA, Hibernate](https://www.bezkoder.com/jpa-one-to-many/)
├── controllers # API 컨트롤러 (Auth, Project, Data, etc.)
├── service # 비즈니스 로직 처리 > [Spring Boot Many To Many example with Spring JPA, Hibernate](https://www.bezkoder.com/jpa-many-to-many/)
├── repository # 데이터 액세스 계층 (JPA)
├── entity # 데이터베이스 엔티티 > [JPA One To One example with Spring Boot](https://www.bezkoder.com/jpa-one-to-one/)
├── security # 시큐리티 설정 및 JWT 처리
├── batch # Spring Batch 작업 구성 Deployment:
├── payload # Request/Response DTO > [Deploy Spring Boot App on AWS Elastic Beanstalk](https://www.bezkoder.com/deploy-spring-boot-aws-eb/)
├── models # 도메인 모델
├── exception # 전역 예외 처리 > [Docker Compose Spring Boot and MySQL example](https://www.bezkoder.com/docker-compose-spring-boot-mysql/)
└── common # 공통 유틸리티 및 설정
``` ## Fullstack Authentication
> [Spring Boot + Vue.js JWT Authentication](https://bezkoder.com/spring-boot-vue-js-authentication-jwt-spring-security/)
> [Spring Boot + Angular 8 JWT Authentication](https://bezkoder.com/angular-spring-boot-jwt-auth/)
> [Spring Boot + Angular 10 JWT Authentication](https://bezkoder.com/angular-10-spring-boot-jwt-auth/)
> [Spring Boot + Angular 11 JWT Authentication](https://bezkoder.com/angular-11-spring-boot-jwt-auth/)
> [Spring Boot + Angular 12 JWT Authentication](https://www.bezkoder.com/angular-12-spring-boot-jwt-auth/)
> [Spring Boot + Angular 13 JWT Authentication](https://www.bezkoder.com/angular-13-spring-boot-jwt-auth/)
> [Spring Boot + Angular 14 JWT Authentication](https://www.bezkoder.com/angular-14-spring-boot-jwt-auth/)
> [Spring Boot + Angular 15 JWT Authentication](https://www.bezkoder.com/angular-15-spring-boot-jwt-auth/)
> [Spring Boot + Angular 16 JWT Authentication](https://www.bezkoder.com/angular-16-spring-boot-jwt-auth/)
> [Spring Boot + Angular 17 JWT Authentication](https://www.bezkoder.com/angular-17-spring-boot-jwt-auth/)
> [Spring Boot + React JWT Authentication](https://bezkoder.com/spring-boot-react-jwt-auth/)
## Fullstack CRUD App
> [Vue.js + Spring Boot + H2 Embedded database example](https://www.bezkoder.com/spring-boot-vue-js-crud-example/)
> [Vue.js + Spring Boot + MySQL example](https://www.bezkoder.com/spring-boot-vue-js-mysql/)
> [Vue.js + Spring Boot + PostgreSQL example](https://www.bezkoder.com/spring-boot-vue-js-postgresql/)
> [Angular 8 + Spring Boot + Embedded database example](https://www.bezkoder.com/angular-spring-boot-crud/)
> [Angular 8 + Spring Boot + MySQL example](https://bezkoder.com/angular-spring-boot-crud/)
> [Angular 8 + Spring Boot + PostgreSQL example](https://bezkoder.com/angular-spring-boot-postgresql/)
> [Angular 10 + Spring Boot + MySQL example](https://bezkoder.com/angular-10-spring-boot-crud/)
> [Angular 10 + Spring Boot + PostgreSQL example](https://bezkoder.com/angular-10-spring-boot-postgresql/)
> [Angular 11 + Spring Boot + MySQL example](https://bezkoder.com/angular-11-spring-boot-crud/)
> [Angular 11 + Spring Boot + PostgreSQL example](https://bezkoder.com/angular-11-spring-boot-postgresql/)
> [Angular 12 + Spring Boot + Embedded database example](https://www.bezkoder.com/angular-12-spring-boot-crud/)
> [Angular 12 + Spring Boot + MySQL example](https://www.bezkoder.com/angular-12-spring-boot-mysql/)
> [Angular 12 + Spring Boot + PostgreSQL example](https://www.bezkoder.com/angular-12-spring-boot-postgresql/)
> [Angular 13 + Spring Boot + H2 Embedded Database example](https://www.bezkoder.com/spring-boot-angular-13-crud/)
> [Angular 13 + Spring Boot + MySQL example](https://www.bezkoder.com/spring-boot-angular-13-mysql/)
> [Angular 13 + Spring Boot + PostgreSQL example](https://www.bezkoder.com/spring-boot-angular-13-postgresql/)
> [Angular 14 + Spring Boot + H2 Embedded Database example](https://www.bezkoder.com/spring-boot-angular-14-crud/)
> [Angular 14 + Spring Boot + MySQL example](https://www.bezkoder.com/spring-boot-angular-14-mysql/)
> [Angular 14 + Spring Boot + PostgreSQL example](https://www.bezkoder.com/spring-boot-angular-14-postgresql/)
> [Angular 15 + Spring Boot + H2 Embedded Database example](https://www.bezkoder.com/spring-boot-angular-15-crud/)
> [Angular 15 + Spring Boot + MySQL example](https://www.bezkoder.com/spring-boot-angular-15-mysql/)
> [Angular 15 + Spring Boot + PostgreSQL example](https://www.bezkoder.com/spring-boot-angular-15-postgresql/)
> [Angular 15 + Spring Boot + MongoDB example](https://www.bezkoder.com/spring-boot-angular-15-mongodb/)
> [Angular 16 + Spring Boot + H2 Embedded Database example](https://www.bezkoder.com/spring-boot-angular-16-crud/)
> [Angular 16 + Spring Boot + MySQL example](https://www.bezkoder.com/spring-boot-angular-16-mysql/)
> [Angular 16 + Spring Boot + PostgreSQL example](https://www.bezkoder.com/spring-boot-angular-16-postgresql/)
> [Angular 16 + Spring Boot + MongoDB example](https://www.bezkoder.com/spring-boot-angular-16-mongodb/)
> [Angular 17 + Spring Boot + H2 Embedded Database example](https://www.bezkoder.com/spring-boot-angular-17-crud/)
> [Angular 17 + Spring Boot + MySQL example](https://www.bezkoder.com/spring-boot-angular-17-mysql/)
> [Angular 17 + Spring Boot + PostgreSQL example](https://www.bezkoder.com/spring-boot-angular-17-postgresql/)
> [Angular 17 + Spring Boot + MongoDB example](https://www.bezkoder.com/spring-boot-angular-17-mongodb/)
> [React + Spring Boot + MySQL example](https://bezkoder.com/react-spring-boot-crud/)
> [React + Spring Boot + PostgreSQL example](https://bezkoder.com/spring-boot-react-postgresql/)
> [React + Spring Boot + MongoDB example](https://bezkoder.com/react-spring-boot-mongodb/)
Run both Back-end & Front-end in one place:
> [Integrate Angular with Spring Boot Rest API](https://bezkoder.com/integrate-angular-spring-boot/)
> [Integrate React.js with Spring Boot Rest API](https://bezkoder.com/integrate-reactjs-spring-boot/)
> [Integrate Vue.js with Spring Boot Rest API](https://bezkoder.com/integrate-vue-spring-boot/)
## More Practice:
> [Spring Boot File upload example with Multipart File](https://bezkoder.com/spring-boot-file-upload/)
> [Exception handling: @RestControllerAdvice example in Spring Boot](https://bezkoder.com/spring-boot-restcontrolleradvice/)
> [Spring Boot Repository Unit Test with @DataJpaTest](https://bezkoder.com/spring-boot-unit-test-jpa-repo-datajpatest/)
> [Spring Boot Pagination & Sorting example](https://www.bezkoder.com/spring-boot-pagination-sorting-example/)
Associations:
> [JPA/Hibernate One To Many example](https://www.bezkoder.com/jpa-one-to-many/)
> [JPA/Hibernate Many To Many example](https://www.bezkoder.com/jpa-many-to-many/)
> [JPA/Hibernate One To One example](https://www.bezkoder.com/jpa-one-to-one/)
Deployment:
> [Deploy Spring Boot App on AWS Elastic Beanstalk](https://www.bezkoder.com/deploy-spring-boot-aws-eb/)
> [Docker Compose Spring Boot and MySQL example](https://www.bezkoder.com/docker-compose-spring-boot-mysql/)

@ -1,6 +1,6 @@
plugins { plugins {
// Spring Boot // Spring Boot
id("org.springframework.boot") version "3.5.14" id("org.springframework.boot") version "3.5.6"
// Spring 의존성 관리(BOM) // Spring 의존성 관리(BOM)
id("io.spring.dependency-management") version "1.1.7" id("io.spring.dependency-management") version "1.1.7"
@ -26,12 +26,16 @@ repositories {
dependencies { dependencies {
// Spring Boot 스타터들 // Spring Boot 스타터들
implementation("org.springframework.boot:spring-boot-starter-web") implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.boot:spring-boot-starter-data-jpa") implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-security") implementation("org.springframework.boot:spring-boot-starter-security")
implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-validation")
// https://mvnrepository.com/artifact/org.springframework.batch/spring-batch-core // https://mvnrepository.com/artifact/org.springframework.batch/spring-batch-core
implementation("org.springframework.boot:spring-boot-starter-batch") implementation("org.springframework.batch:spring-batch-core:5.2.3")
// implementation("org.springframework.boot:spring-boot-starter-batch:5.2.0")
testImplementation("org.springframework.batch:spring-batch-test:5.2.3")
// JWT // JWT
implementation("io.jsonwebtoken:jjwt-api:0.11.5") implementation("io.jsonwebtoken:jjwt-api:0.11.5")
@ -59,8 +63,6 @@ dependencies {
implementation("software.amazon.awssdk:s3:2.35.10") implementation("software.amazon.awssdk:s3:2.35.10")
implementation("io.kubernetes:client-java:19.0.0")
// 테스트 // 테스트
testImplementation("org.springframework.boot:spring-boot-starter-test") testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.springframework.security:spring-security-test") testImplementation("org.springframework.security:spring-security-test")
@ -71,6 +73,9 @@ dependencies {
implementation("io.minio:minio:8.5.17") implementation("io.minio:minio:8.5.17")
// Kubernetes API (Pod 목록 조회, phase == Running 확인)
implementation("io.kubernetes:client-java:19.0.3")
implementation("org.springframework.boot:spring-boot-starter-cache") // 캐시 지원 implementation("org.springframework.boot:spring-boot-starter-cache") // 캐시 지원
implementation("com.github.ben-manes.caffeine:caffeine:3.2.2")} implementation("com.github.ben-manes.caffeine:caffeine:3.2.2")}

@ -1,6 +1,7 @@
#Fri May 08 16:07:48 KST 2026
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists

@ -17,7 +17,7 @@ public class BatchScheduler {
private final JobLauncher jobLauncher; private final JobLauncher jobLauncher;
private final Job runSyncJob; // Spring Batch의 Job 타입 private final Job runSyncJob; // Spring Batch의 Job 타입
@Scheduled(fixedDelay = 300000) // 30초마다 실행 @Scheduled(fixedDelay = 10000) // 10초마다 실행 (KFP 실행 결과를 DB에 반영 → UI 목록 갱신)
public void runJob() throws Exception { public void runJob() throws Exception {
JobParameters params = new JobParametersBuilder() JobParameters params = new JobParametersBuilder()
.addLong("timestamp", System.currentTimeMillis()) // 중복 실행 방지 .addLong("timestamp", System.currentTimeMillis()) // 중복 실행 방지

@ -16,9 +16,9 @@ import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.batch.item.ItemProcessor; import org.springframework.batch.item.ItemProcessor;
import org.springframework.batch.item.ItemReader; import org.springframework.batch.item.ItemReader;
import org.springframework.batch.item.ItemWriter; import org.springframework.batch.item.ItemWriter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cache.Cache; import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager; import org.springframework.cache.CacheManager;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.reactive.ReactorClientHttpConnector; import org.springframework.http.client.reactive.ReactorClientHttpConnector;
@ -42,19 +42,20 @@ public class KubeflowRunBatchConfig {
private final KubeflowRunRepository kubeflowRunRepository; private final KubeflowRunRepository kubeflowRunRepository;
private final CacheManager cacheManager; private final CacheManager cacheManager;
@Value("${kubeflow.url:http://localhost:8080}")
private String kubeflowUrl;
private static final int PAGE_SIZE = 50; private static final int PAGE_SIZE = 50;
private static final String SORT_BY = "created_at DESC"; private static final String SORT_BY = "created_at DESC";
@Value("${kubeflow.url:http://192.168.10.135:32473/}")
private String kubeflowBaseUrl;
@Bean @Bean
public WebClient.Builder webClientBuilder() { public WebClient.Builder webClientBuilder() {
String baseUrl = kubeflowBaseUrl != null ? kubeflowBaseUrl.replaceAll("/+$", "") : "http://192.168.10.135:32473";
HttpClient httpClient = HttpClient.create() HttpClient httpClient = HttpClient.create()
.responseTimeout(Duration.ofSeconds(30)); // 응답 제한 .responseTimeout(Duration.ofSeconds(30)); // 응답 제한
return WebClient.builder() return WebClient.builder()
.baseUrl(kubeflowUrl) .baseUrl(baseUrl)
.clientConnector(new ReactorClientHttpConnector(httpClient)) .clientConnector(new ReactorClientHttpConnector(httpClient))
.exchangeStrategies(ExchangeStrategies.builder() .exchangeStrategies(ExchangeStrategies.builder()
.codecs(configurer -> configurer.defaultCodecs() .codecs(configurer -> configurer.defaultCodecs()
@ -100,9 +101,9 @@ public class KubeflowRunBatchConfig {
.bodyToMono(KubeflowRunResponse.class) .bodyToMono(KubeflowRunResponse.class)
.block(); .block();
if (response == null || response.getRuns() == null || response.getRuns().isEmpty()) { if (response == null || response.getRuns().isEmpty()) {
log.info("KubeflowRunBatch: 조회된 데이터가 없거나 응답이 비어있음"); log.info("KubeflowRunBatch: 데이터 없음, 종료");
runs = Collections.emptyList(); // null 대신 빈 리스트 할당 runs = Collections.emptyList();
return null; return null;
} }

@ -7,41 +7,26 @@ import software.amazon.awssdk.auth.credentials.AwsBasicCredentials;
import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider;
import software.amazon.awssdk.regions.Region; import software.amazon.awssdk.regions.Region;
import software.amazon.awssdk.services.s3.S3Client; import software.amazon.awssdk.services.s3.S3Client;
import software.amazon.awssdk.services.s3.S3ClientBuilder;
import java.net.URI;
@Configuration @Configuration
public class AwsConfig { public class AwsConfig {
@Value("${storage.provider:s3}") @Value("${cloud.aws.credentials.access-key}")
private String storageProvider;
@Value("${cloud.aws.credentials.access-key:minio}")
private String accessKey; private String accessKey;
@Value("${cloud.aws.credentials.secret-key:minio123}") @Value("${cloud.aws.credentials.secret-key}")
private String secretKey; private String secretKey;
@Value("${cloud.aws.region.static:ap-northeast-2}") @Value("${cloud.aws.region.static}")
private String region; private String region;
@Value("${minio.endpoint:http://localhost:9000}")
private String minioEndpoint;
@Bean @Bean
public S3Client s3Client() { public S3Client s3Client() {
AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey);
S3ClientBuilder builder = S3Client.builder() return S3Client.builder()
.region(Region.of(region)) .region(Region.of(region))
.credentialsProvider(StaticCredentialsProvider.create(credentials)); .credentialsProvider(StaticCredentialsProvider.create(credentials))
.build();
if ("minio".equalsIgnoreCase(storageProvider)) {
builder.endpointOverride(URI.create(minioEndpoint))
.forcePathStyle(true);
}
return builder.build();
} }
} }

@ -1,6 +1,7 @@
package kr.re.etri.autoflow.common; package kr.re.etri.autoflow.common;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry; import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@ -9,11 +10,19 @@ import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
public class WebConfiguration implements WebMvcConfigurer { public class WebConfiguration implements WebMvcConfigurer {
@Override @Override
public void addInterceptors( public void addCorsMappings(CorsRegistry registry) {
InterceptorRegistry registry) { registry.addMapping("/**")
.allowedOriginPatterns("http://localhost:3000", "http://localhost:5173", "http://127.0.0.1:3000", "http://127.0.0.1:5173", "http://10.10.11.144", "http://cuuva.com:2481", "http://210.217.121.58:2481") // 허용할 Origin 지정
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*") // 필요하면 "cuuva-jwt", "Content-Type", "Authorization" 명시 가능
.exposedHeaders("cuuva-jwt")
//.allowCredentials(true)
.maxAge(3600);
}
registry.addInterceptor( @Override
new LoggingInterceptor()) public void addInterceptors(InterceptorRegistry registry) {
.addPathPatterns("/**"); registry.addInterceptor(new LoggingInterceptor())
.addPathPatterns("/**"); // Intercepts all requests
} }
} }

@ -9,7 +9,7 @@ import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.PostConstruct; import jakarta.annotation.PostConstruct;
import kr.re.etri.autoflow.payload.request.EdgeSWVO; import kr.re.etri.autoflow.payload.request.EdgeSWVO;
import kr.re.etri.autoflow.service.DynamicStorageAttachmentService; import kr.re.etri.autoflow.service.DynamicMinioAttachmentService;
import kr.re.etri.autoflow.service.EdgeSWUploadService; import kr.re.etri.autoflow.service.EdgeSWUploadService;
import kr.re.etri.autoflow.service.ExternalAuthService; import kr.re.etri.autoflow.service.ExternalAuthService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -45,7 +45,7 @@ public class ExternalAuthController {
private final ExternalAuthService externalAuthService; private final ExternalAuthService externalAuthService;
private final EdgeSWUploadService edgeSWUploadService; private final EdgeSWUploadService edgeSWUploadService;
private final DynamicStorageAttachmentService minioService; private final DynamicMinioAttachmentService minioService;
private RestTemplate restTemplate; private RestTemplate restTemplate;

@ -7,7 +7,7 @@ import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import kr.re.etri.autoflow.entity.StorageAttachmentEntity; import kr.re.etri.autoflow.entity.MinioAttachmentEntity;
import kr.re.etri.autoflow.service.DatasetService; import kr.re.etri.autoflow.service.DatasetService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -26,7 +26,7 @@ import java.util.Map;
public class ExternalDataSetController { public class ExternalDataSetController {
private final DatasetService datasetService; private final DatasetService datasetService;
private final kr.re.etri.autoflow.service.StorageAttachmentService storageAttachmentService; private final kr.re.etri.autoflow.service.MinioAttachmentService minioAttachmentService;
@Operation( @Operation(
summary = "데이터셋 목록 조회", summary = "데이터셋 목록 조회",
@ -64,13 +64,13 @@ public class ExternalDataSetController {
@RequestParam Long projectId @RequestParam Long projectId
) { ) {
try { try {
StorageAttachmentEntity saved = datasetService.downloadDataset( MinioAttachmentEntity saved = datasetService.downloadDataset(
datasetName, path, refId, refType, title, description, version, regUserId, projectId datasetName, path, refId, refType, title, description, version, regUserId, projectId
); );
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("attachment", saved); response.put("attachment", saved);
response.put("minioUrl", storageAttachmentService.getFileUrl(saved.getStoragePath())); response.put("minioUrl", minioAttachmentService.getFileUrl(saved.getStoragePath()));
return ResponseEntity.ok(response); return ResponseEntity.ok(response);
} catch (Exception e) { } catch (Exception e) {

@ -7,9 +7,13 @@ import kr.re.etri.autoflow.entity.KubeflowRunEntity;
import kr.re.etri.autoflow.payload.request.KubeflowRunSearchRequest; import kr.re.etri.autoflow.payload.request.KubeflowRunSearchRequest;
import kr.re.etri.autoflow.repository.KubeflowRunRepository; import kr.re.etri.autoflow.repository.KubeflowRunRepository;
import kr.re.etri.autoflow.service.KubeflowRunService; import kr.re.etri.autoflow.service.KubeflowRunService;
import kr.re.etri.autoflow.service.PipelineUploadService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springdoc.core.annotations.ParameterObject; import org.springdoc.core.annotations.ParameterObject;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
@ -21,9 +25,11 @@ import java.util.List;
@RequiredArgsConstructor @RequiredArgsConstructor
public class KubeflowRunsController { public class KubeflowRunsController {
private static final Logger log = LoggerFactory.getLogger(KubeflowRunsController.class);
private final KubeflowRunRepository runRepository; private final KubeflowRunRepository runRepository;
private final KubeflowRunService kubeflowRunService; private final KubeflowRunService kubeflowRunService;
private final PipelineUploadService pipelineUploadService;
@Operation(summary = "모든 Kubeflow Run 조회") @Operation(summary = "모든 Kubeflow Run 조회")
@GetMapping @GetMapping
@ -48,7 +54,33 @@ public class KubeflowRunsController {
public ResponseEntity<Page<KubeflowRunEntity>> searchRuns( public ResponseEntity<Page<KubeflowRunEntity>> searchRuns(
@ParameterObject @ModelAttribute KubeflowRunSearchRequest request) { @ParameterObject @ModelAttribute KubeflowRunSearchRequest request) {
log.info("[KubeflowRuns] GET /api/kubeflow/runs/search 호출됨 (Run 목록 조회)");
Page<KubeflowRunEntity> page = kubeflowRunService.search(request); Page<KubeflowRunEntity> page = kubeflowRunService.search(request);
int total = page.getNumberOfElements();
String firstRunId = page.getContent().isEmpty() ? null : page.getContent().get(0).getRunId();
log.info("[KubeflowRuns] search 응답: content 개수={}, totalElements={}, 첫 run runId={}",
total, page.getTotalElements(), firstRunId != null ? firstRunId : "(없음)");
return ResponseEntity.ok(page); return ResponseEntity.ok(page);
} }
@Operation(summary = "Kubeflow Run 삭제 (KFP에서 삭제 성공 시에만 DB에서 제거)")
@DeleteMapping("/{runId}")
public ResponseEntity<?> deleteRun(
@Parameter(description = "Kubeflow Run ID", example = "ad980d7f-050a-4c59-a775-94394befad40")
@PathVariable("runId") String runId) {
return runRepository.findByRunId(runId)
.map((entity) -> {
try {
pipelineUploadService.deleteKfpRun(runId, entity.getExperimentId());
} catch (Exception e) {
log.warn("[KubeflowRuns] KFP Run 삭제 실패, DB는 유지: runId={}, error={}", runId, e.getMessage());
return ResponseEntity.status(HttpStatus.BAD_GATEWAY)
.body("KFP Run 삭제 실패. 목록에서 제거되지 않습니다: " + e.getMessage());
}
runRepository.delete(entity);
return ResponseEntity.<Void>noContent().build();
})
.orElse(ResponseEntity.notFound().build());
}
} }

@ -1,30 +1,95 @@
package kr.re.etri.autoflow.controllers; package kr.re.etri.autoflow.controllers;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.MediaType; import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.util.UriComponentsBuilder;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.net.URI;
import java.util.Collections; import java.util.Collections;
import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.stream.Collectors;
@Tag(name = "MLflow API", description = "MLflow Experiment 및 Run 조회 API") @Tag(name = "MLflow API", description = "MLflow Experiment 및 Run 조회 API")
@RestController @RestController
@RequestMapping("/api/mlflow") @RequestMapping("/api/mlflow")
public class MlflowController { public class MlflowController {
private static final Logger log = LoggerFactory.getLogger(MlflowController.class);
private final WebClient webClient; private final WebClient webClient;
/** MLflow 서버 루트( get-artifact 등은 /api/2.0/mlflow 가 아닌 루트에 있음 ) */
private final WebClient artifactWebClient;
/** 정규화된 MLflow 서버 루트 (예: http://localhost:5000) */
private final String mlflowServerRoot;
private final ObjectMapper objectMapper;
public MlflowController() { public MlflowController(
this.webClient = WebClient.builder() ObjectMapper objectMapper,
.baseUrl("http://192.168.10.135:30128/api/2.0/mlflow") @Value("${mlflow.url:http://192.168.10.135:30128/}") String mlflowUrl,
.defaultHeaders(headers -> headers.setBasicAuth("user", "WjWjIi13KEkO")) @Value("${mlflow.user:}") String mlflowUser,
.build(); @Value("${mlflow.password:}") String mlflowPassword) {
this.objectMapper = objectMapper != null ? objectMapper : new ObjectMapper();
String serverRoot = (mlflowUrl != null ? mlflowUrl.replaceAll("/+$", "") : "").trim();
if (serverRoot.isBlank()) {
serverRoot = "http://localhost:5000";
log.info("mlflow.url is blank; using default mlflowServerRoot: {}", serverRoot);
}
// 포트가 없으면 5000으로 보정 (예: http://localhost → http://localhost:5000)
if (serverRoot.matches("https?://[^:/]+$")) {
serverRoot = serverRoot + ":5000";
log.info("mlflow.url has no port; using {} as mlflowServerRoot", serverRoot);
}
this.mlflowServerRoot = serverRoot;
String baseUrl = mlflowServerRoot + "/api/2.0/mlflow";
WebClient.Builder builder = WebClient.builder().baseUrl(baseUrl);
if (mlflowUser != null && !mlflowUser.isBlank() && mlflowPassword != null) {
builder.defaultHeaders(headers -> headers.setBasicAuth(mlflowUser, mlflowPassword != null ? mlflowPassword : ""));
}
this.webClient = builder.build();
// artifactWebClient 는 baseUrl 없이 사용하고, 매 호출마다 절대 URL(mlflowServerRoot + path) 전달
// 큰 파일(수십 MB) 다운로드를 위해 클라이언트 디코더 버퍼를 충분히 크게 설정
WebClient.Builder artifactBuilder = WebClient.builder()
.codecs(c -> c.defaultCodecs().maxInMemorySize(128 * 1024 * 1024)); // 128MB
if (mlflowUser != null && !mlflowUser.isBlank() && mlflowPassword != null) {
artifactBuilder.defaultHeaders(headers -> headers.setBasicAuth(mlflowUser, mlflowPassword != null ? mlflowPassword : ""));
}
this.artifactWebClient = artifactBuilder.build();
}
@Operation(
summary = "전체 Experiment 목록 조회",
description = "MLflow의 전체 Experiment 목록을 조회합니다. experiment 이름을 일일이 등록하지 않고 동적으로 검색할 때 사용합니다.",
responses = {
@ApiResponse(responseCode = "200", description = "Experiment 목록 (experiments 배열)"),
@ApiResponse(responseCode = "500", description = "서버 오류 발생")
}
)
@GetMapping(value = "/experiments", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> getExperiments() {
try {
String body = webClient.post()
.uri("/experiments/search")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("max_results", 1000))
.retrieve()
.bodyToMono(String.class)
.block();
return ResponseEntity.ok(body != null ? body : "{\"experiments\":[]}");
} catch (Exception e) {
return ResponseEntity.internalServerError().body(e.getMessage());
}
} }
@Operation( @Operation(
@ -59,25 +124,140 @@ public class MlflowController {
@Operation( @Operation(
summary = "Run 단건 조회", summary = "Run 단건 조회",
description = "주어진 Run ID의 상세 정보를 조회합니다. MLflow API `/runs/get`를 호출하여 Run 정보를 반환합니다.", description = "주어진 Run ID의 상세 정보를 조회합니다. MLflow API `/runs/get`를 호출하고, run.info.experiment_id가 있으면 Experiments 목록에서 experiment name을 조회해 run.info.experiment_name으로 보강하여 반환합니다.",
responses = { responses = {
@ApiResponse(responseCode = "200", description = "Run 정보 조회 성공"), @ApiResponse(responseCode = "200", description = "Run 정보 조회 성공 (info.experiment_name 포함)"),
@ApiResponse(responseCode = "500", description = "서버 오류 발생") @ApiResponse(responseCode = "500", description = "서버 오류 발생")
} }
) )
@GetMapping(value = "/run", produces = MediaType.APPLICATION_JSON_VALUE) @GetMapping(value = "/run", produces = MediaType.APPLICATION_JSON_VALUE)
public Mono<ResponseEntity<String>> getRun( public ResponseEntity<String> getRun(
@Parameter(description = "조회할 Run ID", required = true, example = "59e4f75b29eb4354b9e9e2ec9d93e2e3") @Parameter(description = "조회할 Run ID", required = true, example = "59e4f75b29eb4354b9e9e2ec9d93e2e3")
@RequestParam String runId) { @RequestParam String runId) {
try {
String uri = String.format("/runs/get?run_id=%s", runId);
String runBody = webClient.get()
.uri(uri)
.retrieve()
.bodyToMono(String.class)
.block();
if (runBody == null || runBody.isBlank()) {
return ResponseEntity.ok(runBody != null ? runBody : "{}");
}
@SuppressWarnings("unchecked")
Map<String, Object> runMap = objectMapper.readValue(runBody, Map.class);
Object runObj = runMap.get("run");
Map<String, Object> run = runObj instanceof Map ? (Map<String, Object>) runObj : null;
if (run == null) {
return ResponseEntity.ok(runBody);
}
Object infoObj = run.get("info");
Map<String, Object> info = infoObj instanceof Map ? (Map<String, Object>) infoObj : null;
Object expIdObj = info != null ? info.get("experiment_id") : null;
if (expIdObj != null && info != null) {
try {
Map<?, ?> expSearchResponse = webClient.post()
.uri("/experiments/search")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("max_results", 1000))
.retrieve()
.bodyToMono(Map.class)
.block();
if (expSearchResponse != null && expSearchResponse.containsKey("experiments")) {
List<?> experiments = (List<?>) expSearchResponse.get("experiments");
String expIdStr = String.valueOf(expIdObj);
for (Object e : experiments != null ? experiments : Collections.emptyList()) {
if (!(e instanceof Map)) continue;
Map<?, ?> exp = (Map<?, ?>) e;
Object id = exp.get("experiment_id");
if (id != null && expIdStr.equals(String.valueOf(id))) {
Object name = exp.get("name");
if (name != null) {
info.put("experiment_name", name.toString());
}
break;
}
}
}
} catch (Exception e) {
log.debug("[MLflow] getRun: experiment_name 보강 실패 (무시), runId={}, error={}", runId, e.getMessage());
}
}
return ResponseEntity.ok(objectMapper.writeValueAsString(runMap));
} catch (Exception e) {
log.warn("[MLflow] getRun 실패: runId={}, error={}", runId, e.getMessage());
return ResponseEntity.internalServerError().body(e.getMessage());
}
}
String uri = String.format("/runs/get?run_id=%s", runId); @Operation(
summary = "Kubeflow Run ID에 해당하는 MLflow Run 목록 조회",
description = "태그 kubeflow_run_id가 일치하는 Run을 전체 experiment에서 검색합니다. experiment name과 무관하게 매칭됩니다.",
responses = {
@ApiResponse(responseCode = "200", description = "Run 목록 조회 성공 (runs 배열)"),
@ApiResponse(responseCode = "500", description = "서버 오류 발생")
}
)
@GetMapping(value = "/runs/by-kubeflow-run-id", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<String> getRunsByKubeflowRunId(
@Parameter(description = "Kubeflow Run ID (workflow.uid)", required = true)
@RequestParam String kubeflowRunId) {
return webClient.get() if (kubeflowRunId == null || (kubeflowRunId = kubeflowRunId.trim()).isBlank()) {
.uri(uri) log.info("[MLflow] getRunsByKubeflowRunId: kubeflowRunId 비어 있음 → runs:[]");
.retrieve() return ResponseEntity.ok("{\"runs\":[]}");
.bodyToMono(String.class) }
.map(ResponseEntity::ok)
.onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().body(e.getMessage()))); log.info("[MLflow] getRunsByKubeflowRunId: kubeflowRunId={}", kubeflowRunId);
try {
Map expSearchResponse = webClient.post()
.uri("/experiments/search")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(Map.of("max_results", 1000))
.retrieve()
.bodyToMono(Map.class)
.block();
List<String> experimentIds = Collections.emptyList();
if (expSearchResponse != null && expSearchResponse.containsKey("experiments")) {
List<Map<String, Object>> experiments = (List<Map<String, Object>>) expSearchResponse.get("experiments");
if (experiments != null && !experiments.isEmpty()) {
experimentIds = experiments.stream()
.map(e -> String.valueOf(e.get("experiment_id")))
.filter(id -> id != null && !"null".equals(id))
.collect(Collectors.toList());
}
}
if (experimentIds.isEmpty()) {
log.info("[MLflow] getRunsByKubeflowRunId: experiment 0개 → runs:[]");
return ResponseEntity.ok("{\"runs\":[]}");
}
String escaped = kubeflowRunId.replace("'", "\\'").replace("\"", "\\\"");
String filter = "tags.kubeflow_run_id = '" + escaped + "'";
Map<String, Object> runsSearchBody = Map.of(
"experiment_ids", experimentIds,
"filter", filter,
"order_by", Collections.singletonList("attribute.start_time DESC"),
"max_results", 100
);
String runsResponse = webClient.post()
.uri("/runs/search")
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(runsSearchBody)
.retrieve()
.bodyToMono(String.class)
.block();
String body = runsResponse != null ? runsResponse : "{\"runs\":[]}";
int runCount = body.contains("\"run_id\"") ? body.split("\"run_id\"").length - 1 : 0;
log.info("[MLflow] getRunsByKubeflowRunId: kubeflowRunId={} → run 수 약 {}개", kubeflowRunId, runCount);
return ResponseEntity.ok(body);
} catch (Exception e) {
log.warn("[MLflow] getRunsByKubeflowRunId 실패: kubeflowRunId={}, error={}", kubeflowRunId, e.getMessage());
return ResponseEntity.internalServerError().body(e.getMessage());
}
} }
@Operation( @Operation(
@ -149,4 +329,57 @@ public class MlflowController {
.map(ResponseEntity::ok) .map(ResponseEntity::ok)
.onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().body(e.getMessage()))); .onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().body(e.getMessage())));
} }
@Operation(
summary = "Artifact 파일 다운로드 (MLflow 프록시)",
description = """
MLflow get-artifact API Run artifact .
MinIO (experiment_id/run_id/artifacts/...) MLflow NoSuchKey .
""",
responses = {
@ApiResponse(responseCode = "200", description = "파일 스트림"),
@ApiResponse(responseCode = "404", description = "Run 또는 artifact 없음"),
@ApiResponse(responseCode = "500", description = "서버 오류")
}
)
@GetMapping(value = "/artifacts/download", produces = MediaType.APPLICATION_OCTET_STREAM_VALUE)
public Mono<ResponseEntity<byte[]>> downloadArtifact(
@Parameter(description = "MLflow Run ID", required = true) @RequestParam(name = "run_id") String runId,
@Parameter(description = "artifact 상대 경로 (예: outputs/sample_model.txt)", required = true) @RequestParam String path) {
URI uri = UriComponentsBuilder.fromHttpUrl(mlflowServerRoot)
.path("/get-artifact")
.queryParam("run_id", runId)
.queryParam("path", path)
.build()
.toUri();
return artifactWebClient.get()
.uri(uri)
.exchangeToMono(response -> {
if (!response.statusCode().is2xxSuccessful()) {
return response.bodyToMono(String.class)
.defaultIfEmpty(response.statusCode().toString())
.map(msg -> ResponseEntity.status(response.statusCode()).body(msg.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
}
return response.bodyToMono(byte[].class)
.map(body -> {
var headers = response.headers().asHttpHeaders();
ResponseEntity.BodyBuilder builder = ResponseEntity.ok();
if (headers.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION) != null) {
builder = builder.header(
org.springframework.http.HttpHeaders.CONTENT_DISPOSITION,
headers.getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION)
);
}
if (headers.getContentType() != null) {
builder = builder.contentType(headers.getContentType());
}
return builder.body(body);
});
})
.onErrorResume(e -> {
log.warn("MLflow artifact download failed: run_id={}, path={}", runId, path, e);
return Mono.just(ResponseEntity.internalServerError()
.body(("Artifact download failed: " + e.getMessage()).getBytes(java.nio.charset.StandardCharsets.UTF_8)));
});
}
} }

@ -6,10 +6,10 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.responses.ApiResponses;
import kr.re.etri.autoflow.entity.StorageAttachmentEntity; import kr.re.etri.autoflow.entity.MinioAttachmentEntity;
import kr.re.etri.autoflow.entity.WorkflowEntity; import kr.re.etri.autoflow.entity.WorkflowEntity;
import kr.re.etri.autoflow.payload.request.CreateRunRequest; import kr.re.etri.autoflow.payload.request.CreateRunRequest;
import kr.re.etri.autoflow.service.StorageAttachmentService; import kr.re.etri.autoflow.service.MinioAttachmentService;
import kr.re.etri.autoflow.service.PipelineUploadService; import kr.re.etri.autoflow.service.PipelineUploadService;
import kr.re.etri.autoflow.service.WorkFlowService; import kr.re.etri.autoflow.service.WorkFlowService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
@ -36,7 +36,7 @@ public class PipelineUploadController {
private final PipelineUploadService pipelineUploadService; private final PipelineUploadService pipelineUploadService;
private final WorkFlowService workFlowService; private final WorkFlowService workFlowService;
private final StorageAttachmentService storageAttachmentService; private final MinioAttachmentService minioAttachmentService;
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Map<String, Object>> uploadPipeline( public ResponseEntity<Map<String, Object>> uploadPipeline(
@ -73,7 +73,7 @@ public class PipelineUploadController {
workFlowService.save(workflow); workFlowService.save(workflow);
// 2. MinIO 업로드 // 2. MinIO 업로드
StorageAttachmentEntity attachment = storageAttachmentService.uploadFile( MinioAttachmentEntity attachment = minioAttachmentService.uploadFile(
file, file,
"workflows/" + projectId, "workflows/" + projectId,
workflow.getId(), workflow.getId(),
@ -85,14 +85,14 @@ public class PipelineUploadController {
projectId projectId
); );
String minioUrl = storageAttachmentService.getFileUrl(attachment.getStoragePath()); String minioUrl = minioAttachmentService.getFileUrl(attachment.getStoragePath());
// 3. 최종 응답 // 3. 최종 응답
Map<String, Object> response = new HashMap<>(); Map<String, Object> response = new HashMap<>();
response.put("pipeline", result); response.put("pipeline", result);
response.put("workflow", workflow); response.put("workflow", workflow);
response.put("attachment", attachment); response.put("attachment", attachment);
response.put("storageUrl", minioUrl); response.put("minioUrl", minioUrl);
return ResponseEntity.ok(response); return ResponseEntity.ok(response);

@ -16,8 +16,7 @@ import org.hibernate.annotations.Comment;
public class RefreshToken { public class RefreshToken {
@Id @Id
@SequenceGenerator(name = "refreshtoken_seq", sequenceName = "tb_refreshtoken_seq", allocationSize = 1) @GeneratedValue(strategy = GenerationType.AUTO)
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "refreshtoken_seq")
private long id; private long id;
@OneToOne @OneToOne

@ -7,6 +7,6 @@ import java.util.List;
@Data @Data
public class KubeflowRunResponse { public class KubeflowRunResponse {
private List<KubeflowRunRequest> runs = new java.util.ArrayList<>(); private List<KubeflowRunRequest> runs;
private int totalSize; private int totalSize;
} }

@ -17,9 +17,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import kr.re.etri.autoflow.security.jwt.AuthEntryPointJwt; import kr.re.etri.autoflow.security.jwt.AuthEntryPointJwt;
import kr.re.etri.autoflow.security.jwt.AuthTokenFilter; import kr.re.etri.autoflow.security.jwt.AuthTokenFilter;
@ -106,37 +103,19 @@ public class WebSecurityConfig { // extends WebSecurityConfigurerAdapter {
// return http.build(); // return http.build();
// } // }
// 임시 설정
@Bean @Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.csrf(AbstractHttpConfigurer::disable) http.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource())) // CORS 설정 추가
.exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler)) .exceptionHandling(exception -> exception.authenticationEntryPoint(unauthorizedHandler))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> .authorizeHttpRequests(auth ->
auth.requestMatchers("/actuator/**").permitAll() auth.anyRequest().permitAll() // 모든 요청 허용
.anyRequest().permitAll()
); );
http.authenticationProvider(authenticationProvider()); http.authenticationProvider(authenticationProvider());
http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class); http.addFilterBefore(authenticationJwtTokenFilter(), UsernamePasswordAuthenticationFilter.class);
return http.build(); return http.build();
} }
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowCredentials(true);
configuration.addAllowedOrigin("http://localhost:3000");
configuration.addAllowedOrigin("http://10.10.11.144");
configuration.addAllowedOrigin("http://cuuva.com:2481");
configuration.addAllowedOrigin("http://210.217.121.58:2481");
configuration.addAllowedOrigin("http://172.28.248.98:30819");
configuration.addAllowedHeader("*");
configuration.addAllowedMethod("*");
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
} }

@ -3,12 +3,12 @@ package kr.re.etri.autoflow.service;
import io.minio.MinioClient; import io.minio.MinioClient;
import io.minio.RemoveObjectArgs; import io.minio.RemoveObjectArgs;
import kr.re.etri.autoflow.entity.DataGroupEntity; import kr.re.etri.autoflow.entity.DataGroupEntity;
import kr.re.etri.autoflow.entity.StorageAttachmentEntity; import kr.re.etri.autoflow.entity.MinioAttachmentEntity;
import kr.re.etri.autoflow.payload.request.ProjectBaseAndRefTypeRequest; import kr.re.etri.autoflow.payload.request.ProjectBaseAndRefTypeRequest;
import kr.re.etri.autoflow.payload.request.ProjectBaseSearchRequest; import kr.re.etri.autoflow.payload.request.ProjectBaseSearchRequest;
import kr.re.etri.autoflow.payload.request.ProjectRequest; import kr.re.etri.autoflow.payload.request.ProjectRequest;
import kr.re.etri.autoflow.repository.DataGroupRepository; import kr.re.etri.autoflow.repository.DataGroupRepository;
import kr.re.etri.autoflow.repository.StorageAttachmentRepository; import kr.re.etri.autoflow.repository.MinioAttachmentRepository;
import kr.re.etri.autoflow.specification.DataGroupSpecification; import kr.re.etri.autoflow.specification.DataGroupSpecification;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -36,7 +36,7 @@ public class DataGroupService {
private final DataGroupRepository dataGroupRepository; private final DataGroupRepository dataGroupRepository;
private final StorageAttachmentRepository storageAttachmentRepository; private final MinioAttachmentRepository minioAttachmentRepository;
private final DataGroupSpecification dataGroupSpecification; private final DataGroupSpecification dataGroupSpecification;
@ -133,11 +133,11 @@ public class DataGroupService {
} }
// 2. refId 기준으로 MinIO 첨부파일 조회 // 2. refId 기준으로 MinIO 첨부파일 조회
List<StorageAttachmentEntity> attachments = List<MinioAttachmentEntity> attachments =
storageAttachmentRepository.findAllByRefId(dataGroupId); minioAttachmentRepository.findAllByRefId(dataGroupId);
// 3. MinIO에서 파일 삭제 // 3. MinIO에서 파일 삭제
for (StorageAttachmentEntity attachment : attachments) { for (MinioAttachmentEntity attachment : attachments) {
try { try {
minioClient.removeObject( minioClient.removeObject(
RemoveObjectArgs.builder() RemoveObjectArgs.builder()
@ -146,7 +146,7 @@ public class DataGroupService {
.build() .build()
); );
// DB에서도 첨부파일 삭제 // DB에서도 첨부파일 삭제
storageAttachmentRepository.delete(attachment); minioAttachmentRepository.delete(attachment);
} catch (Exception e) { } catch (Exception e) {
log.error("MinIO 파일 삭제 실패: {}", attachment.getStoragePath(), e); log.error("MinIO 파일 삭제 실패: {}", attachment.getStoragePath(), e);
} }

@ -2,8 +2,8 @@ package kr.re.etri.autoflow.service;
import io.minio.MinioClient; import io.minio.MinioClient;
import io.minio.PutObjectArgs; import io.minio.PutObjectArgs;
import kr.re.etri.autoflow.entity.StorageAttachmentEntity; import kr.re.etri.autoflow.entity.MinioAttachmentEntity;
import kr.re.etri.autoflow.repository.StorageAttachmentRepository; import kr.re.etri.autoflow.repository.MinioAttachmentRepository;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
@ -33,7 +33,7 @@ public class DatasetService {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final StorageAttachmentRepository storageAttachmentRepository; private final MinioAttachmentRepository minioAttachmentRepository;
private static final String BASE_URL = "http://52.14.11.43:18010"; private static final String BASE_URL = "http://52.14.11.43:18010";
@ -111,7 +111,7 @@ public class DatasetService {
.defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE)
.build(); .build();
public StorageAttachmentEntity downloadDataset( public MinioAttachmentEntity downloadDataset(
String datasetName, String datasetName,
String path, String path,
Long refId, Long refId,
@ -195,7 +195,7 @@ public class DatasetService {
latch.await(); latch.await();
// DB 저장 시 size 컬럼 필수 // DB 저장 시 size 컬럼 필수
StorageAttachmentEntity attachment = StorageAttachmentEntity.builder() MinioAttachmentEntity attachment = MinioAttachmentEntity.builder()
.refId(refId) .refId(refId)
.refType(refType) .refType(refType)
.originalName(datasetName + ".zip") .originalName(datasetName + ".zip")
@ -210,7 +210,7 @@ public class DatasetService {
.size(totalBytes[0]) .size(totalBytes[0])
.build(); .build();
return storageAttachmentRepository.save(attachment); return minioAttachmentRepository.save(attachment);
} catch (Exception e) { } catch (Exception e) {
log.error("외부 API 다운로드 및 MinIO 업로드 실패", e); log.error("외부 API 다운로드 및 MinIO 업로드 실패", e);

@ -26,7 +26,8 @@ public class KubeflowRunService {
@Transactional(readOnly = true) @Transactional(readOnly = true)
public Page<KubeflowRunEntity> search(KubeflowRunSearchRequest request) { public Page<KubeflowRunEntity> search(KubeflowRunSearchRequest request) {
int pageIndex = request.getPage() > 0 ? request.getPage() - 1 : 0; // 프론트가 0-based page 전달 (0=첫페이지, 1=두번째페이지)
int pageIndex = Math.max(0, request.getPage());
Pageable pageable = PageRequest.of( Pageable pageable = PageRequest.of(
pageIndex, pageIndex,
@ -39,7 +40,7 @@ public class KubeflowRunService {
Specification<KubeflowRunEntity> spec = runSpecification.searchByConditions( Specification<KubeflowRunEntity> spec = runSpecification.searchByConditions(
request.getExperimentId(), // experimentId는 필수 request.getExperimentId(),
request.getSearchType(), request.getSearchType(),
request.getKeyword(), request.getKeyword(),
startDate, startDate,

@ -4,16 +4,22 @@ import kr.re.etri.autoflow.payload.request.CreateRunRequest;
import kr.re.etri.autoflow.payload.request.RunCreatedEvent; import kr.re.etri.autoflow.payload.request.RunCreatedEvent;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher; import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.InputStreamResource; import org.springframework.http.HttpEntity;
import org.springframework.http.*; import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import org.springframework.web.client.RestTemplate; import org.springframework.web.client.RestTemplate;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
import org.springframework.web.reactive.function.BodyInserters;
import org.springframework.web.reactive.function.client.WebClient; import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException; import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
@ -21,10 +27,13 @@ import org.springframework.web.util.UriUtils;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
import java.io.IOException; import java.io.IOException;
import java.net.URI;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Duration; import java.time.Duration;
import java.util.*; import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@Service @Service
@ -34,629 +43,323 @@ public class PipelineUploadService {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
private final WebClient webClient;
@Value("${kubeflow.url}") @Value("${kubeflow.url}")
private String kubeflowBaseUrl; private String kubeflowBaseUrl; // 예: http://192.168.10.135:32473/
private final WebClient webClient;
@Autowired @Autowired
private ApplicationEventPublisher eventPublisher; private ApplicationEventPublisher eventPublisher;
/** /**
* Pipeline * Pipeline
*/ */
public Map uploadPipeline( public Map uploadPipeline(MultipartFile file,
MultipartFile file, String name,
String name, String displayName,
String displayName, String description,
String description, String namespace) {
String namespace
) {
try { try {
MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
log.info(""" body.add("uploadfile", new MultipartInputStreamFileResource(file.getInputStream(), file.getOriginalFilename()));
===== Pipeline Upload Start =====
filename={}
name={}
displayName={}
description={}
namespace={}
""",
file.getOriginalFilename(),
name,
displayName,
description,
namespace
);
MultiValueMap<String, Object> body =
new LinkedMultiValueMap<>();
body.add(
"uploadfile",
new MultipartInputStreamFileResource(
file.getInputStream(),
file.getOriginalFilename()
)
);
HttpHeaders headers = new HttpHeaders(); HttpHeaders headers = new HttpHeaders();
headers.setContentType( headers.setContentType(MediaType.MULTIPART_FORM_DATA);
MediaType.MULTIPART_FORM_DATA
);
HttpEntity<MultiValueMap<String, Object>> HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
requestEntity =
new HttpEntity<>(body, headers);
URI uri = UriComponentsBuilder UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/pipelines/upload");
.fromHttpUrl(
normalizeBaseUrl(
kubeflowBaseUrl
)
)
.path("/apis/v2beta1/pipelines/upload")
.queryParamIfPresent(
"name",
optional(name)
)
.queryParamIfPresent(
"display_name",
optional(displayName)
)
.queryParamIfPresent(
"description",
optional(description)
)
.queryParamIfPresent(
"namespace",
optional(namespace)
)
.build(true)
.toUri();
log.info("Pipeline Upload URI={}", uri); if (name != null && !name.isBlank()) builder.queryParam("name", name);
if (displayName != null && !displayName.isBlank()) builder.queryParam("display_name", displayName);
ResponseEntity<Map> response = if (description != null && !description.isBlank()) builder.queryParam("description", description);
restTemplate.postForEntity( if (namespace != null && !namespace.isBlank()) builder.queryParam("namespace", namespace);
uri,
requestEntity,
Map.class
);
ResponseEntity<Map> response = restTemplate.postForEntity(builder.toUriString(), requestEntity, Map.class);
return response.getBody(); return response.getBody();
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException("Pipeline upload failed", e);
log.error(
"Pipeline upload failed",
e
);
throw new RuntimeException(
"Pipeline upload failed",
e
);
} }
} }
/** /**
* Run * Run
* runRequest display_name, pipeline_version_reference, runtime_config .
* KFP v2beta1 runtime_config , display_name .
* parameters , mlflow_experiment_name .
*/ */
public Map<String, Object> createRun( public Map<String, Object> createRun(CreateRunRequest runRequest) {
CreateRunRequest runRequest Set<String> allowedParamNames = getPipelineRootParameterNames(runRequest);
) { Map<String, Object> body = buildKfpRunRequestBody(runRequest, allowedParamNames);
log.debug("[KFP] CreateRun request body: {}", body);
Set<String> allowedParamNames =
getPipelineRootParameterNames(
runRequest
);
Map<String, Object> body =
buildKfpRunRequestBody(
runRequest,
allowedParamNames
);
String uri =
normalizeBaseUrl(kubeflowBaseUrl)
+ "/apis/v2beta1/runs";
log.info("Create Run URI={}", uri);
Map result = webClient.post() Map result = webClient.post()
.uri(uri) .uri(kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/runs")
.contentType( .contentType(MediaType.APPLICATION_JSON)
MediaType.APPLICATION_JSON
)
.bodyValue(body) .bodyValue(body)
.retrieve() .retrieve()
.onStatus( .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
status -> resp -> resp.bodyToMono(String.class)
status.is4xxClientError() .doOnNext(msg -> log.warn("[KFP] CreateRun error {}: {}", resp.statusCode(), msg))
|| status.is5xxServerError(), .map(msg -> new RuntimeException("KFP CreateRun failed: " + resp.statusCode() + " " + msg)))
response ->
response.bodyToMono(String.class)
.map(msg ->
new RuntimeException(
"KFP CreateRun failed: "
+ response.statusCode()
+ " "
+ msg
)
)
)
.bodyToMono(Map.class) .bodyToMono(Map.class)
.block(); .block();
if (result != null && // 이벤트 발행만 비동기로 처리
result.get("run_id") != null) { if (result != null && result.get("run_id") != null) {
String runId = (String) result.get("run_id");
String runId = CompletableFuture.runAsync(() -> eventPublisher.publishEvent(new RunCreatedEvent(runId)));
(String) result.get("run_id");
CompletableFuture.runAsync(() ->
eventPublisher.publishEvent(
new RunCreatedEvent(runId)
)
);
} }
return result; return result;
} }
/** /**
* Pipeline Version Spec * KFP pipeline version root .
* pipeline_version_reference pipeline_id, pipeline_version_id , null .
*/ */
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
private Set<String> getPipelineRootParameterNames( private Set<String> getPipelineRootParameterNames(CreateRunRequest runRequest) {
CreateRunRequest runRequest CreateRunRequest.PipelineVersionReference ref = runRequest != null ? runRequest.getPipeline_version_reference() : null;
) { if (ref == null || ref.getPipeline_id() == null || ref.getPipeline_id().isBlank()
|| ref.getPipeline_version_id() == null || ref.getPipeline_version_id().isBlank()) {
CreateRunRequest.PipelineVersionReference ref =
runRequest != null
? runRequest.getPipeline_version_reference()
: null;
if (ref == null
|| ref.getPipeline_id() == null
|| ref.getPipeline_version_id() == null) {
return null; return null;
} }
String url = kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/pipelines/" + ref.getPipeline_id() + "/versions/" + ref.getPipeline_version_id();
String url =
normalizeBaseUrl(kubeflowBaseUrl)
+ "/apis/v2beta1/pipelines/"
+ ref.getPipeline_id()
+ "/versions/"
+ ref.getPipeline_version_id();
try { try {
Map<String, Object> version = webClient.get()
Map<String, Object> version = .uri(url)
webClient.get() .accept(MediaType.APPLICATION_JSON)
.uri(url) .retrieve()
.retrieve() .bodyToMono(Map.class)
.bodyToMono(Map.class) .block();
.block(); if (version == null) return null;
Object spec = version.get("pipeline_spec");
if (version == null) { if (!(spec instanceof Map)) return null;
return null; Map<String, Object> specMap = (Map<String, Object>) spec;
} Object root = specMap.get("root");
if (root == null) root = specMap.get("Root");
Map<String, Object> spec = if (!(root instanceof Map)) return null;
(Map<String, Object>) Map<String, Object> rootMap = (Map<String, Object>) root;
version.get("pipeline_spec"); Object inputDef = rootMap.get("inputDefinitions");
if (inputDef == null) inputDef = rootMap.get("input_definitions");
if (spec == null) { if (!(inputDef instanceof Map)) return null;
return null; Map<String, Object> inputDefMap = (Map<String, Object>) inputDef;
} Object params = inputDefMap.get("parameters");
if (params == null) return Collections.emptySet();
Map<String, Object> root =
(Map<String, Object>)
spec.get("root");
if (root == null) {
root =
(Map<String, Object>)
spec.get("Root");
}
if (root == null) {
return null;
}
Map<String, Object> inputDefinitions =
(Map<String, Object>)
root.get("inputDefinitions");
if (inputDefinitions == null) {
inputDefinitions =
(Map<String, Object>)
root.get("input_definitions");
}
if (inputDefinitions == null) {
return null;
}
Object parameters =
inputDefinitions.get("parameters");
if (!(parameters instanceof Map<?, ?> map)) {
return Collections.emptySet();
}
Set<String> names = new HashSet<>(); Set<String> names = new HashSet<>();
if (params instanceof Map) {
for (Object key : map.keySet()) { for (Object key : ((Map<?, ?>) params).keySet()) {
names.add(key.toString()); if (key != null) names.add(key.toString());
}
return names;
} }
if (params instanceof Iterable) {
return names; for (Object item : (Iterable<?>) params) {
if (item instanceof Map) {
Object name = ((Map<?, ?>) item).get("name");
if (name != null) names.add(name.toString());
}
}
return names;
}
return Collections.emptySet();
} catch (Exception e) { } catch (Exception e) {
log.warn("[KFP] Pipeline version spec 조회 실패, parameters 필터 없이 전달 (mlflow_experiment_name 제외): {}", e.getMessage());
log.warn(
"Pipeline version spec 조회 실패",
e
);
return null; return null;
} }
} }
/** /**
* Run Request Body * KFP Run API body .
* v2beta1 Run: experiment_id, display_name(), runtime_config(), pipeline_version_reference .
* parameters . allowedParamNames null , mlflow_experiment_name .
*/ */
private Map<String, Object> buildKfpRunRequestBody( private Map<String, Object> buildKfpRunRequestBody(CreateRunRequest runRequest, Set<String> allowedParamNames) {
CreateRunRequest runRequest, Map<String, Object> body = new HashMap<>();
Set<String> allowedParamNames if (runRequest.getExperiment_id() != null && !runRequest.getExperiment_id().isBlank()) {
) { body.put("experiment_id", runRequest.getExperiment_id());
Map<String, Object> body =
new HashMap<>();
if (runRequest.getExperiment_id() != null &&
!runRequest.getExperiment_id().isBlank()) {
body.put(
"experiment_id",
runRequest.getExperiment_id()
);
} }
// display_name 필수 (KFP v2beta1)
body.put( body.put("display_name", runRequest.getDisplay_name() != null && !runRequest.getDisplay_name().isBlank()
"display_name", ? runRequest.getDisplay_name()
runRequest.getDisplay_name() != null : "Run");
&& !runRequest.getDisplay_name().isBlank() if (runRequest.getDescription() != null && !runRequest.getDescription().isBlank()) {
? runRequest.getDisplay_name() body.put("description", runRequest.getDescription());
: "Run"
);
if (runRequest.getDescription() != null &&
!runRequest.getDescription().isBlank()) {
body.put(
"description",
runRequest.getDescription()
);
} }
if (runRequest.getPipeline_version_reference() != null) {
if (runRequest.getPipeline_version_reference() body.put("pipeline_version_reference", runRequest.getPipeline_version_reference());
!= null) {
body.put(
"pipeline_version_reference",
runRequest.getPipeline_version_reference()
);
} }
if (runRequest.getService_account() != null && !runRequest.getService_account().isBlank()) {
Map<String, Object> runtimeConfig = body.put("service_account", runRequest.getService_account());
new HashMap<>(); }
// runtime_config 필수 (KFP v2beta1). 파이프라인에 정의된 파라미터만 전달. 정의되지 않으면 mlflow_experiment_name은 넣지 않음.
if (runRequest.getRuntime_config() != null Map<String, Object> runtimeConfig = new HashMap<>();
&& runRequest.getRuntime_config() if (runRequest.getRuntime_config() != null && runRequest.getRuntime_config().getParameters() != null
.getParameters() != null) { && !runRequest.getRuntime_config().getParameters().isEmpty()) {
Map<String, Object> params = runRequest.getRuntime_config().getParameters();
Map<String, Object> filtered = Map<String, Object> kfpParams = new HashMap<>();
new HashMap<>(); for (Map.Entry<String, Object> e : params.entrySet()) {
String key = e.getKey();
for (Map.Entry<String, Object> entry : if (key == null) continue;
runRequest.getRuntime_config() if (allowedParamNames != null) {
.getParameters() if (!allowedParamNames.contains(key)) continue;
.entrySet()) { } else {
// 스펙 조회 실패 시: mlflow_* 파라미터는 파이프라인에 없을 수 있으므로 제외
String key = entry.getKey(); if (key.startsWith("mlflow_")) continue;
if (allowedParamNames != null &&
!allowedParamNames.contains(key)) {
continue;
} }
kfpParams.put(key, e.getValue());
filtered.put(
key,
entry.getValue()
);
} }
if (!kfpParams.isEmpty()) {
if (!filtered.isEmpty()) { runtimeConfig.put("parameters", kfpParams);
runtimeConfig.put(
"parameters",
filtered
);
} }
} }
body.put("runtime_config", runtimeConfig);
body.put(
"runtime_config",
runtimeConfig
);
return body; return body;
} }
/** /**
* Experiments * Experiments
*/ */
public Map listExperiments( public Map listExperiments(String namespace, int pageSize, String pageToken) {
String namespace, try {
int pageSize, UriComponentsBuilder builder = UriComponentsBuilder
String pageToken .fromHttpUrl(kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/experiments");
) {
URI uri = UriComponentsBuilder if (namespace != null && !namespace.isBlank()) {
.fromHttpUrl( builder.queryParam("namespace", namespace);
normalizeBaseUrl( }
kubeflowBaseUrl if (pageSize > 0) {
) builder.queryParam("page_size", pageSize);
) }
.path("/apis/v2beta1/experiments") if (pageToken != null && !pageToken.isBlank()) {
.queryParamIfPresent( builder.queryParam("page_token", pageToken);
"namespace", }
optional(namespace)
)
.queryParamIfPresent(
"page_token",
optional(pageToken)
)
.queryParam(
"page_size",
pageSize
)
.build(true)
.toUri();
return webClient.get() return webClient.get()
.uri(uri) .uri(builder.toUriString())
.retrieve() .accept(MediaType.APPLICATION_JSON)
.bodyToMono(Map.class) .retrieve()
.block(); .bodyToMono(Map.class)
.block();
} catch (Exception e) {
throw new RuntimeException("Kubeflow Experiments 조회 실패", e);
}
} }
/** /**
* Experiment * Experiment
*/ */
public Map getExperimentById( public Map getExperimentById(String experimentId) {
String experimentId try {
) { String url = kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/experiments/" + experimentId;
String uri = return webClient.get()
normalizeBaseUrl(kubeflowBaseUrl) .uri(url)
+ "/apis/v2beta1/experiments/" .accept(MediaType.APPLICATION_JSON)
+ experimentId; .retrieve()
.bodyToMono(Map.class)
.block();
return webClient.get() } catch (Exception e) {
.uri(uri) throw new RuntimeException("Kubeflow experiment 조회 실패: " + experimentId, e);
.retrieve() }
.bodyToMono(Map.class)
.block();
} }
/** /**
* Run * KFP Run (v2beta1 GET /apis/v2beta1/runs/{run_id}).
* run_details.task_details[].pod_name UI Pod .
*/ */
public Map<String, Object> getKfpRunById( @SuppressWarnings("unchecked")
String runId public Map<String, Object> getKfpRunById(String runId) {
) { if (runId == null || runId.isBlank()) {
if (runId == null ||
runId.isBlank()) {
return null; return null;
} }
String url = kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/runs/" + runId.trim();
String uri =
normalizeBaseUrl(kubeflowBaseUrl)
+ "/apis/v2beta1/runs/"
+ runId.trim();
try { try {
return webClient.get() return webClient.get()
.uri(uri) .uri(url)
.accept(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)
.retrieve() .retrieve()
.bodyToMono(Map.class) .bodyToMono(Map.class)
.block(); .block();
} catch (WebClientResponseException e) { } catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) { if (e.getStatusCode().value() == 404) {
log.debug("[KFP] Run not found: {}", runId);
log.debug( } else {
"Run not found. runId={}", log.warn("[KFP] GetRun {}: {}", runId, e.getMessage());
runId
);
return null;
} }
return null;
throw e; } catch (Exception e) {
log.debug("[KFP] GetRun failed {}: {}", runId, e.getMessage());
return null;
} }
} }
/** /**
* Node Log * KFP ml-pipeline Pod (KFP UI ).
* {@code GET /apis/v1beta1/runs/{run_id}/nodes/{node_id}/log}
* <p>v2 Run ID . 404/ null.</p>
*/ */
public String getV1beta1RunNodeLog( public String getV1beta1RunNodeLog(String runId, String nodeId) {
String runId, if (runId == null || runId.isBlank() || nodeId == null || nodeId.isBlank()) {
String nodeId
) {
if (runId == null ||
nodeId == null) {
return null; return null;
} }
String base = kubeflowBaseUrl.replaceAll("/+$", "");
String uri = String encRun = UriUtils.encodePathSegment(runId.trim(), StandardCharsets.UTF_8);
normalizeBaseUrl(kubeflowBaseUrl) String encNode = UriUtils.encodePathSegment(nodeId.trim(), StandardCharsets.UTF_8);
+ "/apis/v1beta1/runs/" String url = base + "/apis/v1beta1/runs/" + encRun + "/nodes/" + encNode + "/log";
+ UriUtils.encodePathSegment(
runId,
StandardCharsets.UTF_8
)
+ "/nodes/"
+ UriUtils.encodePathSegment(
nodeId,
StandardCharsets.UTF_8
)
+ "/log";
try { try {
return webClient.get() return webClient.get()
.uri(uri) .uri(url)
.accept(MediaType.TEXT_PLAIN) .accept(MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON)
.retrieve() .retrieve()
.bodyToMono(String.class) .bodyToMono(String.class)
.timeout(Duration.ofSeconds(120)) .timeout(Duration.ofSeconds(120))
.block(); .block();
} catch (WebClientResponseException e) {
int code = e.getStatusCode().value();
if (code == 404 || code == 400) {
log.debug("[KFP] v1beta1 node log {} node={}: {}", runId, nodeId, code);
} else {
log.debug("[KFP] v1beta1 node log {} node={}: {}", runId, nodeId, e.getMessage());
}
return null;
} catch (Exception e) { } catch (Exception e) {
log.debug("[KFP] v1beta1 node log failed runId={} node={}: {}", runId, nodeId, e.getMessage());
log.debug(
"Node log 조회 실패",
e
);
return null; return null;
} }
} }
/** /**
* Run * KFP Run (v2beta1 DELETE /apis/v2beta1/runs/{run_id}).
*/ * (2xx) (404) . 4xx/5xx .
public void deleteKfpRun(
String runId,
String experimentId
) {
String uri =
normalizeBaseUrl(kubeflowBaseUrl)
+ "/apis/v2beta1/runs/"
+ runId;
if (experimentId != null &&
!experimentId.isBlank()) {
uri += "?experiment_id="
+ experimentId;
}
webClient.delete()
.uri(uri)
.retrieve()
.onStatus(
status ->
status.is4xxClientError()
|| status.is5xxServerError(),
response ->
response.bodyToMono(String.class)
.map(msg ->
new RuntimeException(
"KFP DeleteRun failed: "
+ msg
)
)
)
.toBodilessEntity()
.block();
}
/**
* Base URL normalize
*/
private String normalizeBaseUrl(
String baseUrl
) {
if (baseUrl == null ||
baseUrl.isBlank()) {
throw new IllegalArgumentException(
"kubeflow.url is empty"
);
}
baseUrl = baseUrl.trim();
if (baseUrl.endsWith("/")) {
baseUrl =
baseUrl.substring(
0,
baseUrl.length() - 1
);
}
return baseUrl;
}
private Optional<String> optional(
String value
) {
if (value == null ||
value.isBlank()) {
return Optional.empty();
}
return Optional.of(value);
}
/**
* Multipart Resource
*/ */
private static class MultipartInputStreamFileResource public void deleteKfpRun(String runId, String experimentId) {
extends InputStreamResource { String base = kubeflowBaseUrl.replaceAll("/+$", "");
String url = base + "/apis/v2beta1/runs/" + runId;
private final String filename; if (experimentId != null && !experimentId.isBlank()) {
url = url + "?experiment_id=" + experimentId;
public MultipartInputStreamFileResource(
java.io.InputStream inputStream,
String filename
) {
super(inputStream);
this.filename = filename;
} }
try {
@Override webClient.delete()
public String getFilename() { .uri(url)
return filename; .retrieve()
} .onStatus(status -> status.value() == 404, resp -> Mono.empty())
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
@Override resp -> resp.bodyToMono(String.class)
public long contentLength() { .doOnNext(msg -> log.warn("[KFP] DeleteRun error {}: {}", resp.statusCode(), msg))
return -1; .map(msg -> new RuntimeException("KFP DeleteRun failed: " + resp.statusCode() + " " + msg)))
.toBodilessEntity()
.block();
} catch (Exception e) {
if (e.getCause() instanceof RuntimeException) {
throw (RuntimeException) e.getCause();
}
throw new RuntimeException("KFP Run 삭제 실패: " + runId, e);
} }
} }
} }

@ -121,7 +121,10 @@ public class KubeflowRunSpecification {
return (root, query, cb) -> { return (root, query, cb) -> {
Predicate predicate = cb.conjunction(); Predicate predicate = cb.conjunction();
predicate = cb.and(predicate, cb.equal(root.get("experimentId"), experimentId)); // experimentId가 있을 때만 필터 (비어 있으면 전체 목록)
if (experimentId != null && !experimentId.isBlank()) {
predicate = cb.and(predicate, cb.equal(root.get("experimentId"), experimentId));
}
if (keyword != null && !keyword.isEmpty()) { if (keyword != null && !keyword.isEmpty()) {
if (searchType == null || searchType.isEmpty() || if (searchType == null || searchType.isEmpty() ||

@ -4,18 +4,14 @@ springdoc.swagger-ui.url=/v3/api-docs
springdoc.swagger-ui.doc-expansion=none springdoc.swagger-ui.doc-expansion=none
springdoc.swagger-ui.disable-swagger-default-url=true springdoc.swagger-ui.disable-swagger-default-url=true
# Local MariaDB spring.jpa.hibernate.ddl-auto=none
spring.datasource.url=jdbc:mariadb://${RDS_HOSTNAME:localhost}:3306/autoflow spring.sql.init.mode=never
spring.datasource.username=${RDS_USERNAME:cuuva}
spring.datasource.password=${RDS_PASSWORD:cuuva}
# Local MinIO # 스크립트 컴파일: Windows에서 서버 실행 시 Python 실행 파일 (python.org 설치 후 pip install kfp)
storage.provider=minio # kfp.compile.python-command=python
minio.endpoint=${MINIO_ENDPOINT:http://localhost:9000}
minio.access-key=minio
minio.secret-key=minio123
minio.bucket=mlpipeline
spring.jpa.hibernate.ddl-auto=update
spring.sql.init.mode=always
# MinIO type2(MLflow): wsl-port-forwards.sh 로 9001->9000 포워드 시 로컬에서 백엔드가 localhost:9001 로 접근
minio.type2.endpoint=http://localhost:9001
minio.type2.bucket=mlflow
minio.type2.access-key=minio-mlflow
minio.type2.secret-key=minio-mlflow-12345

@ -7,6 +7,3 @@ springdoc.swagger-ui.doc-expansion=none
springdoc.swagger-ui.disable-swagger-default-url=true springdoc.swagger-ui.disable-swagger-default-url=true
spring.jpa.hibernate.ddl-auto=none spring.jpa.hibernate.ddl-auto=none
spring.sql.init.mode=never spring.sql.init.mode=never
# ALB / Forwarded Headers
server.forward-headers-strategy=native

@ -1,7 +1,7 @@
#????? ?? ?? #????? ?? ??
server.port = 8080 server.port = 8080
spring.profiles.active=aws spring.profiles.active=local
spring.datasource.url=jdbc:mariadb://192.168.10.143:3306/autoflow spring.datasource.url=jdbc:mariadb://192.168.10.143:3306/autoflow
spring.datasource.driver-class-name=org.mariadb.jdbc.Driver spring.datasource.driver-class-name=org.mariadb.jdbc.Driver
@ -50,18 +50,58 @@ spring.servlet.multipart.max-request-size=500MB
springdoc.swagger-ui.tags-sorter=alpha springdoc.swagger-ui.tags-sorter=alpha
# Storage Provider (minio, s3, or filesystem) # MinIO (기본 단일 MinIO - MinIOConfig 등에서 사용)
storage.provider=minio
# Local FileSystem ??
storage.local.base-path=/app/storage
storage.local.default-bucket=mlpipeline
# MinIO ??
minio.endpoint=http://192.168.10.135:31795 minio.endpoint=http://192.168.10.135:31795
minio.access-key=minio minio.access-key=minio
minio.secret-key=minio123 minio.secret-key=minio123
minio.bucket=mlpipeline minio.bucket=mlpipeline
# Pod에서 접근할 MinIO 주소 (비우면 프론트에서 입력). YAML 생성 시 저장된 정보로 반영됨
minio.endpoint.pod=http://minio-service.kubeflow.svc.cluster.local:9000
# MinIO type1(Kubeflow/파이프라인), type2(MLflow) - DynamicMinioAttachmentService
minio.type1.endpoint=http://192.168.10.135:31795
minio.type1.bucket=mlpipeline
minio.type1.access-key=minio
minio.type1.secret-key=minio123
minio.type2.endpoint=http://192.168.10.135:31000
minio.type2.bucket=mlflow
# MLflow 아티팩트용 MinIO 계정 (YOLO 파이프라인과 동일: minio-mlflow / minio-mlflow-12345)
minio.type2.access-key=minio-mlflow
minio.type2.secret-key=minio-mlflow-12345
# KFP 스크립트 컴파일 (py → YAML). 서버에 Python3 + kfp 필요: pip install kfp
# Linux/WSL: python3, Windows: python
kfp.compile.python-command=python3
# Kubeflow
kubeflow.url=http://192.168.10.135:32473/
# Admin: Kubernetes Pod 상태/로그 (관리자 페이지에서 Pod Running 여부 및 로그 조회)
# kubectl get pods -n kubeflow --show-labels 로 확인한 라벨 기준
# Argo Server (포트포워드 예: kubectl -n argo port-forward svc/argo-server 2746:2746)
# 비우면 Argo 프록시 비활성. 설정 시 로그는 여기를 최우선 시도(실행 중 K8s·종료 후 MinIO 아카이브).
argo.server.url=
# argo.auth token 또는 서비스 계정 토큰 (비우면 무인증 — 클러스터 내부 직통 시)
argo.server.token=
argo.server.container=main
admin.k8s.enabled=true
# Argo Server URL이 있으면 로그 API를 KFP/K8s보다 먼저 시도
admin.logs.prefer-argo-server=true
# KFP ml-pipeline v1beta1 노드 로그 (Argo 실패 시)
admin.k8s.prefer-kfp-api-for-logs=true
admin.k8s.namespace=kubeflow
# 파이프라인 Run Pod 네임스페이스(쉼표 구분, 앞에서부터 시도). 멀티유저 KFP는 보통 프로필 NS (예: kubeflow-user-example-com). 비우면 admin.k8s.namespace 만 사용.
# admin.k8s.pipeline-pod-namespaces=kubeflow-user-example-com,kubeflow
admin.k8s.pipeline-pod-namespaces=
# KFP: ml-pipeline, cache-server, metadata-*, workflow-controller 등 (application-crd-id 공통)
admin.k8s.kfp.label-selector=application-crd-id=kubeflow-pipelines
# MLflow: app=mlflow-server
admin.k8s.mlflow.label-selector=app=mlflow-server
# MinIO: minio-datasets, minio-mlflow (app=minio 없음)
admin.k8s.minio.label-selector=app in (minio-datasets,minio-mlflow)
# Run별 Pod 조회 시 라벨 (라벨키=runId). 기본 pipeline/runid. Tekton 등은 tekton.dev/pipelineRun 등이 될 수 있음 → kubectl get pods -n kubeflow --show-labels 로 확인
# admin.k8s.run-pod-label=pipeline/runid
# MLflow # MLflow
mlflow.url=http://192.168.10.135:30128/ mlflow.url=http://192.168.10.135:30128/
@ -81,10 +121,4 @@ external.auth.sw-search-url=https://a659120d3e2ff43ff94087b29396fd96-1057696791.
cloud.aws.region.static=ap-northeast-2 cloud.aws.region.static=ap-northeast-2
cloud.aws.credentials.access-key=AKIA2UC3EPERDDR4UOWN cloud.aws.credentials.access-key=AKIA2UC3EPERDDR4UOWN
cloud.aws.credentials.secret-key=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 cloud.aws.credentials.secret-key=Ps7ShmtcemhhTmZi+aUCpSpfZxjqFGyy51qgDSGD
cloud.aws.s3.bucket=mlpipeline
# Spring Actuator Configuration
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=always
management.health.defaults.enabled=true

@ -1,47 +1,26 @@
-- tb_project -- tb_project (컬럼명 명시: Hibernate 스키마 순서)
--INSERT INTO `tb_project` VALUES INSERT INTO `tb_project` (id, del_yn, mod_date, mod_user_id, mod_user_nm, prj_cd, prj_desc, prj_end_dt, prj_nm, prj_start_dt, reg_date, reg_user_id, reg_user_nm) VALUES
--('2025-09-22','2025-09-22',1,'2025-09-22 14:28:51.507010','2025-09-22 14:28:51.507010','N','cuuva,admin','cuuva,admin','PRJ1758518911644','배터리 학습입니다.','AI 배터리 학습','cuuva,admin','cuuva,admin'); (1, 'N', '2025-09-22 14:28:51.507010', 'cuuva,admin', 'cuuva,admin', 'PRJ1758518911644', '배터리 학습입니다.', '2025-09-22', 'AI 배터리 학습', '2025-09-22', '2025-09-22 14:28:51.507010', 'cuuva,admin', 'cuuva,admin');
-- tb_role -- tb_role
INSERT IGNORE INTO `tb_role` VALUES INSERT INTO `tb_role` (id, name) VALUES
(1,'ROLE_USER'), (1,'ROLE_USER'),
(2,'ROLE_MODERATOR'), (2,'ROLE_MODERATOR'),
(3,'ROLE_ADMIN'); (3,'ROLE_ADMIN');
-- tb_user -- tb_user
INSERT IGNORE INTO tb_user ( INSERT INTO `tb_user` (id, username, email, password) VALUES
id, (5,'cuuva','cuuva@naver.com','$2a$10$UhWIoxGlxa7u9gks3m498u9tPGcGO2sh5PTeAD6319TJ9M67ZZqmO'),
username, (6,'admin','admin@naver.com','$2a$10$zukuiEA7Ce1ygOeJxZilhOi29jQnsreIswyJQ3Z.lysmKFiQhTXeS'),
email, (7,'user','user@naver.com','$2a$10$jkRSrScnLK.Qiy/AmapKmOVauP4tff.tIMnAzEd1mMoTvRCZXpU4u');
password
) VALUES
(
5,
'cuuva',
'cuuva@naver.com',
'$2a$10$UhWIoxGlxa7u9gks3m498u9tPGcGO2sh5PTeAD6319TJ9M67ZZqmO'
),
(
6,
'admin',
'admin@naver.com',
'$2a$10$zukuiEA7Ce1ygOeJxZilhOi29jQnsreIswyJQ3Z.lysmKFiQhTXeS'
),
(
7,
'user',
'user@naver.com',
'$2a$10$jkRSrScnLK.Qiy/AmapKmOVauP4tff.tIMnAzEd1mMoTvRCZXpU4u'
);
-- tb_user_project_map -- tb_user_project_map
INSERT IGNORE INTO `tb_user_project_map` VALUES INSERT INTO `tb_user_project_map` (id, project_id, user_id) VALUES
(1,1,6), (1,1,6),
(2,1,5); (2,1,5);
-- tb_user_project_permission -- tb_user_project_permission
INSERT IGNORE INTO `tb_user_project_permission` VALUES INSERT INTO `tb_user_project_permission` (user_project_id, permissions) VALUES
(2,'READ'), (2,'READ'),
(1,'READ'), (1,'READ'),
(2,'CREATE'), (2,'CREATE'),
@ -51,12 +30,17 @@ INSERT IGNORE INTO `tb_user_project_permission` VALUES
(2,'DELETE'), (2,'DELETE'),
(1,'DELETE'); (1,'DELETE');
-- tb_user_roles -- tb_user_roles (user_id, role_id) - user 7=USER, 6=MODERATOR, 5=ADMIN
INSERT IGNORE INTO `tb_user_roles` VALUES INSERT INTO `tb_user_roles` (user_id, role_id) VALUES
(1,7), (7,1),
(2,6), (6,2),
(3,5); (5,3);
-- Spring Batch 5.2 (MariaDB 10.3+): 시퀀스 생성 (최초 1회만 성공, 재실행 시 이미 있으면 무시하려면 continue-on-error=true)
CREATE SEQUENCE IF NOT EXISTS BATCH_JOB_SEQ START WITH 1 MINVALUE 1 MAXVALUE 9223372036854775806 INCREMENT BY 1 NOCACHE NOCYCLE ENGINE=InnoDB;
CREATE SEQUENCE IF NOT EXISTS BATCH_JOB_EXECUTION_SEQ START WITH 1 MINVALUE 1 MAXVALUE 9223372036854775806 INCREMENT BY 1 NOCACHE NOCYCLE ENGINE=InnoDB;
CREATE SEQUENCE IF NOT EXISTS BATCH_STEP_EXECUTION_SEQ START WITH 1 MINVALUE 1 MAXVALUE 9223372036854775806 INCREMENT BY 1 NOCACHE NOCYCLE ENGINE=InnoDB;
-- 테이블 생성 (이미 존재하면 생성 안 함) -- 테이블 생성 (이미 존재하면 생성 안 함)
CREATE TABLE IF NOT EXISTS BATCH_JOB_INSTANCE ( CREATE TABLE IF NOT EXISTS BATCH_JOB_INSTANCE (

Loading…
Cancel
Save