Skip to content

Releases: hkjang/relio

v1.11.19

Choose a tag to compare

@github-actions github-actions released this 08 Sep 00:53

Relio v1.11.19 — 릴리즈가 한 번 깨진 뒤로 이미지가 더 나오지 않던 문제

태그는 v1.11.18 까지 붙어 있는데 오프라인 반입용 Docker Image 는 v1.11.14 가 마지막이었습니다. 릴리즈 워크플로의 "이전 릴리즈에서 업그레이드" 단계가 직전 태그를 직전 릴리즈로 여겼기 때문에, 한 번 릴리즈가 실패해 태그만 남으면 그 뒤의 모든 릴리즈가 존재하지 않는 자산을 내려받으려다 1초 만에 실패했습니다. 이번 릴리즈는 업그레이드 출처를 실제로 발행된 가장 최신 릴리즈로 바꿔, 깨진 릴리즈 하나가 다음 릴리즈들을 계속 막는 고리를 끊습니다.

1. 원인

1-1. 직전 태그는 직전 릴리즈가 아니다

업그레이드 검증 단계는 이렇게 이전 릴리즈를 골랐습니다.

previous_tag="$(git tag --sort=-v:refname | awk -v current="$GITHUB_REF_NAME" '$0 != current { print; exit }')"
if [ -n "$previous_tag" ]; then
  previous_asset="relio-${previous_tag}.tar.gz"
  gh release download "$previous_tag" --repo "$GITHUB_REPOSITORY" --pattern "$previous_asset"
  ...
fi

태그는 워크플로가 시작되는 시점에 이미 존재합니다. 그런데 GitHub Release 는 워크플로의 마지막 단계인 "Publish GitHub Release" 가 만듭니다. 그 앞의 어느 단계에서든 멈추면 릴리즈도 이미지도 없는 태그가 남습니다. 다음 릴리즈는 그 태그를 이전 릴리즈로 보고 relio-<태그>.tar.gz 를 요청하지만, 그런 자산은 존재한 적이 없습니다.

1-2. 그 실패가 다음 릴리즈를 다시 실패시킨다

업그레이드 단계가 실패하면 그 뒤의 "Publish GitHub Release" 도 실행되지 않습니다. 즉 실패가 다시 릴리즈 없는 태그를 만들어 다음 릴리즈에 같은 함정을 물려줍니다.

태그 멈춘 지점 남은 것
v1.11.15 Test source 릴리즈 없는 태그
v1.11.16 Verify upgrade (relio-v1.11.15.tar.gz 없음) 릴리즈 없는 태그
v1.11.17 Verify upgrade (relio-v1.11.16.tar.gz 없음) 릴리즈 없는 태그
v1.11.18 Verify upgrade (relio-v1.11.17.tar.gz 없음) 릴리즈 없는 태그

처음 한 번은 소스 테스트 실패라는 정당한 이유였지만, 그 뒤 세 번은 고쳐진 코드가 릴리즈되지 못한 것뿐입니다. 태그 네 개가 릴리즈 없이 남았고, 오프라인망에 반입할 수 있는 마지막 이미지는 v1.11.14 에 멈춰 있었습니다.

1-3. 정렬 순서상 더 나중 릴리즈가 뽑힐 수도 있었다

옛 선택은 "현재 태그가 아닌 가장 최신 태그" 였습니다. 오래된 태그로 워크플로를 다시 돌리면 자기보다 나중 릴리즈에서 업그레이드하는, 방향이 뒤집힌 검증이 됩니다.

2. 수정

2-1. 발행된 릴리즈를 찾을 때까지 거슬러 올라간다

scripts/previous-release-tag.sh 가 현재 태그보다 오래된 태그만 최신순으로 훑으면서, gh release view 로 릴리즈가 실제로 존재하고 relio-<태그>.tar.gz 자산까지 달려 있는 첫 태그를 고릅니다.

older_tags="$(git tag --sort=-v:refname | awk -v current="$current_tag" 'found { print } $0 == current { found = 1 }')"

현재 태그보다 위로 정렬되는 것은 애초에 후보에 들어가지 않으므로, 나중 릴리즈가 업그레이드 출처가 되는 일은 없습니다.

2-2. 느슨해진 것은 없다

건너뛰는 경우는 두 가지, "태그는 있는데 릴리즈가 없다" 와 "릴리즈는 있는데 이미지 자산이 없다" 뿐입니다. 그 외의 실패는 조용히 더 거슬러 올라가지 않고 릴리즈를 멈춥니다.

case "$output" in
*"release not found"* | *"Not Found"* | *"HTTP 404"*) return 1 ;;
esac
printf '%s\n' "$output" >&2
return 2

API 가 503 을 돌려주는 것과 릴리즈가 없는 것은 다른 사실입니다. 전자를 후자로 읽으면 "업그레이드할 이전 릴리즈가 없다" 는 잘못된 결론으로 검증을 통째로 건너뛰게 되므로, 이때는 실패로 처리합니다. 결과가 빈 값인 경우는 여전히 하나, 발행된 릴리즈가 하나도 없는 첫 릴리즈 뿐입니다.

워크플로는 이제 어느 이미지에서 업그레이드했는지도 로그에 남깁니다.

previous_tag="$(./scripts/previous-release-tag.sh "$GITHUB_REF_NAME")"
if [ -z "$previous_tag" ]; then
  echo "No earlier release was ever published, so there is no image to upgrade from."
  exit 0
fi
echo "Upgrading from the newest published release: $previous_tag"

3. 회귀 방지

scripts/previous-release-tag-test.sh 를 CI 의 "Verify the release upgrades from a published release" 단계에 추가했습니다. gh 를 태그별 파일 하나로 답하는 스텁으로 대신해 실제 릴리즈 이력을 재현하고, 8가지를 확인합니다.

  • 직전 태그가 발행된 릴리즈면 그것이 이전 릴리즈인지.
  • 릴리즈 없는 태그는 건너뛰고 실제로 발행된 최신 태그를 고르는지.
  • 이번에 처음 깨진 릴리즈(v1.11.16)의 업그레이드 출처도 제대로 찾아지는지.
  • 이미지 자산이 없는 릴리즈는 건너뛰는지.
  • 나중 릴리즈가 업그레이드 출처로 뽑히지 않는지.
  • 첫 릴리즈는 업그레이드할 대상이 없다고 답하는지.
  • 릴리즈를 읽을 수 없으면(예: 503) 탐색을 멈추고 실패하는지.
  • 체크아웃에 없는 태그는 거절하는지.

