CI/CD 十關

LV.10/10

實機部署到 k8s

用正式網站同一套流水線,把課程網站建成映像、簽章,經 GitOps 由 Argo CD 部署到 k8s,Envoy 對外提供 HTTPS

實機約 45 分鐘Harbor、cosign、Kyverno、GitOps、Argo CD、Envoy Gateway

學習目標

  • 看懂正式環境的完整流程:掃描 → 建映像 → 推 Harbor → 映像掃描與 SBOM → cosign 簽章 → 更新 GitOps repo → Argo CD 同步。
  • 知道為什麼 CI 不直接 kubectl apply,而是改 Git 讓 Argo CD 部署。
  • 會用 include: project 共用別的 repo 的流水線,並覆寫其中一個 job。
  • 會驗證部署:Pod、HTTPRoute、Argo CD、curl --resolve。

這一關部署的就是課程網站本身:https://cicd-tutorial.yc-group.org。

架構

push main(受保護分支)
  │
  ├─ k8s-scan(k8s-ci runner,沒有任何密鑰)
  │    gitleaks 機密掃描、Trivy 相依套件、Semgrep 程式寫法
  ├─ site-check:把網站產生一次並檢查
  │
  ├─ k8s-build(k8s-release runner)  BuildKit 無 root 建置 → harbor/sites/cicd-tutorial:<commit>
  ├─ k8s-image-scan                  Trivy 掃映像(有修補版的 CRITICAL 就擋)+ SBOM
  ├─ k8s-sign                        cosign 簽章,傳統 .sig 標籤格式
  └─ k8s-deploy                      改 homelab-k8s 的 apps/cicd-tutorial/values.yaml → push
                                           │
                         Argo CD(約 3 分鐘內偵測)同步 site-cicd-tutorial
                                           │
                 Kyverno 驗章 → Pod 啟動 → HTTPRoute 接上共用 Gateway → Envoy 對外 443
元件角色
k8s-ci runner跑掃描。分支、MR 也會跑,所以這裡沒有任何密鑰
k8s-release runner只接受保護分支的 job,Harbor 帳密、cosign 私鑰、GitOps 推送金鑰只掛在這裡
Harbor私有映像倉庫,課程網站在 sites/cicd-tutorial
cosign+Kyverno簽章與准入驗章,沒簽章的映像進不了 site-* 命名空間
Argo CD以 homelab-k8s repo 為準,自動同步、自我修復
Envoy Gateway共用 Gateway envoy-gateway-system/homelab,每站一條獨立的 HTTPRoute
cert-manager用 Let's Encrypt HTTP-01 簽發與續期憑證

為什麼走 GitOps

  • Git 就是正式環境的樣子:要知道線上跑哪一版,看 values.yaml 就好。
  • **回復就是 git revert**:Argo CD 會把叢集改回去,不需要登入主機。
  • CI 不需要叢集寫入權限:CI 只能讀部署結果(等 rollout),改版一律經過 Git。
  • 手動改的會被拉回來:Argo CD 開了 selfHeal,在叢集上手改的設定會被還原。

本關的檔案

檔案用途
.gitlab-ci.ymlinclude homelab-k8s 的共用流水線,設定 SITE、IMAGE、DOCKERFILE
Dockerfile兩階段:Python 產生網頁 → nginx-unprivileged 只放靜態檔
nginx/default.conf容器內的 nginx,只聽 8080、只送靜態檔(反向代理是 Envoy 的工作)
nginx/security-headers.confCSP、nosniff 等安全標頭,每個 location 都 include
site/build.py把每一關的 README 轉成網頁,只用標準函式庫
site/check_site.py檢查網頁與品牌資產是否齊全、沒有會被 CSP 擋掉的 inline 腳本

範例解說:共用流水線

yaml
include:
  - project: fjw08199999/homelab-k8s
    ref: main
    file: ci/site.gitlab-ci.yml

variables:
  SITE: cicd-tutorial
  IMAGE: cicd-tutorial
  DOCKERFILE: lv10-k8s-deploy/Dockerfile
  BUILD_CONTEXT: .

k8s-build:
  needs: [site-check, k8s-secret-scan, k8s-dependency-scan, k8s-sast]
  • include: project 拿別的 repo 的檔案,所有網站共用同一份流水線,改一次全部生效。
  • 在自己的檔案再寫一次 k8s-build,GitLab 會把兩份合併,只覆寫 needs。這裡讓建置只等網站檢查與三項掃描,不用等 Lv.1~Lv.9 的教材子 pipeline。
  • 映像標籤是 $CI_COMMIT_SHORT_SHA,不用 latest:每一版都能追溯,回復就是換回舊標籤。

