Compare commits

..

7 Commits

@ -0,0 +1,51 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## 1. Common Development Tasks
### Build and Run
- **Run the application (Spring Boot dev mode):** `./gradlew bootRun`
- **Build JAR:** `./gradlew build`
- **Run JAR:** `java -jar build/libs/autoflow-server-mgmt-0.0.1-SNAPSHOT.jar`
### Testing
- **Run all tests:** `./gradlew test`
## 2. High-Level Code Architecture and Structure
This project is the core management server for the AutoFlow system, built with Spring Boot. It manages machine learning pipelines, datasets, and integrates with external systems like Kubeflow, MLflow, and OTA.
### Key Features:
- **Authentication & Security:** JWT-based authentication with refresh tokens, cookie-based token storage, and fine-grained access control.
- **Project & Data Management:** Hierarchical management of projects, data groups, and datasets.
- **ML Pipeline & Workflow:** Integration with Kubeflow for experiment/run management and pipeline uploads; MLflow for experiment tracking.
- **File Management:** AWS S3 integration for large file storage and multipart uploads.
- **External Integrations:** OTA for external authentication/package search, Spring Batch for large data processing.
### Project Structure (`kr.re.etri.autoflow` package):
- `controllers`: API endpoints for authentication, projects, data, etc.
- `service`: Business logic implementation.
- `repository`: Data access layer using JPA.
- `entity`: Database entities.
- `security`: Spring Security and JWT handling.
- `batch`: Spring Batch job configurations.
- `payload`: Request/Response Data Transfer Objects (DTOs).
- `models`: Domain models.
- `exception`: Global exception handling.
- `common`: Common utilities and configurations.
### Technologies:
- **Language:** Java 17
- **Framework:** Spring Boot 3.5.6
- **Build Tool:** Gradle
- **Database:** MariaDB (JPA / Hibernate)
- **Security:** Spring Security, JWT
- **Storage:** AWS S3
- **Batch Processing:** Spring Batch
- **API Documentation:** Springdoc OpenAPI (Swagger UI)
### Configuration:
- The `src/main/resources/application.properties` file contains critical settings for the database, JWT secret, AWS S3, Kubeflow, and MLflow URLs. These must be configured for the application to run correctly.

@ -4,7 +4,7 @@ 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")
@ -69,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

@ -0,0 +1,60 @@
# Autoflow DB 생성 (WSL / 로컬)
## 1. MariaDB/MySQL이 이미 설치된 경우
### 1) DB와 사용자만 만들기
```bash
# WSL 또는 로컬 터미널에서 (root 비밀번호 입력 필요)
mysql -u root -p < scripts/init-autoflow-db.sql
```
또는 MySQL 클라이언트 접속 후:
```sql
SOURCE /경로/autoflow-server-mgmt-main/autoflow-server-mgmt/scripts/init-autoflow-db.sql;
```
### 2) 테이블 + 초기 데이터 (data.sql 사용)
**WSL 프로파일(`application-wsl.properties`)에서는 이미 아래가 설정되어 있습니다.**
- `spring.jpa.hibernate.ddl-auto=update` → 엔티티 기준으로 `tb_*` 테이블 자동 생성
- `spring.sql.init.mode=always` → 기동 시 **`src/main/resources/data.sql`** 자동 실행
`data.sql`에서 하는 일:
- BATCH_* 테이블 생성 (Spring Batch용)
- 초기 데이터 INSERT: `tb_role`, `tb_user`, `tb_project`, `tb_user_roles`
따라서 **DB(autoflow)와 사용자(autoflow)만 만든 뒤** 백엔드를 `--spring.profiles.active=wsl`로 실행하면, 테이블 생성과 data.sql 적용이 한 번에 이루어집니다. 별도 스키마 SQL 실행은 필요 없습니다.
---
## 2. MariaDB가 없을 때 (Docker로 설치)
```bash
docker run -d \
--name autoflow-mariadb \
-p 3306:3306 \
-e MARIADB_ROOT_PASSWORD=root \
-e MARIADB_DATABASE=autoflow \
-e MARIADB_USER=autoflow \
-e MARIADB_PASSWORD=autoflow \
mariadb:latest
```
이렇게 하면 `autoflow` DB와 사용자 `autoflow`/비밀번호 `autoflow`가 자동 생성됩니다.
그 다음 위 **1.2) 테이블 생성** 중 하나를 진행하면 됩니다.
---
## 3. application-wsl.properties와 맞추기
현재 WSL 설정 기준:
- **URL:** `jdbc:mariadb://localhost:3306/autoflow`
- **사용자:** `autoflow`
- **비밀번호:** `autoflow`
다른 포트/비밀번호를 쓰면 `application-wsl.properties``spring.datasource.*` 값을 같이 수정하면 됩니다.

@ -0,0 +1,20 @@
-- autoflow DB 및 사용자 생성 (MariaDB/MySQL)
-- 실행: root 또는 DB 관리자로 실행
-- 예: mysql -u root -p < init-autoflow-db.sql
-- 또는 mysql -u root -p 후 소스 또는 붙여넣기
CREATE DATABASE IF NOT EXISTS autoflow
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS 'autoflow'@'localhost' IDENTIFIED BY 'autoflow';
CREATE USER IF NOT EXISTS 'autoflow'@'%' IDENTIFIED BY 'autoflow';
GRANT ALL PRIVILEGES ON autoflow.* TO 'autoflow'@'localhost';
GRANT ALL PRIVILEGES ON autoflow.* TO 'autoflow'@'%';
FLUSH PRIVILEGES;
-- 확인
SELECT User, Host FROM mysql.user WHERE User = 'autoflow';
SHOW DATABASES LIKE 'autoflow';

@ -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()
@ -101,8 +102,8 @@ public class KubeflowRunBatchConfig {
.block(); .block();
if (response == null || response.getRuns() == null || response.getRuns().isEmpty()) { if (response == null || response.getRuns() == 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
} }
} }

@ -0,0 +1,29 @@
package kr.re.etri.autoflow.config;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
@Data
@Component
@ConfigurationProperties(prefix = "minio")
public class MinioTypeProperties {
private TypeConfig type1 = new TypeConfig();
private TypeConfig type2 = new TypeConfig();
@Data
public static class TypeConfig {
private String endpoint = "";
private String bucket = "";
private String accessKey = "";
private String secretKey = "";
}
public TypeConfig getByType(String type) {
if (type != null && "type2".equalsIgnoreCase(type.trim())) {
return type2;
}
return type1;
}
}

@ -0,0 +1,86 @@
package kr.re.etri.autoflow.controllers;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import kr.re.etri.autoflow.service.AdminService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
/**
* API: , ( ).
*/
@Tag(name = "관리자", description = "시스템 상태 조회 및 조치 API (관리자 전용)")
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminController {
private final AdminService adminService;
@Operation(summary = "시스템 상태 조회", description = "KFP, MLflow, MinIO HTTP 헬스만 조회 (30초 캐시)")
@GetMapping(value = "/status", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> getStatus() {
return ResponseEntity.ok(adminService.getStatus());
}
@Operation(summary = "Pod 상태 조회", description = "K8s Pod 상태만 별도 조회 (admin.k8s.enabled=true 시)")
@GetMapping(value = "/pods/status", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> getPodStatus() {
return ResponseEntity.ok(adminService.getPodStatus());
}
@Operation(summary = "서비스 재시작 요청", description = "kfp, mlflow, minio 중 하나. 실제 재시작은 K8s 등에서 별도 설정 필요.")
@PostMapping(value = "/restart/{service}", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> restart(
@PathVariable String service) {
return ResponseEntity.ok(adminService.requestRestart(service));
}
@Operation(summary = "Run별 Pod 목록", description = "KFP runId에 해당하는 파이프라인 실행 Pod 목록 (label: pipeline/runid). Executions 로그 버튼용.")
@GetMapping(value = "/pods/by-run", produces = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<Map<String, Object>> getPodsByRunId(
@RequestParam String runId) {
return ResponseEntity.ok(adminService.getPodsByRunId(runId));
}
@Operation(summary = "Pod 로그 조회", description = "지정 namespace/pod 로그. admin.k8s.enabled=true 필요.")
@GetMapping(value = "/pods/logs", produces = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<String> getPodLog(
@RequestParam String namespace,
@RequestParam String pod,
@RequestParam(required = false) String container,
@RequestParam(required = false) Integer tailLines) {
String log = adminService.getPodLog(namespace, pod, container, tailLines);
return ResponseEntity.ok(log != null ? log : "");
}
@Operation(summary = "Run Pod 로그", description = "기본: KFP와 같이 한 스텝(실패 스텝 우선, 없으면 마지막 스텝). allSteps=true 이면 전체. podName/stepName 으로 지정 가능.")
@GetMapping(value = "/pods/logs-by-run", produces = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<String> getPodLogsByRunId(
@RequestParam String runId,
@RequestParam(required = false) Integer tailLines,
@RequestParam(required = false, defaultValue = "false") boolean allSteps,
@RequestParam(required = false) String podName,
@RequestParam(required = false) String stepName,
@RequestParam(required = false) String workflowName,
@RequestParam(required = false) String workflowNamespace) {
String log =
adminService.getPodLogsByRunId(
runId, tailLines, allSteps, podName, stepName, workflowName, workflowNamespace);
return ResponseEntity.ok(log != null ? log : "");
}
@Operation(summary = "지정 Pod 목록 로그 합침", description = "관리자 페이지 Pod 카드용. pod 파라미터 반복 전달. tailLines 0 이하면 Pod당 전체(가능한 범위).")
@GetMapping(value = "/pods/logs-aggregate", produces = MediaType.TEXT_PLAIN_VALUE)
public ResponseEntity<String> getPodLogsAggregate(
@RequestParam String namespace,
@RequestParam(name = "pod") java.util.List<String> podNames,
@RequestParam(required = false) Integer tailLines) {
String log = adminService.getPodLogsAggregate(namespace, podNames, tailLines);
return ResponseEntity.ok(log != null ? log : "");
}
}

@ -23,6 +23,7 @@ import reactor.core.publisher.Mono;
import java.net.URLEncoder; import java.net.URLEncoder;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.time.Instant; import java.time.Instant;
import java.time.LocalDateTime;
import java.time.OffsetDateTime; import java.time.OffsetDateTime;
import java.time.ZoneId; import java.time.ZoneId;
import java.time.ZoneOffset; import java.time.ZoneOffset;
@ -30,6 +31,7 @@ import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import org.springframework.web.reactive.function.client.WebClientResponseException;
@Tag(name = "Experiments", description = "Kubeflow 및 MLflow Experiment API") @Tag(name = "Experiments", description = "Kubeflow 및 MLflow Experiment API")
@RestController @RestController
@ -154,16 +156,61 @@ public class ExperimentsController {
.bodyValue(kubeflowPayload) .bodyValue(kubeflowPayload)
.retrieve() .retrieve()
.bodyToMono(Map.class) .bodyToMono(Map.class)
.onErrorResume(WebClientResponseException.Conflict.class, e -> {
log.info("Kubeflow experiment 가 이미 존재합니다 (409 Conflict). 기존 experiment를 조회합니다.");
return webClientBuilder.build()
.get()
.uri(kubeflowBaseUrl + "/apis/v2beta1/experiments")
.retrieve()
.bodyToMono(Map.class)
.flatMap(listResp -> {
if (listResp != null && listResp.containsKey("experiments")) {
List<Map<String, Object>> experiments = (List<Map<String, Object>>) listResp.get("experiments");
for (Map<String, Object> exp : experiments) {
String expName = (String) exp.get("display_name");
if (saved.getDisplayName().equals(expName)) {
log.info("기존 Kubeflow experiment 발견: {}", exp);
Map<String, Object> mockResp = new HashMap<>();
String expId = exp.get("id") != null ? exp.get("id").toString() :
(exp.get("experiment_id") != null ? exp.get("experiment_id").toString() : null);
mockResp.put("experiment_id", expId);
mockResp.put("created_at", exp.get("created_at"));
return Mono.just(mockResp);
}
}
}
return Mono.error(new RuntimeException("Kubeflow experiment 가 존재한다고 하나 목록에서 일치하는 이름을 찾을 수 없습니다.", e));
});
})
.flatMap(kubeflowResp -> { .flatMap(kubeflowResp -> {
if (kubeflowResp.containsKey("experiment_id")) { if (kubeflowResp.containsKey("experiment_id") && kubeflowResp.get("experiment_id") != null) {
saved.setKubeFlowId((String) kubeflowResp.get("experiment_id")); saved.setKubeFlowId((String) kubeflowResp.get("experiment_id"));
} else if (kubeflowResp.containsKey("id") && kubeflowResp.get("id") != null) {
saved.setKubeFlowId((String) kubeflowResp.get("id"));
} }
if (kubeflowResp.containsKey("created_at")) {
if (kubeflowResp.containsKey("created_at") && kubeflowResp.get("created_at") != null) {
try {
String createdAtStr = (String) kubeflowResp.get("created_at");
saved.setKubeflowCreatedAt( saved.setKubeflowCreatedAt(
Instant.parse((String) kubeflowResp.get("created_at")) Instant.parse(createdAtStr)
.atZone(ZoneId.of("Asia/Seoul")) .atZone(ZoneId.of("Asia/Seoul"))
.toLocalDateTime() .toLocalDateTime()
); );
} catch (Exception parseEx) {
log.warn("Kubeflow created_at 파싱 실패 (Instant), OffsetDateTime 파싱 시도", parseEx);
try {
String createdAtStr = (String) kubeflowResp.get("created_at");
saved.setKubeflowCreatedAt(
OffsetDateTime.parse(createdAtStr)
.atZoneSameInstant(ZoneId.of("Asia/Seoul"))
.toLocalDateTime()
);
} catch (Exception parseEx2) {
log.error("Kubeflow created_at 최종 파싱 실패, 현재 시간으로 설정", parseEx2);
saved.setKubeflowCreatedAt(LocalDateTime.now());
}
}
} }
log.info("Kubeflow experiment 등록 완료: {}", kubeflowResp); log.info("Kubeflow experiment 등록 완료: {}", kubeflowResp);
@ -171,7 +218,6 @@ public class ExperimentsController {
// 2⃣ MLflow 등록 // 2⃣ MLflow 등록
Map<String, Object> mlflowPayload = new HashMap<>(); Map<String, Object> mlflowPayload = new HashMap<>();
mlflowPayload.put("name", saved.getDisplayName()); mlflowPayload.put("name", saved.getDisplayName());
//mlflowPayload.put("artifact_location", "/default/artifacts");
return webClientBuilder.build() return webClientBuilder.build()
.post() .post()
@ -181,6 +227,29 @@ public class ExperimentsController {
.bodyValue(mlflowPayload) .bodyValue(mlflowPayload)
.retrieve() .retrieve()
.bodyToMono(Map.class) .bodyToMono(Map.class)
.onErrorResume(WebClientResponseException.BadRequest.class, ex -> {
log.info("MLflow experiment 가 이미 존재할 가능성이 있습니다 (400 Bad Request). 이름으로 조회합니다.");
try {
String encodedName = URLEncoder.encode(saved.getDisplayName(), StandardCharsets.UTF_8.toString());
return webClientBuilder.build()
.get()
.uri(mlflowBaseUrl + "/api/2.0/mlflow/experiments/get-by-name?experiment_name=" + encodedName)
.headers(headers -> headers.setBasicAuth(mlflowUser, mlflowPassword))
.retrieve()
.bodyToMono(Map.class)
.flatMap(getByNameResp -> {
if (getByNameResp != null && getByNameResp.containsKey("experiment")) {
Map<String, Object> exp = (Map<String, Object>) getByNameResp.get("experiment");
Map<String, Object> mockCreateResp = new HashMap<>();
mockCreateResp.put("experiment_id", exp.get("experiment_id"));
return Mono.just(mockCreateResp);
}
return Mono.error(new RuntimeException("MLflow experiment 가 존재한다고 하나 이름으로 찾을 수 없습니다.", ex));
});
} catch (Exception e) {
return Mono.error(e);
}
})
.flatMap(createResp -> { .flatMap(createResp -> {
log.info("MLflow experiment 등록 완료: {}", createResp); log.info("MLflow experiment 등록 완료: {}", createResp);
String mlflowExpId = (String) createResp.get("experiment_id"); String mlflowExpId = (String) createResp.get("experiment_id");
@ -206,7 +275,14 @@ public class ExperimentsController {
}); });
}); });
}) })
.doOnError(e -> log.error("Experiment 등록 실패", e)); .doOnError(e -> {
if (e instanceof WebClientResponseException) {
WebClientResponseException we = (WebClientResponseException) e;
log.error("Experiment 등록 중 외부 API 오류 발생. 상태코드={}, 응답바디={}", we.getStatusCode(), we.getResponseBodyAsString(), we);
} else {
log.error("Experiment 등록 실패", e);
}
});
} }
@Operation(summary = "Experiment 수정") @Operation(summary = "Experiment 수정")

@ -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.StorageAttachmentService 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 uri = String.format("/runs/get?run_id=%s", runId);
String runBody = webClient.get()
return webClient.get()
.uri(uri) .uri(uri)
.retrieve() .retrieve()
.bodyToMono(String.class) .bodyToMono(String.class)
.map(ResponseEntity::ok) .block();
.onErrorResume(e -> Mono.just(ResponseEntity.internalServerError().body(e.getMessage()))); 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());
}
}
@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) {
if (kubeflowRunId == null || (kubeflowRunId = kubeflowRunId.trim()).isBlank()) {
log.info("[MLflow] getRunsByKubeflowRunId: kubeflowRunId 비어 있음 → runs:[]");
return ResponseEntity.ok("{\"runs\":[]}");
}
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)));
});
}
} }