두 스크립트는 CI 가 직접 실행하므로 실행 권한(100755)으로 커밋되어 있습니다. 100644 로 들어가면 그 단계가 exit 126 으로 멈춥니다.

4. 적용

마이그레이션은 없습니다. Application 코드는 바뀌지 않았고, 이번 릴리즈에서 달라지는 것은 릴리즈 파이프라인뿐입니다. 이 릴리즈의 업그레이드 검증은 v1.11.14 이미지에서 v1.11.19 이미지로 수행됩니다 — v1.11.15 부터 v1.11.18 까지는 태그만 있고 릴리즈가 없기 때문입니다. 따라서 v1.11.19 자산 하나에 v1.11.15 이후의 모든 수정이 함께 들어 있습니다.

오프라인 이미지

항목
Asset relio-v1.11.19.tar.gz
Docker Image relio:v1.11.19
SHA-256 58233d4f0138cd936c301c0c6f0b44b7ca1f294376629ef46f5c3f91119a801c
압축 크기 36M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.19.tar.gz | docker load
docker image inspect relio:v1.11.19

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.19

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.

v1.11.14

Choose a tag to compare

@github-actions github-actions released this 04 Sep 06:28
ad62dd3

Relio v1.11.14 — 큰 MCP 요청이 "Parse error" 로 돌아오던 문제

1MB 를 넘는 MCP 요청을 보내면 클라이언트가 올바르게 만든 JSON-RPC 메시지가 파싱 오류로 거절되던 문제를 고쳤습니다. 이제 크기 문제라는 사실이 응답에 그대로 드러납니다.

1. 원인

1-1. 상한까지만 읽고 멈추면 본문이 잘린다

MCP 엔드포인트는 본문을 이렇게 읽었습니다.

body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20))
if err != nil {
    s.writeErrorStatus(w, http.StatusBadRequest, nil, -32700, "Parse error", nil)
    return
}

io.LimitReader 는 상한을 넘는 입력을 오류로 만들지 않습니다. 딱 그 지점까지 읽고 정상적으로 EOF 를 돌려줍니다. 그래서 1MB 를 넘는 본문은 오류 없이 1MB 에서 잘린 채 다음 단계로 넘어갔습니다.

1-2. 잘린 JSON 은 깨진 JSON 이다

잘려 나간 본문은 닫히지 않은 문자열과 중괄호로 끝나므로 parseRequests 가 반드시 실패합니다. 그 결과 클라이언트가 받는 답은 이것이었습니다.

