Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
.git
.github
.gitignore
.idea
.vscode
__pycache__/
*.py[cod]
.venv/
venv/
env/
.pytest_cache/
.ruff_cache/
.mypy_cache/
.coverage
htmlcov/
coverage.xml
tests/
test-sites/
docs/
*.md
.env
.env.*
data/
*.db
*.db-journal
*.db-wal
*.db-shm
*.log
logs/
node_modules/
.DS_Store
Dockerfile
.dockerignore
.pre-commit-config.yaml
Makefile
86 changes: 86 additions & 0 deletions .github/workflows/cd.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
name: CD

on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
workflow_dispatch: # 수동 실행 (테스트/재배포/롤백)

permissions:
id-token: write # OIDC
contents: read

jobs:
build-and-push:
if: github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success'
runs-on: ubuntu-latest
outputs:
sha: ${{ steps.vars.outputs.sha }}
steps:
- uses: actions/checkout@v4
with:
ref: ${{ github.event.workflow_run.head_sha || github.sha }}
- id: vars
run: echo "sha=$(git rev-parse --short HEAD)" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
${{ vars.IMAGE_NAME }}:latest
${{ vars.IMAGE_NAME }}:${{ steps.vars.outputs.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max

deploy:
needs: build-and-push
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.AWS_DEPLOY_ROLE_ARN }}
aws-region: ${{ vars.AWS_REGION }}
- name: Deploy via SSM SendCommand
run: |
cmd_id=$(aws ssm send-command \
--instance-ids "${{ secrets.APP_INSTANCE_ID }}" \
--document-name "AWS-RunShellScript" \
--comment "deploy fastapi ${{ needs.build-and-push.outputs.sha }}" \
--parameters commands='[
"set -e",
"cd ${{ vars.DEPLOY_DIR }}",
"docker compose pull ${{ vars.DEPLOY_SERVICE }}",
"docker compose up -d ${{ vars.DEPLOY_SERVICE }}",
"docker image prune -f"
]' \
--query "Command.CommandId" --output text)
echo "CommandId=$cmd_id"
# 완료까지 폴링 후 상태 검증
for i in $(seq 1 30); do

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SSM 상태가 Success도 아니고 Failed/Cancelled/TimedOut도 아닌 채 30회 폴링이 끝나면 그냥 루프를 빠져나와 job이 성공합니다. Pending/InProgress/Delayed가 계속돼도 배포 성공으로 보일 수 있습니다.

루프 뒤에 status == Success 검증을 추가해서 아니라면 exit 1 을 해야할 것 같습니다!

status=$(aws ssm get-command-invocation \
--command-id "$cmd_id" \
--instance-id "${{ secrets.APP_INSTANCE_ID }}" \
--query "Status" --output text 2>/dev/null || echo Pending)
echo "status=$status"
case "$status" in
Success) break ;;
Failed|Cancelled|TimedOut)
aws ssm get-command-invocation --command-id "$cmd_id" \
--instance-id "${{ secrets.APP_INSTANCE_ID }}" \
--query "StandardErrorContent" --output text
exit 1 ;;
esac
sleep 10
done
# 루프가 타임아웃(300s)으로 끝났는데 Success 가 아니면 배포 실패로 처리.
# break 없이 빠져나온 Pending/InProgress 상태를 성공으로 오인하지 않도록 명시 검증.
if [ "$status" != "Success" ]; then
echo "deploy did not reach Success within timeout (last status=$status)"
exit 1
fi
27 changes: 27 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: CI

on:
pull_request:
branches: [dev]
push:
branches: [dev, main]

jobs:
check:
runs-on: ubuntu-latest
env:
TLDEXTRACT_CACHE: /tmp/tldextract # 리눅스 러너 캐시 경로 보정
INTERNAL_API_KEY: test-ci-key # 테스트용 더미값 — Settings 필수 필드라 없으면 import 단계에서 실패
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: '3.11'
cache: pip
- run: pip install -e ".[dev]"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ruff와 pytest만 돌고 docker build .가 없습니다. Dockerfile, .dockerignore, entrypoint.sh, 패키징 문제는 merge 후 CD에서야 터질 수 있습니다. CI에 최소 docker build . 추가를 권장드립니다!

- name: Ruff lint
run: ruff check app tests
- name: Pytest
run: pytest
- name: Docker build
run: docker build -t linclean-fastapi:ci .
37 changes: 37 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
FROM python:3.11-slim

ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \
PIP_NO_CACHE_DIR=1 \
PIP_DISABLE_PIP_VERSION_CHECK=1 \
# app 이 site-packages 에 설치돼 alembic/uvicorn 이 그쪽을 import 하므로
# SQLite 경로를 절대경로로 고정 (PROJECT_ROOT 추론이 site-packages 를 가리켜
# 쓰기 불가 위치에 DB 디렉토리를 만들려다 실패하는 문제 방지).
SQLITE_PATH=/app/data/linclean.db \
TLDEXTRACT_CACHE=/tmp/tldextract

