- raspberry_pi4/WIRING.html, raspberry_pi4_sht30/WIRING.html 추가: 자체 완결형(인라인 CSS/SVG, 외부참조 0 → 폐쇄망 오프라인 열람) 40핀 핀맵 도해 + 핀맵 표 + 배선 스키매틱 + 전기설계 경고 + 설치 체크리스트. 인쇄 대응. 누수 노드(Pi-A/Pi-C 공통, GPIO17)·SHT30 노드(I2C1, 0x44) 각 1장. - make_deploy_bundles.py: TEXT_EXTS 에 .html 추가(번들 복사 시 CRLF→LF 정규화), Pi SETUP.md 에 WIRING.html 안내 줄 추가. copy_tree 가 각 Pi 번들에 자동 포함. (동반: 기존 dotfile .htaccess/.gitignore LF 정규화 반영.)
383 lines
18 KiB
Python
383 lines
18 KiB
Python
# =============================================================================
|
|
# make_deploy_bundles.py — 기기별 턴키 배포 번들 생성기
|
|
#
|
|
# python tools/make_deploy_bundles.py
|
|
#
|
|
# repo 루트에 deploy/ 를 만들고, 서버 1 + Pi 3대 각각 "복사 → install.sh →
|
|
# systemctl enable" 만으로 운영 가능한 폴더를 생성한다.
|
|
# - API_KEY 는 최초 실행 시 자동 생성(deploy/.secrets/api_key.txt 에 보관,
|
|
# 재실행 시 재사용) → 서버/Pi 4곳에 동일 주입.
|
|
# - 관리자 비밀번호도 자동 생성 + bcrypt 해시(php 필요) 주입.
|
|
# - 텍스트 파일은 LF 로 정규화(Windows 체크아웃 CRLF → Pi bash 오류 방지).
|
|
# - deploy/ 는 비밀값 포함 — .gitignore 대상. 절대 커밋 금지.
|
|
#
|
|
# 남는 수동 설정(외부 비밀이라 사전 주입 불가):
|
|
# 1) 서버 DB 계정(cafe24 패널 값) 2) SMS 계정/발신·수신번호 3) TOTP 등록 1회
|
|
# =============================================================================
|
|
import hashlib
|
|
import hmac
|
|
import os
|
|
import re
|
|
import secrets
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
ROOT = Path(__file__).resolve().parent.parent
|
|
DEPLOY = ROOT / "deploy"
|
|
SECRETS_DIR = DEPLOY / ".secrets"
|
|
|
|
# ── 배포 기본값 ──────────────────────────────────────────────────────────────
|
|
# WEB_DIR(난독화 경로 폴더명)은 비밀값 — 커밋되는 이 파일에 두지 않고
|
|
# deploy/.secrets/web_dir.txt 에서 로드한다(없으면 랜덤 생성). 아래 DOMAIN 만 수정.
|
|
DOMAIN = "https://pores.kr"
|
|
|
|
TEXT_EXTS = {".sh", ".py", ".php", ".service", ".sql", ".md", ".txt", ".example", ".html"}
|
|
EXCLUDE_NAMES = {"__pycache__", ".gitignore", "config_local.py"}
|
|
|
|
BUNDLES = [
|
|
# (폴더명, 원본, 종류)
|
|
("01_server_cafe24", None, "server"),
|
|
("02_pi_a_leak_id1", "raspberry_pi4", "leak"),
|
|
("03_pi_b_sht30_id2", "raspberry_pi4_sht30", "sht30"),
|
|
("04_pi_c_leak_id3", "raspberry_pi4", "leak"),
|
|
]
|
|
|
|
PI_META = {
|
|
"02_pi_a_leak_id1": {
|
|
"device_id": "rpi4-leak-01", "location": "1번 지점(서버실)",
|
|
"sensors": None, # config.py 기본값 (sensor_id 1)
|
|
"service": "leak-sensor",
|
|
},
|
|
"03_pi_b_sht30_id2": {
|
|
"device_id": "rpi4-sht30-01", "location": "1번 지점(서버실)",
|
|
"sensors": None, # config.py 기본값 (sensor_id 2)
|
|
"service": "sht30-sensor",
|
|
},
|
|
"04_pi_c_leak_id3": {
|
|
"device_id": "rpi4-leak-02", "location": "3번 지점(신규)",
|
|
"sensors": '[\n {"id": 3, "gpio": 17, "name": "3번 센서 (신규 지점)"},\n]',
|
|
"service": "leak-sensor",
|
|
},
|
|
}
|
|
|
|
|
|
def fail(msg: str) -> None:
|
|
print(f"[ERROR] {msg}", file=sys.stderr)
|
|
sys.exit(1)
|
|
|
|
|
|
def load_or_create_secret(name: str, generate) -> str:
|
|
SECRETS_DIR.mkdir(parents=True, exist_ok=True)
|
|
f = SECRETS_DIR / name
|
|
if f.exists():
|
|
return f.read_text(encoding="utf-8").strip()
|
|
value = generate()
|
|
f.write_text(value + "\n", encoding="utf-8")
|
|
os.chmod(f, 0o600) # Windows 에선 read-only 근사지만 POSIX 빌드머신 대비
|
|
return value
|
|
|
|
|
|
def bcrypt_hash(password: str) -> str:
|
|
"""php 로 bcrypt 해시 생성 (서버 setup_hash.php 와 동일 결과).
|
|
비밀번호는 argv 가 아니라 stdin 으로 전달한다(공유 빌드머신 프로세스 목록 노출 방지)."""
|
|
try:
|
|
out = subprocess.run(
|
|
["php", "-r", "echo password_hash(rtrim(fgets(STDIN), \"\\r\\n\"), PASSWORD_BCRYPT);"],
|
|
input=password + "\n",
|
|
capture_output=True, text=True, check=True,
|
|
)
|
|
except (subprocess.CalledProcessError, FileNotFoundError) as e:
|
|
fail(f"php 실행 실패 — bcrypt 해시를 만들 수 없습니다: {e}")
|
|
h = out.stdout.strip()
|
|
if not h.startswith("$2y$"):
|
|
fail(f"bcrypt 해시 형식 이상: {h!r}")
|
|
return h
|
|
|
|
|
|
def normalize_lf(path: Path) -> None:
|
|
"""번들 내 텍스트 파일 CRLF → LF (Pi bash/systemd/Apache 오류 방지).
|
|
확장자 없는 설정 dotfile(.htaccess/.gitignore 등)도 확장자와 무관하게 포함한다
|
|
(.htaccess CRLF 는 Apache Header 값에 CR 를 섞을 수 있음)."""
|
|
dotfiles = {".htaccess", ".gitignore", ".env.example"}
|
|
for p in path.rglob("*"):
|
|
if p.is_file() and (p.suffix in TEXT_EXTS or p.name in dotfiles):
|
|
data = p.read_bytes()
|
|
if b"\r\n" in data:
|
|
p.write_bytes(data.replace(b"\r\n", b"\n"))
|
|
|
|
|
|
def copy_tree(src: Path, dst: Path) -> None:
|
|
shutil.copytree(
|
|
src, dst,
|
|
ignore=shutil.ignore_patterns(*EXCLUDE_NAMES, "*.pyc"),
|
|
dirs_exist_ok=False,
|
|
)
|
|
|
|
|
|
def write_lf(path: Path, content: str) -> None:
|
|
path.write_text(content, encoding="utf-8", newline="\n")
|
|
|
|
|
|
# ── 유출 방어 가드 먼저 — 비밀값을 쓰기 전에 ignore 파일부터 만든다 ─────────
|
|
# (루트 .gitignore 의 deploy/ 항목과 이중 방어. 생성 도중 스크립트가 실패해도
|
|
# deploy/ 아래 비밀값이 git 에 스테이징되지 않도록 순서를 보장한다.)
|
|
DEPLOY.mkdir(parents=True, exist_ok=True)
|
|
write_lf(DEPLOY / ".gitignore", "# 배포 번들 전체 커밋 금지 (비밀값 포함)\n*\n!.gitignore\n")
|
|
|
|
# ── 비밀값 준비 ──────────────────────────────────────────────────────────────
|
|
API_KEY = load_or_create_secret("api_key.txt", lambda: secrets.token_hex(32))
|
|
ADMIN_PASSWORD = load_or_create_secret("admin_password.txt", lambda: secrets.token_urlsafe(18))
|
|
MFA_SETUP_TOKEN = load_or_create_secret("mfa_setup_token.txt", lambda: secrets.token_hex(16))
|
|
ADMIN_HASH = bcrypt_hash(ADMIN_PASSWORD)
|
|
WEB_DIR = load_or_create_secret("web_dir.txt", lambda: secrets.token_hex(8))
|
|
BASE_URL = f"{DOMAIN}/{WEB_DIR}"
|
|
API_URL = f"{BASE_URL}/api/sensor_data.php"
|
|
PHOTO_UPLOAD_URL = f"{BASE_URL}/api/photo_upload.php"
|
|
# cron HTTP 키 — 서버 config.php 의 hash_hmac('sha256','cron',API_KEY) 과 동일.
|
|
# 미리 계산해 SETUP.md 에 넣어, 운영자가 API_KEY 를 셸 명령줄(히스토리/프로세스
|
|
# 목록에 노출)에 올리지 않아도 되게 한다.
|
|
CRON_SECRET = hmac.new(API_KEY.encode(), b"cron", hashlib.sha256).hexdigest()
|
|
|
|
# ── 기존 번들 제거 후 재생성 (.secrets 는 보존) ─────────────────────────────
|
|
for name, _, _ in BUNDLES:
|
|
d = DEPLOY / name
|
|
if d.exists():
|
|
shutil.rmtree(d)
|
|
|
|
# ── Pi 번들 ──────────────────────────────────────────────────────────────────
|
|
for name, src, kind in BUNDLES:
|
|
if kind == "server":
|
|
continue
|
|
dst = DEPLOY / name
|
|
copy_tree(ROOT / src, dst)
|
|
shutil.copy2(ROOT / "tools" / "harden_pi.sh", dst / "harden_pi.sh")
|
|
meta = PI_META[name]
|
|
|
|
sensors_block = ""
|
|
if meta["sensors"]:
|
|
sensors_block = (
|
|
"\n# 이 기기 전용 센서 목록 — config.py 기본값(sensor_id 1) 오버라이드\n"
|
|
f"SENSORS = {meta['sensors']}\n"
|
|
)
|
|
|
|
write_lf(dst / "config_local.py", f'''# =============================================================================
|
|
# config_local.py — {meta["device_id"]} 운영값 (자동 생성, 커밋 금지)
|
|
# =============================================================================
|
|
API_URL = "{API_URL}"
|
|
{'PHOTO_UPLOAD_URL = "' + PHOTO_UPLOAD_URL + '"' if kind == "leak" else '# (SHT30 모듈은 사진 업로드 없음)'}
|
|
API_KEY = "{API_KEY}"
|
|
DEVICE_ID = "{meta["device_id"]}"
|
|
DEVICE_LOCATION = "{meta["location"]}"
|
|
{sensors_block}''')
|
|
|
|
# ── 원샷 설치 래퍼: 설치 → 서비스 등록 → 검증 → 하드닝 ──────────────
|
|
# 검증(test_connection.py)이 실패하면 set -e 로 하드닝 전에 멈춘다 →
|
|
# SSH 가 살아있어 디버깅 가능. 성공했을 때만 하드닝(SSH 차단)까지 간다.
|
|
write_lf(dst / "setup.sh", f'''#!/usr/bin/env bash
|
|
# =============================================================================
|
|
# setup.sh — {meta["device_id"]} 원샷 설치 (설치→서비스→검증→하드닝)
|
|
# pi 사용자로 실행: bash setup.sh (sudo 로 실행하지 말 것 — 내부에서 sudo)
|
|
# 검증 실패 시 하드닝 전에 중단(set -e) → SSH 유지되어 디버깅 가능.
|
|
# =============================================================================
|
|
set -euo pipefail
|
|
cd "$(dirname "$0")"
|
|
|
|
if [ "$(id -u)" -eq 0 ]; then
|
|
echo "[ERROR] sudo 없이 pi 사용자로 실행하세요: bash setup.sh" >&2
|
|
exit 1
|
|
fi
|
|
|
|
echo "== [1/4] 패키지·venv 설치 =="
|
|
chmod +x install.sh && ./install.sh
|
|
|
|
echo "== [2/4] systemd 서비스 등록 =="
|
|
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl enable --now {meta["service"]}
|
|
|
|
echo "== [3/4] 서버 연결 검증 (실패 시 여기서 중단 — SSH 유지됨) =="
|
|
# shellcheck disable=SC1091
|
|
source venv/bin/activate
|
|
python3 test_connection.py
|
|
|
|
echo "== [4/4] 하드닝 (마지막 — SSH 가 차단된다!) =="
|
|
sudo bash harden_pi.sh
|
|
|
|
echo ""
|
|
echo "설치·검증·하드닝 완료. 마무리 리부팅 권장: sudo reboot"
|
|
''')
|
|
|
|
write_lf(dst / "SETUP.md", f'''# {name} 설치 — {meta["device_id"]}
|
|
|
|
사전: Raspberry Pi OS **Bookworm 64-bit**, 사용자 **pi**, SSH 활성(Imager 설정).
|
|
`config_local.py` 는 이미 채워져 있음(API_KEY/URL/기기 ID). 설치 장소가 바뀌면 DEVICE_LOCATION 만 수정.
|
|
|
|
📐 **배선·핀맵:** 같은 폴더의 `WIRING.html` 을 브라우저로 열어 확인(자체 완결형 — 오프라인 열람 가능).
|
|
|
|
## 원샷 설치 (권장)
|
|
|
|
```bash
|
|
# 1. 이 폴더를 Pi 로 복사
|
|
scp -r {name} pi@<PI_IP>:/home/pi/{src}
|
|
|
|
# 2. 한 번에: 설치 → 서비스 등록 → 검증 → 하드닝
|
|
cd /home/pi/{src} && bash setup.sh
|
|
# (sudo 붙이지 말 것 — 내부에서 sudo 사용)
|
|
```
|
|
|
|
`setup.sh` 는 4단계를 자동 수행하고, **검증(3단계)이 실패하면 하드닝 전에 멈춘다**
|
|
(SSH 가 살아있어 원인을 볼 수 있음). 검증이 통과해야만 하드닝까지 진행된다.
|
|
|
|
⚠️ 4단계 하드닝은 **SSH 를 완전히 끈다.** SSH 로 접속해 실행 중이면 완료 직후
|
|
세션이 끊기고 재접속 불가 — 이후 유지보수는 현장 모니터+키보드. 마지막에 `sudo reboot`.
|
|
|
|
## 수동(단계별) — 문제 발생 시
|
|
|
|
```bash
|
|
cd /home/pi/{src}
|
|
chmod +x install.sh && ./install.sh # 1. 설치
|
|
sudo cp systemd/{meta["service"]}.service /etc/systemd/system/
|
|
sudo systemctl daemon-reload && sudo systemctl enable --now {meta["service"]} # 2. 서비스
|
|
source venv/bin/activate && python3 test_connection.py # 3. 검증(서명 200)
|
|
sudo bash harden_pi.sh # 4. 하드닝(SSH 차단)
|
|
```
|
|
|
|
**하드닝 정책**(`harden_pi.sh`): SSH 완전 비활성 · 인바운드 전부 차단(nftables) ·
|
|
불필요 서비스 제거(avahi/bluetooth/cups 등) · 자동 보안 패치 · `config_local.py` 권한 600.
|
|
이 시스템은 outbound 전용이라 인바운드가 필요 없음. 재활성이 필요하면 SD boot 파티션에
|
|
빈 `ssh` 파일 생성(물리 접근 필요).
|
|
''')
|
|
|
|
# ── 서버 번들 ────────────────────────────────────────────────────────────────
|
|
server = DEPLOY / "01_server_cafe24"
|
|
copy_tree(ROOT / "php", server / WEB_DIR)
|
|
# 예시 파일은 혼동 방지를 위해 번들에서 제거 (완성본 config.local.php 를 쓴다)
|
|
(server / WEB_DIR / "config.local.example.php").unlink(missing_ok=True)
|
|
# var/ 는 런타임 상태(접속 IP·감사로그·하트비트·사진) — 빌드 머신의 로컬 파일이
|
|
# 번들에 유입되지 않도록 내용물을 비우고 빈 스켈레톤만 만든다.
|
|
var_dir = server / WEB_DIR / "var"
|
|
if var_dir.exists():
|
|
shutil.rmtree(var_dir)
|
|
var_dir.mkdir()
|
|
write_lf(var_dir / "index.html", "") # 디렉터리 목록 가드
|
|
|
|
# SQL 은 단일 파일만 — schema_sht30.sql 이 신규 설치 완전 스키마(누수 요소 포함).
|
|
# migration_*.sql 은 기존 서버 레트로핏용이라 신규 설치 번들에서 제외한다.
|
|
(server / "sql").mkdir(parents=True)
|
|
schema_src = (ROOT / "sql" / "schema_sht30.sql").read_text(encoding="utf-8")
|
|
write_lf(server / "sql" / "schema.sql",
|
|
"-- schema.sql — 신규 설치용 단일 스키마 (원본: sql/schema_sht30.sql)\n"
|
|
"-- 누수(leak) + 온습도(SHT30) 요소 전부 포함 — 이 파일 하나만 1회 실행하면 됨.\n"
|
|
"-- 이 파일 하나로 설치 완결 (테이블 전체 leak_ 접두사 — 같은 DB 의 다른 테이블과 격리).\n\n"
|
|
+ schema_src)
|
|
|
|
write_lf(server / WEB_DIR / "config.local.php", f'''<?php
|
|
// config.local.php — 자동 생성 운영 설정 (커밋/공유 금지)
|
|
// [CHANGE_ME] 3곳만 채우면 운영 가능: DB 계정, SMS 계정, SMS 수신자.
|
|
return [
|
|
// ── cafe24 MySQL (호스팅 패널 > DB 정보) ── [CHANGE_ME]
|
|
'DB_HOST' => 'localhost',
|
|
'DB_PORT' => 3306,
|
|
'DB_NAME' => 'CHANGE_ME',
|
|
'DB_USER' => 'CHANGE_ME',
|
|
'DB_PASS' => 'CHANGE_ME',
|
|
|
|
// ── 공유 비밀키 (Pi 3대와 동일 값 자동 주입됨 — 수정 불필요) ──
|
|
'API_KEY' => '{API_KEY}',
|
|
'ALLOW_LEGACY_BODY_SIGNATURE' => false,
|
|
|
|
// ── cafe24 SMS (사전 등록된 발신번호 필수) ── [CHANGE_ME]
|
|
'SMS_USER_ID' => 'CHANGE_ME',
|
|
'SMS_SECURE' => 'CHANGE_ME',
|
|
'SMS_SENDER' => '01000000000',
|
|
'SMS_RECIPIENTS' => [
|
|
'01000000000', // [CHANGE_ME] 경보 수신 번호
|
|
],
|
|
|
|
// ── 관리자 로그인 (비밀번호는 deploy/.secrets/admin_password.txt) ──
|
|
'ADMIN_USER' => 'admin',
|
|
'ADMIN_PASSWORD_HASH' => '{ADMIN_HASH}',
|
|
// TOTP 는 서버에서 1회 등록: setup_mfa.php?token=<아래 토큰> 접속 → 앱 등록
|
|
// → 표시된 시크릿을 ADMIN_TOTP_SECRET 에 넣고 → MFA_SETUP_TOKEN 은 '' 로 비울 것.
|
|
'ADMIN_TOTP_SECRET' => '',
|
|
'MFA_SETUP_TOKEN' => '{MFA_SETUP_TOKEN}',
|
|
|
|
// ── 누수 모듈 sensor_id (1번 지점 + 3번 신규 지점) ──
|
|
'LEAK_SENSOR_IDS' => [1, 3],
|
|
|
|
// ── 온습도 임계 (운영 권장값) ──
|
|
'METRIC_TEMP_HIGH_C' => 27,
|
|
'METRIC_TEMP_LOW_C' => 10,
|
|
'METRIC_RH_HIGH' => 60,
|
|
'METRIC_RH_LOW' => -1, // 저습 경보 비활성
|
|
'METRIC_DEWPOINT_MARGIN_C' => 0, // 결로 경보 비활성
|
|
'METRIC_ALERT_COOLDOWN_SEC' => 1800,
|
|
|
|
'SMS_LOG_RETENTION_DAYS' => 365,
|
|
'SENSOR_LOG_RETENTION_DAYS' => 365,
|
|
'SENSOR_METRIC_RETENTION_DAYS' => 365,
|
|
'ADMIN_AUDIT_RETENTION_DAYS' => 365,
|
|
];
|
|
''')
|
|
|
|
write_lf(server / "SETUP.md", f'''# 01_server_cafe24 설치
|
|
|
|
1. **DB 스키마**: cafe24 phpMyAdmin 에서 `sql/schema.sql` **하나만 1회 실행** → 테이블 7개 확인
|
|
(leak_sensor_log, leak_sensor_status, leak_sensor_metric, leak_sms_log, leak_ingest_nonce, leak_incident, leak_photo).
|
|
2. **업로드**: `{WEB_DIR}/` 폴더 전체를 `public_html/` 아래로 업로드.
|
|
3. **config.local.php 의 [CHANGE_ME] 3곳** 채우기: DB 계정 / SMS 계정 / SMS 수신번호.
|
|
4. **HTTPS 강제**: cafe24 호스팅 패널에서 설정 (필수 — 미설정 시 로그인 불가).
|
|
5. **관리자 로그인**: `{BASE_URL}/login.php`
|
|
- ID `admin` / 비밀번호는 `deploy/.secrets/admin_password.txt`
|
|
- 최초 1회 TOTP 등록: `{BASE_URL}/setup_mfa.php?token=<MFA_SETUP_TOKEN 값>` →
|
|
앱 등록 → 표시된 시크릿을 `ADMIN_TOTP_SECRET` 에 → `MFA_SETUP_TOKEN` 비우기.
|
|
6. **cron (1분)**: cafe24 크론탭에 `php /home/<계정>/public_html/{WEB_DIR}/cron_heartbeat.php`
|
|
(HTTP 방식이면 아래 URL 그대로 — CRON_SECRET 은 이미 계산되어 있음:
|
|
`{BASE_URL}/cron_heartbeat.php?key={CRON_SECRET}`)
|
|
7. **검증**: `setup_wizard.php` 전 항목 녹색 + 테스트 SMS 발송.
|
|
''')
|
|
|
|
# ── 최상위 README + gitignore 방어 ──────────────────────────────────────────
|
|
write_lf(DEPLOY / "README.md", f'''# deploy/ — 기기별 턴키 배포 번들 (자동 생성: tools/make_deploy_bundles.py)
|
|
|
|
⚠️ **API_KEY·관리자 비밀번호 포함 — 절대 커밋/공유 금지.** 비밀값: `.secrets/`
|
|
|
|
⚠️ **`.secrets/ 백업 필수`** — `git clean -fdx`·재클론·폴더 삭제 시 소멸. 재생성하면
|
|
새 API_KEY 가 발급되어 **이미 배포된 서버·Pi 전부와 불일치**(전 노드 수집 거부)하게 됨.
|
|
배포 직후 `.secrets/` 를 오프라인 저장소(USB 등)에 복사할 것.
|
|
|
|
| 폴더 | 대상 | sensor_id | 남은 수동 설정 |
|
|
|---|---|---|---|
|
|
| 01_server_cafe24 | cafe24 웹호스팅 | — | DB 계정, SMS 계정/번호, TOTP 1회 |
|
|
| 02_pi_a_leak_id1 | Pi-A 누수+카메라 | 1 | 없음 |
|
|
| 03_pi_b_sht30_id2 | Pi-B SHT30 온습도 | 2 | 없음 |
|
|
| 04_pi_c_leak_id3 | Pi-C 누수+카메라(신규 지점) | 3 | 없음 |
|
|
|
|
## 순서
|
|
1. `01_server_cafe24/SETUP.md` — 서버 먼저 (Pi 검증이 서버에 의존)
|
|
2. Pi 3대 공통 OS: Raspberry Pi Imager → **Bookworm 64-bit**, 사용자 `pi`, SSH 켬, WiFi/유선 설정
|
|
3. 각 Pi 폴더의 `SETUP.md` (복사 → install.sh → systemctl, 병렬 가능)
|
|
|
|
## 참고
|
|
- 서버 주소: `{BASE_URL}` — 도메인은 `tools/make_deploy_bundles.py` 상단 DOMAIN, 경로 폴더명은 `.secrets/web_dir.txt` 수정 후 재실행.
|
|
- 오프라인(폐쇄망) 설치가 필요하면: 인터넷 되는 Pi 1대에서 각 모듈 `./vendor.sh` 실행 후
|
|
생성된 `wheelhouse/`+`debs/` 를 다른 Pi 번들 폴더에 복사 (install.sh 가 자동 오프라인 전환).
|
|
- 전체 배포 계획: `docs/DEPLOYMENT_PLAN_RPI4x3.md`
|
|
''')
|
|
|
|
# (deploy/.gitignore 는 비밀값 생성 전에 이미 작성됨 — 순서 보장)
|
|
|
|
# ── LF 정규화 ────────────────────────────────────────────────────────────────
|
|
normalize_lf(DEPLOY)
|
|
|
|
print("deploy/ 생성 완료")
|
|
# cp949 콘솔(Windows 기본)에서도 죽지 않도록 em-dash 등 비호환 문자 금지.
|
|
print("[주의] deploy/.secrets/ 를 오프라인 저장소에 백업할 것 (git clean -fdx 로 소멸).")
|
|
print(" 재생성 시 새 API_KEY 가 배포된 전 노드와 불일치하게 됨.")
|
|
print(f" API_KEY : {API_KEY[:8]}... (deploy/.secrets/api_key.txt)")
|
|
print(f" admin password : deploy/.secrets/admin_password.txt")
|
|
print(f" 서버 URL : {BASE_URL}")
|
|
for name, _, _ in BUNDLES:
|
|
print(f" - deploy/{name}")
|