{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error"}}

HTTP 상태는 400 입니다. 클라이언트 입장에서 이 답은 자기 직렬화가 잘못됐다는 뜻이므로, 정작 문제인 "본문이 상한을 넘었다" 는 사실은 어디에도 없습니다. 긴 노트 본문이나 큰 arguments 를 담은 tools/call 이 이유 없이 실패하는 것처럼 보이고, 같은 메시지를 그대로 재시도하면 똑같이 실패합니다.

REST 쪽은 이미 이 상황을 구분해서 답하고 있었습니다. httpx.DecodeJSONhttp.MaxBytesReader 로 413 request_too_large 를 돌려주고, 멱등성 미들웨어는 상한보다 한 바이트 더 읽어 초과를 판별합니다. MCP 만 이 구분이 빠져 있었습니다.

2. 수정

상한보다 한 바이트 더 읽어, 잘린 본문과 원래 깨진 본문을 구분합니다.

// MaxRequestBytes caps one MCP HTTP message.
const MaxRequestBytes = 1 << 20

func readRequestBody(r io.Reader) (body []byte, tooLarge bool, err error) {
	body, err = io.ReadAll(io.LimitReader(r, MaxRequestBytes+1))
	if err != nil {
		return nil, false, err
	}
	if len(body) > MaxRequestBytes {
		return nil, true, nil
	}
	return body, false, nil
}

초과한 본문은 413 과 함께 JSON-RPC 오류로 답하고, 지켜야 할 상한을 data 에 담아 보냅니다.

{"jsonrpc":"2.0","id":null,"error":{"code":-32600,"message":"요청 본문이 너무 큽니다.","data":{"maxBytes":1048576}}}

읽기 자체가 실패한 경우(연결 끊김 등)는 지금까지처럼 400 Parse error 입니다. 상한 값 자체는 1MB 그대로이며, 달라진 것은 상한을 넘겼을 때 돌아오는 답뿐입니다.

3. 회귀 방지

  • 상한과 정확히 같은 크기의 본문은 통과하고, 한 바이트만 넘어도 초과로 판정되는지.
  • 상한을 넘긴 실제 tools/call 메시지가 초과로 판정되는지, 그리고 그것을 상한에서 자른 본문은 parseRequests 가 반드시 거절하는지 — 잘린 본문이 우연히 파싱되면 일부만 담긴 호출이 실행될 수 있으므로 함께 확인합니다.
  • 읽기 오류는 초과가 아니라 오류로 전달되는지.
  • 413 응답이 JSON-RPC 형식을 유지하며 data.maxBytes 로 상한을 알려주는지.

4. 적용

마이그레이션은 없습니다. 재시작하면 바로 적용되며, 1MB 이하의 요청은 동작이 전혀 달라지지 않습니다.

오프라인 이미지

항목
Asset relio-v1.11.14.tar.gz
Docker Image relio:v1.11.14
SHA-256 45b56a8586c7634e9101b4e4ef82b69a857d687e4e0d1f269fa0e0ca2b4671f6
압축 크기 36M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.14.tar.gz | docker load
docker image inspect relio:v1.11.14

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.14

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.

v1.11.13

Choose a tag to compare

@github-actions github-actions released this 03 Sep 15:50
68be146

Relio v1.11.13 — 정리 작업이 조용히 멈춰 있던 문제

만료된 Personal Key 정리, 세션·로그인 상태·멱등성 키 삭제, 예측 스냅샷, 인텔리전스 분석을 담당하는 1분 주기 유지보수 작업이 실행되다 말고 조용히 멈추던 문제를 고쳤습니다. 오류도 로그도 남지 않고 그냥 아무 일도 일어나지 않는 형태라 눈치채기 어려웠습니다.

1. 원인

1-1. 세션 Advisory Lock 은 그 잠금을 건 커넥션의 것

여러 Relio 컨테이너가 한 PostgreSQL 을 공유할 때 유지보수를 한 대만 돌리려고 pg_try_advisory_lock 을 씁니다. 그런데 이 잠금은 커넥션(세션) 소유입니다. 기존 코드는 잠금을 커넥션 풀에 대고 걸었습니다.

if err := r.DB.QueryRow(ctx, `SELECT pg_try_advisory_lock(733541122020269)`).Scan(&locked); err != nil || !locked {
    return
}
defer r.DB.Exec(context.Background(), `SELECT pg_advisory_unlock(733541122020269)`)

r.DB 는 풀이므로 각 구문마다 커넥션을 새로 빌립니다. 잠금은 커넥션 A 의 세션에 걸리는데, 그 뒤의 정리 구문과 마지막 해제 구문은 풀이 그때 내주는 아무 커넥션에서나 실행됩니다. HTTP 요청이 같은 풀을 함께 쓰는 실제 운영 환경에서는 다른 커넥션이 나오는 일이 흔합니다.

1-2. 해제가 빗나가면 잠금은 계속 남는다

자기가 갖고 있지 않은 잠금을 푸는 pg_advisory_unlock 은 오류가 아니라 경고와 함께 false 를 돌려줍니다. 반환값은 버려졌으므로 아무도 알아채지 못했고, 잠금은 커넥션 A 가 풀에서 재활용되거나 닫힐 때까지 그대로 남았습니다.

그 다음 1분 뒤 실행부터는 이렇게 갈렸습니다.

이번 회차가 받은 커넥션 결과
커넥션 A 같은 세션이라 잠금이 다시 걸리고(중첩) 정리가 실행됨
그 외 커넥션 false — 다른 인스턴스가 잠갔다고 판단하고 즉시 종료

즉 정리 작업 전체가 "풀이 마침 그 커넥션을 내줬을 때만" 도는 상태가 됩니다. 컨테이너를 한 대만 띄워도 마찬가지입니다.

1-3. 멈춘 작업들

한 회차가 건너뛰어질 때 함께 건너뛰어지는 것들입니다.

  • 회수 유예가 끝난 Personal Key 를 REVOKED 로 만료
  • 만료 시각이 지난 Personal Key 를 EXPIRED 로 전환
  • 만료된 세션, OIDC 로그인 상태, 멱등성 키 삭제
  • 예측 스냅샷 적재
  • 인텔리전스 분석(Signal·Risk·Recommendation 재생성)

2. 수정

Migrate 가 이미 쓰고 있던 방식대로, 커넥션 하나를 명시적으로 빌려 그 위에서 잠금 → 작업 → 해제를 모두 실행합니다.

conn, err := r.DB.Acquire(ctx)
if err != nil {
    if ctx.Err() == nil {
        r.Log.Error("acquire maintenance connection", "error", err)
    }
    return
}
defer conn.Release()
r.maintain(ctx, conn)

해제는 회차의 마지막에 같은 커넥션에서 실행되므로 잠금이 남지 않고, 다음 회차는 어떤 커넥션을 받든 정상적으로 잠금을 얻습니다.

잠금 질의 자체가 실패한 경우도 분리했습니다. 기존에는 오류와 "다른 인스턴스가 갖고 있음" 이 한 조건으로 묶여 둘 다 조용히 종료됐지만, 이제 오류만 로그로 남깁니다. 잠금을 못 얻는 것은 정상적인 결과이므로 그대로 조용히 넘어갑니다.

3. 회귀 방지

internal/job 에는 테스트가 없었습니다. 이번에 유지보수 회차가 실행되는 커넥션을 대역으로 세워 구문 순서를 확인하는 테스트를 넣었습니다.

  • 잠금을 얻으면 첫 구문이 pg_try_advisory_lock, 마지막 구문이 pg_advisory_unlock 이고 그 사이에 정리·스냅샷·분석이 모두 들어 있는지 — 해제가 스냅샷·분석보다 먼저 나오면 두 인스턴스가 동시에 분석을 돌릴 수 있으므로 순서까지 확인합니다.
  • 잠금을 얻지 못하면 잠금 시도 외에는 아무 구문도 실행하지 않는지.
  • 잠금 질의가 실패해도 마찬가지로 즉시 멈추는지.
  • internal/ 전체를 훑어 세션 Advisory Lock 구문이 풀(DB.Exec·DB.Query 등) 위에서 실행되면 실패시키는 가드. 같은 실수가 다른 패키지에서 다시 나오는 것을 막습니다.

4. 적용

마이그레이션은 없습니다. 재시작하면 첫 회차부터 정리와 분석이 매분 정상적으로 실행되며, 그동안 밀려 있던 만료 키·세션 정리와 인텔리전스 재계산이 순차적으로 따라잡습니다. 남아 있던 잠금은 기존 컨테이너가 종료되면서 함께 풀립니다.

오프라인 이미지

항목
Asset relio-v1.11.13.tar.gz
Docker Image relio:v1.11.13
SHA-256 47a1449d967e465663d2e8f09f436ae5b7e580cd8aa9a217353747b05eb457bd
압축 크기 36M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.13.tar.gz | docker load
docker image inspect relio:v1.11.13

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.13

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.

v1.11.12

Choose a tag to compare

@github-actions github-actions released this 03 Sep 08:20

Relio v1.11.12 — 계약 만료·예상 종료일 신호가 하루씩 밀려 나오던 문제

날짜를 세는 Signal이 실제 달력보다 하루 이르게 계산되던 문제를 고쳤습니다. 내일 만료되는 계약이 오늘 만료되는 것처럼 안내되고, 아직 오늘까지 여유가 있는 영업기회에 "예상 종료일이 0일 지났습니다"가 붙던 형태입니다.

1. 원인

1-1. DATE 컬럼은 자정, 엔진의 now 는 시각을 가짐

contracts.end_dateopportunities.expected_close_date 는 DATE 컬럼이라 pgx가 그날 자정으로 돌려줍니다. 반면 분석 엔진의 now 는 실행 시각을 그대로 가집니다. 남은 일수는 이 둘을 빼서 24로 나눴습니다.

remaining := int(c.EndDate.Sub(now).Hours() / 24)

오후 5시에 분석이 돌면 내일 자정 만료 계약과의 차이는 7시간입니다. 24로 나누면 0.29가 되고, 정수 변환은 0 방향으로 잘라 0이 됩니다. 하루가 남았는데 만료 D-0 으로 공지된 것입니다. 하루 이상 남은 모든 계약이 같은 이유로 하루씩 밀렸습니다.

1-2. 공지 창과 심각도 경계가 통째로 하루 앞당겨짐

remaining 은 공지 창(renewalDays = 90일)에 들어오는지, 어떤 심각도로 올릴지도 결정합니다. 하루씩 밀린 값이 그대로 경계에 부딪히면서 판정 자체가 어긋났습니다.

실제 남은 일수 수정 전 수정 후
91일 D-90 · 창 안 (MEDIUM) 신호 없음
31일 D-30 · HIGH (RENEWAL_RISK 60점) D-31 · MEDIUM (40점)
15일 D-14 · 갱신 미착수면 CRITICAL D-15 · HIGH
1일 D-0 D-1
0일 (오늘 만료) D-0 D-0

1-3. 예상 종료일 당일부터 "지났습니다" 오탐

진행 중인 영업기회의 종료일 경과 판정은 시각 비교였습니다.

if o.CloseDate != nil && o.CloseDate.Before(now) {
    overdue := int(now.Sub(*o.CloseDate).Hours() / 24)

종료일이 오늘이면 그 값은 오늘 자정이므로, 자정을 넘긴 순간부터 Before(now) 가 참이 됩니다. 아직 오늘 하루가 남아 있는 딜에 예상 종료일이 0일 지났습니다 라는 HIGH Signal이 뜨고, DEAL_RISK 점수에도 55점이 얹혔습니다. 담당자 입장에서는 마감 당일 아침에 이미 늦은 것으로 표시되는 셈입니다.

2. 수정

  • 두 날짜 사이의 온전한 일수를 세는 calendarDays 를 추가했습니다. 양쪽을 UTC 날짜(연·월·일)로 자른 뒤 날짜끼리 빼므로, 시각이 몇 시든 결과가 흔들리지 않습니다
  • 계약의 남은 일수와 예상 종료일의 경과 일수가 이 함수를 씁니다
  • CLOSE_DATE_PASSED 는 경과 일수가 1일 이상일 때만 만들어집니다. 종료일 당일은 아직 지난 것이 아니기 때문입니다
  • 경과 시간을 묻는 값(단계 정체 일수, 마지막 접촉 이후 경과 등)은 기존 daysSince 를 그대로 씁니다. 이쪽은 타임스탬프 컬럼이라 시각까지가 의미 있는 값입니다

3. 재발 방지

  • calendarDays 5 케이스: 오늘 아침에서 본 내일·오늘·어제 자정, 월 경계를 넘는 30일, UTC가 아닌 시간대의 순간을 덮습니다
  • 계약 만료 5 케이스: D-1, D-0, 이미 만료(신호 없음), 공지 창 마지막 날인 D-90, 창 밖인 91일을 제목과 daysRemaining 근거값까지 고정합니다
  • 예상 종료일 2 케이스: 당일에는 CLOSE_DATE_PASSED 가 없고, 하루 지난 딜은 daysOverdue 가 1이며 제목이 "1일 지났습니다"임을 고정합니다
  • 세 묶음 모두 옛 구현으로 되돌리면 실패합니다. 기존 테스트가 이 결함을 놓친 이유는 날짜를 now.AddDate 로 만들어 now 의 시각이 보존되고, 그래서 뺄셈이 정확히 24시간의 배수가 됐기 때문입니다. 새 케이스는 데이터베이스와 같은 방식으로 자정 날짜를 만듭니다

검증

  • Go 전체 Test/Vet (go test -race ./..., go vet ./...), gofmt
  • TypeScript Typecheck와 React Production Build
  • 환경변수 계약(check-env-contract.sh)과 외부 정적 Asset 차단(check-static-assets.sh)

업그레이드

DB Migration은 없습니다. API 계약과 저장된 데이터도 그대로입니다.

Signal은 분석이 돌 때마다 다시 계산되고 더 이상 감지되지 않는 Signal은 자동으로 정리되므로, 다음 분석 실행부터 제목과 심각도가 달력 기준으로 맞춰집니다. 종료일 당일에 잘못 떠 있던 예상 종료일이 0일 지났습니다 Signal과 그로 인해 올라간 DEAL_RISK도 같은 시점에 해소됩니다. 다만 이전 값 그대로 보고 나간 갱신 공지가 있다면 D- 표기가 하루 늘어난 값으로 바뀐다는 점만 확인해 주세요.

오프라인 이미지

항목
Asset relio-v1.11.12.tar.gz
Docker Image relio:v1.11.12
SHA-256 ff5729a140378c32f33a8b1175fb957265db67236d2a7152173f79db32235bc4
압축 크기 36M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.12.tar.gz | docker load
docker image inspect relio:v1.11.12

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.12

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.

v1.11.11

Choose a tag to compare

@github-actions github-actions released this 03 Sep 01:18

Relio v1.11.11 — 문자 조건을 건 승인 정책이 아무 흔적 없이 무시되던 문제

승인 정책을 켜 두었는데 결재가 잡히지 않고 그대로 통과되던 문제를 고쳤습니다. 정책은 활성 상태로 목록에 보이고 오류도 남지 않기 때문에, 결재가 빠진 사실을 나중에서야 알게 되는 형태였습니다.

1. 원인

1-1. 양쪽이 숫자로 읽히면 무조건 숫자 비교

조건 평가는 실제 값과 조건값이 모두 숫자로 파싱되면 연산자와 무관하게 숫자 분기로 갔습니다.

aNum, aok := asFloat(actual)
bNum, bok := asFloat(p.ConditionValue)
if aok && bok {
    switch op {
    ...
    default:
        return aNum == bNum
    }
}

그래서 amount CONTAINS 50 은 500000000 과 50 을 숫자로 비교했고, 두 값이 같을 리 없으니 어떤 건에서도 일치하지 않았습니다. 관리자가 의도한 "금액에 50 이 들어가는 건"은 영원히 걸리지 않고, 그 정책이 지키던 결재는 조용히 건너뛰어졌습니다.

숫자 분기를 벗어난 뒤에도 문제가 남아 있었습니다. 문자 비교는 fmt.Sprint 를 썼는데, JSON 스냅샷의 금액은 float64 라서 500000000 이 5e+08 로 찍혔습니다. 관리자가 입력한 자릿수와는 어떤 경우에도 맞지 않습니다.

1-2. 문자에 대한 GT/LT 가 동등 비교로 떨어짐

GT, GTE, LT, LTE 는 문자 분기에 아예 없어 default 인 동등 비교로 처리됐습니다. 즉 status GT OPEN 정책은 OPEN 보다 뒤에 오는 값이 아니라, 배제해야 할 바로 그 OPEN 에서 발동했습니다.

1-3. 관리자 화면이 저장할 수 없는 값을 저장함

조건 항목 목록은 status 를 제시하면서, 조건값 입력은 type="number" 였고 저장할 때 Number(value) 로 변환했습니다.

conditionValue: value ? Number(value) : null

OPEN 을 입력하면 숫자 입력칸이 값을 받아 주지 않아 빈 문자열이 되고, 저장되는 값은 null 이 됩니다. null 은 어떤 값과도 일치하지 않으므로 정책은 만들어지자마자 무력화된 상태였습니다. 연산자 목록에도 서버가 지원하는 LT, NE, CONTAINS 가 빠져 있었습니다.

조건 수정 전 수정 후
amount CONTAINS 50 (실제 500000000) 일치하지 않음 일치
amount EQ 500000000 일치 일치
status EQ OPEN (화면에서 저장) 조건값이 null 이라 일치하지 않음 일치
status GT OPEN (실제 OPEN) 발동 (동등 비교) 발동하지 않음
status GT OPEN (실제 WON) 발동하지 않음 발동 (사전순)

2. 수정

  • CONTAINS 는 양쪽이 숫자로 읽히더라도 항상 텍스트 비교입니다. 자릿수를 묻는 연산자를 숫자로 비교하면 언제나 거짓이 되기 때문입니다
  • 숫자 비교는 실제 값과 조건값이 모두 숫자일 때만 들어가고, 그 밖에는 텍스트 비교로 내려갑니다
  • 텍스트 비교에 GT, GTE, LT, LTE 를 추가해 사전순으로 비교합니다. NEEQ 는 이전과 같이 대소문자를 구분하지 않습니다
  • 값 렌더링을 conditionText 로 모았습니다. float64·float32·json.Number 를 지수 표기 없이 온전한 자릿수로 쓰고, nil 은 빈 문자열입니다
  • 연산자의 앞뒤 공백을 제거한 뒤 판정합니다
  • 관리자 화면의 조건값은 일반 텍스트 입력으로 바뀌었고, 입력한 문자열이 숫자로 그대로 읽힐 때만 숫자로 전송합니다. 연산자 목록에 NE, LT, CONTAINS 를 추가했습니다

3. 재발 방지

  • matches 20 케이스: 숫자·문자 각각의 비교 연산자, 텍스트로 적은 숫자 조건값, CONTAINS 의 숫자 스냅샷과 지수 표기, 존재하지 않는 조건 항목, 공백과 소문자가 섞인 연산자, 빈 연산자를 덮습니다
  • conditionText 6 케이스: 큰 금액이 5e+08 이 아니라 500000000 으로, 소수와 nil 이 각각 어떻게 렌더링되는지 고정합니다
  • 두 묶음 모두 옛 동작으로 되돌리면 실패하는 케이스를 포함합니다

검증

  • Go 전체 Test/Vet (go test -race ./..., go vet ./...), gofmt
  • TypeScript Typecheck와 React Production Build
  • 환경변수 계약(check-env-contract.sh)과 외부 정적 Asset 차단(check-static-assets.sh)

업그레이드

DB Migration은 없습니다. API 계약과 저장된 데이터도 그대로입니다.

이미 저장해 둔 정책은 다시 만들 필요가 없지만, 조건값이 비어 있는(null) 문자 조건 정책은 지금까지 아무 건에도 걸리지 않았으므로 관리자 화면에서 값을 다시 입력해 주세요. 숫자 조건만 쓰던 정책의 동작은 이전과 완전히 동일합니다.

오프라인 이미지

항목
Asset relio-v1.11.11.tar.gz
Docker Image relio:v1.11.11
SHA-256 5fb3498f374616356ebab1187e3886bd21f6e48bb3eda73f94b21d4e9ba79794
압축 크기 36M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.11.tar.gz | docker load
docker image inspect relio:v1.11.11

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.11

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.

v1.11.10

Choose a tag to compare

@github-actions github-actions released this 02 Sep 19:50

Relio v1.11.10 — 분석이 많이 쌓인 계정에서 Signal·Risk·Insight·Recommendation 단건 조회가 "not found" 로 끝나던 문제

Signal, Risk, Insight, Recommendation은 목록에는 보이는데 항목을 열면 "not found"가 나고, 무시·수용·기각도 같은 이유로 실패할 수 있었습니다. 분석 결과가 가장 많이 쌓이는 바쁜 계정에서만 나타나던 문제입니다.

1. 원인

단건 조회 네 곳이 목록 질의를 대신 부르고 그 결과를 Go에서 훑었습니다.

items, err := s.ListSignals(ctx, p, SignalFilter{Status: "ALL", Limit: 200})
for _, item := range items {
    if item.ID == id {
        return item, nil
    }
}
return Signal{}, errors.New("signal not found")

목록은 최대 200건이고, 정렬 기준은 심각도(Signal, Risk)와 점수·우선순위(Insight, Recommendation)입니다. 즉 200건을 넘는 계정에서 그 뒤에 밀린 항목은 이 코드가 볼 수 없는 곳에 있었고, 존재하지 않는 것과 똑같이 취급되었습니다.

읽기만 막힌 것이 아닙니다. IgnoreSignal, AcceptRisk, AcceptRecommendation, DismissRecommendation은 모두 쓰기 전에 대상 레코드를 한 번 읽습니다. 그래서 조언과 위험을 처리하는 동작 전체가 같은 경계에서 함께 실패했습니다.

상황 수정 전 수정 후
정렬 상위 200건 안의 항목 조회 정상 정상
그 뒤에 밀린 항목 조회 not found 정상
그 뒤에 밀린 Signal 무시 / Risk 수용 not found 정상
그 뒤에 밀린 Recommendation 수용 / 기각 not found 정상

영향을 받던 경로는 GET·POST /api/v1/signals/{id}, /api/v1/risks/{id}, /api/v1/insights/{id}, /api/v1/recommendations/{id} 계열이며, 같은 Service를 쓰는 MCP Tool도 동일했습니다.

2. 수정

  • 네 Filter(SignalFilter, RiskFilter, InsightFilter, RecommendationFilter)에 ID 필드를 두고, 각 질의에 기존 필터와 같은 형태의 선택 조건 ($n='' OR x.id::text=$n)을 추가했습니다
  • 단건 조회는 이제 ID를 채우고 Limit: 1로 같은 질의를 부릅니다. 목록과 단건이 한 문장을 공유하므로, 가시성을 결정하는 것은 여전히 Data Scope 조인 하나뿐입니다. 권한이 넓어지지도 좁아지지도 않습니다
  • ID가 비어 있거나 공백뿐이면 조건은 걸리지 않습니다. 목록 동작은 이전과 완전히 동일합니다

3. 재발 방지

각 목록 질의를 (sql, args)를 돌려주는 순수 빌더로 분리해, DB 없이 조건과 인자를 검사할 수 있게 했습니다.

검증

  • 단위 Test 3개 추가: 단건 질의 네 개가 실제로 id Placeholder를 걸고 인자와 대응하는지, 빈·공백 id는 목록을 필터링하지 않는지, 모든 $n이 인자와 1:1로 맞는지 확인하는 회귀 가드
  • id 조건을 지우면 새 Test가 실제로 실패하는지 확인
  • Go 전체 Test/Vet (go test -race ./..., go vet ./...), gofmt
  • TypeScript Typecheck와 React Production Build
  • 환경변수 계약(check-env-contract.sh)과 외부 정적 Asset 차단(check-static-assets.sh)

업그레이드

DB Migration은 없습니다. API 계약과 화면도 그대로이며, 저장된 데이터에는 영향이 없습니다.

지금까지 목록 뒤쪽에 밀려 열리지 않던 항목이 열리고, 무시·수용·기각도 정상 처리됩니다. 이 문제로 처리하지 못하고 남겨 둔 조언과 위험이 있다면 이번 Release 이후 다시 처리할 수 있습니다.

오프라인 이미지

항목
Asset relio-v1.11.10.tar.gz
Docker Image relio:v1.11.10
SHA-256 a76c1bb6760fbbdd5fa4f9c974609531686f3d893c50684a1ac19e57c2e6bc5f
압축 크기 36M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.10.tar.gz | docker load
docker image inspect relio:v1.11.10

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.10

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.

v1.11.9

Choose a tag to compare

@github-actions github-actions released this 02 Sep 13:32

Relio v1.11.9 — 인증 없는 CSP 보고로 나던 500 제거, OpenAPI 문서를 실제 라우터와 일치

이번 릴리스는 두 가지 계약 문제를 바로잡습니다. 브라우저가 보낼 수 있는 형태의 CSP 위반 보고 하나가 서버를 패닉시키던 문제와, /api/openapi.json이 서버가 실제로 제공하지 않는 API를 설명하던 문제입니다.

1. 지시자 없는 CSP 보고가 일으키던 패닉

/api/v1/csp-report는 브라우저가 보내는 위반 보고를 받는 엔드포인트로, 로그인 이전 화면도 정책을 위반할 수 있으므로 인증이 없습니다. 이 핸들러가 구형 보고 본문에서 지시자 이름을 꺼내는 코드가 다음과 같았습니다.

directive = strings.Fields(legacy.Report.Violated + " ")[0]

뒤에 붙인 공백은 가드처럼 보이지만 아무 역할도 하지 않습니다. strings.Fields는 공백뿐인 문자열에 빈 슬라이스를 돌려주므로, 첫 원소를 읽는 순간 index out of range로 패닉합니다.

effective-directiveviolated-directive는 둘 다 선택 항목입니다. blocked-uri만 담고 두 지시자가 모두 없는 보고 — 브라우저가 실제로 보낼 수 있는 형태입니다 — 가 도착하면 요청은 500으로 끝나고 로그에는 스택트레이스가 남았습니다. 인증이 없으므로 누구든 이 응답과 로그를 반복해서 유발할 수 있었습니다.

보고 본문 수정 전 수정 후
effective-directive: "script-src" script-src script-src
violated-directive: "script-src 'self'" script-src script-src
blocked-uri만 있고 두 지시자 모두 없음 패닉 → 500 204
두 지시자가 모두 공백 문자열 패닉 → 500 204

지시자 이름을 꺼내는 일을 reportedDirective 헬퍼로 분리했습니다. effective-directive를 먼저 쓰고, 없으면 정책 전체를 담는 violated-directive의 첫 토큰을 쓰며, 어느 쪽도 이름을 대지 못하면 빈 문자열을 돌려줍니다. 빈 값은 RecordViolation이 이미 버리므로 이름 없는 위반이 통계에 섞이지 않습니다.

2. OpenAPI 문서와 라우터의 불일치

/api/openapi.json은 REST·MCP 클라이언트가 가진 유일한 계약인데, 이 문서를 라우터와 대조하는 장치가 없어 서로 어긋나 있었습니다.

  • PUT /opportunities/{id}/playbook을 약속했지만 서버는 이 경로에 405로 답합니다. 실제 라우트는 항목을 받는 PUT /opportunities/{id}/playbook/{itemId}입니다
  • 로그인 흐름 전체(/auth/status, /auth/login, /auth/logout, /auth/me, OIDC 시작·Callback)와 /me/password, /dashboard 등 8개 엔드포인트가 문서에서 빠져 있었습니다. 문서만 읽는 클라이언트는 인증하는 방법조차 알 수 없었습니다

빠진 경로를 채우고 Playbook 경로를 실제 라우트로 바로잡았습니다.

3. 재발 방지

문서가 다시 조용히 어긋나지 않도록, 패키지 원본에서 파싱한 라우트 표와 OpenAPI 문서를 양방향으로 대조하는 Test 2개를 두었습니다. 새 /api/v1 라우트는 문서에 적히기 전까지, 문서에 적힌 경로는 라우팅되기 전까지 빌드를 실패시킵니다.

검증

  • CSP 보고 단위 Test 4개(두 지시자 조합)와 지시자 없는 보고가 204로 끝나는 핸들러 Test 추가
  • OpenAPI 문서와 라우터를 양방향 대조하는 계약 Test 2개 추가
  • Go 전체 Test/Vet (go test ./..., go vet ./...), gofmt
  • TypeScript Typecheck와 React Production Build
  • 환경변수 계약(check-env-contract.sh)과 외부 정적 Asset 차단(check-static-assets.sh)

업그레이드

DB Migration은 없습니다. 저장된 데이터와 화면 동작에는 영향이 없습니다.

/api/openapi.json을 읽어 클라이언트를 생성해 두었다면 다시 생성하세요. Playbook 항목 저장 경로가 문서상 PUT /opportunities/{id}/playbook에서 PUT /opportunities/{id}/playbook/{itemId}로 바뀌지만, 서버가 답하던 경로는 처음부터 후자였으므로 실제 동작이 달라지는 것은 아닙니다.

오프라인 이미지

항목
Asset relio-v1.11.9.tar.gz
Docker Image relio:v1.11.9
SHA-256 b9ff330078b0b65a02c93f8b63eeb3ca12a2658ddbfdac5f88deb7a9524dbfec
압축 크기 36M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.9.tar.gz | docker load
docker image inspect relio:v1.11.9

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.9

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.

v1.11.8

Choose a tag to compare

@github-actions github-actions released this 02 Sep 07:20

Relio v1.11.8 — 검색어의 %_를 와일드카드가 아닌 글자로 처리

고객 검색창에 50%를 입력하면 50이 들어간 모든 고객이 나오고, _는 아무 글자 하나와 일치했으며, % 한 글자만 넣으면 목록 전체가 반환되었습니다. 입력한 그대로 찾을 방법이 없었습니다.

1. 원인

모든 자유 검색 질의가 사용자가 입력한 문자열을 SQL 안에서 그대로 이어 붙였습니다.

lower(c.name) LIKE '%'||lower($4)||'%'

PostgreSQL의 LIKE에서 %는 임의의 문자열, _는 임의의 한 글자를 뜻합니다. 검색어에 포함된 이 두 글자가 패턴의 일부로 해석되어, 사용자가 찾으려던 글자가 아니라 와일드카드로 동작했습니다. 관리자 Audit Log 검색도 ILIKE로 같은 문제를 가지고 있었습니다.

검색어 수정 전 수정 후
50% 50이 들어간 모든 레코드 이름에 50%가 든 레코드
AB_C ABxC, AB1C 등도 일치 AB_C가 든 레코드
% 목록 전체 %가 든 레코드
back\slash 백슬래시가 이스케이프 문자로 소비됨 back\slash가 든 레코드

SQL Injection은 아닙니다. 값은 처음부터 Bind Parameter로 전달되었고, 문제는 전달된 값이 패턴 문법으로 읽힌 것입니다.

2. 수정

  • crm.SearchPattern이 Go 쪽에서 패턴을 만듭니다. 백슬래시와 %, _를 이스케이프한 뒤 앞뒤에 %를 붙입니다
  • 자유 검색 7개 질의 — 고객, 영업기회, 리드, 제품, 담당자, 협업자, 관리자 Audit Log — 가 모두 LIKE $n ESCAPE '\'를 선언합니다
  • 빈 검색어는 빈 패턴을 그대로 돌려줍니다. 각 질의 앞의 $n='' 무필터 가드가 그대로 유지되므로, 검색어 없이 목록을 여는 동작은 이전과 동일합니다
  • 대소문자 구분 없는 검색과 앞뒤 공백 제거 동작도 이전과 동일합니다

3. 재발 방지

새로 추가되는 검색이 조용히 같은 문제를 들여오지 않도록, internal/ 전체의 Go 원본을 훑어 ESCAPE 없는 LIKE·ILIKE를 발견하면 실패하는 Test를 두었습니다.

검증

  • 단위 Test 3개 추가: 이스케이프 결과, 공백 검색어가 빈 패턴을 유지하는지, ESCAPE 없는 검색 질의 회귀 가드
  • Go 전체 Test/Vet (go test -race ./..., go vet ./...), gofmt
  • TypeScript Typecheck와 React Production Build
  • 환경변수 계약(check-env-contract.sh)과 외부 정적 Asset 차단(check-static-assets.sh)

업그레이드

DB Migration은 없습니다. 화면과 API 계약도 그대로이며, 저장된 데이터에는 영향이 없습니다.

검색 결과만 좁아집니다. 지금까지 %_가 든 검색어로 더 넓은 결과를 얻고 있었다면, 이번 Release 이후에는 입력한 문자열이 그대로 포함된 레코드만 나옵니다.

오프라인 이미지

항목
Asset relio-v1.11.8.tar.gz
Docker Image relio:v1.11.8
SHA-256 c286ac3837e733b944c5c7e3c63202216cefd15a8319e95dd4b7faf3025b2fce
압축 크기 36M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.8.tar.gz | docker load
docker image inspect relio:v1.11.8

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.8

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.

v1.11.7

Choose a tag to compare

@github-actions github-actions released this 27 Aug 06:24

Relio v1.11.7 — 끝에 슬래시가 붙은 MCP 주소에서 나던 failed to parse json 제거

Qwen Code와 OpenCode에서 도구 목록을 가져올 때 failed to parse json이 발생했습니다. 전송 구현이나 인증이 아니라, MCP 클라이언트가 보낸 요청이 MCP 핸들러에 도달하지 못하고 화면(SPA)으로 넘어가 HTML을 돌려받은 것이 원인이었습니다.

1. 원인

Relio는 브라우저 라우팅을 위해 알려지지 않은 경로를 모두 index.html로 응답합니다. 이 Fallback이 제외하던 경로는 /api/ 접두사와 정확히 /mcp 하나뿐이었습니다.

실제 서버에서 확인한 응답입니다.

요청 수정 전 수정 후
POST /mcp 200 application/json 200 application/json
POST /mcp/ 200 text/html — 화면 HTML 200 application/json
GET /.well-known/oauth-protected-resource 200 text/html 404 application/json
GET /.well-known/oauth-authorization-server 200 text/html 404 application/json
DELETE /mcp 200, 본문 없음 204 No Content
POST /mcp 잘못된 본문 200 400
POST /app 등 화면 경로 200 text/html 405 application/json

주소 끝에 /를 붙였거나 클라이언트가 정규화 과정에서 붙인 경우, 인증은 통과한 것처럼 보이지만 첫 JSON-RPC 응답이 HTML이므로 클라이언트는 failed to parse json을 보고했습니다. OAuth 탐색 경로도 마찬가지로 HTML을 돌려주어, 인증 방식을 탐색하던 클라이언트가 존재하지 않는 OAuth 메타데이터를 파싱하려 했습니다.

2. 수정

  • /mcp/mcp/ 모두 동일한 MCP 핸들러로 연결
  • /mcp/그외경로는 화면 HTML이 아니라 JSON 404
  • /.well-known/ 경로는 JSON 404 — Relio는 개인 키 Bearer 인증만 사용하므로 클라이언트가 OAuth 시도를 중단하고 보유한 키를 그대로 씁니다
  • 화면 Fallback은 GET·HEAD에만 응답하고 그 외 Method는 JSON 405 — 잘못 라우팅된 API 호출이 HTML로 둔갑하지 않습니다
  • DELETE /mcp는 본문 없는 204
  • 파싱 불가능한 본문은 400 — 응답을 짝지을 id가 없는 상태에서 200을 주면 클라이언트가 오지 않을 응답을 기다립니다

3. 인증 탐색과 브라우저 클라이언트

  • MCP 401 응답에 WWW-Authenticate: Bearer realm="Relio MCP" 추가. 스킴을 명시하지 않으면 클라이언트가 OAuth 메타데이터를 찾아 나섭니다
  • OPTIONS /mcp CORS Preflight를 인증 이전 단계에서 처리. MCP Inspector처럼 브라우저에서 동작하는 클라이언트는 Authorization을 보내기 전에 Preflight를 먼저 보냅니다
  • 허용된 Origin에만 Access-Control-Allow-Origin을 부여하며, 허용되지 않은 Origin의 실제 요청은 기존과 동일하게 403

4. prompts/list

접속 직후 모든 Method를 탐색하는 클라이언트가 정상 서버를 상대로 Method not found를 기록했습니다. prompts Capability를 선언하고 빈 목록으로 응답합니다.

검증

  • Go 전체 Test/Vet, gofmt, TypeScript Typecheck, React Production Build
  • 실제 Qwen Code: http://…/mcp/http://…/mcp 모두 Connected
  • 실제 OpenCode: 두 주소 모두 connected
  • 공식 @modelcontextprotocol/sdk 클라이언트: 두 주소에서 initializetools/list 77개 → tools/call 성공
  • 22가지 전송 케이스 전수 점검에서 text/html 응답 0건
  • 단위 Test: 화면 Fallback이 기계 경로와 비 GET Method를 처리하지 않음, 파싱 실패의 HTTP 상태
  • 오프라인 통합 Test에 /mcp/·OAuth 탐색·DELETE·prompts/list 검사 추가
  • 실제 브라우저: 로그인, 6개 화면, 딥링크 새로고침, MCP 사용 안내 모달 정상
  • 완전 격리 Network 신규 설치 Smoke Test와 자격증명 연속성 Test

업그레이드

DB Migration은 없습니다. 기존 개인 키의 Secret, Scope, Channel, 만료일이 그대로 유지됩니다.

기존 설정에서 주소 끝의 /를 빼는 우회를 적용했다면 되돌릴 필요가 없으며, 두 표기 모두 동작합니다.

오프라인 이미지

항목
Asset relio-v1.11.7.tar.gz
Docker Image relio:v1.11.7
SHA-256 6000f0dcbd7a2cd99482247ae510ebfb0bfd327a01feeb60fe9f94b5eb626aff
압축 크기 36M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.7.tar.gz | docker load
docker image inspect relio:v1.11.7

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.7

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.

v1.11.6

Choose a tag to compare

@github-actions github-actions released this 12 Aug 05:54

Relio v1.11.6 — 고객 관계 연결 후 잘못 뜨던 null 오류 제거

고객 360에서 두 담당자의 관계를 연결하면 서버 저장은 성공했지만, 성공 직후 브라우저에 null TypeError 알림이 추가로 표시되고 관계도가 즉시 갱신되지 않았습니다.

원인

관계 저장 API를 기다린 뒤 React 이벤트의 e.currentTarget으로 폼을 초기화했습니다. 비동기 대기 후에는 이벤트의 currentTargetnull이 될 수 있어 다음 순서로 문제가 발생했습니다.

  1. 관계 데이터와 감사 로그는 정상 저장
  2. 성공 알림 표시
  3. e.currentTarget.reset()에서 TypeError
  4. 예외 처리기가 null 오류를 다시 알림
  5. 관계도 재조회 생략

수정

  • 비동기 요청 전에 실제 Form 참조를 보관
  • 저장 성공 후 보관한 Form을 안전하게 초기화
  • 고객 360, 관계도, Account Plan 재조회를 완료한 뒤 성공 알림 표시
  • 전체 프런트엔드에서 await 이후 e.currentTarget.reset()을 호출하는 동일 패턴 전수 점검

DB Migration은 없으며 기존 고객·담당자·관계 데이터에는 영향이 없습니다.

검증

  • TypeScript Typecheck와 React Production Build
  • 고객 관계 API 저장 성공 응답 계약 확인
  • 전체 프런트엔드의 비동기 Form reset 패턴 검색
  • Go 전체 Test/Vet와 완전 격리 Network Smoke Test
  • v1.11.5 오프라인 이미지에서 업그레이드 및 자격증명 연속성 검증

오프라인 이미지

항목
Asset relio-v1.11.6.tar.gz
Docker Image relio:v1.11.6
SHA-256 0a161f16e78ebd5d4b5e59bc61ef633c2c5937b361e8d861ee66b7b0d371619b
압축 크기 35M
Architecture linux/amd64

이 Release가 직접 제공하는 Asset은 위 Docker Image tar.gz 하나뿐입니다.

오프라인 서버에서 로드

gunzip -c relio-v1.11.6.tar.gz | docker load
docker image inspect relio:v1.11.6

실행

# 최초 1회만 생성하고 비밀번호 관리 도구에 보관하세요.
ENCRYPTION_KEY="$(openssl rand -hex 32)"

docker run -d \
  --name relio \
  -p 8080:8080 \
  -e POSTGRES_DSN="postgres://relio:password@postgres:5432/relio" \
  -e BOOTSTRAP_ADMIN="admin" \
  -e BOOTSTRAP_ADMIN_PASSWORD="ChangeMe-Immediately" \
  -e ENCRYPTION_KEY="$ENCRYPTION_KEY" \
  -v relio-data:/var/lib/relio \
  relio:v1.11.6

Relio Application이 받는 환경변수는 필수 3개(POSTGRES_DSN, BOOTSTRAP_ADMIN, BOOTSTRAP_ADMIN_PASSWORD)와 선택 1개(ENCRYPTION_KEY)뿐입니다.

ENCRYPTION_KEY를 설정하면 Personal Key와 SSO Client Secret이 재기동, 이미지 교체, relio-data Volume 재생성 후에도 유지됩니다. 설정하지 않으면 자격증명을 여는 Key가 Volume 안에만 존재하므로 /var/lib/relio를 PostgreSQL과 같은 복구 시점으로 반드시 보존해야 합니다.

검증

Release 전에 Source Test, TypeScript Typecheck, Frontend Build, Go Vet, 환경변수 계약, 외부 정적 Asset 차단, 비 Root 실행, 완전 격리 Network 신규 설치 Smoke Test, 이전 Release 업그레이드 Test, Docker Save/Load 재검증을 통과합니다.