WORKDIR /app

# hatchling 이 app 패키지를 빌드해야 하므로 install 전에 소스 복사
COPY pyproject.toml ./
COPY app ./app
RUN pip install .

# 마이그레이션 + 기동 스크립트
COPY alembic.ini ./
COPY alembic ./alembic
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh

# non-root + 데이터 디렉토리(SQLite) 소유권
RUN useradd -m -u 1000 appuser \
&& mkdir -p /app/data \
&& chown -R appuser:appuser /app
USER appuser

EXPOSE 8000

HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD python -c "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8000/api/v1/health').status==200 else 1)"

ENTRYPOINT ["./entrypoint.sh"]
2 changes: 1 addition & 1 deletion app/services/content_analyzer/ai_openai.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
},
"reason": {
"type": "string",
"description": "Korean analysis reason plus user action guidance, within 100 chars.",
"description": "Korean analysis reason plus action guidance, within 100 chars.",
},
},
"required": ["verdict", "reason"],
Expand Down
4 changes: 1 addition & 3 deletions app/services/content_analyzer/analyze.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,7 @@ def _should_apply_ai_suspicious_score(
content_signal_values = {signal.value for signal in scoring.signals}
if content_signal_values & _HIGH_RISK_CONTENT_SIGNALS:
return True
if set(upstream_signals) & _HIGH_RISK_UPSTREAM_SIGNALS:
return True
return False
return bool(set(upstream_signals) & _HIGH_RISK_UPSTREAM_SIGNALS)


def _unique_merge(left: list[str], right: list[str]) -> list[str]:
Expand Down
16 changes: 9 additions & 7 deletions app/services/db_independent_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,13 +98,15 @@ def _augment_heuristic_with_redirect_signals(
) -> DomainHeuristicResult:
signals = list(heuristic.signals)
score = heuristic.score
if any(signal.startswith("cross_origin:") for signal in unchain.signals):
if DomainHeuristicSignal.REDIRECT_CROSS_ORIGIN not in signals:
signals.append(DomainHeuristicSignal.REDIRECT_CROSS_ORIGIN)
score = min(
score + settings.score_weight_redirect_cross_origin,
settings.domain_heuristic_score_cap,
)
if (
any(signal.startswith("cross_origin:") for signal in unchain.signals)
and DomainHeuristicSignal.REDIRECT_CROSS_ORIGIN not in signals
):
signals.append(DomainHeuristicSignal.REDIRECT_CROSS_ORIGIN)
score = min(
score + settings.score_weight_redirect_cross_origin,
settings.domain_heuristic_score_cap,
)
if signals == heuristic.signals and score == heuristic.score:
return heuristic
return heuristic.model_copy(update={"signals": signals, "score": score})
Expand Down
16 changes: 9 additions & 7 deletions app/services/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -161,13 +161,15 @@ def _augment_heuristic_with_redirect_signals(
) -> DomainHeuristicResult:
signals = list(heuristic.signals)
score = heuristic.score
if any(signal.startswith("cross_origin:") for signal in unchain.signals):
if DomainHeuristicSignal.REDIRECT_CROSS_ORIGIN not in signals:
signals.append(DomainHeuristicSignal.REDIRECT_CROSS_ORIGIN)
score = min(
score + settings.score_weight_redirect_cross_origin,
settings.domain_heuristic_score_cap,
)
if (
any(signal.startswith("cross_origin:") for signal in unchain.signals)
and DomainHeuristicSignal.REDIRECT_CROSS_ORIGIN not in signals
):
signals.append(DomainHeuristicSignal.REDIRECT_CROSS_ORIGIN)
score = min(
score + settings.score_weight_redirect_cross_origin,
settings.domain_heuristic_score_cap,
)
if signals == heuristic.signals and score == heuristic.score:
return heuristic
return heuristic.model_copy(update={"signals": signals, "score": score})
Expand Down
4 changes: 4 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
#!/bin/sh
set -e
alembic upgrade head
exec uvicorn app.main:app --host 0.0.0.0 --port 8000
2 changes: 1 addition & 1 deletion tests/services/test_db_independent_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,7 @@ async def test_db_independent_pipeline_returns_failure_when_page_unavailable() -


@pytest.mark.asyncio
async def test_db_independent_pipeline_returns_verdict_when_unavailable_url_signal_is_strong() -> None:
async def test_db_independent_pipeline_verdict_on_strong_unavailable_signal() -> None:
final_url = "http://xj3kq9vbnm2p7zla.com/login"
unchain = _make_unchain(final_url)
unchain.error = "dns_failure"
Expand Down
Loading