Skip to content

English {#english}

End-to-End Provisioning Workflow Design (5 Steps)

Purpose: Plan the design and implementation of the execution vehicle (GitHub Actions workflow or Provision Worker) that runs Pulumi (if needed) → Ansible → Zuplo Sync → Control Plane callback in one automated sequence.
No code generation — design requirements, I/O, option comparison, and callback spec only.

References: intelligent-automation-implementation-plan.md §3.3, saas-unified-architecture-hetzner-cloudflare-zuplo-plan.md §9.2, tenant-provisioning-flow.md §A.5, tenant-subdomain-dns-design.md (tenant DNS approach C), provision-tenant-pipeline.md. Unified view: api-control-plane-implementation-plan.md §5, §19, §21. Ansible vars (plan_tier, plan_limits, resource isolation): resource-optimization-safe-adoption-plan.md §2.4, §4.4.


1. Purpose and scope

  • Goal: One provisioning request (job_id, tenant_id, region, placement result) → infra, config, gateway, state update without human steps.
  • Scope: ① Receive job input (or poll Pending job) ② Pulumi Up (conditional) ③ Ansible ④ Zuplo Sync ⑤ Control Plane callback. AI placement, monitoring, Webhook trigger are outside this design.
  • Out of scope: Pulumi/Ansible/Zuplo internal logic, D1 schema changes, Stripe Webhook or placement engine implementation.

2. Execution vehicle comparison

  • GitHub Actions workflow: .github/workflows/provision-tenant.yml. Trigger: workflow_dispatch (job_id, tenant_id, region, target_server_id, create_new_server). Can run Pulumi and Ansible on same runner; callback via Control Plane API.
  • Provision Worker: HTTP POST /provision/run or Cron polling D1. Cannot run Pulumi/Ansible inside Worker; orchestrates by calling external workflow or service. Can update D1 directly for callback.
  • Guidance: Use GitHub Actions when Pulumi and Ansible run in one pipeline. Use Provision Worker (hybrid) when callback is simplified to direct D1 update and Pulumi/Ansible are invoked externally. This plan defines steps workflow-centric; Worker option summarized in §6.

3. I/O spec (summary)

Pipeline inputs: job_id (required), tenant_id (required), region (sg|us|eu), target_server_id (optional), create_new_server (optional). Delivered via workflow_dispatch inputs or Worker body/Cron from D1.
Intermediate outputs: Pulumi → server_ip; node lookup → server_ip; Ansible → api_key (artifact); Zuplo → none; Callback → none.
Callback payload (success): job_id, tenant_id, status=Completed, server_id/node_id, host, site_name (= canonical_hostname), api_key_ref (optional). On failure: status=Failed, error_message or stage. Control Plane sets provision_jobs.status, trace_events.


4. Step design (summary)

  • Step 1: Get job input (workflow inputs or D1 Pending row).
  • Step 2: Resolve server_ip — if target_server_id: read host from D1 nodes; if create_new_server: Pulumi Up, then register new node (POST /internal/nodes or equivalent).
  • Step 3: Ansible — dynamic inventory (server_ip, tenant_id), playbook; output api_key in artifact. Playbook: DB create → user → site dir → site_config → bench init·install-app. Inject tenant_id, site_name (canonical_hostname), db_root_password, plan_limits.
  • Step 4: Zuplo Sync — tenant_id, api_key (file or string). zuplo_sync.ts or CLI. On failure, report Failed; support step-wise retry from Zuplo.
  • Step 5: Callback — POST /internal/provision-complete (success) or provision-failed (failure). Control Plane updates provision_jobs, tenants_master, tenant_runtime. Or Worker updates D1 directly.

5–10. Workflow detail, Worker summary, Security, Prereqs, Checklist, Refs

  • §5 GitHub Actions: name provision-tenant, trigger workflow_dispatch, jobs (resolve-server / pulumi-up → Ansible → Zuplo Sync → callback), Secrets, failure handling.
  • §6 Provision Worker: HTTP or Cron; orchestrator only; calls Pulumi workflow and Ansible runner; Zuplo via fetch; D1 direct update.
  • §7 Security: secrets only, no API key in logs/artifacts. Idempotency: same job_id re-run skips or handles existing resources. Retry: re-dispatch same job_id to resume from failed step. Scalability: DB placement, Docker resource limits, Logpush.
  • §8 Prerequisites: D1 migration 0007, Ansible artifact and zuplo_sync, Control Plane callback or Worker D1 write, Pulumi stacks and tokens.
  • §9 Verification: Pulumi→Ansible→Zuplo→callback success; target_server_id path; failure→Failed callback; idempotent re-run; no API key in logs.
  • §10 Reference docs table.

Full tables (§1 scope, §2 option comparison, §3.1–§3.3, §4.1–§4.5, §5 job layout, §7.1, §10) and exact wording are in the Korean section below.


한국어 {#korean}

5단계 전 구간 자동화 워크플로 설계 기획서

목적: Pulumi(필요 시) → Ansible → Zuplo Sync → Control Plane 콜백까지 한 번에 자동 실행하는 실행 주체(GitHub Actions 워크플로 또는 Provision Worker)의 설계·구현을 위한 기획.
코드 생성 없음 — 설계 요건·입출력·옵션 비교·콜백 스펙만 정의.

참조: intelligent-automation-implementation-plan.md §3.3, saas-unified-architecture-hetzner-cloudflare-zuplo-plan.md §9.2, tenant-provisioning-flow.md §A.5, tenant-subdomain-dns-design.md (테넌트 DNS 방식 C), provision-tenant-pipeline.md. 통합 관점(공개 API·KV·Observability·보안·Runbook·Phase)은 api-control-plane-implementation-plan.md §5·§19·§21 참조. Ansible 입력 변수(plan_tier·plan_limits·자원 격리): resource-optimization-safe-adoption-plan.md §2.4·§4.4.


1. 목적 및 범위

항목내용
목표Control Plane이 트리거한 한 건의 프로비저닝 요청(job_id, tenant_id, region, 배치 결과)을 받아, 인프라·설정·게이트웨이·상태 갱신까지 사람 개입 없이 순차 실행.
범위① job 입력 수신(또는 Pending job 조회) ② Pulumi Up(조건부) ③ Ansible 실행 ④ Zuplo Sync ⑤ Control Plane 콜백. AI 배치 결정·모니터링 수집·Webhook 트리거는 본 설계 밖(선행 단계).
비범위Pulumi/Ansible/Zuplo 내부 로직 변경, D1 스키마 변경, Stripe Webhook·배치 엔진 구현.

2. 실행 주체 옵션 비교

구분GitHub Actions 워크플로Provision Worker
파일/위치.github/workflows/provision-tenant.ymlworkers/provision-tenant/ (또는 기존 control-plane 확장)
트리거workflow_dispatch(Control Plane이 GitHub API로 호출), 입력으로 job_id·tenant_id·region·target_server_id·create_new_server 전달① HTTP: Control Plane이 POST /provision/run 등 호출 시 body로 동일 입력 전달 ② Cron: D1 provision_jobs에서 status=Pending인 행 폴링 후 처리
Pulumi 실행별도 repo(prego-pulumi) checkout 또는 pulumi up 스크립트 호출. 리전별 Secret(HCLOUD_TOKEN_SG 등) 사용.Worker에서 Pulumi CLI 실행 불가 일반적. 외부 호출: GitHub Actions workflow_dispatch(pulumi-up만) 또는 별도 Pulumi Automation API/서비스 호출.
Ansible 실행Self-hosted runner 또는 호스트에서 ansible-playbook 실행. 인벤토리·artifact 경로를 워크플로에서 구성.Worker에서 Ansible 직접 실행 어려움. 외부 호출: GitHub Actions(Ansible만 담당 워크플로) 재사용 또는 별도 실행 환경(예: Self-hosted runner) HTTP로 트리거.
Zuplo Syncnpx tsx infra/zuplo_sync.ts 실행. Env에서 ZUPLO_* 주입.Worker에서 syncTenantApiKey() 호출 또는 fetch로 Zuplo API 직접 호출.
콜백워크플로 step에서 Control Plane API POST /internal/provision-complete(가정) 호출.Worker가 D1 바인딩으로 직접 provision_jobs·tenants_master·tenant_runtime 갱신 가능.
장점Pulumi·Ansible을 같은 runner에서 순차 실행하기 용이. 기존 Pulumi 워크플로와 패턴 통일.D1 직접 접근으로 콜백 단순. Cron 폴링 시 Webhook 실패 시에도 job 소비 가능.
단점Pulumi/Ansible repo·runner·Secret 관리 필요. 콜백 시 Control Plane API 구현 필요.Pulumi·Ansible을 Worker만으로 실행하기 어려워 외부 호출 또는 하이브리드 구조 필요.

선택 가이드

  • Pulumi·Ansible을 한 파이프라인에서 순차 실행하려면 GitHub Actions 워크플로가 적합.
  • 콜백을 D1 직접 갱신으로 단순화하고, Pulumi/Ansible은 기존 워크플로 또는 별도 서비스를 호출만 하려면 Provision Worker(하이브리드) 설계 가능.
  • 본 기획서는 워크플로 중심으로 단계를 정의하고, Worker 옵션은 §6에서 별도 요약.

2.1 이벤트 기반 API 흐름 (Zero-Touch 전략)

가입·결제 완료 후 수동 개입 없이 한 트랜잭션처럼 프로비저닝을 끝내기 위한 흐름 요약. (구현 시 GitHub Actions·Provision Worker·Task Queue 중 채택한 실행 주체에 맞춰 적용.)

단계역할내용
Request Receiver가입 요청 수신Tenant 테이블에 상태 **‘Provisioning’**으로 저장 후, 무거운 인프라 작업을 비동기로 넘김. 웹 요청 블로킹 방지.
Task Queue비동기 실행무거운 작업을 큐에 넣고 즉시 응답. (예: Celery/Redis, 또는 GitHub Actions workflow_dispatch, Provision Worker Cron 폴링.)
Worker Step A인프라·앱 구축Ansible 실행: 테넌트 전용 DB·DB 사용자·사이트 디렉터리·site_config·bench install-app. 워크플로/트리거에서 plan_tier·plan_limits(CPU·메모리 Hard/Soft) 전달 시 컨테이너 자원 격리 적용. §4.3 Playbook 태스크 구성 참조.
Worker Step B에지 라우팅프로비저닝 완료 후 Cloudflare API로 **TENANT_MAP(KV)**에 호스트명→오리진 매핑 등록. cloudflare-orchestration-and-gtape-backup-plan.md §2.1.
Worker Step C완료 처리성공 시 “서비스 준비 완료” 이메일 발송(선택), Tenant 상태 **‘Active’**로 변경, 콜백(§4.5) 수행.

3. 입출력 스펙

3.1 파이프라인 입력(트리거 시 전달)

이름필수타입설명
job_idstringprovision_jobs.job_id (UUID 등). 콜백·로그·trace 연동 시 사용.
tenant_idstring테넌트 ID. canonical_hostname = tenant-{short_id}.pregoi.com (short_id = tenant_id UUID 앞 8자). Frappe site·Ansible·Zuplo에는 canonical 사용. 사용자 노출 URL은 {subdomain_slug}.pregoi.com (CNAME → canonical). tenant-subdomain-dns-design.md 참조.
regionstringsg | us | eu. Pulumi 스택 선택, 노드 조회 시 사용.
target_server_id아니오string기존 노드 배치 시 nodes.node_id. 있으면 Pulumi 단계 스킵, D1에서 해당 노드의 host(IP/FQDN) 조회.
create_new_server아니오boolean/inttrue 또는 1이면 신규 서버 필요. target_server_id 없을 때 Pulumi Up 실행.

전달 경로

  • workflow_dispatch: inputs.job_id, inputs.tenant_id, inputs.region, inputs.target_server_id, inputs.create_new_server.
  • Provision Worker HTTP: JSON body 동일 키.
  • Provision Worker Cron: D1에서 Pending job 1건 조회 시 해당 행의 job_id, tenant_id, region, target_server_id, create_new_server 사용.

3.2 단계별 중간 출력(다음 단계로 전달)

단계출력 이름설명
1. Pulumi(조건부)server_ipPulumi stack output 또는 export. Ansible 인벤토리 ansible_host에 사용.
2. 노드 조회(조건부)server_iptarget_server_id 있을 때 D1 nodes 테이블에서 해당 node_id의 host 값.
3. Ansibleapi_keyprego-ansible 레포에서 playbook 실행 → artifacts/tenant_api_key.txt. 워크플로는 해당 파일을 artifact로 업로드하고, Zuplo Sync 단계에서 Prego의 config/artifacts/에 둔 뒤 전달.
4. Zuplo Sync(없음)성공 시 다음 단계로. 실패 시 콜백에 실패 보고.
5. 콜백(없음)성공 시 job_id·tenant_id·server_id·site_name·api_key_ref(또는 key_id) 등 전달.

3.3 콜백 페이로드(성공 시 Control Plane에 전달할 내용)

타입설명
job_idstring갱신할 provision_jobs 행.
tenant_idstring갱신할 tenants_master·tenant_runtime.
statusstringCompleted.
server_id / node_idstring배치된 노드 ID(nodes.node_id).
hoststringAnsible이 실행된 호스트(IP 또는 FQDN).
site_namestringcanonical_hostname (예: tenant-a1b2c3d4.pregoi.com). Frappe site·라우팅은 canonical만 사용.
api_key_refstring(선택) Zuplo key id 또는 secret_refs용 참조. API Key 원문은 전달하지 않음.

실패 시: status: Failed, error_message(또는 stage where failed) 전달. Control Plane은 provision_jobs.status=Failed, trace_events에 실패 단계 기록.


4. 단계별 설계 요건

4.1 1단계: job 입력 확보

  • 워크플로: workflow_dispatch inputs에서 job_id, tenant_id, region, target_server_id, create_new_server 읽기.
  • Worker(Cron): D1 SELECT * FROM provision_jobs WHERE status = 'Pending' ORDER BY created_at LIMIT 1. 처리 시작 시 해당 행을 status='Running'으로 갱신(선택, 동시 실행 방지).
  • 선행: Control Plane이 이미 provision_jobs에 행을 넣고, 배치 결정 결과(target_server_id 또는 create_new_server)를 입력으로 넘겼다는 가정.

4.2 2단계: 서버 IP 결정 — Pulumi 또는 기존 노드 조회

조건동작
target_server_id 있음Pulumi 실행 없음. D1 nodes에서 node_id = target_server_id인 행의 host를 server_ip로 사용. (nodes가 다른 DB/서비스에 있으면 해당 API 조회.)
target_server_id 없음, create_new_server = truePulumi Up 실행. region에 맞는 스택 선택, pulumi uppulumi stack output server_ip(또는 동일한 출력 이름)를 server_ip로 사용.
둘 다 없음정책에 따라: 기본값으로 create_new_server=true 처리하거나, 에러로 빠져 콜백에 Failed 전달.

Pulumi 실행 환경

  • 워크플로: prego-pulumi checkout, pulumi stack select <region>, pulumi up -y, pulumi stack output server_ip.
  • Worker: Pulumi를 Worker 내에서 실행하지 않으므로, “Pulumi 단계”는 별도 워크플로 호출 또는 외부 서비스 호출로 대체. 호출 시 region·tenant_id(또는 job_id) 전달, 응답으로 server_ip 수신.

신규 서버 생성 후 D1 반영

  • Pulumi로 서버가 생성되면, 해당 server_ip·region을 D1 nodes에 삽입(또는 별도 “노드 등록” API 호출)하는 단계를 설계에 포함. 워크플로에서 “Pulumi up 후 노드 등록 API 호출” 또는 Worker에서 “Pulumi 워크플로 완료 이벤트 수신 후 D1 삽입” 등.

4.3 3단계: Ansible 실행

  • 입력: server_ip, tenant_id.
  • 동작: prego-ansible 레포를 checkout한 뒤 인벤토리를 동적으로 생성(server_ip·tenant_id 치환), ansible-playbook -i inventory/inventory.yml playbook.yml -e tenant_id=... 실행.
  • 출력: prego-ansible의 artifacts/tenant_api_key.txt에 API 키 기록. 워크플로에서는 해당 파일을 artifact로 업로드하고, Zuplo Sync 단계에서 다운로드해 Prego의 config/artifacts/에 둔 뒤 zuplo_sync에 전달.
  • 실행 위치: GitHub Actions면 Self-hosted runner 또는 Ansible이 설치된 컨테이너/호스트. Worker면 Ansible을 실행할 수 없으므로 “Ansible 전용 워크플로” 또는 외부 실행기를 HTTP로 트리거하고 결과(api_key)를 돌려받는 설계.

Playbook 태스크 구성 전략 (한 트랜잭션처럼)
Ansible playbook은 아래 순서로 테넌트 전용 리소스를 만들고, 단계 실패 시 전체를 실패로 처리하는 구조를 권장한다. (코드 없이 단계만 정의.)
① 테넌트 전용 MariaDB DB 생성 → ② 해당 DB 전용 사용자·권한 부여 → ③ Frappe 사이트 디렉터리 생성 및 소유권 설정 → ④ site_config.json 템플릿 배포 → ⑤ bench(또는 동일 도구)로 사이트 초기화·install-app(예: erpnext, hrms).
변수는 파이프라인에서 주입: tenant_id, site_name(또는 canonical_hostname), db_root_password(Vault 등 시크릿).

4.4 4단계: Zuplo Sync

  • 입력: tenant_id, api_key(파일 내용 또는 문자열).
  • 동작: infra/zuplo_sync.tssyncTenantApiKey() 호출 또는 CLI npx tsx infra/zuplo_sync.ts <tenant_id> --api-key-file <path>. ZUPLO_ACCOUNT_NAME, ZUPLO_BUCKET_NAME, ZUPLO_API_KEY는 Secrets/환경 변수에서 주입.
  • 실패 시: 전체 파이프라인 실패로 간주, 콜백에 Failed + 단계 정보 전달. Ansible은 이미 완료되었으므로 재시도 시 Zuplo Sync부터 실행하는 단계별 재시도 설계 권장.

4.5 5단계: Control Plane 콜백

  • 성공 시: 위 §3.3 콜백 페이로드로 Control Plane API POST /internal/provision-complete(가정) 호출. Control Plane은 provision_jobs.status=Completed, tenants_master.status=Active, tenant_runtime 삽입/갱신.
  • 실패 시: POST /internal/provision-failed(가정) 또는 동일 엔드포인트에 status=Failed, job_id, error_message, stage 전달. Control Plane은 provision_jobs.status=Failed, trace_events에 기록.
  • Worker가 D1 직접 갱신하는 경우: 콜백 API 없이 Worker가 D1 바인딩으로 직접 UPDATE provision_jobs, UPDATE tenants_master, INSERT/UPDATE tenant_runtime 수행. 이때 Worker에 D1 쓰기 권한과 SQL(또는 ORM) 구현 필요.

5. GitHub Actions 워크플로 설계 상세

  • 이름: provision-tenant (또는 Provision Tenant).
  • 트리거: workflow_dispatch with inputs: job_id, tenant_id, region, target_server_id(optional), create_new_server(optional).
  • 환경: 리전별로 다른 Secret 사용 시 environment: production-sg 등 선택 입력과 매핑.
  • Job 구성
    • Job 1: 조건 분기 — target_server_id 있으면 “resolve-server” step에서 D1(또는 API) 조회로 server_ip 설정; 없고 create_new_server면 “pulumi-up” step 실행 후 output에서 server_ip 설정.
    • Job 2: Ansible — 인벤토리 생성(또는 기존 파일에 server_ip 치환), playbook 실행, artifact tenant_api_key.txt 업로드.
    • Job 3: Zuplo Sync — artifact 다운로드, npx tsx infra/zuplo_sync.ts ... --api-key-file ... 실행.
    • Job 4: 콜백 — job_id, tenant_id, status=Completed, server_id, host, site_name, api_key_ref 등 페이로드로 Control Plane API 호출.
  • Secrets: HCLOUD_TOKEN_SG/US/EU, ZUPLO_ACCOUNT_NAME, ZUPLO_BUCKET_NAME, ZUPLO_API_KEY, CONTROL_PLANE_CALLBACK_URL, CONTROL_PLANE_API_KEY(또는 내부 전용 토큰), Ansible SSH private key(또는 runner에 이미 배치).
  • 실패 처리: 단계별로 실패 시 즉시 콜백(Failed) 호출 후 job 실패. 재시도는 워크플로 수준이 아닌 “동일 입력으로 workflow_dispatch 재호출”로 처리 가능.

6. Provision Worker 설계 요약

  • 트리거: ① HTTP POST /provision/run (body: job_id, tenant_id, region, target_server_id?, create_new_server?). ② Cron(예: 1분마다)으로 D1 provision_jobs에서 Pending 1건 조회 후 처리.
  • 한계: Worker 내부에서 Pulumi·Ansible 실행이 사실상 불가하므로, “오케스트레이터” 역할만 담당.
    • Pulumi: GitHub Actions workflow_dispatch(pulumi-up 전용) 호출 후 완료 대기(폴링 또는 webhook).
    • Ansible: 동일하게 Ansible 전용 워크플로 또는 외부 실행기 호출 후 api_key 반환 수신.
    • Zuplo: Worker에서 fetch로 Zuplo API 호출.
    • 콜백: Worker가 D1에 직접 UPDATE/INSERT.
  • 데이터 흐름: Worker가 job 입력 확보 → (필요 시) 외부 Pulumi 실행 호출 → server_ip 수신 → Ansible 실행 호출 → api_key 수신 → Zuplo API 호출 → D1 직접 갱신.

7. 보안·멱등·재시도

항목요건
보안API Key·Hetzner 토큰·SSH 키·콜백 API 키는 Secrets/환경 변수로만 주입. 로그·아티팩트에 API Key 원문 출력 금지(no_log, mask).
멱등동일 job_id로 재실행 시: Pulumi는 이미 리소스 존재하면 변경 없음; Ansible은 new-site·api-key가 이미 있으면 creates/조건으로 스킵 또는 에러 처리; Zuplo는 동일 tenant_id로 재등록 시 정책(덮어쓰기/에러) 정의.
재시도실패 시 job은 Failed로 두고, “같은 job_id로 다시 트리거”하면 Pulumi 스킵 후 Ansible(또는 실패한 단계)부터 재실행 가능하도록 단계를 나누어 설계.

7.1 확장성 관리 전략 (온보딩·리소스·로그)

항목전략 요약
DB 부하 분산테넌트 증가 시 단일 DB 서버 한계를 줄이기 위해, 온보딩 파이프라인에 “현재 가장 한가한 DB 서버” 선택 로직을 둔다. server_metrics·tenant_count 기반으로 할당. saas-db-separation-and-scaling-plan.md Phase 2/3와 연동.
리소스 격리특정 테넌트의 무거운 작업이 다른 테넌트에 영향을 주지 않도록, Docker Compose 레벨에서 CPU·Memory 제한(deploy.resources.limits)을 적용한다. Cgroups 기반 격리로 노이즈 감소.
로그 통합테넌트가 여러 노드에 분산되므로, Cloudflare Logpush 또는 ELK Stack 등으로 로그를 한곳에 모아 조회·알람·감사 체계를 갖춘다. 상세: cloudflare-logpush-observability-plan.md.

8. 선행 조건·의존 관계

  • D1 마이그레이션 0007 적용됨(provision_jobs.target_server_id, create_new_server, nodes, server_metrics).
  • Ansible playbook이 artifact에 API 키를 쓰고, zuplo_sync가 --api-key-file 지원함(이미 구현됨).
  • Control Plane(또는 동일 권한)에 “콜백” 수신용 엔드포인트 또는 Worker의 D1 쓰기 권한이 있음.
  • Pulumi 스택(region별) 및 HCLOUD_TOKEN_*·Cloudflare 설정이 준비되어 있음.

9. 검증·체크리스트(구현 후)

  • workflow_dispatch(job_id, tenant_id, region, create_new_server=1) 시 Pulumi Up → Ansible → Zuplo → 콜백까지 성공, provision_jobs=Completed, tenants_master=Active, tenant_runtime 반영.
  • workflow_dispatch(…, target_server_id=기존 node_id) 시 Pulumi 스킵, D1에서 host 조회 후 Ansible만 해당 서버로 실행, Zuplo·콜백 성공.
  • Ansible 또는 Zuplo 단계 실패 시 콜백에 Failed 전달, provision_jobs.status=Failed, trace_events 기록.
  • 동일 job_id로 재실행 시 의도한 대로 멱등 동작(또는 명시적 “재시도” 정책).
  • 로그·아티팩트에 API Key가 노출되지 않음.

10. 참조 문서

문서용도
intelligent-automation-implementation-plan.md구현 순서·산출물 §3.3
saas-unified-architecture-hetzner-cloudflare-zuplo-plan.md§9.2 전 구간 자동화, §9.6 고려사항
tenant-provisioning-flow.md§A.5 워크플로/Worker 동작 순서
provision-tenant-pipeline.md수동 실행 순서·워크플로 입력 스펙
Help