homelab-k8s 這邊要做的事(一次性)

  1. apps/cicd-tutorial/values.yaml:映像、資源限制、/tmp 暫存、網域、開 route.enabled、maxBodySize: 1Mi(純靜態站)、Harbor 拉映像的密鑰(OpenBao sites/cicd-tutorial/pull)。
  2. argocd/charts/envoy-gateway/values-homelab.yaml:hosts 加 cicd-tutorial.yc-group.org(產生 HTTPS listener 與憑證),allowedNamespaces 加 site-cicd-tutorial。
  3. 在主機跑 ansible-playbook playbooks/openbao-sites.yml -e sites=cicd-tutorial,建立 OpenBao 的站台角色與拉映像密鑰。
  4. 專案啟用 k8s-ci、k8s-release 兩個 runner,main 必須是受保護分支。
  5. DNS:cicd-tutorial.yc-group.org 的 A 紀錄指到主機的公網 IP,cert-manager 才能用 HTTP-01 簽憑證。**第 2 步的 hosts 一定要等 DNS 生效後才加**:沒有 DNS 時憑證簽不下來,listener 找不到 Secret,整個共用 Gateway 會變成 Degraded,Argo CD 同步一直重試。本課程第一次上線就踩到,先拿掉網域,DNS 生效後(2026-11-09 17:37)再加回,憑證約 20 秒簽好。

ApplicationSet 會為 apps/ 底下每個資料夾自動產生一個 Argo CD 應用(site-cicd-tutorial),namespace 強制 Pod Security restricted,預設拒絕所有網路,只放行 Envoy 進來。

驗證

sh
# Pod 與映像
kubectl -n site-cicd-tutorial get pods -o wide
kubectl -n site-cicd-tutorial get deploy cicd-tutorial-web -o jsonpath='{.spec.template.spec.containers[0].image}'
# 路由:Accepted、ResolvedRefs 都要是 True
kubectl -n site-cicd-tutorial get httproute cicd-tutorial -o jsonpath='{.status.parents[0].conditions}'
# Argo CD
kubectl -n argocd get application site-cicd-tutorial -o jsonpath='{.status.sync.status} {.status.health.status}'
# 不靠 DNS,直接打主機
curl --resolve cicd-tutorial.yc-group.org:443:10.10.10.50 -I https://cicd-tutorial.yc-group.org/

回復

sh
# 在 homelab-k8s 找到 CI 的部署 commit(訊息是「cicd-tutorial 部署 <版本>」)
git revert <那個 commit>
git push

Argo CD 約 3 分鐘內把舊版換回來。舊映像在 Harbor 上都有簽章,Kyverno 會放行。

常見錯誤

  • **Pod 起不來,事件寫 no signatures found**:映像沒簽章,或用了新的 bundle 格式。共用流水線用 --new-bundle-format=false 產生傳統 .sig。
  • **HTTPRoute Accepted=False、NotAllowedByListeners**:namespace 沒加進 allowedNamespaces。
  • 憑證一直不 Ready:DNS 還沒指到主機,Let's Encrypt 連不到 HTTP-01 驗證網址。
  • **HTTPRoute Accepted=False、NoMatchingParent**:網域還沒加進 Gateway 的 hosts,沒有對應的 listener。DNS 建好前這是預期狀態。
  • **Pod 第一次 ErrImagePull**:namespace 剛建立時,拉映像的 Secret 由 External Secrets 同步,比 Pod 晚幾秒出現;kubelet 重試後就會成功。
  • 15 秒就 504:路由沒設 timeout。共用 chart 預設已經寫好,不要拿掉。
  • 用了 Docker Hub 映像直接部署:Kyverno 會拒絕,映像一律從 Harbor 拉。

結業

恭喜完成十關!回到課程地圖,把卡片拖進「已完成」吧。

範例檔案

資料夾 lv10-k8s-deploy/ 的全部檔案。.gitlab-ci.yml 可以直接複製到自己的專案根目錄執行。

lv10-k8s-deploy/.gitlab-ci.yml
lv10-k8s-deploy/.gitlab-ci.yml
# Lv.10 實機部署:建置映像 → 推上 Harbor → 掃描與 cosign 簽章 → 更新 GitOps repo → Argo CD 上線
# 這個檔案由根目錄的 .gitlab-ci.yml include,部署的是課程網站 https://cicd-tutorial.yc-group.org
# 共用流水線在 homelab-k8s 的 ci/site.gitlab-ci.yml,每個網站都用同一份,說明見 homelab-k8s 的 docs/CICD.md

include:
  - project: fjw08199999/homelab-k8s
    ref: main
    file: ci/site.gitlab-ci.yml

variables:
  SITE: cicd-tutorial                      # homelab-k8s 的 apps/cicd-tutorial/values.yaml、namespace site-cicd-tutorial
  IMAGE: cicd-tutorial                     # Harbor 的 sites/cicd-tutorial
  DOCKERFILE: lv10-k8s-deploy/Dockerfile   # 映像要包含每一關的教材,所以 context 是根目錄
  BUILD_CONTEXT: .

# 先在 CI 裡把網站建一次並檢查,壞掉就不會建映像
site-check:
  stage: site-check
  image: python:3.13-alpine
  tags: [k8s-ci]
  needs: []
  script:
    - python3 lv10-k8s-deploy/site/build.py --out /tmp/site
    - python3 lv10-k8s-deploy/site/check_site.py /tmp/site

# 共用流水線的 k8s-build 原本要等前面所有階段;改成只等網站檢查與三項掃描,教材子 pipeline 失敗不會擋住網站上線
k8s-build:
  needs: [site-check, k8s-secret-scan, k8s-dependency-scan, k8s-sast]
lv10-k8s-deploy/Dockerfile
lv10-k8s-deploy/Dockerfile
# Lv.10 課程網站映像:第一階段用 Python 把各關 README 轉成網頁,第二階段只留 nginx 與靜態檔
FROM python:3.13-alpine AS build
ARG REVISION=local
WORKDIR /src
COPY . .
RUN REVISION="$REVISION" python3 lv10-k8s-deploy/site/build.py --out /out \
 && python3 lv10-k8s-deploy/site/check_site.py /out

