download floorplan image failed
RCA: download floorplan image failed
Overview#
What Happened#
2026-07-15 11:28:46 KST 에 cupixworks-capture-refinement-arm-instance (프로덕션 us-west-2, tenant cupix) 의 capture refinement job(id 1197619) 이 floorplan 이미지 다운로드 단계에서 실패했다. RefinementService::loadFloorplan 이 CPFloorplan.downloadImage 로부터 false 를 받아 Error('download floorplan image failed') 를 throw 했고, 원인은 로컬 저장 경로에 슬래시가 포함된 파일 확장자(rvt-site/lvl 1) 가 들어가 존재하지 않는 하위 디렉터리에 write 를 시도하면서 발생한 ENOENT 였다.
Quick Facts#
| Field | Value |
|---|---|
| exception.class | Error |
| exception.message | download floorplan image failed |
| top_frame | packages/cupix-capture-refinement-agent/src/refinement-service.ts:209 |
| underlying_error | ENOENT: open /tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1 |
| env | production, us-west-2 |
| tenant | cupix |
| team | rsconstruction |
| job_id | 1197619 |
| floorplan_id | 83272 |
| capture_id (workspace) | 731741 |
Affected Teams#
| Team / Domain | Error Count | Impact |
|---|---|---|
| rsconstruction / capture refinement | 1 | 단일 capture refinement job (1197619) 이 실패해 해당 capture 의 refinement 결과가 산출되지 않음 |
Timeline#
- 2026-07-15 11:27:50 KST — Refinement 인스턴스가 부팅되고
RefinementService::init,authenticate,run이 실행됨.JobManager::loadJob이 job id1197619를 로드. - 2026-07-15 11:27:53 KST — capture 리소스/video 파일 관련 warn 로그(
video filepath not found - 693974.insv) 다수 기록. (별개 이슈, 이번 실패의 직접 원인은 아님) - 2026-07-15 11:28:46 KST —
CPFloorplan.downloadImage가ENOENT로 실패,RefinementService::loadFloorplan이download floorplan image failed를 error 로 로깅하며 throw.
Error Log#
download floorplan image failed
Impact#
- Service:
cupixworks-capture-refinement-arm-instance - Team: rsconstruction
- 발생 횟수: 1
- 최초 발생: 2026-07-15 11:28:46 KST
- 최근 발생: 2026-07-15 11:28:46 KST
Blast radius 는 단일 job. 다만 원인이 특정 floorplan record 의 데이터 형태 (file_extension 또는 meta.name 이 슬래시를 포함) 이므로, 동일한 floorplan 을 참조하는 capture refinement job 은 재시도 시에도 계속 실패한다. 다른 BIM/Revit 파생 floorplan 도 같은 패턴이면 재발 가능성 있음.
Root Cause Summary#
CPFloorplan.setDefaultLocalPaths 에서 로컬 이미지 파일 경로를 path.join(captureWorkspaceDirPath, floorplan_${id}.${originalImageFileExt}) 로 구성한다. 이번 사건의 floorplan 83272 는 확장자 부분이 슬래시와 공백을 포함하는 값(rvt-site/lvl 1) 으로 결정되어 최종 경로가 /tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1 이 되었다. 이 값은 확장자가 아니라 하위 디렉터리를 만들어야 하는 문자열이지만, 코드는 파일명만 가정하고 fs.writeFileSync 를 호출하기 때문에 floorplan_83272.rvt-site 디렉터리가 존재하지 않아 ENOENT 로 실패했다. downloadImage 는 예외를 catch 해 false 만 반환하므로 상위 loadFloorplan 에서는 원인 정보 없이 일반 실패 문자열만 throw 된다.
Technical Analysis#
Code Path#
- Entry point:
packages/cupix-capture-refinement-agent/src/refinement-service.ts:192(loadFloorplan) - Path 생성:
packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:35(setDefaultLocalPaths) - Failure point:
packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:67(fs.writeFileSyncon unresolved subdirectory)
loadFloorplan 은 서버에서 floorplan 을 조회하고 CPFloorplan 생성 시 로컬 경로를 계산한 뒤 downloadImage 를 호출한다. downloadImage 가 false 를 반환하면 error log 를 남기고 Error('download floorplan image failed') 를 throw 하여 상위로 전파한다.
private loadFloorplan = async (cpCapture: CPCapture): Promise<void> => {
logger.debug('RefinementService::loadFloorplan | begin');
const floorplanId = cpCapture.cpCluster?.getUsedFloorplanIdFromMeta();
if (floorplanId == undefined) {
logger.error('RefinementService::loadFloorplan | end - not found floorplan id');
this.jobManager.setErrorCode(ErrorCode.ScenemapperUtils.RefinerNotFoundFloorplan);
throw new Error('not found floorplan id');
}
const srvFloorplan = await this.cupixApi.floorplan.get(floorplanId);
logger.debug('RefinementService::loadFloorplan | floorplan id: %d, floorplan name: %s', srvFloorplan.id, srvFloorplan.name);
const newCPFloorplan = new CPFloorplan(srvFloorplan, cpCapture);
cpCapture.setCPFloorplan(newCPFloorplan);
const isSuccess = await newCPFloorplan.downloadImage(this.cupixAuth.accessToken as string);
if (!isSuccess) {
logger.error('RefinementService::loadFloorplan | end - download image failed');
this.jobManager.setErrorCode(ErrorCode.ScenemapperUtils.RefinerNotFoundFloorplan);
throw new Error('download floorplan image failed');
}
// ...
};
로컬 경로 계산 로직은 file_extension 이 비어 있으면 meta.name 의 path.extname().slice(1) 로 대체하여 그대로 파일명에 붙인다. 슬래시/공백에 대한 sanitize 가 전혀 없어 값에 / 나 공백이 들어가면 상위 디렉터리 경로로 해석된다.
private setDefaultLocalPaths = (): boolean => {
if (this.id == Constants.UnknownId) return false;
if (this.cpCapture == undefined) return false;
if (this.srvFloorplan == undefined) return false;
const captureWorkspaceDirPath = this.cpCapture.workspaceDirPath;
CPUtils.checkAndCreateFolder(captureWorkspaceDirPath);
this._localMetaJsonFilePath = path.join(captureWorkspaceDirPath, `floorplan_${this.id}.json`);
let originalImageFileExt = this.srvFloorplan.file_extension;
if (originalImageFileExt == undefined || originalImageFileExt == '') {
const originalImageFileName = (<any>this.srvFloorplan.meta)?.name;
if (originalImageFileName != undefined) {
originalImageFileExt = path.extname(originalImageFileName).slice(1);
}
logger.debug('CPFloorplan::setDefaultLocalPaths | floorplan file_extension: %s, originalImageFileExt: %s', this.srvFloorplan.file_extension, originalImageFileExt);
}
this._localImageFilePath = path.join(captureWorkspaceDirPath, `floorplan_${this.id}.${originalImageFileExt}`);
return true;
};
downloadImage 는 write 실패를 catch 하되, 에러 객체를 JSON.stringify 해 warn 으로만 남기고 boolean false 만 반환한다. 상위 호출자는 저수준 원인(ENOENT, 경로) 을 볼 수 없다.
downloadImage = async (accessToken: string): Promise<boolean> => {
if (this.srvFloorplan == undefined) return false;
if (this.localImageFilePath == undefined) return false;
try {
const url = Environment.CPX_API_ENDPOINT + '/floorplans/' + this.id.toString() + '/download';
const downloadOptions: any = {
headers: { 'X-CUPIX-AUTH': accessToken },
retry: 10
};
fs.writeFileSync(this.localImageFilePath, await download(url, undefined, downloadOptions));
} catch (error) {
logger.warn('CPFloorplan::downloadImage | %s', JSON.stringify(error));
return false;
}
logger.debug('CPFloorplan::downloadImage | floorplanImageFilePath: %s, size: %d bytes', this.localImageFilePath, CPUtils.getFileSize(this.localImageFilePath));
return true;
};
기대 동작 vs 실제 동작: 코드가 가정하는 값은 순수 파일 확장자(예: png, jpg, rvt) 이며 path.join 결과가 파일 경로여야 한다. 실제로 이번 floorplan 은 확장자 자리에 하위 경로(rvt-site/lvl 1) 가 들어와 path.join 이 이를 그대로 결합, floorplan_83272.rvt-site 이라는 존재하지 않는 디렉터리를 부모로 갖는 경로가 만들어져 open ENOENT 로 실패했다.
Log Evidence#
Datadog query (재현용):
service:cupixworks-capture-refinement-arm-instance @environment:production ("Floorplan" OR "floorplan" OR "loadFloorplan" OR "downloadImage")
Time range: 2026-07-15T02:26:00Z ~ 2026-07-15T02:30:00Z
핵심 로그 (시간 오름차순, KST):
2026-07-15 11:27:50 KST info RefinementService::init
2026-07-15 11:27:50 KST info RefinementService::authenticate | begin
2026-07-15 11:27:50 KST info RefinementService::authenticate | end
2026-07-15 11:27:50 KST info CupixAuth::setSession | session_id: be5f455c99b17a61261d4cdd4a847b40f60a505b
2026-07-15 11:27:50 KST info RefinementService::run | begin
2026-07-15 11:27:50 KST info JobManager::loadJob | begin - job id: 1197619
2026-07-15 11:27:50 KST info JobManager::loadJob | end - job id: 1197619
...
2026-07-15 11:28:46 KST warn CPFloorplan::downloadImage | {"errno":-2,"code":"ENOENT","syscall":"open","path":"/tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1"}
2026-07-15 11:28:46 KST error RefinementService::loadFloorplan | end - download image failed
2026-07-15 11:28:46 KST error download floorplan image failed
특기점:
- warn 로그의
path값이 결정적 증거다./tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1에서floorplan_83272.rvt-site는 존재하지 않는 디렉터리 (captureWorkspaceDirPath는checkAndCreateFolder로 생성되지만, 그 하위의.rvt-site는 만들지 않음). errno:-2, code:"ENOENT", syscall:"open"은 Node 의fs.writeFileSync가 존재하지 않는 상위 디렉터리에 대해 write 를 시도할 때 나오는 전형적 에러다. 다운로드 자체(download(url, ...)) 는 이미 완료된 후 write 단계에서 실패했다.- server 가 응답한 floorplan 83272 의
file_extension또는meta.name값이rvt-site/lvl 1을 포함하는 문자열이라는 것을 시사한다 — Revit(.rvt) BIM floorplan 에서 site/level 을 이름에 포함하는 케이스로 보인다.
Hypotheses Considered#
| # | Hypothesis | Evidence for | Evidence against | Verdict |
|---|---|---|---|---|
| H1 | file_extension (또는 meta.name 파생 확장자) 에 슬래시/공백이 포함되어 path.join 이 존재하지 않는 하위 디렉터리를 만들며 fs.writeFileSync 가 ENOENT 로 실패 |
CPFloorplan::downloadImage warn 로그의 path: /tmp/workspace/731741/floorplan_83272.rvt-site/lvl 1, code:"ENOENT", syscall:"open" (2026-07-15 11:28:46 KST). 코드 cpfloorplan.ts:53 는 확장자를 sanitize 없이 파일명에 concat. |
없음 | Confirmed |
| H2 | HTTP 다운로드 자체가 401/403/5xx 로 실패해 body 를 못 받아서 실패 | download 라이브러리는 non-2xx 시 별도 exception 을 던지고 메시지에 상태코드가 포함되어야 하지만 실제 warn 로그의 에러 객체는 errno:-2, code:"ENOENT", syscall:"open" 로 순수 filesystem 에러 |
Confirmed 로그가 fs 계층 에러임을 확정 | Rejected |
| H3 | /tmp/workspace/731741 자체가 없어서 실패 (workspace 초기화 실패) |
워크스페이스 상위 디렉터리가 없다면 다른 파일들(floorplan_${id}.json 등) 도 실패해야 함 |
ENOENT path 가 floorplan_83272.rvt-site/lvl 1 이므로 731741 은 존재. workspaceDirPath 는 checkAndCreateFolder 로 생성됨(cpfloorplan.ts:41) |
Rejected |
| H4 | 액세스 토큰 만료로 다운로드 실패 | 다운로드 실패라면 HTTP 계층 에러가 catch 되어 다른 메시지가 나옴 | ENOENT 는 fs 계층 에러. 인증 실패의 흔적 없음 | Rejected |
Fix Recommendation#
즉시 조치 (Critical)#
- 파일:
applications/agents/packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:35-55 - 접근:
originalImageFileExt를 파일명에 붙이기 전에 반드시 sanitize 한다. 방향은 다음 중 하나 이상 조합:path.extname(name).slice(1)결과에서 슬래시/역슬래시/공백/제어문자를 제거하거나 확장자로 판별되지 않으면 안전한 default(bin또는 확장자 없음) 로 대체.- 최종 파일명에 대해
path.basename을 다시 적용하거나[^a-zA-Z0-9._-]를 하나의 안전 문자(_) 로 치환.
- 근거: 확장자에 경로 구분자가 섞이는 것은 서버 데이터(파일 이름) 에 의존한 결과이므로, 클라이언트 쪽에서 파일 시스템에 안전한 이름으로 정규화하는 것이 재발 방지의 최소 조건이다.
- 파일:
applications/agents/packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:57-75 - 접근:
downloadImagecatch 절에서JSON.stringify(error)대신 원본 Error 객체를 그대로 logger 에 넘겨 message/code/path 가 함께 남게 한다. 상위loadFloorplanthrow 문에도 원인 정보(예: floorplan id, 로컬 경로, ENOENT 여부) 를 포함시켜 재발 시 디버깅 시간을 단축한다. - 근거: 이번 사건도 warn 로그가 없었다면 원인 파악이 매우 어려웠다. logger convention (Error 객체 그대로 넘기기) 은 이미 사내 표준.
단기 개선 (1주 이내)#
- 파일:
applications/agents/packages/cupix-capture-refinement-agent/src/model/cpfloorplan.ts:44-51 - 서버 응답
file_extension이 존재하는 경우와meta.name파생 케이스 모두에 대해, 예상되지 않는 확장자(빈 문자열, 슬래시 포함, 길이 과다) 를 감지하면 명시적으로 warn 로그를 남기고 정규화된 값으로 다운그레이드한다. floorplan record 자체의 데이터 이상은 tesla 쪽에서도 조사할 수 있도록 job 결과(job error code, structured payload) 에 원본 값과 정규화 결과를 포함시키는 것을 검토. - Server-side (tesla) 조사: floorplan
83272의file_extension/meta.name실제 값을 확인하고, Revit BIM floorplan 저장 시 site/level 이 이름 안에 들어가는 경로 컨벤션인지 확인해 서버 스키마 검증(슬래시 금지 등) 을 넣을지 결정.
장기 개선 (재발 방지)#
- Agent 전반에서 외부 값(서버 응답, S3 key, 사용자 파일명) 을 로컬 파일 경로로 사용하는 지점을 감사해 sanitize 유틸(공통
safeFileName등) 로 통일. 유사 코드가cpbim.ts,cpcluster.ts등에도 반복될 가능성 큼. - Refinement/agent 계열 서비스에서 파일 시스템 관련 exception 은 error 레벨에서 원본 Error(code/path 포함) 를 그대로 로깅하도록 lint 규칙 또는 code review checklist 화.
Monitoring#
- 추가 메트릭/알림 방향:
- Refinement job 실패 카운트 (error 레벨 로그 기반) — floorplan 관련 실패를 별도 서브 카운트로 분리.
ENOENT를 포함한 filesystem 계열 에러가 다시 나오는지 감지.
Datadog 쿼리 예시 (release dashboard timeseries widget 에 그대로 넣을 수 있는 표현):
service:cupixworks-capture-refinement-arm-instance status:error "download floorplan image failed"
service:cupixworks-capture-refinement-arm-instance "CPFloorplan::downloadImage" "ENOENT"
service:cupixworks-capture-refinement-arm-instance status:error "RefinementService::loadFloorplan"
Risk Assessment#
- Risk level: medium — blast radius 는 개별 job 이지만, 동일 floorplan record 를 참조하는 후속 job 은 계속 실패한다. 다른 BIM/Revit floorplan 이 같은 이름 패턴이면 반복 재현 가능.
- 예상 복잡도: standard — sanitize 함수 추가와 로거 개선은 단일 파일 수준. 서버 데이터 정규화(스키마) 를 함께 진행하면 조율 필요.