@ -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 StorageAttachmentService 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( StorageAttachmentEntity 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);

@ -0,0 +1,94 @@
package kr.re.etri.autoflow.entity;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.Comment;
import org.springframework.data.annotation.CreatedDate;
import org.springframework.data.jpa.domain.support.AuditingEntityListener;
import java.time.LocalDateTime;
@Schema(description = "MinIO 첨부파일 (Dataset/TrainingScript 통합)")
@Comment("MinIO 첨부파일")
@Entity
@EntityListeners(AuditingEntityListener.class)
@Table(name = "tb_minio_attachment")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MinioAttachmentEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Schema(description = "첨부파일 ID", example = "1")
@Comment("첨부파일 ID")
private Long id;
@Schema(description = "연관 엔티티 ID", example = "1")
@Comment("연관 엔티티 ID")
@Column(nullable = false)
private Long refId;
@Schema(description = "첨부파일 종류 (DATASET / SCRIPT)", example = "DATASET")
@Comment("첨부파일 종류")
@Column(nullable = false, length = 50)
private String refType; // 구분자 (예: WORKFLOW_STEP,DATASET, TRAINING_SCRIPT)
@Schema(description = "원본 파일명", example = "step1.yaml")
@Comment("원본 파일명")
@Column(nullable = false, length = 255)
private String originalName;
@Schema(description = "저장된 파일명(UUID + ver)", example = "a1b2c3d4-step1-ver.1.yaml")
@Comment("저장된 파일명")
@Column(nullable = false, length = 255)
private String storedName;
@Schema(description = "MIME 타입", example = "application/x-yaml")
@Comment("MIME 타입")
@Column(nullable = false, length = 100)
private String contentType;
@Schema(description = "파일 크기(byte)", example = "2048")
@Comment("파일 크기")
@Column(nullable = false)
private Long size;
@Schema(description = "스토리지 경로", example = "/uploads/step1-ver.1.yaml")
@Comment("스토리지 경로")
@Column(nullable = false, length = 500)
private String storagePath;
@Schema(description = "업로더 ID", example = "admin")
@Comment("업로더 ID")
@Column(nullable = false, length = 50)
private String regUserId;
@Schema(description = "업로드 일시", example = "2025-09-17T15:00:00")
@CreatedDate
@Comment("업로드 일시")
@Column(nullable = false, updatable = false)
private LocalDateTime regDt;
@Schema(description = "파일 제목", example = "자율주행차량 데이터 셋")
@Comment("파일 제목")
@Column(length = 200)
private String title;
@Schema(description = "파일 버전", example = "1")
@Comment("파일 버전")
@Column(nullable = false)
private Integer version;
@Schema(description = "파일 설명", example = "자율주행차량 데이터 모음집입니다.")
@Comment("파일 설명")
@Column(length = 1000)
private String description;
@Schema(description = "프로젝트 아이디", example = "1", defaultValue = "0")
@Column(nullable = false)
private Long projectId;
}

@ -16,8 +16,8 @@ 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.SEQUENCE, generator = "refreshtoken_seq") @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "refreshtoken_seq")
@SequenceGenerator(name = "refreshtoken_seq", sequenceName = "tb_refreshtoken_seq", allocationSize = 1)
private long id; private long id;
@OneToOne @OneToOne

@ -0,0 +1,32 @@
package kr.re.etri.autoflow.payload.request;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.util.List;
@Data
public class ScriptMergeRequest {
@Schema(description = "머지할 스크립트(첨부파일) ID 목록", example = "[1,2,3]")
private List<Long> scriptIds;
@Schema(description = "머지 결과 스크립트 제목", example = "merged-preprocess-train-eval")
private String title;
@Schema(description = "머지 결과 설명", example = "preprocess → train → eval 파이프라인")
private String description;
@Schema(description = "연관 refId (Training Script 그룹 ID 등)", example = "0", defaultValue = "0")
private Long refId;
@Schema(description = "연관 refType", example = "TRAINING_SCRIPT", defaultValue = "TRAINING_SCRIPT")
private String refType = "TRAINING_SCRIPT";
@Schema(description = "등록 사용자 ID", example = "admin")
private String regUserId;
@Schema(description = "프로젝트 ID", example = "1")
private Long projectId;
}

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

@ -0,0 +1,16 @@
package kr.re.etri.autoflow.repository;
import kr.re.etri.autoflow.entity.MinioAttachmentEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository;
import java.util.List;
import java.util.Optional;
@Repository
public interface MinioAttachmentRepository extends JpaRepository<MinioAttachmentEntity, Long>, JpaSpecificationExecutor<MinioAttachmentEntity> {
//최신버전 파일 가져오기
Optional<MinioAttachmentEntity> findTopByRefIdAndRefTypeOrderByVersionDesc(Long refId, String refType);
List<MinioAttachmentEntity> findAllByRefId(Long refId);
}

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

