Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e295ad8b96 | ||
|
|
99b8126b59 | ||
|
|
b472581622 | ||
|
|
8fe558f6c1 | ||
|
|
5e6df4b323 | ||
|
|
c9d5d678dc | ||
|
|
e21978c25c |
@@ -25,6 +25,13 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Set up Java 17
|
||||
uses: actions/setup-java@v4
|
||||
with:
|
||||
distribution: temurin
|
||||
java-version: "17"
|
||||
cache: gradle
|
||||
|
||||
- name: Build, sign, and publish release
|
||||
shell: bash
|
||||
run: |
|
||||
|
||||
@@ -254,6 +254,25 @@ Linux 默认路径:
|
||||
|
||||
使用 `--no-service` 只用于开发验收:启动器会在当前用户下运行,但默认设置 `OA_UPDATE_ENABLED=false`。生产环境应使用 systemd/launchd,让在线更新可以在 Java 进程退出后自动拉起新版本。
|
||||
|
||||
### HTTPS 反向代理
|
||||
|
||||
正式服务监听 `8090`。Nginx、宝塔、Caddy 或 CDN 终止 HTTPS 后,必须把公网协议和主机转发给 Spring Boot;否则浏览器的同源 API 请求会被误判为跨域,并收到纯文本 `403 Invalid CORS request`,前端表现为“响应非 JSON (HTTP 403)”。Nginx 的代理位置至少包含:
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
proxy_pass http://127.0.0.1:8090;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-Host $host;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_set_header X-Forwarded-Port $server_port;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
}
|
||||
```
|
||||
|
||||
如果 Nginx 前面还有 Cloudflare 等上游代理,`X-Forwarded-Proto` 必须保留浏览器实际使用的 `https`,不能被内层 HTTP 链路覆盖。前端与 `/api/oa/*` 推荐使用同一个公网域名;不要在 Nginx 中附加 `Access-Control-Allow-Origin *`。
|
||||
|
||||
## 在线更新与回滚
|
||||
|
||||
管理员登录后进入:
|
||||
@@ -417,6 +436,10 @@ Gitea 仓库尚未发布首个可安装版本,或 Release 缺少四个必需
|
||||
|
||||
运行 `ngrok config check` 确认配置有效,并检查固定域名是否属于当前 ngrok 账号。可以通过 `ERP_RUN_NGROK_BIN`、`ERP_RUN_NGROK_CONFIG` 和 `ERP_RUN_NGROK_API_PORT` 覆盖路径与端口。
|
||||
|
||||
### 反向代理后提示“响应非 JSON (HTTP 403)”
|
||||
|
||||
应用自身的 401/403 权限错误始终是 JSON。该提示表示 Nginx、WAF 或 Spring CORS 层提前返回了纯文本/HTML。先确认代理目标为 `http://127.0.0.1:8090`,再按“HTTPS 反向代理”一节补齐 `Host` 和 `X-Forwarded-*` 请求头;响应正文为 `Invalid CORS request` 时即可确认是协议/主机转发不完整。
|
||||
|
||||
### 签名验证失败
|
||||
|
||||
不要跳过验证。确认 Release 的四个资产来自同一次构建、`SHA256SUMS` 未被改写、签名私钥与 `distribution/release-public-key.pem` 匹配。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
server:
|
||||
port: ${SERVER_PORT:8091}
|
||||
shutdown: graceful
|
||||
forward-headers-strategy: framework
|
||||
|
||||
spring:
|
||||
application:
|
||||
|
||||
@@ -190,6 +190,44 @@
|
||||
.progress-item.running .progress-dot { border-color: #2f6fed; background: #2f6fed; box-shadow: 0 0 0 4px #eaf1fd; }
|
||||
.progress-item.done { color: #187149; }
|
||||
.progress-item.done .progress-dot { border-color: #25805a; background: #25805a; }
|
||||
.install-progress { margin-top: 22px; padding-top: 18px; border-top: 1px solid #e8ebf0; }
|
||||
.install-progress-head, .install-progress-meta, .install-console-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
.install-progress-label { color: #334057; font-size: 13px; font-weight: 650; }
|
||||
.install-progress-value { color: #1d3557; font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||
.install-progress-track {
|
||||
height: 10px;
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
background: #e5eaf1;
|
||||
}
|
||||
.install-progress-bar {
|
||||
width: 0;
|
||||
height: 100%;
|
||||
background: #2f6fed;
|
||||
transition: width .35s ease;
|
||||
}
|
||||
.install-progress-meta { margin-top: 8px; color: #707c90; font-size: 12px; }
|
||||
.install-console { margin-top: 14px; overflow: hidden; border: 1px solid #28364a; border-radius: 6px; background: #182233; }
|
||||
.install-console-head { min-height: 36px; padding: 0 12px; border-bottom: 1px solid #314158; background: #111a28; }
|
||||
.install-console-title { color: #dce5f2; font-size: 12px; font-weight: 650; }
|
||||
.install-console-state { color: #7dd3a8; font-size: 12px; }
|
||||
.install-log {
|
||||
height: 148px;
|
||||
margin: 0;
|
||||
overflow: auto;
|
||||
padding: 10px 12px;
|
||||
color: #c9d5e5;
|
||||
font: 12px/1.65 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
letter-spacing: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.complete-mark {
|
||||
width: 58px;
|
||||
height: 58px;
|
||||
@@ -220,6 +258,8 @@
|
||||
.summary-row { grid-template-columns: 1fr; gap: 5px; }
|
||||
.actions { flex-wrap: wrap-reverse; }
|
||||
.btn { flex: 1 1 120px; }
|
||||
.install-progress-meta { align-items: flex-start; flex-direction: column; gap: 4px; }
|
||||
.install-log { height: 172px; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
@@ -299,6 +339,23 @@
|
||||
<div class="progress-item" data-progress="3"><span class="progress-dot"></span><span>创建管理员账号</span></div>
|
||||
<div class="progress-item" data-progress="4"><span class="progress-dot"></span><span>启动正式服务并写入安装锁</span></div>
|
||||
</div>
|
||||
<div class="install-progress">
|
||||
<div class="install-progress-head">
|
||||
<span class="install-progress-label" id="install-phase">准备安装</span>
|
||||
<strong class="install-progress-value" id="install-percent">0%</strong>
|
||||
</div>
|
||||
<div class="install-progress-track" id="install-progress-track" role="progressbar" aria-label="安装进度" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
|
||||
<div class="install-progress-bar" id="install-progress-bar"></div>
|
||||
</div>
|
||||
<div class="install-progress-meta">
|
||||
<span id="install-elapsed">本页已等待 0 秒</span>
|
||||
<span id="install-check">等待安装任务</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="install-console">
|
||||
<div class="install-console-head"><span class="install-console-title">安装状态</span><span class="install-console-state" id="install-console-state">运行中</span></div>
|
||||
<pre class="install-log" id="install-log" aria-live="polite"></pre>
|
||||
</div>
|
||||
<div id="install-message" class="notice info">正在初始化,数据库规模较大时可能需要几分钟。</div>
|
||||
</div>
|
||||
<div id="complete-view" class="hidden">
|
||||
@@ -323,7 +380,18 @@
|
||||
history.replaceState(null, '', clean);
|
||||
}
|
||||
|
||||
const state = { step: 1, database: null, databaseFingerprint: '', administrator: null, installing: false };
|
||||
const state = {
|
||||
step: 1,
|
||||
database: null,
|
||||
databaseFingerprint: '',
|
||||
administrator: null,
|
||||
installing: false,
|
||||
installStartedAt: 0,
|
||||
installPercent: 0,
|
||||
installLogEntries: [],
|
||||
healthPolling: false,
|
||||
};
|
||||
const FORMAL_SERVICE_TIMEOUT_MS = 240000;
|
||||
const $ = (selector) => document.querySelector(selector);
|
||||
const $$ = (selector) => Array.from(document.querySelectorAll(selector));
|
||||
|
||||
@@ -421,24 +489,95 @@
|
||||
});
|
||||
}
|
||||
|
||||
function formatElapsed(milliseconds) {
|
||||
const seconds = Math.max(0, Math.floor(milliseconds / 1000));
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
return `${Math.floor(seconds / 60)} 分 ${seconds % 60} 秒`;
|
||||
}
|
||||
|
||||
function setInstallMeter(percent, phase, detail) {
|
||||
const value = Math.max(state.installPercent, Math.max(0, Math.min(100, Math.round(percent))));
|
||||
state.installPercent = value;
|
||||
$('#install-progress-bar').style.width = `${value}%`;
|
||||
$('#install-percent').textContent = `${value}%`;
|
||||
$('#install-phase').textContent = phase;
|
||||
$('#install-check').textContent = detail;
|
||||
$('#install-elapsed').textContent = `本页已等待 ${formatElapsed(Date.now() - state.installStartedAt)}`;
|
||||
const track = $('#install-progress-track');
|
||||
track.setAttribute('aria-valuenow', String(value));
|
||||
track.setAttribute('aria-valuetext', `${phase},${value}%`);
|
||||
}
|
||||
|
||||
function appendInstallLog(message) {
|
||||
const timestamp = new Date().toLocaleTimeString('zh-CN', { hour12: false });
|
||||
state.installLogEntries.push(`[${timestamp}] ${message}`);
|
||||
state.installLogEntries = state.installLogEntries.slice(-16);
|
||||
const output = $('#install-log');
|
||||
output.textContent = state.installLogEntries.join('\n');
|
||||
output.scrollTop = output.scrollHeight;
|
||||
}
|
||||
|
||||
function beginInstallTracking(resumed) {
|
||||
state.installStartedAt = Date.now();
|
||||
state.installLogEntries = [];
|
||||
$('#install-message').className = 'notice info';
|
||||
$('#install-console-state').textContent = '运行中';
|
||||
if (resumed) {
|
||||
setInstallMeter(70, '恢复安装任务', '准备检查正式服务');
|
||||
appendInstallLog('检测到尚未完成的安装任务,继续等待正式服务。');
|
||||
} else {
|
||||
setInstallMeter(5, '提交安装任务', '准备验证数据库');
|
||||
appendInstallLog('安装请求已提交,正在验证 PostgreSQL 连接和权限。');
|
||||
}
|
||||
}
|
||||
|
||||
async function pollFormalService() {
|
||||
if (state.healthPolling) return;
|
||||
state.healthPolling = true;
|
||||
if (!state.installStartedAt) beginInstallTracking(true);
|
||||
setProgress(4);
|
||||
$('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。';
|
||||
const deadline = Date.now() + 240000;
|
||||
setInstallMeter(Math.max(state.installPercent, 72), '启动正式服务', '等待第 1 次健康检查');
|
||||
appendInstallLog('数据库初始化完成,安装器正在切换到正式服务。');
|
||||
$('#install-message').textContent = '初始化完成,正在等待正式服务通过健康检查。页面会持续显示检查进度。';
|
||||
const healthStartedAt = Date.now();
|
||||
const deadline = healthStartedAt + FORMAL_SERVICE_TIMEOUT_MS;
|
||||
let attempt = 0;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 2000));
|
||||
attempt += 1;
|
||||
const healthElapsed = Date.now() - healthStartedAt;
|
||||
const estimated = 72 + Math.floor((healthElapsed / FORMAL_SERVICE_TIMEOUT_MS) * 23);
|
||||
setInstallMeter(Math.min(95, estimated), '等待正式服务健康检查', `第 ${attempt} 次检查`);
|
||||
try {
|
||||
const response = await fetch('/api/oa/health', { cache: 'no-store' });
|
||||
const body = await response.json();
|
||||
if (response.ok && (body?.data?.status === 'UP' || body?.status === 'UP')) {
|
||||
const serviceStatus = body?.data?.status || body?.status || `HTTP ${response.status}`;
|
||||
const installLocked = body?.data?.installLocked ?? body?.installLocked;
|
||||
if (response.ok && serviceStatus === 'UP' && (installLocked === true || installLocked === 'true')) {
|
||||
setInstallMeter(100, '安装完成', '正式服务已就绪');
|
||||
appendInstallLog(`健康检查第 ${attempt} 次通过,安装锁已确认。`);
|
||||
$('#install-console-state').textContent = '已完成';
|
||||
$$('[data-progress]').forEach((item) => { item.classList.remove('running'); item.classList.add('done'); });
|
||||
$('#installing-view').classList.add('hidden');
|
||||
$('#complete-view').classList.remove('hidden');
|
||||
sessionStorage.removeItem('kaidi.setup.token');
|
||||
state.healthPolling = false;
|
||||
return;
|
||||
}
|
||||
} catch { /* Service is changing from installer to the formal application. */ }
|
||||
if (response.ok && serviceStatus === 'UP') {
|
||||
setInstallMeter(98, '正式服务已通过健康检查', '等待写入安装锁');
|
||||
appendInstallLog(`健康检查第 ${attempt} 次通过,正在等待后台写入安装锁。`);
|
||||
continue;
|
||||
}
|
||||
appendInstallLog(`健康检查第 ${attempt} 次:服务状态 ${serviceStatus}。`);
|
||||
} catch {
|
||||
appendInstallLog(`健康检查第 ${attempt} 次:服务端口正在切换,继续等待。`);
|
||||
}
|
||||
}
|
||||
state.healthPolling = false;
|
||||
setInstallMeter(95, '正式服务尚未就绪', `${Math.round(FORMAL_SERVICE_TIMEOUT_MS / 1000)} 秒检查超时`);
|
||||
appendInstallLog('健康检查超时,安装状态已保留,未写入完成锁。');
|
||||
$('#install-console-state').textContent = '需要检查';
|
||||
$('#install-message').className = 'notice error';
|
||||
$('#install-message').textContent = '正式服务尚未就绪。请查看 systemctl 日志或 /var/lib/kaidi-erp/install-formal.log;安装文件和待确认状态均已保留。';
|
||||
}
|
||||
@@ -506,10 +645,16 @@
|
||||
state.installing = true;
|
||||
$('#confirm-view').classList.add('hidden');
|
||||
$('#installing-view').classList.remove('hidden');
|
||||
beginInstallTracking(false);
|
||||
setProgress(1);
|
||||
let migrationTimer;
|
||||
try {
|
||||
const request = { database: state.database, administrator: state.administrator };
|
||||
setTimeout(() => setProgress(2), 700);
|
||||
migrationTimer = setTimeout(() => {
|
||||
setProgress(2);
|
||||
setInstallMeter(28, '执行数据库初始化', '迁移结构和基础数据');
|
||||
appendInstallLog('数据库连接验证通过,正在执行结构迁移和基础数据初始化。');
|
||||
}, 700);
|
||||
try {
|
||||
await api('/api/install/complete', { method: 'POST', body: JSON.stringify(request) });
|
||||
} catch (error) {
|
||||
@@ -518,12 +663,20 @@
|
||||
// is flushed; the formal health probe is the authoritative result.
|
||||
if (!(error instanceof TypeError) && error.message !== '安装服务响应无效') throw error;
|
||||
}
|
||||
clearTimeout(migrationTimer);
|
||||
setProgress(3);
|
||||
setInstallMeter(68, '创建管理员并切换服务', '数据库初始化已完成');
|
||||
appendInstallLog('数据库迁移和管理员初始化已提交,准备启动正式服务。');
|
||||
state.administrator.password = '';
|
||||
state.database.password = '';
|
||||
await pollFormalService();
|
||||
} catch (error) {
|
||||
clearTimeout(migrationTimer);
|
||||
state.installing = false;
|
||||
state.healthPolling = false;
|
||||
setInstallMeter(state.installPercent, '安装已中断', '请处理错误后重试');
|
||||
appendInstallLog(`安装中断:${error.message}`);
|
||||
$('#install-console-state').textContent = '已中断';
|
||||
$('#install-message').className = 'notice error';
|
||||
$('#install-message').textContent = error.message;
|
||||
const actions = document.createElement('div');
|
||||
@@ -548,6 +701,7 @@
|
||||
go(4);
|
||||
$('#confirm-view').classList.add('hidden');
|
||||
$('#installing-view').classList.remove('hidden');
|
||||
beginInstallTracking(true);
|
||||
return pollFormalService();
|
||||
}
|
||||
$('#java-detail').textContent = `Java ${status.javaVersion},最低要求 Java ${status.minimumJava}`;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package com.kaidi.oa.web;
|
||||
|
||||
import com.kaidi.oa.common.ApiResp;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
@@ -8,6 +9,9 @@ import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
@@ -19,9 +23,12 @@ import java.util.Map;
|
||||
public class HealthController {
|
||||
|
||||
private final DataSource dataSource;
|
||||
private final Path installLockFile;
|
||||
|
||||
public HealthController(DataSource dataSource) {
|
||||
public HealthController(DataSource dataSource,
|
||||
@Value("${ERP_INSTALL_LOCK_FILE:}") String installLockFile) {
|
||||
this.dataSource = dataSource;
|
||||
this.installLockFile = resolveInstallLockFile(installLockFile);
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
@@ -30,12 +37,31 @@ public class HealthController {
|
||||
Statement statement = connection.createStatement();
|
||||
ResultSet result = statement.executeQuery("SELECT 1")) {
|
||||
if (connection.isValid(2) && result.next() && result.getInt(1) == 1) {
|
||||
return ResponseEntity.ok(ApiResp.ok(Map.of("status", "UP", "database", "UP")));
|
||||
return ResponseEntity.ok(ApiResp.ok(readiness("UP", "UP")));
|
||||
}
|
||||
} catch (Exception ignored) {
|
||||
// Health responses deliberately avoid exposing connection details.
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(new ApiResp<>(50301, "database unavailable", Map.of("status", "DOWN", "database", "DOWN")));
|
||||
.body(new ApiResp<>(50301, "database unavailable", readiness("DOWN", "DOWN")));
|
||||
}
|
||||
|
||||
private Map<String, String> readiness(String status, String database) {
|
||||
return Map.of(
|
||||
"status", status,
|
||||
"database", database,
|
||||
"installLocked", Boolean.toString(installLockFile != null && Files.isRegularFile(installLockFile))
|
||||
);
|
||||
}
|
||||
|
||||
private static Path resolveInstallLockFile(String value) {
|
||||
if (value == null || value.isBlank()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return Path.of(value).toAbsolutePath().normalize();
|
||||
} catch (InvalidPathException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
server:
|
||||
port: 8090
|
||||
# Reconstruct the public scheme/host when running behind Nginx, Caddy or a
|
||||
# tunnel so same-origin HTTPS requests are not rejected as cross-origin.
|
||||
forward-headers-strategy: framework
|
||||
|
||||
spring:
|
||||
application:
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
package com.kaidi.oa.web;
|
||||
|
||||
import com.kaidi.oa.common.ApiResp;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
|
||||
import javax.sql.DataSource;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.sql.Connection;
|
||||
import java.sql.ResultSet;
|
||||
import java.sql.Statement;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class HealthControllerTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void reportsTheActualInstallationLockState() throws Exception {
|
||||
DataSource dataSource = mock(DataSource.class);
|
||||
Connection connection = mock(Connection.class);
|
||||
Statement statement = mock(Statement.class);
|
||||
ResultSet resultSet = mock(ResultSet.class);
|
||||
when(dataSource.getConnection()).thenReturn(connection);
|
||||
when(connection.createStatement()).thenReturn(statement);
|
||||
when(connection.isValid(2)).thenReturn(true);
|
||||
when(statement.executeQuery("SELECT 1")).thenReturn(resultSet);
|
||||
when(resultSet.next()).thenReturn(true);
|
||||
when(resultSet.getInt(1)).thenReturn(1);
|
||||
|
||||
Path lockFile = tempDir.resolve("install.lock");
|
||||
HealthController controller = new HealthController(dataSource, lockFile.toString());
|
||||
|
||||
assertThat(healthData(controller.health())).containsEntry("installLocked", "false");
|
||||
|
||||
Files.writeString(lockFile, "locked\n");
|
||||
|
||||
assertThat(healthData(controller.health()))
|
||||
.containsEntry("status", "UP")
|
||||
.containsEntry("database", "UP")
|
||||
.containsEntry("installLocked", "true");
|
||||
}
|
||||
|
||||
private static Map<String, String> healthData(
|
||||
ResponseEntity<ApiResp<Map<String, String>>> response) {
|
||||
assertThat(response.getBody()).isNotNull();
|
||||
return response.getBody().data();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
TARGET=/root/kaidi-erp-reinstall-v031.sh
|
||||
URL=http://38.76.196.225:10099/awaioi/ERP/raw/tag/recovery-v0.3.1-1/reinstall-centos9.sh
|
||||
SHA256=0370fb17926f3b53d5ae849310fdc6cbbf019150415365a464b0972b8edf5d9a
|
||||
|
||||
curl -fsSL --connect-timeout 15 --max-time 300 "$URL" -o "$TARGET"
|
||||
printf '%s %s\n' "$SHA256" "$TARGET" | sha256sum -c -
|
||||
exec bash "$TARGET"
|
||||
Executable
+105
@@ -0,0 +1,105 @@
|
||||
#!/usr/bin/env bash
|
||||
set -Eeuo pipefail
|
||||
|
||||
GITEA_BASE_URL="http://38.76.196.225:10099"
|
||||
REPOSITORY="awaioi/ERP"
|
||||
VERSION="0.3.1"
|
||||
TAG="v${VERSION}"
|
||||
INSTALL_SHA256="89a3c45e76f500c9475cb596ea29e3518bfafdc59f36dd3c7b316ed3cdd0448c"
|
||||
UNINSTALL_SHA256="98c56fed2fd4d01874e4ab5a1a4f3ec42ec3e29b315ffd87385a95488d587546"
|
||||
PG_ROOT="${ERP_RECOVERY_PG_ROOT:-/www/server/pgsql}"
|
||||
PG_DATA="${ERP_RECOVERY_PG_DATA:-/www/server/pgsql/data}"
|
||||
PG_INIT_SCRIPT="${ERP_RECOVERY_PG_INIT_SCRIPT:-/etc/init.d/pgsql}"
|
||||
TMP_DIR=""
|
||||
|
||||
say() { printf '[ERP Recovery] %s\n' "$*"; }
|
||||
fail() { printf '[ERP Recovery] ERROR: %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
cleanup() {
|
||||
[[ -z "$TMP_DIR" || ! -d "$TMP_DIR" ]] || rm -rf -- "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
postgres_port_open() {
|
||||
timeout 2 bash -c 'exec 3<>/dev/tcp/127.0.0.1/5432' >/dev/null 2>&1
|
||||
}
|
||||
|
||||
wait_for_postgres() {
|
||||
local _
|
||||
for _ in {1..60}; do
|
||||
postgres_port_open && return 0
|
||||
sleep 1
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
start_erp_postgres() {
|
||||
if postgres_port_open; then
|
||||
say 'PostgreSQL is already listening on 127.0.0.1:5432'
|
||||
return 0
|
||||
fi
|
||||
|
||||
[[ -f "$PG_DATA/PG_VERSION" ]] \
|
||||
|| fail "PostgreSQL data directory is missing: $PG_DATA"
|
||||
|
||||
if [[ -x "$PG_INIT_SCRIPT" ]]; then
|
||||
say "Starting PostgreSQL with $PG_INIT_SCRIPT"
|
||||
if ! "$PG_INIT_SCRIPT" start; then
|
||||
say 'The init script returned an error; checking whether PostgreSQL still started'
|
||||
fi
|
||||
else
|
||||
local pg_ctl pg_user
|
||||
pg_ctl="$(find "$PG_ROOT" -type f -name pg_ctl -print -quit 2>/dev/null || true)"
|
||||
[[ -n "$pg_ctl" && -x "$pg_ctl" ]] || fail "pg_ctl was not found below $PG_ROOT"
|
||||
pg_user="$(stat -c '%U' "$PG_DATA")" || fail 'Unable to determine the PostgreSQL owner'
|
||||
id "$pg_user" >/dev/null 2>&1 || fail "PostgreSQL owner does not exist: $pg_user"
|
||||
say "Starting PostgreSQL as $pg_user"
|
||||
runuser -u "$pg_user" -- "$pg_ctl" \
|
||||
-D "$PG_DATA" \
|
||||
-l "$PG_DATA/startup.log" \
|
||||
-w -t 60 start
|
||||
fi
|
||||
|
||||
if ! wait_for_postgres; then
|
||||
[[ ! -r "$PG_DATA/startup.log" ]] || tail -n 100 "$PG_DATA/startup.log" >&2
|
||||
fail 'PostgreSQL did not listen on 127.0.0.1:5432 within 60 seconds'
|
||||
fi
|
||||
say 'PostgreSQL is ready on 127.0.0.1:5432'
|
||||
}
|
||||
|
||||
download_verified_script() {
|
||||
local name="$1" expected="$2" destination
|
||||
destination="$TMP_DIR/$name"
|
||||
curl -fsSL --connect-timeout 15 --max-time 300 \
|
||||
"$GITEA_BASE_URL/$REPOSITORY/raw/tag/$TAG/$name" \
|
||||
-o "$destination"
|
||||
printf '%s %s\n' "$expected" "$destination" | sha256sum -c - >&2
|
||||
printf '%s' "$destination"
|
||||
}
|
||||
|
||||
main() {
|
||||
[[ "$(id -u)" == "0" ]] || fail 'Run this recovery command as root'
|
||||
for command in bash curl find runuser sha256sum stat timeout; do
|
||||
command -v "$command" >/dev/null 2>&1 || fail "Missing command: $command"
|
||||
done
|
||||
|
||||
start_erp_postgres
|
||||
TMP_DIR="$(mktemp -d "${TMPDIR:-/tmp}/kaidi-erp-recovery.XXXXXX")"
|
||||
|
||||
local uninstaller installer
|
||||
uninstaller="$(download_verified_script uninstall.sh "$UNINSTALL_SHA256")"
|
||||
say 'Removing the pending installation and its PostgreSQL schema safely'
|
||||
bash "$uninstaller" --purge-database --yes
|
||||
|
||||
installer="$(download_verified_script install.sh "$INSTALL_SHA256")"
|
||||
say "Installing Kaidi ERP $VERSION"
|
||||
bash "$installer" \
|
||||
--gitea-url "$GITEA_BASE_URL" \
|
||||
--repository "$REPOSITORY" \
|
||||
--version "$VERSION" \
|
||||
--allow-insecure
|
||||
}
|
||||
|
||||
if [[ "${BASH_SOURCE[0]:-$0}" == "$0" ]]; then
|
||||
main "$@"
|
||||
fi
|
||||
Executable
+164
@@ -0,0 +1,164 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -Eeuo pipefail
|
||||
|
||||
GITEA_URL="${GITEA_URL:-http://38.76.196.225:10099/}"
|
||||
GITEA_REPOSITORY="${GITEA_REPOSITORY:-awaioi/ERP}"
|
||||
RUNNER_IMAGE="${RUNNER_IMAGE:-}"
|
||||
RUNNER_CONTAINER="${RUNNER_CONTAINER:-gitea-runner}"
|
||||
RUNNER_VOLUME="${RUNNER_VOLUME:-gitea-runner-data}"
|
||||
RUNNER_LABELS="${RUNNER_LABELS:-ubuntu-latest:docker://docker.gitea.com/runner-images:ubuntu-latest}"
|
||||
|
||||
log() {
|
||||
printf '[Gitea Runner] %s\n' "$*"
|
||||
}
|
||||
|
||||
fail() {
|
||||
printf '[Gitea Runner] ERROR: %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v docker >/dev/null 2>&1 || fail "docker is not installed"
|
||||
docker info >/dev/null 2>&1 || fail "docker is not running or the current user cannot access it"
|
||||
|
||||
find_gitea_container() {
|
||||
local container_id image name
|
||||
|
||||
while read -r container_id image name; do
|
||||
case "$image" in
|
||||
gitea/gitea:* | gitea/gitea@* | */gitea/gitea:* | */gitea/gitea@* | docker.gitea.com/gitea:* | docker.gitea.com/gitea@*)
|
||||
printf '%s\n' "$container_id"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done < <(docker ps --format '{{.ID}} {{.Image}} {{.Names}}')
|
||||
|
||||
while read -r container_id name; do
|
||||
case "$name" in
|
||||
*runner*) ;;
|
||||
*gitea*)
|
||||
printf '%s\n' "$container_id"
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
done < <(docker ps --format '{{.ID}} {{.Names}}')
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
pull_runner_image() {
|
||||
local candidate
|
||||
local -a candidates
|
||||
|
||||
if [[ -n "$RUNNER_IMAGE" ]]; then
|
||||
candidates=("$RUNNER_IMAGE")
|
||||
else
|
||||
candidates=(
|
||||
"docker.gitea.com/runner:3.0.2"
|
||||
"docker.gitea.com/act_runner:3.0.2"
|
||||
"gitea/runner:3.0.2"
|
||||
)
|
||||
fi
|
||||
|
||||
for candidate in "${candidates[@]}"; do
|
||||
log "Pulling $candidate"
|
||||
if docker pull "$candidate"; then
|
||||
RUNNER_IMAGE="$candidate"
|
||||
return 0
|
||||
fi
|
||||
log "Image source failed; trying the next source"
|
||||
done
|
||||
|
||||
fail "all official Gitea Runner image sources failed"
|
||||
}
|
||||
|
||||
GITEA_CONTAINER="$(find_gitea_container || true)"
|
||||
[[ -n "$GITEA_CONTAINER" ]] || fail "no running Gitea container was found"
|
||||
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
TOKEN_FILE="$TMP_DIR/runner-token"
|
||||
BOOTSTRAP_RUNNING=0
|
||||
|
||||
cleanup() {
|
||||
if [[ "$BOOTSTRAP_RUNNING" == "1" ]]; then
|
||||
docker rm -f "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
if [[ -f "$TOKEN_FILE" ]]; then
|
||||
: >"$TOKEN_FILE"
|
||||
fi
|
||||
rm -rf -- "$TMP_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
log "Generating a repository-scoped registration token"
|
||||
if ! docker exec -u git "$GITEA_CONTAINER" gitea actions generate-runner-token \
|
||||
--scope "$GITEA_REPOSITORY" | tr -d '\r' | tail -n 1 >"$TOKEN_FILE"; then
|
||||
fail "Gitea could not generate a runner token"
|
||||
fi
|
||||
[[ -s "$TOKEN_FILE" ]] || fail "Gitea returned an empty runner token"
|
||||
chmod 600 "$TOKEN_FILE"
|
||||
|
||||
pull_runner_image
|
||||
docker rm -f "$RUNNER_CONTAINER" >/dev/null 2>&1 || true
|
||||
docker volume create "$RUNNER_VOLUME" >/dev/null
|
||||
docker run --rm --entrypoint /bin/rm \
|
||||
-v "$RUNNER_VOLUME:/data" \
|
||||
"$RUNNER_IMAGE" -f /data/.runner
|
||||
|
||||
log "Registering the runner"
|
||||
docker run -d \
|
||||
--name "$RUNNER_CONTAINER" \
|
||||
-v "$RUNNER_VOLUME:/data" \
|
||||
-v "$TOKEN_FILE:/run/secrets/gitea_runner_token:ro,Z" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e "GITEA_INSTANCE_URL=$GITEA_URL" \
|
||||
-e GITEA_RUNNER_REGISTRATION_TOKEN_FILE=/run/secrets/gitea_runner_token \
|
||||
-e "GITEA_RUNNER_NAME=erp-release-$(hostname -s)" \
|
||||
-e "GITEA_RUNNER_LABELS=$RUNNER_LABELS" \
|
||||
-e GITEA_MAX_REG_ATTEMPTS=24 \
|
||||
"$RUNNER_IMAGE" >/dev/null
|
||||
BOOTSTRAP_RUNNING=1
|
||||
|
||||
READY=0
|
||||
for ((attempt = 1; attempt <= 60; attempt++)); do
|
||||
if docker exec "$RUNNER_CONTAINER" test -s /data/.runner 2>/dev/null; then
|
||||
READY=1
|
||||
break
|
||||
fi
|
||||
if [[ "$(docker inspect -f '{{.State.Running}}' "$RUNNER_CONTAINER" 2>/dev/null || true)" != "true" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 2
|
||||
done
|
||||
|
||||
if [[ "$READY" != "1" ]]; then
|
||||
docker logs --tail 100 "$RUNNER_CONTAINER" >&2 || true
|
||||
fail "runner registration failed"
|
||||
fi
|
||||
|
||||
docker logs --tail 30 "$RUNNER_CONTAINER" || true
|
||||
docker rm -f "$RUNNER_CONTAINER" >/dev/null
|
||||
BOOTSTRAP_RUNNING=0
|
||||
: >"$TOKEN_FILE"
|
||||
|
||||
log "Starting the persistent runner without the registration token"
|
||||
docker run -d \
|
||||
--name "$RUNNER_CONTAINER" \
|
||||
--restart unless-stopped \
|
||||
-v "$RUNNER_VOLUME:/data" \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e "GITEA_INSTANCE_URL=$GITEA_URL" \
|
||||
-e "GITEA_RUNNER_NAME=erp-release-$(hostname -s)" \
|
||||
-e "GITEA_RUNNER_LABELS=$RUNNER_LABELS" \
|
||||
"$RUNNER_IMAGE" >/dev/null
|
||||
|
||||
sleep 5
|
||||
[[ "$(docker inspect -f '{{.State.Running}}' "$RUNNER_CONTAINER")" == "true" ]] || {
|
||||
docker logs --tail 100 "$RUNNER_CONTAINER" >&2 || true
|
||||
fail "runner container stopped after registration"
|
||||
}
|
||||
|
||||
log "Runner is online; queued release jobs can now continue"
|
||||
docker ps --filter "name=^/${RUNNER_CONTAINER}$" \
|
||||
--format 'table {{.Names}}\t{{.Image}}\t{{.Status}}'
|
||||
docker logs --tail 80 "$RUNNER_CONTAINER" || true
|
||||
@@ -312,7 +312,7 @@ PY
|
||||
NO_SERVICE=0
|
||||
PUBLIC_KEY="$(<"$tmp/public.pem")"
|
||||
|
||||
detect_platform() { PLATFORM=darwin; }
|
||||
detect_platform() { PLATFORM=darwin; ARCH=amd64; }
|
||||
check_and_install_dependencies() { JAVA_BIN="$(command -v java)"; return 0; }
|
||||
start_service() { return 0; }
|
||||
wait_for_installer() { return 0; }
|
||||
@@ -323,7 +323,7 @@ PY
|
||||
[[ -r "$INSTALL_ROOT/current/app/kaidi-erp.jar" ]] || return 1
|
||||
(
|
||||
source "$ERP_CONFIG_ROOT/erp.env"
|
||||
[[ "$SPRING_PROFILES_ACTIVE" == "postgres" ]]
|
||||
[[ -z "${SPRING_PROFILES_ACTIVE:-}" ]]
|
||||
[[ "$OA_UPDATE_ENABLED" == "true" ]]
|
||||
[[ "$OA_UPDATE_GITEA_BASE_URL" == "http://127.0.0.1:$port" ]]
|
||||
[[ "$ERP_SETUP_TOKEN" =~ ^[0-9a-f]{64}$ ]]
|
||||
@@ -528,6 +528,7 @@ test_installer_requires_explicit_gitea_url() (
|
||||
set --
|
||||
source "$root/install.sh"
|
||||
GITEA_BASE_URL=
|
||||
NO_SERVICE=1
|
||||
main
|
||||
' _ "$PROJECT_ROOT" 2>&1)" || status=$?
|
||||
[[ "$status" -ne 0 && "$output" == *'Gitea URL is required'* ]]
|
||||
@@ -680,6 +681,8 @@ test_release_workflow_uses_scoped_job_token() (
|
||||
grep -Fqx ' releases: write' "$workflow"
|
||||
grep -Fq 'GITEA_BASE_URL: ${{ github.server_url }}' "$workflow"
|
||||
grep -Fq 'GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}' "$workflow"
|
||||
grep -Fq 'uses: actions/setup-java@v4' "$workflow"
|
||||
grep -Fq 'java-version: "17"' "$workflow"
|
||||
! grep -Fq 'RELEASE_GITEA_TOKEN' "$workflow"
|
||||
! grep -Fq 'vars.GITEA_BASE_URL' "$workflow"
|
||||
)
|
||||
@@ -692,6 +695,23 @@ test_installer_runs_when_piped_to_bash() (
|
||||
[[ "$output" != *'BASH_SOURCE'* ]]
|
||||
)
|
||||
|
||||
test_installer_shows_live_health_progress() (
|
||||
local page="$PROJECT_ROOT/oa-backend/src/installer/resources/static/index.html"
|
||||
grep -Fq 'role="progressbar"' "$page"
|
||||
grep -Fq 'id="install-log"' "$page"
|
||||
grep -Fq 'Math.min(95, estimated)' "$page"
|
||||
grep -Fq '健康检查第 ${attempt} 次' "$page"
|
||||
grep -Fq '本页已等待 ${formatElapsed' "$page"
|
||||
grep -Fq 'installLocked' "$page"
|
||||
)
|
||||
|
||||
test_app_honors_reverse_proxy_headers() (
|
||||
grep -Fqx ' forward-headers-strategy: framework' \
|
||||
"$PROJECT_ROOT/oa-backend/src/main/resources/application.yml"
|
||||
grep -Fqx ' forward-headers-strategy: framework' \
|
||||
"$PROJECT_ROOT/oa-backend/src/installer/resources/application.yml"
|
||||
)
|
||||
|
||||
run_test 'erp-run preserves Java option arguments' test_erp_run_preserves_java_option_arguments
|
||||
run_test 'erp-run finalizes a healthy pending installation' test_erp_run_finalizes_healthy_pending_install
|
||||
run_test 'erp-run preserves a failed pending installation' test_erp_run_preserves_failed_pending_install
|
||||
@@ -713,6 +733,8 @@ run_test 'uninstaller aborts while the service remains active' test_uninstaller_
|
||||
run_test 'no-service install disables online update' test_no_service_install_disables_online_update
|
||||
run_test 'release workflow uses the scoped Gitea job token' test_release_workflow_uses_scoped_job_token
|
||||
run_test 'installer starts correctly when piped to bash' test_installer_runs_when_piped_to_bash
|
||||
run_test 'installer shows live formal-service health progress' test_installer_shows_live_health_progress
|
||||
run_test 'application honors standard reverse-proxy headers' test_app_honors_reverse_proxy_headers
|
||||
|
||||
printf 'RESULT pass=%s fail=%s\n' "$PASS" "$FAIL"
|
||||
[[ "$FAIL" -eq 0 ]]
|
||||
|
||||
Reference in New Issue
Block a user