# 執行階段:非 root 的 nginx,只提供靜態檔(反向代理由 Envoy Gateway 負責)
FROM nginxinc/nginx-unprivileged:1.31.7-alpine3.24-slim
COPY lv10-k8s-deploy/nginx/default.conf /etc/nginx/conf.d/default.conf
COPY lv10-k8s-deploy/nginx/security-headers.conf /etc/nginx/snippets/security-headers.conf
COPY --from=build /out /usr/share/nginx/html
USER 101
lv10-k8s-deploy/nginx/default.conf
lv10-k8s-deploy/nginx/default.conf
# 課程網站:純靜態。TLS、HSTS、壓縮由 Envoy Gateway 處理,這裡只聽 8080
absolute_redirect off;

server {
    listen 8080;
    server_tokens off;
    root /usr/share/nginx/html;
    index index.html;
    charset utf-8;
    error_page 404 /404.html;

    location = /404.html { internal; add_header Cache-Control no-cache always; include /etc/nginx/snippets/security-headers.conf; }
    location = /healthz { access_log off; default_type text/plain; return 200 "ok\n"; }
    location = /robots.txt { default_type text/plain; add_header Cache-Control "public, max-age=3600" always; include /etc/nginx/snippets/security-headers.conf; }
    location = /sitemap.xml { default_type application/xml; add_header Cache-Control "public, max-age=3600" always; include /etc/nginx/snippets/security-headers.conf; }
    # readme.html 是獨立文件,樣式與腳本寫在檔案裡,只有這一頁放寬 inline
    location = /readme.html {
        add_header Cache-Control no-cache always;
        add_header X-Content-Type-Options nosniff always;
        add_header Referrer-Policy strict-origin-when-cross-origin always;
        add_header X-Frame-Options SAMEORIGIN always;
        add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'self' https://*.yc-group.org" always;
    }
    location /assets/ { try_files $uri =404; add_header Cache-Control "public, max-age=604800" always; include /etc/nginx/snippets/security-headers.conf; }
    location / { try_files $uri $uri/ =404; add_header Cache-Control no-cache always; include /etc/nginx/snippets/security-headers.conf; }
}
lv10-k8s-deploy/nginx/security-headers.conf
lv10-k8s-deploy/nginx/security-headers.conf
# 每個 location 都要重新 include:nginx 的 add_header 只要 location 裡有寫,就不會繼承 server 層的設定
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header X-Frame-Options SAMEORIGIN always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; connect-src 'self'; base-uri 'self'; form-action 'none'; frame-ancestors 'self' https://*.yc-group.org" always;
lv10-k8s-deploy/site/build.py
lv10-k8s-deploy/site/build.py
"""課程網站產生器:把每一關的 README.md 與範例檔轉成靜態網頁(只用 Python 標準函式庫)。

用法:python3 lv10-k8s-deploy/site/build.py --out <輸出目錄>
映像建置(Dockerfile 第一階段)與 CI 的 site-check 都跑這支程式,確保兩邊產出相同。
"""
import argparse
import html
import json
import os
from pathlib import Path
import re
import shutil

ROOT = Path(__file__).resolve().parents[2]
STATIC = Path(__file__).resolve().parent / 'static'
SITE_URL = 'https://cicd-tutorial.yc-group.org'
SITE_NAME = 'GitLab CI/CD 十關'
SITE_DESC = '從第一條 pipeline 到 k8s 實機部署:stages、artifacts、cache、variables、rules、needs、services、environments,最後經 Harbor、cosign、Argo CD 上線。'
TYPE_CLASS = {'基礎': 'basic', '進階': 'advanced', '實機': 'live'}
# 範例檔:依序顯示,README 已經另外轉成網頁
SHOW_SUFFIX = {'.yml', '.yaml', '.sh', '.py', '.sql', '.txt', '.md', '.conf'}
SHOW_NAMES = {'Dockerfile'}


def esc(text):
    return html.escape(text, quote=True)


# ---------- Markdown(只支援本課程用到的語法) ----------

def inline(text):
    parts = re.split(r'(`[^`]+`)', text)
    out = []
    for part in parts:
        if part.startswith('`') and part.endswith('`') and len(part) > 1:
            out.append(f'<code>{esc(part[1:-1])}</code>')
            continue
        s = esc(part)
        s = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', s)
        # 自動連結 <https://...>(跳脫後是 &lt;…&gt;)
        s = re.sub(r'&lt;(https://[^\s&]+)&gt;', r'<a href="\1" rel="noopener" target="_blank">\1</a>', s)

        def link(m):
            label, url = m.group(1), html.unescape(m.group(2))
            if not re.match(r'^(https?://|/|#|\.\.?/)', url):
                return label
            ext = ' rel="noopener" target="_blank"' if url.startswith('http') else ''
            return f'<a href="{esc(url)}"{ext}>{label}</a>'
        s = re.sub(r'\[([^\]]+)\]\(([^)\s]+)\)', link, s)
        out.append(s)
    return ''.join(out)


def slug(text, used):
    base = re.sub(r'[^\w一-鿿-]+', '-', text.lower()).strip('-') or 'section'
    name, n = base, 2
    while name in used:
        name, n = f'{base}-{n}', n + 1
    used.add(name)
    return name