@ -0,0 +1,797 @@
package kr.re.etri.autoflow.service;
import io.minio.ListBucketsArgs;
import io.minio.MinioClient;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.HashMap;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* (KFP, MLflow, MinIO).
* 30 .
*
* :
* - KFP/MLflow: URL HTTP 2xx "정상" (API ).
* - MinIO: listBuckets() "정상".
*
* Pod Running Kubernetes API Pod .
* (: admin.k8s.enabled=true, namespace/label CoreV1Api.listNamespacedPod phase == "Running" .
* kubernetes-client-api .)
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AdminService {
private static final int CACHE_SECONDS = 30;
private static final String KFP_ARCHIVE_BUCKET = "mlpipeline";
private static final Pattern WAIT_LOG_ARCHIVE_KEY =
Pattern.compile("\\bkey:\\s*([\\w\\-./]+/main\\.log)\\b");
private final RestTemplate restTemplate;
private final KubernetesPodHealthService podHealthService;
private final PipelineUploadService pipelineUploadService;
private final ArgoServerLogService argoServerLogService;
@Value("${kubeflow.url:}")
private String kubeflowUrl;
@Value("${mlflow.url:}")
private String mlflowUrl;
@Value("${minio.endpoint:}")
private String minioEndpoint;
@Value("${minio.access-key:}")
private String minioAccessKey;
@Value("${minio.secret-key:}")
private String minioSecretKey;
/** true면 KFP ml-pipeline v1beta1 노드 로그 API를 kubectl보다 먼저 시도 (클러스터 내부 조회, UI와 동일 경로) */
@Value("${admin.k8s.prefer-kfp-api-for-logs:true}")
private boolean preferKfpApiForLogs;
/** true면 Argo Server 로그 API를 KFP/K8s보다 먼저 시도 (삭제된 Pod는 MinIO 아카이브) */
@Value("${admin.logs.prefer-argo-server:true}")
private boolean preferArgoServer;
private volatile Map<String, Object> cachedStatus;
private volatile long cachedAt;
/**
* KFP, MLflow, MinIO . 30 .
*/
public Map<String, Object> getStatus() {
long now = System.currentTimeMillis();
if (cachedStatus != null && (now - cachedAt) < CACHE_SECONDS * 1000L) {
return new HashMap<>(cachedStatus);
}
Map<String, Object> status = new HashMap<>();
status.put("kfp", checkKfp());
status.put("mlflow", checkMlflow());
status.put("minio", checkMinio());
status.put("updatedAt", Instant.now().toString());
cachedStatus = status;
cachedAt = now;
return new HashMap<>(status);
}
/**
* Kubernetes Pod ( ).
* admin.k8s.enabled=false kfp/mlflow/minio "Pod 없음" .
*/
public Map<String, Object> getPodStatus() {
Map<String, Object> out = new HashMap<>();
if (podHealthService == null || !podHealthService.isEnabled()) {
out.put("namespace", "");
out.put("message", "admin.k8s.enabled=false");
Map<String, Object> disabled = new HashMap<>();
disabled.put("ok", false);
disabled.put("message", "admin.k8s.enabled=true 설정 후 Pod 상태 조회 가능");
disabled.put("running", 0);
disabled.put("total", 0);
disabled.put("pods", java.util.Collections.emptyList());
out.put("kfp", disabled);
out.put("mlflow", new HashMap<>(disabled));
out.put("minio", new HashMap<>(disabled));
out.put("updatedAt", Instant.now().toString());
return out;
}
out.put("namespace", podHealthService.getNamespace());
out.put("kfp", podHealthService.getKfpPodStatus());
out.put("mlflow", podHealthService.getMlflowPodStatus());
out.put("minio", podHealthService.getMinioPodStatus());
out.put("updatedAt", Instant.now().toString());
return out;
}
private Map<String, Object> checkKfp() {
Map<String, Object> out = new HashMap<>();
String base = (kubeflowUrl != null ? kubeflowUrl.replaceAll("/+$", "") : "").trim();
if (base.isBlank()) {
out.put("status", "skip");
out.put("message", "kubeflow.url 미설정");
return out;
}
String url = base + "/apis/v2beta1/healthz";
try {
ResponseEntity<String> resp = restTemplate.getForEntity(url, String.class);
out.put("status", resp.getStatusCode().is2xxSuccessful() ? "ok" : "error");
out.put("message", "HTTP " + resp.getStatusCode().value());
} catch (Exception e) {
out.put("status", "error");
out.put("message", e.getMessage() != null ? e.getMessage() : "연결 실패");
log.debug("[Admin] KFP health check failed: {}", e.getMessage());
}
return out;
}
private Map<String, Object> checkMlflow() {
Map<String, Object> out = new HashMap<>();
String base = (mlflowUrl != null ? mlflowUrl.replaceAll("/+$", "") : "").trim();
if (base.isBlank()) {
out.put("status", "skip");
out.put("message", "mlflow.url 미설정");
return out;
}
String url = base + "/health";
try {
ResponseEntity<String> resp = restTemplate.getForEntity(url, String.class);
out.put("status", resp.getStatusCode().is2xxSuccessful() ? "ok" : "error");
out.put("message", "HTTP " + resp.getStatusCode().value());
} catch (Exception e) {
out.put("status", "error");
out.put("message", e.getMessage() != null ? e.getMessage() : "연결 실패");
log.debug("[Admin] MLflow health check failed: {}", e.getMessage());
}
return out;
}
/**
* MinIO / (accessKey/secretKey) .
* listBuckets() · .
*/
private Map<String, Object> checkMinio() {
Map<String, Object> out = new HashMap<>();
String endpoint = (minioEndpoint != null
? minioEndpoint.replaceAll("/+$", "") : "").trim();
if (endpoint.isBlank()) {
out.put("status", "skip");
out.put("message", "MinIO endpoint 미설정");
return out;
}
String accessKey = minioAccessKey;
String secretKey = minioSecretKey;
if (accessKey == null) accessKey = "";
if (secretKey == null) secretKey = "";
try {
MinioClient client = MinioClient.builder()
.endpoint(endpoint)
.credentials(accessKey, secretKey)
.build();
client.listBuckets(ListBucketsArgs.builder().build());
out.put("status", "ok");
out.put("message", "연결 정상 (동일 계정)");
} catch (Exception e) {
out.put("status", "error");
out.put("message", e.getMessage() != null ? e.getMessage() : "연결 실패");
log.debug("[Admin] MinIO health check failed: {}", e.getMessage());
}
return out;
}
/**
* . K8s API .
* .
*/
public Map<String, Object> requestRestart(String service) {
log.info("[Admin] restart requested for service: {}", service);
Map<String, Object> out = new HashMap<>();
if (service == null || !java.util.Set.of("kfp", "mlflow", "minio").contains(service.toLowerCase())) {
out.put("success", false);
out.put("message", "지원하지 않는 서비스입니다. (kfp, mlflow, minio 중 하나)");
return out;
}
out.put("success", true);
out.put("message", "재시작 요청이 접수되었습니다. 실제 재시작은 Kubernetes 등에서 별도 설정이 필요합니다.");
return out;
}
/**
* runId(KFP run id) Pod . Executions .
*/
public Map<String, Object> getPodsByRunId(String runId) {
if (podHealthService == null || !podHealthService.isEnabled()) {
Map<String, Object> out = new HashMap<>();
out.put("namespace", "");
out.put("pods", java.util.Collections.emptyList());
out.put("message", "admin.k8s.enabled=false");
return out;
}
Map<String, Object> out = podHealthService.listPodsByRunId(runId);
@SuppressWarnings("unchecked")
List<Map<String, String>> pods = (List<Map<String, String>>) out.get("pods");
if (pods != null && !pods.isEmpty()) {
return out;
}
Map<String, Object> run = pipelineUploadService.getKfpRunById(runId);
List<Map<String, Object>> tasks = kfpTasksWithPods(run);
if (tasks.isEmpty()) {
return out;
}
String ns = podHealthService.getNamespace();
List<Map<String, String>> fromKfp = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (Map<String, Object> t : tasks) {
String pod = firstString(t.get("podName"), t.get("pod_name"));
if (pod == null || pod.isBlank() || !seen.add(pod)) {
continue;
}
Map<String, String> e = new LinkedHashMap<>();
e.put("name", pod);
e.put("phase", kfpStateToPhase(t.get("state")));
e.put("displayName", firstString(t.get("displayName"), t.get("display_name")));
fromKfp.add(e);
}
if (!fromKfp.isEmpty()) {
out.put("namespace", ns);
out.put("pods", fromKfp);
out.put("message", fromKfp.size() + " pods (KFP run_details.task_details)");
out.remove("hint");
}
return out;
}
/**
* Pod . admin.k8s.enabled=true .
*/
public String getPodLog(String namespace, String podName, String container, Integer tailLines) {
if (podHealthService == null || !podHealthService.isEnabled()) {
return "K8s Pod 로그는 비활성화되어 있습니다. (admin.k8s.enabled=true 설정 필요)";
}
return podHealthService.getPodLog(namespace, podName, container, tailLines);
}
/**
* Run . (allSteps=false): KFP UI <b> </b> Pod, Pod.
* allSteps=true task_details Pod .
* podName Pod. stepName displayName .
* Pod {@code admin.k8s.pipeline-pod-namespaces} .
*/
public String getPodLogsByRunId(
String runId,
Integer tailLines,
boolean allSteps,
String podNameParam,
String stepNameParam,
String workflowNameParam,
String workflowNamespaceParam) {
if (podHealthService == null || !podHealthService.isEnabled()) {
if (argoServerLogService != null
&& argoServerLogService.isConfigured()
&& runId != null
&& !runId.isBlank()
&& podNameParam != null
&& !podNameParam.isBlank()) {
String wfNs =
workflowNamespaceParam != null && !workflowNamespaceParam.isBlank()
? workflowNamespaceParam.trim()
: "kubeflow";
String wfName =
workflowNameParam != null && !workflowNameParam.isBlank()
? workflowNameParam.trim()
: ArgoServerLogService.guessWorkflowNameFromExecutorPod(podNameParam.trim());
if (wfName != null) {
ArgoServerLogService.ArgoLogFetchResult al =
argoServerLogService.fetchWorkflowPodLog(
wfNs, wfName, podNameParam.trim(), normalizeTail(tailLines));
if (isSubstantialArgoLogBody(al != null ? al.logText : null)) {
return "-- Argo Server /api/v1/workflows/"
+ wfNs
+ "/"
+ wfName
+ "/log (kubectl 없음) --\n\n"
+ al.logText;
}
}
}
if (runId != null && !runId.isBlank()
&& podNameParam != null && !podNameParam.isBlank()
&& kubeflowUrl != null && !kubeflowUrl.isBlank()) {
String kfpOnly = tryKfpMlPipelineNodeLog(runId, podNameParam.trim());
if (kfpOnly != null) {
return "-- KFP ml-pipeline API (kubectl 없이) v1beta1 노드 로그 --\n\n" + kfpOnly;
}
}
return "K8s Pod 로그는 비활성화되어 있습니다. (admin.k8s.enabled=true 설정 필요)";
}
if (runId == null || runId.isBlank()) {
return "runId가 없습니다.";
}
if (podNameParam != null && !podNameParam.isBlank()) {
KubernetesPodHealthService.ExecutorPodResolution res0 =
podHealthService.resolveKfpExecutorImplPod(runId, podNameParam.trim());
String wfNsPod =
workflowNamespaceParam != null && !workflowNamespaceParam.isBlank()
? workflowNamespaceParam.trim()
: res0.namespace;
ArgoServerLogService.ArgoLogFetchResult argoDebug = null;
if (preferArgoServer && argoServerLogService.isConfigured()) {
argoDebug =
tryArgoWorkflowLogForStep(
wfNsPod,
workflowNameParam,
res0.podName,
podNameParam.trim(),
tailLines);
if (argoDebug != null && isSubstantialArgoLogBody(argoDebug.logText)) {
return "-- Argo Server API (Pod 종료 후 MinIO 아카이브 포함) | wf="
+ wfNsPod
+ " | pod="
+ res0.podName
+ " --\n\n"
+ argoDebug.logText;
}
}
if (preferKfpApiForLogs) {
String kfpFirst = tryKfpMlPipelineNodeLog(runId, res0.podName, podNameParam.trim());
if (kfpFirst != null) {
return "-- KFP ml-pipeline API (UI와 동일) | node: " + res0.podName
+ (podNameParam.trim().equals(res0.podName) ? "" : " (요청: " + podNameParam.trim() + ")")
+ " --\n\n" + kfpFirst;
}
}
KubernetesPodHealthService.ExecutorPodResolution res =
podHealthService.resolveKfpExecutorImplPod(runId, podNameParam.trim());
String logText = podHealthService.readPodLogInNamespace(
res.namespace, res.podName, normalizeTail(tailLines));
if (logText != null && logText.startsWith("로그 조회 실패")) {
KubernetesPodHealthService.PipelinePodLogOutcome fb =
podHealthService.readPipelinePodLog(res.podName, null, normalizeTail(tailLines));
logText = fb.logText;
}
String out = "-- kubectl logs " + res.podName + " -n " + res.namespace
+ (podNameParam.trim().equals(res.podName) ? "" : " (요청: " + podNameParam.trim() + ")")
+ " --\n\n" + (logText != null ? logText : "");
String archived = tryFetchArchivedMainLogFromWait(logText);
if (archived != null) {
out = out + "\n\n" + archived;
}
if ((logText == null || logText.trim().isEmpty() || logText.startsWith("로그 조회 실패"))
&& argoDebug != null
&& argoDebug.url != null
&& !argoDebug.url.isBlank()) {
out =
out
+ "\n\n-- Argo Server 시도(진단) --\n"
+ "url: "
+ argoDebug.url
+ "\nstatus: "
+ (argoDebug.statusCode != null ? argoDebug.statusCode : "(none)")
+ "\nerror: "
+ (argoDebug.error != null ? argoDebug.error : "(none)")
+ "\n(설정: argo.server.url / 필요시 argo.server.token)";
}
return out;
}
Map<String, Object> run = pipelineUploadService.getKfpRunById(runId);
List<Map<String, Object>> tasks = kfpTasksWithPods(run);
if (tasks.isEmpty()) {
String hint =
"KFP Run에 task_details/pod_name이 없거나 API 조회 실패입니다. "
+ "멀티유저 환경이면 application.properties 에 "
+ "admin.k8s.pipeline-pod-namespaces=파이프라인Pod가있는NS 를 설정하세요.\n";
return hint + podHealthService.aggregatePodLogsByRunId(runId, tailLines);
}
if (stepNameParam != null && !stepNameParam.isBlank()) {
String hint = stepNameParam.trim().toLowerCase();
for (Map<String, Object> t : tasks) {
String dn = firstString(t.get("displayName"), t.get("display_name"));
if (dn != null && dn.toLowerCase().contains(hint)) {
return formatSingleTaskLog(runId, t, tailLines, workflowNameParam, workflowNamespaceParam);
}
}
return "스텝 표시명에 \"" + stepNameParam + "\" 가 포함된 task를 찾지 못했습니다. (allSteps=true 로 전체 확인 가능)";
}
if (allSteps) {
List<String> podNames = new ArrayList<>();
List<String> stepNames = new ArrayList<>();
Set<String> seen = new HashSet<>();
for (Map<String, Object> t : tasks) {
String pod = firstString(t.get("podName"), t.get("pod_name"));
if (pod == null || pod.isBlank() || !seen.add(pod)) {
continue;
}
podNames.add(pod);
stepNames.add(firstString(t.get("displayName"), t.get("display_name")));
}
return podHealthService.aggregatePodLogsForKfpTasks(runId, podNames, stepNames, tailLines);
}
Map<String, Object> chosen = pickPrimaryKfpTaskForLog(tasks);
return formatSingleTaskLog(runId, chosen, tailLines, workflowNameParam, workflowNamespaceParam);
}
private static Integer normalizeTail(Integer tailLines) {
if (tailLines == null) {
return null;
}
if (tailLines <= 0) {
return null;
}
return tailLines;
}
private String formatSingleTaskLog(
String runId,
Map<String, Object> task,
Integer tailLines,
String workflowNameParam,
String workflowNamespaceParam) {
String pod = firstString(task.get("podName"), task.get("pod_name"));
String step = firstString(task.get("displayName"), task.get("display_name"));
if (pod == null || pod.isBlank()) {
return "선택된 task에 pod_name이 없습니다.";
}
KubernetesPodHealthService.ExecutorPodResolution res =
podHealthService.resolveKfpExecutorImplPod(runId, pod.trim());
String wfNsEff =
workflowNamespaceParam != null && !workflowNamespaceParam.isBlank()
? workflowNamespaceParam.trim()
: res.namespace;
if (preferArgoServer && argoServerLogService.isConfigured()) {
ArgoServerLogService.ArgoLogFetchResult argoLog =
tryArgoWorkflowLogForStep(
wfNsEff, workflowNameParam, res.podName, pod.trim(), tailLines);
if (argoLog != null && isSubstantialArgoLogBody(argoLog.logText)) {
StringBuilder a = new StringBuilder();
a.append("-- Argo Server workflow log (실시간·아카이브 자동) --\n");
a.append("namespace: ").append(wfNsEff).append(" | pod: ").append(res.podName);
if (!pod.trim().equals(res.podName)) {
a.append(" | KFP pod_name: ").append(pod.trim());
}
a.append(" | Step: ").append(step != null ? step : "(이름 없음)").append(" --\n\n");
a.append(argoLog.logText != null ? argoLog.logText : "");
return a.toString();
}
}
if (preferKfpApiForLogs) {
String kfpLog = tryKfpMlPipelineNodeLog(runId, res.podName, pod.trim());
if (kfpLog != null) {
StringBuilder k = new StringBuilder();
k.append("-- KFP ml-pipeline API v1beta1/runs/.../nodes/{node_id}/log (UI와 동일 백엔드) --\n");
k.append("node_id: ").append(res.podName);
if (!pod.trim().equals(res.podName)) {
k.append(" | KFP task pod_name: ").append(pod.trim());
}
k.append(" | Step: ").append(step != null ? step : "(이름 없음)").append(" --\n\n");
k.append(kfpLog);
return k.toString();
}
}
String logText = podHealthService.readPodLogInNamespace(
res.namespace, res.podName, normalizeTail(tailLines));
if (logText != null && logText.startsWith("로그 조회 실패")) {
KubernetesPodHealthService.PipelinePodLogOutcome fb =
podHealthService.readPipelinePodLog(res.podName, null, normalizeTail(tailLines));
logText = fb.logText;
}
StringBuilder sb = new StringBuilder();
sb.append("-- kubectl logs ").append(res.podName).append(" -n ").append(res.namespace);
sb.append(" (KFP 로그와 동일) | Step: ").append(step != null ? step : "(이름 없음)");
if (!pod.trim().equals(res.podName)) {
sb.append(" | KFP API pod_name: ").append(pod.trim());
}
sb.append(" --\n\n");
sb.append(logText != null ? logText : "");
String archived = tryFetchArchivedMainLogFromWait(logText);
if (archived != null) {
sb.append("\n\n").append(archived);
}
return sb.toString();
}
/**
* KFP UI ml-pipeline . node_id .
*/
private String tryKfpMlPipelineNodeLog(String runId, String... nodeIdsOrdered) {
if (runId == null || runId.isBlank() || kubeflowUrl == null || kubeflowUrl.isBlank()) {
return null;
}
LinkedHashSet<String> seen = new LinkedHashSet<>();
for (String id : nodeIdsOrdered) {
if (id == null || id.isBlank()) {
continue;
}
String t = id.trim();
if (!seen.add(t)) {
continue;
}
String body = pipelineUploadService.getV1beta1RunNodeLog(runId, t);
if (isSubstantialKfpV1LogBody(body)) {
return body;
}
}
return null;
}
private static boolean isSubstantialKfpV1LogBody(String s) {
if (s == null) {
return false;
}
String t = s.trim();
if (t.length() < 15) {
return false;
}
if (t.startsWith("{") && (t.contains("\"error\"") || t.contains("Error"))) {
return false;
}
String low = t.toLowerCase();
if (low.startsWith("failed to get")) {
return false;
}
return true;
}
private ArgoServerLogService.ArgoLogFetchResult tryArgoWorkflowLogForStep(
String workflowNamespace,
String workflowNameOverride,
String implPodName,
String apiPodHint,
Integer tailLines) {
if (!preferArgoServer || argoServerLogService == null || !argoServerLogService.isConfigured()) {
return null;
}
if (workflowNamespace == null || workflowNamespace.isBlank()) {
return null;
}
String wfNs = workflowNamespace.trim();
String wfName =
workflowNameOverride != null && !workflowNameOverride.isBlank()
? workflowNameOverride.trim()
: null;
if (wfName == null && podHealthService != null && podHealthService.isEnabled()) {
wfName = podHealthService.getArgoWorkflowNameFromPod(wfNs, implPodName);
}
if (wfName == null || wfName.isBlank()) {
wfName = ArgoServerLogService.guessWorkflowNameFromExecutorPod(implPodName);
}
if (wfName == null || wfName.isBlank()) {
wfName = ArgoServerLogService.guessWorkflowNameFromExecutorPod(apiPodHint);
}
if (wfName == null || wfName.isBlank()) {
return null;
}
return argoServerLogService.fetchWorkflowPodLog(
wfNs, wfName, implPodName, normalizeTail(tailLines));
}
private static boolean isSubstantialArgoLogBody(String s) {
if (s == null) {
return false;
}
String t = s.trim();
if (t.length() < 12) {
return false;
}
if (t.startsWith("{") && (t.contains("\"error\"") || t.contains("Error"))) {
return false;
}
String low = t.toLowerCase();
if (low.startsWith("rpc error") || low.startsWith("failed to retrieve")) {
return false;
}
return true;
}
/**
* KFP/Argo executor impl Pod kubectl wait main.log key ,
* MinIO(mlpipeline ) "실제 실행 로그(main)" .
*/
private String tryFetchArchivedMainLogFromWait(String logText) {
if (logText == null || logText.isBlank()) {
return null;
}
String t = logText.trim();
if (!t.startsWith("-- container=wait --")) {
return null;
}
Matcher m = WAIT_LOG_ARCHIVE_KEY.matcher(t);
if (!m.find()) {
return null;
}
String key = m.group(1);
if (key == null || key.isBlank()) {
return null;
}
try {
MinioClient client = MinioClient.builder().endpoint(minioEndpoint).credentials(minioAccessKey, minioSecretKey).build();
try (java.io.InputStream is = client.getObject(io.minio.GetObjectArgs.builder().bucket(KFP_ARCHIVE_BUCKET).object(key.trim()).build())) {
byte[] bytes = is.readAllBytes();
String body = new String(bytes, StandardCharsets.UTF_8);
if (body.isBlank()) return null;
return "-- MinIO archived main.log (bucket=" + KFP_ARCHIVE_BUCKET + ", key=" + key.trim() + ") --\n\n" + body;
}
} catch (Exception e) {
return "-- MinIO archived main.log 읽기 실패 (bucket=" + KFP_ARCHIVE_BUCKET + ", key=" + key.trim() + "): "
+ (e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName())
+ " --";
}
}
/** 실패 스텝 중 시간상 마지막 것, 없으면 생성 순 마지막 스텝 */
private static Map<String, Object> pickPrimaryKfpTaskForLog(List<Map<String, Object>> tasks) {
List<Map<String, Object>> failed = new ArrayList<>();
for (Map<String, Object> t : tasks) {
if (kfpTaskStateFailed(t)) {
failed.add(t);
}
}
if (!failed.isEmpty()) {
return failed.get(failed.size() - 1);
}
return tasks.get(tasks.size() - 1);
}
private static boolean kfpTaskStateFailed(Map<String, Object> task) {
Object st = task.get("state");
if (st instanceof Map) {
Object rs = ((Map<?, ?>) st).get("runtimeState");
if (rs == null) {
rs = ((Map<?, ?>) st).get("runtime_state");
}
if (rs != null) {
st = rs;
}
}
if (st instanceof Number) {
return ((Number) st).intValue() == 5;
}
String s = String.valueOf(st).toUpperCase();
return s.contains("FAILED") || s.contains("ERROR") || s.contains("CANCEL");
}
@SuppressWarnings("unchecked")
private static Map<String, Object> unwrapKfpTaskMap(Object node) {
if (!(node instanceof Map)) {
return null;
}
Map<String, Object> m = (Map<String, Object>) node;
Object inner = m.get("task");
if (inner instanceof Map) {
return (Map<String, Object>) inner;
}
return m;
}
/** task_details 항목에서 pod가 있는 태스크를 모음(평면 배열 + 중첩 child). Pod당 최신 항목 유지. */
@SuppressWarnings("unchecked")
private static void mergeKfpTaskWithPod(Object node, Map<String, Map<String, Object>> byPod) {
Map<String, Object> task = unwrapKfpTaskMap(node);
if (task == null) {
return;
}
String pod = firstString(task.get("podName"), task.get("pod_name"));
if (pod != null && !pod.isBlank()) {
byPod.put(pod.trim(), task);
}
Object child = task.get("childTasks");
if (child == null) {
child = task.get("child_tasks");
}
if (!(child instanceof List)) {
return;
}
for (Object c : (List<?>) child) {
if (!(c instanceof Map)) {
continue;
}
Map<String, Object> cm = (Map<String, Object>) c;
if (cm.get("task") instanceof Map) {
mergeKfpTaskWithPod(cm.get("task"), byPod);
} else if (firstString(cm.get("podName"), cm.get("pod_name")) != null) {
mergeKfpTaskWithPod(cm, byPod);
}
}
}
@SuppressWarnings("unchecked")
private static List<Map<String, Object>> kfpTasksWithPods(Map<String, Object> run) {
if (run == null) {
return List.of();
}
Object rd = run.get("runDetails");
if (rd == null) {
rd = run.get("run_details");
}
if (!(rd instanceof Map)) {
return List.of();
}
Object td = ((Map<?, ?>) rd).get("taskDetails");
if (td == null) {
td = ((Map<?, ?>) rd).get("task_details");
}
if (!(td instanceof List)) {
return List.of();
}
Map<String, Map<String, Object>> byPod = new LinkedHashMap<>();
for (Object item : (List<?>) td) {
mergeKfpTaskWithPod(item, byPod);
}
List<Map<String, Object>> flat = new ArrayList<>(byPod.values());
flat.sort(Comparator.comparing(m -> {
Object ct = m.get("createTime");
if (ct == null) {
ct = m.get("create_time");
}
return ct != null ? ct.toString() : "";
}));
return flat;
}
private static String firstString(Object a, Object b) {
if (a != null) {
String s = String.valueOf(a).trim();
if (!s.isEmpty()) {
return s;
}
}
if (b != null) {
String s = String.valueOf(b).trim();
if (!s.isEmpty()) {
return s;
}
}
return null;
}
@SuppressWarnings("unchecked")
private static String kfpStateToPhase(Object state) {
if (state == null) {
return null;
}
if (state instanceof Map) {
Object rs = ((Map<?, ?>) state).get("runtimeState");
if (rs == null) {
rs = ((Map<?, ?>) state).get("runtime_state");
}
if (rs != null) {
state = rs;
}
}
String s = state.toString();
if (s.contains("RUNNING")) {
return "Running";
}
if (s.contains("SUCCEEDED")) {
return "Succeeded";
}
if (s.contains("FAILED") || s.contains("ERROR")) {
return "Failed";
}
if (s.contains("PENDING") || s.contains("SKIPPED")) {
return s.contains("SKIPPED") ? "Skipped" : "Pending";
}
return s;
}
/** 관리자 페이지: 동일 카드(KFP/MLflow/MinIO)에 속한 Pod들 로그를 한 문자열로 */
public String getPodLogsAggregate(String namespace, java.util.List<String> podNames, Integer tailLines) {
if (podHealthService == null || !podHealthService.isEnabled()) {
return "K8s Pod 로그는 비활성화되어 있습니다. (admin.k8s.enabled=true 설정 필요)";
}
return podHealthService.aggregatePodLogsByNames(namespace, podNames, tailLines);
}
}

