Files
service-catalog/.github/workflows/build-image.yml
T
wbsong111 4f67f63f69 SBOM 파이프라인 문서 정비 + 이미지 목록 열거 제거
## doc/sbom-pipeline.md — 중복·모순 정리 (328 → 286줄)

같은 사실이 여러 절에 흩어져 있었고, 일부는 문서가 아니라 변경 이력이었다.

- `SEVERITY` 를 전 심각도로 덮어써야 하는 이유가 환경변수 절·CI 절·요약 절 3곳에
  있었다. 스크립트 절의 blockquote 하나로 합쳤다 — "게이트를 돌릴 거라면 전 심각도로
  스캔해야 한다"가 핵심이고 나머지는 그 결과다.
- Job Summary 1MB 제한이 CI 절과 결과 확인 절에 중복됐다. CI 절 하나로 합쳤다.
- `--warn-only` 서술이 mermaid 라벨·CI 절·게이트 절·트리아지 절 4곳에 있었다.
  게이트 절 하나로 합치고, `build-image.yml` 쪽은 이미 강제라는 대비를 함께 적었다.
- 자체 빌드 트리거 표가 "PR 은 push 안 함"을 말하는데 바로 아래 불릿이 같은 말을
  반복했다. 표는 그대로 두고 불릿은 **왜** 그런지(REGISTRY 미전달 → localhost 태그라
  push 를 시도할 수조차 없다)만 남겼다.
- "오해를 주던 단일 '총 소요'는 제거" 같은 변경 이력 서술을 걷어냈다. 문서는 현재
  상태를 적는 곳이다.
- "첫 전체 실행 결과(2026-07-08)" 절은 수치를 싣고 바로 아래에서 "현재 수치가
  아니다"로 무효화하는 구조였다. 절 자체를 없애고, 거기서 유일하게 쓸모 있던 사실
  (SBOM 생성 실패는 대부분 사설/미인증 레지스트리이거나 대용량 timeout)만 스크립트
  절로 옮겼다.
- "실행 이력(2026-08-04)" 절은 MEMORY.md 와 중복이라 제거했다. 거기서만 알 수 있던
  사실(Actions 시크릿의 push 권한 확인)은 GitHub 설정 표에 반영했다.
- `CVE_API_KEY` 가 본문에만 있고 GitHub 설정 표에 빠져 있어 추가했다.
- 아키텍처 절 불릿이 mermaid 서브그래프 라벨과 같은 말을 하고 있어, "pull 을 ② 한
  곳에 몰아둔 것이 핵심"이라는 결론 한 문장으로 줄였다.

## 이미지 목록을 문서에 박아두지 않는다

이미지는 계속 추가되므로 열거하면 추가할 때마다 낡는다. `images/` 디렉토리를 단일
출처로 삼고 CLAUDE.md·image-authoring.md·build-image.yml·sbom-pipeline.md 의 열거를
걷어냈다. keycloak README 의 베이스 OS 결정 근거도 "기존 3종" 대신 "먼저 들어온
이미지들"로 바꿨다 — 근거의 내용은 그대로다.

## 현황 서술 정정

- build-image.yml 주석이 "아직 도입된 자체 빌드 이미지가 없다(images/ 가 비어 있음)"
  로 남아 있었다. 이 레포 CI 에서 빌드→검증→게이트→push→카탈로그 브랜치 push 까지
  실제로 검증된 상태다.
- MEMORY.md: cve-exceptions.json 첫 예외 등록, 베이스 OS 정책 확정, PR 자동 생성이
  조직 정책으로 불가하다는 실측(run 30882785612)을 반영했다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-07 14:27:03 +09:00

314 lines
16 KiB
YAML