def highlight(code, lang):
    """很簡單的上色:只標註註解,不改變內容。"""
    lines = []
    for line in code.split('\n'):
        mark = None
        if lang in ('yaml', 'yml', 'sh', 'shell', 'python', 'py', 'conf', 'dockerfile', 'nginx'):
            quote = None
            for i, ch in enumerate(line):
                if ch in '"\'' and quote is None:
                    quote = ch
                elif ch == quote:
                    quote = None
                elif ch == '#' and quote is None and (i == 0 or line[i - 1] in ' \t'):
                    mark = i
                    break
        elif lang == 'sql' and '--' in line:
            mark = line.index('--')
        if mark is None:
            lines.append(esc(line))
        else:
            lines.append(esc(line[:mark]) + '<span class="c">' + esc(line[mark:]) + '</span>')
    return '\n'.join(lines)


def code_block(code, lang='', title='', copy=True):
    head = ''
    if title or copy:
        btn = '<button type="button" class="copy" data-copy>複製</button>' if copy else ''
        head = f'<div class="code-head"><span>{esc(title or lang)}</span>{btn}</div>'
    return f'<figure class="code">{head}<pre><code class="lang-{esc(lang)}">{highlight(code, lang)}</code></pre></figure>'


def markdown(text):
    text = re.sub(r'<!--.*?-->', '', text, flags=re.S)
    lines = text.split('\n')
    out, used, toc = [], set(), []
    i = 0
    block_start = re.compile(r'^(```|#{1,6} |\||[-*] |\d+\. |>|---\s*$)')
    while i < len(lines):
        line = lines[i]
        if line.startswith('```'):
            lang = line[3:].strip()
            body = []
            i += 1
            while i < len(lines) and not lines[i].startswith('```'):
                body.append(lines[i])
                i += 1
            i += 1
            out.append(code_block('\n'.join(body), lang, copy=lang not in ('text', '')))
            continue
        m = re.match(r'^(#{1,6}) (.*)', line)
        if m:
            level, title = len(m.group(1)), m.group(2).strip()
            if level == 1:
                i += 1
                continue  # 標題放在頁首
            anchor = slug(title, used)
            if level == 2:
                toc.append((anchor, title))
            out.append(f'<h{level} id="{anchor}">{inline(title)}</h{level}>')
            i += 1
            continue
        if line.startswith('|'):
            rows = []
            while i < len(lines) and lines[i].startswith('|'):
                rows.append([c.strip() for c in lines[i].strip().strip('|').split('|')])
                i += 1
            head, body = rows[0], [r for r in rows[2:]]
            th = ''.join(f'<th>{inline(c)}</th>' for c in head)
            trs = ''.join('<tr>' + ''.join(f'<td>{inline(c)}</td>' for c in r) + '</tr>' for r in body)
            out.append(f'<div class="table"><table><thead><tr>{th}</tr></thead><tbody>{trs}</tbody></table></div>')
            continue
        m = re.match(r'^([-*]|\d+\.) ', line)
        if m:
            tag = 'ol' if m.group(1)[0].isdigit() else 'ul'
            items = []
            while i < len(lines) and re.match(r'^([-*]|\d+\.) ', lines[i]):
                item = re.sub(r'^([-*]|\d+\.) ', '', lines[i])
                i += 1
                while i < len(lines) and lines[i].startswith('  ') and lines[i].strip():
                    item += ' ' + lines[i].strip()
                    i += 1
                items.append(f'<li>{inline(item)}</li>')
            out.append(f'<{tag}>{"".join(items)}</{tag}>')
            continue
        if line.startswith('>'):
            quote = []
            while i < len(lines) and lines[i].startswith('>'):
                quote.append(lines[i].lstrip('> ').rstrip())
                i += 1
            out.append(f'<blockquote><p>{inline(" ".join(quote))}</p></blockquote>')
            continue
        if re.match(r'^---\s*$', line):
            out.append('<hr>')
            i += 1
            continue
        if not line.strip():
            i += 1
            continue
        para = [line.strip()]
        i += 1
        while i < len(lines) and lines[i].strip() and not block_start.match(lines[i]):
            para.append(lines[i].strip())
            i += 1
        out.append(f'<p>{inline(" ".join(para))}</p>')
    return '\n'.join(out), toc


# ---------- 讀取關卡 ----------

def load_lessons():
    lessons = []
    for folder in sorted(ROOT.glob('lv[0-9][0-9]-*')):
        readme = (folder / 'README.md').read_text(encoding='utf-8')
        meta = {}
        m = re.search(r'<!--\s*meta:(.*?)-->', readme, re.S)
        if m:
            for pair in m.group(1).split(';'):
                if '=' in pair:
                    key, value = pair.split('=', 1)
                    meta[key.strip()] = value.strip()
        title = re.search(r'^# (.+)$', readme, re.M).group(1).strip()
        number = int(folder.name[2:4])
        files = sorted(
            [p for p in folder.rglob('*') if p.is_file() and p.name != 'README.md' and ' 2.' not in p.name
             and 'static' not in p.parts and (p.suffix in SHOW_SUFFIX or p.name in SHOW_NAMES)],
            key=lambda p: (p.name != '.gitlab-ci.yml', str(p)))
        lessons.append({
            'id': f'lv{number:02d}', 'number': number, 'folder': folder.name, 'readme': readme,
            'title': re.sub(r'^Lv\.\d+\s*', '', title), 'full_title': title,
            'type': meta.get('type', '基礎'), 'minutes': meta.get('minutes', ''),
            'keywords': meta.get('keywords', ''), 'summary': meta.get('summary', ''), 'files': files,
        })
    return lessons


