feat: build runtime releases from pinned Git branch commits
This commit is contained in:
@@ -19,3 +19,6 @@ UPLOAD_ALLOWED_EXTENSIONS=
|
|||||||
PANEL_PUBLIC_URL=http://127.0.0.1:8088
|
PANEL_PUBLIC_URL=http://127.0.0.1:8088
|
||||||
# Optional proxy networks. X-Forwarded-For is ignored unless the peer is in these CIDRs.
|
# Optional proxy networks. X-Forwarded-For is ignored unless the peer is in these CIDRs.
|
||||||
TRUSTED_PROXY_CIDRS=
|
TRUSTED_PROXY_CIDRS=
|
||||||
|
# Trusted executable runtime build source. Do not embed credentials in this URL.
|
||||||
|
DSH_RELEASE_REPOSITORY=https://git.misaka.ren/m1saka/dsh-web.git
|
||||||
|
DSH_RELEASE_BRANCH=dockerfile
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ GET /api/orphans 返回可回收的完整容器ID数组。DELETE /api/orphans?id
|
|||||||
- GET /api/audit(管理员)
|
- GET /api/audit(管理员)
|
||||||
|
|
||||||
## 镜像与构建
|
## 镜像与构建
|
||||||
|
|
||||||
|
- GET /api/releases/latest(仅管理员;获取并校验配置的 Git 发布分支,返回 release_id、version、commit、repository、branch、recipe_rev、platforms、compatible、reason,以及可选的 build。检查会下载构建文件,但不执行 Dockerfile;未知部署协议或平台不允许构建)
|
||||||
|
- POST /api/releases/build {commit}(仅管理员、同源和 CSRF;提交检查时看到的精确 commit,分支已变化返回 409,需重新检查。返回 202 构建任务;不自动升级实例)
|
||||||
|
- Git 构建任务附带 release_id、source_commit、source_repository 来源信息;发布身份独立于 DSH 版本及部署布局,允许同一 DSH 版本构建新的脚本修订。可信发布通过现有镜像身份和启动验证后可准入,显式禁用仍优先。原 /api/latest 与 /api/builds 保留内置构建逻辑。
|
||||||
- GET /api/images(每项含 admission、admission_reason、selectable;只有 status=available 且 selectable=true 才可新建/升级)
|
- GET /api/images(每项含 admission、admission_reason、selectable;只有 status=available 且 selectable=true 才可新建/升级)
|
||||||
- GET /api/compatibility(登录后可读;返回 recipe_rev、platform、entries[{version,recipe_rev,platform,state,build_allowed,reason}]、unknown{state,build_allowed,reason}、experimental_admin_only。此为普通版本构建清单,不等于每个实际镜像的准入结果)
|
- GET /api/compatibility(登录后可读;返回 recipe_rev、platform、entries[{version,recipe_rev,platform,state,build_allowed,reason}]、unknown{state,build_allowed,reason}、experimental_admin_only。此为普通版本构建清单,不等于每个实际镜像的准入结果)
|
||||||
- POST /api/images/admission {image_id,state,evidence}(管理员、同源和CSRF;state为recommended/compatible/existing_only/blocked,evidence必填。正向准入要求精确镜像身份、成功验证记录、受支持挂载布局与平台;策略与审计持久化。单镜像准入不自动扩大普通版本构建白名单)
|
- POST /api/images/admission {image_id,state,evidence}(管理员、同源和CSRF;state为recommended/compatible/existing_only/blocked,evidence必填。正向准入要求精确镜像身份、成功验证记录、受支持挂载布局与平台;策略与审计持久化。单镜像准入不自动扩大普通版本构建白名单)
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@ COPY image/ ./image/
|
|||||||
RUN CGO_ENABLED=0 go build -trimpath -o /out/dsh-mgr ./cmd/server
|
RUN CGO_ENABLED=0 go build -trimpath -o /out/dsh-mgr ./cmd/server
|
||||||
|
|
||||||
FROM debian:bookworm-slim
|
FROM debian:bookworm-slim
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates && rm -rf /var/lib/apt/lists/*
|
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates git && rm -rf /var/lib/apt/lists/*
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=backend /out/dsh-mgr /usr/local/bin/dsh-mgr
|
COPY --from=backend /out/dsh-mgr /usr/local/bin/dsh-mgr
|
||||||
COPY --from=web /src/web/dist ./web/dist
|
COPY --from=web /src/web/dist ./web/dist
|
||||||
|
|||||||
@@ -53,6 +53,36 @@
|
|||||||
- 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。
|
- 人工恢复前停止相关容器并另做完整备份。中断点不同,不能统一用一次目录覆盖或状态修改处理;当前无自动灾难恢复命令。
|
||||||
- 删除实例会删除其容器与网络,但保留数据目录。升级快照暂无自动清理;不要删除仍被未完成 journal 引用的快照。
|
- 删除实例会删除其容器与网络,但保留数据目录。升级快照暂无自动清理;不要删除仍被未完成 journal 引用的快照。
|
||||||
|
|
||||||
|
## Git 分支发布运行镜像构建配置
|
||||||
|
|
||||||
|
发布源默认为本仓库 `https://git.misaka.ren/m1saka/dsh-web.git` 的 `dockerfile` 分支,通过 `DSH_RELEASE_REPOSITORY` / `DSH_RELEASE_BRANCH` 配置。它发布的是构建文件,不是预构建镜像,也不是面板程序自身的更新。修改配置后需重新创建面板容器。
|
||||||
|
|
||||||
|
直接运行面板二进制时需要安装 `git`;面板 Dockerfile 已包含该依赖。当前通道仅支持无需认证的 HTTPS 仓库,不读取宿主 Git 凭据、不跟随重定向,也不支持 SSH 或 URL 内嵌令牌。私有仓库认证需另行扩展,不能把访问令牌写入仓库地址。
|
||||||
|
|
||||||
|
分支根目录放 `release.json`,完整构建目录放 `image/`。本仓库提供初始描述文件;发布时一起提交 Dockerfile、Caddyfile、entrypoint 和全部安装/适配脚本。只把 Dockerfile 单独放到分支上是不完整的发布。不要把 `.env`、私钥、凭据、用户数据或 `.git` 放进构建目录;`.dockerignore` 只是辅助措施,发布者仍须审查内容。
|
||||||
|
|
||||||
|
Git 通道直接向 Docker API 发送构建上下文,不执行 Docker CLI 的 `.dockerignore` 过滤。面板会排除 `.git`、`.env` 系列和常见凭据文件,并拒绝链接、特殊文件及越界路径,但不能识别任意文件名下的秘密。构建归档最大 32 MiB;Git 获取有 45 秒超时及文件大小限制,建议发布分支仅保留发布所需文件。
|
||||||
|
|
||||||
|
`release.json` 示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"release_id": "runtime-7.1",
|
||||||
|
"version": "0.2.0-rc.2",
|
||||||
|
"deployment_schema": 7,
|
||||||
|
"platforms": ["linux/amd64"]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`version` 是精确 DSH 版本;`release_id` 是构建配置发布编号,修改脚本或 Dockerfile 后即使 DSH 版本不变,也应使用新的编号。`deployment_schema` 声明运行布局兼容协议,当前 Git 发布通道支持协议 7:实例 home 挂载 `/workspace`、共享目录 `/shared`、代理端口 8080,并保留现有认证与启动约定。它不应随普通构建修订递增;不兼容的运行约定需要面板新增支持。`platforms` 只能声明发布者实际验证的平台。
|
||||||
|
|
||||||
|
首次发布时,在独立 checkout 或 worktree 准备 `dockerfile` 分支,将 `release.json` 和完整 `image/` 目录提交并推送到同一远程仓库。无需切换正在开发的主工作目录,也无需 CI。此功能的代码安装本身不会替你创建或推送远程分支;分支未发布前,检查失败是预期状态,旧实例与旧构建入口不受影响。
|
||||||
|
|
||||||
|
管理员打开镜像页会检查 Git 发布,页面保持打开时每五分钟复查,也可手动刷新。确认构建后面板以固定 commit 获取文件;确认期间分支发生变化会拒绝并要求重新检查。构建任务记录发布编号、仓库和 commit,同一 DSH 版本的新构建发布不会误用旧版本构建任务。构建与验证成功后才可选择镜像;不会自动升级任何已有实例,升级仍走密码确认、备份、快照和失败回滚流程。
|
||||||
|
|
||||||
|
该分支是可执行代码信任边界:限制写权限,发布前审查 Dockerfile 与脚本,不向不可信仓库开放构建。可信发布只替代内置版本名单,不替代完整的上线验收;应在发布前验证启动、代理/认证、文件访问、插件以及升级恢复。管理员对精确镜像设置的禁用策略仍然有效。上游依赖、基础镜像和网络变化可能影响重建结果,commit 固定并不意味着所有外部依赖都已固定。Git 故障不会停止已有实例。
|
||||||
|
|
||||||
## 创建与构建恢复记录
|
## 创建与构建恢复记录
|
||||||
|
|
||||||
创建失败不代表 Docker 没有创建资源。面板在发请求前记录 `runtime_creates`(实例 ID、容器/网络结果待确认标志及其置位时间)及 `image_build_checks`(构建 ID、镜像 ID、已确认的验证容器 ID、记录创建时间),已确认网络身份保存在 `runtime_networks`。资源可见后按身份校验再清理;已记录 ID 时按 ID 查询,不能把名称消失当成资源删除。身份不一致时拒绝删除。构建验证残留在启动时及后台重试回收,无需新建构建任务触发。
|
创建失败不代表 Docker 没有创建资源。面板在发请求前记录 `runtime_creates`(实例 ID、容器/网络结果待确认标志及其置位时间)及 `image_build_checks`(构建 ID、镜像 ID、已确认的验证容器 ID、记录创建时间),已确认网络身份保存在 `runtime_networks`。资源可见后按身份校验再清理;已记录 ID 时按 ID 查询,不能把名称消失当成资源删除。身份不一致时拒绝删除。构建验证残留在启动时及后台重试回收,无需新建构建任务触发。
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
## 当前实现
|
## 当前实现
|
||||||
|
|
||||||
|
DSH 运行镜像支持从本仓库 `dockerfile` 分支获取独立发布的构建配置,无需 CI 预构建镜像。发布包含 `release.json` 和完整的 `image/` 目录;面板锁定 Git commit,在本地构建并验证。管理员在镜像页检查并确认构建,实例升级仍需另行确认,不会因分支更新自动停机。发布约定和首次启用步骤见 [OPERATIONS.md](OPERATIONS.md)。
|
||||||
|
|
||||||
实例所有者可管理完整 home,包括读取自己的配置和凭据;不承诺密钥 write-only,跨用户隔离保持不变。通用文件操作不受专用 Profile 编辑器的 YAML 校验和停机要求约束。下载、打包与敏感修改提示用于防误操作,不是权限限制;文件下载和目录 ZIP 不会自动加密,修改配置前建议先停止实例并备份。
|
实例所有者可管理完整 home,包括读取自己的配置和凭据;不承诺密钥 write-only,跨用户隔离保持不变。通用文件操作不受专用 Profile 编辑器的 YAML 校验和停机要求约束。下载、打包与敏感修改提示用于防误操作,不是权限限制;文件下载和目录 ZIP 不会自动加密,修改配置前建议先停止实例并备份。
|
||||||
|
|
||||||
- 内置账号、管理员配额、短JWT与refresh轮换、失败锁定及审计。
|
- 内置账号、管理员配额、短JWT与refresh轮换、失败锁定及审计。
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ services:
|
|||||||
HOST_DATA_ROOT: ${DSH_HOST_DATA_ROOT:?Set an absolute host data directory}
|
HOST_DATA_ROOT: ${DSH_HOST_DATA_ROOT:?Set an absolute host data directory}
|
||||||
PANEL_PUBLIC_URL: ${PANEL_PUBLIC_URL:-http://127.0.0.1:8088}
|
PANEL_PUBLIC_URL: ${PANEL_PUBLIC_URL:-http://127.0.0.1:8088}
|
||||||
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
|
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
|
||||||
|
DSH_RELEASE_REPOSITORY: ${DSH_RELEASE_REPOSITORY:-https://git.misaka.ren/m1saka/dsh-web.git}
|
||||||
|
DSH_RELEASE_BRANCH: ${DSH_RELEASE_BRANCH:-dockerfile}
|
||||||
WEB_DIR: /app/web/dist
|
WEB_DIR: /app/web/dist
|
||||||
UPLOAD_ALLOWED_EXTENSIONS: ${UPLOAD_ALLOWED_EXTENSIONS:-}
|
UPLOAD_ALLOWED_EXTENSIONS: ${UPLOAD_ALLOWED_EXTENSIONS:-}
|
||||||
ADMIN_USERNAME: ${DSH_ADMIN_USERNAME:-admin}
|
ADMIN_USERNAME: ${DSH_ADMIN_USERNAME:-admin}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
.git
|
||||||
|
**/.git
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
**/.env
|
||||||
|
**/.env.*
|
||||||
|
node_modules
|
||||||
|
**/node_modules
|
||||||
|
*.test.mjs
|
||||||
|
embed.go
|
||||||
@@ -89,6 +89,11 @@ func (a *API) evaluateImage(ctx context.Context, id string) (imageRecord, error)
|
|||||||
if count == 0 {
|
if count == 0 {
|
||||||
return im, nil
|
return im, nil
|
||||||
}
|
}
|
||||||
|
var trustedRelease int
|
||||||
|
err = a.s.DB.QueryRowContext(ctx, "SELECT count(*) FROM image_builds WHERE image_id=? AND version=? AND recipe_rev=? AND platform=? AND status='success' AND release_id<>'' AND release_id=? AND source_repository=? AND source_commit=?", id, im.Version, im.RecipeRev, im.Platform, actual.Config.Labels["dsh-mgr.release"], actual.Config.Labels["dsh-mgr.source"], actual.Config.Labels["dsh-mgr.commit"]).Scan(&trustedRelease)
|
||||||
|
if err != nil {
|
||||||
|
return im, err
|
||||||
|
}
|
||||||
im.Admission = defaultAdmission(im.Version, im.RecipeRev, im.Platform)
|
im.Admission = defaultAdmission(im.Version, im.RecipeRev, im.Platform)
|
||||||
im.AdmissionReason = "no accepted compatibility evidence for this tuple"
|
im.AdmissionReason = "no accepted compatibility evidence for this tuple"
|
||||||
if positiveAdmission(im.Admission) {
|
if positiveAdmission(im.Admission) {
|
||||||
@@ -101,6 +106,10 @@ func (a *API) evaluateImage(ctx context.Context, id string) (imageRecord, error)
|
|||||||
} else {
|
} else {
|
||||||
im.Status = "existing_only"
|
im.Status = "existing_only"
|
||||||
}
|
}
|
||||||
|
if trustedRelease > 0 {
|
||||||
|
im.Admission = "compatible"
|
||||||
|
im.AdmissionReason = "trusted pinned release passed exact image startup validation"
|
||||||
|
}
|
||||||
if state != "" {
|
if state != "" {
|
||||||
im.Admission = state
|
im.Admission = state
|
||||||
im.AdmissionReason = evidence
|
im.AdmissionReason = evidence
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ func (a *API) Handler() http.Handler {
|
|||||||
m.HandleFunc("/api/images/admission", a.imageAdmission)
|
m.HandleFunc("/api/images/admission", a.imageAdmission)
|
||||||
m.HandleFunc("/api/compatibility", a.compatibility)
|
m.HandleFunc("/api/compatibility", a.compatibility)
|
||||||
m.HandleFunc("/api/latest", a.latest)
|
m.HandleFunc("/api/latest", a.latest)
|
||||||
|
m.HandleFunc("/api/releases/latest", a.latestRelease)
|
||||||
|
m.HandleFunc("/api/releases/build", a.releaseBuild)
|
||||||
m.HandleFunc("/api/builds", a.builds)
|
m.HandleFunc("/api/builds", a.builds)
|
||||||
m.HandleFunc("/api/builds/", a.buildDetail)
|
m.HandleFunc("/api/builds/", a.buildDetail)
|
||||||
m.HandleFunc("/api/intents", a.intents)
|
m.HandleFunc("/api/intents", a.intents)
|
||||||
|
|||||||
@@ -11,10 +11,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Summary queries never load log bodies; history cannot inflate a page response.
|
// Summary queries never load log bodies; history cannot inflate a page response.
|
||||||
const buildSummaryColumns = "id,version,status,error,image_id,created_at,requested_by,COALESCE(recipe_rev,''),COALESCE(platform,''),COALESCE(finished_at,''),experimental"
|
const buildSummaryColumns = "id,version,status,error,image_id,created_at,requested_by,COALESCE(recipe_rev,''),COALESCE(platform,''),COALESCE(finished_at,''),experimental,release_id,source_repository,source_commit"
|
||||||
|
|
||||||
func scanBuildSummary(row interface{ Scan(...any) error }, b *buildRecord) error {
|
func scanBuildSummary(row interface{ Scan(...any) error }, b *buildRecord) error {
|
||||||
return row.Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental)
|
return row.Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental, &b.ReleaseID, &b.SourceRepository, &b.SourceCommit)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *API) listBuilds(w http.ResponseWriter, r *http.Request) {
|
func (a *API) listBuilds(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -97,7 +97,7 @@ func (a *API) buildDetail(w http.ResponseWriter, r *http.Request) {
|
|||||||
buildQueue.Unlock()
|
buildQueue.Unlock()
|
||||||
var b buildRecord
|
var b buildRecord
|
||||||
if err == nil {
|
if err == nil {
|
||||||
err = a.s.DB.QueryRowContext(ctx, "SELECT "+buildSummaryColumns+",logs FROM image_builds WHERE id=?", id).Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental, &b.Logs)
|
err = a.s.DB.QueryRowContext(ctx, "SELECT "+buildSummaryColumns+",logs FROM image_builds WHERE id=?", id).Scan(&b.ID, &b.Version, &b.Status, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &b.RecipeRev, &b.Platform, &b.Finished, &b.Experimental, &b.ReleaseID, &b.SourceRepository, &b.SourceCommit, &b.Logs)
|
||||||
}
|
}
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
fail(w, 404, "build not found")
|
fail(w, 404, "build not found")
|
||||||
|
|||||||
+24
-7
@@ -35,6 +35,9 @@ func (a *API) ensureBuilds() error {
|
|||||||
"ALTER TABLE image_builds ADD COLUMN platform TEXT",
|
"ALTER TABLE image_builds ADD COLUMN platform TEXT",
|
||||||
"ALTER TABLE image_builds ADD COLUMN finished_at TEXT",
|
"ALTER TABLE image_builds ADD COLUMN finished_at TEXT",
|
||||||
"ALTER TABLE image_builds ADD COLUMN experimental INTEGER NOT NULL DEFAULT 0",
|
"ALTER TABLE image_builds ADD COLUMN experimental INTEGER NOT NULL DEFAULT 0",
|
||||||
|
"ALTER TABLE image_builds ADD COLUMN release_id TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE image_builds ADD COLUMN source_repository TEXT NOT NULL DEFAULT ''",
|
||||||
|
"ALTER TABLE image_builds ADD COLUMN source_commit TEXT NOT NULL DEFAULT ''",
|
||||||
"CREATE TABLE IF NOT EXISTS image_admissions(image_id TEXT NOT NULL,version TEXT NOT NULL,recipe_rev TEXT NOT NULL,platform TEXT NOT NULL,state TEXT NOT NULL,evidence TEXT NOT NULL,admitted_by TEXT NOT NULL,admitted_at TEXT NOT NULL,PRIMARY KEY(image_id,version,recipe_rev,platform))",
|
"CREATE TABLE IF NOT EXISTS image_admissions(image_id TEXT NOT NULL,version TEXT NOT NULL,recipe_rev TEXT NOT NULL,platform TEXT NOT NULL,state TEXT NOT NULL,evidence TEXT NOT NULL,admitted_by TEXT NOT NULL,admitted_at TEXT NOT NULL,PRIMARY KEY(image_id,version,recipe_rev,platform))",
|
||||||
"ALTER TABLE images ADD COLUMN recipe_rev TEXT",
|
"ALTER TABLE images ADD COLUMN recipe_rev TEXT",
|
||||||
"ALTER TABLE images ADD COLUMN platform TEXT",
|
"ALTER TABLE images ADD COLUMN platform TEXT",
|
||||||
@@ -44,11 +47,11 @@ func (a *API) ensureBuilds() error {
|
|||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
_, err = a.s.DB.Exec("DROP INDEX IF EXISTS image_builds_version; CREATE UNIQUE INDEX IF NOT EXISTS image_builds_identity ON image_builds(version,recipe_rev,platform) WHERE recipe_rev IS NOT NULL AND platform IS NOT NULL AND status IN ('queued','running','validating','success')")
|
_, err = a.s.DB.Exec("DROP INDEX IF EXISTS image_builds_version; DROP INDEX IF EXISTS image_builds_identity; CREATE UNIQUE INDEX image_builds_identity ON image_builds(version,recipe_rev,platform) WHERE release_id='' AND recipe_rev IS NOT NULL AND platform IS NOT NULL AND status IN ('queued','running','validating','success'); CREATE UNIQUE INDEX IF NOT EXISTS image_builds_release_identity ON image_builds(source_repository,source_commit,release_id,platform) WHERE release_id<>'' AND status IN ('queued','running','validating','success')")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='version requires administrator experimental build' WHERE status='queued' AND experimental=0 AND version NOT IN ('0.2.0-rc.1','0.2.0-rc.2')"); err != nil {
|
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed',error='version requires administrator experimental build' WHERE status='queued' AND release_id='' AND experimental=0 AND version NOT IN ('0.2.0-rc.1','0.2.0-rc.2')"); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if !buildQueue.initialized[a.s.DB] {
|
if !buildQueue.initialized[a.s.DB] {
|
||||||
@@ -117,7 +120,7 @@ var errBuildRateLimited = errors.New("build limit: three new tasks per user per
|
|||||||
func (a *API) lookupActiveBuild(version string) (buildRecord, bool, error) {
|
func (a *API) lookupActiveBuild(version string) (buildRecord, bool, error) {
|
||||||
var b buildRecord
|
var b buildRecord
|
||||||
var finished, recipe, platform sql.NullString
|
var finished, recipe, platform sql.NullString
|
||||||
err := a.s.DB.QueryRow("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at,experimental FROM image_builds WHERE version=? AND recipe_rev=? AND platform=? AND status IN ('queued','running','validating','success')", version, imageRecipeRev, imagePlatform).Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished, &b.Experimental)
|
err := a.s.DB.QueryRow("SELECT id,version,status,logs,error,image_id,created_at,requested_by,recipe_rev,platform,finished_at,experimental FROM image_builds WHERE release_id='' AND version=? AND recipe_rev=? AND platform=? AND status IN ('queued','running','validating','success')", version, imageRecipeRev, imagePlatform).Scan(&b.ID, &b.Version, &b.Status, &b.Logs, &b.Error, &b.ImageID, &b.Created, &b.RequestedBy, &recipe, &platform, &finished, &b.Experimental)
|
||||||
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
|
b.RecipeRev, b.Platform, b.Finished = recipe.String, platform.String, finished.String
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return b, false, nil
|
return b, false, nil
|
||||||
@@ -280,7 +283,8 @@ func (a *API) startBuildWorkerLocked() {
|
|||||||
}
|
}
|
||||||
buildQueue.Lock()
|
buildQueue.Lock()
|
||||||
var b buildRecord
|
var b buildRecord
|
||||||
err := a.s.DB.QueryRow("SELECT id,version,experimental,requested_by FROM image_builds WHERE status='queued' ORDER BY created_at,id LIMIT 1").Scan(&b.ID, &b.Version, &b.Experimental, &b.RequestedBy)
|
row := a.s.DB.QueryRow("SELECT " + buildSummaryColumns + " FROM image_builds WHERE status='queued' ORDER BY created_at,id LIMIT 1")
|
||||||
|
err := scanBuildSummary(row, &b)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
_, err = a.s.DB.Exec("UPDATE image_builds SET status='running' WHERE id=?", b.ID)
|
_, err = a.s.DB.Exec("UPDATE image_builds SET status='running' WHERE id=?", b.ID)
|
||||||
}
|
}
|
||||||
@@ -345,7 +349,7 @@ func (a *API) runImageBuild(b buildRecord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
tarball, err := imageBuildContext()
|
tarball, err := imageBuildContext()
|
||||||
if !b.Experimental && !normalBuildAllowed(b.Version) {
|
if b.ReleaseID == "" && !b.Experimental && !normalBuildAllowed(b.Version) {
|
||||||
err = errBuildUnsupported
|
err = errBuildUnsupported
|
||||||
}
|
}
|
||||||
if b.Experimental {
|
if b.Experimental {
|
||||||
@@ -355,7 +359,10 @@ func (a *API) runImageBuild(b buildRecord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
var id string
|
var id string
|
||||||
if err == nil {
|
if b.ReleaseID != "" {
|
||||||
|
tarball, err = a.releaseBuildContext(ctx, b)
|
||||||
|
}
|
||||||
|
if err == nil && b.ReleaseID == "" {
|
||||||
id, err = a.localBuildImage(ctx, b.Version, "")
|
id, err = a.localBuildImage(ctx, b.Version, "")
|
||||||
}
|
}
|
||||||
if err == nil && id == "" {
|
if err == nil && id == "" {
|
||||||
@@ -365,11 +372,15 @@ func (a *API) runImageBuild(b buildRecord) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err == nil && id == "" {
|
if err == nil && id == "" {
|
||||||
|
if b.ReleaseID == "" {
|
||||||
id, err = a.docker.Build(ctx, tarball, b.Version, "dsh-web:"+b.Version+"-"+imageRecipeRev, appendLog)
|
id, err = a.docker.Build(ctx, tarball, b.Version, "dsh-web:"+b.Version+"-"+imageRecipeRev, appendLog)
|
||||||
|
} else {
|
||||||
|
id, err = a.docker.BuildRelease(ctx, tarball, b.Version, "dsh-web:release-"+b.SourceCommit, b.ReleaseID, b.SourceRepository, b.SourceCommit, appendLog)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if err == nil {
|
if err == nil {
|
||||||
var matches bool
|
var matches bool
|
||||||
matches, err = a.imageMatches(ctx, id, b.Version)
|
matches, err = a.buildImageMatches(ctx, id, b)
|
||||||
if err == nil && !matches {
|
if err == nil && !matches {
|
||||||
err = errors.New("image template or platform mismatch")
|
err = errors.New("image template or platform mismatch")
|
||||||
}
|
}
|
||||||
@@ -517,6 +528,9 @@ type imageRecord struct {
|
|||||||
UsageCount int `json:"usage_count"`
|
UsageCount int `json:"usage_count"`
|
||||||
}
|
}
|
||||||
type buildRecord struct {
|
type buildRecord struct {
|
||||||
|
ReleaseID string `json:"release_id,omitempty"`
|
||||||
|
SourceRepository string `json:"source_repository,omitempty"`
|
||||||
|
SourceCommit string `json:"source_commit,omitempty"`
|
||||||
Experimental bool `json:"experimental"`
|
Experimental bool `json:"experimental"`
|
||||||
ImageID string `json:"image_id,omitempty"`
|
ImageID string `json:"image_id,omitempty"`
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
@@ -721,6 +735,9 @@ func (a *API) localBuildImage(ctx context.Context, version, preferred string) (s
|
|||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
for _, im := range images {
|
for _, im := range images {
|
||||||
|
if preferred == "" && im.Labels["dsh-mgr.release"] != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if im.ID != "" && im.Labels["dsh-mgr.role"] == "runtime" && im.Labels["dsh-mgr.version"] == version && im.Labels["dsh-mgr.recipe"] == imageRecipeRev && (preferred == "" || im.ID == preferred) {
|
if im.ID != "" && im.Labels["dsh-mgr.role"] == "runtime" && im.Labels["dsh-mgr.version"] == version && im.Labels["dsh-mgr.recipe"] == imageRecipeRev && (preferred == "" || im.ID == preferred) {
|
||||||
ok, err := a.imageMatches(ctx, im.ID, version)
|
ok, err := a.imageMatches(ctx, im.ID, version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -240,7 +240,7 @@ func (a *API) insertBuildRequest(b buildRecord, user string) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if _, err = tx.Exec("INSERT INTO image_builds(id,version,status,created_at,requested_by,recipe_rev,platform,experimental) VALUES(?,?,?,?,?,?,?,?)", b.ID, b.Version, b.Status, b.Created, b.RequestedBy, b.RecipeRev, b.Platform, b.Experimental); err != nil {
|
if _, err = tx.Exec("INSERT INTO image_builds(id,version,status,created_at,requested_by,recipe_rev,platform,experimental,release_id,source_repository,source_commit) VALUES(?,?,?,?,?,?,?,?,?,?,?)", b.ID, b.Version, b.Status, b.Created, b.RequestedBy, b.RecipeRev, b.Platform, b.Experimental, b.ReleaseID, b.SourceRepository, b.SourceCommit); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err = tx.Exec("INSERT INTO image_build_requests(user_id,build_id,created_at) VALUES(?,?,?)", user, b.ID, b.Created); err != nil {
|
if _, err = tx.Exec("INSERT INTO image_build_requests(user_id,build_id,created_at) VALUES(?,?,?)", user, b.ID, b.Created); err != nil {
|
||||||
@@ -261,7 +261,7 @@ func (a *API) reconcileBuilds(parent context.Context, records []buildRecord) err
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if actual == nil || actual.ID != b.ImageID || actual.Config.Labels["dsh-mgr.role"] != "runtime" || actual.Config.Labels["dsh-mgr.version"] != b.Version || actual.Config.Labels["dsh-mgr.recipe"] != b.RecipeRev || actual.platform() != b.Platform {
|
if actual == nil || actual.ID != b.ImageID || actual.Config.Labels["dsh-mgr.role"] != "runtime" || actual.Config.Labels["dsh-mgr.version"] != b.Version || actual.Config.Labels["dsh-mgr.recipe"] != b.RecipeRev || actual.platform() != b.Platform || !releaseLabelsMatch(actual, *b) {
|
||||||
b.Status, b.Error = "missing", "image no longer available"
|
b.Status, b.Error = "missing", "image no longer available"
|
||||||
if _, err := a.s.DB.Exec("UPDATE image_builds SET status=?,error=? WHERE id=? AND status='success'", b.Status, b.Error, b.ID); err != nil {
|
if _, err := a.s.DB.Exec("UPDATE image_builds SET status=?,error=? WHERE id=? AND status='success'", b.Status, b.Error, b.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"git.misaka.ren/m1saka/dsh-web/internal/store"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var fetchRelease = readRelease
|
||||||
|
|
||||||
|
func (a *API) latestRelease(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u, _, err := a.user(r)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 401, "authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !u.Admin {
|
||||||
|
fail(w, 403, "administrator required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodGet {
|
||||||
|
fail(w, 405, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repository, branch, err := releaseSource()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, _, err := fetchRelease(r.Context(), repository, "refs/heads/"+branch)
|
||||||
|
if err != nil && !errors.Is(err, errReleaseIncompatible) {
|
||||||
|
fail(w, 502, sanitizeBuildLog(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
compatible := err == nil
|
||||||
|
reason := "trusted release supports current layout and platform"
|
||||||
|
if !compatible {
|
||||||
|
reason = errReleaseIncompatible.Error()
|
||||||
|
}
|
||||||
|
buildQueue.Lock()
|
||||||
|
err = a.ensureBuilds()
|
||||||
|
buildQueue.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var b buildRecord
|
||||||
|
err = scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE source_repository=? AND source_commit=? AND release_id=? AND platform=? ORDER BY created_at DESC,id DESC LIMIT 1", repository, info.Commit, info.ReleaseID, imagePlatform), &b)
|
||||||
|
if err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
fail(w, 500, "database error")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := map[string]any{"release_id": info.ReleaseID, "version": info.Version, "commit": info.Commit, "repository": repository, "branch": branch, "recipe_rev": strconv.Itoa(info.DeploymentSchema), "platforms": info.Platforms, "compatible": compatible, "reason": reason}
|
||||||
|
if err == nil {
|
||||||
|
result["build"] = b
|
||||||
|
}
|
||||||
|
writeJSON(w, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) releaseBuild(w http.ResponseWriter, r *http.Request) {
|
||||||
|
u, _, err := a.user(r)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 401, "authentication required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !u.Admin {
|
||||||
|
fail(w, 403, "administrator required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
fail(w, 405, "method not allowed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var in struct {
|
||||||
|
Commit string `json:"commit"`
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 4096))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if dec.Decode(&in) != nil || dec.Decode(new(any)) != io.EOF || !releaseCommit.MatchString(in.Commit) {
|
||||||
|
fail(w, 400, "fixed commit required")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
repository, branch, err := releaseSource()
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 500, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
info, _, err := fetchRelease(r.Context(), repository, "refs/heads/"+branch)
|
||||||
|
if err != nil {
|
||||||
|
fail(w, 502, sanitizeBuildLog(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if info.Commit != in.Commit {
|
||||||
|
fail(w, 409, "release changed; check the latest release again")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
b, err := a.enqueueRelease(u.ID, info)
|
||||||
|
if err != nil {
|
||||||
|
status := 500
|
||||||
|
if errors.Is(err, errBuildRateLimited) {
|
||||||
|
status = 429
|
||||||
|
}
|
||||||
|
fail(w, status, sanitizeBuildLog(err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
a.startBuildWorker()
|
||||||
|
w.WriteHeader(http.StatusAccepted)
|
||||||
|
writeJSON(w, b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) enqueueRelease(uid string, info releaseInfo) (buildRecord, error) {
|
||||||
|
buildQueue.Lock()
|
||||||
|
defer buildQueue.Unlock()
|
||||||
|
var b buildRecord
|
||||||
|
if err := a.ensureBuilds(); err != nil {
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
err := scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE source_repository=? AND source_commit=? AND release_id=? AND platform=? AND status IN ('queued','running','validating','success')", info.Repository, info.Commit, info.ReleaseID, imagePlatform), &b)
|
||||||
|
if err == nil {
|
||||||
|
if b.Status == "success" {
|
||||||
|
buildQueue.Unlock()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
matches, verifyErr := a.buildImageMatches(ctx, b.ImageID, b)
|
||||||
|
cancel()
|
||||||
|
buildQueue.Lock()
|
||||||
|
if verifyErr != nil {
|
||||||
|
return b, verifyErr
|
||||||
|
}
|
||||||
|
if !matches {
|
||||||
|
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='missing',error='image no longer available' WHERE id=? AND status='success'", b.ID); err != nil {
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
buildQueue.Unlock()
|
||||||
|
b, err = a.enqueueRelease(uid, info)
|
||||||
|
buildQueue.Lock()
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
if !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err = a.s.DB.QueryRow("SELECT count(*) FROM image_build_requests WHERE user_id=? AND created_at>?", uid, time.Now().Add(-time.Hour).UTC().Format(time.RFC3339Nano)).Scan(&count); err != nil {
|
||||||
|
return b, err
|
||||||
|
}
|
||||||
|
if count >= 3 {
|
||||||
|
return b, errBuildRateLimited
|
||||||
|
}
|
||||||
|
b = buildRecord{ID: store.ID(), Version: info.Version, Status: "queued", Created: time.Now().UTC().Format(time.RFC3339Nano), RequestedBy: uid, RecipeRev: imageRecipeRev, Platform: imagePlatform, ReleaseID: info.ReleaseID, SourceRepository: info.Repository, SourceCommit: info.Commit}
|
||||||
|
return b, a.insertBuildRequest(b, uid)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *API) releaseBuildContext(ctx context.Context, b buildRecord) (io.Reader, error) {
|
||||||
|
repository, _, err := releaseSource()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if repository != b.SourceRepository || !releaseCommit.MatchString(b.SourceCommit) {
|
||||||
|
return nil, errors.New("queued release source is no longer trusted")
|
||||||
|
}
|
||||||
|
u, err := a.s.UserByID(b.RequestedBy)
|
||||||
|
if err != nil || !u.Admin {
|
||||||
|
return nil, errors.New("release requester is not an administrator")
|
||||||
|
}
|
||||||
|
info, archive, err := fetchRelease(ctx, repository, b.SourceCommit)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info.Commit != b.SourceCommit || info.ReleaseID != b.ReleaseID || info.Version != b.Version {
|
||||||
|
return nil, errors.New("pinned release manifest mismatch")
|
||||||
|
}
|
||||||
|
return archive, nil
|
||||||
|
}
|
||||||
|
func releaseLabelsMatch(im *inspectedImage, b buildRecord) bool {
|
||||||
|
if b.ReleaseID == "" {
|
||||||
|
return im.Config.Labels["dsh-mgr.release"] == ""
|
||||||
|
}
|
||||||
|
return im.Config.Labels["dsh-mgr.release"] == b.ReleaseID && im.Config.Labels["dsh-mgr.source"] == b.SourceRepository && im.Config.Labels["dsh-mgr.commit"] == b.SourceCommit
|
||||||
|
}
|
||||||
|
func (a *API) buildImageMatches(ctx context.Context, id string, b buildRecord) (bool, error) {
|
||||||
|
matches, err := a.imageMatches(ctx, id, b.Version)
|
||||||
|
if err != nil || !matches {
|
||||||
|
return matches, err
|
||||||
|
}
|
||||||
|
im, err := a.inspectLocalImage(ctx, id)
|
||||||
|
if err != nil || im == nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
return releaseLabelsMatch(im, b), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/url"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type releaseManifest struct {
|
||||||
|
SchemaVersion int `json:"schema_version"`
|
||||||
|
ReleaseID string `json:"release_id"`
|
||||||
|
Version string `json:"version"`
|
||||||
|
DeploymentSchema int `json:"deployment_schema"`
|
||||||
|
Platforms []string `json:"platforms"`
|
||||||
|
}
|
||||||
|
type releaseInfo struct {
|
||||||
|
releaseManifest
|
||||||
|
Repository string `json:"source_repository"`
|
||||||
|
Commit string `json:"source_commit"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var errReleaseIncompatible = errors.New("release layout or platform is incompatible")
|
||||||
|
|
||||||
|
var releaseCommit = regexp.MustCompile(`^[0-9a-f]{40}$`)
|
||||||
|
var releaseID = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`)
|
||||||
|
|
||||||
|
const releaseLimit = 32 << 20
|
||||||
|
|
||||||
|
func releaseSource() (string, string, error) {
|
||||||
|
repository := os.Getenv("DSH_RELEASE_REPOSITORY")
|
||||||
|
if repository == "" {
|
||||||
|
repository = "https://git.misaka.ren/m1saka/dsh-web.git"
|
||||||
|
}
|
||||||
|
branch := os.Getenv("DSH_RELEASE_BRANCH")
|
||||||
|
if branch == "" {
|
||||||
|
branch = "dockerfile"
|
||||||
|
}
|
||||||
|
u, err := url.Parse(repository)
|
||||||
|
if err != nil || u.Scheme != "https" || u.Hostname() == "" || u.User != nil || u.RawQuery != "" || u.Fragment != "" || strings.ContainsAny(repository, "\r\n") || !regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._/-]*$`).MatchString(branch) || strings.Contains(branch, "..") {
|
||||||
|
return "", "", errors.New("invalid trusted release source configuration")
|
||||||
|
}
|
||||||
|
return repository, branch, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type boundedReleaseBuffer struct{ bytes.Buffer }
|
||||||
|
|
||||||
|
func (b *boundedReleaseBuffer) Write(p []byte) (int, error) {
|
||||||
|
if b.Len()+len(p) > releaseLimit {
|
||||||
|
return 0, errors.New("release exceeds size limit")
|
||||||
|
}
|
||||||
|
return b.Buffer.Write(p)
|
||||||
|
}
|
||||||
|
func gitReleaseCommand(ctx context.Context, dir string, args ...string) ([]byte, error) {
|
||||||
|
gitArgs := append([]string{"-c", "credential.helper=", "-c", "http.followRedirects=false", "-c", "core.hooksPath=/dev/null", "-c", "protocol.allow=never", "-c", "protocol.https.allow=always", "-c", "fetch.unpackLimit=1"}, args...)
|
||||||
|
// The inherited file-size limit also bounds Git's on-disk pack, not just stdout.
|
||||||
|
cmd := exec.CommandContext(ctx, "/bin/sh", append([]string{"-c", "ulimit -f 32768; exec git \"$@\"", "git"}, gitArgs...)...)
|
||||||
|
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||||
|
cmd.Cancel = func() error { return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) }
|
||||||
|
cmd.Dir = dir
|
||||||
|
cmd.Env = []string{"PATH=" + os.Getenv("PATH"), "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null", "GIT_TERMINAL_PROMPT=0", "GIT_LFS_SKIP_SMUDGE=1"}
|
||||||
|
cmd.WaitDelay = time.Second
|
||||||
|
var out boundedReleaseBuffer
|
||||||
|
cmd.Stdout = &out
|
||||||
|
if err := cmd.Run(); err != nil {
|
||||||
|
return nil, errors.New("trusted release Git operation failed")
|
||||||
|
}
|
||||||
|
return out.Bytes(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var releaseFetchSlot = make(chan struct{}, 1)
|
||||||
|
|
||||||
|
func readRelease(ctx context.Context, repository, ref string) (releaseInfo, io.Reader, error) {
|
||||||
|
var info releaseInfo
|
||||||
|
ctx, cancel := context.WithTimeout(ctx, 45*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
select {
|
||||||
|
case releaseFetchSlot <- struct{}{}:
|
||||||
|
defer func() { <-releaseFetchSlot }()
|
||||||
|
case <-ctx.Done():
|
||||||
|
return info, nil, ctx.Err()
|
||||||
|
}
|
||||||
|
dir, err := os.MkdirTemp("", "dsh-release-")
|
||||||
|
if err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(dir)
|
||||||
|
if _, err = gitReleaseCommand(ctx, dir, "init", "--quiet"); err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
if _, err = gitReleaseCommand(ctx, dir, "fetch", "--quiet", "--depth=1", "--no-tags", repository, ref); err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
commit, err := gitReleaseCommand(ctx, dir, "rev-parse", "FETCH_HEAD^{commit}")
|
||||||
|
if err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
info.Commit = strings.TrimSpace(string(commit))
|
||||||
|
info.Repository = repository
|
||||||
|
if !releaseCommit.MatchString(info.Commit) || (releaseCommit.MatchString(ref) && info.Commit != ref) {
|
||||||
|
return info, nil, errors.New("release commit mismatch")
|
||||||
|
}
|
||||||
|
archive, err := gitReleaseCommand(ctx, dir, "archive", "--format=tar", info.Commit, "--", "release.json", "image")
|
||||||
|
if err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
return parseReleaseArchive(info, archive)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseReleaseArchive(info releaseInfo, archive []byte) (releaseInfo, io.Reader, error) {
|
||||||
|
var manifest []byte
|
||||||
|
tr := tar.NewReader(bytes.NewReader(archive))
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
if h.Name == "release.json" && h.Typeflag == tar.TypeReg {
|
||||||
|
if h.Size > 16384 {
|
||||||
|
return info, nil, errors.New("release manifest too large")
|
||||||
|
}
|
||||||
|
manifest, err = io.ReadAll(tr)
|
||||||
|
if err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(manifest))
|
||||||
|
dec.DisallowUnknownFields()
|
||||||
|
if dec.Decode(&info.releaseManifest) != nil || dec.Decode(new(any)) != io.EOF {
|
||||||
|
return info, nil, errors.New("invalid release manifest")
|
||||||
|
}
|
||||||
|
m := info.releaseManifest
|
||||||
|
compatible := false
|
||||||
|
for _, p := range m.Platforms {
|
||||||
|
if p == imagePlatform {
|
||||||
|
compatible = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.SchemaVersion != 1 || !releaseID.MatchString(m.ReleaseID) || !validBuildVersion(m.Version) || m.DeploymentSchema < 1 || len(m.Platforms) == 0 {
|
||||||
|
return info, nil, errors.New("unsupported release manifest or platform")
|
||||||
|
}
|
||||||
|
if m.DeploymentSchema != 7 || !compatible {
|
||||||
|
return info, nil, errReleaseIncompatible
|
||||||
|
}
|
||||||
|
var out bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&out)
|
||||||
|
tr = tar.NewReader(bytes.NewReader(archive))
|
||||||
|
dockerfile := false
|
||||||
|
for {
|
||||||
|
h, err := tr.Next()
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
name := h.Name
|
||||||
|
if !strings.HasPrefix(name, "image/") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name = strings.TrimPrefix(name, "image/")
|
||||||
|
if name == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if path.IsAbs(name) || path.Clean(strings.TrimSuffix(name, "/")) != strings.TrimSuffix(name, "/") || name == ".." || strings.HasPrefix(name, "../") {
|
||||||
|
return info, nil, errors.New("unsafe release context path")
|
||||||
|
}
|
||||||
|
excluded := false
|
||||||
|
for _, part := range strings.Split(name, "/") {
|
||||||
|
if part == ".git" || part == ".env" || strings.HasPrefix(part, ".env.") || part == ".netrc" || part == ".npmrc" || part == ".ssh" || part == ".git-credentials" {
|
||||||
|
excluded = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if excluded {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if h.Typeflag != tar.TypeReg && h.Typeflag != tar.TypeDir {
|
||||||
|
return info, nil, errors.New("release context links and special files are not supported")
|
||||||
|
}
|
||||||
|
h.Name = name
|
||||||
|
h.PAXRecords = nil
|
||||||
|
h.Uname = ""
|
||||||
|
h.Gname = ""
|
||||||
|
h.Uid = 0
|
||||||
|
h.Gid = 0
|
||||||
|
if name == "Dockerfile" && h.Typeflag == tar.TypeReg {
|
||||||
|
dockerfile = true
|
||||||
|
}
|
||||||
|
if err = tw.WriteHeader(h); err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
if _, err = io.Copy(tw, tr); err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !dockerfile {
|
||||||
|
return info, nil, errors.New("release context Dockerfile missing")
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
return info, nil, err
|
||||||
|
}
|
||||||
|
return info, bytes.NewReader(out.Bytes()), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,235 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func testReleaseArchive(t *testing.T, m string, files map[string]string) []byte {
|
||||||
|
t.Helper()
|
||||||
|
var b bytes.Buffer
|
||||||
|
tw := tar.NewWriter(&b)
|
||||||
|
files["release.json"] = m
|
||||||
|
for name, data := range files {
|
||||||
|
if err := tw.WriteHeader(&tar.Header{Name: name, Size: int64(len(data)), Mode: 0644}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := tw.Write([]byte(data)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tw.Close(); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return b.Bytes()
|
||||||
|
}
|
||||||
|
func releaseFixture() releaseInfo {
|
||||||
|
return releaseInfo{releaseManifest: releaseManifest{SchemaVersion: 1, ReleaseID: "runtime-7.1", Version: "0.2.0-rc.3", DeploymentSchema: 7, Platforms: []string{imagePlatform}}, Repository: "https://git.misaka.ren/m1saka/dsh-web.git", Commit: strings.Repeat("a", 40)}
|
||||||
|
}
|
||||||
|
func TestReleaseManifestAndContext(t *testing.T) {
|
||||||
|
info := releaseFixture()
|
||||||
|
data, _ := json.Marshal(info.releaseManifest)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, manifest string
|
||||||
|
files map[string]string
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"valid", string(data), map[string]string{"image/Dockerfile": "FROM scratch", "image/nested/helper": "complete", "image/.git/config": "secret", "image/.env": "secret"}, false},
|
||||||
|
{"malformed", "{", map[string]string{"image/Dockerfile": "FROM scratch"}, true},
|
||||||
|
{"unknown", strings.Replace(string(data), "schema_version", "unknown", 1), map[string]string{"image/Dockerfile": "FROM scratch"}, true},
|
||||||
|
{"incompatible", strings.Replace(string(data), "deployment_schema\":7", "deployment_schema\":8", 1), map[string]string{"image/Dockerfile": "FROM scratch"}, true},
|
||||||
|
{"missing Dockerfile", string(data), map[string]string{"image/helper": "x"}, true},
|
||||||
|
{"unsafe path", string(data), map[string]string{"image/Dockerfile": "FROM scratch", "image/../escape": "x"}, true},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
_, r, err := parseReleaseArchive(info, testReleaseArchive(t, tc.manifest, tc.files))
|
||||||
|
if (err != nil) != tc.wantErr {
|
||||||
|
t.Fatalf("err=%v", err)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
tr := tar.NewReader(r)
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for {
|
||||||
|
h, e := tr.Next()
|
||||||
|
if e == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if e != nil {
|
||||||
|
t.Fatal(e)
|
||||||
|
}
|
||||||
|
seen[h.Name] = true
|
||||||
|
}
|
||||||
|
if !seen["nested/helper"] || seen[".env"] || seen[".git/config"] {
|
||||||
|
t.Fatal(seen)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestReleaseQueueIdentityRecoveryAndFailure(t *testing.T) {
|
||||||
|
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) })
|
||||||
|
info := releaseFixture()
|
||||||
|
b, err := a.enqueueRelease("u", info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
again, err := a.enqueueRelease("u", info)
|
||||||
|
if err != nil || again.ID != b.ID {
|
||||||
|
t.Fatalf("repeat %v %v", again, err)
|
||||||
|
}
|
||||||
|
info.Commit = strings.Repeat("b", 40)
|
||||||
|
info.ReleaseID = "runtime-7.2"
|
||||||
|
next, err := a.enqueueRelease("u", info)
|
||||||
|
if err != nil || next.ID == b.ID || next.Version != b.Version {
|
||||||
|
t.Fatalf("next %v %v", next, err)
|
||||||
|
}
|
||||||
|
buildQueue.Lock()
|
||||||
|
delete(buildQueue.initialized, a.s.DB)
|
||||||
|
err = a.ensureBuilds()
|
||||||
|
buildQueue.Unlock()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var recovered buildRecord
|
||||||
|
if err = scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE id=?", next.ID), &recovered); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if recovered.Status != "queued" || recovered.SourceCommit != info.Commit {
|
||||||
|
t.Fatal(recovered)
|
||||||
|
}
|
||||||
|
if _, err = a.s.DB.Exec("UPDATE image_builds SET status='failed' WHERE id=?", next.ID); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
retry, err := a.enqueueRelease("u", info)
|
||||||
|
if err != nil || retry.ID == next.ID {
|
||||||
|
t.Fatalf("retry %v %v", retry, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestReleaseEndpointsAuthorizationFailureAndMismatch(t *testing.T) {
|
||||||
|
a, sid := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(404) })
|
||||||
|
if w := imageRequest(a.latestRelease, sid, "GET", ""); w.Code != 403 {
|
||||||
|
t.Fatal(w.Code)
|
||||||
|
}
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='u'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
old := fetchRelease
|
||||||
|
t.Cleanup(func() { fetchRelease = old })
|
||||||
|
fetchRelease = func(context.Context, string, string) (releaseInfo, io.Reader, error) {
|
||||||
|
return releaseInfo{}, nil, errors.New("network failure")
|
||||||
|
}
|
||||||
|
if w := imageRequest(a.latestRelease, sid, "GET", ""); w.Code != 502 {
|
||||||
|
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
fetchRelease = func(context.Context, string, string) (releaseInfo, io.Reader, error) {
|
||||||
|
return releaseFixture(), nil, nil
|
||||||
|
}
|
||||||
|
if w := imageRequest(a.releaseBuild, sid, "POST", `{"commit":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}`); w.Code != 409 {
|
||||||
|
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
fetchRelease = func(context.Context, string, string) (releaseInfo, io.Reader, error) {
|
||||||
|
return releaseFixture(), nil, errReleaseIncompatible
|
||||||
|
}
|
||||||
|
if w := imageRequest(a.latestRelease, sid, "GET", ""); w.Code != 200 || !strings.Contains(w.Body.String(), `"compatible":false`) {
|
||||||
|
t.Fatalf("%d %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
func TestReleaseGitEnvironmentAndFailure(t *testing.T) {
|
||||||
|
out, err := gitReleaseCommand(context.Background(), t.TempDir(), "--version")
|
||||||
|
if err != nil || !strings.HasPrefix(string(out), "git version") {
|
||||||
|
t.Fatalf("%q %v", out, err)
|
||||||
|
}
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
cancel()
|
||||||
|
if _, err = gitReleaseCommand(ctx, t.TempDir(), "--version"); err == nil {
|
||||||
|
t.Fatal("cancel ignored")
|
||||||
|
}
|
||||||
|
for _, repository := range []string{"http://example.test/repo", "https://user:[email protected]/repo", "https://example.test/repo?token=secret"} {
|
||||||
|
t.Setenv("DSH_RELEASE_REPOSITORY", repository)
|
||||||
|
if _, _, err = releaseSource(); err == nil {
|
||||||
|
t.Fatal("accepted unsafe source")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPinnedReleaseWorkerAdmissionAndBlock(t *testing.T) {
|
||||||
|
info := releaseFixture()
|
||||||
|
labels := map[string]string{"dsh-mgr.role": "runtime", "dsh-mgr.version": info.Version, "dsh-mgr.recipe": "7", "dsh-mgr.release": info.ReleaseID, "dsh-mgr.source": info.Repository, "dsh-mgr.commit": info.Commit}
|
||||||
|
a, _ := imageTestAPI(t, func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/v1.47/build":
|
||||||
|
var got map[string]string
|
||||||
|
if err := json.Unmarshal([]byte(r.URL.Query().Get("labels")), &got); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
if got["dsh-mgr.commit"] != info.Commit || r.URL.Query().Get("t") != "dsh-web:release-"+info.Commit {
|
||||||
|
t.Error("missing provenance", r.URL)
|
||||||
|
}
|
||||||
|
data, _ := io.ReadAll(r.Body)
|
||||||
|
if !bytes.Contains(data, []byte("pinned context")) {
|
||||||
|
t.Error("wrong build context")
|
||||||
|
}
|
||||||
|
io.WriteString(w, `{"aux":{"ID":"sha256:release"}}`)
|
||||||
|
case "/v1.47/images/sha256:release/json":
|
||||||
|
parts := strings.Split(imagePlatform, "/")
|
||||||
|
writeJSON(w, map[string]any{"Id": "sha256:release", "Os": parts[0], "Architecture": parts[1], "Config": map[string]any{"Labels": labels}})
|
||||||
|
case "/v1.47/containers/create":
|
||||||
|
io.WriteString(w, `{"Id":"check"}`)
|
||||||
|
case "/v1.47/containers/check/start", "/v1.47/exec/probe/start", "/v1.47/containers/check":
|
||||||
|
w.WriteHeader(204)
|
||||||
|
case "/v1.47/containers/check/exec":
|
||||||
|
io.WriteString(w, `{"Id":"probe"}`)
|
||||||
|
case "/v1.47/exec/probe/json":
|
||||||
|
io.WriteString(w, `{"Running":false,"ExitCode":0}`)
|
||||||
|
case "/v1.47/containers/check/json":
|
||||||
|
io.WriteString(w, `{"State":{"Running":true}}`)
|
||||||
|
default:
|
||||||
|
t.Errorf("unexpected %s", r.URL)
|
||||||
|
w.WriteHeader(500)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if _, err := a.s.DB.Exec("UPDATE users SET admin=1 WHERE id='u'"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
npmBuildClient = &http.Client{Transport: imageRoundTrip(func(r *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{StatusCode: 200, Body: io.NopCloser(strings.NewReader(`{"version":"0.2.0-rc.3"}`)), Header: make(http.Header)}, nil
|
||||||
|
})}
|
||||||
|
old := fetchRelease
|
||||||
|
t.Cleanup(func() { fetchRelease = old })
|
||||||
|
fetchRelease = func(ctx context.Context, repository, ref string) (releaseInfo, io.Reader, error) {
|
||||||
|
if repository != info.Repository || ref != info.Commit {
|
||||||
|
t.Error("not pinned", repository, ref)
|
||||||
|
}
|
||||||
|
return info, strings.NewReader("pinned context"), nil
|
||||||
|
}
|
||||||
|
b, err := a.enqueueRelease("u", info)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
a.runImageBuild(b)
|
||||||
|
if err = scanBuildSummary(a.s.DB.QueryRow("SELECT "+buildSummaryColumns+" FROM image_builds WHERE id=?", b.ID), &b); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if b.Status != "success" {
|
||||||
|
t.Fatalf("%+v", b)
|
||||||
|
}
|
||||||
|
im, err := a.evaluateImage(context.Background(), b.ImageID)
|
||||||
|
if err != nil || !im.Selectable {
|
||||||
|
t.Fatalf("%+v %v", im, err)
|
||||||
|
}
|
||||||
|
if _, err = a.s.DB.Exec("INSERT INTO image_admissions(image_id,version,recipe_rev,platform,state,evidence,admitted_by,admitted_at) VALUES(?,?,?,?,?,?,?,?)", b.ImageID, b.Version, "7", imagePlatform, "blocked", "explicit block", "u", "now"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
im, err = a.evaluateImage(context.Background(), b.ImageID)
|
||||||
|
if err != nil || im.Selectable || im.Admission != "blocked" {
|
||||||
|
t.Fatalf("%+v %v", im, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -277,7 +277,11 @@ func (a *API) persistBuildResult(b buildRecord, status, logs, detail, image, fin
|
|||||||
}
|
}
|
||||||
defer tx.Rollback()
|
defer tx.Rollback()
|
||||||
if status == "success" && image != "" {
|
if status == "success" && image != "" {
|
||||||
_, err = tx.Exec("INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at", image, b.Version, imageRecipeRev, imagePlatform, "dsh-web:"+b.Version+"-"+imageRecipeRev, "unverified", finished)
|
tag := "dsh-web:" + b.Version + "-" + imageRecipeRev
|
||||||
|
if b.ReleaseID != "" {
|
||||||
|
tag = "dsh-web:release-" + b.SourceCommit
|
||||||
|
}
|
||||||
|
_, err = tx.Exec("INSERT INTO images(image_id,version,recipe_rev,platform,tag,status,built_at) VALUES(?,?,?,?,?,?,?) ON CONFLICT(image_id) DO UPDATE SET version=excluded.version,recipe_rev=excluded.recipe_rev,platform=excluded.platform,tag=excluded.tag,status=excluded.status,built_at=excluded.built_at", image, b.Version, imageRecipeRev, imagePlatform, tag, "unverified", finished)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -190,11 +190,23 @@ func (c *Client) Logs(ctx context.Context, id string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (c *Client) Build(ctx context.Context, tar io.Reader, version, tag string, onLog func(string)) (string, error) {
|
func (c *Client) Build(ctx context.Context, tar io.Reader, version, tag string, onLog func(string)) (string, error) {
|
||||||
|
return c.build(ctx, tar, version, tag, nil, onLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) BuildRelease(ctx context.Context, tar io.Reader, version, tag, releaseID, repository, commit string, onLog func(string)) (string, error) {
|
||||||
|
return c.build(ctx, tar, version, tag, map[string]string{"dsh-mgr.release": releaseID, "dsh-mgr.source": repository, "dsh-mgr.commit": commit}, onLog)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Client) build(ctx context.Context, tar io.Reader, version, tag string, labels map[string]string, onLog func(string)) (string, error) {
|
||||||
args, err := json.Marshal(map[string]string{"DSH_VERSION": version})
|
args, err := json.Marshal(map[string]string{"DSH_VERSION": version})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
query := url.Values{"t": {tag}, "buildargs": {string(args)}, "rm": {"1"}}
|
query := url.Values{"t": {tag}, "buildargs": {string(args)}, "rm": {"1"}}
|
||||||
|
if labels != nil {
|
||||||
|
data, _ := json.Marshal(labels)
|
||||||
|
query.Set("labels", string(data))
|
||||||
|
}
|
||||||
resp, err := c.Stream(ctx, http.MethodPost, "/build?"+query.Encode(), tar, "application/x-tar")
|
resp, err := c.Stream(ctx, http.MethodPost, "/build?"+query.Encode(), tar, "application/x-tar")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"release_id": "runtime-7.1",
|
||||||
|
"version": "0.2.0-rc.2",
|
||||||
|
"deployment_schema": 7,
|
||||||
|
"platforms": ["linux/amd64"]
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ vi.mock('./ui', async original => ({
|
|||||||
}));
|
}));
|
||||||
import { api, type Compatibility } from './api';
|
import { api, type Compatibility } from './api';
|
||||||
import { Images } from './Images';
|
import { Images } from './Images';
|
||||||
|
import { ReleasePanel } from './ReleasePanel';
|
||||||
import { CreateInstance } from './CreateInstance';
|
import { CreateInstance } from './CreateInstance';
|
||||||
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
|
vi.mock('./ProfileEditor', () => ({ProfileEditor: () => null}));
|
||||||
import { Instances } from './Instances';
|
import { Instances } from './Instances';
|
||||||
@@ -26,6 +27,8 @@ beforeEach(() => {state.slots = []; state.cursor = 0; vi.stubGlobal('window', {c
|
|||||||
afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();});
|
afterEach(() => {vi.restoreAllMocks(); vi.unstubAllGlobals();});
|
||||||
it('hides experimental and admission controls from ordinary users', () => {
|
it('hides experimental and admission controls from ordinary users', () => {
|
||||||
const tree = render();
|
const tree = render();
|
||||||
|
expect(nodes(tree, n => n.type === ReleasePanel)).toHaveLength(0);
|
||||||
|
expect(nodes(render(true), n => n.type === ReleasePanel)).toHaveLength(1);
|
||||||
expect(nodes(tree, n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0);
|
expect(nodes(tree, n => n.type === 'input' && n.props.type === 'checkbox')).toHaveLength(0);
|
||||||
expect(nodes(tree, n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(0);
|
expect(nodes(tree, n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(0);
|
||||||
expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(2);
|
expect(nodes(render(true), n => n.type === 'button' && n.props.children === '准入管理')).toHaveLength(2);
|
||||||
|
|||||||
+4
-3
@@ -1,6 +1,7 @@
|
|||||||
import { useState, type FormEvent } from 'react';
|
import { useState, type FormEvent } from 'react';
|
||||||
import { api, normalBuildAllowed, type AdmissionState, type Build } from './api';
|
import { api, normalBuildAllowed, type AdmissionState, type Build } from './api';
|
||||||
import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui';
|
import { Panel, ResourceState, Empty, Status, date, useAction, useResource } from './ui';
|
||||||
|
import { BuildProvenance, ReleasePanel } from './ReleasePanel';
|
||||||
export const activeBuild = (build: Build) => ['queued', 'running', 'validating'].includes(build.status);
|
export const activeBuild = (build: Build) => ['queued', 'running', 'validating'].includes(build.status);
|
||||||
function BuildLogs({id, close}: {id: string; close: () => void}) {
|
function BuildLogs({id, close}: {id: string; close: () => void}) {
|
||||||
const logs = useResource(() => api.build(id), id, 5000, activeBuild);
|
const logs = useResource(() => api.build(id), id, 5000, activeBuild);
|
||||||
@@ -9,7 +10,7 @@ function BuildLogs({id, close}: {id: string; close: () => void}) {
|
|||||||
<button onClick={close}>关闭日志</button>
|
<button onClick={close}>关闭日志</button>
|
||||||
<ResourceState resource={logs}/>{logs.data && <>
|
<ResourceState resource={logs}/>{logs.data && <>
|
||||||
<p>{logs.data.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}{logs.data.version} · <Status value={logs.data.status}/>
|
<p>{logs.data.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}{logs.data.version} · <Status value={logs.data.status}/>
|
||||||
</p>{logs.data.error && <p className="alert">{logs.data.error}</p>}<pre className="logs">{logs.data.logs || '任务尚未输出日志'}</pre>
|
</p><BuildProvenance build={logs.data}/>{logs.data.error && <p className="alert">{logs.data.error}</p>}<pre className="logs">{logs.data.logs || '任务尚未输出日志'}</pre>
|
||||||
</>}</div>;
|
</>}</div>;
|
||||||
}
|
}
|
||||||
function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise<void>}) {
|
function Admission({imageId, refresh}: {imageId:string; refresh:() => Promise<void>}) {
|
||||||
@@ -42,7 +43,7 @@ export function Images({admin = false}: {admin?:boolean}) {
|
|||||||
function build(e: FormEvent) {e.preventDefault(); void action.run(async () => {if (!(admin && experimental) && !normalBuildAllowed(await api.compatibility(), version)) throw new Error('此版本不支持普通构建,请管理员选择实验构建;实验构建不会自动准入。'); await api.startBuild(version, admin && experimental); if (page === 0) await builds.refresh(); else setPage(0);}, '构建任务已提交,请查看任务的实际状态。', (experimental ? '实验构建不会自动准入或用于创建。确认实验构建精确版本 ' : '确认在本机手动构建精确版本 ') + version + '?构建会消耗磁盘、网络与 CPU,不会自动升级实例。');}
|
function build(e: FormEvent) {e.preventDefault(); void action.run(async () => {if (!(admin && experimental) && !normalBuildAllowed(await api.compatibility(), version)) throw new Error('此版本不支持普通构建,请管理员选择实验构建;实验构建不会自动准入。'); await api.startBuild(version, admin && experimental); if (page === 0) await builds.refresh(); else setPage(0);}, '构建任务已提交,请查看任务的实际状态。', (experimental ? '实验构建不会自动准入或用于创建。确认实验构建精确版本 ' : '确认在本机手动构建精确版本 ') + version + '?构建会消耗磁盘、网络与 CPU,不会自动升级实例。');}
|
||||||
const rows = builds.data?.slice(0, 20);
|
const rows = builds.data?.slice(0, 20);
|
||||||
function changePage(next: number) { setSelected(undefined); setPage(next); }
|
function changePage(next: number) { setSelected(undefined); setPage(next); }
|
||||||
return <>{action.feedback}<Panel title="查询与手动构建">
|
return <>{action.feedback}{admin && <ReleasePanel onBuilt={async () => {if (page === 0) await builds.refresh(); else changePage(0);}}/>}<Panel title="查询与手动构建">
|
||||||
<ResourceState resource={compatibility}/>{version && compatibility.data && !normalBuildAllowed(compatibility.data, version) && <p className="alert">此版本不支持普通构建。仅管理员可实验构建,不会自动准入。</p>}<div className="split">
|
<ResourceState resource={compatibility}/>{version && compatibility.data && !normalBuildAllowed(compatibility.data, version) && <p className="alert">此版本不支持普通构建。仅管理员可实验构建,不会自动准入。</p>}<div className="split">
|
||||||
<div>
|
<div>
|
||||||
<p className="muted">从 npm 查询 dist-tags.latest,仅查询,不自动构建。</p>
|
<p className="muted">从 npm 查询 dist-tags.latest,仅查询,不自动构建。</p>
|
||||||
@@ -104,7 +105,7 @@ export function Images({admin = false}: {admin?:boolean}) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>{rows!.map(b => <tr key={b.id}>
|
<tbody>{rows!.map(b => <tr key={b.id}>
|
||||||
<td className="mono">{b.id}</td>
|
<td className="mono">{b.id}</td>
|
||||||
<td>{b.version}{b.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}</td>
|
<td>{b.version}{b.experimental && <strong className="alert">实验构建 · 不自动准入</strong>}<BuildProvenance build={b}/></td>
|
||||||
<td>
|
<td>
|
||||||
<Status value={b.status}/>
|
<Status value={b.status}/>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
const hooks = vi.hoisted(() => ({slots: [] as any[], cursor: 0, effects: [] as (() => void)[]}));
|
||||||
|
vi.mock('react', async original => ({
|
||||||
|
...await original<typeof import('react')>(),
|
||||||
|
useRef(value: unknown) {const i = hooks.cursor++; return hooks.slots[i] ??= {current:value};},
|
||||||
|
useState(value: unknown) {const i = hooks.cursor++; if (!(i in hooks.slots)) hooks.slots[i] = value; return [hooks.slots[i], (v: unknown) => {hooks.slots[i] = v;}];},
|
||||||
|
useCallback(fn: unknown, deps: unknown[]) {
|
||||||
|
const i = hooks.cursor++, previous = hooks.slots[i];
|
||||||
|
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.slots[i] = {deps, fn};
|
||||||
|
return hooks.slots[i].fn;
|
||||||
|
},
|
||||||
|
useEffect(fn: () => (() => void) | undefined, deps: unknown[]) {
|
||||||
|
const i = hooks.cursor++, previous = hooks.slots[i];
|
||||||
|
if (!previous || deps.some((v, n) => v !== previous.deps[n])) hooks.effects.push(() => {previous?.cleanup?.(); hooks.slots[i] = {deps, cleanup:fn()};});
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
import { api, ApiError, createApi, type Release } from './api';
|
||||||
|
import { BuildProvenance, ReleasePanel } from './ReleasePanel';
|
||||||
|
import { ResourceState } from './ui';
|
||||||
|
const release: Release = {release_id:'release-1', version:'1.2.3', commit:'abc123', repository:'owner/repo', branch:'main', recipe_rev:'7', platforms:['linux/amd64'], compatible:true, reason:'supported'};
|
||||||
|
const built = {id:'build-1', version:'1.2.3', status:'queued', created_at:''};
|
||||||
|
const onBuilt = vi.fn(async () => {});
|
||||||
|
function nodes(root: any, predicate: (node: any) => boolean): any[] {
|
||||||
|
if (Array.isArray(root)) return root.flatMap(child => nodes(child, predicate));
|
||||||
|
if (!root || typeof root !== 'object') return [];
|
||||||
|
return [...(predicate(root) ? [root] : []), ...nodes(root.props?.children, predicate)];
|
||||||
|
}
|
||||||
|
function render() {hooks.cursor = 0; const tree = ReleasePanel({onBuilt}); hooks.effects.splice(0).forEach(effect => effect()); return tree;}
|
||||||
|
const button = () => nodes(render(), n => n.type === 'button')[0];
|
||||||
|
const settle = () => vi.advanceTimersByTimeAsync(0);
|
||||||
|
beforeEach(() => {
|
||||||
|
hooks.slots = []; hooks.cursor = 0; hooks.effects = []; onBuilt.mockClear();
|
||||||
|
vi.useFakeTimers(); vi.stubGlobal('window', {setInterval, clearInterval, confirm:vi.fn(() => false)});
|
||||||
|
vi.spyOn(api, 'latestRelease').mockResolvedValue({...release});
|
||||||
|
vi.spyOn(api, 'buildRelease').mockResolvedValue(built);
|
||||||
|
vi.spyOn(api, 'build').mockResolvedValue(built);
|
||||||
|
});
|
||||||
|
afterEach(() => {hooks.slots.forEach(s => s?.cleanup?.()); vi.restoreAllMocks(); vi.useRealTimers(); vi.unstubAllGlobals();});
|
||||||
|
it('checks on mount and every five minutes, supports manual refresh, and never automatically builds', async () => {
|
||||||
|
render(); await settle(); expect(api.latestRelease).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(299_999); expect(api.latestRelease).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(1); expect(api.latestRelease).toHaveBeenCalledTimes(2);
|
||||||
|
await render().props.action.props.onClick(); expect(api.latestRelease).toHaveBeenCalledTimes(3);
|
||||||
|
expect(api.buildRelease).not.toHaveBeenCalled();
|
||||||
|
hooks.slots.forEach(s => s?.cleanup?.());
|
||||||
|
await vi.advanceTimersByTimeAsync(300_000); expect(api.latestRelease).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
it('cancellation sends no mutation; confirmed duplicate clicks submit the exact displayed commit once', async () => {
|
||||||
|
render(); await settle(); await button().props.onClick();
|
||||||
|
expect(api.buildRelease).not.toHaveBeenCalled();
|
||||||
|
expect(window.confirm).toHaveBeenCalledWith(expect.stringContaining(release.commit));
|
||||||
|
vi.mocked(window.confirm).mockReturnValue(true);
|
||||||
|
const submit = button().props.onClick; await Promise.all([submit(), submit()]);
|
||||||
|
expect(api.buildRelease).toHaveBeenCalledExactlyOnceWith(release.commit);
|
||||||
|
expect(onBuilt).toHaveBeenCalledOnce();
|
||||||
|
expect(button().props.disabled).toBe(true);
|
||||||
|
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build).toEqual(built);
|
||||||
|
});
|
||||||
|
it.each(['succeeded', 'failed'])('polls the accepted task to %s when latest omits its build', async status => {
|
||||||
|
render(); await settle(); vi.mocked(window.confirm).mockReturnValue(true);
|
||||||
|
await button().props.onClick(); render(); await settle();
|
||||||
|
expect(api.build).toHaveBeenCalledExactlyOnceWith(built.id);
|
||||||
|
expect(button().props.disabled).toBe(true);
|
||||||
|
vi.mocked(api.build).mockResolvedValue({...built, status, error:status === 'failed' ? 'retry failed' : undefined});
|
||||||
|
await vi.advanceTimersByTimeAsync(4999); expect(api.build).toHaveBeenCalledTimes(1);
|
||||||
|
await vi.advanceTimersByTimeAsync(1);
|
||||||
|
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.status).toBe(status);
|
||||||
|
expect(button().props.disabled).toBe(false);
|
||||||
|
if (status === 'failed') expect(JSON.stringify(render())).toContain('retry failed');
|
||||||
|
await vi.advanceTimersByTimeAsync(10_000); expect(api.build).toHaveBeenCalledTimes(2);
|
||||||
|
expect(api.latestRelease).toHaveBeenCalledTimes(2);
|
||||||
|
expect(api.buildRelease).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('prefers a new retry over an old failed build and ignores earlier same-commit task data', async () => {
|
||||||
|
const old = {...built, id:'old-build', status:'failed', error:'old failure'};
|
||||||
|
vi.mocked(api.latestRelease).mockResolvedValue({...release, build:old});
|
||||||
|
vi.mocked(api.build).mockResolvedValue(old);
|
||||||
|
render(); await settle(); render(); await settle();
|
||||||
|
expect(button().props.disabled).toBe(false);
|
||||||
|
vi.mocked(window.confirm).mockReturnValue(true);
|
||||||
|
await button().props.onClick();
|
||||||
|
vi.mocked(api.build).mockResolvedValue({...built, status:'running'});
|
||||||
|
expect(button().props.disabled).toBe(true);
|
||||||
|
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.id).toBe(built.id);
|
||||||
|
await button().props.onClick(); expect(api.buildRelease).toHaveBeenCalledTimes(1);
|
||||||
|
await settle(); expect(api.build).toHaveBeenLastCalledWith(built.id);
|
||||||
|
expect(nodes(render(), n => n.props?.role === 'alert')).toHaveLength(0);
|
||||||
|
vi.mocked(api.latestRelease).mockResolvedValue({...release, build:built});
|
||||||
|
await render().props.action.props.onClick();
|
||||||
|
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.status).toBe('running');
|
||||||
|
vi.mocked(api.build).mockResolvedValue({...built, status:'failed', error:'new failure'});
|
||||||
|
await vi.advanceTimersByTimeAsync(5000);
|
||||||
|
expect(button().props.disabled).toBe(false);
|
||||||
|
expect(JSON.stringify(render())).toContain('new failure');
|
||||||
|
vi.mocked(api.buildRelease).mockResolvedValue({...built, id:'build-2'});
|
||||||
|
await button().props.onClick();
|
||||||
|
expect(button().props.disabled).toBe(true);
|
||||||
|
expect(nodes(render(), n => n.type === BuildProvenance)[0].props.build.id).toBe('build-2');
|
||||||
|
});
|
||||||
|
it('blocks incompatible releases and shows the server reason', async () => {
|
||||||
|
vi.mocked(api.latestRelease).mockResolvedValue({...release, compatible:false, reason:'unsupported recipe'});
|
||||||
|
render(); await settle(); expect(button().props.disabled).toBe(true);
|
||||||
|
expect(JSON.stringify(render())).toContain('unsupported recipe');
|
||||||
|
await button().props.onClick(); expect(window.confirm).not.toHaveBeenCalled();
|
||||||
|
expect(api.buildRelease).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('blocks stale data after refresh failure and exposes retry feedback', async () => {
|
||||||
|
render(); await settle(); vi.mocked(api.latestRelease).mockRejectedValue(new Error('offline'));
|
||||||
|
await render().props.action.props.onClick();
|
||||||
|
expect(button().props.disabled).toBe(true);
|
||||||
|
expect(nodes(render(), n => n.type === ResourceState)[0].props.resource.error).toBe('offline');
|
||||||
|
});
|
||||||
|
it('409 refreshes the branch without retrying a build, and requires new confirmation', async () => {
|
||||||
|
render(); await settle(); vi.mocked(window.confirm).mockReturnValue(true);
|
||||||
|
vi.mocked(api.buildRelease).mockRejectedValueOnce(new ApiError(409, 'changed'));
|
||||||
|
vi.mocked(api.latestRelease).mockResolvedValue({...release, commit:'def456'});
|
||||||
|
await button().props.onClick();
|
||||||
|
expect(api.buildRelease).toHaveBeenCalledTimes(1);
|
||||||
|
expect(JSON.stringify(render())).toContain('再次明确确认');
|
||||||
|
vi.mocked(window.confirm).mockReturnValue(false); await button().props.onClick();
|
||||||
|
expect(window.confirm).toHaveBeenLastCalledWith(expect.stringContaining('def456'));
|
||||||
|
expect(api.buildRelease).toHaveBeenCalledTimes(1);
|
||||||
|
vi.mocked(window.confirm).mockReturnValue(true); await button().props.onClick();
|
||||||
|
expect(api.buildRelease).toHaveBeenLastCalledWith('def456');
|
||||||
|
});
|
||||||
|
it('shows build errors and optional provenance without affecting legacy builds', async () => {
|
||||||
|
const build = {...built, status:'failed', error:'build failed', release_id:release.release_id, source_commit:release.commit, source_repository:release.repository};
|
||||||
|
vi.mocked(api.latestRelease).mockResolvedValue({...release, build});
|
||||||
|
render(); await settle(); expect(JSON.stringify(render())).toContain('build failed');
|
||||||
|
const provenance = JSON.stringify(BuildProvenance({build}));
|
||||||
|
for (const value of [release.release_id, release.commit, release.repository]) expect(provenance).toContain(value);
|
||||||
|
expect(BuildProvenance({build:built})).toBeNull();
|
||||||
|
});
|
||||||
|
it('surfaces build request failure without refreshing or upgrading', async () => {
|
||||||
|
render(); await settle(); vi.mocked(window.confirm).mockReturnValue(true);
|
||||||
|
vi.mocked(api.buildRelease).mockRejectedValue(new ApiError(500, 'builder unavailable'));
|
||||||
|
await button().props.onClick(); expect(JSON.stringify(render())).toContain('builder unavailable');
|
||||||
|
expect(onBuilt).not.toHaveBeenCalled(); expect(api.latestRelease).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
it('uses the assigned endpoints and sends only the pinned commit', async () => {
|
||||||
|
const fetcher = vi.fn<typeof fetch>().mockResolvedValueOnce(new Response(JSON.stringify(release))).mockResolvedValueOnce(new Response(JSON.stringify(built), {status:202}));
|
||||||
|
const client = createApi(fetcher);
|
||||||
|
expect(await client.latestRelease()).toEqual(release);
|
||||||
|
expect(await client.buildRelease(release.commit)).toEqual(built);
|
||||||
|
expect(fetcher.mock.calls[0][0]).toBe('/api/releases/latest');
|
||||||
|
expect(fetcher.mock.calls[1][0]).toBe('/api/releases/build');
|
||||||
|
expect(fetcher.mock.calls[1][1]).toMatchObject({method:'POST', body:JSON.stringify({commit:release.commit})});
|
||||||
|
});
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { api, ApiError, type Build } from './api';
|
||||||
|
import { Panel, ResourceState, Status, useAction, useResource } from './ui';
|
||||||
|
|
||||||
|
export function BuildProvenance({build}: {build: Build}) {
|
||||||
|
if (!build.release_id && !build.source_commit && !build.source_repository) return null;
|
||||||
|
return <div className="mono wrap">
|
||||||
|
{build.release_id && <p>发布标识:{build.release_id}</p>}
|
||||||
|
{build.source_repository && <p>源码仓库:{build.source_repository}</p>}
|
||||||
|
{build.source_commit && <p>源码提交:{build.source_commit}</p>}
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ReleasePanel({onBuilt}: {onBuilt: () => Promise<void>}) {
|
||||||
|
const release = useResource(api.latestRelease, '', 300_000);
|
||||||
|
const action = useAction();
|
||||||
|
const [submitted, setSubmitted] = useState<{commit: string; build: Build}>();
|
||||||
|
const current = release.data;
|
||||||
|
const accepted = submitted?.commit === current?.commit ? submitted?.build : undefined;
|
||||||
|
const associated = accepted || current?.build;
|
||||||
|
const task = useResource(() => associated ? api.build(associated.id) : Promise.resolve(undefined), associated?.id, associated ? 5000 : 0,
|
||||||
|
value => !!value && ['queued', 'running', 'validating'].includes(value.status));
|
||||||
|
const build = task.data?.id === associated?.id ? task.data : associated;
|
||||||
|
const active = build && ['queued', 'running', 'validating'].includes(build.status);
|
||||||
|
const disabled = action.busy || release.loading || !!release.error || !current?.compatible || !!active;
|
||||||
|
async function submit() {
|
||||||
|
if (disabled || !current) return;
|
||||||
|
await action.run(async () => {
|
||||||
|
let result: Build;
|
||||||
|
try { result = await api.buildRelease(current.commit); }
|
||||||
|
catch (error) {
|
||||||
|
if (error instanceof ApiError && error.status === 409) {
|
||||||
|
await release.refresh();
|
||||||
|
throw new Error('发布分支已变化,已重新查询。请核对最新提交并再次明确确认构建。');
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
setSubmitted({commit: current.commit, build: result});
|
||||||
|
await Promise.all([release.refresh(), onBuilt()]);
|
||||||
|
}, '源码发布构建任务已提交,不会自动升级实例。',
|
||||||
|
`确认手动构建源码发布 ${current.version}?\n仓库:${current.repository}\n分支:${current.branch}\n提交:${current.commit}\n部署协议:${current.recipe_rev}\n平台:${current.platforms.join(', ')}\n构建会消耗磁盘、网络与 CPU;不会自动升级实例,构建成功不等于通过验收。`);
|
||||||
|
}
|
||||||
|
return <Panel title="源码发布(管理员)" action={<button disabled={release.loading || action.busy} onClick={release.refresh}>刷新源码发布</button>}>
|
||||||
|
<p className="muted">进入页面时查询,此后每 5 分钟检查一次。仅检查,不自动构建或升级;与 npm latest / 实验构建相互独立。</p>
|
||||||
|
<ResourceState resource={release}/>{associated && <ResourceState resource={task}/>} {action.feedback}
|
||||||
|
{current && <>
|
||||||
|
<h3>{current.version}</h3>
|
||||||
|
<div className="mono wrap">
|
||||||
|
<p>发布标识:{current.release_id}</p>
|
||||||
|
<p>源码仓库:{current.repository}</p>
|
||||||
|
<p>分支:{current.branch}</p>
|
||||||
|
<p>源码提交:{current.commit}</p>
|
||||||
|
</div>
|
||||||
|
<p>部署协议:{current.recipe_rev} · 平台:{current.platforms.join(', ')}</p>
|
||||||
|
<p className={current.compatible ? 'callout' : 'alert'}>兼容性:{current.compatible ? '兼容,可手动构建' : '不兼容,禁止构建'} · {current.reason}</p>
|
||||||
|
{build && <div><p>关联构建:{build.id} · <Status value={build.status}/></p><BuildProvenance build={build}/>{build.error && <p className="alert" role="alert">{build.error}</p>}</div>}
|
||||||
|
<button className="primary" disabled={disabled} onClick={submit}>手动构建此源码发布</button>
|
||||||
|
</>}
|
||||||
|
<p className="callout">构建和启动验证通过后可用于实例;发布者仍需完成上线验收,实例升级须另行确认。</p>
|
||||||
|
</Panel>;
|
||||||
|
}
|
||||||
+4
-1
@@ -6,7 +6,8 @@ export interface Session { user: User; csrf_token: string }
|
|||||||
export interface Instance { id: string; name: string; status: string; image_id: string; version: string; created_at: string }
|
export interface Instance { id: string; name: string; status: string; image_id: string; version: string; created_at: string }
|
||||||
export type AdmissionState = 'recommended' | 'compatible' | 'existing_only' | 'blocked';
|
export type AdmissionState = 'recommended' | 'compatible' | 'existing_only' | 'blocked';
|
||||||
export interface Image { id: string; image_id?: string; version: string; tag?: string; status?: string; admission?: string; admission_reason?: string; selectable?: boolean; usage_count?: number; recipe_rev?: string; platform?: string; created_at?: string }
|
export interface Image { id: string; image_id?: string; version: string; tag?: string; status?: string; admission?: string; admission_reason?: string; selectable?: boolean; usage_count?: number; recipe_rev?: string; platform?: string; created_at?: string }
|
||||||
export interface Build { id: string; version: string; status: string; experimental?: boolean; image_id?: string; logs?: string; error?: string; created_at: string }
|
export interface Build { id: string; version: string; status: string; experimental?: boolean; image_id?: string; logs?: string; error?: string; created_at: string; release_id?: string; source_commit?: string; source_repository?: string }
|
||||||
|
export interface Release { release_id: string; version: string; commit: string; repository: string; branch: string; recipe_rev: string; platforms: string[]; compatible: boolean; reason: string; build?: Build }
|
||||||
export const selectableImage = (image: Image) => image.status === 'available' && image.selectable === true;
|
export const selectableImage = (image: Image) => image.status === 'available' && image.selectable === true;
|
||||||
export interface CompatibilityRule { state: string; build_allowed: boolean; reason: string }
|
export interface CompatibilityRule { state: string; build_allowed: boolean; reason: string }
|
||||||
export interface Compatibility { recipe_rev: string; platform: string; entries: (CompatibilityRule & {version:string; recipe_rev:string; platform:string})[]; unknown: CompatibilityRule; experimental_admin_only: boolean }
|
export interface Compatibility { recipe_rev: string; platform: string; entries: (CompatibilityRule & {version:string; recipe_rev:string; platform:string})[]; unknown: CompatibilityRule; experimental_admin_only: boolean }
|
||||||
@@ -84,6 +85,8 @@ export function createApi(fetcher: typeof fetch = fetch) {
|
|||||||
instances: () => request<Instance[]>('/instances'),
|
instances: () => request<Instance[]>('/instances'),
|
||||||
images: () => request<Image[]>('/images'),
|
images: () => request<Image[]>('/images'),
|
||||||
compatibility: () => request<Compatibility>('/compatibility'),
|
compatibility: () => request<Compatibility>('/compatibility'),
|
||||||
|
latestRelease: () => request<Release>('/releases/latest'),
|
||||||
|
buildRelease: (commit: string) => request<Build>('/releases/build', 'POST', {commit}),
|
||||||
startBuild: (version: string, experimental = false) => request<Build>('/builds', 'POST', {version, experimental}),
|
startBuild: (version: string, experimental = false) => request<Build>('/builds', 'POST', {version, experimental}),
|
||||||
admitImage: (image_id: string, state: AdmissionState, evidence: string) => request('/images/admission', 'POST', {image_id, state, evidence}),
|
admitImage: (image_id: string, state: AdmissionState, evidence: string) => request('/images/admission', 'POST', {image_id, state, evidence}),
|
||||||
builds: (limit = 20, offset = 0) => request<Build[]>(`/builds?limit=${limit}&offset=${offset}`),
|
builds: (limit = 20, offset = 0) => request<Build[]>(`/builds?limit=${limit}&offset=${offset}`),
|
||||||
|
|||||||
Reference in New Issue
Block a user