feat(web): add auth and fix deployment checks
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 9s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Snapshot Admin Deployment / build-and-deploy (push) Failing after 2m30s
Deploy to Production / Build & Deploy to Production (push) Failing after 3m49s
Quant Engine CI/CD Pipeline / validate-core (push) Failing after 9s
WBS-9.3 - NULL Policy CI Gate / NULL Policy Validation (push) Failing after 6s
Quant Engine CI/CD Pipeline / validate-ui-and-storage (push) Has been skipped
Snapshot Admin Deployment / build-and-deploy (push) Failing after 2m30s
Deploy to Production / Build & Deploy to Production (push) Failing after 3m49s
This commit is contained in:
@@ -101,25 +101,42 @@ jobs:
|
|||||||
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 tools/deploy_quantengine.sh "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/deploy.sh"
|
scp -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 tools/deploy_quantengine.sh "$DEPLOY_USER@$DEPLOY_HOST:/home/kjh2064/tmp/deploy.sh"
|
||||||
|
|
||||||
# 2. 배포 스크립트 실행
|
# 2. 배포 스크립트 실행
|
||||||
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "chmod +x /home/kjh2064/tmp/deploy.sh && /home/kjh2064/tmp/deploy.sh"
|
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "chmod +x /home/kjh2064/tmp/deploy.sh && CI_DEPLOY=1 /home/kjh2064/tmp/deploy.sh"
|
||||||
|
|
||||||
# 3. 배포 성공 검증
|
# 3. 배포 성공 검증
|
||||||
|
echo "=== Verifying Loopback Health ==="
|
||||||
|
loopback_html=$(ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i ~/.ssh/id_ed25519 "$DEPLOY_USER@$DEPLOY_HOST" "curl -sf http://127.0.0.1:5000/ || true")
|
||||||
|
if ! printf '%s' "$loopback_html" | grep -q "Quant Engine"; then
|
||||||
|
echo "Loopback health check failed for quantengine" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Verifying Favicon Assets ==="
|
||||||
|
favicon_svg_code=$(curl -s -o /dev/null -w "%{http_code}" "http://${DEPLOY_HOST}/favicon.svg")
|
||||||
|
favicon_png_code=$(curl -s -o /dev/null -w "%{http_code}" "http://${DEPLOY_HOST}/favicon.png")
|
||||||
|
echo "/favicon.svg -> ${favicon_svg_code}"
|
||||||
|
echo "/favicon.png -> ${favicon_png_code}"
|
||||||
|
if [ "$favicon_svg_code" != "200" ] && [ "$favicon_png_code" != "200" ]; then
|
||||||
|
echo "Favicon assets are not reachable after deploy" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo "=== Verifying Public Routes ==="
|
echo "=== Verifying Public Routes ==="
|
||||||
root_html=$(curl -sf "http://${DEPLOY_HOST}/quant/" 2>/dev/null || echo "")
|
root_html=$(curl -sf "http://${DEPLOY_HOST}/" 2>/dev/null || echo "")
|
||||||
ops_html=$(curl -sf "http://${DEPLOY_HOST}/quant/operations" 2>/dev/null || echo "")
|
ops_html=$(curl -sf "http://${DEPLOY_HOST}/operations" 2>/dev/null || echo "")
|
||||||
|
|
||||||
root_code=$(printf '%s' "$root_html" | grep -q "Quant Engine" && echo 200 || echo 500)
|
root_code=$(printf '%s' "$root_html" | grep -q "Quant Engine" && echo 200 || echo 500)
|
||||||
ops_code=$(printf '%s' "$ops_html" | grep -q "Operational Report" && echo 200 || echo 500)
|
ops_code=$(printf '%s' "$ops_html" | grep -q "Operational Report" && echo 200 || echo 500)
|
||||||
|
|
||||||
echo "/quant/ -> ${root_code}"
|
echo "/ -> ${root_code}"
|
||||||
echo "/quant/operations -> ${ops_code}"
|
echo "/operations -> ${ops_code}"
|
||||||
|
|
||||||
if [ "$root_code" != "200" ]; then
|
if [ "$root_code" != "200" ]; then
|
||||||
echo "Deployment content check failed for /quant/" >&2
|
echo "Deployment content check failed for /" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
if [ "$ops_code" != "200" ]; then
|
if [ "$ops_code" != "200" ]; then
|
||||||
echo "Deployment content check failed for /quant/operations" >&2
|
echo "Deployment content check failed for /operations" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -110,6 +110,8 @@
|
|||||||
- D+2 영업일 기준 현금을 즉시방어 자산으로 간주하고, 목표 예산 5억 원을 기준으로 포지션 사이징 및 리스크 버킷을 제어한다.
|
- D+2 영업일 기준 현금을 즉시방어 자산으로 간주하고, 목표 예산 5억 원을 기준으로 포지션 사이징 및 리스크 버킷을 제어한다.
|
||||||
- 매주 주말 리밸런싱(rebalance_required=true) 및 매월 1일/11일/21일 중간점검(mid_check_required=true) 운영 cadence를 준수한다.
|
- 매주 주말 리밸런싱(rebalance_required=true) 및 매월 1일/11일/21일 중간점검(mid_check_required=true) 운영 cadence를 준수한다.
|
||||||
- 커밋, 푸쉬, PR 작업 시 반드시 로컬의 .gs 파일을 Google Apps Script 원격 프로젝트에 업로드(python tools/deploy_gas.py 실행)하고, 사용자에게 스프레드시트 상의 스크립트 실행(예: runDataFeed)을 통한 검증을 유도 및 가이드해야 한다.
|
- 커밋, 푸쉬, PR 작업 시 반드시 로컬의 .gs 파일을 Google Apps Script 원격 프로젝트에 업로드(python tools/deploy_gas.py 실행)하고, 사용자에게 스프레드시트 상의 스크립트 실행(예: runDataFeed)을 통한 검증을 유도 및 가이드해야 한다.
|
||||||
|
- QuantEngine 배포는 CI 전용이다. 로컬에서 서버로 산출물을 직접 업로드하거나 `scp`/`rsync`로 수동 반영하지 않는다. 실배포는 `.gitea/workflows/snapshot_admin_deploy.yml`만 사용하며, 로컬 스크립트는 CI 환경에서만 실행 가능해야 한다.
|
||||||
|
- 원격 서버 확인이 필요하면 `ssh kjh2064@178.104.200.7` 접속을 먼저 시도하고, 사용자에게 매번 접속 확인을 요구하지 말고 직접 상태/로그/헬스체크를 수집한 뒤 결과만 보고한다.
|
||||||
|
|
||||||
## 4. 보고 규칙
|
## 4. 보고 규칙
|
||||||
- 모든 숫자에는 반드시 provenance(출처)를 남기며, 출처가 유효하지 않거나 없는 숫자는 보고서 표기를 전면 배제(DATA_MISSING 처리)한다.
|
- 모든 숫자에는 반드시 provenance(출처)를 남기며, 출처가 유효하지 않거나 없는 숫자는 보고서 표기를 전면 배제(DATA_MISSING 처리)한다.
|
||||||
|
|||||||
@@ -207,6 +207,7 @@ services:
|
|||||||
|
|
||||||
- `.gitea/workflows/ci.yml`: 검증 전용. 스펙/공식/리포트/아티팩트 생성까지만 수행한다.
|
- `.gitea/workflows/ci.yml`: 검증 전용. 스펙/공식/리포트/아티팩트 생성까지만 수행한다.
|
||||||
- `.gitea/workflows/snapshot_admin_deploy.yml`: 실배포 전용. `dotnet publish` 후 `tools/deploy_quantengine.sh`를 이용해 `/home/kjh2064/quantengine_active`로 반영한다.
|
- `.gitea/workflows/snapshot_admin_deploy.yml`: 실배포 전용. `dotnet publish` 후 `tools/deploy_quantengine.sh`를 이용해 `/home/kjh2064/quantengine_active`로 반영한다.
|
||||||
|
- 수동 배포 금지: 로컬에서 `scp`/`rsync`로 `quantengine_active`를 갱신하지 않는다. 배포는 CI가 원격에서만 수행하고, 로컬 스크립트는 `CI_DEPLOY=1` 없이 실행되면 실패해야 한다.
|
||||||
- 공개 URL `/quant/` 갱신은 `snapshot_admin_deploy.yml`의 성공 여부를 기준으로 판단한다.
|
- 공개 URL `/quant/` 갱신은 `snapshot_admin_deploy.yml`의 성공 여부를 기준으로 판단한다.
|
||||||
|
|
||||||
### 6.2. 러너 설정
|
### 6.2. 러너 설정
|
||||||
@@ -401,19 +402,9 @@ docker ps -a
|
|||||||
### QuantEngine 배포
|
### QuantEngine 배포
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 1. 새 배포 디렉토리 생성
|
# CI에서만 배포
|
||||||
DEPLOY_DIR=~/deployments/quantengine_$(date +%Y%m%d_%H%M%S)
|
# 로컬에서 scp/rsync로 quantengine_active를 갱신하지 않는다.
|
||||||
mkdir -p "$DEPLOY_DIR"
|
# 배포는 .gitea/workflows/snapshot_admin_deploy.yml 실행 결과로만 반영한다.
|
||||||
|
|
||||||
# 2. 빌드 산출물 복사 (로컬에서 scp 또는 CI에서)
|
|
||||||
scp -r publish/* kjh2064@178.104.200.7:"$DEPLOY_DIR"/
|
|
||||||
|
|
||||||
# 3. symlink 교체
|
|
||||||
ln -sfn "$DEPLOY_DIR" ~/quantengine_active
|
|
||||||
|
|
||||||
# 4. 서비스 재시작
|
|
||||||
sudo systemctl restart quantengine
|
|
||||||
sudo systemctl status quantengine
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Gitea Act Runner 등록
|
### Gitea Act Runner 등록
|
||||||
|
|||||||
@@ -123,6 +123,12 @@ public class ApplicationServiceTests
|
|||||||
public (string Domain, string TargetRef)? LastReleasedLock { get; private set; }
|
public (string Domain, string TargetRef)? LastReleasedLock { get; private set; }
|
||||||
|
|
||||||
public Task<IEnumerable<Setting>> GetSettingsAsync() => Task.FromResult(Enumerable.Empty<Setting>());
|
public Task<IEnumerable<Setting>> GetSettingsAsync() => Task.FromResult(Enumerable.Empty<Setting>());
|
||||||
|
public Task<IEnumerable<WorkspaceAccount>> GetAccountsAsync() => Task.FromResult(Enumerable.Empty<WorkspaceAccount>());
|
||||||
|
public Task<WorkspaceAccount?> GetAccountByUsernameAsync(string username) => Task.FromResult<WorkspaceAccount?>(null);
|
||||||
|
public Task<bool> UpsertAccountAsync(WorkspaceAccount account) => Task.FromResult(true);
|
||||||
|
public Task<WorkspaceSession?> GetSessionByTokenHashAsync(string tokenHash) => Task.FromResult<WorkspaceSession?>(null);
|
||||||
|
public Task<bool> UpsertSessionAsync(WorkspaceSession session) => Task.FromResult(true);
|
||||||
|
public Task<bool> RevokeSessionAsync(string tokenHash, string revokedAt) => Task.FromResult(true);
|
||||||
public Task<Setting?> GetSettingByKeyAsync(string key) => Task.FromResult<Setting?>(null);
|
public Task<Setting?> GetSettingByKeyAsync(string key) => Task.FromResult<Setting?>(null);
|
||||||
public Task<bool> UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); }
|
public Task<bool> UpsertSettingAsync(Setting setting) { LastSetting = setting; return Task.FromResult(true); }
|
||||||
public Task<bool> DeleteSettingAsync(string key) => Task.FromResult(true);
|
public Task<bool> DeleteSettingAsync(string key) => Task.FromResult(true);
|
||||||
|
|||||||
@@ -6,6 +6,14 @@ namespace QuantEngine.Core.Interfaces
|
|||||||
{
|
{
|
||||||
public interface IWorkspaceRepository
|
public interface IWorkspaceRepository
|
||||||
{
|
{
|
||||||
|
// Accounts
|
||||||
|
Task<IEnumerable<WorkspaceAccount>> GetAccountsAsync();
|
||||||
|
Task<WorkspaceAccount?> GetAccountByUsernameAsync(string username);
|
||||||
|
Task<bool> UpsertAccountAsync(WorkspaceAccount account);
|
||||||
|
Task<WorkspaceSession?> GetSessionByTokenHashAsync(string tokenHash);
|
||||||
|
Task<bool> UpsertSessionAsync(WorkspaceSession session);
|
||||||
|
Task<bool> RevokeSessionAsync(string tokenHash, string revokedAt);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
Task<IEnumerable<Setting>> GetSettingsAsync();
|
Task<IEnumerable<Setting>> GetSettingsAsync();
|
||||||
Task<Setting?> GetSettingByKeyAsync(string key);
|
Task<Setting?> GetSettingByKeyAsync(string key);
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
namespace QuantEngine.Core.Models
|
||||||
|
{
|
||||||
|
public class WorkspaceAccount
|
||||||
|
{
|
||||||
|
public int Ordinal { get; set; }
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string PasswordHash { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = "Admin";
|
||||||
|
public string IsActive { get; set; } = "true";
|
||||||
|
public string CreatedAt { get; set; } = string.Empty;
|
||||||
|
public string UpdatedAt { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
namespace QuantEngine.Core.Models
|
||||||
|
{
|
||||||
|
public class WorkspaceSession
|
||||||
|
{
|
||||||
|
public string SessionTokenHash { get; set; } = string.Empty;
|
||||||
|
public string Username { get; set; } = string.Empty;
|
||||||
|
public string Role { get; set; } = "Admin";
|
||||||
|
public string CreatedAt { get; set; } = string.Empty;
|
||||||
|
public string ExpiresAt { get; set; } = string.Empty;
|
||||||
|
public string? RevokedAt { get; set; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -30,6 +30,32 @@ namespace QuantEngine.Infrastructure.Data
|
|||||||
);
|
);
|
||||||
");
|
");
|
||||||
|
|
||||||
|
// 0b. workspace_account
|
||||||
|
conn.Execute(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_account (
|
||||||
|
ordinal INT NOT NULL,
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
password_hash TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'Admin',
|
||||||
|
is_active TEXT NOT NULL DEFAULT 'true',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_account_active ON workspace_account(is_active, username);
|
||||||
|
");
|
||||||
|
|
||||||
|
conn.Execute(@"
|
||||||
|
CREATE TABLE IF NOT EXISTS workspace_session (
|
||||||
|
session_token_hash TEXT PRIMARY KEY,
|
||||||
|
username TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'Admin',
|
||||||
|
created_at TEXT NOT NULL,
|
||||||
|
expires_at TEXT NOT NULL,
|
||||||
|
revoked_at TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_workspace_session_username ON workspace_session(username, expires_at DESC);
|
||||||
|
");
|
||||||
|
|
||||||
// 1. collection_runs
|
// 1. collection_runs
|
||||||
conn.Execute(@"
|
conn.Execute(@"
|
||||||
CREATE TABLE IF NOT EXISTS collection_runs (
|
CREATE TABLE IF NOT EXISTS collection_runs (
|
||||||
@@ -157,6 +183,16 @@ namespace QuantEngine.Infrastructure.Data
|
|||||||
);
|
);
|
||||||
");
|
");
|
||||||
|
|
||||||
|
conn.Execute(@"
|
||||||
|
INSERT INTO quantengine.workspace_account (
|
||||||
|
ordinal, username, password_hash, role, is_active, created_at, updated_at
|
||||||
|
)
|
||||||
|
SELECT 1, 'admin', '8C6976E5B5410415BDE908BD4DEE15DFB167A9C873FC4BB8A81F6F2AB448A918', 'Admin', 'true', NOW()::text, NOW()::text
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM quantengine.workspace_account WHERE username = 'admin'
|
||||||
|
);
|
||||||
|
");
|
||||||
|
|
||||||
// 10. engine_history schema and tables
|
// 10. engine_history schema and tables
|
||||||
conn.Execute(@"
|
conn.Execute(@"
|
||||||
CREATE SCHEMA IF NOT EXISTS engine_history;
|
CREATE SCHEMA IF NOT EXISTS engine_history;
|
||||||
|
|||||||
@@ -17,6 +17,89 @@ namespace QuantEngine.Infrastructure.Repositories
|
|||||||
_connectionFactory = connectionFactory;
|
_connectionFactory = connectionFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Accounts
|
||||||
|
public async Task<IEnumerable<WorkspaceAccount>> GetAccountsAsync()
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
return await conn.QueryAsync<WorkspaceAccount>(@"
|
||||||
|
SELECT ordinal, username as Username, password_hash as PasswordHash, role as Role,
|
||||||
|
is_active as IsActive, created_at as CreatedAt, updated_at as UpdatedAt
|
||||||
|
FROM quantengine.workspace_account
|
||||||
|
ORDER BY ordinal ASC"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkspaceAccount?> GetAccountByUsernameAsync(string username)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<WorkspaceAccount>(@"
|
||||||
|
SELECT ordinal, username as Username, password_hash as PasswordHash, role as Role,
|
||||||
|
is_active as IsActive, created_at as CreatedAt, updated_at as UpdatedAt
|
||||||
|
FROM quantengine.workspace_account
|
||||||
|
WHERE username = @Username",
|
||||||
|
new { Username = username }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpsertAccountAsync(WorkspaceAccount account)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
var affected = await conn.ExecuteAsync(@"
|
||||||
|
INSERT INTO quantengine.workspace_account (ordinal, username, password_hash, role, is_active, created_at, updated_at)
|
||||||
|
VALUES (@Ordinal, @Username, @PasswordHash, @Role, @IsActive, @CreatedAt, @UpdatedAt)
|
||||||
|
ON CONFLICT (username) DO UPDATE SET
|
||||||
|
ordinal = EXCLUDED.ordinal,
|
||||||
|
password_hash = EXCLUDED.password_hash,
|
||||||
|
role = EXCLUDED.role,
|
||||||
|
is_active = EXCLUDED.is_active,
|
||||||
|
updated_at = EXCLUDED.updated_at",
|
||||||
|
account
|
||||||
|
);
|
||||||
|
return affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<WorkspaceSession?> GetSessionByTokenHashAsync(string tokenHash)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
return await conn.QueryFirstOrDefaultAsync<WorkspaceSession>(@"
|
||||||
|
SELECT session_token_hash as SessionTokenHash, username as Username, role as Role,
|
||||||
|
created_at as CreatedAt, expires_at as ExpiresAt, revoked_at as RevokedAt
|
||||||
|
FROM quantengine.workspace_session
|
||||||
|
WHERE session_token_hash = @TokenHash",
|
||||||
|
new { TokenHash = tokenHash }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> UpsertSessionAsync(WorkspaceSession session)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
var affected = await conn.ExecuteAsync(@"
|
||||||
|
INSERT INTO quantengine.workspace_session
|
||||||
|
(session_token_hash, username, role, created_at, expires_at, revoked_at)
|
||||||
|
VALUES
|
||||||
|
(@SessionTokenHash, @Username, @Role, @CreatedAt, @ExpiresAt, @RevokedAt)
|
||||||
|
ON CONFLICT (session_token_hash) DO UPDATE SET
|
||||||
|
username = EXCLUDED.username,
|
||||||
|
role = EXCLUDED.role,
|
||||||
|
expires_at = EXCLUDED.expires_at,
|
||||||
|
revoked_at = EXCLUDED.revoked_at",
|
||||||
|
session
|
||||||
|
);
|
||||||
|
return affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<bool> RevokeSessionAsync(string tokenHash, string revokedAt)
|
||||||
|
{
|
||||||
|
using var conn = _connectionFactory.CreateConnection();
|
||||||
|
var affected = await conn.ExecuteAsync(@"
|
||||||
|
UPDATE quantengine.workspace_session
|
||||||
|
SET revoked_at = @RevokedAt
|
||||||
|
WHERE session_token_hash = @TokenHash",
|
||||||
|
new { TokenHash = tokenHash, RevokedAt = revokedAt }
|
||||||
|
);
|
||||||
|
return affected > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
public async Task<IEnumerable<Setting>> GetSettingsAsync()
|
public async Task<IEnumerable<Setting>> GetSettingsAsync()
|
||||||
{
|
{
|
||||||
|
|||||||
+49
-9
@@ -7,25 +7,41 @@ namespace QuantEngine.Web.Client.Infrastructure
|
|||||||
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
public class CustomAuthenticationStateProvider : AuthenticationStateProvider
|
||||||
{
|
{
|
||||||
private readonly LocalStorageService _localStorage;
|
private readonly LocalStorageService _localStorage;
|
||||||
|
private readonly HttpClient _http;
|
||||||
private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
|
private readonly ClaimsPrincipal _anonymous = new ClaimsPrincipal(new ClaimsIdentity());
|
||||||
private const string StorageKey = "quant_admin_session";
|
private const string TokenKey = "quant_admin_access_token";
|
||||||
|
private const string UsernameKey = "quant_admin_username";
|
||||||
|
private const string RoleKey = "quant_admin_role";
|
||||||
|
|
||||||
public CustomAuthenticationStateProvider(LocalStorageService localStorage)
|
public CustomAuthenticationStateProvider(LocalStorageService localStorage, HttpClient http)
|
||||||
{
|
{
|
||||||
_localStorage = localStorage;
|
_localStorage = localStorage;
|
||||||
|
_http = http;
|
||||||
}
|
}
|
||||||
|
|
||||||
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
public override async Task<AuthenticationState> GetAuthenticationStateAsync()
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var username = await _localStorage.GetAsync<string>(StorageKey);
|
var token = await _localStorage.GetAsync<string>(TokenKey);
|
||||||
if (!string.IsNullOrEmpty(username))
|
var username = await _localStorage.GetAsync<string>(UsernameKey);
|
||||||
|
var role = await _localStorage.GetAsync<string>(RoleKey) ?? "Admin";
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(token) && !string.IsNullOrWhiteSpace(username))
|
||||||
{
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Get, "api/auth/me");
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
|
var response = await _http.SendAsync(request);
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
await MarkUserAsLoggedOutAsync();
|
||||||
|
return new AuthenticationState(_anonymous);
|
||||||
|
}
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(new[]
|
var identity = new ClaimsIdentity(new[]
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.Name, username),
|
new Claim(ClaimTypes.Name, username),
|
||||||
new Claim(ClaimTypes.Role, "Admin")
|
new Claim(ClaimTypes.Role, role)
|
||||||
}, "QuantAdminAuth");
|
}, "QuantAdminAuth");
|
||||||
|
|
||||||
var user = new ClaimsPrincipal(identity);
|
var user = new ClaimsPrincipal(identity);
|
||||||
@@ -40,14 +56,16 @@ namespace QuantEngine.Web.Client.Infrastructure
|
|||||||
return new AuthenticationState(_anonymous);
|
return new AuthenticationState(_anonymous);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MarkUserAsAuthenticatedAsync(string username)
|
public async Task MarkUserAsAuthenticatedAsync(string username, string accessToken, string role)
|
||||||
{
|
{
|
||||||
await _localStorage.SetAsync(StorageKey, username);
|
await _localStorage.SetAsync(TokenKey, accessToken);
|
||||||
|
await _localStorage.SetAsync(UsernameKey, username);
|
||||||
|
await _localStorage.SetAsync(RoleKey, role);
|
||||||
|
|
||||||
var identity = new ClaimsIdentity(new[]
|
var identity = new ClaimsIdentity(new[]
|
||||||
{
|
{
|
||||||
new Claim(ClaimTypes.Name, username),
|
new Claim(ClaimTypes.Name, username),
|
||||||
new Claim(ClaimTypes.Role, "Admin")
|
new Claim(ClaimTypes.Role, role)
|
||||||
}, "QuantAdminAuth");
|
}, "QuantAdminAuth");
|
||||||
|
|
||||||
var user = new ClaimsPrincipal(identity);
|
var user = new ClaimsPrincipal(identity);
|
||||||
@@ -56,8 +74,30 @@ namespace QuantEngine.Web.Client.Infrastructure
|
|||||||
|
|
||||||
public async Task MarkUserAsLoggedOutAsync()
|
public async Task MarkUserAsLoggedOutAsync()
|
||||||
{
|
{
|
||||||
await _localStorage.DeleteAsync(StorageKey);
|
await _localStorage.DeleteAsync(TokenKey);
|
||||||
|
await _localStorage.DeleteAsync(UsernameKey);
|
||||||
|
await _localStorage.DeleteAsync(RoleKey);
|
||||||
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_anonymous)));
|
NotifyAuthenticationStateChanged(Task.FromResult(new AuthenticationState(_anonymous)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task LogoutFromServerAsync()
|
||||||
|
{
|
||||||
|
var token = await _localStorage.GetAsync<string>(TokenKey);
|
||||||
|
if (!string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var request = new HttpRequestMessage(HttpMethod.Post, "api/auth/logout");
|
||||||
|
request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
|
await _http.SendAsync(request);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
// Best-effort server revocation; always clear local state.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await MarkUserAsLoggedOutAsync();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
private async Task HandleLogoutAsync()
|
private async Task HandleLogoutAsync()
|
||||||
{
|
{
|
||||||
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
||||||
await customProvider.MarkUserAsLoggedOutAsync();
|
await customProvider.LogoutFromServerAsync();
|
||||||
NavigationManager.NavigateTo("login");
|
NavigationManager.NavigateTo("login");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
<p class="brand-subtitle">은퇴자산포트폴리오 투자 관리 시스템</p>
|
<p class="brand-subtitle">은퇴자산포트폴리오 투자 관리 시스템</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form @onsubmit="HandleLoginAsync" class="auth-form">
|
<form @onsubmit="HandleLoginAsync" @onsubmit:preventDefault="true" class="auth-form">
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label for="username">관리자 아이디</label>
|
<label for="username">관리자 아이디</label>
|
||||||
<input type="text" id="username" class="form-control" @bind="Username" placeholder="아이디를 입력하세요" autocomplete="username" />
|
<input type="text" id="username" class="form-control" @bind="Username" placeholder="아이디를 입력하세요" autocomplete="username" />
|
||||||
@@ -268,6 +268,15 @@
|
|||||||
private string ErrorMessage { get; set; } = string.Empty;
|
private string ErrorMessage { get; set; } = string.Empty;
|
||||||
private bool IsSubmitting { get; set; } = false;
|
private bool IsSubmitting { get; set; } = false;
|
||||||
|
|
||||||
|
private sealed class LoginResponse
|
||||||
|
{
|
||||||
|
public bool Success { get; set; }
|
||||||
|
public string? Username { get; set; }
|
||||||
|
public string? Role { get; set; }
|
||||||
|
public string? AccessToken { get; set; }
|
||||||
|
public string? ExpiresAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
private async Task HandleLoginAsync()
|
private async Task HandleLoginAsync()
|
||||||
{
|
{
|
||||||
ErrorMessage = string.Empty;
|
ErrorMessage = string.Empty;
|
||||||
@@ -285,8 +294,15 @@
|
|||||||
|
|
||||||
if (response.IsSuccessStatusCode)
|
if (response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
var auth = await response.Content.ReadFromJsonAsync<LoginResponse>();
|
||||||
|
if (auth is null || string.IsNullOrWhiteSpace(auth.AccessToken))
|
||||||
|
{
|
||||||
|
ErrorMessage = "로그인 응답이 유효하지 않습니다.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
var customProvider = (CustomAuthenticationStateProvider)AuthStateProvider;
|
||||||
await customProvider.MarkUserAsAuthenticatedAsync(Username);
|
await customProvider.MarkUserAsAuthenticatedAsync(auth.Username ?? Username, auth.AccessToken, auth.Role ?? "Admin");
|
||||||
|
|
||||||
// Redirect back to home dashboard
|
// Redirect back to home dashboard
|
||||||
NavigationManager.NavigateTo("");
|
NavigationManager.NavigateTo("");
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<base href="/quant/" />
|
<base href="/" />
|
||||||
<ResourcePreloader />
|
<ResourcePreloader />
|
||||||
<!-- Fluent UI CSS -->
|
<!-- Fluent UI CSS -->
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" rel="stylesheet" />
|
||||||
@@ -12,7 +12,8 @@
|
|||||||
<link rel="stylesheet" href="@Assets["app.css"]" />
|
<link rel="stylesheet" href="@Assets["app.css"]" />
|
||||||
<link rel="stylesheet" href="@Assets["QuantEngine.Web.styles.css"]" />
|
<link rel="stylesheet" href="@Assets["QuantEngine.Web.styles.css"]" />
|
||||||
<ImportMap />
|
<ImportMap />
|
||||||
<link rel="icon" type="image/png" href="favicon.png" />
|
<link rel="icon" type="image/svg+xml" href="favicon.svg" />
|
||||||
|
<link rel="alternate icon" type="image/png" href="favicon.png" />
|
||||||
<HeadOutlet @rendermode="InteractiveWebAssembly" />
|
<HeadOutlet @rendermode="InteractiveWebAssembly" />
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ using Serilog;
|
|||||||
using QuantEngine.Web.Client.Infrastructure;
|
using QuantEngine.Web.Client.Infrastructure;
|
||||||
using QuantEngine.Web.Client.Services;
|
using QuantEngine.Web.Client.Services;
|
||||||
using QuantEngine.Web.Endpoints;
|
using QuantEngine.Web.Endpoints;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using QuantEngine.Core.Models;
|
||||||
|
|
||||||
// Serilog Configuration with Telegram Sink
|
// Serilog Configuration with Telegram Sink
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
@@ -30,6 +33,8 @@ builder.Services.AddRazorComponents()
|
|||||||
|
|
||||||
// Authentication and Custom State Provider (Shared client components)
|
// Authentication and Custom State Provider (Shared client components)
|
||||||
builder.Services.AddCascadingAuthenticationState();
|
builder.Services.AddCascadingAuthenticationState();
|
||||||
|
builder.Services.AddAuthentication();
|
||||||
|
builder.Services.AddAuthorization();
|
||||||
builder.Services.AddScoped<LocalStorageService>();
|
builder.Services.AddScoped<LocalStorageService>();
|
||||||
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
|
builder.Services.AddScoped<AuthenticationStateProvider, CustomAuthenticationStateProvider>();
|
||||||
builder.Services.AddAuthorizationCore();
|
builder.Services.AddAuthorizationCore();
|
||||||
@@ -41,6 +46,7 @@ builder.Services.AddFluentUIComponents();
|
|||||||
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection")
|
||||||
?? "Host=127.0.0.1;Database=giteadb;Username=gitea;Password=C8RFlZ9fdQrBA1vyLhLDS4v70I8dJfRS2ERJW4+zsS4=;Search Path=quantengine;";
|
?? "Host=127.0.0.1;Database=giteadb;Username=gitea;Password=C8RFlZ9fdQrBA1vyLhLDS4v70I8dJfRS2ERJW4+zsS4=;Search Path=quantengine;";
|
||||||
builder.Services.AddSingleton<IDbConnectionFactory>(new DbConnectionFactory(connectionString));
|
builder.Services.AddSingleton<IDbConnectionFactory>(new DbConnectionFactory(connectionString));
|
||||||
|
builder.Services.AddSingleton<DbMigrator>();
|
||||||
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
builder.Services.AddScoped<IWorkspaceRepository, WorkspaceRepository>();
|
||||||
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
builder.Services.AddScoped<IPostgresqlHistoryStore, PostgresqlHistoryStore>();
|
||||||
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
builder.Services.AddScoped<IPostgresqlHistorySnapshotReader, PostgresqlHistorySnapshotReader>();
|
||||||
@@ -61,14 +67,18 @@ var app = builder.Build();
|
|||||||
// Initialize database tables (PostgreSQL-backed repositories)
|
// Initialize database tables (PostgreSQL-backed repositories)
|
||||||
using (var scope = app.Services.CreateScope())
|
using (var scope = app.Services.CreateScope())
|
||||||
{
|
{
|
||||||
|
var migrator = scope.ServiceProvider.GetRequiredService<DbMigrator>();
|
||||||
var tokenCache = scope.ServiceProvider.GetRequiredService<ITokenCache>();
|
var tokenCache = scope.ServiceProvider.GetRequiredService<ITokenCache>();
|
||||||
var collectionRepo = scope.ServiceProvider.GetRequiredService<ICollectionRepository>();
|
var collectionRepo = scope.ServiceProvider.GetRequiredService<ICollectionRepository>();
|
||||||
|
var workspaceRepo = scope.ServiceProvider.GetRequiredService<IWorkspaceRepository>();
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
migrator.Migrate();
|
||||||
// Ensure tables exist on startup
|
// Ensure tables exist on startup
|
||||||
await tokenCache.GetCachedTokenAsync("_init_test_");
|
await tokenCache.GetCachedTokenAsync("_init_test_");
|
||||||
await collectionRepo.GetDashboardStateAsync();
|
await collectionRepo.GetDashboardStateAsync();
|
||||||
|
await workspaceRepo.GetAccountsAsync();
|
||||||
Log.Information("Database tables initialized successfully");
|
Log.Information("Database tables initialized successfully");
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -77,9 +87,6 @@ using (var scope = app.Services.CreateScope())
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Enable reverse proxy subpath mapping
|
|
||||||
app.UsePathBase("/quant");
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure the HTTP request pipeline.
|
||||||
if (!app.Environment.IsDevelopment())
|
if (!app.Environment.IsDevelopment())
|
||||||
{
|
{
|
||||||
@@ -96,6 +103,8 @@ app.UseStatusCodePages(async ctx =>
|
|||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
app.UseAntiforgery();
|
app.UseAntiforgery();
|
||||||
|
app.UseAuthentication();
|
||||||
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapStaticAssets();
|
app.MapStaticAssets();
|
||||||
|
|
||||||
@@ -103,16 +112,91 @@ app.MapStaticAssets();
|
|||||||
app.MapCollectionEndpoints();
|
app.MapCollectionEndpoints();
|
||||||
|
|
||||||
// Login API (API-First for Blazor WASM client authentication)
|
// Login API (API-First for Blazor WASM client authentication)
|
||||||
app.MapPost("/api/auth/login", (LoginRequest request, IConfiguration config) =>
|
app.MapPost("/api/auth/login", async (LoginRequest request, IWorkspaceRepository workspaceRepo) =>
|
||||||
{
|
{
|
||||||
var expectedUser = config["AdminSettings:Username"] ?? "admin";
|
if (string.IsNullOrWhiteSpace(request.Username) || string.IsNullOrWhiteSpace(request.Password))
|
||||||
var expectedPass = config["AdminSettings:Password"] ?? "quant123!";
|
|
||||||
|
|
||||||
if (request.Username == expectedUser && request.Password == expectedPass)
|
|
||||||
{
|
{
|
||||||
return Results.Ok(new { success = true, username = request.Username });
|
return Results.BadRequest(new { success = false, error = "missing_credentials" });
|
||||||
}
|
}
|
||||||
return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401);
|
|
||||||
|
var account = await workspaceRepo.GetAccountByUsernameAsync(request.Username.Trim());
|
||||||
|
if (account is null || !string.Equals(account.IsActive, "true", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
var passwordHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(request.Password)));
|
||||||
|
if (!string.Equals(account.PasswordHash, passwordHash, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.Json(new { success = false, error = "invalid_credentials" }, statusCode: 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
var rawToken = Guid.NewGuid().ToString("N");
|
||||||
|
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(rawToken)));
|
||||||
|
var now = DateTimeOffset.UtcNow;
|
||||||
|
var expiresAt = now.AddDays(7);
|
||||||
|
|
||||||
|
await workspaceRepo.UpsertSessionAsync(new WorkspaceSession
|
||||||
|
{
|
||||||
|
SessionTokenHash = tokenHash,
|
||||||
|
Username = account.Username,
|
||||||
|
Role = account.Role,
|
||||||
|
CreatedAt = now.ToString("O"),
|
||||||
|
ExpiresAt = expiresAt.ToString("O"),
|
||||||
|
RevokedAt = null
|
||||||
|
});
|
||||||
|
|
||||||
|
return Results.Ok(new
|
||||||
|
{
|
||||||
|
success = true,
|
||||||
|
username = account.Username,
|
||||||
|
role = account.Role,
|
||||||
|
accessToken = rawToken,
|
||||||
|
expiresAt = expiresAt.ToString("O")
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapGet("/api/auth/me", async (HttpContext context, IWorkspaceRepository workspaceRepo) =>
|
||||||
|
{
|
||||||
|
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = authHeader["Bearer ".Length..].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||||
|
var session = await workspaceRepo.GetSessionByTokenHashAsync(tokenHash);
|
||||||
|
if (session is null || !string.IsNullOrWhiteSpace(session.RevokedAt) || DateTimeOffset.TryParse(session.ExpiresAt, out var expiresAt) && expiresAt <= DateTimeOffset.UtcNow)
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
return Results.Ok(new { authenticated = true, username = session.Username, role = session.Role });
|
||||||
|
});
|
||||||
|
|
||||||
|
app.MapPost("/api/auth/logout", async (HttpContext context, IWorkspaceRepository workspaceRepo) =>
|
||||||
|
{
|
||||||
|
var authHeader = context.Request.Headers.Authorization.ToString();
|
||||||
|
if (string.IsNullOrWhiteSpace(authHeader) || !authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var token = authHeader["Bearer ".Length..].Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(token))
|
||||||
|
{
|
||||||
|
return Results.Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
|
||||||
|
await workspaceRepo.RevokeSessionAsync(tokenHash, DateTimeOffset.UtcNow.ToString("O"));
|
||||||
|
return Results.Ok(new { success = true });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Operational Report serving API (WASM safe file loading substitute)
|
// Operational Report serving API (WASM safe file loading substitute)
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="QuantEngine favicon">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="g" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" stop-color="#00f2fe"/>
|
||||||
|
<stop offset="100%" stop-color="#4facfe"/>
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
<rect width="64" height="64" rx="16" fill="#0b1020"/>
|
||||||
|
<circle cx="32" cy="32" r="22" fill="url(#g)" opacity="0.18"/>
|
||||||
|
<path d="M20 24h12c7.2 0 12 4.5 12 10.8 0 4.6-2.4 8.1-6.5 9.8L45 44h-8l-6.2-6.2H28V44h-8V24Zm8 6v4h4.6c2 0 3.4-.8 3.4-2.1 0-1.4-1.4-1.9-3.3-1.9H28Z" fill="url(#g)"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 602 B |
@@ -1,8 +1,12 @@
|
|||||||
#!/bin/bash
|
#!/usr/bin/env bash
|
||||||
# Quant Engine Shadow Copy Hot Deploy Script
|
# Quant Engine CI-only hot deploy script
|
||||||
# To be executed on Hz-Prod-01 Remote Server
|
|
||||||
|
|
||||||
set -e
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ "${CI_DEPLOY:-0}" != "1" ]; then
|
||||||
|
echo "ERROR: CI-only deployment policy. Use the Gitea workflow to deploy."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
DEPLOY_BASE="/home/kjh2064/deployments"
|
DEPLOY_BASE="/home/kjh2064/deployments"
|
||||||
ACTIVE_LINK="/home/kjh2064/quantengine_active"
|
ACTIVE_LINK="/home/kjh2064/quantengine_active"
|
||||||
@@ -15,11 +19,9 @@ echo "========================================="
|
|||||||
echo "Starting Shadow Copy Hot Deploy [${TIMESTAMP}]"
|
echo "Starting Shadow Copy Hot Deploy [${TIMESTAMP}]"
|
||||||
echo "========================================="
|
echo "========================================="
|
||||||
|
|
||||||
# 1. Ensure directories exist
|
|
||||||
mkdir -p "${DEPLOY_BASE}"
|
mkdir -p "${DEPLOY_BASE}"
|
||||||
mkdir -p "${TARGET_DIR}"
|
mkdir -p "${TARGET_DIR}"
|
||||||
|
|
||||||
# 2. Extract build artifact to unique shadow directory
|
|
||||||
if [ -f "${TMP_ARCHIVE}" ]; then
|
if [ -f "${TMP_ARCHIVE}" ]; then
|
||||||
echo "Extracting build artifact to ${TARGET_DIR}..."
|
echo "Extracting build artifact to ${TARGET_DIR}..."
|
||||||
tar -xzf "${TMP_ARCHIVE}" -C "${TARGET_DIR}"
|
tar -xzf "${TMP_ARCHIVE}" -C "${TARGET_DIR}"
|
||||||
@@ -29,15 +31,12 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Swap symbolic link atomically
|
|
||||||
echo "Swapping symbolic link dynamically..."
|
echo "Swapping symbolic link dynamically..."
|
||||||
ln -sfn "${TARGET_DIR}" "${ACTIVE_LINK}"
|
ln -sfn "${TARGET_DIR}" "${ACTIVE_LINK}"
|
||||||
|
|
||||||
# 4. Restart Systemd service (requires passwordless sudo reload or specific policy)
|
|
||||||
echo "Restarting Systemd service..."
|
echo "Restarting Systemd service..."
|
||||||
sudo systemctl restart quantengine
|
sudo systemctl restart quantengine
|
||||||
|
|
||||||
# 5. Clean up old deployments (keep last 5)
|
|
||||||
echo "Cleaning up obsolete deployments..."
|
echo "Cleaning up obsolete deployments..."
|
||||||
cd "${DEPLOY_BASE}"
|
cd "${DEPLOY_BASE}"
|
||||||
ls -dt quantengine_* | tail -n +6 | while read -r old_dir; do
|
ls -dt quantengine_* | tail -n +6 | while read -r old_dir; do
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
RESTART=0
|
||||||
|
if [[ "${1:-}" == "--restart" ]]; then
|
||||||
|
RESTART=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== QuantEngine 502 Diagnosis ==="
|
||||||
|
echo "Host: $(hostname)"
|
||||||
|
echo "Time: $(date -Is)"
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Service Status ==="
|
||||||
|
systemctl is-active quantengine || true
|
||||||
|
systemctl is-active nginx || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Active Deployment ==="
|
||||||
|
readlink -f /home/kjh2064/quantengine_active || true
|
||||||
|
ls -ld /home/kjh2064/quantengine_active || true
|
||||||
|
ls -1dt /home/kjh2064/deployments/quantengine_* 2>/dev/null | head -n 5 || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Version Marker ==="
|
||||||
|
cat /home/kjh2064/quantengine_active/wwwroot/version.json 2>/dev/null || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Local Port Checks ==="
|
||||||
|
ss -ltnp | grep -E ':(5000|443)\s' || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Loopback HTTP Check ==="
|
||||||
|
curl -i --max-time 10 http://127.0.0.1:5000/ || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Favicon Checks ==="
|
||||||
|
curl -i --max-time 10 http://127.0.0.1:5000/favicon.svg || true
|
||||||
|
curl -i --max-time 10 http://127.0.0.1:5000/favicon.png || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Public HTTP Check ==="
|
||||||
|
curl -i --max-time 15 https://quant.taxbaik.com/ || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Nginx Config Test ==="
|
||||||
|
nginx -t || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
echo "=== Recent QuantEngine Logs ==="
|
||||||
|
journalctl -u quantengine -n 120 --no-pager || true
|
||||||
|
echo
|
||||||
|
|
||||||
|
if [[ "$RESTART" -eq 1 ]]; then
|
||||||
|
echo "=== Restarting Services ==="
|
||||||
|
systemctl restart quantengine
|
||||||
|
systemctl reload nginx
|
||||||
|
sleep 2
|
||||||
|
echo
|
||||||
|
echo "=== Post-Restart Status ==="
|
||||||
|
systemctl is-active quantengine || true
|
||||||
|
systemctl is-active nginx || true
|
||||||
|
echo
|
||||||
|
echo "=== Post-Restart Loopback Check ==="
|
||||||
|
curl -i --max-time 10 http://127.0.0.1:5000/ || true
|
||||||
|
echo
|
||||||
|
echo "=== Public Endpoint Check ==="
|
||||||
|
curl -i --max-time 15 https://quant.taxbaik.com/ || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "=== Next Step ==="
|
||||||
|
echo "If http://127.0.0.1:5000/ fails, the problem is inside quantengine."
|
||||||
|
echo "If localhost works but the public domain still fails, inspect nginx/proxy config only for quant.taxbaik.com."
|
||||||
Reference in New Issue
Block a user