# ---------- 版面 ----------

def page(title, desc, body, path, rev, active=''):
    url = SITE_URL + path
    full = title if title == SITE_NAME else f'{title}|{SITE_NAME}'
    nav = [('/', '課程', 'home'), ('/#flow', '部署流程', 'flow'), ('/readme.html', '說明文件', 'readme')]
    current = ' aria-current="page"'
    links = ''.join(f'<a href="{h}"{current if k == active else ""}>{t}</a>' for h, t, k in nav)
    v = esc(rev)
    return f'''<!doctype html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{esc(full)}</title>
<meta name="description" content="{esc(desc)}">
<link rel="canonical" href="{url}">
<meta name="theme-color" content="#F2EEE4" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#070918" media="(prefers-color-scheme: dark)">
<link rel="icon" href="/assets/favicon.svg?v={v}" type="image/svg+xml">
<link rel="icon" href="/assets/favicon-32.png?v={v}" sizes="32x32" type="image/png">
<link rel="apple-touch-icon" href="/assets/apple-touch-icon.png?v={v}">
<link rel="manifest" href="/manifest.webmanifest">
<meta property="og:type" content="website">
<meta property="og:site_name" content="{SITE_NAME}">
<meta property="og:locale" content="zh_TW">
<meta property="og:title" content="{esc(full)}">
<meta property="og:description" content="{esc(desc)}">
<meta property="og:url" content="{url}">
<meta property="og:image" content="{SITE_URL}/assets/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:alt" content="GitLab CI/CD 十關:從第一條 pipeline 到 k8s 實機部署">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="{esc(full)}">
<meta name="twitter:description" content="{esc(desc)}">
<meta name="twitter:image" content="{SITE_URL}/assets/og-image.png">
<script src="/assets/theme-init.js?v={v}"></script>
<link rel="stylesheet" href="/assets/site.css?v={v}">
<script src="/assets/site.js?v={v}" defer></script>
</head>
<body>
<a class="skip" href="#main">跳到主要內容</a>
<header class="topbar">
  <div class="wrap topbar-in">
    <a class="brand" href="/"><img src="/assets/favicon.svg?v={v}" alt="" width="28" height="28"><span>CI/CD 十關</span></a>
    <nav class="nav" aria-label="主要選單">{links}</nav>
    <button type="button" class="theme-toggle" data-theme-toggle aria-label="切換深淺色">
      <svg class="i-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></svg>
      <svg class="i-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.5A8 8 0 0 1 9.5 4a8 8 0 1 0 10.5 10.5z"/></svg>
      <span class="theme-label">深色</span>
    </button>
  </div>
</header>
<main id="main">
{body}
</main>
<footer class="footer">
  <div class="wrap footer-in">
    <p>GitLab CI/CD 十關|部署在 homelab k8s(Envoy Gateway、Harbor、Argo CD)</p>
    <p class="muted">版本 <code>{esc(rev[:8])}</code>|字型 Sora、Inter 採 <a href="/assets/licenses/OFL-Sora.txt">SIL OFL</a> 授權</p>
  </div>
</footer>
<div class="toast" role="status" aria-live="polite" data-toast hidden></div>
</body>
</html>
'''


def card(lesson):
    t = lesson['type']
    return f'''<article class="card" tabindex="0" data-card="{lesson['id']}" data-type="{esc(t)}" aria-describedby="drag-help">
  <div class="card-top"><span class="lv">Lv.{lesson['number']}</span><span class="tag tag-{TYPE_CLASS.get(t, 'basic')}">{esc(t)}</span><span class="mins">約 {esc(lesson['minutes'])} 分鐘</span></div>
  <h3 class="card-title">{esc(lesson['title'])}</h3>
  <p class="card-summary">{esc(lesson['summary'])}</p>
  <p class="card-keys"><span>關鍵字</span>{esc(lesson['keywords'])}</p>
  <div class="card-actions">
    <a class="btn btn-primary btn-sm" href="/{lesson['id']}/">開啟</a>
    <label class="move"><span>移到群組</span><select data-move aria-label="把 Lv.{lesson['number']} 移到群組"></select></label>
  </div>
</article>'''


FLOW = [
    ('push main', '受保護分支才會建映像與部署;分支與 MR 只跑掃描'),
    ('掃描', 'gitleaks 機密、Trivy 相依套件、Semgrep 程式寫法'),
    ('建置映像', 'BuildKit 無 root 建置,推上 Harbor sites/cicd-tutorial'),
    ('映像檢查', 'Trivy 掃描映像並產生 SBOM'),
    ('cosign 簽章', '傳統 .sig 標籤格式,Kyverno 只認得這種'),
    ('更新 GitOps', 'CI 把新標籤寫進 homelab-k8s 的 apps/cicd-tutorial/values.yaml'),
    ('Argo CD 同步', '約 3 分鐘內偵測到變更,滾動更新 site-cicd-tutorial'),
    ('Envoy 上線', 'HTTPRoute 接到共用 Gateway,cert-manager 簽發 HTTPS 憑證'),
]

