Gitea Act Runner — Synology 기동 스크립트 + 토큰 홈 검증 도구
로컬 워크스페이스에서 Gitea Actions를 검증·디스패치할 수 있는 GITEA_TOKEN_HOME API 토큰 계약과, Synology에서 act_runner를 기동하는 스크립트를 추가한다. - validate_gitea_token_home_v1.py: 저장소 메타데이터 조회, 워크플로 접근 확인, workflow_dispatch 트리거 + 최신 실행 결과 폴링 - start_act_runner_synology.sh: Synology 환경에서 act_runner 기동 - setup_act_runner.sh: 기동 절차 갱신
This commit is contained in:
@@ -0,0 +1,43 @@
|
|||||||
|
# GITEA_TOKEN_HOME
|
||||||
|
|
||||||
|
`GITEA_TOKEN_HOME` is the local API token used to validate and optionally dispatch Gitea Actions from this workspace.
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
- Read repository metadata
|
||||||
|
- Confirm workflow access
|
||||||
|
- Optionally trigger a workflow dispatch
|
||||||
|
- Poll the latest run for evidence
|
||||||
|
|
||||||
|
## Harness
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/validate_gitea_token_home_v1.py
|
||||||
|
```
|
||||||
|
|
||||||
|
To dispatch the KIS collector workflow as a smoke test:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/validate_gitea_token_home_v1.py --dispatch --workflow kis_data_collection.yml --ref main
|
||||||
|
```
|
||||||
|
|
||||||
|
## Expected behavior
|
||||||
|
|
||||||
|
- Without `GITEA_TOKEN_HOME`, the harness exits with `GITEA_TOKEN_HOME missing or empty`.
|
||||||
|
- With a valid token, the harness should return `gate: PASS`.
|
||||||
|
- With `--dispatch`, the harness posts a workflow dispatch and reports the latest run evidence.
|
||||||
|
|
||||||
|
## Security notes
|
||||||
|
|
||||||
|
- Do not print the token value.
|
||||||
|
- Do not commit the token into repo files.
|
||||||
|
- Treat the token as a repo-scoped write credential.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
- `401 Unauthorized`: token is missing, expired, or scoped to the wrong repository.
|
||||||
|
- `404 Not Found`: repo path or workflow filename is wrong.
|
||||||
|
- `latest_run_missing`: dispatch succeeded, but the workflow run did not appear in time; increase `--wait-seconds`.
|
||||||
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
# GITEA_TOKEN_HOME Runbook
|
||||||
|
|
||||||
|
## 1. Confirm presence
|
||||||
|
|
||||||
|
Check that `GITEA_TOKEN_HOME` is set in the shell that runs the harness.
|
||||||
|
|
||||||
|
## 2. Validate read-only access
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/validate_gitea_token_home_v1.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- `gate = PASS`
|
||||||
|
- repo access works
|
||||||
|
- workflow metadata is readable
|
||||||
|
|
||||||
|
## 3. Dispatch smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python tools/validate_gitea_token_home_v1.py --dispatch --workflow kis_data_collection.yml --ref main
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected:
|
||||||
|
|
||||||
|
- dispatch returns `204` or `201`
|
||||||
|
- latest run is visible in the API
|
||||||
|
- run status becomes visible in the response payload
|
||||||
|
|
||||||
|
## 4. If it fails
|
||||||
|
|
||||||
|
- `GITEA_TOKEN_HOME missing or empty`: environment is not configured
|
||||||
|
- `401 Unauthorized`: token is wrong or lacks repo scope
|
||||||
|
- `404 Not Found`: repo or workflow path mismatch
|
||||||
|
- `latest_run_missing`: dispatch accepted, but run listing lagged behind
|
||||||
|
- `queued` or long `in_progress`: runner is busy or the job is waiting for the self-hosted runner slot
|
||||||
|
|
||||||
|
## 5. Evidence to capture
|
||||||
|
|
||||||
|
- Harness JSON output
|
||||||
|
- Workflow run URL
|
||||||
|
- Failing step name if the dispatched run fails
|
||||||
|
- Runner status from the API if the job stays queued
|
||||||
|
|
||||||
|
## Current run 161 finding
|
||||||
|
|
||||||
|
- `Checkout Code` failed before Python started.
|
||||||
|
- The repository checkout did not contain `GatherTradingData.json`.
|
||||||
|
- This means the next fix is not token-related; it is seed-file availability at workflow runtime.
|
||||||
|
- The workflow is now updated to regenerate the JSON from `GatherTradingData.xlsx` when possible and to fail with explicit recovery instructions when both are missing.
|
||||||
+24
-82
@@ -1,87 +1,29 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Gitea Act Runner 자동 설치 — Synology NAS (armv7l / x86_64 / arm64)
|
set -eu
|
||||||
|
|
||||||
GITEA_URL="http://192.168.123.100:8418"
|
ROOT_DIR="${ROOT_DIR:-/volume1/projects/data_feed}"
|
||||||
REG_TOKEN="Kj6L43zdWKZdTtqFswN0PYFWRHkdUChxG8yIfr8L"
|
|
||||||
RUNNER_NAME="synology-runner"
|
|
||||||
RUNNER_DIR="/volume1/gitea/act_runner"
|
|
||||||
ACT_RUNNER_VERSION="0.2.11"
|
|
||||||
|
|
||||||
echo "=== Gitea Act Runner Setup ==="
|
re_register() {
|
||||||
|
bash "${ROOT_DIR}/tools/re_register_act_runner_synology.sh"
|
||||||
|
}
|
||||||
|
|
||||||
# 1. 아키텍처 감지
|
start_runner() {
|
||||||
ARCH=$(uname -m)
|
bash "${ROOT_DIR}/tools/start_act_runner_synology.sh"
|
||||||
case "$ARCH" in
|
}
|
||||||
x86_64) BINARY="act_runner-${ACT_RUNNER_VERSION}-linux-amd64" ;;
|
|
||||||
aarch64) BINARY="act_runner-${ACT_RUNNER_VERSION}-linux-arm64" ;;
|
case "${1:-all}" in
|
||||||
armv7l) BINARY="act_runner-${ACT_RUNNER_VERSION}-linux-arm-7" ;;
|
re-register)
|
||||||
*) echo "ERROR: 지원하지 않는 아키텍처: $ARCH"; exit 1 ;;
|
re_register
|
||||||
|
;;
|
||||||
|
start)
|
||||||
|
start_runner
|
||||||
|
;;
|
||||||
|
all)
|
||||||
|
re_register
|
||||||
|
start_runner
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "usage: $0 {all|re-register|start}"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
esac
|
esac
|
||||||
echo "[1] 아키텍처: $ARCH -> $BINARY"
|
|
||||||
|
|
||||||
# 2. 설치 디렉토리 생성
|
|
||||||
mkdir -p "$RUNNER_DIR/workspace"
|
|
||||||
echo "[2] 디렉토리: $RUNNER_DIR"
|
|
||||||
|
|
||||||
# 3. 바이너리 다운로드
|
|
||||||
DOWNLOAD_URL="https://gitea.com/gitea/act_runner/releases/download/v${ACT_RUNNER_VERSION}/${BINARY}"
|
|
||||||
echo "[3] 다운로드: $DOWNLOAD_URL"
|
|
||||||
curl -L --progress-bar "$DOWNLOAD_URL" -o "$RUNNER_DIR/act_runner"
|
|
||||||
chmod +x "$RUNNER_DIR/act_runner"
|
|
||||||
echo "[3] 완료: $("$RUNNER_DIR/act_runner" --version 2>&1 | head -1)"
|
|
||||||
|
|
||||||
# 4. config.yaml 생성
|
|
||||||
cat > "$RUNNER_DIR/config.yaml" <<YAML
|
|
||||||
runner:
|
|
||||||
name: ${RUNNER_NAME}
|
|
||||||
labels:
|
|
||||||
- "self-hosted:host"
|
|
||||||
- "linux:host"
|
|
||||||
|
|
||||||
container:
|
|
||||||
network: host
|
|
||||||
docker_host: "-"
|
|
||||||
|
|
||||||
host:
|
|
||||||
workdir_parent: ${RUNNER_DIR}/workspace
|
|
||||||
YAML
|
|
||||||
echo "[4] config.yaml 생성"
|
|
||||||
|
|
||||||
# 5. Runner 등록
|
|
||||||
echo "[5] Runner 등록..."
|
|
||||||
"$RUNNER_DIR/act_runner" register \
|
|
||||||
--no-interactive \
|
|
||||||
--instance "$GITEA_URL" \
|
|
||||||
--token "$REG_TOKEN" \
|
|
||||||
--name "$RUNNER_NAME" \
|
|
||||||
--labels "self-hosted,linux" \
|
|
||||||
--config "$RUNNER_DIR/config.yaml"
|
|
||||||
echo "[5] 등록 완료"
|
|
||||||
|
|
||||||
# 6. 부팅 시작 스크립트
|
|
||||||
cat > "$RUNNER_DIR/start.sh" <<'SH'
|
|
||||||
#!/bin/bash
|
|
||||||
RUNNER_DIR="/volume1/gitea/act_runner"
|
|
||||||
PID_FILE="$RUNNER_DIR/runner.pid"
|
|
||||||
LOG="$RUNNER_DIR/runner.log"
|
|
||||||
if [ -f "$PID_FILE" ] && kill -0 $(cat "$PID_FILE") 2>/dev/null; then
|
|
||||||
echo "already running pid=$(cat $PID_FILE)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
nohup "$RUNNER_DIR/act_runner" daemon --config "$RUNNER_DIR/config.yaml" >> "$LOG" 2>&1 &
|
|
||||||
echo $! > "$PID_FILE"
|
|
||||||
echo "started pid=$!"
|
|
||||||
SH
|
|
||||||
chmod +x "$RUNNER_DIR/start.sh"
|
|
||||||
echo "[6] start.sh 생성"
|
|
||||||
|
|
||||||
# 7. 즉시 시작
|
|
||||||
echo "[7] Runner 시작..."
|
|
||||||
"$RUNNER_DIR/start.sh"
|
|
||||||
sleep 3
|
|
||||||
echo "[7] PID: $(cat $RUNNER_DIR/runner.pid 2>/dev/null || echo 'N/A')"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "=== 설치 완료 ==="
|
|
||||||
echo "DSM 작업 스케줄러 → 부팅 트리거 → 명령: bash ${RUNNER_DIR}/start.sh"
|
|
||||||
echo "Gitea 확인: ${GITEA_URL}/KimJaeHyun/myfinance/settings/runners"
|
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
RUNNER_DIR="${RUNNER_DIR:-/volume1/gitea/act_runner}"
|
||||||
|
PID_FILE="${PID_FILE:-$RUNNER_DIR/runner.pid}"
|
||||||
|
LOG_FILE="${LOG_FILE:-$RUNNER_DIR/runner.log}"
|
||||||
|
|
||||||
|
if [ ! -x "$RUNNER_DIR/act_runner" ]; then
|
||||||
|
echo "ERROR: act_runner binary missing at $RUNNER_DIR/act_runner"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ ! -f "$RUNNER_DIR/config.yaml" ]; then
|
||||||
|
echo "ERROR: config missing at $RUNNER_DIR/config.yaml"
|
||||||
|
echo "Run tools/re_register_act_runner_synology.sh first."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -f "$PID_FILE" ] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
|
||||||
|
echo "already running pid=$(cat "$PID_FILE")"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
nohup "$RUNNER_DIR/act_runner" daemon --config "$RUNNER_DIR/config.yaml" >> "$LOG_FILE" 2>&1 &
|
||||||
|
echo $! > "$PID_FILE"
|
||||||
|
echo "started pid=$!"
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
import urllib.error
|
||||||
|
import urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
DEFAULT_BASE_URL = "http://192.168.123.100:8418"
|
||||||
|
DEFAULT_OWNER = "KimJaeHyun"
|
||||||
|
DEFAULT_REPO = "myfinance"
|
||||||
|
|
||||||
|
|
||||||
|
def _token() -> str:
|
||||||
|
token = os.environ.get("GITEA_TOKEN_HOME", "")
|
||||||
|
if not token.strip():
|
||||||
|
raise SystemExit("GITEA_TOKEN_HOME missing or empty")
|
||||||
|
return token.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def _request_json(url: str, token: str, method: str = "GET", body: dict[str, Any] | None = None) -> tuple[int, str, Any]:
|
||||||
|
data = None
|
||||||
|
headers = {
|
||||||
|
"Authorization": f"token {token}",
|
||||||
|
"Accept": "application/json",
|
||||||
|
}
|
||||||
|
if body is not None:
|
||||||
|
headers["Content-Type"] = "application/json"
|
||||||
|
data = json.dumps(body).encode("utf-8")
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method=method)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=30) as resp:
|
||||||
|
raw = resp.read().decode("utf-8", errors="replace")
|
||||||
|
payload = json.loads(raw) if raw else None
|
||||||
|
return resp.status, resp.reason, payload
|
||||||
|
except urllib.error.HTTPError as exc:
|
||||||
|
raw = exc.read().decode("utf-8", errors="replace")
|
||||||
|
try:
|
||||||
|
payload = json.loads(raw) if raw else None
|
||||||
|
except Exception:
|
||||||
|
payload = raw
|
||||||
|
return exc.code, exc.reason or "", payload
|
||||||
|
|
||||||
|
|
||||||
|
def _repo_base(args: argparse.Namespace) -> str:
|
||||||
|
return f"{args.base_url.rstrip('/')}/api/v1/repos/{args.owner}/{args.repo}"
|
||||||
|
|
||||||
|
|
||||||
|
def _latest_run(workflow_runs: Any, workflow_name: str) -> dict[str, Any] | None:
|
||||||
|
if not isinstance(workflow_runs, dict):
|
||||||
|
return None
|
||||||
|
runs = workflow_runs.get("workflow_runs") or workflow_runs.get("runs") or []
|
||||||
|
if not isinstance(runs, list):
|
||||||
|
return None
|
||||||
|
for item in runs:
|
||||||
|
if not isinstance(item, dict):
|
||||||
|
continue
|
||||||
|
path = str(item.get("path") or "")
|
||||||
|
if workflow_name in path:
|
||||||
|
return item
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
ap = argparse.ArgumentParser(description="Validate and optionally dispatch Gitea Actions using GITEA_TOKEN_HOME.")
|
||||||
|
ap.add_argument("--base-url", default=DEFAULT_BASE_URL)
|
||||||
|
ap.add_argument("--owner", default=DEFAULT_OWNER)
|
||||||
|
ap.add_argument("--repo", default=DEFAULT_REPO)
|
||||||
|
ap.add_argument("--workflow", default="kis_data_collection.yml")
|
||||||
|
ap.add_argument("--ref", default="main")
|
||||||
|
ap.add_argument("--dispatch", action="store_true", help="Trigger workflow_dispatch after the read-only checks pass.")
|
||||||
|
ap.add_argument("--wait-seconds", type=int, default=5, help="Seconds to wait before polling for the dispatched run.")
|
||||||
|
args = ap.parse_args()
|
||||||
|
|
||||||
|
token = _token()
|
||||||
|
base = _repo_base(args)
|
||||||
|
evidence: dict[str, Any] = {
|
||||||
|
"base_url": args.base_url,
|
||||||
|
"owner": args.owner,
|
||||||
|
"repo": args.repo,
|
||||||
|
"workflow": args.workflow,
|
||||||
|
"ref": args.ref,
|
||||||
|
"token_length": len(token),
|
||||||
|
}
|
||||||
|
|
||||||
|
status, reason, repo_payload = _request_json(base, token)
|
||||||
|
evidence["repo_status"] = status
|
||||||
|
evidence["repo_reason"] = reason
|
||||||
|
if status != 200:
|
||||||
|
print(json.dumps({"gate": "FAIL", "evidence": evidence, "error": repo_payload}, ensure_ascii=False, indent=2))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
status, reason, workflow_payload = _request_json(f"{base}/actions/workflows/{args.workflow}", token)
|
||||||
|
evidence["workflow_status"] = status
|
||||||
|
evidence["workflow_reason"] = reason
|
||||||
|
evidence["workflow_payload_type"] = type(workflow_payload).__name__
|
||||||
|
if status != 200:
|
||||||
|
print(json.dumps({"gate": "FAIL", "evidence": evidence, "error": workflow_payload}, ensure_ascii=False, indent=2))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
dispatch_result = None
|
||||||
|
if args.dispatch:
|
||||||
|
status, reason, dispatch_result = _request_json(
|
||||||
|
f"{base}/actions/workflows/{args.workflow}/dispatches",
|
||||||
|
token,
|
||||||
|
method="POST",
|
||||||
|
body={"ref": args.ref},
|
||||||
|
)
|
||||||
|
evidence["dispatch_status"] = status
|
||||||
|
evidence["dispatch_reason"] = reason
|
||||||
|
if status not in (201, 204):
|
||||||
|
print(json.dumps({"gate": "FAIL", "evidence": evidence, "error": dispatch_result}, ensure_ascii=False, indent=2))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
time.sleep(max(0, args.wait_seconds))
|
||||||
|
runs_status, runs_reason, runs_payload = _request_json(
|
||||||
|
f"{base}/actions/runs?per_page=10",
|
||||||
|
token,
|
||||||
|
)
|
||||||
|
evidence["runs_status"] = runs_status
|
||||||
|
evidence["runs_reason"] = runs_reason
|
||||||
|
latest = _latest_run(runs_payload, args.workflow)
|
||||||
|
evidence["latest_run"] = latest
|
||||||
|
if not latest:
|
||||||
|
print(json.dumps({"gate": "FAIL", "evidence": evidence, "error": "latest_run_missing"}, ensure_ascii=False, indent=2))
|
||||||
|
return 1
|
||||||
|
|
||||||
|
result = {
|
||||||
|
"gate": "PASS",
|
||||||
|
"evidence": evidence,
|
||||||
|
"dispatch_result": dispatch_result,
|
||||||
|
}
|
||||||
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user