@ -0,0 +1,137 @@
package kr.re.etri.autoflow.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import org.springframework.web.util.UriComponentsBuilder;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/**
* Argo Server REST: Pod K8s , MinIO Argo .
* {@code GET /api/v1/workflows/{namespace}/{workflowName}/log?podName=...&logOptions.container=main&logOptions.follow=false}
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ArgoServerLogService {
private final WebClient webClient;
public static final class ArgoLogFetchResult {
public final String url;
public final String logText;
public final Integer statusCode;
public final String error;
public ArgoLogFetchResult(String url, String logText, Integer statusCode, String error) {
this.url = url;
this.logText = logText;
this.statusCode = statusCode;
this.error = error;
}
public boolean ok() {
return logText != null && !logText.isBlank();
}
}
@Value("${argo.server.url:}")
private String argoBaseUrl;
@Value("${argo.server.token:}")
private String argoToken;
@Value("${argo.server.container:main}")
private String logContainer;
public boolean isConfigured() {
return argoBaseUrl != null && !argoBaseUrl.isBlank();
}
/**
* KFP v2 executor impl Pod Argo Workflow .
* : {@code my-pipeline-j8prl-retry-system-container-impl-123} {@code my-pipeline-j8prl}
*/
public static String guessWorkflowNameFromExecutorPod(String podName) {
if (podName == null || podName.isBlank()) {
return null;
}
String s = podName.trim();
if (s.matches(".*-retry-system-container-impl-\\d+$")) {
return s.replaceFirst("-retry-system-container-impl-\\d+$", "");
}
if (s.matches(".*-system-container-impl-\\d+$")) {
return s.replaceFirst("-system-container-impl-\\d+$", "");
}
int last = s.lastIndexOf('-');
if (last > 0 && !s.contains("system-container")) {
String tail = s.substring(last + 1);
if (tail.matches("\\d{6,}")) {
return s.substring(0, last);
}
}
return null;
}
public ArgoLogFetchResult fetchWorkflowPodLog(
String workflowNamespace,
String workflowName,
String podName,
Integer tailLines) {
if (!isConfigured()
|| workflowNamespace == null
|| workflowNamespace.isBlank()
|| workflowName == null
|| workflowName.isBlank()
|| podName == null
|| podName.isBlank()) {
return new ArgoLogFetchResult("", null, null, "argo.server.url 또는 파라미터 누락");
}
String base = argoBaseUrl.replaceAll("/+$", "");
UriComponentsBuilder ub =
UriComponentsBuilder.fromHttpUrl(base)
.path("/api/v1/workflows/{namespace}/{name}/log")
.queryParam("podName", podName)
.queryParam("logOptions.container", logContainer != null && !logContainer.isBlank() ? logContainer : "main")
.queryParam("logOptions.follow", "false");
if (tailLines != null && tailLines > 0) {
ub.queryParam("logOptions.tailLines", tailLines);
}
String url =
ub.encode(StandardCharsets.UTF_8)
.buildAndExpand(workflowNamespace.trim(), workflowName.trim())
.toUriString();
try {
WebClient.RequestHeadersSpec<?> spec =
webClient.get().uri(url).accept(MediaType.TEXT_PLAIN);
if (argoToken != null && !argoToken.isBlank()) {
spec = spec.header(HttpHeaders.AUTHORIZATION, "Bearer " + argoToken.trim());
}
String body =
spec.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(180))
.block();
return new ArgoLogFetchResult(url, body, 200, null);
} catch (WebClientResponseException e) {
log.debug(
"[Argo] workflow log {} {}/{} pod={}: {}",
e.getStatusCode().value(),
workflowNamespace,
workflowName,
podName,
e.getMessage());
return new ArgoLogFetchResult(url, null, e.getStatusCode().value(), e.getResponseBodyAsString());
} catch (Exception e) {
log.debug("[Argo] workflow log failed {}/{}: {}", workflowNamespace, workflowName, e.getMessage());
return new ArgoLogFetchResult(url, null, null, e.getMessage());
}
}
}