name: self-build-image
# 자체 빌드 이미지(images/<image>, 실행기 scripts/build/build-hardened-image.sh)의
# 빌드·검증·스캔·게이트·카탈로그 반영을 자동화한다. security-catalog 의 동일 워크플로에서
# 프레임워크를 포팅했고 이 레포 CI 에서 빌드→검증→게이트→push→카탈로그 브랜치 push 까지
# 실제로 검증됐다(2026-08 기준). 대상 이미지 목록은 images/ 디렉토리가 단일 출처다.
# 게이트(scripts/pipeline/cve-gate.py)가 상위 태그·베이스
# OS 교체로 해소되지 않는 차단 CVE 를 찾으면 이 워크플로로 자체 빌드를 검토한다 —
# 절차: .claude/image-authoring.md, 트리거·제약 요약: doc/sbom-pipeline.md 의 "자체 빌드" 절
#
# "이미지가 어느 차트의 어느 필드를 가리키는가" 는 images/<image>/catalog.env 가 선언한다
# (CHART_DIRS·TAG_STYLE·TAG_BLOCK·DEFAULT_BASE_OS). 태그 표기 스타일이 두 가지다:
# imageName 단일 필드 문자열 (`imageName: "repo:tag"`)
# split registry/repository/tag 세 필드로 분리된 블록
# 둘 다 scripts/build/patch-catalog-tag.py 하나로 다룬다(YAML 파서 없이 텍스트 치환만
# 해서 기존 주석·포매팅을 보존한다 — 예상 패턴을 못 찾으면 조용히 넘어가지 않고 실패한다).
#
# 이 워크플로는 helm-catalog-sbom(sbom.yml)과 별도 파일이다. sbom.yml 은
# vars.SBOM_PIPELINE_IMAGE 컨테이너 안에서 도는데 거기엔 docker/buildx 가 없다.
# 빌드는 호스트 러너여야 한다.
#
# 트리거 2종이 같은 스텝(빌드→verify.sh→SBOM→scan-sbom.sh→cve-gate.py)을 돈다.
# 차이는 대상 이미지를 어떻게 정하는지, 그리고 push·카탈로그 브랜치 push 여부뿐이다.
#
# pull_request(images/**) 변경된 images/<image>/ 디렉토리를 diff 로 자동 탐지해 그
# 이미지들만 검증한다(push·카탈로그 브랜치 없음). 여러 이미지가
# 한 PR 에서 바뀌면 각각 매트릭스로 병렬 실행된다.
# workflow_dispatch `image` 입력으로 대상을 명시한다. 실제 빌드·push·카탈로그 태그
# 갱신 브랜치 push 는 이 트리거로만 일어난다(사람이 수동 실행) —
# sbom.yml 의 게이트는 현재 이 워크플로를 자동으로 호출하지 않는다.
# push 입력이 false 면 검증만 한다.
#
# 카탈로그 PR 은 자동 생성하지 않는다 — GitHub Actions 는 GITHUB_TOKEN 으로 PR 을 만들 수
# 없다는 조직 정책("Allow GitHub Actions to create and approve pull requests" 미허용,
# 리포 설정으로 못 바꿈)에 실측으로 막혔다(2026-08-04, run 30882785612, GraphQL:
# "GitHub Actions is not permitted to create or approve pull requests"). 브랜치·커밋·push
# 까지만 워크플로가 하고, PR 오픈은 Job Summary 에 남는 compare 링크로 사람이 직접 연다
# (사람의 gh/브라우저 인증은 이 제약을 받지 않는다).
on:
workflow_dispatch:
inputs:
image:
description: '빌드할 이미지 디렉토리명 (images/<image>/)'
required: true
base_os:
description: '빌드 변종 (images/<image>/<base_os>.build.env). 비우면 catalog.env 의 DEFAULT_BASE_OS 사용'
required: false
default: ''
push:
description: '레지스트리 push + 카탈로그 태그 갱신 브랜치 push 여부'
required: false
default: 'true'
pull_request:
paths:
- 'images/**'
permissions:
contents: write
env:
REGISTRY_HOST: docker.io/paasup
jobs:
# --- 대상 이미지 결정 ---------------------------------------------------------
# workflow_dispatch: inputs.image 하나. pull_request: images/<image>/ 아래 변경이 있는
# 디렉토리를 전부 찾는다(여러 이미지가 한 PR 에서 바뀌면 각각 매트릭스로 돈다).
discover:
runs-on: ubuntu-latest
outputs:
images: ${{ steps.list.outputs.images }}
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: 대상 이미지 목록 산출
id: list
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
img="${{ github.event.inputs.image }}"
[ -n "$img" ] || { echo "::error::image 입력이 비어있다"; exit 1; }
[ -d "images/$img" ] || { echo "::error::이미지 디렉토리 없음: images/$img"; exit 1; }
json="[\"$img\"]"
else
changed="$(git diff --name-only \
"${{ github.event.pull_request.base.sha }}" "${{ github.event.pull_request.head.sha }}" \
-- images/ | awk -F/ 'NF>1 {print $2}' | sort -u)"
if [ -z "$changed" ]; then
json="[]"
else
json="$(printf '%s\n' "$changed" \
| python3 -c 'import json,sys; print(json.dumps([l.strip() for l in sys.stdin if l.strip()]))')"
fi
fi
echo "images=$json" >> "$GITHUB_OUTPUT"
echo "대상 이미지: $json"
build:
needs: discover
if: needs.discover.outputs.images != '[]'
strategy:
fail-fast: false
matrix:
image: ${{ fromJson(needs.discover.outputs.images) }}
runs-on: ubuntu-latest
timeout-minutes: 60
env:
IMAGE_DIR: images/${{ matrix.image }}
steps:
- uses: actions/checkout@v4
- name: Preflight — 도구 확인
run: |
set -e
for t in docker python3 bash; do
command -v "$t" >/dev/null || { echo "::error::러너에 $t 없음"; exit 1; }
done
docker version
- name: trivy 설치
run: |
curl -fsSL https://raw.githubusercontent.com/aquasecurity/trivy/main/contrib/install.sh \
| sh -s -- -b /usr/local/bin
trivy --version | head -1
# "이 이미지가 어느 차트의 어느 필드를 가리키는가" 는 이미지 디렉토리 자신이
# 선언한다(catalog.env) — 이 워크플로가 이미지별 지식을 갖지 않게 하기 위함
# (build.env 계약과 같은 원칙, .claude/image-authoring.md).
- name: 이미지 메타데이터 로드 (catalog.env)
id: meta
run: |
set -euo pipefail
ENV_FILE="$IMAGE_DIR/catalog.env"
[ -f "$ENV_FILE" ] || { echo "::error::catalog.env 없음: $ENV_FILE — images/<image>/catalog.env 를 추가해야 한다"; exit 1; }
# shellcheck disable=SC1090
. "$ENV_FILE"
: "${DEFAULT_BASE_OS:?catalog.env 에 DEFAULT_BASE_OS 가 없다}"
: "${CHART_DIRS:?catalog.env 에 CHART_DIRS 가 없다}"
: "${TAG_STYLE:?catalog.env 에 TAG_STYLE 이 없다}"
BASE_OS="${{ github.event.inputs.base_os }}"
BASE_OS="${BASE_OS:-$DEFAULT_BASE_OS}"
{
echo "base_os=$BASE_OS"
echo "chart_dirs=$CHART_DIRS"
echo "tag_style=$TAG_STYLE"
echo "tag_block=${TAG_BLOCK:-image}"
} >> "$GITHUB_OUTPUT"
echo " image=${{ matrix.image }} base_os=$BASE_OS chart_dirs=$CHART_DIRS tag_style=$TAG_STYLE"
# push 여부를 트리거별로 정한다. build-hardened-image.sh 는 REGISTRY 가 비어 있으면
# push 를 생략하고 TAG 를 localhost/... 로 둔다 — pull_request(검증만)의 안전장치다.
- name: 게시 여부 결정
id: publish
run: |
case "${{ github.event_name }}" in
pull_request) echo "enabled=false" >> "$GITHUB_OUTPUT" ;;
workflow_dispatch)
[ "${{ github.event.inputs.push }}" = "false" ] \
&& echo "enabled=false" >> "$GITHUB_OUTPUT" \
|| echo "enabled=true" >> "$GITHUB_OUTPUT" ;;
*) echo "enabled=true" >> "$GITHUB_OUTPUT" ;;
esac
- name: 레지스트리 로그인
if: steps.publish.outputs.enabled == 'true'
env:
DOCKERHUB_USER: ${{ secrets.DOCKERHUB_USER }}
DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }}
run: |
[ -n "$DOCKERHUB_USER" ] || { echo "::error::DOCKERHUB_USER 시크릿 없음"; exit 1; }
echo "$DOCKERHUB_TOKEN" | docker login docker.io -u "$DOCKERHUB_USER" --password-stdin
- name: 빌드 → 검증 → SBOM → 스캔 → 게이트
id: build
env:
IMAGE: ${{ matrix.image }}
BASE_OS: ${{ steps.meta.outputs.base_os }}
run: |
set -uo pipefail
OUT="$GITHUB_WORKSPACE/build-out"
mkdir -p "$OUT"
# build-hardened-image.sh 의 build.log 는 그 안에서 docker build 출력만 담는
# 별도 파일이다(스크립트 자신의 tag= 안내는 그 파일에 없다). 태그를 뒤에서
# 뽑으려면 스크립트 자신의 표준출력을 따로 남겨야 한다 — tee 로 wrapper.log
# 에도 기록한다.
#
# GitHub Actions 의 run: 스텝은 기본으로 bash -e 다. 빌드 스크립트의 실패를
# $rc 로 정상 캡처하려면 그 호출 동안만 -e 를 끈다 — 아니면 실패 시 여기서
# 바로 중단돼 아래의 rc 기록·output 기록이 실행되지 않는다.
set +e
if [ "${{ steps.publish.outputs.enabled }}" = "true" ]; then
REGISTRY="$REGISTRY_HOST" bash scripts/build/build-hardened-image.sh "$OUT" 2>&1 | tee "$OUT/wrapper.log"
else
bash scripts/build/build-hardened-image.sh "$OUT" 2>&1 | tee "$OUT/wrapper.log" # push 없음 — 검증만
fi
rc="${PIPESTATUS[0]}"
set -e
echo "rc=$rc" >> "$GITHUB_OUTPUT"
# 끝에 `|| true` 를 붙인다 — grep 이 매치를 못 찾아 실패해도(pipefail 이
# 그 실패를 대입식 전체의 실패로 만든다) -e 아래에서 스크립트가 중단되지
# 않게 한다. tag 가 비어도 그만이다 — rc!=0 이면 어차피 이후 단계가 건너뛴다.
tag="$(grep -h '^ tag=' "$OUT/wrapper.log" 2>/dev/null | tail -1 | cut -d= -f2- || true)"
echo "tag=$tag" >> "$GITHUB_OUTPUT"
exit "$rc"
- name: 아티팩트 업로드
if: always()
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.image }}-build
path: |
build-out/build.log
build-out/verify.log
build-out/cve-gate.md
build-out/sbom
if-no-files-found: warn
- name: Job Summary
if: always()
run: |
if [ -f build-out/cve-gate.md ]; then
{ echo "## ${{ matrix.image }}"; cat build-out/cve-gate.md; echo; } >> "$GITHUB_STEP_SUMMARY"
fi
# --- 카탈로그 반영 (push/workflow_dispatch 이고 게이트 PASS 일 때만) --------------
- name: 현재 카탈로그 태그 확인
id: current
if: steps.publish.outputs.enabled == 'true' && steps.build.outputs.rc == '0'
run: |
set -euo pipefail
# CHART_DIRS 가 여러 개일 수 있으나(공백 구분) 첫 번째 디렉토리를 기준값으로
# 삼는다 — 지금까지 이미지 하나가 차트 여러 개에 걸친 사례가 없다.
first_dir="$(echo "${{ steps.meta.outputs.chart_dirs }}" | awk '{print $1}')"
cur="$(python3 scripts/build/patch-catalog-tag.py \
--style "${{ steps.meta.outputs.tag_style }}" \
--block "${{ steps.meta.outputs.tag_block }}" \
--read "$first_dir/custom-values.yaml")"
echo "tag=$cur" >> "$GITHUB_OUTPUT"
echo "현재 카탈로그 태그: $cur"
echo "새 빌드 태그: ${{ steps.build.outputs.tag }}"
# 이 워크플로를 트리거하는 것 자체가 이미 "조치가 필요하다" 는 판단(sbom.yml 의
# 게이트가 차단+수정가능 CVE 를 확인)이거나 사람의 명시적 실행이다. 예전에는
# 블라인드 스케줄 재빌드가 있어서 "정말 개선인지" 를 여기서 재확인해야 했는데,
# 그 트리거를 없앤 뒤로는 불필요해졌다 — 게이트 PASS + 태그 변경만 확인한다.
#
# PR 은 만들지 않는다(위 파일 헤더 참고 — GITHUB_TOKEN 으로 PR 생성이 조직 정책으로
# 막혀 있고 우리 쪽에서 그 정책을 못 바꾼다, 실측: run 30882785612). 브랜치 push 까지만
# 하고 compare 링크를 Job Summary 에 남겨 사람이 직접 PR 을 연다.
- name: 카탈로그 브랜치 push + PR 안내
if: >
steps.publish.outputs.enabled == 'true' && steps.build.outputs.rc == '0' &&
steps.current.outputs.tag != steps.build.outputs.tag
run: |
set -uo pipefail
NEW="${{ steps.build.outputs.tag }}"
OLD="${{ steps.current.outputs.tag }}"
IMAGE="${{ matrix.image }}"
BRANCH="build/${IMAGE}-$(date -u +%Y%m%d%H%M%S)"
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git checkout -b "$BRANCH"
TOUCHED=()
for dir in ${{ steps.meta.outputs.chart_dirs }}; do
candidates=()
[ -f "$dir/custom-values.yaml" ] && candidates+=("$dir/custom-values.yaml")
[ -f "$dir/dip-values.yaml" ] && candidates+=("$dir/dip-values.yaml")
[ "${#candidates[@]}" -eq 0 ] && continue
out="$(python3 scripts/build/patch-catalog-tag.py \
--style "${{ steps.meta.outputs.tag_style }}" \
--block "${{ steps.meta.outputs.tag_block }}" \
--old "$OLD" --new "$NEW" "${candidates[@]}")"
echo "$out"
TOUCHED+=("${candidates[@]}")
done
git diff --stat
git add "${TOUCHED[@]}"
git commit \
-m "${IMAGE} 이미지 태그 갱신: ${OLD##*:} → ${NEW##*:}" \
-m "게이트 PASS(build-image.yml, ${{ github.event_name }} 트리거)로 확인된 태그로 교체한다." \
-m "Co-Authored-By: github-actions[bot] <github-actions[bot]@users.noreply.github.com>"
git push -u origin "$BRANCH"
COMPARE_URL="https://github.com/${{ github.repository }}/compare/main...${BRANCH}?expand=1"
{
echo "## ${IMAGE} 카탈로그 태그 갱신 — 브랜치 push 완료, PR 은 직접 열어야 함"
echo
echo "- 이전 태그: \`$OLD\`"
echo "- 새 태그: \`$NEW\`"
echo "- 브랜치: \`$BRANCH\`"
echo "- 게이트: PASS (아티팩트의 \`cve-gate.md\` 참고)"
echo
echo "**PR 은 자동 생성되지 않는다** — GitHub 조직 정책상 \`GITHUB_TOKEN\`(Actions 자체 토큰)으로는"
echo "PR 을 만들 수 없다. 아래 링크에서 사람이 직접 연다:"
echo
echo "$COMPARE_URL"
echo
echo "PR을 연 뒤 병합 전에 아래를 수동으로 실행해 카탈로그 게이트를 확인한다."
echo
echo '```sh'
echo "gh workflow run helm-catalog-sbom --ref $BRANCH"
echo '```'
echo
echo "이미지가 실제로 동작하는지도 병합 전에 수동으로 확인한다(해당 차트 배포 후"
echo "기능 점검 — 자동화된 배포 테스트 절차는 아직 없다)."
} >> "$GITHUB_STEP_SUMMARY"
echo "브랜치 push 완료 — PR은 사람이 직접 열어야 한다: $COMPARE_URL"