diff --git a/docs/GITEA_TOKEN_HOME.md b/docs/GITEA_TOKEN_HOME.md new file mode 100644 index 0000000..4238f98 --- /dev/null +++ b/docs/GITEA_TOKEN_HOME.md @@ -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`. + diff --git a/docs/GITEA_TOKEN_HOME_RUNBOOK.md b/docs/GITEA_TOKEN_HOME_RUNBOOK.md new file mode 100644 index 0000000..6f86552 --- /dev/null +++ b/docs/GITEA_TOKEN_HOME_RUNBOOK.md @@ -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. diff --git a/tools/setup_act_runner.sh b/tools/setup_act_runner.sh index 61da9a2..2e90770 100644 --- a/tools/setup_act_runner.sh +++ b/tools/setup_act_runner.sh @@ -1,87 +1,29 @@ #!/bin/bash -# Gitea Act Runner 자동 설치 — Synology NAS (armv7l / x86_64 / arm64) +set -eu -GITEA_URL="http://192.168.123.100:8418" -REG_TOKEN="Kj6L43zdWKZdTtqFswN0PYFWRHkdUChxG8yIfr8L" -RUNNER_NAME="synology-runner" -RUNNER_DIR="/volume1/gitea/act_runner" -ACT_RUNNER_VERSION="0.2.11" +ROOT_DIR="${ROOT_DIR:-/volume1/projects/data_feed}" -echo "=== Gitea Act Runner Setup ===" +re_register() { + bash "${ROOT_DIR}/tools/re_register_act_runner_synology.sh" +} -# 1. 아키텍처 감지 -ARCH=$(uname -m) -case "$ARCH" in - x86_64) BINARY="act_runner-${ACT_RUNNER_VERSION}-linux-amd64" ;; - aarch64) BINARY="act_runner-${ACT_RUNNER_VERSION}-linux-arm64" ;; - armv7l) BINARY="act_runner-${ACT_RUNNER_VERSION}-linux-arm-7" ;; - *) echo "ERROR: 지원하지 않는 아키텍처: $ARCH"; exit 1 ;; +start_runner() { + bash "${ROOT_DIR}/tools/start_act_runner_synology.sh" +} + +case "${1:-all}" in + re-register) + re_register + ;; + start) + start_runner + ;; + all) + re_register + start_runner + ;; + *) + echo "usage: $0 {all|re-register|start}" + exit 2 + ;; 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" < "$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" diff --git a/tools/start_act_runner_synology.sh b/tools/start_act_runner_synology.sh new file mode 100644 index 0000000..c070c86 --- /dev/null +++ b/tools/start_act_runner_synology.sh @@ -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=$!" diff --git a/tools/validate_gitea_token_home_v1.py b/tools/validate_gitea_token_home_v1.py new file mode 100644 index 0000000..2303ba4 --- /dev/null +++ b/tools/validate_gitea_token_home_v1.py @@ -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())