Ansible Semaphore로 VM post-provisioning하기(2)
2026.04.13 - [프로젝트/OpenCSP] - [OpenCSP] Index - Provisioning Flow
[OpenCSP] Index - Provisioning Flow
2026.03.23 - [프로젝트/OpenCSP] - API 요청으로 Terraform CR 생성하기 2026.03.25 - [프로젝트/OpenCSP] - API 요청으로 PVE VM 생성하기 2026.03.26 - [프로젝트/OpenCSP] - OpenCSP Console로 VM 생성해보기 2026.03.28 - [프로젝
miiml.tistory.com
이전 글에서 semaphore UI를 사용해서 워크 플로우를 직접 테스트 해봤다.
semaphore는 Template 단위로 작업을 정의하고, 초기든 이후 단계든 상관없이 run으로 프로비저닝할 수 있다.
근데 template를 생성하려면 레포지토리(ansible role), 인벤토리, key store(SSH 키) 그리고 Variable Group이 필요하고
이중에 VM이 생성된 이후에 알 수 있는 값들과 공통으로 사용해야 하는 값들이 섞여 있었다.
우선 Variable group과 repository -> 얘네는 공통의 모듈을 사용하고 VM에 주입해야 하는 값들도 지금은 텔레포트 Join token (Mvp 개발에선 단일 토큰을 재활용 하는 방식) 이라 초기에 한번에 생성해주면 되서 FE > admin > integrations 탭에 UI를 만들고 백엔드에 간단히 CRUD 할 수 있는 API Endpoint, Service, Domain, DTO 같은걸 만들어줬다.
그리고 Key Store, Inventory, Template는 VM 생성 이후 시점(Terraform 프로비저닝 이후)에 알게된 IP나 노드 이름, VM ID 같은 걸로 순서대로 만들어야 된다. (Inventory 생성하려면 key Store가 지정되어야하고, Temeplate는 위에 말한대로 다 필요하기 때문)
백엔드 작업 내용
아래 코드는 세마포어에 대한 인터페이스인데
사실 이 부분은 세마포어 1개에 대한게 아니라 post provisioning 해주는 여러 툴들을 활용해서 백엔드가 공통적으로 처리해야 하는 기능을 정의해줘야 한다. (파사드 패턴?)
근데 이부분(AWX, Ansible Tower, Semaphore, Ansible CLI 등)을 한 단어로 뭐라고 할지 모르겠어서
나중에 수정하기로 하고 일단 개발했다.
package io.hlab.opencsp.infrastructure.semaphore;
import java.util.Map;
public interface SemaphoreClient {
boolean isConfigured(); // 현재 레이어의 도구(semaphore 같은)가 설정되어 있는지 여부
PostProvisionResult triggerPostProvisionJob(String crName, Map<String, String> outputs); // 프로비저닝 완료 후 post-provisioning Ansible job을 실행한다.
void cleanupPostProvision(int sshKeyId, int templateId, int inventoryId, int environmentId); // 프로비저닝 삭제 시 Semaphore에 생성했던 리소스(sshKey, inventory, template)를 정리한다.
TaskResult getTaskResult(int taskId); // Semaphore Task의 실행 상태와 로그 출력을 조회한다.
record PostProvisionResult(int sshKeyId, int inventoryId, int templateId, int taskId, int environmentId) {}
record TaskResult(String status, boolean success, String output) {}
}
그리고 위 인터페이스의 구현체는 아래처럼 만들어 지고 있음
길어서 접어 뒀는데 구조를 좀 요약하면 기본적으로 Semaphore 와 연동 방식은 WebClient를 활용해서 API를 사용하고
필요한 공통 옵션(레포지토리, variable group 등)은 configStore(이건 DB에 저장된 설정 값을 런타임에 조회하는 클래스로 백엔드 전역에서 여러 시스템과 연동을 위해 사용됨, admin integrations)로 관리하고 나머지는 필요한 정보를 각 API 양식에 맞게 채워서 요청하는 기능들이다.
package io.hlab.opencsp.infrastructure.semaphore;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.hlab.opencsp.domain.config.ConfigCategory;
import io.hlab.opencsp.infrastructure.config.ConfigStore;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
import org.springframework.web.reactive.function.client.WebClientResponseException;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.Optional;
/**
* Semaphore REST API v2 클라이언트.
*
* <h3>Semaphore Task 흐름 (프로비저닝 1건당)</h3>
* <ol>
* <li>POST /api/project/{id}/keys → sshKeyId (Terraform output의 ssh_private_key 사용, 없으면 정적 config)</li>
* <li>POST /api/project/{id}/inventory → inventoryId</li>
* <li>POST /api/project/{id}/templates → templateId (동적 생성)</li>
* <li>POST /api/project/{id}/tasks → taskId</li>
* </ol>
*
* <h3>정리 (destroy 시)</h3>
* <ol>
* <li>DELETE /api/project/{id}/templates/{templateId}</li>
* <li>DELETE /api/project/{id}/inventory/{inventoryId}</li>
* <li>DELETE /api/project/{id}/keys/{sshKeyId} (동적 생성된 경우만)</li>
* </ol>
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class SemaphoreHttpClient implements SemaphoreClient {
private final ConfigStore configStore;
private final ObjectMapper objectMapper;
// ──────────────────────────────────────────────────────────────────────────
// SemaphoreClient 구현
// ──────────────────────────────────────────────────────────────────────────
@Override
public boolean isConfigured() {
return configStore.get(ConfigCategory.SEMAPHORE, "semaphore.url")
.filter(v -> !v.isBlank())
.isPresent();
}
@Override
public PostProvisionResult triggerPostProvisionJob(String crName, Map<String, String> outputs) {
String baseUrl = requireUrl();
int projectId = requireInt("semaphore.project.id");
int repositoryId = requireInt("semaphore.repository.id");
String playbook = require("semaphore.playbook");
String token = require("semaphore.api.token");
WebClient wc = buildWebClient(baseUrl, token);
// 1. SSH 키 — BE가 인스턴스 생성 시 생성한 private key를 outputs에서 읽어 Semaphore에 등록
String privateKey = outputs.getOrDefault("vm_ssh_private_key",
outputs.getOrDefault("ssh_private_key", null));
if (privateKey == null || privateKey.isBlank()) {
throw new IllegalStateException("outputs에 SSH private key(vm_ssh_private_key)가 없습니다: crName=" + crName);
}
int sshKeyId = createSshKey(wc, projectId, crName, privateKey);
log.atInfo()
// .addKeyValue("cr_name", crName)
.addKeyValue("semaphore_ssh_key_id", sshKeyId)
.log("SSH 키 등록");
// 2. 인벤토리 생성
String inventoryContent = buildInventory(crName, outputs);
int inventoryId = createInventory(wc, projectId, crName, inventoryContent, sshKeyId);
log.atInfo()
// .addKeyValue("cr_name", crName)
.addKeyValue("semaphore_inventory_id", inventoryId)
.log("인벤토리 생성");
// 3. 환경 — 정적 config(semaphore.environment.id) 우선, 없으면 동적 생성
int envId;
boolean dynamicEnv;
Optional<Integer> staticEnvId = optionalInt("semaphore.environment.id");
if (staticEnvId.isPresent()) {
envId = staticEnvId.get();
dynamicEnv = false;
log.atInfo()
// .addKeyValue("cr_name", crName)
.addKeyValue("semaphore_env_id", envId)
.addKeyValue("env_source", "static")
.log("환경 사용");
} else {
envId = createEnvironment(wc, projectId, crName);
dynamicEnv = true;
log.atInfo()
// .addKeyValue("cr_name", crName)
.addKeyValue("semaphore_env_id", envId)
.addKeyValue("env_source", "dynamic")
.log("환경 생성");
}
// 4. 템플릿 동적 생성
int templateId = createTemplate(wc, projectId, crName, repositoryId, playbook, sshKeyId, inventoryId, envId);
log.atInfo()
// .addKeyValue("cr_name", crName)
.addKeyValue("semaphore_template_id", templateId)
.log("템플릿 생성");
// 5. Task 실행
int taskId = runTask(wc, projectId, templateId, inventoryId, crName);
log.atInfo()
// .addKeyValue("cr_name", crName)
.addKeyValue("semaphore_task_id", taskId)
.log("Task 실행");
return new PostProvisionResult(sshKeyId, inventoryId, templateId, taskId, envId);
}
@Override
public TaskResult getTaskResult(int taskId) {
String baseUrl = requireUrl();
int projectId = requireInt("semaphore.project.id");
String token = require("semaphore.api.token");
WebClient wc = buildWebClient(baseUrl, token);
try {
// 태스크 상태 조회
String taskJson = wc.get()
.uri("/api/project/{pid}/tasks/{tid}", projectId, taskId)
.retrieve().bodyToMono(String.class).block();
JsonNode task = objectMapper.readTree(taskJson);
String status = task.path("status").asText("unknown");
boolean success = "success".equals(status);
// 태스크 출력 조회
String outputJson = wc.get()
.uri("/api/project/{pid}/tasks/{tid}/output", projectId, taskId)
.retrieve().bodyToMono(String.class).block();
StringBuilder sb = new StringBuilder();
for (JsonNode line : objectMapper.readTree(outputJson)) {
sb.append(line.path("output").asText()).append("\n");
}
return new TaskResult(status, success, sb.toString().stripTrailing());
} catch (WebClientResponseException e) {
log.atWarn()
.addKeyValue("semaphore_task_id", taskId)
.addKeyValue("http_status", e.getStatusCode().value())
.setCause(e)
.log("Task 결과 조회 실패");
return new TaskResult("error", false, "HTTP " + e.getStatusCode().value());
} catch (Exception e) {
log.atWarn()
.addKeyValue("semaphore_task_id", taskId)
.addKeyValue("error", e.getMessage())
.setCause(e)
.log("Task 결과 조회 실패");
return new TaskResult("error", false, e.getMessage());
}
}
@Override
public void cleanupPostProvision(int sshKeyId, int templateId, int inventoryId, int environmentId) {
String baseUrl = requireUrl();
int projectId = requireInt("semaphore.project.id");
String token = require("semaphore.api.token");
WebClient wc = buildWebClient(baseUrl, token);
deleteTemplate(wc, projectId, templateId);
deleteInventory(wc, projectId, inventoryId);
if (sshKeyId > 0) {
deleteSshKey(wc, projectId, sshKeyId);
}
// 정적 config로 지정된 공유 환경은 삭제하지 않음
boolean isStaticEnv = optionalInt("semaphore.environment.id")
.filter(id -> id == environmentId)
.isPresent();
if (environmentId > 0 && !isStaticEnv) {
deleteEnvironment(wc, projectId, environmentId);
}
}
// ──────────────────────────────────────────────────────────────────────────
// Ansible 인벤토리 빌더
// ──────────────────────────────────────────────────────────────────────────
String buildInventory(String crName, Map<String, String> outputs) {
String hostname = outputs.getOrDefault("vm_hostname",
outputs.getOrDefault("vm_name", crName));
String ip = outputs.getOrDefault("vm_ip",
outputs.getOrDefault("ip_address", ""));
String user = outputs.getOrDefault("ansible_user",
outputs.getOrDefault("vm_user", "ubuntu"));
StringBuilder sb = new StringBuilder("[test_vms]\n");
sb.append(hostname);
if (!ip.isBlank()) sb.append(" ansible_host=").append(ip);
sb.append(" ansible_user=").append(user);
sb.append("\n\n[test_vms:vars]\n");
sb.append("opencsp_cr_name=").append(crName).append("\n");
if (!ip.isBlank()) sb.append("opencsp_vm_ip=").append(ip).append("\n");
if (!hostname.isBlank()) sb.append("opencsp_vm_hostname=").append(hostname).append("\n");
if (!hostname.isBlank()) sb.append("node_name=").append(hostname).append("\n");
return sb.toString();
}
// ──────────────────────────────────────────────────────────────────────────
// Semaphore API 호출
// ──────────────────────────────────────────────────────────────────────────
private int createSshKey(WebClient wc, int projectId, String crName, String privateKey) {
Map<String, Object> ssh = new LinkedHashMap<>();
ssh.put("private_key", privateKey);
Map<String, Object> body = new LinkedHashMap<>();
body.put("name", "opencsp-" + crName);
body.put("project_id", projectId);
body.put("type", "ssh");
body.put("ssh", ssh);
try {
String response = wc.post()
.uri("/api/project/{id}/keys", projectId)
.bodyValue(body)
.retrieve()
.bodyToMono(String.class)
.block();
JsonNode root = objectMapper.readTree(response);
return root.path("id").asInt();
} catch (WebClientResponseException e) {
log.atError()
.addKeyValue("http_status", e.getStatusCode().value())
.addKeyValue("response_body", e.getResponseBodyAsString())
.setCause(e)
.log("SSH 키 등록 실패");
throw new IllegalStateException("Semaphore SSH 키 등록 실패: " + e.getMessage(), e);
} catch (Exception e) {
throw new IllegalStateException("Semaphore SSH 키 등록 실패", e);
}
}
private int createInventory(WebClient wc, int projectId, String crName,
String inventoryContent, int sshKeyId) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("name", "opencsp-" + crName);
body.put("project_id", projectId);
body.put("inventory", inventoryContent);
body.put("ssh_key_id", sshKeyId);
body.put("type", "static");
try {
String response = wc.post()
.uri("/api/project/{id}/inventory", projectId)
.bodyValue(body)
.retrieve()
.bodyToMono(String.class)
.block();
JsonNode root = objectMapper.readTree(response);
return root.path("id").asInt();
} catch (WebClientResponseException e) {
log.atError()
.addKeyValue("http_status", e.getStatusCode().value())
.addKeyValue("response_body", e.getResponseBodyAsString())
.setCause(e)
.log("인벤토리 생성 실패");
throw new IllegalStateException("Semaphore 인벤토리 생성 실패: " + e.getMessage(), e);
} catch (Exception e) {
throw new IllegalStateException("Semaphore 인벤토리 생성 실패", e);
}
}
private int createTemplate(WebClient wc, int projectId, String crName,
int repositoryId, String playbook, int sshKeyId, int inventoryId,
int environmentId) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("project_id", projectId);
body.put("name", "opencsp-" + crName);
body.put("app", "ansible");
body.put("playbook", playbook);
body.put("repository_id", repositoryId);
body.put("inventory_id", inventoryId);
body.put("ssh_key_id", sshKeyId);
body.put("environment_id", environmentId);
body.put("type", "");
try {
String response = wc.post()
.uri("/api/project/{id}/templates", projectId)
.bodyValue(body)
.retrieve()
.bodyToMono(String.class)
.block();
JsonNode root = objectMapper.readTree(response);
return root.path("id").asInt();
} catch (WebClientResponseException e) {
log.atError()
.addKeyValue("http_status", e.getStatusCode().value())
.addKeyValue("response_body", e.getResponseBodyAsString())
.setCause(e)
.log("템플릿 생성 실패");
throw new IllegalStateException("Semaphore 템플릿 생성 실패: " + e.getMessage(), e);
} catch (Exception e) {
throw new IllegalStateException("Semaphore 템플릿 생성 실패", e);
}
}
private int runTask(WebClient wc, int projectId, int templateId,
int inventoryId, String crName) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("template_id", templateId);
body.put("inventory_id", inventoryId);
body.put("message", "OpenCSP post-provision: " + crName);
body.put("debug", false);
body.put("dry_run", false);
body.put("diff", false);
try {
String response = wc.post()
.uri("/api/project/{id}/tasks", projectId)
.bodyValue(body)
.retrieve()
.bodyToMono(String.class)
.block();
JsonNode root = objectMapper.readTree(response);
return root.path("id").asInt();
} catch (WebClientResponseException e) {
log.atError()
.addKeyValue("http_status", e.getStatusCode().value())
.addKeyValue("response_body", e.getResponseBodyAsString())
.setCause(e)
.log("Task 실행 실패");
throw new IllegalStateException("Semaphore Task 실행 실패: " + e.getMessage(), e);
} catch (Exception e) {
throw new IllegalStateException("Semaphore Task 실행 실패", e);
}
}
private void deleteTemplate(WebClient wc, int projectId, int templateId) {
try {
wc.delete()
.uri("/api/project/{projectId}/templates/{templateId}", projectId, templateId)
.retrieve()
.bodyToMono(Void.class)
.block();
log.atInfo()
.addKeyValue("semaphore_template_id", templateId)
.log("템플릿 삭제");
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.atDebug()
.addKeyValue("semaphore_template_id", templateId)
.log("템플릿 이미 없음 (정상)");
return;
}
log.atWarn()
.addKeyValue("semaphore_template_id", templateId)
.addKeyValue("http_status", e.getStatusCode().value())
.setCause(e)
.log("템플릿 삭제 실패");
}
}
private void deleteInventory(WebClient wc, int projectId, int inventoryId) {
try {
wc.delete()
.uri("/api/project/{projectId}/inventory/{inventoryId}", projectId, inventoryId)
.retrieve()
.bodyToMono(Void.class)
.block();
log.atInfo()
.addKeyValue("semaphore_inventory_id", inventoryId)
.log("인벤토리 삭제");
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.atDebug()
.addKeyValue("semaphore_inventory_id", inventoryId)
.log("인벤토리 이미 없음 (정상)");
return;
}
log.atWarn()
.addKeyValue("semaphore_inventory_id", inventoryId)
.addKeyValue("http_status", e.getStatusCode().value())
.setCause(e)
.log("인벤토리 삭제 실패");
}
}
private void deleteSshKey(WebClient wc, int projectId, int sshKeyId) {
try {
wc.delete()
.uri("/api/project/{projectId}/keys/{sshKeyId}", projectId, sshKeyId)
.retrieve()
.bodyToMono(Void.class)
.block();
log.atInfo()
.addKeyValue("semaphore_ssh_key_id", sshKeyId)
.log("SSH 키 삭제");
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.atDebug()
.addKeyValue("semaphore_ssh_key_id", sshKeyId)
.log("SSH 키 이미 없음 (정상)");
return;
}
log.atWarn()
.addKeyValue("semaphore_ssh_key_id", sshKeyId)
.addKeyValue("http_status", e.getStatusCode().value())
.setCause(e)
.log("SSH 키 삭제 실패");
}
}
private int createEnvironment(WebClient wc, int projectId, String crName) {
Map<String, Object> body = new LinkedHashMap<>();
body.put("name", "opencsp-" + crName);
body.put("project_id", projectId);
body.put("json", "{}");
body.put("env", null);
try {
String response = wc.post()
.uri("/api/project/{id}/environment", projectId)
.bodyValue(body)
.retrieve()
.bodyToMono(String.class)
.block();
JsonNode root = objectMapper.readTree(response);
return root.path("id").asInt();
} catch (WebClientResponseException e) {
log.atError()
.addKeyValue("http_status", e.getStatusCode().value())
.addKeyValue("response_body", e.getResponseBodyAsString())
.setCause(e)
.log("환경 생성 실패");
throw new IllegalStateException("Semaphore 환경 생성 실패: " + e.getMessage(), e);
} catch (Exception e) {
throw new IllegalStateException("Semaphore 환경 생성 실패", e);
}
}
private void deleteEnvironment(WebClient wc, int projectId, int environmentId) {
try {
wc.delete()
.uri("/api/project/{projectId}/environment/{environmentId}", projectId, environmentId)
.retrieve()
.bodyToMono(Void.class)
.block();
log.atInfo()
.addKeyValue("semaphore_env_id", environmentId)
.log("환경 삭제");
} catch (WebClientResponseException e) {
if (e.getStatusCode().value() == 404) {
log.atDebug()
.addKeyValue("semaphore_env_id", environmentId)
.log("환경 이미 없음 (정상)");
return;
}
log.atWarn()
.addKeyValue("semaphore_env_id", environmentId)
.addKeyValue("http_status", e.getStatusCode().value())
.setCause(e)
.log("환경 삭제 실패");
}
}
private String requireUrl() {
return configStore.get(ConfigCategory.SEMAPHORE, "semaphore.url")
.orElseThrow(() -> new IllegalStateException("semaphore.url 설정이 없습니다."));
}
private String require(String key) {
String value = configStore.get(ConfigCategory.SEMAPHORE, key)
.orElseThrow(() -> new IllegalStateException("Semaphore 설정 누락: " + key));
if (value == null || value.isBlank()) {
throw new IllegalStateException("Semaphore 설정값이 비어있음: " + key);
}
return value;
}
private Optional<Integer> optionalInt(String key) {
return configStore.get(ConfigCategory.SEMAPHORE, key)
.filter(v -> !v.isBlank())
.map(v -> {
try { return Integer.parseInt(v.trim()); }
catch (NumberFormatException e) { return null; }
});
}
private int requireInt(String key) {
String value = require(key);
try {
return Integer.parseInt(value.trim());
} catch (NumberFormatException e) {
throw new IllegalStateException("Semaphore 설정값이 정수가 아님: " + key + "=" + value, e);
}
}
private WebClient buildWebClient(String baseUrl, String token) {
return WebClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Authorization", "Bearer " + token)
.defaultHeader("Content-Type", "application/json")
.build();
}
}
인터페이스에서 정의한 triggerPostProvisionJob 구현하려면 동적으로 생성해야하는 key store, inventory, template들을 순차적으로 실행해줘야 했고 각 작업을 API 요청 단위로 정리했다.
좀 고민했었던 부분은 SSH 키 생성을 누가 언제할 거고 어떻게 주입할건지에 대한 부분과 트렌젝션 관리에 대한 부분인데
처음엔 SSH 키 생성을 Terraform에서 담당하도록 모듈을 수정했지만 tofu-controller에서 생성한 결과 키를 semaphore나 BE로 전달할 방법이 없었다. (tofu-controller가 output을 secret으로 관리하는데 private key는 생성이 안됨)
그래서 BE가 해당 부분을 담당하게 설계 수정해서 해결했다.
이전에 BE에서 teleport 랑 SSH 릴레이를 구현해보려고 JSch 의존성을 추가했었는데 (근데 SSL Handshake 중 실패해서 tsh로 변경)
이걸 사용해서 ssh 키를 생성했고 위에 구현체에 주입해주는 방식으로 바꿨다.
프론트엔드 작업 내용
프론트에선 매니저가 integrations 를 UI에서 할 수 있도록 아래처럼 섹션을 추가했는데 개선이 좀 필요할 거 같다.

그리고 아래처럼 인스턴스에 대한 Ansible 결과를 볼 수 있게 컬럼도 추가했는데 이부분도 상태 코드로 수정해서 알려주는 방식으로 변경해야 될 거 같다. (프로젝트 설계 철학? 때문에)

이제 사용자가 웹에서 프로비저닝을 요청하면 트랜젝션이 생성되고 그 과정 중에 위에 구현체를 사용해서 요청을 보내면 아래처럼 생성이 잘되는걸 볼 수 있고, 앤서블이 동작하면서 텔레포트에 노드 등록이 되는 것까지 확인했으니까
프로비저닝 플로우 개발이 완료됐다고 할 수 있을 거 같다.

추가로 고민해볼만한건
트랜젝션의 관리와 롤백을 어떻게 개선할 지 (지금은 백엔드가 해주고 있지만 중간 실패 단계에서 알림만 해주고 자동으로 롤백이나 취소처리해주지 않음),
운영중인 상태에서 BE가 죽거나 DB가 통째로 증발(?)하면 core의 각 마이크로 서비스들에 있는 파편화된 정보들을 BE가 어떻게 다시 제어할 수 있을 지
이런 부분들인데 어느 정도 방향성은 있긴하지만 MVP 개발에선 신경안쓰려고 한다.
나중에 시간되면 하나씩 포스팅해보겠음