diff --git a/README.md b/README.md index e894d4d..bdc1809 100644 --- a/README.md +++ b/README.md @@ -71,7 +71,12 @@ cargo build --release ``` 앱 첫 화면·상단바·로그 첫 줄에 `v0.1.0 (커밋 · 날짜)` 형태로 실행 중인 빌드가 표시된다. -업데이트 절차와 "고쳤는데 런처에서 안 바뀐다" 문제 해결은 **[docs/updating.md](docs/updating.md)** 참고. + +문서: + +- **[docs/updating.md](docs/updating.md)** — hostmover 업데이트 절차, "고쳤는데 런처에서 안 바뀐다" 해결 +- **[docs/bulk-update.md](docs/bulk-update.md)** — 일괄 업데이트의 안정 릴리스 원칙, 결과 보고서/메일, 플러그인 골라 업데이트 +- **[docs/rx-cli.md](docs/rx-cli.md)** — Rhymix 터미널 도구 rx-cli 배포와 사용 ### macOS diff --git a/docs/bulk-update.md b/docs/bulk-update.md new file mode 100644 index 0000000..dbefb3e --- /dev/null +++ b/docs/bulk-update.md @@ -0,0 +1,135 @@ +# 일괄 업데이트 — 안정 릴리스 원칙 + +서버의 모든 사이트를 한 번에 올리는 기능의 정책과 사용법. + +--- + +## 1. 원칙: master 가 아니라 릴리스 태그 + +**`master`(또는 `main`)는 개발 trunk 다. 프로덕션에 올리면 안 된다.** + +- `WordPress/WordPress` 의 master → 다음 버전의 nightly(알파) +- `rhymix/rhymix`, `gnuboard/gnuboard5` 의 master → 개발 중인 코드 + +그래서 업데이트는 이렇게 한다: + +| CMS | 방식 | 기준 | +|---|---|---| +| WordPress | wp-cli (`core`/`plugin`/`theme`/`language update`) | wp.org 안정 릴리스 | +| git 설치본 | `fetch tag` + `reset --hard <태그>` | `x.y[.z]` 형태의 최신 릴리스 태그 | +| 비-git Rhymix/그누보드 | `clone --branch <태그>` 후 rsync 오버레이 | 최신 릴리스 태그 | + +릴리스 태그가 아예 없는 저장소만 브랜치로 폴백하고, 그 사실을 로그와 보고서에 남긴다. + +태그 선택은 `ops.rs` 의 `hm_latest_tag` 가 한다: + +```bash +git ls-remote --tags --refs <저장소> \ + | awk -F/ '{print $NF}' \ + | grep -E '^v?[0-9]+\.[0-9]+(\.[0-9]+)?$' \ + | sort -V | tail -n 1 +``` + +`-rc1`, `-beta` 같은 접미사가 붙은 태그는 정규식에서 걸러진다. + +실측(2026-07-25): rhymix `2.1.35` · gnuboard5 `v5.7.5` · xe-core `1.11.22` · WordPress `7.0.2` + +### 개발버전이 이미 올라간 WordPress + +`wp core version` 이 `alpha`/`beta`/`RC`/`-src` 를 포함하면, api.wordpress.org 가 알려주는 +안정 릴리스로 `--force` 되돌린다. 버전 번호상으로는 알파가 더 높아서 그냥 `core update` 로는 +내려가지 않기 때문이다. + +--- + +## 2. 2026-07-25 사고 — WordPress 4곳이 nightly 로 덮임 + +`설정 > 일괄 업데이트` 버튼이 계정별 업데이트와 **다른 코드**를 돌리고 있었다. + +```bash +# 옛 build_bulk_git_update — CMS 를 가리지 않는다 +if [ -d "$WR/.git" ]; then + git fetch --depth=1 origin "$BR" && git reset --hard FETCH_HEAD +``` + +`.git` 만 있으면 origin 이 어디든 강제로 당겼다. 그래서 origin 이 `WordPress/WordPress` 인 +사이트가 개발 trunk 로 덮였다: + +- health.logicmaru.co.kr · dental.eond.com · dev.koreanhub24.com · koreanhub.oiii.kr + +계정별 업데이트가 쓰던 `SITE_UPDATE_STEP` 에는 원래 `wp-load.php` 를 먼저 보는 올바른 분기가 +있었다. **두 경로가 갈라져 있었던 것**이 원인이라, 지금은 하나로 합쳤다. + +교훈: 같은 일을 하는 코드가 두 벌 있으면 한쪽만 고쳐진다. + +### 남은 뒷정리 + +nightly 로 덮인 사이트에는 `.git`(origin=WordPress/WordPress)이 남아 있다. 그대로 두면 +누군가 git 으로 다시 당길 수 있으므로, 사이트가 정상인지 확인한 뒤 `.git` 을 지우는 게 좋다. +업데이트를 돌리면 보고서에 `※ .git(WordPress) 남아 있음 — 제거 권장` 으로 표시된다. + +--- + +## 3. 결과 보고서 / 메일 + +작업이 끝나면 로그 맨 아래에 요약 표가 나온다. 수백 줄을 다 읽을 필요 없이 이것만 보면 된다. + +``` +════════════════════════════════════════════════════════════════════ + 전체 사이트 일괄 업데이트 (안정 릴리스) + mars.eond.com · 2026-07-25 08:01:45 +════════════════════════════════════════════════════════════════════ + 성공 4 건너뜀 1 실패 1 +──────────────────────────────────────────────────────────────────── + 사이트 방식 결과 버전 +──────────────────────────────────────────────────────────────────── + dental.eond.com wp OK 7.1-alpha → 7.0.2 ※ 개발버전→안정 되돌림 + eond.com git-tag OK 9eb6931f80 → 2.1.35 + korean.haus overlay OK - → 2.1.35 + swslr.com git-branch FAIL 9ad995279 → - ※ 릴리스 태그 없는 저장소 + pub.eond.com unknown SKIP CMS 를 판별하지 못함 +════════════════════════════════════════════════════════════════════ +``` + +방식 표기: + +| 값 | 뜻 | +|---|---| +| `wp` | wp-cli 로 처리 | +| `git-tag` | 릴리스 태그로 reset | +| `git-branch` | 태그가 없어 브랜치로 폴백 | +| `overlay` | 비-git 설치본에 최신본 덮어쓰기 | +| `unknown` | CMS 판별 실패 → 손대지 않음 | + +**메일**: 카드의 "결과 메일 받기" 에 주소를 넣으면 서버가 같은 보고서를 메일로 보낸다 +(`mail` → `sendmail` 순으로 시도). 비워두면 발송하지 않는다. 서버에 둘 다 없으면 그 사실을 +로그에 남기고 보고서는 그대로 출력된다. + +--- + +## 4. WordPress 플러그인만 골라서 업데이트 + +망보드처럼 여러 사이트에 공통으로 깔린 플러그인 하나만 올릴 때. + +설정 > 일괄 업데이트 > "WordPress 플러그인 골라서 일괄 업데이트" + +1. **슬러그** — `wp-content/plugins/` 아래 폴더 이름 (예: `mangboard`) +2. **대상 계정** — 비우면 서버 전체 +3. **점검만** 을 먼저 눌러 어디에 몇 버전이 깔려 있는지 확인 (읽기 전용, 확인 모달 없음) +4. 그다음 **업데이트 실행** + +``` + alpha.com 1.2.0 active → 1.3.0 있음 + beta.com 1.2.0 active → 1.3.0 있음 + +== 점검 완료: 설치됨 2 · 미설치 1 == +``` + +자체 업데이터를 쓰는 상용 플러그인은 wp-cli 로 안 올라갈 수 있고, 그 경우 `FAIL` 로 보고된다. + +--- + +## 5. 관련 + +- rx-cli(Rhymix CLI) 배포: [rx-cli.md](rx-cli.md) +- hostmover 자체 업데이트: [updating.md](updating.md) diff --git a/docs/rx-cli.md b/docs/rx-cli.md new file mode 100644 index 0000000..00d503b --- /dev/null +++ b/docs/rx-cli.md @@ -0,0 +1,94 @@ +# rx-cli — Rhymix 터미널 도구 + +wp-cli 의 Rhymix 판. 직접 만든 도구이고, hostmover 에서 서버로 배포할 수 있다. + +--- + +## 1. 소스 위치 + +``` +/home/dell/dev/rx/modules/rxdashboard/bin/rx +``` + +rxdashboard 모듈(Rhymix 대시보드)에 딸린 단일 PHP 파일이다. 의존성 없이 이 파일 하나면 동작한다. + +hostmover 는 **설정 > Rhymix 소스** 경로(`rx_source_local`) 하위의 +`modules/rxdashboard/bin/rx` 를 읽는다. 그 설정이 `/home/dell/dev/rx` 를 가리키면 위 경로가 된다. + +--- + +## 2. 배포 + +**hostmover**: 설정 > 일괄 업데이트 탭 > "rx-cli 배포" 카드 > `rx-cli 배포` 버튼. + +하는 일: + +1. 기존 `/usr/local/bin/rx` 가 있으면 `rx.bak.<타임스탬프>` 로 백업 +2. 파일 내용을 그대로 `/usr/local/bin/rx` 에 쓰고 `755 root:root` +3. `php -l` 로 문법 검사 (실패하면 즉시 중단) +4. 서버의 Rhymix 사이트를 하나 찾아 `rx version` 을 시험 실행 + +수동으로 하려면: + +```bash +scp /home/dell/dev/rx/modules/rxdashboard/bin/rx 서버:/tmp/rx +ssh 서버 'sudo install -m755 -o root -g root /tmp/rx /usr/local/bin/rx && php -l /usr/local/bin/rx' +``` + +### 심볼릭 링크 대신 복사인 이유 + +파일 머리말에는 `ln -s` 로 심볼릭 링크를 걸라고 적혀 있는데, 그건 **개발 PC 기준**이다. +서버에는 Rhymix 사이트가 수십 개라 특정 사이트의 rxdashboard 를 가리키게 하면 그 사이트를 +지우는 순간 rx 가 깨진다. 그래서 파일 자체를 복사한다. + +`rx` 는 실행 위치를 이렇게 찾는다: + +1. 스크립트 위치 기준 3단계 위(`modules/rxdashboard/bin/rx` → Rhymix 루트) — 복사본에서는 실패 +2. **현재 작업 디렉터리에서 위로 올라가며** `index.php` + `common/` 이 있는 곳 — 이걸로 동작 + +즉 복사해 두면 wp-cli 처럼 아무 Rhymix 사이트에서나 쓸 수 있다. + +--- + +## 3. 사용 + +**`--path` 옵션이 없다.** 반드시 사이트 디렉터리 안에서 실행한다. + +```bash +cd /home/<유저>/web/<도메인>/public_html +rx version +``` + +| 명령 | 하는 일 | +|---|---| +| `rx version` | 코어/PHP 버전 | +| `rx cache flush` | 전체 캐시 비우기 | +| `rx module list` | 설치된 모듈 목록 | +| `rx module install ` | 모듈 설치 | +| `rx module delete ` | 모듈 삭제 | +| `rx update check` | 코어 최신 버전 확인 | +| `rx db cli` | 설정된 DB 로 mysql 접속 | +| `rx help` | 도움말 | + +사이트 소유자 권한으로 돌려야 파일 소유권이 어긋나지 않는다: + +```bash +cd /home/eond/web/eond.com/public_html +sudo -u eond rx cache flush +``` + +--- + +## 4. 주의 + +- Rhymix 루트를 못 찾으면 `Rhymix 루트를 찾을 수 없습니다` 로 종료한다. `cd` 를 확인할 것. +- `rx db cli` 는 사이트 설정의 DB 자격증명으로 접속하므로, 공용 터미널에서 쓰지 말 것. +- root 로 실행하면 캐시 파일이 root 소유로 생겨 사이트가 깨질 수 있다. `sudo -u <유저>` 를 쓴다. +- 업데이트하려면 소스를 고친 뒤 hostmover 에서 다시 배포하면 된다(기존 파일은 자동 백업). + +--- + +## 5. 관련 + +- 일괄 업데이트 정책: [bulk-update.md](bulk-update.md) +- hostmover 자체 업데이트: [updating.md](updating.md) diff --git a/src/app.rs b/src/app.rs index 4466d93..648b8f1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -408,6 +408,11 @@ pub struct App { /// 설정의 Rhymix 모듈 삭제 도구 입력값 mod_del_name: String, mod_del_acct: String, + /// WordPress 특정 플러그인 일괄 업데이트 — 슬러그 / 대상 계정(비우면 서버 전체) + wp_plug_slug: String, + wp_plug_acct: String, + /// 일괄 업데이트 결과 보고서를 받을 메일 주소 (비우면 발송 안 함) + bulk_mail: String, disk_path: String, disk_alert_email: String, /// WordPress 플러그인/테마 버전비교 스캔 결과 캐시 — (도메인id, 종류) → 행들. @@ -500,6 +505,9 @@ impl App { last_edit: 0.0, mod_del_name: String::new(), mod_del_acct: String::new(), + wp_plug_slug: String::new(), + wp_plug_acct: String::new(), + bulk_mail: String::new(), disk_path: "/backup".to_string(), disk_alert_email: "eond@eond.com".to_string(), wp_cache: std::collections::HashMap::new(), @@ -747,6 +755,9 @@ impl App { let mut do_disk_scrub: Option = None; let mut do_disk_monitor: Option = None; let mut do_bulk_update: Option = None; + // (실행할지, 점검만인지) — 점검은 읽기 전용이라 확인 모달 없이 바로 돈다 + let mut do_wp_plugin: Option<(bool, bool)> = None; + let mut do_rx_cli: Option = None; let mut do_module_delete: Option = None; let mut do_backup = false; let mut do_restore: Option = None; @@ -878,10 +889,17 @@ impl App { } SettingsTab::BulkUpdate => { card(ui, |ui| { - ui.strong(format!("{} Rhymix/그누보드 일괄 업데이트", ph::ARROWS_CLOCKWISE)); - ui.label(egui::RichText::new("서버의 모든 vhost 웹루트(/home/*/web/*/public_html)를 순회합니다. .git 있으면 얕은 업데이트(depth=1), 없으면 '수동/선택 필요'로 보고만 합니다.").weak()); + ui.strong(format!("{} 전체 사이트 일괄 업데이트 (안정 릴리스)", ph::ARROWS_CLOCKWISE)); + ui.label(egui::RichText::new("서버의 모든 vhost 웹루트(/home/*/web/*/public_html)를 순회하며 CMS 유형별로 업데이트합니다. WordPress=wp-cli(core·플러그인·테마), git 설치본=최신 릴리스 태그, 비-git Rhymix/그누보드=최신본 오버레이.").weak()); + ui.label(egui::RichText::new("※ master(개발 trunk)가 아니라 릴리스 태그만 올립니다. 개발버전(alpha/beta/RC)이 올라간 WordPress는 안정 릴리스로 되돌립니다.").weak()); ui.label(egui::RichText::new("※ 서버 SSH(sudo 유저) 설정이 필요합니다.").weak()); ui.add_space(6.0); + ui.horizontal(|ui| { + grid_label(ui, "결과 메일 받기"); + ui.add(egui::TextEdit::singleline(&mut self.bulk_mail).hint_text("비우면 발송 안 함 (예: eond@eond.com)").desired_width(240.0).margin(FIELD_MARGIN)) + .on_hover_text("작업이 끝나면 서버가 요약 보고서를 이 주소로 메일 발송합니다 (mail/sendmail 사용)"); + }); + ui.add_space(6.0); ui.horizontal(|ui| { if ui.add_enabled(!running, btn_primary(format!("{} 일괄 업데이트 실행", ph::ARROWS_CLOCKWISE))).clicked() { do_bulk_update = Some(true); @@ -891,6 +909,50 @@ impl App { } }); }); + card(ui, |ui| { + ui.strong(format!("{} WordPress 플러그인 골라서 일괄 업데이트", ph::PUZZLE_PIECE)); + ui.label(egui::RichText::new("망보드처럼 여러 사이트에 공통으로 깔린 플러그인 하나만 골라 한 번에 올립니다. 먼저 '점검만'으로 어디에 몇 버전이 깔려 있는지 확인하세요.").weak()); + ui.add_space(6.0); + egui::Grid::new("wp_plug_grid").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + grid_label(ui, "플러그인 슬러그"); + ui.add(egui::TextEdit::singleline(&mut self.wp_plug_slug).hint_text("예: mangboard (wp-content/plugins/ 폴더 이름)").desired_width(260.0).margin(FIELD_MARGIN)); + ui.end_row(); + grid_label(ui, "대상 계정"); + ui.add(egui::TextEdit::singleline(&mut self.wp_plug_acct).hint_text("비우면 서버 전체 / 입력 시 해당 계정만").desired_width(260.0).margin(FIELD_MARGIN)); + ui.end_row(); + }); + ui.add_space(6.0); + let enabled = !running && !self.wp_plug_slug.trim().is_empty(); + ui.horizontal(|ui| { + if ui.add_enabled(enabled, egui::Button::new(format!("{} 점검만 (읽기 전용)", ph::MAGNIFYING_GLASS))).clicked() { + do_wp_plugin = Some((true, true)); + } + if ui.add_enabled(enabled, btn_primary(format!("{} 업데이트 실행", ph::ARROWS_CLOCKWISE))).clicked() { + do_wp_plugin = Some((true, false)); + } + if ui.add_enabled(enabled, egui::Button::new(format!("{} 명령어 보기", ph::FILE_TEXT))).clicked() { + do_wp_plugin = Some((false, false)); + } + }); + ui.add_space(4.0); + ui.label(egui::RichText::new("자체 업데이터를 쓰는 상용 플러그인은 wp-cli 로 올라가지 않을 수 있습니다(실패로 보고됨).").weak()); + }); + card(ui, |ui| { + ui.strong(format!("{} rx-cli 배포 (Rhymix 터미널 도구)", ph::TERMINAL_WINDOW)); + ui.label(egui::RichText::new("wp-cli 처럼 쓰는 Rhymix CLI 를 서버 /usr/local/bin/rx 에 설치합니다. 소스는 '설정 > Rhymix 소스' 경로의 modules/rxdashboard/bin/rx 입니다.").weak()); + ui.label(egui::RichText::new("설치 후: cd <웹루트> && rx version / rx cache flush / rx module list / rx update check").weak()); + ui.add_space(6.0); + ui.horizontal(|ui| { + if ui.add_enabled(!running, btn_primary(format!("{} rx-cli 배포", ph::UPLOAD_SIMPLE))) + .on_hover_text("서버에 설치 + php -l 문법검사 + Rhymix 사이트에서 rx version 시험 실행 (기존 파일은 백업)") + .clicked() { do_rx_cli = Some(true); } + if ui.button(format!("{} 명령어 보기", ph::FILE_TEXT)).clicked() { + do_rx_cli = Some(false); + } + }); + ui.add_space(4.0); + ui.label(egui::RichText::new("rx 는 --path 를 받지 않고 현재 디렉터리에서 Rhymix 루트를 찾습니다. 반드시 사이트 폴더 안에서 실행하세요.").weak()); + }); } SettingsTab::ModuleDelete => { card(ui, |ui| { @@ -1178,7 +1240,7 @@ impl App { } } if let Some(run) = do_bulk_update { - match ops::build_bulk_git_update(&self.store.settings) { + match ops::build_bulk_git_update(&self.store.settings, &self.bulk_mail) { Ok(job) => { if run { self.eond_confirm = Some(job); @@ -1193,6 +1255,46 @@ impl App { } } } + if let Some(run) = do_rx_cli { + match ops::build_rx_cli_install(&self.store.settings) { + Ok(job) => { + if run { + self.eond_confirm = Some(job); + } else { + self.cmd_view = Some(CmdView { title: job.title.clone(), command: render_command(&job, self.show_pw) }); + } + } + Err(e) => { + self.log.push(format!("rx-cli 배포: {e}")); + self.status = format!("오류: {e}"); + self.last_ok = Some(false); + } + } + } + if let Some((run, dry)) = do_wp_plugin { + let (slug, acct) = (self.wp_plug_slug.clone(), self.wp_plug_acct.clone()); + match ops::build_wp_plugin_bulk_update(&self.store.settings, &slug, &acct, dry) { + Ok(job) => { + if !run { + self.cmd_view = Some(CmdView { title: job.title.clone(), command: render_command(&job, self.show_pw) }); + } else if dry { + // 점검은 서버를 바꾸지 않으므로 확인 모달 없이 실행 + self.running = true; + self.last_ok = None; + self.status = format!("플러그인 점검 중: {}", slug.trim()); + let ctx2 = ctx.clone(); + ops::spawn(job, self.tx.clone(), move || ctx2.request_repaint()); + } else { + self.eond_confirm = Some(job); + } + } + Err(e) => { + self.log.push(format!("플러그인 일괄 업데이트: {e}")); + self.status = format!("오류: {e}"); + self.last_ok = Some(false); + } + } + } if let Some(run) = do_module_delete { let (name, acct) = (self.mod_del_name.clone(), self.mod_del_acct.clone()); match ops::build_module_delete(&self.store.settings, &name, &acct) { diff --git a/src/ops.rs b/src/ops.rs index 2590ea6..e1d07e5 100644 --- a/src/ops.rs +++ b/src/ops.rs @@ -2006,7 +2006,8 @@ fn build_git_cms_update(server: &Site, c: &CmsInstall, s: &Settings, domain_name /// 서버 전체 일괄 업데이트: sudo 유저(예: tong)로 SSH → sudo 로 root → /home/*/web/*/public_html 순회. /// .git 있으면 소유 유저로 얕은 업데이트(depth=1), 없으면 "선택 필요"로 보고만. -pub fn build_bulk_git_update(s: &Settings) -> Result { +/// `mailto` 가 비어 있지 않으면 서버에서 결과 보고서를 그 주소로 메일 발송한다. +pub fn build_bulk_git_update(s: &Settings, mailto: &str) -> Result { let host = if s.ssh_host.trim().is_empty() { s.hestia_host.trim() } else { s.ssh_host.trim() }; if host.is_empty() { return Err("서버 SSH 호스트가 비어 있습니다 (설정)".into()); } let user = s.ssh_user.trim(); @@ -2020,40 +2021,182 @@ pub fn build_bulk_git_update(s: &Settings) -> Result { ssh_port: s.ssh_port.trim().to_string(), ..Default::default() }; - // 단일 인용부호 heredoc 안에서 원격 bash 로 실행됨 (로컬 확장 없음) - let raw = r#"set -o pipefail + // 단일 인용부호 heredoc 안에서 원격 bash 로 실행됨 (로컬 확장 없음). + // ※ 예전에는 여기서 .git 존재만 보고 무조건 reset --hard 했다. 그 탓에 origin 이 + // WordPress/WordPress 인 사이트가 nightly 로 덮였다(2026-07-25). 지금은 계정별 + // 업데이트와 같은 SITE_UPDATE_STEP 을 쓴다 — WordPress 는 wp-cli, git 은 릴리스 태그. + let raw = format!( + r#"set -o pipefail export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin" -echo "== Rhymix/그누보드 일괄 업데이트 (서버 전체 git 사이트) ==" +RPT_TITLE="전체 사이트 일괄 업데이트 (안정 릴리스)" +MAILTO={mailto} +echo "== $RPT_TITLE ==" shopt -s nullglob -OK=0; SKIP=0; FAIL=0 +{tagfn} +OK=0; SKIP=0; FAIL=0; RPT="" for WR in /home/*/web/*/public_html; do [ -d "$WR" ] || continue DOM="$(basename "$(dirname "$WR")")" OWN="$(stat -c %U "$WR" 2>/dev/null)" - if [ -d "$WR/.git" ]; then - echo "── [$DOM] git 얕은 업데이트 (유저 $OWN) ──" - BR="$(sudo -u "$OWN" git -C "$WR" rev-parse --abbrev-ref HEAD 2>/dev/null)"; [ -z "$BR" -o "$BR" = HEAD ] && BR=master - if sudo -u "$OWN" git -C "$WR" fetch --depth=1 origin "$BR" && sudo -u "$OWN" git -C "$WR" reset --hard FETCH_HEAD; then - sudo -u "$OWN" git -C "$WR" reflog expire --expire=now --all 2>/dev/null || true - sudo -u "$OWN" git -C "$WR" gc --prune=now --quiet 2>/dev/null || true - echo " ✓ $(sudo -u "$OWN" git -C "$WR" rev-parse --short HEAD 2>/dev/null) (.git $(du -sh "$WR/.git" 2>/dev/null | awk '{print $1}'))" - OK=$((OK+1)) - else - echo " ✗ 실패: $DOM"; FAIL=$((FAIL+1)) - fi + [ -z "$OWN" ] && continue +{step} + chown -R "$OWN:$OWN" "$WR" 2>/dev/null || true +done +{footer}"#, + mailto = sq(mailto.trim()), + tagfn = STABLE_TAG_FN, + step = SITE_UPDATE_STEP, + footer = REPORT_FOOTER, + ); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); + Ok(Job { + title: "전체 사이트 일괄 업데이트 (안정 릴리스)".into(), + script, + sshpass, + env, + note: format!("{user}@{host} → sudo 로 root 후 /home/*/web/*/public_html 순회 · WordPress=wp-cli, git=최신 릴리스 태그, 비-git=오버레이"), + }) +} + +/// rx-cli (Rhymix 터미널 도구, wp-cli 스타일) 를 서버 `/usr/local/bin/rx` 에 배포한다. +/// +/// 소스는 설정의 'Rhymix 소스' 경로 하위 `modules/rxdashboard/bin/rx`. +/// rx-cli 는 `--path` 를 받지 않고 **현재 디렉터리에서 위로 올라가며** Rhymix 루트를 찾으므로, +/// 심볼릭 링크가 아니라 파일 자체를 복사해 두고 `cd <웹루트> && rx <명령>` 으로 쓴다. +/// 배포 후 서버의 Rhymix 사이트 한 곳에서 `rx version` 을 시험 실행해 동작을 확인한다. +pub fn build_rx_cli_install(s: &Settings) -> Result { + let src = s.rx_source_local.trim(); + if src.is_empty() { + return Err("설정 > HestiaCP 연동 의 'Rhymix 소스' 경로가 비어 있습니다 (예: /home/dell/dev/rx)".into()); + } + let path = Path::new(src).join("modules").join("rxdashboard").join("bin").join("rx"); + let body = std::fs::read_to_string(&path) + .map_err(|e| format!("rx-cli 를 읽지 못했습니다 ({}): {e}", path.display()))?; + if body.is_empty() { + return Err(format!("rx-cli 가 비어 있습니다: {}", path.display())); + } + // 중첩 heredoc 마커가 본문에 섞여 있으면 스크립트가 깨진다 + if body.contains("HM_RX_EOF") || body.contains("HM_EOF") { + return Err("rx-cli 본문에 heredoc 마커(HM_RX_EOF/HM_EOF)가 있어 이 방식으로 배포할 수 없습니다".into()); + } + let srv = ssh_admin_site(s)?; + let raw = format!( + r#"set -e +export PATH="$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin" +echo "== rx-cli 배포 → /usr/local/bin/rx ==" +[ -f /usr/local/bin/rx ] && cp -a /usr/local/bin/rx "/usr/local/bin/rx.bak.$(date +%Y%m%d%H%M%S)" && echo "기존 rx 백업함" +cat > /usr/local/bin/rx <<'HM_RX_EOF' +{body} +HM_RX_EOF +chmod 755 /usr/local/bin/rx +chown root:root /usr/local/bin/rx +echo "설치 완료: $(ls -la /usr/local/bin/rx)" +echo +echo "-- PHP 문법 검사 --" +php -l /usr/local/bin/rx || {{ echo "✗ 문법 오류 — 배포한 파일을 확인하세요"; exit 1; }} +echo +echo "-- Rhymix 사이트에서 시험 실행 --" +shopt -s nullglob +TESTED=0 +for WR in /home/*/web/*/public_html; do + [ -d "$WR/common/framework" ] || [ -f "$WR/common/constants.php" ] || continue + OWN="$(stat -c %U "$WR" 2>/dev/null)"; [ -z "$OWN" ] && continue + echo " 대상: $WR (유저 $OWN)" + (cd "$WR" && sudo -u "$OWN" /usr/local/bin/rx version 2>&1 | head -n 6) + TESTED=1 + break +done +[ "$TESTED" = 0 ] && echo " (서버에서 Rhymix 사이트를 찾지 못해 시험 실행은 건너뜀)" +echo +echo "== 사용법 ==" +echo " cd /home/<유저>/web/<도메인>/public_html && rx version" +echo " rx cache flush · rx module list · rx update check · rx db cli · rx help" +echo " ※ rx 는 --path 를 받지 않는다. 반드시 Rhymix 디렉터리 안에서 실행할 것." +"#, + body = body.trim_end(), + ); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); + Ok(Job { + title: "rx-cli 배포 (/usr/local/bin/rx)".into(), + script, + sshpass, + env, + note: format!("{} → sudo 로 rx-cli 설치 + php -l 검사 + Rhymix 사이트에서 rx version 시험", path.display()), + }) +} + +/// 특정 WordPress 플러그인만 골라 일괄 처리 (서버 전체 또는 한 계정). +/// 망보드처럼 여러 사이트에 공통으로 깔린 플러그인을 한 번에 올릴 때 쓴다. +/// `dry=true` 면 어디에 몇 버전이 깔려 있는지 **점검만** 하고 아무것도 바꾸지 않는다. +pub fn build_wp_plugin_bulk_update(s: &Settings, slug: &str, account: &str, dry: bool) -> Result { + let sl = slug.trim(); + if !is_safe_name(sl) { + return Err("플러그인 슬러그는 영숫자/._- 만 허용됩니다 (예: mangboard)".into()); + } + let acct = account.trim(); + if !acct.is_empty() && !is_safe_name(acct) { + return Err("계정 이름은 영숫자/._- 만 허용됩니다".into()); + } + let srv = ssh_admin_site(s)?; + let home = if acct.is_empty() { "/home/*".to_string() } else { format!("/home/{acct}") }; + let scope = if acct.is_empty() { "서버 전체".to_string() } else { format!("계정 {acct}") }; + let mode = if dry { "점검" } else { "업데이트" }; + let head = format!( + "set +e\nexport PATH=\"$PATH:/usr/local/bin:/usr/bin:/bin:/usr/local/sbin\"\nSLUG={slug}\nDRY={dry}\nGLOB={home}\n", + slug = sq(sl), + dry = if dry { "1" } else { "0" }, + home = sq(&home), + ); + // GLOB 은 따옴표 없이 전개해야 /home/* 가 확장된다 (값은 위에서 검증된 안전한 문자열) + let body = r#"shopt -s nullglob +echo "== WordPress 플러그인 [$SLUG] $([ "$DRY" = 1 ] && echo 점검 || echo 일괄업데이트) ==" +WP=/usr/local/bin/wp +if ! [ -x "$WP" ]; then echo "wp-cli 설치"; curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o "$WP" 2>/dev/null && chmod +x "$WP"; fi +FOUND=0; OK=0; NOUP=0; FAIL=0; NONE=0 +for WR in $GLOB/web/*/public_html; do + [ -d "$WR" ] || continue + [ -f "$WR/wp-load.php" ] || continue + DOM="$(basename "$(dirname "$WR")")" + OWN="$(stat -c %U "$WR" 2>/dev/null)"; [ -z "$OWN" ] && continue + CUR="$(sudo -u "$OWN" "$WP" --path="$WR" plugin get "$SLUG" --field=version 2>/dev/null)" + if [ -z "$CUR" ]; then NONE=$((NONE+1)); continue; fi + FOUND=$((FOUND+1)) + STAT="$(sudo -u "$OWN" "$WP" --path="$WR" plugin get "$SLUG" --field=status 2>/dev/null)" + AVAIL="$(sudo -u "$OWN" "$WP" --path="$WR" plugin list --name="$SLUG" --field=update_version 2>/dev/null | head -n 1)" + if [ "$DRY" = 1 ]; then + printf ' %-34s %-10s %s%s\n' "$DOM" "$CUR" "$STAT" "$([ -n "$AVAIL" ] && echo " → $AVAIL 있음")" + continue + fi + if [ -z "$AVAIL" ]; then + echo "── [$OWN/$DOM] $CUR — 새 버전 없음"; NOUP=$((NOUP+1)); continue + fi + echo "── [$OWN/$DOM] $CUR → $AVAIL ──" + if sudo -u "$OWN" "$WP" --path="$WR" plugin update "$SLUG"; then + NEW="$(sudo -u "$OWN" "$WP" --path="$WR" plugin get "$SLUG" --field=version 2>/dev/null)" + chown -R "$OWN:$OWN" "$WR/wp-content/plugins/$SLUG" 2>/dev/null || true + echo " OK $CUR → $NEW"; OK=$((OK+1)) else - echo "── [$DOM] git 아님 → 수동/선택 필요: $WR"; SKIP=$((SKIP+1)) + echo " FAIL (자체 업데이터를 쓰는 상용 플러그인은 wp-cli 로 못 올릴 수 있음)"; FAIL=$((FAIL+1)) fi done -echo "== 완료: 업데이트 $OK · git아님(선택필요) $SKIP · 실패 $FAIL ==" +echo +if [ "$DRY" = 1 ]; then + echo "== 점검 완료: 설치됨 $FOUND · 미설치 $NONE ==" +else + echo "== 완료: 업데이트 $OK · 이미최신 $NOUP · 실패 $FAIL · 미설치 $NONE ==" +fi "#; - let (script, sshpass, env) = eondcms_exec(&srv, raw, false, true); + let raw = format!("{head}{body}"); + let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true); Ok(Job { - title: "Rhymix/그누보드 일괄 업데이트 (서버 git 사이트)".into(), + title: format!("WordPress 플러그인 {mode} : {sl} ({scope})"), script, sshpass, env, - note: format!("{user}@{host} → sudo 로 root 후 /home/*/web/*/public_html 순회 (git=얕은 업데이트, 비-git=보고)"), + note: format!( + "{home}/web/*/public_html 중 WordPress 사이트만 순회 · wp-cli plugin {}", + if dry { "get/list (읽기 전용)" } else { "update" } + ), }) } @@ -2305,38 +2448,143 @@ pub fn scan_account_sites(s: &Settings, account: &str) -> Result → 최신 릴리스 태그(없으면 빈 문자열) +hm_latest_tag() { + sudo -u "$1" git ls-remote --tags --refs "$2" 2>/dev/null \ + | awk -F/ '{print $NF}' \ + | grep -E '^v?[0-9]+\.[0-9]+(\.[0-9]+)?$' \ + | sort -V | tail -n 1 +} +# hm_wp_stable → wordpress.org 가 알려주는 최신 안정 릴리스 +hm_wp_stable() { + curl -fsSL https://api.wordpress.org/core/version-check/1.7/ 2>/dev/null \ + | tr ',' '\n' | grep -m1 '"current"' | cut -d'"' -f4 +} +"#; + +/// 작업 끝에 사람이 읽기 쉬운 요약 보고서를 찍고, $MAILTO 가 있으면 메일로도 보낸다. +/// 앞서 $RPT(사이트별 한 줄 누적)·$OK·$SKIP·$FAIL·$RPT_TITLE 이 채워져 있어야 한다. +/// 로그 수백 줄을 다 읽지 않아도 무엇이 바뀌고 무엇이 실패했는지 이 블록만 보면 된다. +const REPORT_FOOTER: &str = r#" +HOSTN="$(hostname -f 2>/dev/null || hostname)" +WHEN="$(date '+%Y-%m-%d %H:%M:%S')" +BAR="════════════════════════════════════════════════════════════════════════" +SUB="────────────────────────────────────────────────────────────────────────" +REPORT="$BAR + $RPT_TITLE + $HOSTN · $WHEN +$BAR + 성공 $OK 건너뜀 $SKIP 실패 $FAIL +$SUB + 사이트 방식 결과 버전 +$SUB +$RPT$BAR" +echo +echo "$REPORT" +if [ -n "$MAILTO" ]; then + SUBJ="[hostmover] $RPT_TITLE — 성공 $OK / 실패 $FAIL ($HOSTN)" + if command -v mail >/dev/null 2>&1; then + printf '%s\n' "$REPORT" | mail -s "$SUBJ" "$MAILTO" && echo "✉ 메일 발송: $MAILTO" || echo "✉ 메일 발송 실패" + elif command -v sendmail >/dev/null 2>&1; then + printf 'To: %s\nSubject: %s\n\n%s\n' "$MAILTO" "$SUBJ" "$REPORT" | sendmail -t && echo "✉ 메일 발송: $MAILTO" || echo "✉ 메일 발송 실패" + else + echo "✉ mail/sendmail 이 없어 발송하지 못함 — 위 보고서를 복사해 쓰세요" + fi +fi +"#; + /// 한 사이트 업데이트 스텝(소유자 $OWN, 웹루트 $WR 기준). CMS 유형 자동 판별: -/// WordPress=wp-cli, git 설치본=얕은 업데이트, 일반(비-git) Rhymix/그누보드=최신본 오버레이. -const SITE_UPDATE_STEP: &str = r#" if [ -f "$WR/wp-load.php" ]; then +/// WordPress=wp-cli, git 설치본=최신 릴리스 태그, 일반(비-git) Rhymix/그누보드=최신본 오버레이. +/// 어느 경로든 **안정 릴리스**만 올린다. 앞에 STABLE_TAG_FN 이 정의돼 있어야 한다. +const SITE_UPDATE_STEP: &str = r#" RES=""; CMSN=""; VFROM=""; VTO=""; NOTE="" + if [ -f "$WR/wp-load.php" ]; then WP=/usr/local/bin/wp if ! [ -x "$WP" ]; then echo "wp-cli 설치"; curl -fsSL https://raw.githubusercontent.com/wp-cli/builds/gh-pages/phar/wp-cli.phar -o "$WP" 2>/dev/null && chmod +x "$WP"; fi - echo "── [$OWN/$DOM] WordPress 업데이트 (wp-cli) ──" - if sudo -u "$OWN" "$WP" --path="$WR" core update; then + echo "── [$OWN/$DOM] WordPress 업데이트 (wp-cli · 안정 릴리스) ──" + CUR="$(sudo -u "$OWN" "$WP" --path="$WR" core version 2>/dev/null)" + CMSN=wp; VFROM="$CUR" + FORCE="" + # 과거 git 업데이트로 개발 trunk 가 올라간 사이트는 안정 릴리스로 되돌린다 + case "$CUR" in + *alpha*|*beta*|*RC*|*-src*) + STABLE="$(hm_wp_stable)" + if [ -n "$STABLE" ]; then + echo " ! 개발버전 감지 ($CUR) → 안정 $STABLE 로 되돌림" + FORCE="--version=$STABLE --force"; NOTE="개발버전→안정 되돌림" + else + echo " ! 개발버전 감지 ($CUR) — 안정 버전 조회 실패, 되돌리지 못함" + NOTE="개발버전인데 되돌리지 못함" + fi ;; + esac + if sudo -u "$OWN" "$WP" --path="$WR" core update $FORCE; then sudo -u "$OWN" "$WP" --path="$WR" core update-db || true sudo -u "$OWN" "$WP" --path="$WR" plugin update --all || true sudo -u "$OWN" "$WP" --path="$WR" theme update --all || true sudo -u "$OWN" "$WP" --path="$WR" language core update || true - echo " OK $(sudo -u "$OWN" "$WP" --path="$WR" core version 2>/dev/null)"; OK=$((OK+1)) - else echo " FAIL"; FAIL=$((FAIL+1)); fi + VTO="$(sudo -u "$OWN" "$WP" --path="$WR" core version 2>/dev/null)" + echo " OK $VTO"; OK=$((OK+1)); RES=OK + else echo " FAIL"; FAIL=$((FAIL+1)); RES=FAIL; fi + # WordPress 인데 .git 이 남아 있으면 git 경로로 다시 샐 수 있다 — 알린다 + if [ -d "$WR/.git" ]; then + RM="$(sudo -u "$OWN" git -C "$WR" remote get-url origin 2>/dev/null)" + case "$RM" in *[Ww]ord[Pp]ress/[Ww]ord[Pp]ress*) + echo " ! .git 이 남아 있음 (origin=$RM) — nightly 로 덮인 원인. 확인 후 .git 제거 권장" + NOTE="${NOTE:+$NOTE / }.git(WordPress) 남아 있음 — 제거 권장" ;; + esac + fi elif [ -d "$WR/.git" ]; then - BR="$(sudo -u "$OWN" git -C "$WR" rev-parse --abbrev-ref HEAD 2>/dev/null)"; [ -z "$BR" -o "$BR" = HEAD ] && BR=master - echo "── [$OWN/$DOM] git 얕은 업데이트 ($BR) ──" - if sudo -u "$OWN" git -C "$WR" fetch --depth=1 origin "$BR" && sudo -u "$OWN" git -C "$WR" reset --hard FETCH_HEAD; then - sudo -u "$OWN" git -C "$WR" reflog expire --expire=now --all 2>/dev/null || true - sudo -u "$OWN" git -C "$WR" gc --prune=now --quiet 2>/dev/null || true - echo " OK $(sudo -u "$OWN" git -C "$WR" rev-parse --short HEAD 2>/dev/null)"; OK=$((OK+1)) - else echo " FAIL"; FAIL=$((FAIL+1)); fi + TAG="$(hm_latest_tag "$OWN" "$WR")" + VFROM="$(sudo -u "$OWN" git -C "$WR" rev-parse --short HEAD 2>/dev/null)" + if [ -n "$TAG" ]; then + CMSN="git-tag" + echo "── [$OWN/$DOM] git 안정 릴리스 업데이트 ($TAG) ──" + if sudo -u "$OWN" git -C "$WR" fetch --depth=1 -f origin "refs/tags/$TAG:refs/tags/$TAG" \ + && sudo -u "$OWN" git -C "$WR" reset --hard "$TAG"; then + sudo -u "$OWN" git -C "$WR" reflog expire --expire=now --all 2>/dev/null || true + sudo -u "$OWN" git -C "$WR" gc --prune=now --quiet 2>/dev/null || true + VTO="$TAG" + echo " OK $TAG ($(sudo -u "$OWN" git -C "$WR" rev-parse --short HEAD 2>/dev/null))"; OK=$((OK+1)); RES=OK + else echo " FAIL"; FAIL=$((FAIL+1)); RES=FAIL; fi + else + CMSN="git-branch" + BR="$(sudo -u "$OWN" git -C "$WR" rev-parse --abbrev-ref HEAD 2>/dev/null)"; [ -z "$BR" -o "$BR" = HEAD ] && BR=master + NOTE="릴리스 태그 없는 저장소 — 개발 브랜치 $BR" + echo "── [$OWN/$DOM] 릴리스 태그 없음 → 브랜치 $BR 얕은 업데이트 ──" + if sudo -u "$OWN" git -C "$WR" fetch --depth=1 origin "$BR" && sudo -u "$OWN" git -C "$WR" reset --hard FETCH_HEAD; then + sudo -u "$OWN" git -C "$WR" reflog expire --expire=now --all 2>/dev/null || true + sudo -u "$OWN" git -C "$WR" gc --prune=now --quiet 2>/dev/null || true + VTO="$(sudo -u "$OWN" git -C "$WR" rev-parse --short HEAD 2>/dev/null)" + echo " OK $VTO (개발 브랜치 — 태그 릴리스가 없는 저장소)"; OK=$((OK+1)); RES=OK + else echo " FAIL"; FAIL=$((FAIL+1)); RES=FAIL; fi + fi else REPO=""; EXCL="" if [ -d "$WR/common/framework" ] || [ -f "$WR/common/constants.php" ] || [ -f "$WR/config/config.inc.php" ]; then REPO="https://github.com/rhymix/rhymix.git"; EXCL="--exclude=/config/ --exclude=/files/" elif [ -f "$WR/common.php" ] && [ -d "$WR/bbs" ]; then REPO="https://github.com/gnuboard/gnuboard5.git"; EXCL="--exclude=/data/" fi - if [ -z "$REPO" ]; then echo "── [$OWN/$DOM] CMS 미상 → 건너뜀"; SKIP=$((SKIP+1)); continue; fi - echo "── [$OWN/$DOM] 일반설치 오버레이 업데이트 (보존 $EXCL) ──" + if [ -z "$REPO" ]; then + echo "── [$OWN/$DOM] CMS 미상 → 건너뜀"; SKIP=$((SKIP+1)) + RPT="$RPT$(printf ' %-32s %-11s %-6s %s' "$DOM" "unknown" "SKIP" "CMS 를 판별하지 못함")"$'\n' + continue + fi + CMSN="overlay" + TAG="$(hm_latest_tag "$OWN" "$REPO")" + CLONE_AT=""; [ -n "$TAG" ] && CLONE_AT="--branch $TAG" + echo "── [$OWN/$DOM] 일반설치 오버레이 업데이트 ${TAG:-개발브랜치} (보존 $EXCL) ──" TMP="$(sudo -u "$OWN" mktemp -d)" - if sudo -u "$OWN" git clone --depth 1 "$REPO" "$TMP" && sudo -u "$OWN" rsync -a $EXCL "$TMP"/ "$WR"/; then - sudo -u "$OWN" rm -rf "$TMP"; echo " OK 오버레이 완료 (다음부터 git)"; OK=$((OK+1)) - else sudo -u "$OWN" rm -rf "$TMP" 2>/dev/null; echo " FAIL"; FAIL=$((FAIL+1)); fi + if sudo -u "$OWN" git clone --depth 1 $CLONE_AT "$REPO" "$TMP" && sudo -u "$OWN" rsync -a $EXCL "$TMP"/ "$WR"/; then + sudo -u "$OWN" rm -rf "$TMP"; VTO="${TAG:-개발브랜치}" + echo " OK 오버레이 완료 ${TAG:+($TAG)} (다음부터 git)"; OK=$((OK+1)); RES=OK + else sudo -u "$OWN" rm -rf "$TMP" 2>/dev/null; echo " FAIL"; FAIL=$((FAIL+1)); RES=FAIL; fi + fi + # 사이트 한 줄 요약을 모아 마지막 보고서에 쓴다 + if [ -n "$RES" ]; then + RPT="$RPT$(printf ' %-32s %-11s %-6s %s → %s%s' "$DOM" "$CMSN" "$RES" "${VFROM:--}" "${VTO:--}" "${NOTE:+ ※ $NOTE}")"$'\n' fi "#; @@ -2358,10 +2606,10 @@ pub fn build_account_git_update(s: &Settings, account: &str, domains: &[String]) }; let scope = if domains.is_empty() { "전체".to_string() } else { format!("{}개 선택", domains.len()) }; let body = format!( - "shopt -s nullglob\nOK=0; SKIP=0; FAIL=0\n\ - for WR in {list}; do\n [ -d \"$WR\" ] || continue\n DOM=\"$(basename \"$(dirname \"$WR\")\")\"\n{step}\n chown -R \"$OWN:$OWN\" \"$WR\" 2>/dev/null || true\ndone\n\ - echo \"== 완료: 업데이트 $OK · 건너뜀 $SKIP · 실패 $FAIL (소유권 $OWN 로 보정) ==\"\n", - list = list, step = SITE_UPDATE_STEP, + "shopt -s nullglob\nRPT_TITLE=\"계정 {acct} 사이트 업데이트 ({scope})\"\nMAILTO=\"\"\n{tagfn}\nOK=0; SKIP=0; FAIL=0; RPT=\"\"\n\ + for WR in {list}; do\n [ -d \"$WR\" ] || continue\n DOM=\"$(basename \"$(dirname \"$WR\")\")\"\n{step}\n chown -R \"$OWN:$OWN\" \"$WR\" 2>/dev/null || true\ndone\n{footer}", + acct = acct, scope = scope, tagfn = STABLE_TAG_FN, list = list, + step = SITE_UPDATE_STEP, footer = REPORT_FOOTER, ); let raw = format!("export PATH=\"$PATH:/usr/local/bin:/usr/bin:/bin\"\nOWN={}\n{}", sq(acct), body); let (script, sshpass, env) = eondcms_exec(&srv, &raw, false, true);