Files
pipeline/backend-node/src/fleet/fleetCommandService.ts
T
chpark 4c1dc4082e
Build and Push Images / build-and-push (push) Has been cancelled
feat: Fleet/Collector/엣지 배포 관련 누적 작업 일괄 커밋
이전 세션들에서 작업된 아래 범위를 모두 포함:

Fleet 서브시스템 (src/fleet/)
- fleetDeviceService / fleetCommandService / fleetDeploymentService / fleetReleaseService
- fleetMetricsService, fleetScriptService, fleetEdgeConfigService
- Edge 디바이스 관리, 커맨드 발행, 배포/릴리스, 스크립트 동기화

Collector 확장
- centralMqttForwarder / centralForwarderConfigService
- equipmentStateService, pythonHookRunner, scriptCache
- Modbus/OPC-UA/S7/XGT 프로토콜 클라이언트
- targetDbIntrospection (저장 DB 조회)

Routes / API
- automationDashboardRoutes, centralForwarderRoutes, equipmentStateRoutes

DB
- importEdgeConfig (Python cached config → Pipeline DB)
- seedDataSources (external_db_connections 초기 시드)

엣지 배포 리소스
- docker/edge/Dockerfile.backend.prod, Dockerfile.frontend.prod
- docker/edge/docker-compose.edge.yml

프론트엔드
- admin/automaticMng (centralForwarder, dashboard, equipmentState)
- admin/fleet (commands, devices, deployments, releases, scripts, alerts)
- admin/pipeline-device 개선 (저장 DB 드롭다운, 태그 매핑 등)
- ExternalDbConnectionModal, ScriptsManagerDialog 등 신규 컴포넌트
- lib/api: automationDashboard, centralForwarder, equipmentState, fleet

docs/
- EDGE_SERVER_STRUCTURE, FLEET_COMPLETE, FLEET_EDGE_INTEGRATION, FLEET_HOOK_INTEGRATION

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-23 20:00:06 +09:00

207 lines
5.5 KiB
TypeScript

/**
* Fleet Command Service
* - 원격 커맨드 실행 (MQTT 기반)
* - 9가지 커맨드 타입 지원
*/
import { query, queryOne } from "../database/db";
import { logger } from "../utils/logger";
import { getFleetMqttBroker } from "./mqttBroker";
export type CommandType =
| "restart_container"
| "pull_image"
| "update_agent"
| "health_check"
| "execute_script"
| "deploy"
| "rollback"
| "collect_logs"
| "restart_device";
export type CommandStatus = "pending" | "sent" | "executing" | "success" | "failed" | "timeout";
export interface FleetCommand {
id?: number;
device_id: string;
command_type: CommandType;
payload?: Record<string, any>;
status?: CommandStatus;
result?: Record<string, any>;
error_message?: string;
issued_by?: string;
issued_at?: Date;
sent_at?: Date;
responded_at?: Date;
timeout_sec?: number;
}
export class FleetCommandService {
/**
* 커맨드 발행 (DB 저장 + MQTT 전송)
*/
static async issueCommand(
deviceId: string,
commandType: CommandType,
payload: Record<string, any> = {},
issuedBy?: string,
timeoutSec = 300,
): Promise<FleetCommand> {
// 커맨드 타입 검증
const typeCheck = await queryOne<any>(
`SELECT command_type FROM fleet_command_types WHERE command_type = $1`,
[commandType],
);
if (!typeCheck) {
throw new Error(`알 수 없는 커맨드 타입: ${commandType}`);
}
// 디바이스 확인
const device = await queryOne<any>(
`SELECT device_id, is_online FROM fleet_devices WHERE device_id = $1`,
[deviceId],
);
if (!device) {
throw new Error(`존재하지 않는 디바이스: ${deviceId}`);
}
// DB에 커맨드 기록
const result = await query<FleetCommand>(
`INSERT INTO fleet_commands
(device_id, command_type, payload, status, issued_by, timeout_sec)
VALUES ($1, $2, $3::jsonb, 'pending', $4, $5)
RETURNING *`,
[deviceId, commandType, JSON.stringify(payload), issuedBy || null, timeoutSec],
);
const command = result[0];
// MQTT로 디바이스에 전송
try {
const broker = getFleetMqttBroker();
await broker.sendCommandToDevice(deviceId, {
command_id: command.id,
command_type: commandType,
payload,
timeout_sec: timeoutSec,
});
await query(
`UPDATE fleet_commands SET status = 'sent', sent_at = NOW() WHERE id = $1`,
[command.id],
);
command.status = "sent";
logger.info(`[Fleet] 커맨드 발송: ${commandType}${deviceId} (id=${command.id})`);
} catch (e: any) {
await query(
`UPDATE fleet_commands SET status = 'failed', error_message = $2 WHERE id = $1`,
[command.id, `MQTT 발송 실패: ${e.message}`],
);
command.status = "failed";
command.error_message = e.message;
logger.error(`[Fleet] 커맨드 MQTT 전송 실패 (id=${command.id}):`, e);
}
return command;
}
/**
* 디바이스 응답 수신 처리 (MQTT 구독자에서 호출)
*/
static async handleResponse(
deviceId: string,
response: {
command_id: number;
status: "success" | "failed" | "executing";
result?: Record<string, any>;
error?: string;
},
) {
const newStatus: CommandStatus =
response.status === "success"
? "success"
: response.status === "failed"
? "failed"
: "executing";
await query(
`UPDATE fleet_commands SET
status = $1,
result = $2::jsonb,
error_message = $3,
responded_at = NOW()
WHERE id = $4 AND device_id = $5`,
[
newStatus,
JSON.stringify(response.result || {}),
response.error || null,
response.command_id,
deviceId,
],
);
logger.info(
`[Fleet] 커맨드 응답: id=${response.command_id} device=${deviceId} status=${newStatus}`,
);
}
/**
* 커맨드 목록 조회
*/
static async listCommands(filter: {
device_id?: string;
command_type?: string;
status?: string;
limit?: number;
} = {}) {
const wheres: string[] = [];
const params: any[] = [];
let idx = 1;
if (filter.device_id) {
wheres.push(`device_id = $${idx++}`);
params.push(filter.device_id);
}
if (filter.command_type) {
wheres.push(`command_type = $${idx++}`);
params.push(filter.command_type);
}
if (filter.status) {
wheres.push(`status = $${idx++}`);
params.push(filter.status);
}
const whereClause = wheres.length > 0 ? `WHERE ${wheres.join(" AND ")}` : "";
params.push(filter.limit || 100);
return await query<any>(
`SELECT * FROM fleet_commands ${whereClause}
ORDER BY issued_at DESC LIMIT $${idx}`,
params,
);
}
/**
* 타임아웃된 커맨드 정리 (주기적으로 호출)
*/
static async markTimedOutCommands() {
const result = await query<any>(
`UPDATE fleet_commands
SET status = 'timeout',
error_message = '응답 타임아웃'
WHERE status IN ('sent', 'executing')
AND sent_at IS NOT NULL
AND sent_at < NOW() - (timeout_sec || ' seconds')::INTERVAL
RETURNING id, device_id, command_type`,
);
if (result.length > 0) {
logger.warn(`[Fleet] 타임아웃 커맨드: ${result.length}`);
}
return result;
}
static async getCommandTypes() {
return await query<any>(
`SELECT * FROM fleet_command_types ORDER BY category, command_type`,
);
}
}