commit c65b5470e07ca4cc08f067ccd0aaec7822b25b16 Author: Dever Automation Date: Fri Oct 9 00:45:47 2026 +0000 Add Go demo with isolated CI and manual production release diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..423690d --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +** +!src/ +!src/** +!build/ +!build/Dockerfile +src/.env +src/.env.* +src/demo diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml new file mode 100644 index 0000000..531b080 --- /dev/null +++ b/.gitea/workflows/ci.yaml @@ -0,0 +1,22 @@ +name: Demo CI +on: + push: + branches: [main] + workflow_dispatch: +concurrency: + group: demo-cicd + cancel-in-progress: false +jobs: + check: + runs-on: demo-build + timeout-minutes: 30 + services: + buildkit: + image: mirror.gcr.io/moby/buildkit@sha256:f8a833b2de9d68e27f0815e4a737abdfaf8a2e4c615650557df11025101557b4 + steps: + - name: Clean checkout and isolated Go checks + env: + GITEA_TOKEN: ${{ gitea.token }} + GITEA_REPOSITORY: ${{ gitea.repository }} + SOURCE_SHA: ${{ gitea.sha }} + run: python3 /opt/ci/pipeline.py check diff --git a/.gitea/workflows/release.yaml b/.gitea/workflows/release.yaml new file mode 100644 index 0000000..f902001 --- /dev/null +++ b/.gitea/workflows/release.yaml @@ -0,0 +1,51 @@ +name: Demo Release +on: + workflow_dispatch: + inputs: + operation: + description: Release selected Git ref, or roll back to a verified revision + type: choice + options: [release, rollback] + default: release + required: true + target_revision: + description: Full 40-character previously released commit, required only for rollback + type: string + required: false +concurrency: + group: demo-cicd + cancel-in-progress: false +jobs: + build: + if: ${{ inputs.operation == 'release' }} + runs-on: demo-build + timeout-minutes: 30 + services: + buildkit: + image: mirror.gcr.io/moby/buildkit@sha256:f8a833b2de9d68e27f0815e4a737abdfaf8a2e4c615650557df11025101557b4 + steps: + - name: Build and publish private immutable candidate + env: + GITEA_TOKEN: ${{ gitea.token }} + GITEA_REPOSITORY: ${{ gitea.repository }} + SOURCE_SHA: ${{ gitea.sha }} + REGISTRY_TOKEN: ${{ secrets.DEMO_REGISTRY_PUSH_TOKEN }} + run: python3 /opt/ci/pipeline.py build + deploy: + needs: [build] + if: ${{ always() && ((inputs.operation == 'release' && needs.build.result == 'success') || inputs.operation == 'rollback') }} + runs-on: demo-deploy + timeout-minutes: 15 + steps: + - name: Verify actual runtime image and deploy + env: + GITEA_REPOSITORY: ${{ gitea.repository }} + SOURCE_SHA: ${{ gitea.sha }} + TARGET_REVISION: ${{ inputs.target_revision }} + OPERATION: ${{ inputs.operation }} + run: | + case "$OPERATION" in + release) python3 /opt/ci/pipeline.py deploy ;; + rollback) python3 /opt/ci/pipeline.py rollback ;; + *) exit 1 ;; + esac diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..11ff827 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/images/ +/src/demo +/.env +/.env.* diff --git a/README.md b/README.md new file mode 100644 index 0000000..6621dc2 --- /dev/null +++ b/README.md @@ -0,0 +1,42 @@ +# Demo + +最小 Go HTTP 服务,源码入口 `src/`,项目根是 Git 仓库边界;私有仓库为 `https://git.dever.cc/rabin/demo`。仅使用 Go 标准库,不引入业务框架。 + +2026-10-08(UTC)已部署到共享 dev,`https://demo-test.5dev.cn` 无需登录即可返回 `hello world`。真实 HTTP、源码同步、单服务重启、停止释放端口及 dev Pod 重建恢复均已验证;没有构建或发布业务生产镜像。 + +## 行为 + +- `GET /` 返回 `hello world` 和换行,Content-Type 为 `text/plain; charset=utf-8`。 +- 其他路径返回 404,不支持的方法返回 405;`HEAD /` 使用 HTTP 标准行为。 +- 程序读取 `PORT`,开发配置固定为 `18080`,未提供时使用 `8080`;非法端口启动失败。 +- Supervisor 程序名为 `demo`,日志是 dev 内 `/work/log/demo.log`。 + +## 开发 + +```bash +kubectl exec -n platform-dev deployment/dev -c dev -- sh -c 'cd /dm/service/project/demo/src && go fmt ./... && go test ./... && go vet ./...' +kubectl exec -n platform-dev deployment/dev -c dev -- supervisorctl -c /etc/supervisor/supervisord.conf restart demo +``` + +当前没有额外测试框架,`go test` 的包检查不能替代真实 HTTP 请求验证。源码修改不构建镜像;普通依赖在 dev 内按兼容 Go 1.25 的固定版本添加,检查 `go.mod`、`go.sum` 的变化。 + +## 开发入口 + +`demo-test.5dev.cn` 复用现有 Caddy、Traefik 和 `*.5dev.cn` 证书。`config/dev/` 管理 Service、Ingress;Service `demo-dev:80` 指向共享 dev 的 `18080` 端口。 + +平台入口不叠加统一登录,登录和鉴权由项目自行实现;后续 project 项目也遵循此约定。Demo 没有应用认证,因此可公开访问。初次部署使用的 BasicAuth Middleware、专用 Secret 和生成的凭据文件已按用户要求移除。 + +开发 Service 不用于生产。正式环境使用独立的 `project-demo/demo` Deployment,通过 `demo.5dev.cn` 访问;实际发布结果以 Actions 和 `images/<完整提交>.json` 为准。 + +## CI/CD + +- `.gitea/workflows/ci.yaml`:推送到 `main` 后自动检查格式、执行 `go test`、`go vet` 并编译;不推镜像、不发布生产。也可手动执行检查。 +- `.gitea/workflows/release.yaml`:只接受手动触发。选择明确 Git 分支或标签,`operation=release`,流水线检出该次事件的完整 commit 后测试、构建、推送私有镜像、验证真实运行镜像,再发布生产并检查公网 HTTPS。 +- `operation=rollback` 时,`target_revision` 必须填写曾经成功发布的完整 40 位 commit。回退复用已有镜像摘要和校验过的归档,不重新编译;仍执行运行镜像与正式 HTTPS 检查。 +- 镜像为 `git.dever.cc/demo-ci/demo:<完整提交>`,实际 Deployment 固定 `@sha256`,不用 `latest`。二进制在隔离 BuildKit 容器里用 Go 1.25.14 编译,运行层是非 root 的 scratch 镜像,不含编译器、Supervisor 或项目源码。 +- 发布阶段失败会尝试恢复之前的 Demo 镜像和副本数,并在 Actions 中报失败,不把自动回退当作发布成功。首次发布失败恢复未启动状态。 +- `images/<完整提交>.tar`、`.tar.sha256`、`.json` 是正式发行归档,Git 忽略整个 `images/`。源码、Git、配置与归档保留;临时 CI 工作目录和验证 Job 会清理。 + +`config/namespace.yaml` 和 `config/prod/` 是首次建立生产资源的配置。生产镜像与副本数之后由发布流程管理;不要直接重套初始化配置覆盖正在运行的版本。更新平台拓扑前需保留当前固定镜像摘要和副本数,审查差异后再应用;日常发布和回退使用 Actions。 + +Runner、工具镜像、缓存和角色配置归 `/dm/service/platform/ci/`。Runner 仅接受本仓库任务、并发为 1;构建 Pod 不挂 Kubernetes 令牌,部署 Pod 的生产权限仅能读取和更新本项目的 `demo` Deployment。只有可信仓库维护者可以修改工作流或触发发布,不接收外部 fork/PR 的自动任务。 diff --git a/build/Dockerfile b/build/Dockerfile new file mode 100644 index 0000000..ba3ddb3 --- /dev/null +++ b/build/Dockerfile @@ -0,0 +1,21 @@ +FROM docker.io/library/golang:1.25.14-bookworm@sha256:c268a04d59aea0b180ed9946a658cfab9e7b3391dc90eed6e4969ccff98c851f AS build +ENV GOTOOLCHAIN=local CGO_ENABLED=0 +WORKDIR /src +COPY src/go.mod ./ +RUN --mount=type=cache,target=/go/pkg/mod go mod download +COPY src/ ./ +RUN --mount=type=cache,target=/root/.cache/go-build \ + test -z "$(gofmt -l .)" \ + && go test ./... \ + && go vet ./... \ + && go build -trimpath -ldflags='-s -w' -o /out/demo . + +FROM scratch AS runtime +ARG SOURCE_REVISION +LABEL org.opencontainers.image.source="https://git.dever.cc/rabin/demo" \ + org.opencontainers.image.revision=$SOURCE_REVISION +COPY --from=build /out/demo /demo +USER 65532:65532 +ENV PORT=8080 +EXPOSE 8080 +ENTRYPOINT ["/demo"] diff --git a/config/dev/ingress.yaml b/config/dev/ingress.yaml new file mode 100644 index 0000000..2679e44 --- /dev/null +++ b/config/dev/ingress.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: demo-dev + namespace: platform-dev + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web +spec: + ingressClassName: traefik + rules: + - host: demo-test.5dev.cn + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: demo-dev + port: + name: http diff --git a/config/dev/kustomization.yaml b/config/dev/kustomization.yaml new file mode 100644 index 0000000..ab2ea8d --- /dev/null +++ b/config/dev/kustomization.yaml @@ -0,0 +1,5 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - service.yaml + - ingress.yaml diff --git a/config/dev/service.yaml b/config/dev/service.yaml new file mode 100644 index 0000000..6834c45 --- /dev/null +++ b/config/dev/service.yaml @@ -0,0 +1,14 @@ +apiVersion: v1 +kind: Service +metadata: + name: demo-dev + namespace: platform-dev +spec: + type: ClusterIP + selector: + app.kubernetes.io/name: dev + ports: + - name: http + port: 80 + targetPort: 18080 + protocol: TCP diff --git a/config/dev/supervisor.ini b/config/dev/supervisor.ini new file mode 100644 index 0000000..d73f29c --- /dev/null +++ b/config/dev/supervisor.ini @@ -0,0 +1,12 @@ +[program:demo] +directory=/dm/service/project/demo/src +command=/usr/local/go/bin/go run . +environment=PORT="18080" +autostart=true +autorestart=unexpected +stopasgroup=true +killasgroup=true +redirect_stderr=true +stdout_logfile=/work/log/demo.log +stdout_logfile_maxbytes=10MB +stdout_logfile_backups=3 diff --git a/config/namespace.yaml b/config/namespace.yaml new file mode 100644 index 0000000..c308abf --- /dev/null +++ b/config/namespace.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: project-demo + labels: + app.kubernetes.io/part-of: demo diff --git a/config/prod/deployment.yaml b/config/prod/deployment.yaml new file mode 100644 index 0000000..6c65ad4 --- /dev/null +++ b/config/prod/deployment.yaml @@ -0,0 +1,66 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: demo + namespace: project-demo +spec: + replicas: 0 + revisionHistoryLimit: 5 + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 + selector: + matchLabels: + app.kubernetes.io/name: demo + template: + metadata: + labels: + app.kubernetes.io/name: demo + spec: + automountServiceAccountToken: false + nodeSelector: + kubernetes.io/hostname: shemic + imagePullSecrets: + - name: demo-registry-pull + terminationGracePeriodSeconds: 15 + securityContext: + runAsNonRoot: true + runAsUser: 65532 + runAsGroup: 65532 + seccompProfile: + type: RuntimeDefault + containers: + - name: demo + image: git.dever.cc/demo-ci/demo:bootstrap + imagePullPolicy: Always + env: + - name: PORT + value: '8080' + ports: + - name: http + containerPort: 8080 + securityContext: + allowPrivilegeEscalation: false + readOnlyRootFilesystem: true + capabilities: + drop: [ALL] + resources: + requests: + cpu: 10m + memory: 16Mi + limits: + cpu: 250m + memory: 64Mi + readinessProbe: + httpGet: + path: / + port: http + periodSeconds: 3 + livenessProbe: + httpGet: + path: / + port: http + initialDelaySeconds: 5 + periodSeconds: 10 diff --git a/config/prod/ingress.yaml b/config/prod/ingress.yaml new file mode 100644 index 0000000..2b5b5f5 --- /dev/null +++ b/config/prod/ingress.yaml @@ -0,0 +1,20 @@ +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: demo + namespace: project-demo + annotations: + traefik.ingress.kubernetes.io/router.entrypoints: web +spec: + ingressClassName: traefik + rules: + - host: demo.5dev.cn + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: demo + port: + name: http diff --git a/config/prod/kustomization.yaml b/config/prod/kustomization.yaml new file mode 100644 index 0000000..0be9968 --- /dev/null +++ b/config/prod/kustomization.yaml @@ -0,0 +1,8 @@ +apiVersion: kustomize.config.k8s.io/v1beta1 +kind: Kustomization +resources: + - deployment.yaml + - service.yaml + - ingress.yaml + - networkpolicy.yaml + - rbac.yaml diff --git a/config/prod/networkpolicy.yaml b/config/prod/networkpolicy.yaml new file mode 100644 index 0000000..386d5f8 --- /dev/null +++ b/config/prod/networkpolicy.yaml @@ -0,0 +1,22 @@ +apiVersion: networking.k8s.io/v1 +kind: NetworkPolicy +metadata: + name: demo + namespace: project-demo +spec: + podSelector: + matchLabels: + app.kubernetes.io/name: demo + policyTypes: [Ingress, Egress] + egress: [] + ingress: + - from: + - namespaceSelector: + matchLabels: + kubernetes.io/metadata.name: kube-system + podSelector: + matchLabels: + app.kubernetes.io/name: traefik + ports: + - protocol: TCP + port: 8080 diff --git a/config/prod/rbac.yaml b/config/prod/rbac.yaml new file mode 100644 index 0000000..556d771 --- /dev/null +++ b/config/prod/rbac.yaml @@ -0,0 +1,24 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: demo-release + namespace: project-demo +rules: + - apiGroups: [apps] + resources: [deployments] + resourceNames: [demo] + verbs: [get, watch, patch] +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: demo-release + namespace: project-demo +subjects: + - kind: ServiceAccount + name: demo-deployer + namespace: platform-ci +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: demo-release diff --git a/config/prod/service.yaml b/config/prod/service.yaml new file mode 100644 index 0000000..19b2641 --- /dev/null +++ b/config/prod/service.yaml @@ -0,0 +1,12 @@ +apiVersion: v1 +kind: Service +metadata: + name: demo + namespace: project-demo +spec: + selector: + app.kubernetes.io/name: demo + ports: + - name: http + port: 80 + targetPort: http diff --git a/src/go.mod b/src/go.mod new file mode 100644 index 0000000..dd728ee --- /dev/null +++ b/src/go.mod @@ -0,0 +1,3 @@ +module example.com/demo + +go 1.25.0 diff --git a/src/main.go b/src/main.go new file mode 100644 index 0000000..b46e5fe --- /dev/null +++ b/src/main.go @@ -0,0 +1,38 @@ +package main + +import ( + "log" + "net/http" + "os" + "strconv" + "time" +) + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + portNumber, err := strconv.Atoi(port) + if err != nil || portNumber < 1 || portNumber > 65535 { + log.Fatal("PORT must be an integer between 1 and 65535") + } + + mux := http.NewServeMux() + mux.HandleFunc("GET /{$}", func(writer http.ResponseWriter, request *http.Request) { + writer.Header().Set("Content-Type", "text/plain; charset=utf-8") + if _, err := writer.Write([]byte("hello world\n")); err != nil { + log.Printf("write response: %v", err) + } + }) + + server := &http.Server{ + Addr: ":" + port, + Handler: mux, + ReadHeaderTimeout: 5 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 60 * time.Second, + } + log.Printf("listening on %s", server.Addr) + log.Fatal(server.ListenAndServe()) +}