STACK = [
    ('GitLab CI/CD', 'gitlab.com 共享 runner 跑 Lv.1~Lv.9;homelab 的 k8s-ci、k8s-release runner 跑 Lv.10'),
    ('Harbor', '私有映像倉庫,映像放 sites/ 專案'),
    ('cosign+Kyverno', '簽章與准入驗章,未簽章的映像進不了 site-* 命名空間'),
    ('Argo CD', 'GitOps:以 homelab-k8s repo 為準,自動同步與自我修復'),
    ('Envoy Gateway', '對外入口與反向代理,每站獨立 HTTPRoute'),
    ('cert-manager', "Let's Encrypt HTTP-01 簽發與自動續期"),
    ('nginx-unprivileged', '容器內提供靜態檔,非 root、唯讀根目錄'),
    ('Python 標準函式庫', '把各關 README 轉成網頁,不需要任何第三方套件'),
]


def build_index(lessons, rev):
    cards = '\n'.join(card(x) for x in lessons)
    flow = ''.join(f'<li><span class="step-n">{n}</span><div><strong>{esc(t)}</strong><p>{esc(d)}</p></div></li>'
                   for n, (t, d) in enumerate(FLOW, 1))
    stack = ''.join(f'<tr><th scope="row">{esc(a)}</th><td>{esc(b)}</td></tr>' for a, b in STACK)
    counts = {t: sum(1 for x in lessons if x['type'] == t) for t in TYPE_CLASS}
    chips = ''.join(f'<button type="button" class="chip" data-filter="{t}" aria-pressed="false">{t}<span>{counts[t]}</span></button>'
                    for t in TYPE_CLASS)
    body = f'''<section class="hero">
  <div class="wrap">
    <p class="eyebrow">GITLAB CI/CD 教學</p>
    <h1>從第一條 pipeline,<br>一路做到 k8s 實機部署</h1>
    <p class="lede">十關由淺入深。Lv.1~Lv.9 每關一個可以直接執行的 <code>.gitlab-ci.yml</code>,都在 gitlab.com 共享 runner 實跑通過;Lv.10 用和正式網站相同的流水線,把這個網站建成映像、簽章,經 GitOps 部署到 k8s。</p>
    <div class="hero-actions"><a class="btn btn-primary" href="/lv01/">從 Lv.1 開始</a><a class="btn" href="#flow">看部署流程</a></div>
  </div>
</section>
<section class="wrap section" aria-labelledby="map-title">
  <div class="section-head">
    <div><p class="eyebrow">課程地圖</p><h2 id="map-title">十關課程</h2></div>
    <p class="muted" id="drag-help">按住卡片拖拉可以排序或拖進群組;手機長按約半秒再拖。鍵盤:在卡片上按空白鍵拿起,方向鍵移動,再按空白鍵放下,Esc 取消;Enter 開啟。排法存在這台裝置的瀏覽器。</p>
  </div>
  <div class="toolbar">
    <div class="chips" role="group" aria-label="依類型篩選"><button type="button" class="chip" data-filter="" aria-pressed="true">全部<span>{len(lessons)}</span></button>{chips}</div>
    <div class="toolbar-actions"><button type="button" class="btn btn-sm" data-add-group>+ 新增群組</button><button type="button" class="btn btn-sm btn-ghost" data-reset>恢復預設</button></div>
  </div>
  <div class="board" data-board>
    <section class="group" data-group="ungrouped">
      <div class="group-head"><h3 class="group-name">未分類</h3><span class="count">{len(lessons)}</span></div>
      <div class="card-list" data-list="ungrouped">
{cards}
      </div>
    </section>
  </div>
  <p class="sr-only" aria-live="assertive" data-live></p>
</section>
<section class="wrap section" id="flow" aria-labelledby="flow-title">
  <div class="section-head"><div><p class="eyebrow">Lv.10 實機部署</p><h2 id="flow-title">push 之後發生什麼事</h2></div>
  <p class="muted">這個網站本身就是 Lv.10 的成果。詳細步驟與 YAML 在 <a href="/lv10/">Lv.10</a>。</p></div>
  <ol class="flow">{flow}</ol>
</section>
<section class="wrap section" aria-labelledby="stack-title">
  <div class="section-head"><div><p class="eyebrow">技術棧</p><h2 id="stack-title">用到的工具</h2></div></div>
  <div class="table"><table class="stack"><tbody>{stack}</tbody></table></div>
</section>'''
    return page(SITE_NAME, SITE_DESC, body, '/', rev, 'home')


