Repository navigation
Test Specs IV0
생성: 2026-10-13 · 기준: v07 ← Test Scenarios · IV 허브 · 기능 TC · TC-ADMIN
Playwright(TypeScript) spec 소스입니다. 시나리오마다 ## <시나리오번호> 섹션이 있고 앵커는 #<시나리오번호 소문자> 입니다.
IV0 는 브라우저 없이 설치 대상 서버의 터미널 명령으로 확인하는 CLI 테스트입니다. 의존은 @playwright/test 뿐이라, 이미 설치된 Playwright 프로젝트의 tests/iv0/ 에 공용 헬퍼 _cli.ts 와 spec 파일을 복사하면 실행됩니다.
# 원격 서버(ssh)에서 실행 — 상태를 바꾸지 않는 시나리오만 실행되고 나머지는 건너뜀
ADMIN_CLI_SSH=ubuntu@<host> ADMIN_CLI_SSH_KEY=<key.pem> ADMIN_CLI_BIN=~/mc-admin-cli/bin npx playwright test tests/iv0
# 시나리오 1건
ADMIN_CLI_SSH=ubuntu@<host> ADMIN_CLI_SSH_KEY=<key.pem> npx playwright test tests/iv0/IV0_S14.spec.ts
# 설치·삭제 등 서버 상태를 바꾸는 시나리오(전용 테스트 서버에서만)
ADMIN_CLI_ALLOW_DESTRUCTIVE=1 ADMIN_CLI_SSH=ubuntu@<host> ADMIN_CLI_SSH_KEY=<key.pem> npx playwright test tests/iv0/IV0_S01.spec.ts| 환경변수 | 기본값 | 사용 시나리오 | 설명 |
|---|---|---|---|
ADMIN_CLI_SSH |
(없음 = 로컬) | 전체 | 설치 대상 ssh 주소 |
ADMIN_CLI_SSH_KEY |
(없음) | 전체 | ssh 개인키 경로 |
ADMIN_CLI_BIN |
~/mc-admin-cli/bin |
전체 | mc-admin-cli bin 경로 |
ADMIN_CLI_ALLOW_DESTRUCTIVE |
(없음) | S01 S02 S05~S12 S24 S26 |
1 이면 서버 상태를 바꾸는 시나리오 실행 |
ADMIN_CLI_ALLOW_REINSTALL |
(없음) | S01 S02 S10 |
1 이면 기존 mc- 컨테이너가 있어도 설치 진행 |
ADMIN_CLI_DOMAIN |
(없음 = Enter, localhost) | S01 | 도메인 입력값. IP 면 자체 서명 인증서 경로 |
ADMIN_CLI_FQDN |
(없음) | S02 S10 | 실제 도메인(Let's Encrypt) |
ADMIN_CLI_PUBLIC_HOST |
(없음) | S03 | 외부에서 접속할 서버 주소 |
ADMIN_CLI_PORTS |
80,443,3001,33002,7780,9090,9000,3000 |
S03 | 점검할 포트 |
ADMIN_CLI_DEFAULT_WORKSPACE / _PROJECT
|
ws01 / default
|
S04 | 초기화로 생성되는 기본 workspace/project |
ADMIN_CLI_TARGET_SERVICE |
mc-data-manager |
S07 S08 | 대상 서비스(all=전체, S08) |
ADMIN_CLI_K8S_PROVIDER / _VALUES
|
minikube / ../helm-chart/values.yaml
|
S11~S13 | K8s 배포 대상 |
ADMIN_CLI_ECHO_PORT |
18999 |
S14~S18 | 임시 echo 서버 포트(대상 서버 127.0.0.1) |
ADMIN_CLI_TB_AUTH |
default:default |
S15 S21~S25 | mc-infra-manager Basic 인증 |
ADMIN_CLI_API_CONFIG |
(없음 = api.yaml 호스트명→localhost 사본) | S20~S22 | mcc api 설정 파일 |
ADMIN_CLI_CSP / ADMIN_CLI_CRED_FILE
|
aws / (없음) |
S24 | 등록할 CSP, credential key=value 파일(이 PC) |
ADMIN_CLI_INIT_INPUT |
y\n1\ny\n |
S26 | infra-init 대화형 입력 |
결과: 명령 출력이 Playwright 리포트 첨부로 남습니다(비밀번호·credential 값은 첨부 전에 가림). 화면이 없어 영상은 생성되지 않습니다.
| 시나리오 | 파일 | 줄 | 해시 |
|---|---|---|---|
| 공용 헬퍼 | tests/iv0/_cli.ts |
179 | 1754fd79 |
| IV0_S01 | tests/iv0/IV0_S01.spec.ts |
37 | 36583587 |
| IV0_S02 | tests/iv0/IV0_S02.spec.ts |
48 | 1fda97b0 |
| IV0_S03 | tests/iv0/IV0_S03.spec.ts |
36 | 8210157a |
| IV0_S04 | tests/iv0/IV0_S04.spec.ts |
39 | 7159259d |
| IV0_S05 | tests/iv0/IV0_S05.spec.ts |
33 | cb875115 |
| IV0_S06 | tests/iv0/IV0_S06.spec.ts |
27 | a84ddad3 |
| IV0_S07 | tests/iv0/IV0_S07.spec.ts |
33 | 79f3ea35 |
| IV0_S08 | tests/iv0/IV0_S08.spec.ts |
38 | fcc2a71a |
| IV0_S09 | tests/iv0/IV0_S09.spec.ts |
35 | b47701dd |
| IV0_S10 | tests/iv0/IV0_S10.spec.ts |
44 | 279c16b7 |
| IV0_S11 | tests/iv0/IV0_S11.spec.ts |
39 | 6b23b35d |
| IV0_S12 | tests/iv0/IV0_S12.spec.ts |
23 | fd80da3a |
| IV0_S13 | tests/iv0/IV0_S13.spec.ts |
23 | c9c82ca1 |
| IV0_S14 | tests/iv0/IV0_S14.spec.ts |
26 | b6c58611 |
| IV0_S15 | tests/iv0/IV0_S15.spec.ts |
34 | 94094503 |
| IV0_S16 | tests/iv0/IV0_S16.spec.ts |
30 | 1caf97f6 |
| IV0_S17 | tests/iv0/IV0_S17.spec.ts |
28 | e71547ba |
| IV0_S18 | tests/iv0/IV0_S18.spec.ts |
25 | 087c2c71 |
| IV0_S19 | tests/iv0/IV0_S19.spec.ts |
37 | 9fe1e75f |
| IV0_S20 | tests/iv0/IV0_S20.spec.ts |
32 | 653f91eb |
| IV0_S21 | tests/iv0/IV0_S21.spec.ts |
35 | aed0c1ea |
| IV0_S22 | tests/iv0/IV0_S22.spec.ts |
24 | 9740de4b |
| IV0_S23 | tests/iv0/IV0_S23.spec.ts |
41 | 9f25ea19 |
| IV0_S24 | tests/iv0/IV0_S24.spec.ts |
59 | a55caa67 |
| IV0_S25 | tests/iv0/IV0_S25.spec.ts |
62 | 56b20ddb |
| IV0_S26 | tests/iv0/IV0_S26.spec.ts |
46 | 140a8e5b |
tests/iv0/_cli.ts · 179줄 · 해시 1754fd79
import { expect, test, type TestInfo } from '@playwright/test';
import { spawn } from 'node:child_process';
// IV0 공용 헬퍼 — mc-admin-cli(CLI) 테스트를 설치 대상 서버의 터미널 명령으로 실행한다.
// 외부 의존은 @playwright/test 뿐이다. 이미 설치된 Playwright 프로젝트의 tests/iv0/ 에 이 파일과 spec 을 함께 복사한다.
//
// 공통 환경변수
// ADMIN_CLI_SSH 설치 대상 ssh 주소(예: ubuntu@1.2.3.4). 비우면 로컬에서 실행
// ADMIN_CLI_SSH_KEY ssh 개인키 경로(선택)
// ADMIN_CLI_BIN mc-admin-cli 의 bin 경로 (기본 ~/mc-admin-cli/bin)
// ADMIN_CLI_TB_AUTH mc-infra-manager Basic 인증 user:password (기본 default:default)
// ADMIN_CLI_ALLOW_DESTRUCTIVE=1 설치·삭제처럼 서버 상태를 바꾸는 TC 실행 허용(기본: 건너뜀)
export const SSH = process.env.ADMIN_CLI_SSH ?? '';
export const KEY = process.env.ADMIN_CLI_SSH_KEY ?? '';
export const BIN = process.env.ADMIN_CLI_BIN ?? '~/mc-admin-cli/bin';
export const TB_AUTH = process.env.ADMIN_CLI_TB_AUTH ?? 'default:default';
export const TB = 'http://localhost:1323/tumblebug';
export type Run = { code: number; out: string };
const q = (s: string) => `'${s.replace(/'/g, `'\\''`)}'`;
// 대상 서버에서 bash 명령 실행. stdin 으로 대화형 입력을 넘긴다. stdout+stderr 를 합쳐 돌려준다.
export function sh(cmd: string, stdin = '', timeout = 120_000): Promise<Run> {
const args = SSH
? ['-o', 'StrictHostKeyChecking=accept-new', '-o', 'ServerAliveInterval=30', ...(KEY ? ['-i', KEY] : []), SSH, 'bash', '-lc', q(cmd)]
: ['-lc', cmd];
return new Promise((resolve) => {
const p = spawn(SSH ? 'ssh' : 'bash', args, { env: { ...process.env, TERM: 'dumb' } });
let out = '';
p.stdout.on('data', (d) => (out += d));
p.stderr.on('data', (d) => (out += d));
const t = setTimeout(() => p.kill('SIGTERM'), timeout);
p.on('close', (code) => { clearTimeout(t); resolve({ code: code ?? -1, out }); });
p.stdin.end(stdin);
});
}
// bin 폴더에서 실행 (mcc, *.sh 는 bin 기준 상대경로를 쓴다)
export const inBin = (cmd: string, stdin = '', timeout?: number) => sh(`cd ${BIN} && ${cmd}`, stdin, timeout);
// 명령 출력을 리포트에 첨부
export async function attach(testInfo: TestInfo, name: string, r: Run) {
await testInfo.attach(name, { body: `exit=${r.code}\n${r.out}`, contentType: 'text/plain' });
}
// 서버 상태를 바꾸는 TC 는 명시적으로 허용했을 때만 실행
export function requireDestructive(what: string) {
test.skip(process.env.ADMIN_CLI_ALLOW_DESTRUCTIVE !== '1', `${what} — 서버 상태를 바꾸므로 ADMIN_CLI_ALLOW_DESTRUCTIVE=1 일 때만 실행`);
}
// installAll.sh 의 EXPECTED_CONTAINERS / NO_HEALTH_CHECK_CONTAINERS 배열 (버전마다 다르므로 스크립트에서 읽음)
export async function readArray(name: string): Promise<string[]> {
const r = await inBin(`sed -n '/^${name}=(/,/^)/p' installAll.sh`);
return r.out.split('\n').map((l) => l.trim()).filter((l) => /^"[^"]+"$/.test(l)).map((l) => l.slice(1, -1));
}
// mcc infra info 출력에서 컨테이너별 상태 행을 찾는다(앞쪽 compose ps 표만 사용)
export function psLine(info: string, name: string): string | undefined {
return info.split('[v]Status of M-CMP runtime images')[0].split('\n').find((l) => new RegExp(`^${name}\\s`).test(l));
}
// 기대 컨테이너 전체가 Up(+health check 가 있으면 healthy)인지 확인. 문제 목록을 돌려준다.
// docker compose 버전에 따라 상태가 'Up 5 minutes (healthy)' 또는 'running (healthy)' 로 나온다.
export async function unhealthy(names?: string[]): Promise<{ bad: string[]; info: Run }> {
const info = await inBin('./mcc infra info');
const expected = names ?? (await readArray('EXPECTED_CONTAINERS'));
const noHealth = new Set(await readArray('NO_HEALTH_CHECK_CONTAINERS'));
const bad: string[] = [];
for (const name of expected) {
const line = psLine(info.out, name);
if (!line) bad.push(`${name}: 없음`);
else if (!/\bUp\b|\brunning\b/.test(line) || /unhealthy|starting|restarting/i.test(line)) bad.push(`${name}: ${line.replace(/\s{2,}/g, ' ')}`);
else if (!noHealth.has(name) && !/\(healthy\)/.test(line)) bad.push(`${name}: healthy 표시 없음`);
}
return { bad, info };
}
// healthy 가 될 때까지 대기
export async function waitHealthy(names?: string[], timeoutMs = 20 * 60_000) {
let last: string[] = [];
await expect.poll(async () => (last = (await unhealthy(names)).bad).length, { timeout: timeoutMs, intervals: [15_000] })
.toBe(0)
.catch(() => { throw new Error(`healthy 아닌 컨테이너:\n${last.join('\n')}`); });
}
// M-CMP(mcc compose 프로젝트) 컨테이너 이름 목록 — 이름이 mc- 로 시작하는 다른 제품 컨테이너(mc-terrarium 등)는 제외
export async function mcContainers(all = true): Promise<string[]> {
const r = await sh(`docker ps ${all ? '-a' : ''} --filter label=com.docker.compose.project=mcc --format '{{.Names}}'`);
return r.out.split('\n').map((s) => s.trim()).filter(Boolean);
}
// 클린 설치 TC 의 선행 상태: mc- 컨테이너가 없어야 한다
export async function requireClean() {
const existing = await mcContainers();
if (existing.length && process.env.ADMIN_CLI_ALLOW_REINSTALL !== '1') {
throw new Error(`M-CMP(mcc) 컨테이너 ${existing.length}개가 이미 있습니다. 클린 설치가 아니면 TC-ADMIN-005(cleanAll.sh) 후 실행하거나 ADMIN_CLI_ALLOW_REINSTALL=1 로 진행하세요.`);
}
}
// mc-infra-manager(tumblebug) REST 호출 — 대상 서버의 localhost 기준
export async function tb(path: string, method = 'GET', body?: unknown): Promise<{ status: number; json: any; raw: string }> {
const data = body === undefined ? '' : ` -H 'Content-Type: application/json' -d ${q(JSON.stringify(body))}`;
const r = await sh(`curl -s -u ${q(TB_AUTH)} -X ${method} -w '\\n%{http_code}' ${q(TB + path)}${data}`);
const lines = r.out.trimEnd().split('\n');
const status = Number(lines.pop());
const raw = lines.join('\n');
let json: any = null;
try { json = JSON.parse(raw); } catch { /* 본문이 JSON 이 아님 */ }
return { status, json, raw };
}
// mc-iam-manager 플랫폼 관리자 토큰 — 계정은 대상 서버 .env 에서 읽고 화면·리포트에 남기지 않는다
export async function iamToken(): Promise<string> {
const env = `${BIN}/../conf/docker/conf/mc-iam-manager/.env`;
const r = await sh(
`set -a; . ${env}; set +a; ` +
`curl -s -X POST http://localhost:5000/api/auth/login -H 'Content-Type: application/json' ` +
`-d "{\\"id\\":\\"$MC_IAM_MANAGER_PLATFORMADMIN_ID\\",\\"password\\":\\"$MC_IAM_MANAGER_PLATFORMADMIN_PASSWORD\\"}"`,
);
const token = (() => { try { return JSON.parse(r.out).access_token as string; } catch { return ''; } })();
expect(token, `플랫폼 관리자 로그인 실패: ${r.out.slice(0, 200)}`).toBeTruthy();
return token;
}
// mc-iam-manager POST */list 호출
export async function iamList(token: string, path: string): Promise<any> {
const r = await sh(`curl -s -X POST ${q('http://localhost:5000' + path)} -H 'Content-Type: application/json' -H ${q('Authorization: Bearer ' + token)} -d '{}'`);
try { return JSON.parse(r.out); } catch { return r.out; }
}
// ── mcc rest 검증용 echo 서버 ─────────────────────────────────────────────
// 대상 서버에 받은 요청(method, path, headers, body)을 JSON 으로 돌려주는 임시 서버를 띄운다.
// ?status=NNN 이면 그 상태 코드로 응답한다. 테스트 끝에 stopEcho() 로 종료.
export const ECHO_PORT = Number(process.env.ADMIN_CLI_ECHO_PORT ?? 18999);
export const ECHO = `http://127.0.0.1:${ECHO_PORT}/echo`;
const ECHO_PY = `
import json, sys
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.parse import urlparse, parse_qs
class H(BaseHTTPRequestHandler):
def _h(self):
n = int(self.headers.get('Content-Length') or 0)
body = self.rfile.read(n).decode() if n else ''
st = int(parse_qs(urlparse(self.path).query).get('status', ['200'])[0])
out = json.dumps({'method': self.command, 'path': self.path, 'headers': dict(self.headers), 'body': body}).encode()
self.send_response(st); self.send_header('Content-Type', 'application/json'); self.send_header('X-Echo', 'iv0')
self.send_header('Content-Length', str(len(out))); self.end_headers()
if self.command != 'HEAD': self.wfile.write(out)
do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = _h
def log_message(self, *a): pass
HTTPServer(('127.0.0.1', int(sys.argv[1])), H).serve_forever()
`;
export async function startEcho() {
await sh(`cat > /tmp/iv0-echo.py <<'PYEOF'\n${ECHO_PY}\nPYEOF\nnohup python3 /tmp/iv0-echo.py ${ECHO_PORT} >/dev/null 2>&1 & echo $! > /tmp/iv0-echo.pid; sleep 1`);
expect((await sh(`curl -s -o /dev/null -w '%{http_code}' ${ECHO}`)).out, 'echo 서버 기동 실패').toBe('200');
}
export async function stopEcho() {
await sh('kill $(cat /tmp/iv0-echo.pid) 2>/dev/null; rm -f /tmp/iv0-echo.pid /tmp/iv0-echo.py');
}
// mcc 출력에서 echo 응답 JSON 을 찾는다
export function echoed(out: string): any {
const m = out.match(/\{"method".*\}/);
return m ? JSON.parse(m[0]) : null;
}
// ── mcc api 설정 ────────────────────────────────────────────────────────
// conf/api.yaml 의 baseurl 은 docker 내부 호스트명(http://mc-infra-manager:1323 …)이라 서버 셸에서는 풀리지 않는다.
// 문서 안내대로 '실제 서버 주소에 맞게' 호스트명만 localhost 로 바꾼 사본(/tmp/iv0-api.yaml)을 만든다.
// ADMIN_CLI_API_CONFIG 를 주면 그 파일을 그대로 쓴다.
export async function apiConfig(): Promise<string> {
if (process.env.ADMIN_CLI_API_CONFIG) return process.env.ADMIN_CLI_API_CONFIG;
await inBin(`sed -E 's#(baseurl: *"?https?://)[a-z0-9-]+:#\\1localhost:#' ../conf/api.yaml > /tmp/iv0-api.yaml`);
return '/tmp/iv0-api.yaml';
}
const [TBU, TBP] = TB_AUTH.split(':');
// mc-infra-manager 호출 시 인증을 명령 인자로 덮어쓴다(--authUser/--authPassword)
export const TB_AUTH_ARGS = `--authUser ${q(TBU)} --authPassword ${q(TBP)}`;tests/iv0/IV0_S01.spec.ts · 37줄 · 해시 36583587 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, attach, requireDestructive, requireClean, unhealthy } from './_cli';
// IV0_S01 : Mode A 개발 설치 (installAll.sh 대화형 입력) — TC-ADMIN-001
// 입력: 모드 1(Developer) / 도메인 Enter(기본 localhost) / 실행 방식 2(Background)
// ADMIN_CLI_DOMAIN 에 서버 IP 를 넣으면 자체 서명 인증서(HTTPS) 경로로 설치한다(비우면 localhost, plain HTTP).
const DOMAIN = process.env.ADMIN_CLI_DOMAIN ?? '';
test('IV0_S01 Mode A 개발 설치 (installAll.sh → mcc infra info 전체 healthy)', async ({}, testInfo) => {
requireDestructive('클린 설치');
test.setTimeout(60 * 60_000);
await test.step('1. 선행 상태 확인 (docker compose, installAll.sh, 기존 mc- 컨테이너 없음)', async () => {
expect((await inBin('docker compose version && ls installAll.sh mcc')).code).toBe(0);
await requireClean();
});
const install = await test.step(`2. installAll.sh 실행 — 1 / ${DOMAIN || 'Enter(localhost)'} / 2`, async () => {
const r = await inBin('./installAll.sh', `1\n${DOMAIN}\n2\n`, 45 * 60_000);
await attach(testInfo, 'installAll.log', r);
return r;
});
await test.step('3. 설치 완료 메시지 확인', async () => {
expect(install.out).toContain(`Using domain: ${DOMAIN || 'localhost'}`);
expect(install.out).toMatch(DOMAIN ? /Developer mode configuration completed/ : /Local HTTP mode configuration completed/);
expect(install.out, '컨테이너 모니터링이 healthy 로 끝나지 않음').toContain('All environments have been set up!');
expect(install.out, 'mc-iam-manager-post-initial 미완료').not.toContain('mc-iam-manager-post-initial did not complete');
expect(install.code, 'installAll.sh 종료 코드').toBe(0);
});
await test.step('4. ./mcc infra info → 전체 framework healthy', async () => {
const { bad, info } = await unhealthy();
await attach(testInfo, 'mcc-infra-info.txt', info);
expect(bad, `healthy 아닌 컨테이너:\n${bad.join('\n')}`).toEqual([]);
});
});tests/iv0/IV0_S02.spec.ts · 48줄 · 해시 1fda97b0 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, sh, attach, requireDestructive, requireClean, unhealthy } from './_cli';
// IV0_S02 : Mode B 운영 설치 (Let's Encrypt) — TC-ADMIN-002
// 입력: 모드 2(Production) / 도메인 = 실제 FQDN(이 서버를 가리키는 DNS A 레코드 필요) / 실행 방식 2(Background)
// 전제: 80·443 이 외부에서 열려 있어야 certbot(HTTP-01) 발급이 된다. 주: Let's Encrypt 발급 횟수 제한(같은 도메인 주 5회)
const FQDN = process.env.ADMIN_CLI_FQDN ?? '';
test("IV0_S02 Mode B 운영 설치 (Let's Encrypt 인증서 발급 → HTTPS 정상 · 전체 healthy)", async ({ request }, testInfo) => {
requireDestructive('운영 모드 클린 설치');
test.skip(!FQDN, 'ADMIN_CLI_FQDN(실제 도메인) 미지정');
test.setTimeout(70 * 60_000);
await test.step('1. 선행 상태 확인 (클린 상태, 도메인 DNS 가 이 서버를 가리킴, 80 미사용)', async () => {
await requireClean();
const dns = await sh(`getent hosts ${FQDN} | awk '{print $1}'; curl -s https://checkip.amazonaws.com`);
const [resolved, mine] = dns.out.trim().split('\n');
testInfo.annotations.push({ type: 'dns', description: `${FQDN} → ${resolved} / 서버 공인 IP ${mine}` });
expect(resolved, 'DNS A 레코드가 이 서버의 공인 IP 와 다름').toBe(mine);
expect((await sh(`ss -ltn '( sport = :80 )' | tail -n +2`)).out.trim(), '80 포트가 이미 사용 중').toBe('');
});
const install = await test.step(`2. installAll.sh 실행 — 2 / ${FQDN} / 2`, async () => {
const r = await inBin('./installAll.sh', `2\n${FQDN}\n2\n`, 55 * 60_000);
await attach(testInfo, 'installAll.log', r);
return r;
});
await test.step('3. 인증서 발급·운영 모드 설정·설치 완료 메시지 확인', async () => {
expect(install.out).toContain('Certificate generation completed');
expect(install.out).toContain('Production mode configuration completed');
expect(install.out).toContain('All environments have been set up!');
expect(install.code).toBe(0);
});
await test.step('4. 외부에서 HTTPS 접속 — 공인 인증서 검증 통과 (443, 3001)', async () => {
for (const url of [`https://${FQDN}/`, `https://${FQDN}:3001/`]) {
const res = await request.get(url, { ignoreHTTPSErrors: false, maxRedirects: 0 });
expect(res.status(), url).toBeLessThan(500);
}
});
await test.step('5. ./mcc infra info → 전체 framework healthy', async () => {
const { bad, info } = await unhealthy();
await attach(testInfo, 'mcc-infra-info.txt', info);
expect(bad, bad.join('\n')).toEqual([]);
});
});tests/iv0/IV0_S03.spec.ts · 36줄 · 해시 8210157a · 시나리오 보기
import { test, expect } from '@playwright/test';
import net from 'node:net';
import { sh, attach } from './_cli';
// IV0_S03 : 방화벽 포트 점검 — TC-ADMIN-003
// 필수 외부 포트(README "Required Firewall Services" 기준, Cost FE 프록시는 현재 .env.setup 값 7780 — README 의 7781 은 옛 값)가 (1) 서버에서 LISTEN 중이고 (2) 외부(이 테스트를 실행하는 PC)에서 TCP 접속되는지 확인한다.
// ADMIN_CLI_PUBLIC_HOST: 외부에서 접속할 서버 주소(도메인 또는 공인 IP). ADMIN_CLI_PORTS 로 목록을 바꿀 수 있다.
const HOST = process.env.ADMIN_CLI_PUBLIC_HOST ?? '';
const PORTS = (process.env.ADMIN_CLI_PORTS ?? '80,443,3001,33002,7780,9090,9000,3000').split(',').map(Number);
function connect(host: string, port: number, timeout = 5_000): Promise<boolean> {
return new Promise((resolve) => {
const s = net.connect({ host, port, timeout });
s.once('connect', () => { s.destroy(); resolve(true); });
s.once('timeout', () => { s.destroy(); resolve(false); });
s.once('error', () => resolve(false));
});
}
test('IV0_S03 방화벽 포트 점검 (필수 포트 LISTEN + 외부 TCP 접속)', async ({}, testInfo) => {
test.skip(!HOST, 'ADMIN_CLI_PUBLIC_HOST 미지정');
await test.step(`1. 서버에서 필수 포트 LISTEN 확인 (${PORTS.join(', ')})`, async () => {
const r = await sh(`ss -ltnH | awk '{print $4}' | sed 's/.*://' | sort -un`);
await attach(testInfo, 'listen-ports.txt', r);
const listening = new Set(r.out.split('\n').map(Number));
expect(PORTS.filter((p) => !listening.has(p)), 'LISTEN 하지 않는 포트').toEqual([]);
});
await test.step(`2. 외부(${HOST})에서 TCP 접속 확인`, async () => {
const closed: number[] = [];
for (const p of PORTS) if (!(await connect(HOST, p))) closed.push(p);
testInfo.annotations.push({ type: 'ports', description: `열림 ${PORTS.length - closed.length}/${PORTS.length}${closed.length ? `, 막힘: ${closed.join(', ')}` : ''}` });
expect(closed, '외부에서 접속되지 않는 포트(보안그룹·방화벽 확인)').toEqual([]);
});
});tests/iv0/IV0_S04.spec.ts · 39줄 · 해시 7159259d · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, attach, tb, iamToken, iamList } from './_cli';
// IV0_S04 : IAM 초기화 (iam_manager_init.sh / mc-iam-manager-post-initial) — TC-ADMIN-004
// 선행: TC-ADMIN-001(설치) 완료, mc-infra-manager healthy
// 확인: post-initial 로그의 성공 메시지 → IAM(관리자·역할·메뉴·기본 workspace/project) → INFRA(namespace) 생성
const WS = process.env.ADMIN_CLI_DEFAULT_WORKSPACE ?? 'ws01';
const PJ = process.env.ADMIN_CLI_DEFAULT_PROJECT ?? 'default';
test('IV0_S04 IAM 초기화 결과 확인 (post-initial 성공 로그 · IAM 기본 데이터 · INFRA namespace)', async ({}, testInfo) => {
await test.step('1. docker logs mc-iam-manager-post-initial → 성공 메시지, 종료 코드 0', async () => {
const log = await sh('docker logs mc-iam-manager-post-initial 2>&1 | tail -n 60; echo "exit=$(docker inspect -f {{.State.ExitCode}} mc-iam-manager-post-initial)"');
await attach(testInfo, 'post-initial.log', log);
expect(log.out).toContain('[Success] MC-IAM-Manager initialization completed successfully!');
expect(log.out).toContain('Container will exit normally.');
expect(log.out).toContain('exit=0');
});
const token = await test.step('2. MC-IAM 플랫폼 관리자 로그인', iamToken);
await test.step('3. MC-IAM 역할·메뉴·기본 workspace/project 생성 확인', async () => {
const roles = JSON.stringify(await iamList(token, '/api/roles/list'));
const menus = JSON.stringify(await iamList(token, '/api/menus/list'));
const wss = JSON.stringify(await iamList(token, '/api/workspaces/list'));
const pjs = JSON.stringify(await iamList(token, '/api/projects/list'));
testInfo.annotations.push({ type: 'iam', description: `roles ${roles.length}B, menus ${menus.length}B, workspaces ${wss.length}B, projects ${pjs.length}B` });
expect(roles, '플랫폼 관리자 역할 admin').toContain('"name":"admin"');
expect(menus.length, '메뉴 목록').toBeGreaterThan(100);
expect(wss, `기본 workspace ${WS}`).toContain(`"${WS}"`);
expect(pjs, `기본 project ${PJ}`).toContain(`"${PJ}"`);
});
await test.step('4. MC-INFRA namespace 생성 확인 (GET /ns)', async () => {
const ns = await tb('/ns');
await testInfo.attach('ns.json', { body: ns.raw, contentType: 'application/json' });
expect(ns.status).toBe(200);
expect((ns.json?.ns ?? []).map((n: any) => n.id), `project ${PJ} 의 namespace`).toContain(PJ);
});
});tests/iv0/IV0_S05.spec.ts · 33줄 · 해시 cb875115 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive } from './_cli';
// IV0_S05 : 환경 전체 정리 (cleanAll.sh) — TC-ADMIN-005
// 주의: cleanAll.sh 는 M-CMP 뿐 아니라 이 서버의 모든 docker 이미지·컨테이너·볼륨·네트워크와 container-volume 폴더를 지운다.
// 전용 테스트 서버에서만 실행한다. sudo 비밀번호 없이 실행 가능해야 한다(container-volume 삭제).
test('IV0_S05 환경 전체 정리 (cleanAll.sh → 이미지·컨테이너·볼륨 삭제)', async ({}, testInfo) => {
requireDestructive('전체 docker 자원 삭제');
test.setTimeout(30 * 60_000);
await test.step('1. 선행 상태 기록 (이미지·컨테이너·볼륨 수)', async () => {
const r = await sh('echo images=$(docker images -q | wc -l) containers=$(docker ps -aq | wc -l) volumes=$(docker volume ls -q | wc -l)');
testInfo.annotations.push({ type: 'before', description: r.out.trim() });
expect(r.out).not.toContain('images=0 ');
});
const clean = await test.step('2. bin 폴더에서 ./cleanAll.sh 실행 → 확인 질문에 y', async () => {
const r = await inBin('./cleanAll.sh', 'y\n', 25 * 60_000);
await attach(testInfo, 'cleanAll.log', r);
return r;
});
await test.step('3. docker 이미지·컨테이너·볼륨이 모두 삭제됨', async () => {
expect(clean.out).not.toContain('Aborted by user');
const r = await sh('echo images=$(docker images -q | wc -l) containers=$(docker ps -aq | wc -l) volumes=$(docker volume ls -q | wc -l)');
testInfo.annotations.push({ type: 'after', description: r.out.trim() });
expect(r.out).toContain('images=0 containers=0 volumes=0');
});
await test.step('4. container-volume 폴더 삭제됨', async () => {
expect((await inBin('test -d ../conf/docker/container-volume && echo exists || echo removed')).out.trim()).toBe('removed');
});
});tests/iv0/IV0_S06.spec.ts · 27줄 · 해시 a84ddad3 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, attach, requireDestructive, waitHealthy, unhealthy } from './_cli';
// IV0_S06 : 풀스택 이미지 풀 및 기동 (mcc infra pull → run -d) — TC-ADMIN-006
// 선행: installAll.sh 설정 단계 완료(.env, nginx.conf 생성) — TC-ADMIN-001 의 '실행 방식 3(Skip)' 또는 기존 설치
test('IV0_S06 풀스택 이미지 풀 및 기동 (pull → run -d → 전체 healthy)', async ({}, testInfo) => {
requireDestructive('전체 스택 이미지 pull·기동');
test.setTimeout(60 * 60_000);
await test.step('1. ./mcc infra pull → 이미지 내려받기 성공', async () => {
const r = await inBin('./mcc infra pull 2>&1', '', 40 * 60_000);
await attach(testInfo, 'pull.log', r);
expect(r.code).toBe(0);
expect(r.out).not.toMatch(/error|denied|not found/i);
});
await test.step('2. ./mcc infra run -d → 백그라운드 기동 (종료 코드 0)', async () => {
const r = await inBin('./mcc infra run -d 2>&1', '', 20 * 60_000);
await attach(testInfo, 'run.log', r);
expect(r.code).toBe(0);
});
await test.step('3. ./mcc infra info → 전체 framework healthy (최대 20분 대기)', async () => {
await waitHealthy();
await attach(testInfo, 'mcc-infra-info.txt', (await unhealthy()).info);
});
});tests/iv0/IV0_S07.spec.ts · 33줄 · 해시 79f3ea35 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive, waitHealthy } from './_cli';
// IV0_S07 : 단일 서비스 타겟 기동 (-s 플래그) — TC-ADMIN-007
// 대상 서비스 1개만 중지했다가 -s 로 그 서비스만 다시 기동하고, 다른 서비스는 재시작되지 않았는지 확인한다.
// ADMIN_CLI_TARGET_SERVICE(기본 mc-data-manager), 비교용 ADMIN_CLI_OTHER_SERVICE(기본 mc-web-console-api)
const TARGET = process.env.ADMIN_CLI_TARGET_SERVICE ?? 'mc-data-manager';
const OTHER = process.env.ADMIN_CLI_OTHER_SERVICE ?? 'mc-web-console-api';
const startedAt = async (c: string) => (await sh(`docker inspect -f '{{.State.StartedAt}}' ${c}`)).out.trim();
const status = async (c: string) => (await sh(`docker inspect -f '{{.State.Status}}' ${c}`)).out.trim();
test(`IV0_S07 단일 서비스 타겟 기동 (-s ${TARGET})`, async ({}, testInfo) => {
requireDestructive(`${TARGET} 중지·재기동`);
test.setTimeout(20 * 60_000);
const otherBefore = await startedAt(OTHER);
await test.step(`1. ./mcc infra stop -s ${TARGET} → 대상만 중지`, async () => {
await attach(testInfo, 'stop.log', await inBin(`./mcc infra stop -s ${TARGET} 2>&1`));
expect(await status(TARGET)).toBe('exited');
expect(await status(OTHER)).toBe('running');
});
await test.step(`2. ./mcc infra run -d -s ${TARGET} → 대상만 기동`, async () => {
const r = await inBin(`./mcc infra run -d -s ${TARGET} 2>&1`, '', 10 * 60_000);
await attach(testInfo, 'run.log', r);
expect(r.code).toBe(0);
});
await test.step('3. 대상 healthy, 다른 서비스는 재시작되지 않음', async () => {
await waitHealthy([TARGET], 10 * 60_000);
expect(await startedAt(OTHER), `${OTHER} 가 함께 재시작됨`).toBe(otherBefore);
});
});tests/iv0/IV0_S08.spec.ts · 38줄 · 해시 fcc2a71a · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive, waitHealthy } from './_cli';
// IV0_S08 : 중지 → 재기동 → 이미지 업데이트 — TC-ADMIN-008
// ADMIN_CLI_TARGET_SERVICE(기본 mc-data-manager)로 범위를 한정한다. 'all' 이면 전체 스택(-s 없이) 대상.
// 참고: v07 bin/mcc 의 update 는 pull 후 'docker compose up'(-d 없음)으로 포그라운드에 붙어 끝나지 않는다
// (MZC-CSC/mc-admin-cli#100 에서 항상 up -d 로 수정). 15분 timeout(124)·강제 종료(255 등) 모두 FAIL —
// 명령이 스스로 exit 0 으로 끝나야 PASS.
const TARGET = process.env.ADMIN_CLI_TARGET_SERVICE ?? 'mc-data-manager';
const S = TARGET === 'all' ? '' : ` -s ${TARGET}`;
// 중지 확인: 단일 서비스는 exited, 전체는 실행 중인 mc- 컨테이너 0
const stopped = async () => TARGET === 'all'
? (await sh(`docker ps -q --filter label=com.docker.compose.project=mcc | wc -l`)).out.trim() === '0'
: (await sh(`docker inspect -f '{{.State.Status}}' ${TARGET}`)).out.trim() === 'exited';
test(`IV0_S08 중지 → 재기동 → 이미지 업데이트 (${TARGET})`, async ({}, testInfo) => {
requireDestructive(`${TARGET} 중지·재기동·업데이트`);
test.setTimeout(60 * 60_000);
const targets = TARGET === 'all' ? undefined : [TARGET];
await test.step(`1. ./mcc infra stop${S} → 중지됨`, async () => {
await attach(testInfo, 'stop.log', await inBin(`./mcc infra stop${S} 2>&1`, '', 15 * 60_000));
expect(await stopped(), '중지되지 않음').toBe(true);
});
await test.step(`2. ./mcc infra run -d${S} → 다시 healthy`, async () => {
expect((await inBin(`./mcc infra run -d${S} 2>&1`, '', 15 * 60_000)).code).toBe(0);
await waitHealthy(targets);
});
await test.step(`3. ./mcc infra update${S} → 최신 이미지 pull 후 재기동, 명령이 종료됨`, async () => {
const r = await inBin(`timeout 900 ./mcc infra update${S} 2>&1`, '', 16 * 60_000);
await attach(testInfo, 'update.log', r);
expect(r.out).toContain('Install the latest Docker images');
expect(r.code, r.code === 124 ? 'update 명령이 15분 안에 끝나지 않음(포그라운드 up)' : 'update 명령이 정상 종료(exit 0)하지 않음').toBe(0);
await waitHealthy(targets);
});
});tests/iv0/IV0_S09.spec.ts · 35줄 · 해시 b47701dd · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive } from './_cli';
// IV0_S09 : 스택 정리 (mcc infra remove -v -i) — TC-ADMIN-009
// 컨테이너·named volume·compose 이미지를 모두 지운다. 전용 테스트 서버에서만 실행.
const COMPOSE = '../conf/docker/docker-compose.yaml';
test('IV0_S09 스택 정리 (remove -v -i → 컨테이너·볼륨·이미지 삭제)', async ({}, testInfo) => {
requireDestructive('스택 전체 삭제');
test.setTimeout(30 * 60_000);
const images = await test.step('1. 선행 상태 기록 (mcc 프로젝트 컨테이너·볼륨, compose 이미지)', async () => {
const r = await inBin(`docker compose -f ${COMPOSE} config --images | sort -u`);
const list = r.out.split('\n').map((s) => s.trim()).filter(Boolean);
const c = (await sh(`docker ps -aq --filter label=com.docker.compose.project=mcc | wc -l`)).out.trim();
testInfo.annotations.push({ type: 'before', description: `컨테이너 ${c}, compose 이미지 ${list.length}` });
expect(Number(c), '정리할 스택이 없음').toBeGreaterThan(0);
return list;
});
await test.step('2. ./mcc infra remove -v -i 실행', async () => {
await attach(testInfo, 'remove.log', await inBin('./mcc infra remove -v -i 2>&1', '', 25 * 60_000));
});
await test.step('3. mcc 프로젝트 컨테이너·볼륨 없음', async () => {
const r = await sh(`echo c=$(docker ps -aq --filter label=com.docker.compose.project=mcc | wc -l) v=$(docker volume ls -q --filter label=com.docker.compose.project=mcc | wc -l)`);
expect(r.out.trim()).toBe('c=0 v=0');
});
await test.step('4. compose 이미지가 로컬에 남아 있지 않음', async () => {
const left: string[] = [];
for (const img of images) if ((await sh(`docker image inspect ${img} >/dev/null 2>&1 && echo y || echo n`)).out.trim() === 'y') left.push(img);
expect(left, '삭제되지 않은 이미지').toEqual([]);
});
});tests/iv0/IV0_S10.spec.ts · 44줄 · 해시 279c16b7 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, requireDestructive, requireClean, waitHealthy } from './_cli';
// IV0_S10 : Compose Variant 전환 (cert / mini) — TC-ADMIN-010
// mini : docker-compose.mini.yaml 로 최소 구성(infra-connector/manager, iam, web-console)만 기동 → healthy → remove
// cert : docker-compose.cert.yaml 의 certbot 으로 인증서 발급(ADMIN_CLI_FQDN 지정 시에만, 80 포트 비어 있어야 함)
const MINI = '../conf/docker/docker-compose.mini.yaml';
const CERT = '../conf/docker/docker-compose.cert.yaml';
const FQDN = process.env.ADMIN_CLI_FQDN ?? '';
test('IV0_S10 Compose Variant 전환 (mini 기동 / cert 발급)', async ({}, testInfo) => {
requireDestructive('variant 스택 기동·삭제');
test.setTimeout(60 * 60_000);
await test.step('1. 선행 상태: 기본 스택이 없음 (컨테이너 이름이 같아 함께 띄울 수 없음)', requireClean);
const services = await test.step('2. ./mcc infra run -d -f mini → 기동', async () => {
const svc = (await inBin(`docker compose -f ${MINI} config --services`)).out.split('\n').map((s) => s.trim()).filter((s) => s && s !== 'mc-iam-manager-post-initial');
const r = await inBin(`./mcc infra run -d -f ${MINI} 2>&1`, '', 30 * 60_000);
await attach(testInfo, 'mini-run.log', r);
expect(r.code).toBe(0);
return svc;
});
await test.step(`3. mini 서비스 ${services.length}개 healthy, 그 밖의 mc- 컨테이너 없음`, async () => {
await waitHealthy(services);
const extra = (await sh(`docker ps --filter label=com.docker.compose.project=mcc --format '{{.Names}}'`)).out.split('\n').filter((n) => n && !services.includes(n) && n !== 'mc-iam-manager-post-initial');
expect(extra, 'mini 정의에 없는 컨테이너').toEqual([]);
});
await test.step('4. ./mcc infra remove -v -f mini → 정리', async () => {
await attach(testInfo, 'mini-remove.log', await inBin(`./mcc infra remove -v -f ${MINI} 2>&1`, '', 10 * 60_000));
expect((await sh(`docker ps -aq --filter label=com.docker.compose.project=mcc | wc -l`)).out.trim()).toBe('0');
});
await test.step('5. ./mcc infra run -f cert → certbot 인증서 발급 (ADMIN_CLI_FQDN 지정 시)', async () => {
if (!FQDN) { testInfo.annotations.push({ type: 'cert', description: 'ADMIN_CLI_FQDN 미지정 — cert 단계 건너뜀' }); return; }
const r = await inBin(`./mcc infra run -f ${CERT} 2>&1`, '', 10 * 60_000);
await attach(testInfo, 'cert-run.log', r);
expect((await sh(`docker inspect -f '{{.State.ExitCode}}' mcmp-certbot`)).out.trim()).toBe('0');
const pem = await inBin(`sudo test -f ../conf/docker/container-volume/mc-iam-manager/certs/live/${FQDN}/fullchain.pem && echo ok`);
expect(pem.out.trim(), '인증서 파일 없음').toBe('ok');
});
});tests/iv0/IV0_S11.spec.ts · 39줄 · 해시 6b23b35d · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, sh, attach, requireDestructive } from './_cli';
// IV0_S11 : K8s 배포 (mcc k8s run, GKE/EKS/AKS/minikube) — TC-ADMIN-011
// 현 버전 README: "K8S is not currently supported" — k8s 서브커맨드가 등록되지 않으면(src/cmd/k8s/root.go AddCommand 주석) SKIP 으로 남긴다.
// 지원될 때의 절차: kubectl 컨텍스트가 대상 클러스터 / helm 설치 / bin/../helm-chart 존재
// ADMIN_CLI_K8S_PROVIDER = gke|eks|aks|minikube, ADMIN_CLI_K8S_VALUES = values 파일(기본 ../helm-chart/values.yaml)
const PROVIDER = process.env.ADMIN_CLI_K8S_PROVIDER ?? 'minikube';
const VALUES = process.env.ADMIN_CLI_K8S_VALUES ?? '../helm-chart/values.yaml';
test(`IV0_S11 K8s 배포 (mcc k8s run --k8sprovider ${PROVIDER})`, async ({}, testInfo) => {
const help = await inBin('./mcc k8s --help 2>&1');
test.skip(/unknown command "k8s"/.test(help.out), 'mcc k8s 미지원(현 버전 비활성, README: K8S is not currently supported)');
requireDestructive('K8s 클러스터에 M-CMP 배포');
test.setTimeout(60 * 60_000);
await test.step('1. 선행 상태 확인 (kubectl 접속, helm, helm-chart)', async () => {
expect((await sh('kubectl cluster-info && helm version')).code).toBe(0);
expect((await inBin(`test -f ${VALUES} && echo ok`)).out.trim(), 'values 파일 없음').toBe('ok');
});
await test.step(`2. ./mcc k8s run -f ${VALUES} --k8sprovider ${PROVIDER}`, async () => {
const r = await inBin(`./mcc k8s run -f ${VALUES} --k8sprovider ${PROVIDER} 2>&1`, '', 30 * 60_000);
await attach(testInfo, 'k8s-run.log', r);
expect(r.out).not.toMatch(/Error:|failed/i);
});
await test.step('3. ./mcc k8s info → 모든 Pod Running/Completed', async () => {
await expect.poll(async () => {
const r = await sh(`kubectl get pods -n mcmp --no-headers | awk '$3!="Running" && $3!="Completed"' | wc -l`);
return r.out.trim();
}, { timeout: 25 * 60_000, intervals: [20_000] }).toBe('0');
await attach(testInfo, 'k8s-info.log', await inBin('./mcc k8s info 2>&1'));
});
await test.step('4. ./mcc k8s stop → 정리', async () => {
await attach(testInfo, 'k8s-stop.log', await inBin(`./mcc k8s stop -f ${VALUES} 2>&1`, '', 10 * 60_000));
});
});tests/iv0/IV0_S12.spec.ts · 23줄 · 해시 fd80da3a · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, sh, attach, requireDestructive } from './_cli';
// IV0_S12 : Weave Scope 설치/제거 (mcc k8s weave-scope install/uninstall) — TC-ADMIN-012
// k8s 서브커맨드가 비활성이면 SKIP. 참고: 설치 매니페스트 URL(cloud.weave.works)은 Weaveworks 서비스 종료로 접속되지 않을 수 있다.
const PROVIDER = process.env.ADMIN_CLI_K8S_PROVIDER ?? 'minikube';
test(`IV0_S12 Weave Scope 설치/제거 (--k8sprovider ${PROVIDER})`, async ({}, testInfo) => {
const help = await inBin('./mcc k8s --help 2>&1');
test.skip(/unknown command "k8s"/.test(help.out), 'mcc k8s 미지원(현 버전 비활성)');
requireDestructive('클러스터에 weave 네임스페이스 생성·삭제');
test.setTimeout(20 * 60_000);
await test.step('1. install-weave-scope → weave 네임스페이스 Pod Running', async () => {
await attach(testInfo, 'install.log', await inBin(`./mcc k8s weave-scope install-weave-scope --k8sprovider ${PROVIDER} 2>&1`, '', 10 * 60_000));
await expect.poll(async () => (await sh(`kubectl get pods -n weave --no-headers 2>/dev/null | grep -c Running || true`)).out.trim(), { timeout: 8 * 60_000 }).not.toBe('0');
});
await test.step('2. weave-scope uninstall → weave 자원 삭제', async () => {
await attach(testInfo, 'uninstall.log', await inBin(`./mcc k8s weave-scope uninstall --k8sprovider ${PROVIDER} 2>&1`, '', 10 * 60_000));
await expect.poll(async () => (await sh(`kubectl get ns weave --no-headers 2>/dev/null | wc -l`)).out.trim(), { timeout: 5 * 60_000 }).toBe('0');
});
});tests/iv0/IV0_S13.spec.ts · 23줄 · 해시 c9c82ca1 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, mcContainers, attach } from './_cli';
// IV0_S13 : Docker-K8s 상호배타 검증 (예외) — TC-ADMIN-013
// Docker Compose 스택이 실행 중일 때 mcc k8s run 은 거부되어야 한다(같은 플랫폼의 이중 배포 방지).
// k8s 서브커맨드가 비활성이면 SKIP.
const PROVIDER = process.env.ADMIN_CLI_K8S_PROVIDER ?? 'minikube';
test('IV0_S13 Docker-K8s 상호배타 (Compose 실행 중 k8s run 거부)', async ({}, testInfo) => {
const help = await inBin('./mcc k8s --help 2>&1');
test.skip(/unknown command "k8s"/.test(help.out), 'mcc k8s 미지원(현 버전 비활성)');
await test.step('1. 선행 상태: Docker Compose 스택 실행 중', async () => {
expect((await mcContainers(false)).length, 'Compose 스택이 실행 중이 아님').toBeGreaterThan(0);
});
await test.step('2. ./mcc k8s run 실행 → 거부 메시지, helm install 미실행', async () => {
const r = await inBin(`./mcc k8s run --k8sprovider ${PROVIDER} 2>&1`, '', 5 * 60_000);
await attach(testInfo, 'k8s-run.log', r);
expect(r.out, 'Docker 실행 중인데 helm install 이 실행됨').not.toContain('helm install');
expect(r.out).toMatch(/docker|compose|already running|mutually exclusive/i);
});
});tests/iv0/IV0_S14.spec.ts · 26줄 · 해시 b6c58611 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, attach, startEcho, stopEcho, echoed, ECHO } from './_cli';
// IV0_S14 : mcc rest HTTP 메서드별 호출 (GET/POST/PUT/PATCH/DELETE) — TC-ADMIN-014
// 대상 서버에 임시 echo 서버(127.0.0.1:18999)를 띄워, 서버가 받은 메서드·본문을 그대로 돌려받아 비교한다.
const METHODS = ['get', 'post', 'put', 'patch', 'delete'] as const;
test.describe('IV0_S14', () => {
test.beforeAll(startEcho);
test.afterAll(stopEcho);
test('IV0_S14 HTTP 메서드별 호출 (5종 메서드가 서버에 그대로 전달됨)', async ({}, testInfo) => {
for (const m of METHODS) {
await test.step(`./mcc rest ${m} ${ECHO}${m === 'get' || m === 'delete' ? '' : " -d '{\"k\":\"v\"}'"}`, async () => {
const data = m === 'get' || m === 'delete' ? '' : ` -d '{"k":"v"}'`;
const r = await inBin(`./mcc rest ${m} ${ECHO}${data}`);
await attach(testInfo, `${m}.txt`, r);
const e = echoed(r.out);
// 5종을 모두 확인하도록 soft 단언(한 메서드 실패로 나머지를 건너뛰지 않음)
expect.soft(e?.method, `${m} 요청이 다른 메서드로 전송됨`).toBe(m.toUpperCase());
if (data) expect.soft(e?.body, `${m} 본문 불일치`).toBe('{"k":"v"}');
expect.soft(r.code).toBe(0);
});
}
});
});tests/iv0/IV0_S15.spec.ts · 34줄 · 해시 94094503 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, attach, startEcho, stopEcho, echoed, ECHO, TB, TB_AUTH } from './_cli';
// IV0_S15 : mcc rest 인증 모드 (Basic / Bearer) — TC-ADMIN-015
// echo 서버로 Authorization 헤더 값을 확인하고, 실제 서비스(mc-infra-manager readyz)에 Basic 인증으로 호출한다.
test.describe('IV0_S15', () => {
test.beforeAll(startEcho);
test.afterAll(stopEcho);
test('IV0_S15 인증 모드 (Basic -u/-p, Bearer --authToken, 사용자 scheme --authScheme)', async ({}, testInfo) => {
await test.step('1. Basic: -u iv0user -p iv0pass → Authorization: Basic base64(iv0user:iv0pass)', async () => {
const r = await inBin(`./mcc rest get -u iv0user -p iv0pass ${ECHO}`);
expect(echoed(r.out)?.headers?.Authorization).toBe(`Basic ${Buffer.from('iv0user:iv0pass').toString('base64')}`);
});
await test.step('2. Bearer: --authToken iv0token → Authorization: Bearer iv0token', async () => {
const r = await inBin(`./mcc rest get --authToken iv0token ${ECHO}`);
expect(echoed(r.out)?.headers?.Authorization).toBe('Bearer iv0token');
});
await test.step('3. 사용자 scheme: --authScheme OAuth --authToken iv0token → Authorization: OAuth iv0token', async () => {
const r = await inBin(`./mcc rest get --authScheme OAuth --authToken iv0token ${ECHO}`);
expect(echoed(r.out)?.headers?.Authorization).toBe('OAuth iv0token');
});
await test.step('4. 실제 서비스: mc-infra-manager /readyz 를 Basic 인증으로 호출 → ready', async () => {
const [u, p] = TB_AUTH.split(':');
const r = await inBin(`./mcc rest get -u '${u}' -p '${p}' ${TB}/readyz`);
await attach(testInfo, 'tb-readyz.txt', { ...r, out: r.out.replace(p, '***') });
expect(r.out).toContain('"ready":true');
expect(r.code).toBe(0);
});
});
});tests/iv0/IV0_S16.spec.ts · 30줄 · 해시 1caf97f6 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, startEcho, stopEcho, echoed, ECHO } from './_cli';
// IV0_S16 : mcc rest 헤더·바디 설정 (-H, -d, -f) — TC-ADMIN-016
// 본문 전달은 PUT 으로 확인한다(POST 자체의 메서드 결함은 IV0_S14 에서 판정).
test.describe('IV0_S16', () => {
test.beforeAll(startEcho);
test.afterAll(stopEcho);
test('IV0_S16 헤더·바디 설정 (-H 여러 개, -d 문자열, -f 파일)', async ({}, testInfo) => {
await test.step('1. -H "Content-Type:application/json" -H "X-Iv0:hello" → 서버가 두 헤더를 받음', async () => {
const r = await inBin(`./mcc rest get -H "Content-Type:application/json" -H "X-Iv0:hello" ${ECHO}`);
const h = echoed(r.out)?.headers ?? {};
expect(h['Content-Type']).toBe('application/json');
expect(h['X-Iv0']).toBe('hello');
});
await test.step('2. -d 로 JSON 본문 전달(put) → 서버가 같은 본문을 받음', async () => {
const r = await inBin(`./mcc rest put -H "Content-Type:application/json" ${ECHO} -d '{"RegionName":"iv0"}'`);
expect(echoed(r.out)?.body).toBe('{"RegionName":"iv0"}');
});
await test.step('3. -f 파일로 JSON 본문 전달(put) → 서버가 파일 내용을 받음', async () => {
await sh(`echo -n '{"from":"file"}' > /tmp/iv0-body.json`);
const r = await inBin(`./mcc rest put -H "Content-Type:application/json" ${ECHO} -f /tmp/iv0-body.json`);
await attach(testInfo, 'post-file.txt', r);
expect(echoed(r.out)?.body, '-f 파일 내용이 전송되지 않음').toBe('{"from":"file"}');
});
});
});tests/iv0/IV0_S17.spec.ts · 28줄 · 해시 e71547ba · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, attach, startEcho, stopEcho, ECHO } from './_cli';
// IV0_S17 : mcc rest HEAD(-I 응답 헤더) / verbose(-v 추적 정보) — TC-ADMIN-017
test.describe('IV0_S17', () => {
test.beforeAll(startEcho);
test.afterAll(stopEcho);
test('IV0_S17 -I 응답 헤더 표시 / -v 요청 추적 정보 표시', async ({}, testInfo) => {
await test.step('1. ./mcc rest get -I → 응답 헤더(X-Echo, Content-Type) 출력', async () => {
const r = await inBin(`./mcc rest get -I ${ECHO}`);
await attach(testInfo, 'head.txt', r);
expect(r.out).toContain('Headers:');
expect(r.out).toMatch(/X-Echo: iv0/);
expect(r.out).toMatch(/Content-Type: application\/json/);
// 플래그 설명은 "Show response headers only" — 본문도 함께 출력되면 비고로 남긴다
if (r.out.includes('"method"')) testInfo.annotations.push({ type: '개선 메모', description: '-I 가 헤더와 함께 본문도 출력(설명: headers only)' });
});
await test.step('2. ./mcc rest get -v → Request Trace Info · Status Code 200 출력', async () => {
const r = await inBin(`./mcc rest get -v ${ECHO}`);
await attach(testInfo, 'verbose.txt', r);
expect(r.out).toContain('Request Trace Info:');
expect(r.out).toContain('TotalTime');
expect(r.out).toMatch(/Status Code: 200/);
});
});
});tests/iv0/IV0_S18.spec.ts · 25줄 · 해시 087c2c71 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, startEcho, stopEcho, ECHO } from './_cli';
// IV0_S18 : mcc rest 종료 코드 = HTTP 상태 첫 자리 (Docker Compose healthcheck 연동) — TC-ADMIN-018
// 200 → 0, 4xx → 4, 5xx → 5. compose healthcheck 가 이 종료 코드로 healthy 를 판정한다.
test.describe('IV0_S18', () => {
test.beforeAll(startEcho);
test.afterAll(stopEcho);
test('IV0_S18 healthcheck 종료 코드 (200→0, 404→4, 503→5 · compose healthcheck 연동)', async ({}, testInfo) => {
for (const [st, code] of [[200, 0], [404, 4], [503, 5]] as const) {
await test.step(`./mcc rest get ${ECHO}?status=${st} → 종료 코드 ${code}`, async () => {
const r = await inBin(`./mcc rest get '${ECHO}?status=${st}'`);
expect(r.code).toBe(code);
});
}
await test.step('compose healthcheck 가 mcc rest get 을 사용하고 healthy 로 판정됨 (mc-infra-manager)', async () => {
const r = await sh(`docker inspect -f '{{json .Config.Healthcheck.Test}} {{.State.Health.Status}}' mc-infra-manager`);
await attach(testInfo, 'healthcheck.txt', r);
expect(r.out).toContain('"/app/tool/mcc","rest","get"');
expect(r.out.trim()).toMatch(/healthy$/);
});
});
});tests/iv0/IV0_S19.spec.ts · 37줄 · 해시 9fe1e75f · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, attach } from './_cli';
// IV0_S19 : mcc api 서비스·액션 카탈로그 조회 (--list) — TC-ADMIN-019
// 기준: bin/../conf/api.yaml 의 services / serviceActions 목록과 출력이 일치해야 한다.
const yamlKeys = (section: string, svc = '') => inBin(
`python3 -c "import yaml,sys;d=yaml.safe_load(open('../conf/api.yaml'));s=d['${section}']${svc ? `['${svc}']` : ''};print('\\n'.join(sorted(s or {})))"`,
);
test('IV0_S19 서비스·액션 카탈로그 조회 (api --list, -s <service> --list)', async ({}, testInfo) => {
await test.step('1. ./mcc api --list → api.yaml 의 서비스 전체 출력', async () => {
const r = await inBin('./mcc api --list');
await attach(testInfo, 'service-list.txt', r);
const expected = (await yamlKeys('services')).out.split('\n').filter(Boolean);
expect(expected.length).toBeGreaterThan(0);
const shown = r.out.split('\n').map((s) => s.trim());
expect(expected.filter((s) => !shown.includes(s)), '목록에 없는 서비스').toEqual([]);
});
await test.step('2. ./mcc api -s mc-infra-manager --list → 액션 전체 출력 (GetReadyz 포함)', async () => {
const r = await inBin('./mcc api -s mc-infra-manager --list');
await attach(testInfo, 'action-list.txt', r);
const expected = (await yamlKeys('serviceActions', 'mc-infra-manager')).out.split('\n').filter(Boolean);
const shown = r.out.split('\n').map((s) => s.trim());
expect(r.out).toContain('[mc-infra-manager] Service Actions list');
// viper 가 키를 소문자로 바꿔 출력한다(getreadyz) — 대소문자 무시 비교, 호출은 원래 이름(-a GetReadyz)으로도 된다
const lower = new Set(shown.map((s) => s.toLowerCase()));
expect(lower.has('getreadyz')).toBe(true);
if (!shown.includes('GetReadyz')) testInfo.annotations.push({ type: '개선 메모', description: '액션 목록이 소문자로 출력됨(api.yaml 이름과 다름)' });
expect(expected.filter((a) => !lower.has(a.toLowerCase())), '목록에 없는 액션').toEqual([]);
});
await test.step('3. 없는 서비스 지정 → 지원 서비스가 아니라는 안내', async () => {
const r = await inBin('./mcc api -s no-such-service -a X');
expect(r.out).toContain('is not on the list of supported services');
});
});tests/iv0/IV0_S20.spec.ts · 32줄 · 해시 653f91eb · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, attach, apiConfig } from './_cli';
// IV0_S20 : mcc api 서비스별 기본 호출 (9종) — TC-ADMIN-020
// api.yaml 의 9개 서비스에 대해 인증·경로 파라미터가 필요 없는 상태 확인 액션을 호출한다(설정 파일의 인증값 그대로 사용).
// baseurl 의 docker 내부 호스트명은 localhost 로 바꾼 사본을 쓴다(_cli.apiConfig). 호스트에 포트가 공개되지 않은 서비스는 실패로 남는다.
const CALLS: [string, string, RegExp][] = [
['mc-infra-connector', 'Health-Check-Readyz', /ready/i],
['mc-infra-manager', 'GetReadyz', /"ready":\s*true/],
['mc-iam-manager', 'Readyz', /healthy|ready/i],
['mc-web-console', 'getSetupYamlCheck', /\{/],
['mc-observability', 'GetObsReadyz', /ready|ok|success/i],
['mc-application-manager', 'GetAppMgrReadyz', /ready|ok|success/i],
['mc-workflow-manager', 'GetWorkflowReadyz', /ready|ok|success/i],
['mc-cost-optimizer', 'GetCostReadyz', /ready|ok|success/i],
['mc-data-manager', 'GetDataMgrReadyz', /ready|ok|success/i],
];
test('IV0_S20 서비스별 기본 호출 (9종 상태 확인 액션 응답)', async ({}, testInfo) => {
const cfg = await apiConfig();
const failed: string[] = [];
for (const [svc, action, ok] of CALLS) {
await test.step(`./mcc api -c ${cfg} -s ${svc} -a ${action}`, async () => {
const r = await inBin(`./mcc api -c ${cfg} -s ${svc} -a ${action}`, '', 60_000);
await attach(testInfo, `${svc}.txt`, r);
const body = r.out.split('service calling...').pop() ?? '';
if (!ok.test(body) || /connection refused|no such host|Unauthorized|dial tcp/i.test(body)) failed.push(`${svc}/${action}: ${body.trim().slice(0, 120)}`);
});
}
testInfo.annotations.push({ type: 'services', description: `성공 ${CALLS.length - failed.length}/${CALLS.length}` });
expect(failed, `응답이 기대와 다른 서비스:\n${failed.join('\n')}`).toEqual([]);
});tests/iv0/IV0_S21.spec.ts · 35줄 · 해시 aed0c1ea · 시나리오 보기
import { test, expect } from '@playwright/test';
import { inBin, attach, apiConfig, TB_AUTH_ARGS } from './_cli';
// IV0_S21 : mcc api 경로 파라미터·쿼리 문자열 치환 (-p, -q) — TC-ADMIN-021
// mc-infra-manager 의 GetNs(/ns/{nsId}), GetAllNs(/ns) 사용. 인증은 --authUser/--authPassword 로 지정(ADMIN_CLI_TB_AUTH).
const NS = process.env.ADMIN_CLI_TB_NS ?? 'default';
test('IV0_S21 경로 파라미터·쿼리 문자열 치환 (-p nsId:<ns>, -q option=id)', async ({}, testInfo) => {
const cfg = await apiConfig();
const api = (args: string) => inBin(`./mcc api -c ${cfg} -s mc-infra-manager ${TB_AUTH_ARGS} ${args}`);
await test.step(`1. -a GetNs -p nsId:${NS} → /ns/${NS} 호출, 해당 namespace 반환`, async () => {
const r = await api(`-a GetNs -p nsId:${NS}`);
await attach(testInfo, 'getns.txt', r);
expect(r.out).toContain(`"id":"${NS}"`);
});
await test.step('2. -p 없이 가변 경로 액션 호출 → 경로 파라미터 필요 안내', async () => {
const r = await api('-a GetNs');
expect(r.out).toContain("couldn't find uri path parameter");
expect(r.out).toContain('/ns/{nsId}');
});
await test.step('3. -a GetAllNs -q option=id → /ns?option=id 호출, id 목록(output) 반환', async () => {
const r = await api('-a GetAllNs -q option=id');
await attach(testInfo, 'getallns-id.txt', r);
expect(r.out).toMatch(/"output":\s*\[/);
expect(r.out).toContain(`"${NS}"`);
});
await test.step('4. -q "?option=id" (물음표 포함) → 같은 결과', async () => {
const r = await api(`-a GetAllNs -q '?option=id'`);
expect(r.out).toMatch(/"output":\s*\[/);
});
});tests/iv0/IV0_S22.spec.ts · 24줄 · 해시 9740de4b · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, apiConfig, TB_AUTH_ARGS } from './_cli';
// IV0_S22 : mcc api 출력 파일 저장 (-o) — TC-ADMIN-022
const OUT = '/tmp/iv0-getallns.json';
test('IV0_S22 출력 파일 저장 (-o 파일에 응답 JSON 저장)', async ({}, testInfo) => {
const cfg = await apiConfig();
await sh(`rm -f ${OUT}`);
await test.step(`1. ./mcc api -s mc-infra-manager -a GetAllNs -o ${OUT}`, async () => {
const r = await inBin(`./mcc api -c ${cfg} -s mc-infra-manager ${TB_AUTH_ARGS} -a GetAllNs -o ${OUT}`);
await attach(testInfo, 'stdout.txt', r);
});
await test.step('2. 파일이 생성되고 응답 JSON(ns 목록)이 들어 있음', async () => {
const r = await sh(`cat ${OUT}`);
await attach(testInfo, 'output-file.json', r);
expect(r.code, '출력 파일 없음').toBe(0);
const json = JSON.parse(r.out);
expect(Array.isArray(json.ns), 'ns 목록 아님').toBe(true);
});
await sh(`rm -f ${OUT}`);
});tests/iv0/IV0_S23.spec.ts · 41줄 · 해시 9f25ea19 · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, TB, TB_AUTH } from './_cli';
// IV0_S23 : mcc api tool — Swagger JSON → api.yaml 액션 정의 생성 — TC-ADMIN-023
// 실행 중인 mc-infra-manager 의 Swagger(/tumblebug/api/doc.json)를 받아 tool 로 변환하고,
// (1) operationId 마다 액션이 생성되는지 (2) 현재 api.yaml 의 mc-infra-manager 액션과 얼마나 일치하는지 확인한다.
const SWAGGER = '/tmp/iv0-tb-swagger.json';
test('IV0_S23 api tool Swagger → api.yaml 액션 재생성 (operationId 1:1)', async ({}, testInfo) => {
await test.step('1. mc-infra-manager Swagger JSON 내려받기', async () => {
const r = await sh(`curl -s -u '${TB_AUTH}' ${TB}/api/doc.json -o ${SWAGGER} && python3 -c "import json;d=json.load(open('${SWAGGER}'));print(d.get('basePath'), sum(1 for p in d['paths'].values() for m in p.values() if isinstance(m,dict) and m.get('operationId')))"`);
testInfo.annotations.push({ type: 'swagger', description: `basePath, operationId 수: ${r.out.trim()}` });
expect(Number(r.out.trim().split(' ').pop()), 'operationId 없음').toBeGreaterThan(0);
});
const generated = await test.step(`2. ./mcc api tool -f ${SWAGGER} → 액션 정의 출력`, async () => {
const r = await inBin(`./mcc api tool -f ${SWAGGER}`);
await attach(testInfo, 'tool-output.yaml', r);
expect(r.out).toContain('Base Path:');
return r.out;
});
await test.step('3. operationId 수 = 생성된 액션 수, 각 액션에 method·resourcePath 존재', async () => {
const ops = Number((await sh(`python3 -c "import json;d=json.load(open('${SWAGGER}'));print(sum(1 for p in d['paths'].values() for m in p.values() if isinstance(m,dict) and m.get('operationId')))"`)).out.trim());
const actions = generated.match(/^ {4}\S+:$/gm) ?? [];
expect(actions.length).toBe(ops);
expect((generated.match(/^ {6}method: /gm) ?? []).length).toBe(ops);
expect((generated.match(/^ {6}resourcePath: /gm) ?? []).length).toBe(ops);
});
await test.step('4. 현재 api.yaml(mc-infra-manager)과 비교 — 누락·추가 액션 수 기록', async () => {
const r = await inBin(`python3 -c "import yaml;print('\\n'.join(yaml.safe_load(open('../conf/api.yaml'))['serviceActions']['mc-infra-manager']))"`);
const current = new Set(r.out.split('\n').filter(Boolean));
const gen = new Set((generated.match(/^ {4}(\S+):$/gm) ?? []).map((s) => s.trim().slice(0, -1)));
const missing = [...gen].filter((a) => !current.has(a));
const stale = [...current].filter((a) => !gen.has(a));
testInfo.annotations.push({ type: 'diff', description: `api.yaml 에 없는 새 액션 ${missing.length}, Swagger 에 없는 옛 액션 ${stale.length}` });
await testInfo.attach('diff.txt', { body: `[새 액션]\n${missing.join('\n')}\n\n[옛 액션]\n${stale.join('\n')}`, contentType: 'text/plain' });
});
await sh(`rm -f ${SWAGGER}`);
});tests/iv0/IV0_S24.spec.ts · 59줄 · 해시 a55caa67 · 시나리오 보기
import { test, expect } from '@playwright/test';
import fs from 'node:fs';
import { inBin, attach, tb, requireDestructive, TB_AUTH } from './_cli';
// IV0_S24 : CSP Credential 등록 (mcc setup credential, RSA-OAEP + AES-CBC 암호화) — TC-ADMIN-024
// 절차: CSP 선택(--csp) → 필요한 credential 항목 입력(비밀번호 방식, TTY 필요) → 공개키로 암호화 → POST /credential
// 입력값: ADMIN_CLI_CSP(기본 aws), ADMIN_CLI_CRED_FILE = 이 PC 의 key=value 파일
// 항목 이름은 metainfo 의 CredentialCSP(예: aws_access_key_id / aws_secret_access_key) — mc-infra-manager 가 허용하는 키
// 주의: 같은 CSP 의 기존 credential(credentialHolder=admin)을 덮어쓴다. 입력값은 리포트에 남기지 않는다.
// 인증: api.yaml 의 mc-infra-manager 인증값(spider)은 설치 기본값(default)과 달라 401 이 나므로 -u/-p 로 지정한다(ADMIN_CLI_TB_AUTH).
const CSP = (process.env.ADMIN_CLI_CSP ?? 'aws').toLowerCase();
const CRED_FILE = process.env.ADMIN_CLI_CRED_FILE ?? '';
test(`IV0_S24 CSP Credential 등록 (${CSP}, 암호화 후 mc-infra-manager 등록 → connConfig verified)`, async ({}, testInfo) => {
requireDestructive(`${CSP} credential 등록(기존 값 덮어씀)`);
test.skip(!CRED_FILE, 'ADMIN_CLI_CRED_FILE(credential key=value 파일) 미지정');
test.setTimeout(15 * 60_000);
const keys = await test.step(`1. 필요한 credential 항목 조회 (POST /forward/cloudos/metainfo/${CSP} → CredentialCSP)`, async () => {
const meta = await tb(`/forward/cloudos/metainfo/${CSP}`, 'POST', {});
const list: string[] = meta.json?.CredentialCSP ?? meta.json?.Credential ?? [];
testInfo.annotations.push({ type: 'keys', description: list.join(', ') });
expect(list.length, `metainfo 조회 실패: ${meta.raw.slice(0, 200)}`).toBeGreaterThan(0);
return list;
});
const values = Object.fromEntries(fs.readFileSync(CRED_FILE, 'utf8').split('\n').filter((l) => l.includes('=')).map((l) => [l.slice(0, l.indexOf('=')).trim(), l.slice(l.indexOf('=') + 1).trim()]));
const before = (await tb(`/connConfig?filterCredentialHolder=admin`)).json?.connectionconfig?.filter((c: any) => c.providerName?.toLowerCase() === CSP).length ?? 0;
const r = await test.step(`2. ./mcc setup credential --csp ${CSP} → 항목 입력 → 검토 no → 확인 yes`, async () => {
const missing = keys.filter((k) => !(k in values));
expect(missing, 'credential 파일에 없는 항목').toEqual([]);
const stdin = keys.map((k) => values[k]).join('\n') + '\nno\nyes\n'; // 검토 no → 확인 yes
// 가상 터미널(script)이 입력을 화면에 되돌려 찍지 않도록 stty -echo. 출력은 항상 값을 가린 뒤에만 쓴다(단언 메시지 포함).
const res = await inBin(`script -qec 'stty -echo; ./mcc setup credential --csp ${CSP} -u ${TB_AUTH.split(':')[0]} -p ${TB_AUTH.split(':')[1]}' /dev/null`, stdin, 10 * 60_000);
let masked = res.out;
for (const v of Object.values(values)) if (v) masked = masked.split(v).join('***');
await attach(testInfo, 'credential.log', { ...res, out: masked });
return { code: res.code, out: masked };
});
await test.step('3. 공개키 조회·암호화·등록 결과 확인', async () => {
expect(r.out).toContain(`Processing authentication information for selected [${CSP}] CSP`);
expect(r.out, '등록 완료 메시지 없음').toContain('CSP Credential registration completed successfully');
expect(r.out).toContain('Result:');
expect(r.out).not.toMatch(/^Error:/m);
// 서버 거부 여부와 이번 등록 결과를 직접 확인한다(기존 연결이 남아 있으면 4단계만으로는 등록 실패를 놓친다)
expect(r.out, '서버가 credential 등록을 거부함').not.toMatch(/invalid credential|"message":\s*"[^"]*(invalid|error|fail)/i);
expect(r.out, '등록 결과가 비어 있음(Result: map[])').not.toMatch(/Result: map\[\]\s*$/m);
});
await test.step('4. mc-infra-manager connConfig 에 해당 CSP 연결이 생기고 verified', async () => {
const list = (await tb(`/connConfig?filterCredentialHolder=admin`)).json?.connectionconfig ?? [];
const mine = list.filter((c: any) => c.providerName?.toLowerCase() === CSP);
testInfo.annotations.push({ type: 'connConfig', description: `${CSP} 연결 ${before} → ${mine.length}, verified ${mine.filter((c: any) => c.verified).length}` });
expect(mine.length).toBeGreaterThan(0);
expect(mine.some((c: any) => c.verified), 'verified 연결 없음').toBe(true);
});
});tests/iv0/IV0_S25.spec.ts · 62줄 · 해시 56b20ddb · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, tb } from './_cli';
// IV0_S25 : 암호화 cross-check (Python PyCryptodome) — TC-ADMIN-025
// mcc setup credential 과 같은 방식(RSA-OAEP(SHA-256)로 AES-256 키 암호화 + 값마다 AES-CBC(IV 앞붙임, PKCS7))을
// 저장소의 test_encryption.py 로 재현하고, (1) 스크립트 실행 (2) 임시 키쌍으로 복호화 왕복 (3) mc-infra-manager 공개키로 암호화 형식 확인.
// 선행: python3 + pycryptodome. 없으면 uv 로 임시 환경을 만들어 실행한다(uv run --with pycryptodome, 시스템 패키지 설치 없음).
const PY = String.raw`
import base64, json, sys, runpy, io, contextlib
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_OAEP, AES
from Crypto.Hash import SHA256
with contextlib.redirect_stdout(io.StringIO()):
mod = runpy.run_path('../test_encryption.py')
enc = mod['encrypt_credential_value_with_publickey']
creds = {'ClientId': 'iv0-id', 'ClientSecret': 'iv0-secret-✓', 'Region': 'ap-northeast-2'}
mode = sys.argv[1]
if mode == 'roundtrip':
key = RSA.generate(4096)
ec, ek = enc(key.publickey().export_key().decode(), creds)
aes = PKCS1_OAEP.new(key, hashAlgo=SHA256).decrypt(base64.b64decode(ek))
out = {}
for k, v in ec.items():
raw = base64.b64decode(v); c = AES.new(aes, AES.MODE_CBC, raw[:16]).decrypt(raw[16:])
out[k] = c[:-c[-1]].decode()
print(json.dumps({'aesKeyLen': len(aes), 'match': out == creds}))
else:
pub = sys.stdin.read()
ec, ek = enc(pub, creds)
print(json.dumps({'encKeyBytes': len(base64.b64decode(ek)), 'valueBlocksOk': all((len(base64.b64decode(v)) - 16) % 16 == 0 for v in ec.values())}))
`;
test('IV0_S25 암호화 cross-check (test_encryption.py · 복호화 왕복 · 실제 공개키 형식)', async ({}, testInfo) => {
const py = await test.step('1. 선행: pycryptodome 사용 가능 (python3 또는 uv)', async () => {
if ((await sh(`python3 -c "import Crypto"`)).code === 0) return 'python3';
const uv = 'PATH=$HOME/.local/bin:$PATH uv run -q --no-project --with pycryptodome python3';
expect((await sh(`${uv} -c "import Crypto"`, '', 5 * 60_000)).code, 'pycryptodome 없음, uv 도 없음').toBe(0);
testInfo.annotations.push({ type: 'python', description: 'uv 임시 환경(pycryptodome) 사용' });
return uv;
});
await sh(`cat > /tmp/iv0-crypto.py <<'PYEOF'\n${PY}\nPYEOF`);
await test.step('2. python3 test_encryption.py 실행 → 4개 값 암호화 완료', async () => {
const r = await inBin(`${py} ../test_encryption.py`);
await attach(testInfo, 'test_encryption.log', r);
expect(r.code).toBe(0);
expect(r.out).toContain('총 4 개의 credentials가 암호화되었습니다.');
});
await test.step('3. 임시 RSA-4096 키쌍으로 암호화 → 복호화 왕복 결과가 원문과 같음 (AES-256)', async () => {
const r = await inBin(`${py} /tmp/iv0-crypto.py roundtrip`, '', 5 * 60_000);
expect(JSON.parse(r.out.trim().split('\n').pop()!)).toEqual({ aesKeyLen: 32, match: true });
});
await test.step('4. mc-infra-manager 공개키(GET /credential/publicKey)로 암호화 → RSA 4096 키 길이·AES 블록 형식', async () => {
const pk = await tb('/credential/publicKey');
expect(pk.json?.publicKey, `공개키 조회 실패: ${pk.raw.slice(0, 200)}`).toBeTruthy();
const r = await inBin(`${py} /tmp/iv0-crypto.py live`, pk.json.publicKey);
expect(JSON.parse(r.out.trim().split('\n').pop()!)).toEqual({ encKeyBytes: 512, valueBlocksOk: true });
});
await sh('rm -f /tmp/iv0-crypto.py');
});tests/iv0/IV0_S26.spec.ts · 46줄 · 해시 140a8e5b · 시나리오 보기
import { test, expect } from '@playwright/test';
import { sh, inBin, attach, tb, requireDestructive } from './_cli';
// IV0_S26 : CB-Tumblebug infra-init (mcc setup infra-init: 버전 확인 → clone → init.sh) — TC-ADMIN-026
// 선행: mc-infra-manager healthy, 암호화된 credential 파일(~/.cloud-barista/credentials.yaml.enc) 준비, uv 설치, docker 그룹 사용자
// 대화형 입력은 ADMIN_CLI_INIT_INPUT 로 넘긴다(기본: 준비 확인 y → 폴더 메뉴 1(새로 받기) → init.sh 진행 y).
// init.sh 는 CSP 연결 등록·자산(spec/image) 적재로 수십 분 걸릴 수 있다.
const INPUT = (process.env.ADMIN_CLI_INIT_INPUT ?? 'y\\n1\\ny\\n').replace(/\\n/g, '\n');
const TB_DIR = '~/go/src/github.com/cloud-barista/cb-tumblebug';
test('IV0_S26 CB-Tumblebug infra-init (실행 버전과 같은 태그 clone → init.sh → initialized)', async ({}, testInfo) => {
requireDestructive('mc-infra-manager 초기화(연결·자산 등록)');
test.setTimeout(90 * 60_000);
await test.step('1. 선행: credential 암호화 파일 존재', async () => {
expect((await sh('test -f ~/.cloud-barista/credentials.yaml.enc && echo ok')).out.trim(), 'credentials.yaml.enc 없음').toBe('ok');
});
const r = await test.step('2. ./mcc setup infra-init 실행', async () => {
const res = await inBin('script -qec "./mcc setup infra-init" /dev/null', INPUT, 80 * 60_000);
await attach(testInfo, 'infra-init.log', res);
return res;
});
const version = await test.step('3. 실행 상태·버전·health 확인 메시지', async () => {
expect(r.out).toContain('MC-Infra-Manager is running.');
expect(r.out).toContain('MC-Infra-Manager is healthy.');
const v = r.out.match(/Version confirmed: (\d+\.\d+\.\d+)/)?.[1];
expect(v, '실행 버전 확인 실패(이미지 태그가 x.y.z 가 아니면 감지 못함)').toBeTruthy();
expect(r.out).toContain('Executing init.sh');
return v!;
});
await test.step(`4. cb-tumblebug 폴더가 실행 버전 태그(v${version})로 checkout`, async () => {
const tag = (await sh(`cd ${TB_DIR} && git describe --tags --exact-match HEAD`)).out.trim();
expect(tag.replace(/^v/, '')).toBe(version);
});
await test.step('5. mc-infra-manager initialized=true, 연결 정보(connConfig) 등록됨', async () => {
const ready = await tb('/readyz');
expect(ready.json?.initialized).toBe(true);
const conns = (await tb('/connConfig')).json?.connectionconfig ?? [];
testInfo.annotations.push({ type: 'connConfig', description: `연결 ${conns.length}개, verified ${conns.filter((c: any) => c.verified).length}개` });
expect(conns.length).toBeGreaterThan(0);
});
});