133 lines
5.3 KiB
Java
133 lines
5.3 KiB
Java
package com.erp.provisioning;
|
|
|
|
import com.erp.tenant.TenantDbSettings;
|
|
import lombok.RequiredArgsConstructor;
|
|
import lombok.extern.slf4j.Slf4j;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
import java.io.IOException;
|
|
import java.io.InputStream;
|
|
import java.io.OutputStream;
|
|
import java.nio.charset.StandardCharsets;
|
|
import java.util.regex.Pattern;
|
|
|
|
/**
|
|
* pg_dump → psql 파이프로 스키마 복제.
|
|
*
|
|
* ★ 보안 강화 (2026-04-24 Codex 리뷰 반영):
|
|
* - 이전 버전: "sh -c 'pg_dump ... | psql ...'" 로 쉘 문자열 구성 → command injection 표면.
|
|
* - 현재: ProcessBuilder 에 각 인자를 배열로 전달 + JVM 내부에서 stdout→stdin 파이프.
|
|
* - DB 이름은 ^[a-z][a-z0-9_]{{2,40}}$ 화이트리스트 재검증.
|
|
* - PGPASSWORD 는 여전히 env 로 전달. (.pgpass 전환은 별도 작업)
|
|
*/
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
@Slf4j
|
|
public class SchemaCopier {
|
|
|
|
private static final Pattern SAFE_DB_NAME = Pattern.compile("^[a-z][a-z0-9_]{2,40}$");
|
|
private static final int BUFFER_SIZE = 64 * 1024;
|
|
private static final long TIMEOUT_MILLIS = 5 * 60 * 1000; // 5분
|
|
|
|
private final TenantDbSettings settings;
|
|
|
|
public void copySchema(String srcDb, String dstDb) throws IOException, InterruptedException {
|
|
requireSafeDbName(srcDb);
|
|
requireSafeDbName(dstDb);
|
|
|
|
ProcessBuilder dumpPb = new ProcessBuilder(
|
|
"pg_dump",
|
|
"-h", settings.host(),
|
|
"-p", String.valueOf(settings.port()),
|
|
"-U", settings.username(),
|
|
"-s", // schema only
|
|
"--no-owner",
|
|
"--no-privileges",
|
|
srcDb
|
|
);
|
|
dumpPb.environment().put("PGPASSWORD", settings.password());
|
|
dumpPb.redirectErrorStream(false);
|
|
|
|
ProcessBuilder restorePb = new ProcessBuilder(
|
|
"psql",
|
|
"-h", settings.host(),
|
|
"-p", String.valueOf(settings.port()),
|
|
"-U", settings.username(),
|
|
"-d", dstDb,
|
|
"-v", "ON_ERROR_STOP=1"
|
|
);
|
|
restorePb.environment().put("PGPASSWORD", settings.password());
|
|
restorePb.redirectErrorStream(false);
|
|
|
|
Process dump = dumpPb.start();
|
|
Process restore = restorePb.start();
|
|
|
|
// dump stdout -> restore stdin
|
|
Thread pipe = new Thread(() -> {
|
|
try (InputStream in = dump.getInputStream();
|
|
OutputStream out = restore.getOutputStream()) {
|
|
byte[] buf = new byte[BUFFER_SIZE];
|
|
int n;
|
|
while ((n = in.read(buf)) != -1) out.write(buf, 0, n);
|
|
} catch (IOException e) {
|
|
log.warn("[Provisioning] pipe error (dump→psql): {}", e.getMessage());
|
|
}
|
|
}, "schema-copy-pipe");
|
|
pipe.setDaemon(true);
|
|
|
|
// stderr 캡처 (비동기)
|
|
StringBuilder dumpErr = new StringBuilder();
|
|
StringBuilder restoreErr = new StringBuilder();
|
|
Thread dumpErrT = asyncCollect(dump.getErrorStream(), dumpErr, "pg_dump-stderr");
|
|
Thread restoreErrT = asyncCollect(restore.getErrorStream(), restoreErr, "psql-stderr");
|
|
|
|
pipe.start();
|
|
dumpErrT.start();
|
|
restoreErrT.start();
|
|
|
|
long deadline = System.currentTimeMillis() + TIMEOUT_MILLIS;
|
|
boolean dumpDone = dump.waitFor(TIMEOUT_MILLIS, java.util.concurrent.TimeUnit.MILLISECONDS);
|
|
if (!dumpDone) { dump.destroyForcibly(); restore.destroyForcibly(); throw new IOException("pg_dump timed out"); }
|
|
long remaining = Math.max(1, deadline - System.currentTimeMillis());
|
|
boolean restoreDone = restore.waitFor(remaining, java.util.concurrent.TimeUnit.MILLISECONDS);
|
|
if (!restoreDone) { restore.destroyForcibly(); throw new IOException("psql timed out"); }
|
|
|
|
pipe.join(5_000);
|
|
dumpErrT.join(5_000);
|
|
restoreErrT.join(5_000);
|
|
|
|
int dumpExit = dump.exitValue();
|
|
int restoreExit = restore.exitValue();
|
|
|
|
if (dumpExit != 0 || restoreExit != 0) {
|
|
String dumpLog = dumpErr.length() > 2000 ? dumpErr.substring(0, 2000) + "…" : dumpErr.toString();
|
|
String restoreLog = restoreErr.length() > 2000 ? restoreErr.substring(0, 2000) + "…" : restoreErr.toString();
|
|
throw new IOException(String.format(
|
|
"schema copy failed (dumpExit=%d, restoreExit=%d)\n--pg_dump stderr--\n%s\n--psql stderr--\n%s",
|
|
dumpExit, restoreExit, dumpLog, restoreLog));
|
|
}
|
|
log.info("[Provisioning] COPY SCHEMA: {} -> {}", srcDb, dstDb);
|
|
}
|
|
|
|
private static void requireSafeDbName(String name) {
|
|
if (name == null || !SAFE_DB_NAME.matcher(name).matches()) {
|
|
throw new IllegalArgumentException("unsafe db name: " + name);
|
|
}
|
|
}
|
|
|
|
private static Thread asyncCollect(InputStream in, StringBuilder sink, String threadName) {
|
|
Thread t = new Thread(() -> {
|
|
try (InputStream is = in) {
|
|
byte[] buf = new byte[4096];
|
|
int n;
|
|
while ((n = is.read(buf)) != -1) {
|
|
sink.append(new String(buf, 0, n, StandardCharsets.UTF_8));
|
|
if (sink.length() > 10_000) break; // runaway 방어
|
|
}
|
|
} catch (IOException ignored) { }
|
|
}, threadName);
|
|
t.setDaemon(true);
|
|
return t;
|
|
}
|
|
}
|