def build_lesson(lesson, prev, nxt, rev):
    content, toc = markdown(lesson['readme'])
    samples = []
    for f in lesson['files']:
        rel = f.relative_to(ROOT).as_posix()
        lang = {'.yml': 'yaml', '.yaml': 'yaml', '.sh': 'sh', '.py': 'python', '.sql': 'sql', '.conf': 'nginx',
                '.md': 'text', '.txt': 'text'}.get(f.suffix, 'dockerfile' if f.name == 'Dockerfile' else 'text')
        code = f.read_text(encoding='utf-8').rstrip('\n')
        opened = ' open' if f.name == '.gitlab-ci.yml' else ''
        samples.append(f'<details class="sample"{opened}><summary><code>{esc(rel)}</code></summary>{code_block(code, lang, rel)}</details>')
    if lesson['number'] == 10:
        root_ci = (ROOT / '.gitlab-ci.yml').read_text(encoding='utf-8').rstrip('\n')
        samples.append(f'<details class="sample"><summary><code>.gitlab-ci.yml</code>(根目錄總控)</summary>{code_block(root_ci, "yaml", ".gitlab-ci.yml")}</details>')
    toc_html = ''.join(f'<li><a href="#{a}">{esc(t)}</a></li>' for a, t in toc)
    pager = '<nav class="pager" aria-label="上一關與下一關">'
    pager += f'<a href="/{prev["id"]}/" rel="prev"><span>上一關</span>{esc(prev["full_title"])}</a>' if prev else '<span></span>'
    pager += f'<a href="/{nxt["id"]}/" rel="next" class="next"><span>下一關</span>{esc(nxt["full_title"])}</a>' if nxt else '<a href="/" class="next"><span>完成</span>回到課程地圖</a>'
    pager += '</nav>'
    t = lesson['type']
    body = f'''<div class="wrap lesson">
  <nav class="crumbs" aria-label="路徑"><a href="/">課程</a><span aria-hidden="true">/</span><span>Lv.{lesson['number']}</span></nav>
  <header class="lesson-head">
    <p class="eyebrow">LV.{lesson['number']}/10</p>
    <h1>{esc(lesson['title'])}</h1>
    <p class="lede">{esc(lesson['summary'])}</p>
    <p class="meta"><span class="tag tag-{TYPE_CLASS.get(t, 'basic')}">{esc(t)}</span><span>約 {esc(lesson['minutes'])} 分鐘</span><span>{esc(lesson['keywords'])}</span></p>
  </header>
  <div class="lesson-grid">
    <aside class="toc" aria-label="本頁目錄"><p class="eyebrow">本頁目錄</p><ol>{toc_html}<li><a href="#samples">範例檔案</a></li></ol></aside>
    <article class="prose">
{content}
      <h2 id="samples">範例檔案</h2>
      <p>資料夾 <code>{esc(lesson['folder'])}/</code> 的全部檔案。<code>.gitlab-ci.yml</code> 可以直接複製到自己的專案根目錄執行。</p>
      {''.join(samples)}
      {pager}
    </article>
  </div>
</div>'''
    return page(lesson['full_title'], lesson['summary'], body, f'/{lesson["id"]}/', rev)


def main():
    parser = argparse.ArgumentParser(description='產生課程網站')
    parser.add_argument('--out', required=True)
    args = parser.parse_args()
    out = Path(args.out)
    rev = os.environ.get('REVISION') or os.environ.get('CI_COMMIT_SHA') or 'local'
    if out.exists():
        shutil.rmtree(out)
    shutil.copytree(STATIC, out / 'assets', ignore=shutil.ignore_patterns('* 2.*', '.DS_Store'))
    lessons = load_lessons()
    (out / 'index.html').write_text(build_index(lessons, rev), encoding='utf-8')
    for i, lesson in enumerate(lessons):
        prev = lessons[i - 1] if i else None
        nxt = lessons[i + 1] if i + 1 < len(lessons) else None
        d = out / lesson['id']
        d.mkdir()
        (d / 'index.html').write_text(build_lesson(lesson, prev, nxt, rev), encoding='utf-8')
    body404 = '''<div class="wrap notfound"><p class="eyebrow">404</p><h1>這一頁不存在</h1>
<p class="lede">網址可能打錯了,或這一關換了位置。</p><p><a class="btn btn-primary" href="/">回到課程地圖</a></p></div>'''
    (out / '404.html').write_text(page('找不到頁面', SITE_DESC, body404, '/404.html', rev), encoding='utf-8')
    shutil.copy(ROOT / 'readme.html', out / 'readme.html')
    (out / 'robots.txt').write_text(f'User-agent: *\nAllow: /\nSitemap: {SITE_URL}/sitemap.xml\n', encoding='utf-8')
    urls = ['/'] + [f'/{x["id"]}/' for x in lessons] + ['/readme.html']
    sitemap = ''.join(f'<url><loc>{SITE_URL}{u}</loc></url>' for u in urls)
    (out / 'sitemap.xml').write_text(f'<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">{sitemap}</urlset>\n', encoding='utf-8')
    manifest = {
        'name': SITE_NAME, 'short_name': 'CI/CD 十關', 'lang': 'zh-Hant', 'start_url': '/', 'display': 'standalone',
        'background_color': '#070918', 'theme_color': '#070918',
        'icons': [{'src': '/assets/icon-192.png', 'sizes': '192x192', 'type': 'image/png'},
                  {'src': '/assets/icon-512.png', 'sizes': '512x512', 'type': 'image/png'}],
    }
    (out / 'manifest.webmanifest').write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding='utf-8')
    print(f'已產生 {len(lessons)} 關,輸出到 {out}(版本 {rev[:8]})')


if __name__ == '__main__':
    main()
lv10-k8s-deploy/site/check_site.py
lv10-k8s-deploy/site/check_site.py
"""檢查產生的網站:每一關都有頁面、品牌資產齊全、頁面沒有會被 CSP 擋掉的 inline 腳本。

用法:python3 lv10-k8s-deploy/site/check_site.py <網站目錄>
"""
from pathlib import Path
import re
import sys

