aiakiv 0.2.2 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +106 -0
- package/dist/ctx.js +2 -1
- package/dist/enterprise/precheck.js +656 -0
- package/dist/enterprise/reportMarkdown.js +188 -0
- package/dist/local/checks.js +34 -0
- package/dist/local/doctor.js +28 -2
- package/dist/local/portable.js +495 -0
- package/dist/main.js +180 -5
- package/dist/proc.js +51 -0
- package/dist/version.js +5 -1
- package/package.json +1 -1
|
@@ -0,0 +1,656 @@
|
|
|
1
|
+
// `aiakiv enterprise precheck` — 유료 기업판 설치 전 점검(K2G docs/212 BP-173 §3, P3)을 이 컴퓨터의 Docker 로
|
|
2
|
+
// 돌린다. 점검 본체는 유료 기업판 엔진 이미지 안의 `aiakiv-admin precheck`(Python)이고, 이 명령은 그 앞뒤를
|
|
3
|
+
// 맡는다: 설정 파일 찾기, Docker 확인, 이미지 정하기(이미 있는 것, tar, 내려받기), compose 파일 쓰기, 컨테이너
|
|
4
|
+
// 실행, 로그인 주소 보이기와 브라우저 열기, 결과 정리. 점검의 동작 규격(종료 코드, --json 줄 형식, 로그인 대기
|
|
5
|
+
// 줄)은 K2G packaging/enterprise/precheck/README.md §2 에 있다.
|
|
6
|
+
//
|
|
7
|
+
// 종료 코드: 점검의 것(0 실패 없음, 3 실패 있음, 2 설정 오류)을 그대로 돌려준다. 이 명령의 것은 2 인자 오류
|
|
8
|
+
// (설정 파일 없음, --out 이 작업 폴더 밖), 4 Docker 를 쓸 수 없음(실행 파일 없음, 데몬에 닿지 못함, compose v2
|
|
9
|
+
// 없음, Windows 컨테이너 모드), 5 이미지를 구하지 못함, 1 점검 컨테이너가 보고서 없이 끝남이나 보고서 파일을 쓰지 못함.
|
|
10
|
+
// 작업 폴더(설정 파일이 있는 폴더)는 컨테이너의 /config 로 붙는다. 설정의 ca_bundle = "/config/corp-ca.pem" 이 여기에 기댄다.
|
|
11
|
+
// 보고서 파일은 컨테이너가 아니라 이 CLI 가 <작업 폴더>/<--out>/ 에 쓴다(reportMarkdown.ts).
|
|
12
|
+
// 비밀 값은 다루지 않는다. 같은 폴더의 .env 는 compose 의 env_file 로 넘길 뿐 열어 읽지 않는다. 로그인은 사람이
|
|
13
|
+
// 브라우저에서 하고, 이 명령은 주소만 보인다.
|
|
14
|
+
import { createHash } from "node:crypto";
|
|
15
|
+
import { closeSync, createReadStream, createWriteStream, existsSync, mkdirSync, openSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
16
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
17
|
+
import { pipeline } from "node:stream/promises";
|
|
18
|
+
import { readTextIfExists, writeFileAtomic } from "../fsutil.js";
|
|
19
|
+
import { failReason } from "../proc.js";
|
|
20
|
+
import { truncate } from "../render.js";
|
|
21
|
+
import { ENTERPRISE_ENGINE_VERSION } from "../version.js";
|
|
22
|
+
import { checkCompose, checkDockerOs, daemonFix, dockerInstallUrl, isPermissionDenied, normArch, parseDockerInfo, } from "../local/checks.js";
|
|
23
|
+
import { STATUS_LABEL } from "../local/doctor.js";
|
|
24
|
+
import { reportJsonText, reportLanguage, toMarkdown } from "./reportMarkdown.js";
|
|
25
|
+
export const PRECHECK_IMAGE_REPO = "aiakiv/engine-enterprise";
|
|
26
|
+
export const DEFAULT_PRECHECK_CONFIG = "aiakiv-enterprise.toml";
|
|
27
|
+
export const EXAMPLE_PRECHECK_CONFIG = "aiakiv-enterprise.example.toml";
|
|
28
|
+
export const DEFAULT_PRECHECK_OUT = "report";
|
|
29
|
+
/** compose 프로젝트 이름과, 작업 폴더 안에 compose 파일을 두는 폴더. */
|
|
30
|
+
export const PRECHECK_PROJECT = "aiakiv-precheck";
|
|
31
|
+
export const PRECHECK_DIR = ".aiakiv-precheck";
|
|
32
|
+
export const ENTERPRISE_PAGE = "https://www.aiakiv.com/enterprise";
|
|
33
|
+
/** 점검의 마지막 줄(보고서)의 tool 값. */
|
|
34
|
+
export const PRECHECK_TOOL = "aiakiv-precheck";
|
|
35
|
+
export const EXIT_OK = 0;
|
|
36
|
+
export const EXIT_RUN_FAILED = 1;
|
|
37
|
+
export const EXIT_USAGE = 2;
|
|
38
|
+
export const EXIT_FAILED = 3;
|
|
39
|
+
export const EXIT_DOCKER = 4;
|
|
40
|
+
export const EXIT_IMAGE = 5;
|
|
41
|
+
const DOCKER_TIMEOUT_MS = 30_000;
|
|
42
|
+
const MANIFEST_TIMEOUT_MS = 30_000;
|
|
43
|
+
const LOAD_TIMEOUT_MS = 30 * 60_000;
|
|
44
|
+
export function defaultImageRef() {
|
|
45
|
+
return `${PRECHECK_IMAGE_REPO}:${ENTERPRISE_ENGINE_VERSION}`;
|
|
46
|
+
}
|
|
47
|
+
/** 이 CLI 판에 고정한 목록 파일 주소. AIAKIV_ENTERPRISE_MANIFEST_URL 로 바꾼다(시험용). */
|
|
48
|
+
export function manifestUrl(env) {
|
|
49
|
+
return (env.AIAKIV_ENTERPRISE_MANIFEST_URL?.trim() ||
|
|
50
|
+
`https://www.aiakiv.com/download/enterprise/${ENTERPRISE_ENGINE_VERSION}/manifest.json`);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* --out 을 작업 폴더 안의 상대 경로(조각을 / 로 이은 것)로. 절대 경로, 드라이브 문자, 작업 폴더 밖으로 나가는
|
|
54
|
+
* `..`, 작업 폴더 자신이면 null. 역슬래시는 Windows 의 구분자로 보고 / 로 바꾼다.
|
|
55
|
+
*/
|
|
56
|
+
export function relativeOut(out) {
|
|
57
|
+
const s = out.trim().replace(/\\/g, "/");
|
|
58
|
+
if (!s || s.startsWith("/") || /^[A-Za-z]:/.test(s))
|
|
59
|
+
return null;
|
|
60
|
+
const parts = [];
|
|
61
|
+
for (const seg of s.split("/")) {
|
|
62
|
+
if (seg === "" || seg === ".")
|
|
63
|
+
continue;
|
|
64
|
+
if (seg === "..") {
|
|
65
|
+
if (!parts.length)
|
|
66
|
+
return null;
|
|
67
|
+
parts.pop();
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
parts.push(seg);
|
|
71
|
+
}
|
|
72
|
+
return parts.length ? parts.join("/") : null;
|
|
73
|
+
}
|
|
74
|
+
/** compose 파일에 적는 호스트 경로. Windows 는 역슬래시를 / 로(F:\work 는 F:/work, Docker Desktop 이 읽는다). */
|
|
75
|
+
export function composeHostPath(p, platform) {
|
|
76
|
+
return platform === "win32" ? p.replace(/\\/g, "/") : p;
|
|
77
|
+
}
|
|
78
|
+
/** YAML 의 큰따옴표 글자. compose 는 값 안의 `$` 를 변수로 펼치므로 `$$` 로 적는다. */
|
|
79
|
+
function yamlString(s) {
|
|
80
|
+
return JSON.stringify(s.replace(/\$/g, "$$$$"));
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* K2G packaging/enterprise/precheck/precheck-compose.yml 의 precheck 서비스와 같은 내용. 이미지는 정한 참조로
|
|
84
|
+
* 고정하고 받으러 나가지 않는다(pull_policy never). 볼륨은 Windows 드라이브 문자의 `:` 와 헷갈리지 않게 긴 형식으로
|
|
85
|
+
* 적는다. 동봉 Keycloak 서비스는 넣지 않는다.
|
|
86
|
+
*/
|
|
87
|
+
export function composeYaml(spec) {
|
|
88
|
+
const lines = [
|
|
89
|
+
"# aiakiv CLI(npx aiakiv enterprise precheck)가 만든 파일입니다. 돌릴 때마다 다시 쓰므로 고치지 마세요.",
|
|
90
|
+
"services:",
|
|
91
|
+
" precheck:",
|
|
92
|
+
` image: ${yamlString(spec.image)}`,
|
|
93
|
+
" pull_policy: never",
|
|
94
|
+
' entrypoint: ["aiakiv-admin", "precheck"]',
|
|
95
|
+
" healthcheck: {disable: true}",
|
|
96
|
+
" # 로그인 콜백은 이 컴퓨터의 127.0.0.1:7777 에서만 받습니다(브라우저가 같은 컴퓨터에 있을 때).",
|
|
97
|
+
' ports: ["127.0.0.1:7777:7777"]',
|
|
98
|
+
];
|
|
99
|
+
if (spec.envFile)
|
|
100
|
+
lines.push(` env_file: [${yamlString(composeHostPath(spec.envFile, spec.platform))}]`);
|
|
101
|
+
lines.push(" volumes:", " - type: bind", ` source: ${yamlString(composeHostPath(spec.workDir, spec.platform))}`, " target: /config", "");
|
|
102
|
+
return lines.join("\n");
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* `docker compose ... run` 의 인자(맨 앞의 docker 는 빼고). -T 는 가상 터미널을 붙이지 않게 한다. 붙으면 점검의
|
|
106
|
+
* stderr 가 stdout 에 섞이고 줄 끝이 \r\n 이 되어 JSON 줄을 가르기 어렵다.
|
|
107
|
+
* 점검에 --out 은 넘기지 않는다. 보고서 파일은 마지막 줄을 받아 이 CLI 가 쓴다(reportMarkdown.ts 머리 주석).
|
|
108
|
+
*/
|
|
109
|
+
export function composeRunArgs(s) {
|
|
110
|
+
const args = [
|
|
111
|
+
"compose", "-p", PRECHECK_PROJECT, "-f", s.composeFile,
|
|
112
|
+
"run", "--rm", "--service-ports", "-T", "precheck",
|
|
113
|
+
"--config", `/config/${s.configName}`, "--json",
|
|
114
|
+
];
|
|
115
|
+
if (s.device)
|
|
116
|
+
args.push("--device");
|
|
117
|
+
if (s.noLogin)
|
|
118
|
+
args.push("--no-login");
|
|
119
|
+
if (s.only !== undefined)
|
|
120
|
+
args.push("--only", s.only);
|
|
121
|
+
if (s.skip !== undefined)
|
|
122
|
+
args.push("--skip", s.skip);
|
|
123
|
+
if (s.redactClaims)
|
|
124
|
+
args.push("--redact-claims");
|
|
125
|
+
return args;
|
|
126
|
+
}
|
|
127
|
+
/** `<파일>.sha256` 의 내용(`<지문>` 또는 sha256sum 형식 `<지문> <파일 이름>`)에서 지문. 없으면 null. */
|
|
128
|
+
export function parseSha256File(text) {
|
|
129
|
+
const m = /(?:^|\s)([0-9a-fA-F]{64})(?:\s|$)/.exec(text);
|
|
130
|
+
return m ? m[1].toLowerCase() : null;
|
|
131
|
+
}
|
|
132
|
+
/** `docker load` 의 출력에서 불러온 이미지 참조. 태그가 있으면 마지막 태그, 없으면 이미지 id. */
|
|
133
|
+
export function parseLoadOutput(stdout) {
|
|
134
|
+
let tag = null;
|
|
135
|
+
let id = null;
|
|
136
|
+
for (const l of stdout.split(/\r?\n/)) {
|
|
137
|
+
const t = /^\s*Loaded image:\s*(\S+)\s*$/.exec(l);
|
|
138
|
+
if (t)
|
|
139
|
+
tag = t[1];
|
|
140
|
+
const i = /^\s*Loaded image ID:\s*(\S+)\s*$/.exec(l);
|
|
141
|
+
if (i)
|
|
142
|
+
id = i[1];
|
|
143
|
+
}
|
|
144
|
+
return tag ?? id;
|
|
145
|
+
}
|
|
146
|
+
export async function sha256File(file) {
|
|
147
|
+
const h = createHash("sha256");
|
|
148
|
+
await pipeline(createReadStream(file), async (src) => {
|
|
149
|
+
for await (const chunk of src)
|
|
150
|
+
h.update(chunk);
|
|
151
|
+
});
|
|
152
|
+
return h.digest("hex");
|
|
153
|
+
}
|
|
154
|
+
/** 긴 오류 출력에서 첫 줄만, 너무 길면 자른다. */
|
|
155
|
+
function oneLine(s, max = 200) {
|
|
156
|
+
const l = s.trim().split(/\r?\n/).find((x) => x.trim()) ?? "";
|
|
157
|
+
return l.length > max ? `${l.slice(0, max - 1)}…` : l;
|
|
158
|
+
}
|
|
159
|
+
const downloadFix = () => `홈페이지(${ENTERPRISE_PAGE})에서 점검용 이미지를 내려받아 --image-tar 로 넣으세요.`;
|
|
160
|
+
// ---- Docker ---------------------------------------------------------------------------------
|
|
161
|
+
async function checkDocker(ctx) {
|
|
162
|
+
const docker = ctx.which("docker");
|
|
163
|
+
if (!docker) {
|
|
164
|
+
return { ok: false, detail: "docker 실행 파일을 찾지 못했습니다", fix: `Docker 를 설치하세요: ${dockerInstallUrl(ctx.platform)}` };
|
|
165
|
+
}
|
|
166
|
+
const r = await ctx.run(docker, ["info", "--format", "{{json .}}"], { capture: true, timeoutMs: DOCKER_TIMEOUT_MS });
|
|
167
|
+
const parsed = parseDockerInfo(r.stdout);
|
|
168
|
+
if (!r.ok || !parsed.info) {
|
|
169
|
+
const reason = parsed.errors.length ? parsed.errors.join("; ") : r.stderr.trim() || failReason(r);
|
|
170
|
+
return {
|
|
171
|
+
ok: false,
|
|
172
|
+
detail: `Docker 데몬에 닿지 못했습니다: ${oneLine(reason)}`,
|
|
173
|
+
fix: daemonFix(ctx.platform, isPermissionDenied(`${reason}\n${r.stderr}`)),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
const os = checkDockerOs(parsed.info.osType);
|
|
177
|
+
if (os.status === "fail")
|
|
178
|
+
return { ok: false, detail: os.detail, fix: os.fix ?? "리눅스 컨테이너 모드로 바꾸세요." };
|
|
179
|
+
const c = await ctx.run(docker, ["compose", "version", "--short"], { capture: true, timeoutMs: DOCKER_TIMEOUT_MS });
|
|
180
|
+
const short = c.ok ? c.stdout.trim().split(/\r?\n/)[0]?.trim() || null : null;
|
|
181
|
+
const compose = checkCompose(short, !!ctx.which("docker-compose"), ctx.platform);
|
|
182
|
+
if (compose.status === "fail")
|
|
183
|
+
return { ok: false, detail: compose.detail, fix: compose.fix ?? "" };
|
|
184
|
+
return {
|
|
185
|
+
ok: true,
|
|
186
|
+
docker,
|
|
187
|
+
info: parsed.info,
|
|
188
|
+
detail: `Docker 서버 ${parsed.info.serverVersion}, ${compose.detail}`,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
async function imageExists(ctx, docker, ref) {
|
|
192
|
+
const r = await ctx.run(docker, ["image", "inspect", "--format", "{{.Id}}", ref], {
|
|
193
|
+
capture: true,
|
|
194
|
+
timeoutMs: DOCKER_TIMEOUT_MS,
|
|
195
|
+
});
|
|
196
|
+
return r.ok;
|
|
197
|
+
}
|
|
198
|
+
async function dockerLoad(ctx, docker, file) {
|
|
199
|
+
ctx.err(`이미지를 불러오는 중입니다(docker load, 몇 분 걸릴 수 있습니다): ${file}`);
|
|
200
|
+
const r = await ctx.run(docker, ["load", "-i", file], { capture: true, timeoutMs: LOAD_TIMEOUT_MS });
|
|
201
|
+
if (!r.ok) {
|
|
202
|
+
return {
|
|
203
|
+
ok: false,
|
|
204
|
+
detail: `docker load 가 실패했습니다: ${oneLine(failReason(r))}`,
|
|
205
|
+
fix: `파일이 온전한지 확인하고 다시 내려받으세요. 계속 실패하면 ${ENTERPRISE_PAGE} 에서 받은 파일인지 확인하세요.`,
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
const image = parseLoadOutput(r.stdout);
|
|
209
|
+
if (!image) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
detail: `docker load 의 출력에서 불러온 이미지를 찾지 못했습니다: ${oneLine(r.stdout || r.stderr)}`,
|
|
213
|
+
fix: "docker images 로 불러온 이미지 이름을 확인하고 --image <이름:판> 으로 주세요.",
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
return { ok: true, image };
|
|
217
|
+
}
|
|
218
|
+
async function imageFromTar(ctx, docker, tarArg) {
|
|
219
|
+
const file = resolve(ctx.cwd, tarArg);
|
|
220
|
+
let isFile = false;
|
|
221
|
+
try {
|
|
222
|
+
isFile = statSync(file).isFile();
|
|
223
|
+
}
|
|
224
|
+
catch {
|
|
225
|
+
/* 아래에서 알린다 */
|
|
226
|
+
}
|
|
227
|
+
if (!isFile) {
|
|
228
|
+
return { ok: false, detail: `이미지 파일이 없습니다: ${file}`, fix: "--image-tar 에 내려받은 tar 파일의 경로를 주세요." };
|
|
229
|
+
}
|
|
230
|
+
const got = await sha256File(file);
|
|
231
|
+
const sumFile = `${file}.sha256`;
|
|
232
|
+
let note = "지문 파일이 없어 비교하지 않았습니다";
|
|
233
|
+
if (existsSync(sumFile)) {
|
|
234
|
+
const want = parseSha256File(readText(sumFile));
|
|
235
|
+
if (!want) {
|
|
236
|
+
return {
|
|
237
|
+
ok: false,
|
|
238
|
+
detail: `지문 파일 ${sumFile} 에서 sha256 지문을 읽지 못했습니다`,
|
|
239
|
+
fix: "홈페이지에서 지문 파일을 다시 받거나, 지문 파일을 지우고 아래 지문을 홈페이지의 값과 직접 비교하세요.",
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
if (want !== got) {
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
detail: `이미지 파일의 내용 지문이 다릅니다(지문 파일 ${want}, 이 파일 ${got})`,
|
|
246
|
+
fix: `파일이 손상됐거나 다른 파일입니다. ${ENTERPRISE_PAGE} 에서 다시 내려받으세요.`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
note = `지문 파일 ${basename(sumFile)} 과 같습니다`;
|
|
250
|
+
}
|
|
251
|
+
const loaded = await dockerLoad(ctx, docker, file);
|
|
252
|
+
if (!loaded.ok)
|
|
253
|
+
return loaded;
|
|
254
|
+
return { ok: true, image: loaded.image, source: "tar", sha256: got, detail: `${basename(file)} 를 불러왔습니다(sha256 ${got}, ${note})` };
|
|
255
|
+
}
|
|
256
|
+
function readText(file) {
|
|
257
|
+
// 지문 파일은 짧은 글자 파일이다. 큰 파일을 잘못 가리켜도 앞부분만 본다.
|
|
258
|
+
const buf = Buffer.alloc(4096);
|
|
259
|
+
const fd = openSync(file, "r");
|
|
260
|
+
try {
|
|
261
|
+
const n = readSync(fd, buf, 0, buf.length, 0);
|
|
262
|
+
return buf.subarray(0, n).toString("utf8");
|
|
263
|
+
}
|
|
264
|
+
finally {
|
|
265
|
+
closeSync(fd);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
/** 목록 파일 `{"version":…, "images":{"amd64":{url, sha256}, "arm64":{…}}}` 에서 그 아키텍처의 항목. */
|
|
269
|
+
export function pickManifestImage(body, arch) {
|
|
270
|
+
if (!body || typeof body !== "object")
|
|
271
|
+
return "목록 파일이 JSON 객체가 아닙니다";
|
|
272
|
+
const images = body.images;
|
|
273
|
+
if (!images || typeof images !== "object")
|
|
274
|
+
return "목록 파일에 images 가 없습니다";
|
|
275
|
+
const it = images[arch];
|
|
276
|
+
if (!it || typeof it !== "object")
|
|
277
|
+
return `목록 파일에 ${arch} 이미지가 없습니다`;
|
|
278
|
+
const { url, sha256 } = it;
|
|
279
|
+
if (typeof url !== "string" || !url)
|
|
280
|
+
return `목록 파일의 ${arch} 항목에 url 이 없습니다`;
|
|
281
|
+
if (typeof sha256 !== "string" || !/^[0-9a-fA-F]{64}$/.test(sha256))
|
|
282
|
+
return `목록 파일의 ${arch} 항목에 sha256 지문이 없습니다`;
|
|
283
|
+
return { url, sha256: sha256.toLowerCase() };
|
|
284
|
+
}
|
|
285
|
+
/** 받아서 지문을 보며 임시 파일에 쓰고, 맞으면 dest 로 옮긴다. 성공이면 null, 실패면 이유. */
|
|
286
|
+
async function downloadVerified(ctx, url, dest, expected) {
|
|
287
|
+
let res;
|
|
288
|
+
try {
|
|
289
|
+
res = await ctx.fetch(url, { redirect: "follow" });
|
|
290
|
+
}
|
|
291
|
+
catch (e) {
|
|
292
|
+
const err = e;
|
|
293
|
+
return `받지 못했습니다(${err.cause?.code ?? err.cause?.message ?? err.message})`;
|
|
294
|
+
}
|
|
295
|
+
if (!res.ok || !res.body) {
|
|
296
|
+
try {
|
|
297
|
+
await res.body?.cancel();
|
|
298
|
+
}
|
|
299
|
+
catch {
|
|
300
|
+
/* 본문은 읽지 않는다 */
|
|
301
|
+
}
|
|
302
|
+
return `받지 못했습니다(HTTP ${res.status})`;
|
|
303
|
+
}
|
|
304
|
+
mkdirSync(dirname(dest), { recursive: true });
|
|
305
|
+
const tmp = `${dest}.part-${process.pid}`;
|
|
306
|
+
const h = createHash("sha256");
|
|
307
|
+
const body = res.body;
|
|
308
|
+
try {
|
|
309
|
+
await pipeline((async function* () {
|
|
310
|
+
for await (const chunk of body) {
|
|
311
|
+
h.update(chunk);
|
|
312
|
+
yield chunk;
|
|
313
|
+
}
|
|
314
|
+
})(), createWriteStream(tmp));
|
|
315
|
+
}
|
|
316
|
+
catch (e) {
|
|
317
|
+
rmSync(tmp, { force: true });
|
|
318
|
+
return `내려받다가 멈췄습니다(${e.message})`;
|
|
319
|
+
}
|
|
320
|
+
const got = h.digest("hex");
|
|
321
|
+
if (got !== expected) {
|
|
322
|
+
rmSync(tmp, { force: true });
|
|
323
|
+
return `받은 파일의 내용 지문이 목록과 다릅니다(목록 ${expected}, 받은 파일 ${got})`;
|
|
324
|
+
}
|
|
325
|
+
renameSync(tmp, dest);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
async function imageFromDownload(ctx, docker, info) {
|
|
329
|
+
const mUrl = manifestUrl(ctx.env);
|
|
330
|
+
const arch = normArch(info.architecture) ?? normArch(process.arch);
|
|
331
|
+
if (!arch) {
|
|
332
|
+
return {
|
|
333
|
+
ok: false,
|
|
334
|
+
detail: `Docker 아키텍처 ${info.architecture ?? "알 수 없음"} 용 이미지가 없습니다(amd64, arm64 만)`,
|
|
335
|
+
fix: "amd64 또는 arm64 컴퓨터에서 점검하세요.",
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
let body;
|
|
339
|
+
try {
|
|
340
|
+
if (!/^https?:\/\//i.test(mUrl))
|
|
341
|
+
throw new Error(`http 나 https 주소가 아닙니다(${mUrl})`);
|
|
342
|
+
const res = await ctx.fetch(mUrl, { signal: AbortSignal.timeout(MANIFEST_TIMEOUT_MS) });
|
|
343
|
+
if (!res.ok) {
|
|
344
|
+
try {
|
|
345
|
+
await res.body?.cancel();
|
|
346
|
+
}
|
|
347
|
+
catch {
|
|
348
|
+
/* 본문은 읽지 않는다 */
|
|
349
|
+
}
|
|
350
|
+
return { ok: false, detail: `점검용 이미지 목록을 받지 못했습니다(HTTP ${res.status}, ${mUrl})`, fix: downloadFix() };
|
|
351
|
+
}
|
|
352
|
+
body = JSON.parse(await res.text());
|
|
353
|
+
}
|
|
354
|
+
catch (e) {
|
|
355
|
+
const err = e;
|
|
356
|
+
const why = err.name === "TimeoutError" || err.name === "AbortError"
|
|
357
|
+
? `${MANIFEST_TIMEOUT_MS / 1000}초 안에 응답이 없음`
|
|
358
|
+
: err.cause?.code ?? err.cause?.message ?? err.message;
|
|
359
|
+
return { ok: false, detail: `점검용 이미지 목록에 닿지 못했습니다(${why}, ${mUrl})`, fix: downloadFix() };
|
|
360
|
+
}
|
|
361
|
+
const picked = pickManifestImage(body, arch);
|
|
362
|
+
if (typeof picked === "string")
|
|
363
|
+
return { ok: false, detail: `${picked}(${mUrl})`, fix: downloadFix() };
|
|
364
|
+
let tarUrl;
|
|
365
|
+
try {
|
|
366
|
+
tarUrl = new URL(picked.url, mUrl).toString();
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return { ok: false, detail: `목록 파일의 주소를 읽지 못했습니다(${picked.url})`, fix: downloadFix() };
|
|
370
|
+
}
|
|
371
|
+
if (!/^https?:\/\//i.test(tarUrl)) {
|
|
372
|
+
return { ok: false, detail: `목록 파일의 주소가 http 나 https 가 아닙니다(${tarUrl})`, fix: downloadFix() };
|
|
373
|
+
}
|
|
374
|
+
const name = basename(new URL(tarUrl).pathname).replace(/[^A-Za-z0-9._-]/g, "_") ||
|
|
375
|
+
`aiakiv-engine-enterprise-${ENTERPRISE_ENGINE_VERSION}-${arch}.tar`;
|
|
376
|
+
const dest = join(ctx.home, ".aiakiv", "cache", name);
|
|
377
|
+
let reused = false;
|
|
378
|
+
if (existsSync(dest) && (await sha256File(dest)) === picked.sha256) {
|
|
379
|
+
reused = true;
|
|
380
|
+
ctx.err(`이미 받아 둔 파일을 씁니다: ${dest}`);
|
|
381
|
+
}
|
|
382
|
+
else {
|
|
383
|
+
ctx.err(`점검용 이미지(${arch})를 내려받는 중입니다: ${tarUrl}`);
|
|
384
|
+
const why = await downloadVerified(ctx, tarUrl, dest, picked.sha256);
|
|
385
|
+
if (why)
|
|
386
|
+
return { ok: false, detail: `점검용 이미지를 ${why}`, fix: downloadFix() };
|
|
387
|
+
}
|
|
388
|
+
const loaded = await dockerLoad(ctx, docker, dest);
|
|
389
|
+
if (!loaded.ok)
|
|
390
|
+
return loaded;
|
|
391
|
+
return {
|
|
392
|
+
ok: true,
|
|
393
|
+
image: loaded.image,
|
|
394
|
+
source: "download",
|
|
395
|
+
sha256: picked.sha256,
|
|
396
|
+
detail: `${reused ? "받아 둔" : "내려받은"} ${arch} 이미지를 불러왔습니다(${dest}, sha256 ${picked.sha256})`,
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
async function resolveImage(ctx, docker, info, opts) {
|
|
400
|
+
if (opts.image !== undefined) {
|
|
401
|
+
if (await imageExists(ctx, docker, opts.image)) {
|
|
402
|
+
return { ok: true, image: opts.image, source: "flag", detail: `이 컴퓨터에 있는 이미지 ${opts.image} 를 씁니다` };
|
|
403
|
+
}
|
|
404
|
+
return {
|
|
405
|
+
ok: false,
|
|
406
|
+
detail: `이 컴퓨터에 이미지 ${opts.image} 가 없습니다`,
|
|
407
|
+
fix: "docker images 로 이름과 판을 확인하거나, 내려받은 tar 파일을 --image-tar 로 주세요.",
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
if (opts.imageTar !== undefined)
|
|
411
|
+
return imageFromTar(ctx, docker, opts.imageTar);
|
|
412
|
+
const ref = defaultImageRef();
|
|
413
|
+
if (await imageExists(ctx, docker, ref)) {
|
|
414
|
+
return { ok: true, image: ref, source: "local", detail: `이 컴퓨터에 있는 이미지 ${ref} 를 씁니다` };
|
|
415
|
+
}
|
|
416
|
+
return imageFromDownload(ctx, docker, info);
|
|
417
|
+
}
|
|
418
|
+
const isObj = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
419
|
+
const label = (s) => STATUS_LABEL[s] ?? String(s);
|
|
420
|
+
/** 사람용 한 줄. 점검의 detail 은 160자에서 자른다(전체는 보고서 파일에 있다). 이 명령 자신의 줄은 자르지 않는다. */
|
|
421
|
+
function stepText(step, status, detail, ms, max = 160) {
|
|
422
|
+
const flat = typeof detail === "string" ? detail.replace(/\s+/g, " ") : "";
|
|
423
|
+
const d = flat ? ` ${max === Infinity ? flat : truncate(flat, max)}` : "";
|
|
424
|
+
const t = typeof ms === "number" ? ` (${ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}초`})` : "";
|
|
425
|
+
return `[${label(status)}] ${step}${t}${d}`;
|
|
426
|
+
}
|
|
427
|
+
/** 로그인 대기 줄을 눈에 띄게. 브라우저 방식은 login_url, 기기 코드 방식은 주소와 코드. */
|
|
428
|
+
function loginLines(w) {
|
|
429
|
+
const timeout = typeof w.timeout_s === "number" ? `(최대 ${Math.round(w.timeout_s)}초 기다립니다)` : "";
|
|
430
|
+
const bar = "=".repeat(64);
|
|
431
|
+
const lines = ["", bar];
|
|
432
|
+
if (typeof w.user_code === "string") {
|
|
433
|
+
lines.push(`로그인이 필요합니다${timeout}. 휴대전화나 다른 컴퓨터의 브라우저에서 이 주소를 열고 코드를 넣으세요.`);
|
|
434
|
+
lines.push(` 주소: ${String(w.verification_uri ?? "")}`);
|
|
435
|
+
lines.push(` 코드: ${w.user_code}`);
|
|
436
|
+
if (typeof w.verification_uri_complete === "string")
|
|
437
|
+
lines.push(` 코드가 들어간 주소: ${w.verification_uri_complete}`);
|
|
438
|
+
}
|
|
439
|
+
else {
|
|
440
|
+
lines.push(`로그인이 필요합니다${timeout}. 이 컴퓨터의 브라우저에서 이 주소를 열고 시험용 계정으로 로그인하세요.`);
|
|
441
|
+
lines.push(` ${String(w.login_url ?? "")}`);
|
|
442
|
+
}
|
|
443
|
+
lines.push("비밀번호는 이 화면에 넣지 않습니다. AI 가 점검을 돌리는 경우에도 로그인은 사람이 합니다.", bar, "");
|
|
444
|
+
return lines;
|
|
445
|
+
}
|
|
446
|
+
function summaryText(s) {
|
|
447
|
+
const m = isObj(s) ? s : {};
|
|
448
|
+
return ["pass", "warn", "fail", "skip", "info"].map((k) => `${STATUS_LABEL[k]} ${Number(m[k] ?? 0)}`).join(", ");
|
|
449
|
+
}
|
|
450
|
+
/** 사람용 끝 정리: 결과, 고칠 것, 발급 조건, 보고서 위치. */
|
|
451
|
+
function finalLines(report, files) {
|
|
452
|
+
const lines = [""];
|
|
453
|
+
const s = isObj(report.summary) ? report.summary : {};
|
|
454
|
+
const fails = Number(s.fail ?? 0);
|
|
455
|
+
lines.push(report.ok ? `결과: 실패 없음(${summaryText(s)})` : `결과: 실패 ${fails}건(${summaryText(s)})`);
|
|
456
|
+
const checks = Array.isArray(report.checks) ? report.checks.filter(isObj) : [];
|
|
457
|
+
const todo = checks.filter((c) => typeof c.fix === "string" && c.fix && (c.status === "fail" || c.status === "warn"));
|
|
458
|
+
if (todo.length) {
|
|
459
|
+
lines.push("", "고칠 것:");
|
|
460
|
+
todo.sort((a, b) => (a.status === b.status ? 0 : a.status === "fail" ? -1 : 1));
|
|
461
|
+
for (const c of todo)
|
|
462
|
+
lines.push(` [${label(c.status)}] ${String(c.id)}: ${String(c.fix)}`);
|
|
463
|
+
}
|
|
464
|
+
const q = isObj(report.quote) ? report.quote : {};
|
|
465
|
+
const left = Array.isArray(q.failed_required) ? q.failed_required.map(String) : [];
|
|
466
|
+
lines.push("", q.eligible === true
|
|
467
|
+
? `견적과 라이선스 발급 조건(quote.eligible): 충족(로그인 방식 ${String(q.mode ?? "-")})`
|
|
468
|
+
: `견적과 라이선스 발급 조건(quote.eligible): 충족하지 못함(로그인 방식 ${String(q.mode ?? "-")}` +
|
|
469
|
+
`${left.length ? `, 채우지 못한 필수 점검: ${left.join(", ")}` : ""})`);
|
|
470
|
+
if (files.length)
|
|
471
|
+
lines.push(`보고서: ${files.join(", ")}`);
|
|
472
|
+
return lines;
|
|
473
|
+
}
|
|
474
|
+
// ---- 실행 -----------------------------------------------------------------------------------
|
|
475
|
+
export async function runEnterprisePrecheck(ctx, opts) {
|
|
476
|
+
const json = !!opts.json;
|
|
477
|
+
/** 이 명령 자신의 단계 줄. --json 이면 한 줄 JSON, 아니면 사람용 한 줄(실패면 고칠 것도). */
|
|
478
|
+
const step = (line) => {
|
|
479
|
+
if (json) {
|
|
480
|
+
ctx.out(JSON.stringify(line));
|
|
481
|
+
return;
|
|
482
|
+
}
|
|
483
|
+
ctx.out(stepText(line.step, line.status, line.detail, undefined, Infinity));
|
|
484
|
+
if (line.fix)
|
|
485
|
+
ctx.out(` 고칠 것: ${line.fix}`);
|
|
486
|
+
};
|
|
487
|
+
const failStep = (id, f, code) => {
|
|
488
|
+
step({ step: id, status: "fail", detail: f.detail, fix: f.fix });
|
|
489
|
+
if (json)
|
|
490
|
+
ctx.err(`${f.detail}. ${f.fix}`);
|
|
491
|
+
return code;
|
|
492
|
+
};
|
|
493
|
+
// 1. 설정 파일. 그 파일이 있는 폴더가 작업 폴더이고 컨테이너의 /config 로 붙는다.
|
|
494
|
+
const configPath = resolve(ctx.cwd, opts.config ?? DEFAULT_PRECHECK_CONFIG);
|
|
495
|
+
let configOk = false;
|
|
496
|
+
try {
|
|
497
|
+
configOk = statSync(configPath).isFile();
|
|
498
|
+
}
|
|
499
|
+
catch {
|
|
500
|
+
/* 아래에서 알린다 */
|
|
501
|
+
}
|
|
502
|
+
if (!configOk) {
|
|
503
|
+
return failStep("cli.config", {
|
|
504
|
+
ok: false,
|
|
505
|
+
detail: `설정 파일이 없습니다: ${configPath}`,
|
|
506
|
+
fix: `예시 파일 ${EXAMPLE_PRECHECK_CONFIG} 을 ${DEFAULT_PRECHECK_CONFIG} 로 복사해 고친 뒤 그 폴더에서 다시 돌리거나, --config 로 설정 파일을 주세요.`,
|
|
507
|
+
}, EXIT_USAGE);
|
|
508
|
+
}
|
|
509
|
+
const workDir = dirname(configPath);
|
|
510
|
+
const configName = basename(configPath);
|
|
511
|
+
const outRel = relativeOut(opts.out ?? DEFAULT_PRECHECK_OUT);
|
|
512
|
+
if (!outRel) {
|
|
513
|
+
return failStep("cli.config", {
|
|
514
|
+
ok: false,
|
|
515
|
+
detail: `--out 은 설정 파일이 있는 폴더(${workDir}) 안의 상대 경로여야 합니다(받은 값: ${opts.out})`,
|
|
516
|
+
fix: `--out ${DEFAULT_PRECHECK_OUT} 처럼 그 폴더 안의 폴더 이름을 주세요.`,
|
|
517
|
+
}, EXIT_USAGE);
|
|
518
|
+
}
|
|
519
|
+
const envPath = join(workDir, ".env");
|
|
520
|
+
const envFile = existsSync(envPath) ? envPath : null;
|
|
521
|
+
// 2. Docker.
|
|
522
|
+
const d = await checkDocker(ctx);
|
|
523
|
+
if (!d.ok)
|
|
524
|
+
return failStep("cli.docker", d, EXIT_DOCKER);
|
|
525
|
+
step({ step: "cli.docker", status: "pass", detail: d.detail });
|
|
526
|
+
// 3. 이미지.
|
|
527
|
+
const img = await resolveImage(ctx, d.docker, d.info, opts);
|
|
528
|
+
if (!img.ok) {
|
|
529
|
+
const f = img;
|
|
530
|
+
if (json) {
|
|
531
|
+
ctx.out(JSON.stringify({ step: "cli.image", status: "fail", image: null, source: null, detail: f.detail, fix: f.fix }));
|
|
532
|
+
ctx.err(`${f.detail}. ${f.fix}`);
|
|
533
|
+
}
|
|
534
|
+
else
|
|
535
|
+
step({ step: "cli.image", status: "fail", detail: f.detail, fix: f.fix });
|
|
536
|
+
return EXIT_IMAGE;
|
|
537
|
+
}
|
|
538
|
+
const imageLine = {
|
|
539
|
+
step: "cli.image", status: "pass", image: img.image, source: img.source, detail: img.detail,
|
|
540
|
+
};
|
|
541
|
+
if (img.sha256)
|
|
542
|
+
imageLine.sha256 = img.sha256;
|
|
543
|
+
step(imageLine);
|
|
544
|
+
// 4. compose 파일.
|
|
545
|
+
const composeFile = join(workDir, PRECHECK_DIR, "compose.yml");
|
|
546
|
+
writeFileAtomic(composeFile, composeYaml({ image: img.image, workDir, envFile, platform: ctx.platform }));
|
|
547
|
+
const outHost = join(workDir, ...outRel.split("/"));
|
|
548
|
+
// 5. 실행. 점검의 표준 출력을 줄 단위 JSON 으로 읽는다.
|
|
549
|
+
const args = composeRunArgs({
|
|
550
|
+
composeFile, configName, device: opts.device, noLogin: opts.noLogin,
|
|
551
|
+
only: opts.only, skip: opts.skip, redactClaims: opts.redactClaims,
|
|
552
|
+
});
|
|
553
|
+
if (!json)
|
|
554
|
+
ctx.out(`점검을 시작합니다(이미지 ${img.image}, 설정 ${configPath}).`);
|
|
555
|
+
const st = {
|
|
556
|
+
final: null, precheckError: null, stderrTail: [], opens: [],
|
|
557
|
+
};
|
|
558
|
+
const onWaiting = (w) => {
|
|
559
|
+
for (const l of loginLines(w))
|
|
560
|
+
(json ? ctx.err : ctx.out)(l);
|
|
561
|
+
const url = typeof w.login_url === "string" ? w.login_url : "";
|
|
562
|
+
if (opts.device || opts.noBrowser || typeof w.user_code === "string" || !/^https?:\/\//i.test(url))
|
|
563
|
+
return;
|
|
564
|
+
st.opens.push(ctx.openUrl(url).then((ok) => {
|
|
565
|
+
if (!ok)
|
|
566
|
+
ctx.err("브라우저를 열지 못했습니다. 위 주소를 직접 여세요.");
|
|
567
|
+
}, () => ctx.err("브라우저를 열지 못했습니다. 위 주소를 직접 여세요.")));
|
|
568
|
+
};
|
|
569
|
+
const onStdout = (line) => {
|
|
570
|
+
const t = line.trim();
|
|
571
|
+
let obj = null;
|
|
572
|
+
if (t.startsWith("{")) {
|
|
573
|
+
try {
|
|
574
|
+
obj = JSON.parse(t);
|
|
575
|
+
}
|
|
576
|
+
catch {
|
|
577
|
+
obj = null;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
if (!isObj(obj)) {
|
|
581
|
+
if (!json && t)
|
|
582
|
+
ctx.err(line);
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
// --json 이면 점검의 줄은 바꾸지 않고 그대로 낸다.
|
|
586
|
+
if (json)
|
|
587
|
+
ctx.out(line);
|
|
588
|
+
if (obj.tool === PRECHECK_TOOL) {
|
|
589
|
+
st.final = obj;
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
if (obj.ok === false && typeof obj.error === "string") {
|
|
593
|
+
st.precheckError = obj;
|
|
594
|
+
if (!json)
|
|
595
|
+
ctx.err(`점검이 설정이나 인자를 받아들이지 않았습니다(${obj.error}): ${String(obj.message ?? "")}`);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
if (obj.step === "oidc.login" && obj.status === "waiting") {
|
|
599
|
+
onWaiting(obj);
|
|
600
|
+
return;
|
|
601
|
+
}
|
|
602
|
+
if (!json && typeof obj.step === "string")
|
|
603
|
+
ctx.out(stepText(obj.step, obj.status, obj.detail, obj.ms));
|
|
604
|
+
};
|
|
605
|
+
const onStderr = (line) => {
|
|
606
|
+
if (!json) {
|
|
607
|
+
ctx.err(line);
|
|
608
|
+
return;
|
|
609
|
+
}
|
|
610
|
+
if (line.trim())
|
|
611
|
+
st.stderrTail = [...st.stderrTail, line].slice(-5);
|
|
612
|
+
};
|
|
613
|
+
const res = await ctx.runLines(d.docker, args, { cwd: workDir, onStdout, onStderr });
|
|
614
|
+
await Promise.all(st.opens);
|
|
615
|
+
if (res.error) {
|
|
616
|
+
return failStep("cli.run", {
|
|
617
|
+
ok: false,
|
|
618
|
+
detail: `docker compose run 을 실행하지 못했습니다: ${res.error}`,
|
|
619
|
+
fix: "Docker 가 켜져 있는지 확인하고 다시 돌리세요.",
|
|
620
|
+
}, EXIT_RUN_FAILED);
|
|
621
|
+
}
|
|
622
|
+
if (!st.final) {
|
|
623
|
+
if (res.code === EXIT_USAGE && st.precheckError)
|
|
624
|
+
return EXIT_USAGE;
|
|
625
|
+
const tail = st.stderrTail.length ? `: ${st.stderrTail.join(" / ")}` : "";
|
|
626
|
+
return failStep("cli.run", {
|
|
627
|
+
ok: false,
|
|
628
|
+
detail: `점검이 보고서를 내지 못하고 끝났습니다(종료 코드 ${res.code ?? "없음"})${json ? tail : ""}`,
|
|
629
|
+
fix: `위 오류를 보고 고친 뒤 다시 돌리세요. 포트 7777 을 다른 프로그램이 쓰고 있으면 끄고 다시 돌리세요(compose 파일 ${composeFile}).`,
|
|
630
|
+
}, EXIT_RUN_FAILED);
|
|
631
|
+
}
|
|
632
|
+
// 보고서 파일. 점검 본체의 --out 과 같은 이름과 형식(report.json, report.md)으로 이 CLI 가 쓴다.
|
|
633
|
+
const reportJson = join(outHost, "report.json");
|
|
634
|
+
const reportMd = join(outHost, "report.md");
|
|
635
|
+
let wrote = true;
|
|
636
|
+
try {
|
|
637
|
+
mkdirSync(outHost, { recursive: true });
|
|
638
|
+
writeFileSync(reportJson, reportJsonText(st.final), "utf8");
|
|
639
|
+
writeFileSync(reportMd, toMarkdown(st.final, reportLanguage(ctx.env, readTextIfExists(configPath))), "utf8");
|
|
640
|
+
}
|
|
641
|
+
catch (e) {
|
|
642
|
+
wrote = false;
|
|
643
|
+
ctx.err(`보고서 파일을 쓰지 못했습니다(${outHost}): ${e.message}`);
|
|
644
|
+
}
|
|
645
|
+
const files = wrote ? [reportJson, reportMd] : [];
|
|
646
|
+
if (!json)
|
|
647
|
+
for (const l of finalLines(st.final, files))
|
|
648
|
+
ctx.out(l);
|
|
649
|
+
else if (files.length)
|
|
650
|
+
ctx.err(`보고서: ${files.join(", ")}`);
|
|
651
|
+
if (!wrote)
|
|
652
|
+
return EXIT_RUN_FAILED;
|
|
653
|
+
if (res.code === EXIT_OK || res.code === EXIT_FAILED || res.code === EXIT_USAGE)
|
|
654
|
+
return res.code;
|
|
655
|
+
return st.final.ok ? EXIT_OK : EXIT_FAILED;
|
|
656
|
+
}
|