@ -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,

@ -0,0 +1,855 @@
package kr.re.etri.autoflow.service;
import io.kubernetes.client.openapi.ApiClient;
import io.kubernetes.client.openapi.ApiException;
import io.kubernetes.client.openapi.apis.CoreV1Api;
import io.kubernetes.client.openapi.models.V1Container;
import io.kubernetes.client.openapi.models.V1Pod;
import io.kubernetes.client.openapi.models.V1PodList;
import io.kubernetes.client.util.Config;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Kubernetes API Pod phase == "Running" .
* - : ServiceAccount .
* - : KUBECONFIG ~/.kube/config .
*/
@Slf4j
@Service
public class KubernetesPodHealthService {
private static final String PHASE_RUNNING = "Running";
@Value("${admin.k8s.enabled:false}")
private boolean enabled;
@Value("${admin.k8s.namespace:kubeflow}")
private String namespace;
/** MLflow 관련 Pod 라벨 (예: app=mlflow-server) */
@Value("${admin.k8s.mlflow.label-selector:app=mlflow-server}")
private String mlflowLabelSelector;
/** MinIO 관련 Pod 라벨 (예: app=minio) */
@Value("${admin.k8s.minio.label-selector:app=minio}")
private String minioLabelSelector;
/** KFP(ML Pipeline API) 관련 Pod 라벨 (예: app.kubernetes.io/name=ml-pipeline) */
@Value("${admin.k8s.kfp.label-selector:app.kubernetes.io/name=ml-pipeline}")
private String kfpLabelSelector;
/** Run별 Pod 조회 시 사용하는 라벨 키. 값은 runId. (예: pipeline/runid → pipeline/runid=runId) Tekton 등은 tekton.dev/pipelineRun 등으로 다를 수 있음. */
@Value("${admin.k8s.run-pod-label:pipeline/runid}")
private String runPodLabelKey;
/**
* Pod ( ). KFP UI Run Pod NS .
* {@link #namespace} .
*/
@Value("${admin.k8s.pipeline-pod-namespaces:}")
private String pipelinePodNamespaces;
/**
* namespace + labelSelector Pod Running .
*
* @param labelSelector : "app=mlflow-server", "app in (minio,minio-mlflow)"
* @return { "running": n, "total": m, "message": "n/m Running", "ok": true if total>0 and all running }
*/
public Map<String, Object> getPodStatus(String labelSelector) {
Map<String, Object> out = new HashMap<>();
if (!enabled) {
out.put("ok", false);
out.put("message", "admin.k8s.enabled=false");
out.put("running", 0);
out.put("total", 0);
return out;
}
if (namespace == null || namespace.isBlank()) {
out.put("ok", false);
out.put("message", "admin.k8s.namespace 미설정");
out.put("running", 0);
out.put("total", 0);
return out;
}
try {
ApiClient client = Config.defaultClient();
CoreV1Api api = new CoreV1Api(client);
// listNamespacedPod(namespace, pretty, allowWatchBookmarks, _continue, fieldSelector, labelSelector, limit, resourceVersion, resourceVersionMatch, timeoutSeconds, watch, sendInitialEvents)
V1PodList list = api.listNamespacedPod(
namespace,
null,
null,
null,
null,
labelSelector != null && !labelSelector.isBlank() ? labelSelector : null,
null,
null,
null,
null,
null,
null
);
int total = list.getItems() != null ? list.getItems().size() : 0;
long running = list.getItems() == null ? 0
: list.getItems().stream()
.map(V1Pod::getStatus)
.filter(s -> s != null && PHASE_RUNNING.equals(s.getPhase()))
.count();
out.put("total", total);
out.put("running", running);
out.put("message", running + "/" + total + " Running");
out.put("ok", total > 0 && running == total);
List<Map<String, String>> pods = new ArrayList<>();
if (list.getItems() != null) {
for (V1Pod p : list.getItems()) {
Map<String, String> entry = new HashMap<>();
entry.put("name", p.getMetadata() != null ? p.getMetadata().getName() : null);
entry.put("phase", p.getStatus() != null ? p.getStatus().getPhase() : null);
pods.add(entry);
}
}
out.put("pods", pods);
} catch (Exception e) {
log.debug("[Admin] K8s pod list failed: {}", e.getMessage());
out.put("ok", false);
out.put("message", e.getMessage() != null ? e.getMessage() : "K8s API 연결 실패");
out.put("running", 0);
out.put("total", 0);
out.put("pods", new ArrayList<>());
}
return out;
}
/** KFP 관련 Pod가 전부 Running인지 */
public Map<String, Object> getKfpPodStatus() {
return getPodStatus(kfpLabelSelector);
}
/** MLflow 관련 Pod가 전부 Running인지 */
public Map<String, Object> getMlflowPodStatus() {
return getPodStatus(mlflowLabelSelector);
}
/** MinIO 관련 Pod가 전부 Running인지 */
public Map<String, Object> getMinioPodStatus() {
return getPodStatus(minioLabelSelector);
}
public boolean isEnabled() {
return enabled;
}
private static final String ARGO_WORKFLOW_LABEL = "workflows.argoproj.io/workflow";
/**
* Pod Argo Workflow (Workflow metadata.name).
*/
public String getArgoWorkflowNameFromPod(String podNamespace, String podName) {
if (!enabled || podNamespace == null || podNamespace.isBlank() || podName == null || podName.isBlank()) {
return null;
}
try {
ApiClient client = Config.defaultClient();
CoreV1Api api = new CoreV1Api(client);
V1Pod p = api.readNamespacedPod(podName.trim(), podNamespace.trim(), null);
if (p.getMetadata() != null && p.getMetadata().getLabels() != null) {
String w = p.getMetadata().getLabels().get(ARGO_WORKFLOW_LABEL);
if (w != null && !w.isBlank()) {
return w.trim();
}
}
} catch (Exception e) {
log.debug("[Admin] Argo workflow label for pod {}: {}", podName, e.getMessage());
}
return null;
}
public String getNamespace() {
return namespace;
}
/** 파이프라인 Pod 로그 조회 시 시도할 네임스페이스 순서 (앞이 우선). */
public List<String> getPipelinePodNamespaceCandidates() {
List<String> fromConfig = new ArrayList<>();
if (pipelinePodNamespaces != null && !pipelinePodNamespaces.isBlank()) {
fromConfig = Arrays.stream(pipelinePodNamespaces.split(","))
.map(String::trim)
.filter(s -> !s.isEmpty())
.collect(Collectors.toList());
}
if (!fromConfig.isEmpty()) {
if (namespace != null && !namespace.isBlank() && !fromConfig.contains(namespace)) {
fromConfig.add(namespace);
}
return fromConfig;
}
return (namespace != null && !namespace.isBlank()) ? List.of(namespace) : List.of();
}
/**
* Pod 1 ( KFP NS ).
*/
public PipelinePodLogOutcome readPipelinePodLog(String podName, String container, Integer tailLines) {
List<String> nss = getPipelinePodNamespaceCandidates();
if (nss.isEmpty()) {
return new PipelinePodLogOutcome("", "admin.k8s.namespace 미설정");
}
String lastFail = "";
for (String ns : nss) {
String log = readPodLogTail(ns, podName, container, tailLines);
if (log == null || !log.startsWith("로그 조회 실패")) {
return new PipelinePodLogOutcome(ns, log != null ? log : "");
}
lastFail = log;
}
return new PipelinePodLogOutcome(nss.get(0), lastFail + "\n(시도한 네임스페이스: " + String.join(", ", nss) + ")");
}
/** Pod 로그 + 실제로 성공한 namespace */
public static final class PipelinePodLogOutcome {
public final String namespace;
public final String logText;
public PipelinePodLogOutcome(String namespace, String logText) {
this.namespace = namespace != null ? namespace : "";
this.logText = logText != null ? logText : "";
}
}
/**
* KFP UI / kubectl logs ...-system-container-impl-... Pod.
* KFP API task_details pod_name system-container-driver , impl Pod.
*/
public static final class ExecutorPodResolution {
public final String podName;
public final String namespace;
/** KFP API가 준 원본 Pod 이름 (driver 등) */
public final String sourcePodFromApi;
public ExecutorPodResolution(String podName, String namespace, String sourcePodFromApi) {
this.podName = podName != null ? podName : "";
this.namespace = namespace != null ? namespace : "";
this.sourcePodFromApi = sourcePodFromApi != null ? sourcePodFromApi : "";
}
}
/**
* Run Pod (·NS·).
*/
public List<Map<String, String>> listRunPodsAllNamespaces(String runId) {
List<Map<String, String>> out = new ArrayList<>();
if (!enabled || runId == null || runId.isBlank()) {
return out;
}
Set<String> nss = new LinkedHashSet<>();
nss.addAll(getPipelinePodNamespaceCandidates());
if (namespace != null && !namespace.isBlank()) {
nss.add(namespace.trim());
}
if (nss.isEmpty()) {
return out;
}
String labelKey = (runPodLabelKey != null && !runPodLabelKey.isBlank()) ? runPodLabelKey : "pipeline/runid";
String labelSelector = labelKey + "=" + runId.trim();
try {
ApiClient client = Config.defaultClient();
CoreV1Api api = new CoreV1Api(client);
for (String ns : nss) {
try {
V1PodList list = api.listNamespacedPod(
ns,
null,
null,
null,
null,
labelSelector,
null,
null,
null,
null,
null,
null);
if (list.getItems() == null) {
continue;
}
for (V1Pod p : list.getItems()) {
if (p.getMetadata() == null) {
continue;
}
Map<String, String> row = new HashMap<>();
row.put("name", p.getMetadata().getName());
row.put("namespace", ns);
if (p.getMetadata().getCreationTimestamp() != null) {
row.put("createdAt", p.getMetadata().getCreationTimestamp().toString());
}
out.add(row);
}
} catch (Exception e) {
log.debug("[Admin] list pods runId={} ns={}: {}", runId, ns, e.getMessage());
}
}
} catch (Exception e) {
log.debug("[Admin] listRunPodsAllNamespaces: {}", e.getMessage());
}
return out;
}
/**
* KFP API Pod , kubectl logs ...-system-container-impl-... Pod .
*/
public ExecutorPodResolution resolveKfpExecutorImplPod(String runId, String podNameFromKfpApi) {
if (podNameFromKfpApi == null || podNameFromKfpApi.isBlank()) {
return new ExecutorPodResolution("", namespace, podNameFromKfpApi);
}
String reported = podNameFromKfpApi.trim();
List<Map<String, String>> runPods = listRunPodsAllNamespaces(runId);
String defaultNs = (namespace != null && !namespace.isBlank()) ? namespace : "";
if (reported.contains("system-container-impl")) {
for (Map<String, String> row : runPods) {
if (reported.equals(row.get("name"))) {
return new ExecutorPodResolution(reported, row.get("namespace"), reported);
}
}
return new ExecutorPodResolution(reported, defaultNs, reported);
}
if (reported.contains("system-container-driver")) {
int idx = reported.indexOf("-system-container-driver");
if (idx > 0) {
String prefix = reported.substring(0, idx);
List<Map<String, String>> implCandidates = new ArrayList<>();
for (Map<String, String> row : runPods) {
String n = row.get("name");
if (n == null || n.isBlank()) {
continue;
}
if (n.contains("system-container-impl") && n.startsWith(prefix)) {
implCandidates.add(row);
}
}
implCandidates.sort(Comparator.comparing(
m -> parseCreated(m.get("createdAt")),
Comparator.nullsFirst(Comparator.naturalOrder())));
if (!implCandidates.isEmpty()) {
Map<String, String> best = pickLatestImplPod(implCandidates);
return new ExecutorPodResolution(
best.get("name"), best.get("namespace"), reported);
}
}
}
/*
* KFP ...-j8prl-2326247768 driver/impl Pod .
* - Run system-container-impl Pod .
* (kubectl: ...-retry-system-container-impl-2358626606)
*/
String wfPrefix = workflowInstancePrefixFromReportedPod(reported);
if (wfPrefix != null && !wfPrefix.isBlank()) {
List<Map<String, String>> implByPrefix = new ArrayList<>();
for (Map<String, String> row : runPods) {
String n = row.get("name");
if (n != null && n.contains("system-container-impl") && n.startsWith(wfPrefix)) {
implByPrefix.add(row);
}
}
if (!implByPrefix.isEmpty()) {
Map<String, String> best = pickLatestImplPod(implByPrefix);
return new ExecutorPodResolution(best.get("name"), best.get("namespace"), reported);
}
}
for (Map<String, String> row : runPods) {
if (reported.equals(row.get("name"))) {
return new ExecutorPodResolution(reported, row.get("namespace"), reported);
}
}
return new ExecutorPodResolution(reported, defaultNs, reported);
}
/** 생성 시각 기준 가장 늦은 impl Pod (재시도 시 최신) */
private static Map<String, String> pickLatestImplPod(List<Map<String, String>> implCandidates) {
implCandidates.sort(Comparator.comparing(
m -> parseCreated(m.get("createdAt")),
Comparator.nullsFirst(Comparator.naturalOrder())));
return implCandidates.get(implCandidates.size() - 1);
}
/**
* : minimal-gpu-mlflow-train-v2-j8prl-2326247768 minimal-gpu-mlflow-train-v2-j8prl
* ( system-container )
*/
private static String workflowInstancePrefixFromReportedPod(String reported) {
if (reported == null || reported.isBlank()) {
return null;
}
if (reported.contains("system-container")) {
return null;
}
int last = reported.lastIndexOf('-');
if (last <= 0) {
return null;
}
String tail = reported.substring(last + 1);
if (!tail.matches("\\d{6,}")) {
return null;
}
return reported.substring(0, last);
}
private static OffsetDateTime parseCreated(String s) {
if (s == null || s.isBlank()) {
return null;
}
try {
return OffsetDateTime.parse(s);
} catch (Exception e) {
return null;
}
}
/**
* KFP run id Pod .
* : admin.k8s.run-pod-label=runId ( pipeline/runid). Tekton .
*/
public Map<String, Object> listPodsByRunId(String runId) {
Map<String, Object> out = new HashMap<>();
if (!enabled) {
out.put("namespace", "");
out.put("pods", new ArrayList<Map<String, String>>());
out.put("message", "admin.k8s.enabled=false");
return out;
}
if (runId == null || runId.isBlank()) {
out.put("namespace", namespace);
out.put("pods", new ArrayList<Map<String, String>>());
out.put("message", "runId 없음");
return out;
}
String labelKey = (runPodLabelKey != null && !runPodLabelKey.isBlank()) ? runPodLabelKey : "pipeline/runid";
String labelSelector = labelKey + "=" + runId.trim();
try {
ApiClient client = Config.defaultClient();
CoreV1Api api = new CoreV1Api(client);
V1PodList list = api.listNamespacedPod(
namespace,
null,
null,
null,
null,
labelSelector,
null,
null,
null,
null,
null,
null
);
List<Map<String, String>> pods = new ArrayList<>();
if (list.getItems() != null) {
for (V1Pod p : list.getItems()) {
Map<String, String> entry = new HashMap<>();
entry.put("name", p.getMetadata() != null ? p.getMetadata().getName() : null);
entry.put("phase", p.getStatus() != null ? p.getStatus().getPhase() : null);
String displayName = getStepDisplayName(p);
if (displayName != null) {
entry.put("displayName", displayName);
}
if (p.getMetadata() != null && p.getMetadata().getCreationTimestamp() != null) {
entry.put("createdAt", p.getMetadata().getCreationTimestamp().toString());
}
pods.add(entry);
}
}
pods.sort(Comparator.comparing(m -> m.get("createdAt"), Comparator.nullsLast(Comparator.naturalOrder())));
out.put("namespace", namespace);
out.put("pods", pods);
out.put("message", pods.size() + " pods");
if (pods.isEmpty()) {
out.put("hint", "라벨 " + labelSelector + " 로 조회했으나 Pod 없음. kubectl get pods -n " + namespace + " --show-labels 로 실제 Run Pod 라벨 확인 후 application.properties 의 admin.k8s.run-pod-label 설정 변경.");
}
} catch (Exception e) {
log.debug("[Admin] Pods by runId failed: {}", e.getMessage());
out.put("namespace", namespace);
out.put("pods", new ArrayList<Map<String, String>>());
out.put("message", e.getMessage() != null ? e.getMessage() : "K8s API 연결 실패");
}
return out;
}
/** KFP/Argo Pod에서 그래프 모듈(스텝) 표시 이름 추출. 로그 섹션 헤더용. */
private String getStepDisplayName(V1Pod p) {
if (p == null || p.getMetadata() == null) return null;
Map<String, String> ann = p.getMetadata().getAnnotations();
if (ann != null) {
String v = ann.get("workflows.argoproj.io/template");
if (v != null && !v.isBlank()) return v;
v = ann.get("pipelines.kubeflow.org/component_ref");
if (v != null && !v.isBlank()) return v;
v = ann.get("workflows.argoproj.io/display-name");
if (v != null && !v.isBlank()) return v;
}
Map<String, String> labels = p.getMetadata().getLabels();
if (labels != null) {
String v = labels.get("pipelines.kubeflow.org/component_ref");
if (v != null && !v.isBlank()) return v;
}
return null;
}
/**
* Pod ( tailLines ). container Pod .
* KFP Run (launcher + executor stdout).
*/
/** 지정 NS/Pod 로그 (kubectl logs 와 동일). */
public String readPodLogInNamespace(String ns, String podName, Integer tailLines) {
if (!enabled || ns == null || ns.isBlank() || podName == null || podName.isBlank()) {
return "";
}
return readPodLogTail(ns.trim(), podName.trim(), null, tailLines);
}
public String getPodLog(String namespaceOverride, String podName, String container, Integer tailLines) {
if (!enabled) return "";
String ns = (namespaceOverride != null && !namespaceOverride.isBlank()) ? namespaceOverride : namespace;
if (ns == null || ns.isBlank() || podName == null || podName.isBlank()) return "";
try {
ApiClient client = Config.defaultClient();
CoreV1Api api = new CoreV1Api(client);
// readNamespacedPodLog(name, namespace, container, follow, previous, sinceSeconds, timestamps, tailLines, limitBytes, insecureSkipTLSVerify, previous)
return api.readNamespacedPodLog(
podName,
ns,
(container != null && !container.isBlank()) ? container : null,
false,
null,
null,
null,
false,
tailLines != null ? tailLines : 500,
null,
null
);
} catch (Exception e) {
log.debug("[Admin] Pod log failed {}: {}", podName, e.getMessage());
return "로그 조회 실패: " + (e.getMessage() != null ? e.getMessage() : "unknown");
}
}
/**
* runId Pod .
*
* @param tailLinesPerPod Pod . null 50_000, 0 tail ( ) .
*/
@SuppressWarnings("unchecked")
public String aggregatePodLogsByRunId(String runId, Integer tailLinesPerPod) {
if (!enabled) {
return "";
}
Map<String, Object> list = listPodsByRunId(runId);
List<Map<String, String>> pods = (List<Map<String, String>>) list.get("pods");
String ns = list.get("namespace") != null ? list.get("namespace").toString() : namespace;
if (pods == null || pods.isEmpty()) {
String msg = list.get("message") != null ? list.get("message").toString() : "";
return "이 Run에 해당하는 Pod가 없습니다.\n" + msg;
}
List<Map<String, String>> workPods = pods.stream()
.filter(p -> {
String n = p.get("name");
return n != null && n.contains("system-container-impl");
})
.collect(Collectors.toList());
if (workPods.isEmpty()) {
workPods = pods.stream()
.filter(p -> {
String n = p.get("name");
return n != null && !n.contains("system-container-driver");
})
.collect(Collectors.toList());
}
if (workPods.isEmpty()) {
workPods = new ArrayList<>(pods);
}
Integer tl;
if (tailLinesPerPod == null) {
tl = 50_000;
} else if (tailLinesPerPod <= 0) {
tl = null;
} else {
tl = tailLinesPerPod;
}
StringBuilder sb = new StringBuilder();
sb.append("-- Run ").append(runId).append(" | namespace=").append(ns)
.append(" | 스텝 ").append(workPods.size())
.append("개 (system-container-impl Pod = kubectl logs / KFP UI 와 동일) --\n");
for (Map<String, String> p : workPods) {
String name = p.get("name");
if (name == null || name.isBlank()) {
continue;
}
String displayName = p.get("displayName");
String phase = p.get("phase");
if (displayName != null && !displayName.isBlank()) {
sb.append("\n========== Step: ").append(displayName).append(" (Pod: ").append(name).append(")");
} else {
sb.append("\n========== Pod: ").append(name);
}
if (phase != null) {
sb.append(" [").append(phase).append("]");
}
sb.append(tl == null ? " [전체 로그]" : " [최근 " + tl + "줄]");
sb.append(" ==========\n\n");
sb.append(readPodLogTail(ns, name, null, tl));
sb.append("\n");
}
return sb.toString();
}
/**
* KFP task_details Pod system-container-impl Pod (kubectl logs / KFP UI ).
*
* @param stepNames API Pod (null )
*/
public String aggregatePodLogsForKfpTasks(
String runId,
List<String> podNamesFromKfpApi,
List<String> stepNames,
Integer tailLinesPerPod) {
if (!enabled) {
return "";
}
if (podNamesFromKfpApi == null || podNamesFromKfpApi.isEmpty()) {
return "";
}
Integer tl;
if (tailLinesPerPod == null) {
tl = 50_000;
} else if (tailLinesPerPod <= 0) {
tl = null;
} else {
tl = tailLinesPerPod;
}
String rid = runId != null ? runId.trim() : "";
/*
* task pod_name driver system-container-impl
* (kubectl Pod ) .
*/
Map<String, List<Integer>> byResolvedPod = new LinkedHashMap<>();
for (int i = 0; i < podNamesFromKfpApi.size(); i++) {
String apiName = podNamesFromKfpApi.get(i);
if (apiName == null || apiName.isBlank()) {
continue;
}
ExecutorPodResolution res = resolveKfpExecutorImplPod(rid, apiName.trim());
String key = res.namespace + "\0" + res.podName;
byResolvedPod.computeIfAbsent(key, k -> new ArrayList<>()).add(i);
}
StringBuilder sb = new StringBuilder();
sb.append("-- KFP task_details → system-container-impl | task ")
.append(podNamesFromKfpApi.stream().filter(s -> s != null && !s.isBlank()).count())
.append("건 → 고유 Pod ")
.append(byResolvedPod.size())
.append("개 (동일 Pod는 로그 1회) --\n");
for (List<Integer> group : byResolvedPod.values()) {
int i0 = group.get(0);
String apiName0 = podNamesFromKfpApi.get(i0).trim();
ExecutorPodResolution res = resolveKfpExecutorImplPod(rid, apiName0);
sb.append("\n========== ");
if (group.size() == 1) {
String step = (stepNames != null && i0 < stepNames.size()) ? stepNames.get(i0) : null;
if (step != null && !step.isBlank()) {
sb.append("Step: ").append(step);
} else {
sb.append("Pod");
}
} else {
sb.append("Steps (동일 Pod ").append(group.size()).append("건 묶음): ");
for (int g = 0; g < group.size(); g++) {
int idx = group.get(g);
String step = (stepNames != null && idx < stepNames.size()) ? stepNames.get(idx) : null;
String api = podNamesFromKfpApi.get(idx);
if (step != null && !step.isBlank()) {
sb.append(step);
} else {
sb.append(api != null ? api : "?");
}
if (g < group.size() - 1) {
sb.append(", ");
}
}
}
sb.append(" | kubectl: logs ").append(res.podName).append(" -n ").append(res.namespace);
if (!apiName0.equals(res.podName)) {
sb.append(" (대표 KFP pod_name: ").append(apiName0).append(")");
}
if (group.size() > 1) {
sb.append(" | 생략: 동일 로그 (다른 task pod_name ");
for (int j = 1; j < group.size(); j++) {
sb.append(podNamesFromKfpApi.get(group.get(j)));
if (j < group.size() - 1) {
sb.append(", ");
}
}
sb.append(")");
}
sb.append(tl == null ? " [전체 로그]" : " [최근 " + tl + "줄]");
sb.append(" ==========\n\n");
String logText = readPodLogTail(res.namespace, res.podName, null, tl);
if (logText != null && logText.startsWith("로그 조회 실패")) {
PipelinePodLogOutcome fb = readPipelinePodLog(res.podName, null, tl);
logText = fb.logText;
}
sb.append(logText != null ? logText : "");
sb.append("\n");
}
return sb.toString();
}
/**
* namespace Pod . ( KFP/MLflow/MinIO )
*/
public String aggregatePodLogsByNames(String namespaceOverride, List<String> podNames, Integer tailLinesPerPod) {
if (!enabled) {
return "";
}
String ns = (namespaceOverride != null && !namespaceOverride.isBlank()) ? namespaceOverride : namespace;
if (podNames == null || podNames.isEmpty()) {
return "Pod 목록이 비어 있습니다.";
}
Integer tl;
if (tailLinesPerPod == null) {
tl = 50_000;
} else if (tailLinesPerPod <= 0) {
tl = null;
} else {
tl = tailLinesPerPod;
}
StringBuilder sb = new StringBuilder();
sb.append("-- namespace=").append(ns).append(" | Pod ").append(podNames.size()).append("개 --\n");
for (String name : podNames) {
if (name == null || name.isBlank()) {
continue;
}
sb.append("\n========== Pod: ").append(name);
sb.append(tl == null ? " [전체 로그]" : " [최근 " + tl + "줄]");
sb.append(" ==========\n\n");
sb.append(readPodLogTail(ns, name.trim(), null, tl));
sb.append("\n");
}
return sb.toString();
}
/** tailLines null 이면 API에 tail 제한 없이 요청(전체 로그). 실패 시 대량 tail로 재시도. */
private String readPodLogTail(String ns, String podName, String container, Integer tailLines) {
if (ns == null || ns.isBlank() || podName == null || podName.isBlank()) {
return "";
}
String c = (container != null && !container.isBlank()) ? container : null;
try {
ApiClient client = Config.defaultClient();
CoreV1Api api = new CoreV1Api(client);
return api.readNamespacedPodLog(podName, ns, c, false, null, null, null, false, tailLines, null, null);
} catch (Exception e1) {
// container 미지정인데 Pod에 컨테이너가 여러 개인 경우: 컨테이너별로 재시도
if (c == null) {
List<String> containers = listPodContainers(ns, podName);
if (!containers.isEmpty()) {
for (String cn : containers) {
try {
ApiClient client = Config.defaultClient();
CoreV1Api api = new CoreV1Api(client);
String body = api.readNamespacedPodLog(podName, ns, cn, false, null, null, null, false, tailLines, null, null);
if (body != null && !body.isBlank()) {
return "-- container=" + cn + " --\n" + body;
}
} catch (Exception ignore) {
// keep trying
}
}
}
}
if (tailLines == null) {
try {
ApiClient client = Config.defaultClient();
CoreV1Api api = new CoreV1Api(client);
return api.readNamespacedPodLog(podName, ns, c, false, null, null, null, false, 2_000_000, null, null);
} catch (Exception e2) {
log.debug("[Admin] Pod log (full) failed {}: {}", podName, e2.getMessage());
return "로그 조회 실패: " + formatK8sException(e2);
}
}
log.debug("[Admin] Pod log failed {}: {}", podName, e1.getMessage());
return "로그 조회 실패: " + formatK8sException(e1);
}
}
private static String formatK8sException(Exception e) {
if (e == null) {
return "unknown";
}
if (e instanceof ApiException) {
ApiException ae = (ApiException) e;
String msg = ae.getMessage();
String body = ae.getResponseBody();
String code = String.valueOf(ae.getCode());
if (body != null && body.length() > 2000) {
body = body.substring(0, 2000) + "…(truncated)";
}
return "ApiException(code=" + code + ") "
+ (msg != null ? msg : "")
+ (body != null && !body.isBlank() ? " | body=" + body : "");
}
String msg = e.getMessage();
return e.getClass().getSimpleName() + (msg != null && !msg.isBlank() ? (": " + msg) : "");
}
/** Pod의 컨테이너 이름 목록(순서 보존). */
private List<String> listPodContainers(String ns, String podName) {
try {
ApiClient client = Config.defaultClient();
CoreV1Api api = new CoreV1Api(client);
V1Pod p = api.readNamespacedPod(podName, ns, null);
if (p != null
&& p.getSpec() != null
&& p.getSpec().getContainers() != null
&& !p.getSpec().getContainers().isEmpty()) {
List<String> out = new ArrayList<>();
for (V1Container c : p.getSpec().getContainers()) {
if (c != null && c.getName() != null && !c.getName().isBlank()) {
out.add(c.getName().trim());
}
}
// KFP/Argo executor pod: 보통 "main" 로그가 사용자 로그.
out.sort((a, b) -> Integer.compare(containerRank(a), containerRank(b)));
return out;
}
} catch (Exception e) {
// ignore
}
return new ArrayList<>();
}
private static int containerRank(String name) {
if (name == null) return 100;
String n = name.trim().toLowerCase();
if (n.equals("main")) return 0;
if (n.equals("user-container")) return 1;
if (n.contains("main")) return 2;
if (n.equals("wait")) return 90;
return 50;
}
}

