시작하기#
기본 개념#
Git으로 관리하지 않는 파일과 결과물을 별도로 전송하기
Git에서는 소스 코드와 변경 이력을 관리하고, 빌드 결과물이나 배포 패키지, 대용량 데이터와 같은 파일은 별도의 전송 흐름으로 관리할 수 있습니다.
Git 작업 이후 생성되거나 준비된 파일을 필요한 장비로 전송하도록 연결하면, 파일의 특성과 활용 환경에 맞춰 관리 흐름을 구성할 수 있습니다.
Git Repository
│
│ Code Change
▼
Build / Processing
│
│ Output Files
▼
File Transfer
│
▼
Target Device
예를 들어 소스 코드를 변경한 후 빌드 작업이 완료되면 생성된 패키지나 결과 파일을 테스트 서버, 배포 서버, 데이터 처리 장비로 자동 전송할 수 있습니다.
연계 흐름#
Git 작업부터 파일 전송까지 자동으로 이어가기
Git 작업을 기준으로 빌드나 파일 처리 작업을 진행하고, 필요한 파일이 준비되면 지정된 조건에 따라 파일 전송을 시작합니다.
전송이 완료되면 대상 장비에서 테스트, 배포, 데이터 처리와 같은 다음 업무를 이어갈 수 있습니다.
Git 작업
│
▼
빌드 · 파일 처리
│
▼
결과 파일 생성
│
▼
전송 작업 실행
│
▼
대상 장비 반영
│
▼
다음 작업 진행
분리 효과#
소스 코드와 업무 파일을 각각의 방식으로 관리하기
Git과 파일 전송을 함께 활용하면 소스 코드의 변경 관리와 별도 파일의 전송 및 활용을 하나의 업무 흐름 안에서 연결할 수 있습니다.
| 구분 | Git | 파일 전송 |
|---|---|---|
| 관리 대상 | 소스 코드와 변경 이력 | 결과 파일과 업무 파일 |
| 주요 역할 | 코드 변경과 버전 관리 | 파일 처리와 대상 장비 반영 |
| 실행 시점 | 코드 작업과 이벤트 발생 | 설정한 작업 조건 충족 |
| 활용 환경 | 개발과 형상 관리 | 테스트, 배포, 업무 장비 |
이를 통해 각 파일을 목적에 맞게 관리하면서 필요한 시점에는 Git 작업 이후의 파일 전송을 자동으로 실행할 수 있습니다.
IT 엔지니어#
환경 연결#
Git 작업과 파일 전송 장비 연결하기
먼저 Git 작업과 파일 전송에 사용할 장비를 하나의 흐름으로 연결합니다.
Git에서 발생하는 작업과 빌드 또는 파일 처리 환경, 결과 파일을 사용할 대상 장비를 연결하면 전체 파일 흐름을 구성할 수 있습니다.
┌──────────────┐
│ Git │
└──────┬───────┘
│
▼
┌──────────────┐
│ Build Server │
└──────┬───────┘
│
▼
┌──────────────┐
│ File Transfer│
└──────┬───────┘
│
▼
┌──────────────┐
│Target Device │
└──────────────┘
각 장비와 작업을 연결하면 Git 작업 이후 생성되는 파일이 다음 전송 단계까지 이어지는 기본 환경이 준비됩니다.
전송 규칙#
파일과 대상, 실행 조건을 하나의 기준으로 설정하기
별도로 관리할 파일과 전송 대상 장비를 지정하고, Git 또는 후속 작업의 어떤 시점에 파일 전송을 시작할지 함께 설정합니다.
전송할 파일의 경로와 종류를 지정하고, 대상 서버 또는 장비를 연결합니다. 이후 코드 변경, 빌드 완료, 결과 파일 생성과 같은 작업 조건을 전송 시작 기준으로 구성할 수 있습니다.
Git / Build Event
│
▼
Start Condition
│
├── Source
│ └── File / Path
│
└── Target
└── Device
│
▼
Transfer Run
| 구성 항목 | 설정 내용 |
|---|---|
| 시작 조건 | Git 작업 또는 빌드 완료 등 전송을 시작할 기준 |
| Source | 결과 파일과 파일 경로 |
| Filter | 전송할 파일 이름과 확장자 조건 |
| Target | 파일을 사용할 서버 또는 장비 |
| Transfer | 설정된 조건에 따라 실행할 파일 전송 |
이렇게 구성하면 어떤 작업 이후 어떤 파일을 어느 장비로 전송할지를 하나의 실행 기준으로 관리할 수 있습니다.
자동 흐름#
Git 작업 이후 파일을 지정된 장비로 자동 전송하기
앞에서 구성한 장비와 전송 규칙을 기준으로 Git 작업부터 파일 전송까지 자동 흐름을 완성합니다.
Git 작업이 발생하면 연결된 빌드 또는 처리 작업이 진행되고, 설정한 조건에 따라 준비된 파일을 지정된 장비로 전송합니다.
Git Push
│
▼
Build
│
▼
Output Ready
│
├──────────────┐
│ │
▼ ▼
Test Server Deploy Server
│ │
└──────┬───────┘
▼
Complete
하나의 결과 파일을 여러 테스트 또는 배포 환경으로 전송하도록 구성하면, Git 작업 이후 필요한 업무 환경까지 파일을 자동으로 연결할 수 있습니다.
결과 관리#
전송 상태와 재처리 흐름을 함께 관리하기
실행된 파일 전송 작업은 Runs와 실행 상세 정보를 통해 확인합니다.
Git 작업을 기준으로 어떤 전송 작업이 실행되었는지 확인하고, 대상 장비별 처리 상태와 전송된 파일 정보를 함께 관리할 수 있습니다.
Git Workflow
│
▼
Transfer Run
│
┌────┼───────┐
▼ ▼ ▼
Files Status Progress
│
▼
Result Review
│
┌────┴─────────────┐
▼ ▼
Completed Check Required
│
▼
Run Details
│
▼
Condition Check
│
▼
Retry
│
▼
Complete
주요 확인 항목은 다음과 같습니다.
| 확인 항목 | 확인 내용 |
|---|---|
| Trigger | 파일 전송을 시작한 Git 또는 후속 작업 |
| Source | 전송한 파일과 파일 경로 |
| Target | 파일을 사용할 대상 장비 |
| Progress | 전송 진행 상태 |
| Status | 실행 상태와 처리 결과 |
| Files | 처리된 파일 수와 용량 |
| Audit Log | 작업별 실행 기록과 상세 정보 |
실행 결과에서 추가 확인이 필요한 경우에는 해당 Run의 상세 정보와 Audit Log를 통해 파일 준비 상태, 전송 경로, 대상 장비의 연결 상태를 확인한 후 필요한 작업을 다시 실행할 수 있습니다.
개발자#
빌드 산출물을 여러 배포 대상에 보내고 전송 결과를 파이프라인 종료 코드로 반영하기
연동 준비#
공통 호출 코드와 CI 자격 준비하기
import os
import requests
BASE_URL = os.getenv("INNORIX_BASE_URL", "https://app.innorix.com").rstrip("/")
TOKEN = os.environ["INNORIX_ACCESS_TOKEN"]
WORKSPACE_ID = os.getenv("INNORIX_WORKSPACE_ID") # optional; falls back to the current workspace
STATUS_COMPLETE = 2
TERMINAL = {2, 4, 5, 9, 99} # complete / error / cancelled / partial / failed
NOT_SUCCEEDED = {4, 5, 9, 99}
def api(method, path, body=None, params=None):
headers = {
"Content-Type": "application/json",
"Authorization": f"Bearer {TOKEN}",
}
if WORKSPACE_ID:
headers["x-workspace-id"] = WORKSPACE_ID
response = requests.request(
method, BASE_URL + path,
headers=headers, json=body, params=params, timeout=30,
)
payload = response.json() if response.content else {}
if not response.ok:
raise RuntimeError(payload.get("message") or f"HTTP {response.status_code}")
return payload.get("data")
def is_terminal(detail):
return detail.get("isTerminal", detail.get("status") in TERMINAL)// InnorixClient.java
public static final String BASE_URL =
env("INNORIX_BASE_URL", "https://app.innorix.com").replaceAll("/+quot;, "");
public static final String WORKSPACE_ID = env("INNORIX_WORKSPACE_ID", null);
public static final int STATUS_COMPLETE = 2;
// States the transfer no longer moves out of
public static final Set<Integer> TERMINAL = Set.of(2, 4, 5, 9, 99);
// Terminal states that are not a full success
public static final Set<Integer> NOT_SUCCEEDED = Set.of(4, 5, 9, 99);
private HttpRequest.Builder headers(HttpRequest.Builder builder) {
builder.header("Content-Type", "application/json")
.header("Authorization", "Bearer " + session.accessToken());
// When omitted the account's current workspace is used.
if (workspaceId != null) builder.header("x-workspace-id", workspaceId);
return builder;
}
/** Unwraps and returns data from the response. Throws ApiError on failure. */
public Object api(String method, String path, Object body, Map<String, Object> params) {
Resp response = request(method, path, body, params);
Object payload = null;
try {
payload = Json.parse(response.text());
} catch (RuntimeException ignored) {
payload = null;
}
if (!response.ok()) {
Map<String, Object> map = Json.asObj(payload);
String message = Json.str(map, "message", Json.str(map, "error", "unknown error"));
throw new ApiError(response.status, message, map);
}
return Json.get(payload, "data");
}
/** Use the server flag when present, otherwise fall back to the status code. */
public static boolean isTerminal(Map<String, Object> record) {
Boolean flag = Json.boolOrNull(record, "isTerminal");
if (flag != null) return flag;
Integer status = Json.intOrNull(record, "status");
return status != null && TERMINAL.contains(status);
}// innorix-client.js
const BASE_URL = (process.env.INNORIX_BASE_URL
|| "https://app.innorix.com").replace(/\/+$/, "");
const TOKEN = process.env.INNORIX_ACCESS_TOKEN;
const WORKSPACE_ID = process.env.INNORIX_WORKSPACE_ID || null;
export const STATUS_COMPLETE = 2;
export const TERMINAL = new Set([2, 4, 5, 9, 99]); // complete / error / cancelled / partial / failed
export const NOT_SUCCEEDED = new Set([4, 5, 9, 99]);
export async function api(method, path, body = null, params = null) {
const url = new URL(BASE_URL + path);
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value === undefined || value === null) continue;
url.searchParams.set(key, String(value));
}
}
const headers = {
"Content-Type": "application/json",
Authorization: `Bearer ${TOKEN}`,
};
// When omitted the account's current workspace is used.
if (WORKSPACE_ID) headers["x-workspace-id"] = WORKSPACE_ID;
const response = await fetch(url, {
method,
headers,
body: body === null ? undefined : JSON.stringify(body),
signal: AbortSignal.timeout(30_000),
});
const payload = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(payload.message || `HTTP ${response.status}`);
}
return payload.data;
}
export function isTerminal(detail) {
return detail.isTerminal !== undefined
? detail.isTerminal
: TERMINAL.has(detail.status);
}// InnorixClient.cs
public static readonly string BaseUrl =
Env("INNORIX_BASE_URL", "https://app.innorix.com").TrimEnd('/');
public static readonly string WorkspaceIdFromEnv = Env("INNORIX_WORKSPACE_ID", null);
public const int StatusComplete = 2;
/// <summary>States the transfer no longer moves out of</summary>
public static readonly HashSet<int> Terminal = new HashSet<int> { 2, 4, 5, 9, 99 };
/// <summary>Terminal states that are not a full success</summary>
public static readonly HashSet<int> NotSucceeded = new HashSet<int> { 4, 5, 9, 99 };
// Applied on every request
request.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Session.AccessToken);
// When omitted the account's current workspace is used.
if (WorkspaceId != null) request.Headers.TryAddWithoutValidation("x-workspace-id", WorkspaceId);
public async Task<JsonNode> ApiAsync(string method, string path, JsonNode body = null,
IDictionary<string, object> parameters = null)
{
Resp response = await RequestAsync(method, path, body, parameters).ConfigureAwait(false);
JsonNode payload = null;
try
{
payload = J.Parse(response.Text());
}
catch (Exception)
{
payload = null;
}
if (!response.Ok)
{
JsonObject map = J.AsObj(payload);
string message = J.Str(map, "message", J.Str(map, "error", "unknown error"));
throw new ApiError(response.Status, message, map);
}
return J.Get(payload, "data");
}
/// <summary>Use the server flag when present, otherwise fall back to the status code.</summary>
public static bool IsTerminal(JsonObject record)
{
bool? flag = J.BoolOrNull(record, "isTerminal");
if (flag != null) return flag.Value;
int? status = J.IntOrNull(record, "status");
return status != null && Terminal.Contains(status.Value);
}전송 상태는 아래 값으로 판단합니다. 종료 상태는 다섯 개이고 성공에 해당하는 값은 완료(2)입니다.
| 상태 값 | 의미 | 종료 |
|---|---|---|
| 2 | 완료 | 예 |
| 4 | 오류 | 예 |
| 5 | 취소 | 예 |
| 9 | 부분 완료 | 예 |
| 99 | 실패 | 예 |
| 1 · 6 · 12 · 13 | 시작·전송 중·동기화 중·수신 중 | 아니오 |
CI에서 호출한다면 토큰을 파이프라인 시크릿으로 주입합니다. 저장소에 커밋하지 않습니다.
# GitHub Actions example
env:
INNORIX_BASE_URL: https://app.innorix.com
INNORIX_ACCESS_TOKEN: ${{ secrets.INNORIX_ACCESS_TOKEN }}산출물 경로 설계#
커밋이나 태그를 경로에 담아 배포 이력 남기기
어느 코드에서 나온 산출물인지 경로에 기록합니다. 버전 폴더를 따로 두면 이전 산출물이 남아 있어 문제가 생겼을 때 되돌리기 쉽습니다.
import os
import subprocess
def git_ref():
try:
sha = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"], text=True).strip()
except (subprocess.CalledProcessError, FileNotFoundError):
sha = "unknown"
return os.getenv("GIT_TAG") or sha
def target_path(base, ref):
return f"{base}/{ref}"String gitRef() {
String sha;
try {
Process process = new ProcessBuilder("git", "rev-parse", "--short", "HEAD")
.redirectErrorStream(true).start();
sha = new String(process.getInputStream().readAllBytes(),
StandardCharsets.UTF_8).strip();
if (process.waitFor() != 0) sha = "unknown";
} catch (Exception error) {
sha = "unknown";
}
String tag = System.getenv("GIT_TAG");
return tag != null ? tag : sha;
}
String targetPath(String base, String ref) {
return base + "/" + ref;
}import { execFileSync } from "node:child_process";
function gitRef() {
let sha;
try {
sha = execFileSync("git", ["rev-parse", "--short", "HEAD"],
{ encoding: "utf8" }).trim();
} catch {
sha = "unknown";
}
return process.env.GIT_TAG || sha;
}
const targetPath = (base, ref) => `${base}/${ref}`;string GitRef()
{
string sha;
try
{
var psi = new ProcessStartInfo("git", "rev-parse --short HEAD")
{
RedirectStandardOutput = true,
};
using var process = Process.Start(psi);
sha = process.StandardOutput.ReadToEnd().Trim();
process.WaitForExit();
if (process.ExitCode != 0) sha = "unknown";
}
catch
{
sha = "unknown";
}
return Environment.GetEnvironmentVariable("GIT_TAG") ?? sha;
}
string TargetPath(string baseDir, string ref) => quot;{baseDir}/{ref}";한 경로에 덮어쓰면 롤백할 대상이 없습니다.
산출물 전송#
빌드 결과 파일을 대상 장비로 보내기
파일을 보낼 때는 sourceItem에 isDir: false로 명시합니다. sourcePaths는 모든 경로를 폴더로 취급하므로, 산출물 파일을 넣으면 서버가 폴더로 스캔하려 해 느려지거나 시간이 초과됩니다.
def deploy_artifact(source, target, artifact_path, base):
ref = git_ref()
transfer = api("POST", "/api/transfers/manual", {
"sourceDevice": source,
"targetDevice": target,
"targetPath": target_path(base, ref),
"sourceItem": [{
"path": artifact_path,
"isDir": False,
"isFolder": False,
"fileSize": os.path.getsize(artifact_path),
}],
"sendAllFolder": False,
"transferOptions": {"target-action": "overwrite"},
})
return transfer["monitorId"], target_path(base, ref)String[] deployArtifact(String source, String target,
String artifactPath, String base) {
String ref = gitRef();
String targetPath = targetPath(base, ref);
Map<String, Object> transfer = client.apiObj("POST", "/api/transfers/manual", Json.newObj(
"sourceDevice", source,
"targetDevice", target,
"targetPath", targetPath,
"sourceItem", List.of(Json.newObj(
"path", artifactPath,
"isDir", false,
"isFolder", false,
"fileSize", new File(artifactPath).length())),
"sendAllFolder", false,
"transferOptions", Json.newObj("target-action", "overwrite")));
return new String[]{Json.str(transfer, "monitorId"), targetPath};
}import { statSync } from "node:fs";
async function deployArtifact(source, target, artifactPath, base) {
const ref = gitRef();
const path = targetPath(base, ref);
const transfer = await client.api("POST", "/api/transfers/manual", {
sourceDevice: source,
targetDevice: target,
targetPath: path,
sourceItem: [{
path: artifactPath,
isDir: false,
isFolder: false,
fileSize: statSync(artifactPath).size,
}],
sendAllFolder: false,
transferOptions: { "target-action": "overwrite" },
});
return [transfer.monitorId, path];
}async Task<(string MonitorId, string Path)> DeployArtifactAsync(
string source, string target, string artifactPath, string baseDir)
{
string ref = GitRef();
string path = TargetPath(baseDir, ref);
JsonObject transfer = await client.ApiObjAsync("POST", "/api/transfers/manual",
new JsonObject
{
["sourceDevice"] = source,
["targetDevice"] = target,
["targetPath"] = path,
["sourceItem"] = new JsonArray
{
new JsonObject
{
["path"] = artifactPath,
["isDir"] = false,
["isFolder"] = false,
["fileSize"] = new FileInfo(artifactPath).Length,
},
},
["sendAllFolder"] = false,
["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
});
return (J.Str(transfer, "monitorId"), path);
}빌드 디렉터리를 폴더 단위로 보낼 때는 sourcePaths와 sendAllFolder: True를 씁니다.
api("POST", "/api/transfers/manual", {
"sourceDevice": source,
"targetDevice": target,
"targetPath": target_path(base, ref),
"sourcePaths": ["/build/output"],
"sendAllFolder": True,
"transferOptions": {"target-action": "overwrite"},
})client.api("POST", "/api/transfers/manual", Json.newObj(
"sourceDevice", source,
"targetDevice", target,
"targetPath", targetPath(base, ref),
"sourcePaths", List.of("/build/output"),
"sendAllFolder", true,
"transferOptions", Json.newObj("target-action", "overwrite")));await client.api("POST", "/api/transfers/manual", {
sourceDevice: source,
targetDevice: target,
targetPath: targetPath(base, ref),
sourcePaths: ["/build/output"],
sendAllFolder: true,
transferOptions: { "target-action": "overwrite" },
});await client.ApiAsync("POST", "/api/transfers/manual", new JsonObject
{
["sourceDevice"] = source,
["targetDevice"] = target,
["targetPath"] = TargetPath(baseDir, ref),
["sourcePaths"] = new JsonArray { "/build/output" },
["sendAllFolder"] = true,
["transferOptions"] = new JsonObject { ["target-action"] = "overwrite" },
});같은 참조로 다시 빌드해 올리는 경우가 대부분이므로 overwrite를 씁니다. numbering을 쓰면 재배포할 때마다 사본이 쌓입니다.
여러 대상 배포#
하나의 산출물을 여러 환경으로 보내기
하나의 전송은 대상 장비 하나를 다룹니다. 테스트와 스테이징으로 함께 보내려면 전송을 나눠 만듭니다.
TARGETS = [
("device-test-01", "/deploy/app"),
("device-stage-01", "/deploy/app"),
]
transfers = {
target: deploy_artifact("device-build-01", target,
"/build/output/app.tar.gz", base)[0]
for target, base in TARGETS
}List<String[]> targets = List.of(
new String[]{"device-test-01", "/deploy/app"},
new String[]{"device-stage-01", "/deploy/app"});
Map<String, String> transfers = new LinkedHashMap<>();
for (String[] t : targets) {
String monitorId = deployArtifact("device-build-01", t[0],
"/build/output/app.tar.gz", t[1])[0];
transfers.put(t[0], monitorId);
}const TARGETS = [
["device-test-01", "/deploy/app"],
["device-stage-01", "/deploy/app"],
];
const transfers = {};
for (const [target, base] of TARGETS) {
const [monitorId] = await deployArtifact("device-build-01", target,
"/build/output/app.tar.gz", base);
transfers[target] = monitorId;
}var targets = new[]
{
("device-test-01", "/deploy/app"),
("device-stage-01", "/deploy/app"),
};
var transfers = new Dictionary<string, string>();
foreach (var (target, baseDir) in targets)
{
var (monitorId, _) = await DeployArtifactAsync("device-build-01", target,
"/build/output/app.tar.gz", baseDir);
transfers[target] = monitorId;
}def wait(monitor_id, timeout=3600, interval=3):
deadline = time.time() + timeout
while time.time() < deadline:
detail = api("GET", f"/api/transfers/{monitor_id}")
if is_terminal(detail):
return detail
time.sleep(interval)
raise TimeoutError(monitor_id)
def failed_files(monitor_id):
result = api("GET", f"/api/transfers/{monitor_id}/files", params={
"state": "any", "size": 500,
}) or {}
return [r for r in (result.get("children") or [])
if r.get("status") in NOT_SUCCEEDED]
def retry_failed(monitor_id):
rows = failed_files(monitor_id)
if not rows:
return 0
api("POST", f"/api/transfers/{monitor_id}/retry", {
"filesRetry": [
{"filePath": r["sourceFilePath"], "isDir": bool(r.get("isFolder"))}
for r in rows
]
})
return len(rows)public Map<String, Object> wait(String monitorId, int timeoutSeconds, int intervalSeconds) {
long deadline = System.currentTimeMillis() + timeoutSeconds * 1000L;
while (System.currentTimeMillis() < deadline) {
Map<String, Object> detail = apiObj("GET", "/api/transfers/" + monitorId);
if (isTerminal(detail)) return detail;
sleep(intervalSeconds * 1000L);
}
throw new ApiError(0, "timeout waiting for " + monitorId);
}
public List<Map<String, Object>> failedFiles(String monitorId) {
List<Map<String, Object>> failed = new ArrayList<>();
for (Map<String, Object> row : transferFiles(monitorId)) {
Integer status = Json.intOrNull(row, "status");
if (status != null && NOT_SUCCEEDED.contains(status)) failed.add(row);
}
return failed;
}
public int retryFailed(String monitorId) {
List<Map<String, Object>> rows = failedFiles(monitorId);
if (rows.isEmpty()) return 0;
List<Object> filesRetry = new ArrayList<>();
for (Map<String, Object> row : rows) {
String path = Json.str(row, "sourceFilePath");
if (path == null) continue;
filesRetry.add(Json.newObj("filePath", path, "isDir", Json.bool(row, "isFolder", false)));
}
api("POST", "/api/transfers/" + monitorId + "/retry", Json.newObj("filesRetry", filesRetry));
return rows.size();
}const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
export async function wait(monitorId, { timeout = 3600, interval = 3 } = {}) {
const deadline = Date.now() + timeout * 1000;
while (Date.now() < deadline) {
const detail = await api("GET", `/api/transfers/${monitorId}`);
if (isTerminal(detail)) return detail;
await sleep(interval * 1000);
}
throw new Error(`timeout waiting for ${monitorId}`);
}
export async function failedFiles(monitorId) {
const result = (await api("GET", `/api/transfers/${monitorId}/files`, null, {
state: "any", size: 500,
})) || {};
return (result.children || []).filter((row) => NOT_SUCCEEDED.has(row.status));
}
export async function retryFailed(monitorId) {
const rows = await failedFiles(monitorId);
if (rows.length === 0) return 0;
await api("POST", `/api/transfers/${monitorId}/retry`, {
filesRetry: rows
.filter((row) => row.sourceFilePath)
.map((row) => ({ filePath: row.sourceFilePath, isDir: Boolean(row.isFolder) })),
});
return rows.length;
}public async Task<JsonObject> WaitAsync(string monitorId, int timeoutSeconds = 3600,
int intervalSeconds = 3)
{
long deadline = Environment.TickCount64 + timeoutSeconds * 1000L;
while (Environment.TickCount64 < deadline)
{
JsonObject detail = await ApiObjAsync("GET", "/api/transfers/" + monitorId)
.ConfigureAwait(false);
if (IsTerminal(detail)) return detail;
await Task.Delay(intervalSeconds * 1000).ConfigureAwait(false);
}
throw new ApiError(0, "timeout waiting for " + monitorId);
}
public async Task<List<JsonObject>> FailedFilesAsync(string monitorId)
{
List<JsonObject> rows = await TransferFilesAsync(monitorId).ConfigureAwait(false);
return rows.Where(row =>
{
int? status = J.IntOrNull(row, "status");
return status != null && NotSucceeded.Contains(status.Value);
}).ToList();
}
/// <summary>Only callable once the transfer has reached a terminal state.</summary>
public async Task<int> RetryFailedAsync(string monitorId)
{
List<JsonObject> rows = await FailedFilesAsync(monitorId).ConfigureAwait(false);
if (rows.Count == 0) return 0;
var filesRetry = new JsonArray();
foreach (JsonObject row in rows)
{
string path = J.Str(row, "sourceFilePath");
if (path == null) continue;
filesRetry.Add(new JsonObject
{
["filePath"] = path,
["isDir"] = J.Bool(row, "isFolder", false),
});
}
await ApiAsync("POST", "/api/transfers/" + monitorId + "/retry",
new JsonObject { ["filesRetry"] = filesRetry }).ConfigureAwait(false);
return rows.Count;
}results = {}
for target, monitor_id in transfers.items():
results[target] = wait(monitor_id, timeout=3600)
failed = [t for t, d in results.items() if d["status"] != STATUS_COMPLETE]Map<String, Map<String, Object>> results = new LinkedHashMap<>();
for (var entry : transfers.entrySet()) {
results.put(entry.getKey(), client.await(entry.getValue(), 3600, null));
}
List<String> failed = new ArrayList<>();
results.forEach((target, detail) -> {
if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
failed.add(target);
}
});const results = {};
for (const [target, monitorId] of Object.entries(transfers)) {
results[target] = await wait(monitorId, { timeout: 3600 });
}
const failed = Object.entries(results)
.filter(([, d]) => d.status !== STATUS_COMPLETE)
.map(([target]) => target);var results = new Dictionary<string, JsonObject>();
foreach (var (target, monitorId) in transfers)
{
results[target] = await client.WaitAsync(monitorId, 3600);
}
var failed = results
.Where(kv => J.IntOrNull(kv.Value, "status") != InnorixClient.StatusComplete)
.Select(kv => kv.Key).ToList();한 대상이 실패해도 나머지를 계속 확인해야 어디까지 반영됐는지 알 수 있습니다. 첫 실패에서 멈추면 재배포 범위를 정할 수 없습니다.
대용량 산출물 전송#
대용량 산출물의 처리량을 속도·동시성 옵션으로 조절하기
컨테이너 이미지나 데이터셋처럼 크기가 큰 산출물은 처리량 옵션으로 전송 속도를 조절합니다.
api("POST", "/api/transfers/manual", {
"sourceDevice": source,
"targetDevice": target,
"targetPath": target_path(base, ref),
"sourcePaths": ["/build/output"],
"sendAllFolder": True,
"transferOptions": {
"target-action": "overwrite",
"networkLevel": 3, # throughput priority level
"concurrentTransfers": 8, # concurrent transfers
},
})client.api("POST", "/api/transfers/manual", Json.newObj(
"sourceDevice", source,
"targetDevice", target,
"targetPath", targetPath(base, ref),
"sourcePaths", List.of("/build/output"),
"sendAllFolder", true,
"transferOptions", Json.newObj(
"target-action", "overwrite",
"networkLevel", 3, // throughput priority level
"concurrentTransfers", 8))); // concurrent transfersawait client.api("POST", "/api/transfers/manual", {
sourceDevice: source,
targetDevice: target,
targetPath: targetPath(base, ref),
sourcePaths: ["/build/output"],
sendAllFolder: true,
transferOptions: {
"target-action": "overwrite",
networkLevel: 3, // throughput priority level
concurrentTransfers: 8, // concurrent transfers
},
});await client.ApiAsync("POST", "/api/transfers/manual", new JsonObject
{
["sourceDevice"] = source,
["targetDevice"] = target,
["targetPath"] = TargetPath(baseDir, ref),
["sourcePaths"] = new JsonArray { "/build/output" },
["sendAllFolder"] = true,
["transferOptions"] = new JsonObject
{
["target-action"] = "overwrite",
["networkLevel"] = 3, // throughput priority level
["concurrentTransfers"] = 8, // concurrent transfers
},
});CI 러너와 배포 대상이 같은 회선을 쓰는 환경이라면 속도 제한을 걸어 다른 작업에 영향이 가지 않게 합니다.
"transferOptions": {
"target-action": "overwrite",
"limitRate": 51200
}limitRate의 단위는 KB/s입니다.
파이프라인 연결#
전송 결과로 파이프라인 종료 코드 내기
CI는 종료 코드로 성공 여부를 판단합니다. 전송이 실패하면 파이프라인도 실패해야 합니다.
import sys
if __name__ == "__main__":
monitor_id, path = deploy_artifact(
os.environ["BUILD_DEVICE"],
os.environ["TARGET_DEVICE"],
os.environ["ARTIFACT_PATH"],
os.environ["DEPLOY_BASE"],
)
detail = wait(monitor_id)
if detail["status"] != STATUS_COMPLETE:
for row in failed_files(monitor_id)[:10]:
print(row["sourceFilePath"], row.get("errorCode"), file=sys.stderr)
print(f"retried {retry_failed(monitor_id)} files", file=sys.stderr)
sys.exit(1)
print(f"deployed: {path}")public static void main(String[] args) throws Exception {
String[] out = deployArtifact(
System.getenv("BUILD_DEVICE"),
System.getenv("TARGET_DEVICE"),
System.getenv("ARTIFACT_PATH"),
System.getenv("DEPLOY_BASE"));
String monitorId = out[0], path = out[1];
Map<String, Object> detail = client.await(monitorId, 3600, null);
if (Json.intOr(detail, "status", -1) != InnorixClient.STATUS_COMPLETE) {
for (Map<String, Object> row : client.failedFiles(monitorId).subList(0,
Math.min(10, client.failedFiles(monitorId).size()))) {
System.err.println(Json.str(row, "sourceFilePath") + " "
+ Json.str(row, "errorCode"));
}
System.err.println("재전송 " + client.retryFailed(monitorId) + "건");
System.exit(1);
}
System.out.println("배포 완료: " + path);
}const [monitorId, path] = await deployArtifact(
process.env.BUILD_DEVICE,
process.env.TARGET_DEVICE,
process.env.ARTIFACT_PATH,
process.env.DEPLOY_BASE,
);
const detail = await wait(monitorId);
if (detail.status !== STATUS_COMPLETE) {
for (const row of (await failedFiles(monitorId)).slice(0, 10)) {
console.error(row.sourceFilePath, row.errorCode);
}
console.error(`재전송 ${await retryFailed(monitorId)}건`);
process.exit(1);
}
console.log(`배포 완료: ${path}`);var (monitorId, path) = await DeployArtifactAsync(
Environment.GetEnvironmentVariable("BUILD_DEVICE"),
Environment.GetEnvironmentVariable("TARGET_DEVICE"),
Environment.GetEnvironmentVariable("ARTIFACT_PATH"),
Environment.GetEnvironmentVariable("DEPLOY_BASE"));
JsonObject detail = await client.WaitAsync(monitorId, 3600);
if (J.IntOrNull(detail, "status") != InnorixClient.StatusComplete)
{
foreach (JsonObject row in (await client.FailedFilesAsync(monitorId)).Take(10))
{
Console.Error.WriteLine(quot;{J.Str(row, "sourceFilePath")} "
+ quot;{J.Str(row, "errorCode")}");
}
Console.Error.WriteLine(quot;재전송 {await client.RetryFailedAsync(monitorId)}건");
Environment.Exit(1);
}
Console.WriteLine(quot;배포 완료: {path}");배포 이력 연결#
monitorId와 커밋 정보를 짝지어 기록하기
monitorId와 커밋 정보를 함께 남기면 배포 이력과 코드 이력이 연결됩니다.
record = {
"monitorId": monitor_id,
"commit": subprocess.check_output(
["git", "rev-parse", "HEAD"], text=True).strip(),
"branch": os.getenv("GIT_BRANCH"),
"buildNumber": os.getenv("BUILD_NUMBER"),
"targetPath": path,
}
db.insert("deployments", record)Map<String, Object> record = Json.newObj(
"monitorId", monitorId,
"commit", new String(new ProcessBuilder("git", "rev-parse", "HEAD")
.start().getInputStream().readAllBytes(), StandardCharsets.UTF_8).strip(),
"branch", System.getenv("GIT_BRANCH"),
"buildNumber", System.getenv("BUILD_NUMBER"),
"targetPath", path);
db.insert("deployments", record);const record = {
monitorId,
commit: execFileSync("git", ["rev-parse", "HEAD"], { encoding: "utf8" }).trim(),
branch: process.env.GIT_BRANCH,
buildNumber: process.env.BUILD_NUMBER,
targetPath: path,
};
await db.insert("deployments", record);var record = new
{
monitorId,
commit = RunGit("rev-parse HEAD").Trim(),
branch = Environment.GetEnvironmentVariable("GIT_BRANCH"),
buildNumber = Environment.GetEnvironmentVariable("BUILD_NUMBER"),
targetPath = path,
};
await db.InsertAsync("deployments", record);| 기록 항목 | 내용 |
|---|---|
monitorId |
전송 식별자 |
commit · branch |
산출물이 나온 코드 지점 |
buildNumber |
CI 실행 번호 |
targetPath |
대상 장비의 반영 경로 |
배포에 문제가 생겼을 때 monitorId로 이력을 찾고, 거기서 커밋까지 거슬러 올라갈 수 있습니다.
최근 배포 이력은 기간으로 조회합니다.
from datetime import datetime, timedelta, timezone
def paginate(path, params=None, limit=200, max_pages=50):
query = dict(params or {})
query["limit"] = limit
cursor = None
for _ in range(max_pages):
if cursor:
query["cursor"] = cursor
result = api("GET", path, params=query) or {}
for record in result.get("data") or []:
yield record
pagination = result.get("pagination") or {}
if not pagination.get("hasMore"):
return
cursor = pagination.get("nextCursor")
if not cursor:
return
end = datetime.now(timezone.utc)
fmt = "%Y-%m-%dT%H:%M:%SZ"
for row in paginate("/api/transfer-history", params={
"startDate": (end - timedelta(days=7)).strftime(fmt),
"endDate": end.strftime(fmt),
}):
print(row["monitorId"], row.get("statusName"),
row.get("targetDeviceName"), row.get("startDate"))Instant end = Instant.now();
DateTimeFormatter fmt = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'")
.withZone(ZoneOffset.UTC);
List<Map<String, Object>> rows = client.paginate("/api/transfer-history",
Json.newObj("startDate", fmt.format(end.minus(7, ChronoUnit.DAYS)),
"endDate", fmt.format(end)), 200, 50);
for (Map<String, Object> row : rows) {
System.out.println(Json.str(row, "monitorId") + " "
+ Json.str(row, "statusName") + " "
+ Json.str(row, "targetDeviceName") + " "
+ Json.str(row, "startDate"));
}const end = new Date();
const fmt = (d) => d.toISOString().replace(/\.\d{3}Z$/, "Z");
const params = {
startDate: fmt(new Date(end.getTime() - 7 * 86400000)),
endDate: fmt(end),
};
for await (const row of paginate("/api/transfer-history", params, 200)) {
console.log(row.monitorId, row.statusName, row.targetDeviceName, row.startDate);
}DateTime end = DateTime.UtcNow;
const string Fmt = "yyyy-MM-dd'T'HH:mm:ss'Z'";
List<JsonObject> rows = await client.PaginateAsync("/api/transfer-history",
new Dictionary<string, object>
{
["startDate"] = end.AddDays(-7).ToString(Fmt),
["endDate"] = end.ToString(Fmt),
}, 200);
foreach (JsonObject row in rows)
{
Console.WriteLine(quot;{J.Str(row, "monitorId")} {J.Str(row, "statusName")} "
+ quot;{J.Str(row, "targetDeviceName")} {J.Str(row, "startDate")}");
}| 확인 항목 | 확인 내용 |
|---|---|
| 산출물 | 전송한 파일과 경로 |
| 참조 | 커밋 또는 태그 |
| 대상 | 반영된 장비와 경로 |
| 상태 | 대상별 성공과 실패 |
| 실패 파일 | 파일별 오류와 재전송 결과 |