ROOT = Path(__file__).resolve().parents[2]
site = Path(sys.argv[1])
errors = []

lessons = sorted(ROOT.glob('lv[0-9][0-9]-*'))
if len(lessons) != 10:
    errors.append(f'應該有 10 關,實際 {len(lessons)} 關')
for folder in lessons:
    for name in ('README.md', '.gitlab-ci.yml'):
        if not (folder / name).is_file():
            errors.append(f'{folder.name} 缺少 {name}')

required = ['index.html', '404.html', 'readme.html', 'robots.txt', 'sitemap.xml', 'manifest.webmanifest',
            'assets/site.css', 'assets/site.js', 'assets/theme-init.js', 'assets/favicon.svg', 'assets/favicon-32.png',
            'assets/apple-touch-icon.png', 'assets/og-image.png', 'assets/icon-192.png', 'assets/icon-512.png',
            'assets/fonts/sora-latin-wght.woff2', 'assets/fonts/inter-latin-wght.woff2']
required += [f'lv{n:02d}/index.html' for n in range(1, 11)]
for rel in required:
    path = site / rel
    if not path.is_file() or path.stat().st_size == 0:
        errors.append(f'缺少 {rel}')

for path in [site / 'index.html', site / '404.html', *sorted(site.glob('lv*/index.html'))]:
    if not path.is_file():
        continue
    text = path.read_text(encoding='utf-8')
    rel = path.relative_to(site)
    for needle in ('og:image', 'twitter:card', 'apple-touch-icon', 'rel="icon"', 'name="viewport"', 'lang="zh-Hant"'):
        if needle not in text:
            errors.append(f'{rel} 缺少 {needle}')
    if re.search(r'<script(?![^>]*\bsrc=)[^>]*>', text):
        errors.append(f'{rel} 有 inline <script>,會被 CSP 擋掉')
    if re.search(r'\sstyle="', text):
        errors.append(f'{rel} 有 inline style,會被 CSP 擋掉')

index = (site / 'index.html').read_text(encoding='utf-8') if (site / 'index.html').is_file() else ''
cards = re.findall(r'data-card="(lv\d\d)"', index)
if cards != [f'lv{n:02d}' for n in range(1, 11)]:
    errors.append(f'首頁卡片不是 lv01~lv10:{cards}')

if errors:
    print('網站檢查失敗:')
    for e in errors:
        print(' -', e)
    sys.exit(1)
print(f'網站檢查通過:{len(required)} 個檔案、{len(cards)} 張課程卡片')
.gitlab-ci.yml(根目錄總控)
.gitlab-ci.yml
# 課程總控 pipeline
# - Lv.1~Lv.9:每一關的 .gitlab-ci.yml 都是獨立範例,這裡用「子 pipeline」逐關觸發,跑在 gitlab.com 共享 runner
# - Lv.10:include lv10-k8s-deploy/.gitlab-ci.yml,把課程網站建成映像並部署到 k8s(跑在 homelab 的 k8s-ci、k8s-release runner)
# 只有改到的關卡才會重跑;手動執行(Run pipeline)或帶 RUN_LESSONS=all 時十關全跑

include:
  - local: lv10-k8s-deploy/.gitlab-ci.yml

stages:
  - lessons
  - site-check
  - k8s-scan
  - k8s-build
  - k8s-check
  - k8s-deploy

.lesson:
  stage: lessons
  needs: []
  trigger:
    strategy: depend           # 子 pipeline 的結果就是這個 job 的結果
    forward:
      yaml_variables: false    # 不把 Lv.10 的 SITE、IMAGE 等變數帶進教材
      pipeline_variables: false

lv01:
  extends: .lesson
  trigger: { include: lv01-first-pipeline/.gitlab-ci.yml }
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
    - changes: [lv01-first-pipeline/**/*]

lv02:
  extends: .lesson
  trigger: { include: lv02-stages-jobs/.gitlab-ci.yml }
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
    - changes: [lv02-stages-jobs/**/*]

lv03:
  extends: .lesson
  trigger: { include: lv03-artifacts/.gitlab-ci.yml }
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
    - changes: [lv03-artifacts/**/*]

lv04:
  extends: .lesson
  trigger: { include: lv04-variables/.gitlab-ci.yml }
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
    - changes: [lv04-variables/**/*]

lv05:
  extends: .lesson
  trigger: { include: lv05-cache/.gitlab-ci.yml }
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
    - changes: [lv05-cache/**/*, lv0*/.gitlab-ci.yml]

lv06:
  extends: .lesson
  trigger: { include: lv06-rules/.gitlab-ci.yml }
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
    - changes: [lv06-rules/**/*]

lv07:
  extends: .lesson
  trigger: { include: lv07-needs-dag/.gitlab-ci.yml }
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
    - changes: [lv07-needs-dag/**/*]

lv08:
  extends: .lesson
  trigger: { include: lv08-services/.gitlab-ci.yml }
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
    - changes: [lv08-services/**/*]

lv09:
  extends: .lesson
  trigger: { include: lv09-environments/.gitlab-ci.yml }
  rules:
    - if: $CI_PIPELINE_SOURCE == "web" || $RUN_LESSONS == "all"
    - changes: [lv09-environments/**/*]