@ -4,22 +4,36 @@ 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.util.UriComponentsBuilder; import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import reactor.core.publisher.Mono;
import java.io.IOException; import java.io.IOException;
import java.net.URI; import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map; import java.util.Map;
import java.util.Set;
import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletableFuture;
@Service @Service
@ -30,289 +44,322 @@ public class PipelineUploadService {
private final RestTemplate restTemplate; private final RestTemplate restTemplate;
@Value("${kubeflow.url}") @Value("${kubeflow.url}")
private String kubeflowBaseUrl; private String kubeflowBaseUrl; // 예: http://192.168.10.135:32473/
private final WebClient webClient; 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={}
kubeflowBaseUrl={}
""",
file.getOriginalFilename(),
name,
displayName,
description,
namespace,
kubeflowBaseUrl
);
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(MediaType.MULTIPART_FORM_DATA); headers.setContentType(MediaType.MULTIPART_FORM_DATA);
HttpEntity<MultiValueMap<String, Object>> requestEntity = HttpEntity<MultiValueMap<String, Object>> requestEntity = new HttpEntity<>(body, headers);
new HttpEntity<>(body, headers);
String normalizedBaseUrl = normalizeBaseUrl(kubeflowBaseUrl);
URI uri = UriComponentsBuilder UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/pipelines/upload");
.fromHttpUrl(normalizedBaseUrl)
.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(""" if (name != null && !name.isBlank()) builder.queryParam("name", name);
===== Final Upload URI ===== if (displayName != null && !displayName.isBlank()) builder.queryParam("display_name", displayName);
uri={} if (description != null && !description.isBlank()) builder.queryParam("description", description);
""", if (namespace != null && !namespace.isBlank()) builder.queryParam("namespace", namespace);
uri
);
ResponseEntity<Map> response =
restTemplate.postForEntity(
uri,
requestEntity,
Map.class
);
log.info("""
===== Pipeline Upload Success =====
status={}
body={}
""",
response.getStatusCode(),
response.getBody()
);
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 =====
filename={}
kubeflowBaseUrl={}
""",
file.getOriginalFilename(),
kubeflowBaseUrl,
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);
String normalizedBaseUrl =
normalizeBaseUrl(kubeflowBaseUrl);
String uri = normalizedBaseUrl +
"/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(MediaType.APPLICATION_JSON) .contentType(MediaType.APPLICATION_JSON)
.bodyValue(runRequest) .bodyValue(body)
.retrieve() .retrieve()
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
resp -> resp.bodyToMono(String.class)
.doOnNext(msg -> log.warn("[KFP] CreateRun error {}: {}", resp.statusCode(), msg))
.map(msg -> new RuntimeException("KFP CreateRun failed: " + resp.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;
} }
/** /**
* Experiments * KFP pipeline version root .
* pipeline_version_reference pipeline_id, pipeline_version_id , null .
*/ */
public Map listExperiments( @SuppressWarnings("unchecked")
String namespace, private Set<String> getPipelineRootParameterNames(CreateRunRequest runRequest) {
int pageSize, CreateRunRequest.PipelineVersionReference ref = runRequest != null ? runRequest.getPipeline_version_reference() : null;
String pageToken if (ref == null || ref.getPipeline_id() == null || ref.getPipeline_id().isBlank()
) { || ref.getPipeline_version_id() == null || ref.getPipeline_version_id().isBlank()) {
return null;
}
String url = kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/pipelines/" + ref.getPipeline_id() + "/versions/" + ref.getPipeline_version_id();
try { try {
Map<String, Object> version = webClient.get()
.uri(url)
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(Map.class)
.block();
if (version == null) return null;
Object spec = version.get("pipeline_spec");
if (!(spec instanceof Map)) return null;
Map<String, Object> specMap = (Map<String, Object>) spec;
Object root = specMap.get("root");
if (root == null) root = specMap.get("Root");
if (!(root instanceof Map)) return null;
Map<String, Object> rootMap = (Map<String, Object>) root;
Object inputDef = rootMap.get("inputDefinitions");
if (inputDef == null) inputDef = rootMap.get("input_definitions");
if (!(inputDef instanceof Map)) return null;
Map<String, Object> inputDefMap = (Map<String, Object>) inputDef;
Object params = inputDefMap.get("parameters");
if (params == null) return Collections.emptySet();
Set<String> names = new HashSet<>();
if (params instanceof Map) {
for (Object key : ((Map<?, ?>) params).keySet()) {
if (key != null) names.add(key.toString());
}
return names;
}
if (params instanceof Iterable) {
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) {
log.warn("[KFP] Pipeline version spec 조회 실패, parameters 필터 없이 전달 (mlflow_experiment_name 제외): {}", e.getMessage());
return null;
}
}
String normalizedBaseUrl = /**
normalizeBaseUrl(kubeflowBaseUrl); * 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(CreateRunRequest runRequest, Set<String> allowedParamNames) {
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("display_name", runRequest.getDisplay_name() != null && !runRequest.getDisplay_name().isBlank()
? runRequest.getDisplay_name()
: "Run");
if (runRequest.getDescription() != null && !runRequest.getDescription().isBlank()) {
body.put("description", runRequest.getDescription());
}
if (runRequest.getPipeline_version_reference() != null) {
body.put("pipeline_version_reference", runRequest.getPipeline_version_reference());
}
if (runRequest.getService_account() != null && !runRequest.getService_account().isBlank()) {
body.put("service_account", runRequest.getService_account());
}
// runtime_config 필수 (KFP v2beta1). 파이프라인에 정의된 파라미터만 전달. 정의되지 않으면 mlflow_experiment_name은 넣지 않음.
Map<String, Object> runtimeConfig = new HashMap<>();
if (runRequest.getRuntime_config() != null && runRequest.getRuntime_config().getParameters() != null
&& !runRequest.getRuntime_config().getParameters().isEmpty()) {
Map<String, Object> params = runRequest.getRuntime_config().getParameters();
Map<String, Object> kfpParams = new HashMap<>();
for (Map.Entry<String, Object> e : params.entrySet()) {
String key = e.getKey();
if (key == null) continue;
if (allowedParamNames != null) {
if (!allowedParamNames.contains(key)) continue;
} else {
// 스펙 조회 실패 시: mlflow_* 파라미터는 파이프라인에 없을 수 있으므로 제외
if (key.startsWith("mlflow_")) continue;
}
kfpParams.put(key, e.getValue());
}
if (!kfpParams.isEmpty()) {
runtimeConfig.put("parameters", kfpParams);
}
}
body.put("runtime_config", runtimeConfig);
return body;
}
URI uri = UriComponentsBuilder /**
.fromHttpUrl(normalizedBaseUrl) * Experiments
.path("/apis/v2beta1/experiments") */
.queryParamIfPresent( public Map listExperiments(String namespace, int pageSize, String pageToken) {
"namespace", try {
optional(namespace) UriComponentsBuilder builder = UriComponentsBuilder
) .fromHttpUrl(kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/experiments");
.queryParamIfPresent(
"page_token",
optional(pageToken)
)
.queryParam(
"page_size",
pageSize
)
.build(true)
.toUri();
log.info("List Experiments URI={}", uri); if (namespace != null && !namespace.isBlank()) {
builder.queryParam("namespace", namespace);
}
if (pageSize > 0) {
builder.queryParam("page_size", pageSize);
}
if (pageToken != null && !pageToken.isBlank()) {
builder.queryParam("page_token", pageToken);
}
return webClient.get() return webClient.get()
.uri(uri) .uri(builder.toUriString())
.accept(MediaType.APPLICATION_JSON) .accept(MediaType.APPLICATION_JSON)
.retrieve() .retrieve()
.bodyToMono(Map.class) .bodyToMono(Map.class)
.block(); .block();
} catch (Exception e) { } catch (Exception e) {
throw new RuntimeException("Kubeflow Experiments 조회 실패", e);
log.error("Experiment list failed", e);
throw new RuntimeException(
"Kubeflow Experiments 조회 실패",
e
);
} }
} }
/** /**
* Experiment * Experiment
*/ */
public Map getExperimentById( public Map getExperimentById(String experimentId) {
String experimentId
) {
try { try {
String url = kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/experiments/" + experimentId;
String normalizedBaseUrl =
normalizeBaseUrl(kubeflowBaseUrl);
URI uri = UriComponentsBuilder
.fromHttpUrl(normalizedBaseUrl)
.path("/apis/v2beta1/experiments/{id}")
.buildAndExpand(experimentId)
.toUri();
log.info("Get Experiment URI={}", uri);
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 (Exception e) { } catch (Exception e) {
throw new RuntimeException("Kubeflow experiment 조회 실패: " + experimentId, e);
log.error(
"Experiment 조회 실패. experimentId={}",
experimentId,
e
);
throw new RuntimeException(
"Kubeflow experiment 조회 실패: "
+ experimentId,
e
);
} }
} }
private String normalizeBaseUrl( /**
String baseUrl * KFP Run (v2beta1 GET /apis/v2beta1/runs/{run_id}).
) { * run_details.task_details[].pod_name UI Pod .
*/
if (baseUrl == null || @SuppressWarnings("unchecked")
baseUrl.isBlank()) { public Map<String, Object> getKfpRunById(String runId) {
if (runId == null || runId.isBlank()) {
throw new IllegalArgumentException( return null;
"kubeflow.url is empty"
);
} }
String url = kubeflowBaseUrl.replaceAll("/+$", "") + "/apis/v2beta1/runs/" + runId.trim();
baseUrl = baseUrl.trim(); try {
return webClient.get()
if (baseUrl.endsWith("/")) { .uri(url)
baseUrl = .accept(MediaType.APPLICATION_JSON)
baseUrl.substring( .retrieve()
0, .bodyToMono(Map.class)
baseUrl.length() - 1 .block();
); } catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.debug("[KFP] Run not found: {}", runId);
} else {
log.warn("[KFP] GetRun {}: {}", runId, e.getMessage());
}
return null;
} catch (Exception e) {
log.debug("[KFP] GetRun failed {}: {}", runId, e.getMessage());
return null;
} }
return baseUrl;
} }
private java.util.Optional<String> optional( /**
String value * KFP ml-pipeline Pod (KFP UI ).
) { * {@code GET /apis/v1beta1/runs/{run_id}/nodes/{node_id}/log}
* <p>v2 Run ID . 404/ null.</p>
if (value == null || */
value.isBlank()) { public String getV1beta1RunNodeLog(String runId, String nodeId) {
if (runId == null || runId.isBlank() || nodeId == null || nodeId.isBlank()) {
return java.util.Optional.empty(); return null;
}
String base = kubeflowBaseUrl.replaceAll("/+$", "");
String encRun = UriUtils.encodePathSegment(runId.trim(), StandardCharsets.UTF_8);
String encNode = UriUtils.encodePathSegment(nodeId.trim(), StandardCharsets.UTF_8);
String url = base + "/apis/v1beta1/runs/" + encRun + "/nodes/" + encNode + "/log";
try {
return webClient.get()
.uri(url)
.accept(MediaType.TEXT_PLAIN, MediaType.APPLICATION_JSON)
.retrieve()
.bodyToMono(String.class)
.timeout(Duration.ofSeconds(120))
.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) {
log.debug("[KFP] v1beta1 node log failed runId={} node={}: {}", runId, nodeId, e.getMessage());
return null;
}
} }
return java.util.Optional.of(value); /**
* KFP Run (v2beta1 DELETE /apis/v2beta1/runs/{run_id}).
* (2xx) (404) . 4xx/5xx .
*/
public void deleteKfpRun(String runId, String experimentId) {
String base = kubeflowBaseUrl.replaceAll("/+$", "");
String url = base + "/apis/v2beta1/runs/" + runId;
if (experimentId != null && !experimentId.isBlank()) {
url = url + "?experiment_id=" + experimentId;
}
try {
webClient.delete()
.uri(url)
.retrieve()
.onStatus(status -> status.value() == 404, resp -> Mono.empty())
.onStatus(status -> status.is4xxClientError() || status.is5xxServerError(),
resp -> resp.bodyToMono(String.class)
.doOnNext(msg -> log.warn("[KFP] DeleteRun error {}: {}", resp.statusCode(), msg))
.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();
// experimentId가 있을 때만 필터 (비어 있으면 전체 목록)
if (experimentId != null && !experimentId.isBlank()) {
predicate = cb.and(predicate, cb.equal(root.get("experimentId"), experimentId)); 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() ||

@ -0,0 +1,95 @@
package kr.re.etri.autoflow.specification;
import jakarta.annotation.PostConstruct;
import jakarta.persistence.EntityManager;
import jakarta.persistence.criteria.Predicate;
import jakarta.persistence.metamodel.Attribute;
import jakarta.persistence.metamodel.EntityType;
import jakarta.persistence.metamodel.Metamodel;
import kr.re.etri.autoflow.entity.MinioAttachmentEntity;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Component;
import java.time.LocalDate;
import java.util.Set;
import java.util.stream.Collectors;
@Slf4j
@Component
@RequiredArgsConstructor
public class MinioAttachmentSpecification {
private final EntityManager entityManager;
private Set<String> stringFields;
@PostConstruct
public void init() {
Metamodel metamodel = entityManager.getMetamodel();
EntityType<MinioAttachmentEntity> entityType = metamodel.entity(MinioAttachmentEntity.class);
// 문자열 타입 필드명만 추출
stringFields = entityType.getAttributes().stream()
.filter(attr -> attr.getJavaType().equals(String.class))
.map(Attribute::getName)
.collect(Collectors.toSet());
log.info("MinioAttachmentEntity string fields: {}", stringFields);
}
public Specification<MinioAttachmentEntity> searchByConditions(
String refType,
Integer refId,
String searchType,
String keyword,
LocalDate startDate,
LocalDate endDate
) {
return (root, query, cb) -> {
Predicate predicate = cb.conjunction();
// refType 조건 추가
if (refType != null && !refType.isBlank()) {
predicate = cb.and(predicate, cb.equal(root.get("refType"), refType));
}
// refId 조건 추가
if (refId != null ) {
predicate = cb.and(predicate, cb.equal(root.get("refId"), refId));
}
// keyword 검색
if (keyword != null && !keyword.isEmpty()) {
if (searchType == null || searchType.isEmpty()
|| "전체".equalsIgnoreCase(searchType)
|| "all".equalsIgnoreCase(searchType)) {
Predicate orPredicate = cb.disjunction();
for (String field : stringFields) {
orPredicate = cb.or(orPredicate,
cb.like(cb.lower(root.get(field)), "%" + keyword.toLowerCase() + "%"));
}
predicate = cb.and(predicate, orPredicate);
} else if (stringFields.contains(searchType)) {
predicate = cb.and(predicate,
cb.like(cb.lower(root.get(searchType)), "%" + keyword.toLowerCase() + "%"));
}
}
// 날짜 검색
if (startDate != null) {
predicate = cb.and(predicate,
cb.greaterThanOrEqualTo(root.get("regDt"), startDate.atStartOfDay()));
}
if (endDate != null) {
predicate = cb.and(predicate,
cb.lessThanOrEqualTo(root.get("regDt"), endDate.atTime(23, 59, 59)));
}
return predicate;
};
}
}

@ -39,7 +39,12 @@ server.forward-headers-strategy=native
kubeflow.url=http://ml-pipeline-ui.kubeflow.svc.cluster.local:80 kubeflow.url=http://ml-pipeline-ui.kubeflow.svc.cluster.local:80
# MinIO Configuration for K3s # MinIO Configuration for K3s
minio.endpoint=http://minio.minio.svc.cluster.local:9000 minio.endpoint=http://minio.etri-aisw.svc.cluster.local:9000
minio.access-key=HpaY4yx33VhsIE18nh1b minio.access-key=HpaY4yx33VhsIE18nh1b
minio.secret-key=SDuToOgDZdSKR032j895mHZyDOqQaB88Wpg9RjMk minio.secret-key=SDuToOgDZdSKR032j895mHZyDOqQaB88Wpg9RjMk
minio.bucket=mlpipeline minio.bucket=mlpipeline
# MLflow
mlflow.url=http://mlflow.etri-aisw.svc.cluster.local:80
mlflow.user=mlflow
mlflow.password=mlflowpassword

@ -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

@ -0,0 +1,48 @@
# WSL 로컬 환경: k3s Kubeflow/MLflow/MinIO 연동 (port-forward 기준)
# 사용: spring.profiles.active=wsl 또는 --spring.profiles.active=wsl
#
# 스크립트 컴파일(TrainingScript py→YAML) 사용 시 WSL에 한 번 설치:
# sudo apt install -y python3 python3-pip
# pip3 install kfp
# DB - WSL 내 MariaDB 또는 kubeflow/mysql port-forward 3306:3306 후 DB 생성
spring.datasource.url=jdbc:mariadb://localhost:3306/autoflow
spring.datasource.username=autoflow
spring.datasource.password=autoflow
# resource/data.sql 사용: 테이블 생성(BATCH_*) + 초기 데이터(tb_role, tb_user, tb_project 등)
# JPA가 엔티티 테이블(tb_*)을 만든 뒤 data.sql이 실행됨
spring.jpa.hibernate.ddl-auto=update
spring.sql.init.mode=always
# 재시작 시 data.sql 재실행 시 INSERT 중복/시퀀스 이미 존재 등 오류 무시
spring.sql.init.continue-on-error=true
# Kubeflow Pipelines API (WSL에서: kubectl port-forward -n kubeflow svc/ml-pipeline 8888:8888)
kubeflow.url=http://localhost:8888
# MLflow (WSL에서: kubectl port-forward -n kubeflow svc/mlflow-server 5000:5000)
mlflow.url=http://localhost:5000
mlflow.user=
mlflow.password=
# MinIO type1 = Kubeflow 파이프라인 (port-forward: svc/minio-service 9000:9000)
# 기본 단일 MinIO는 application.properties와 동일 계정(minio/minio123) 사용
minio.endpoint=http://localhost:9000
minio.access-key=minio
minio.secret-key=minio123
minio.bucket=mlpipeline
minio.endpoint.pod=http://minio-service.kubeflow.svc.cluster.local:9000
minio.type1.endpoint=http://localhost:9000
minio.type1.bucket=mlpipeline
minio.type1.access-key=minio
minio.type1.secret-key=minio123
# MinIO type2 = MLflow 아티팩트 (port-forward: svc/minio-mlflow 9001:9000)
minio.type2.endpoint=http://localhost:9001
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 스크립트 컴파일: WSL에서는 python3 사용 (기본값)
kfp.compile.python-command=python3

@ -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/
@ -82,9 +122,3 @@ 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 (

@ -0,0 +1,54 @@
#!/usr/bin/env python3
"""
KFP DSL .py 파이프라인을 KFP 학습 실행용 YAML로 컴파일합니다.
사용법: python compile_kfp_pipeline.py <input.py> <output.yaml>
스크립트에는 @dsl.pipeline 데코레이터가 붙은 함수가 하나 이상 있어야 합니다.
우선 'pipeline', 'my_pipeline' 이름을 찾고, 없으면 모듈 모든 @dsl.pipeline 함수를 시도합니다.
"""
import sys
import importlib.util
def main():
if len(sys.argv) != 3:
print("Usage: compile_kfp_pipeline.py <input.py> <output.yaml>", file=sys.stderr)
sys.exit(1)
py_path = sys.argv[1]
yaml_path = sys.argv[2]
spec = importlib.util.spec_from_file_location("user_pipeline", py_path)
mod = importlib.util.module_from_spec(spec)
sys.modules["user_pipeline"] = mod
spec.loader.exec_module(mod)
from kfp import compiler
comp = compiler.Compiler()
# 1) 권장 이름 먼저
pipeline_fn = getattr(mod, "pipeline", None) or getattr(mod, "my_pipeline", None)
if pipeline_fn is not None and callable(pipeline_fn):
comp.compile(pipeline_fn, yaml_path)
return
# 2) 모듈에서 @dsl.pipeline 데코레이터가 붙은 함수 찾기 (이름 무관)
for name in dir(mod):
if name.startswith("_"):
continue
obj = getattr(mod, name)
if not callable(obj):
continue
try:
comp.compile(obj, yaml_path)
return
except Exception:
continue
print(
"No @dsl.pipeline function found. Define a function with @dsl.pipeline (e.g. pipeline, my_pipeline, or any name).",
file=sys.stderr,
)
sys.exit(2)
if __name__ == "__main__":
main()
Loading…
Cancel
Save