feat(pages): pull latest by project and keep a single edge release

Agents now treat pages_project_id as the stable anchor and fetch the
control-plane active package via project latest APIs, so activating a
deployment updates edges without republishing main config. Local roots use
projects/{id}/current, only the newest release is retained after a successful
switch, hash/package races retry, and per-project failures no longer block
siblings.
This commit is contained in:
ryan
2026-07-17 17:43:40 +08:00
parent a0fcf9f627
commit ce736e2de4
21 changed files with 871 additions and 219 deletions
+61 -6
View File
@@ -156,7 +156,7 @@ func ReportApplyLogHandler(c *gin.Context) {
// GetPagesDeploymentHashHandler returns the upload SHA-256 hash for a Pages deployment package.
// @Summary 查询 Pages 部署包哈希
// @Description 返回 upload 框架记录的 SHA-256 哈希,供 Agent 对比本地缓存并按需拉取部署包
// @Description 返回 upload 框架记录的 SHA-256 哈希,供 Agent 对比本地缓存并按需拉取部署包(兼容旧路径)
// @Tags openflare-agent
// @Produce json
// @Security AgentTokenAuth
@@ -166,7 +166,7 @@ func ReportApplyLogHandler(c *gin.Context) {
// @Failure 401 {object} response.Any "Token 无效"
// @Router /api/v1/agent/pages/deployments/{deployment_id}/hash [get]
func GetPagesDeploymentHashHandler(c *gin.Context) {
deploymentID, ok := pagesDeploymentIDParam(c)
deploymentID, ok := pagesUintParam(c, "deployment_id")
if !ok {
return
}
@@ -182,7 +182,7 @@ func GetPagesDeploymentHashHandler(c *gin.Context) {
// DownloadPagesPackageHandler streams the Pages deployment artifact to an authenticated agent.
// @Summary 下载 Pages 部署包
// @Description 流式下载指定部署的静态资源压缩包,供 Agent 边缘分发
// @Description 流式下载指定部署的静态资源压缩包,供 Agent 边缘分发(兼容旧路径)
// @Tags openflare-agent
// @Produce application/octet-stream
// @Security AgentTokenAuth
@@ -192,7 +192,7 @@ func GetPagesDeploymentHashHandler(c *gin.Context) {
// @Failure 401 {object} response.Any "Token 无效"
// @Router /api/v1/agent/pages/deployments/{deployment_id}/package [get]
func DownloadPagesPackageHandler(c *gin.Context) {
deploymentID, ok := pagesDeploymentIDParam(c)
deploymentID, ok := pagesUintParam(c, "deployment_id")
if !ok {
return
}
@@ -208,8 +208,63 @@ func DownloadPagesPackageHandler(c *gin.Context) {
c.DataFromReader(http.StatusOK, packageObj.ContentLength, packageObj.ContentType, packageObj.Body, nil)
}
func pagesDeploymentIDParam(c *gin.Context) (uint, bool) {
raw := c.Param("deployment_id")
// GetPagesProjectLatestHashHandler returns the hash of a project's currently active deployment.
// @Summary 查询 Pages 项目最新激活部署哈希
// @Description 按项目 ID 返回当前激活部署的包哈希(类似 latest 指针),Agent 无需关心具体部署 ID
// @Tags openflare-agent
// @Produce json
// @Security AgentTokenAuth
// @Param project_id path int true "Pages 项目 ID"
// @Success 200 {object} response.Any{data=protocol.PagesProjectLatestHashResponse}
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "Token 无效"
// @Router /api/v1/agent/pages/projects/{project_id}/latest/hash [get]
func GetPagesProjectLatestHashHandler(c *gin.Context) {
projectID, ok := pagesUintParam(c, "project_id")
if !ok {
return
}
deploymentID, hash, err := pages.GetProjectLatestPackageHash(c.Request.Context(), projectID)
if apiutil.AbortBadRequestOnError(c, err) {
return
}
c.JSON(http.StatusOK, response.OK(protocol.PagesProjectLatestHashResponse{
ProjectID: projectID,
DeploymentID: deploymentID,
Hash: hash,
}))
}
// DownloadPagesProjectLatestPackageHandler streams the active deployment package for a project.
// @Summary 下载 Pages 项目最新激活部署包
// @Description 按项目 ID 下载当前激活部署的压缩包,供 Agent 边缘分发
// @Tags openflare-agent
// @Produce application/octet-stream
// @Security AgentTokenAuth
// @Param project_id path int true "Pages 项目 ID"
// @Success 200 {file} binary "部署包文件"
// @Failure 400 {object} response.Any "参数错误"
// @Failure 401 {object} response.Any "Token 无效"
// @Router /api/v1/agent/pages/projects/{project_id}/latest/package [get]
func DownloadPagesProjectLatestPackageHandler(c *gin.Context) {
projectID, ok := pagesUintParam(c, "project_id")
if !ok {
return
}
packageObj, err := pages.OpenProjectLatestPackage(c.Request.Context(), projectID)
if apiutil.AbortBadRequestOnError(c, err) {
return
}
defer func() { _ = packageObj.Body.Close() }()
c.Header("Content-Disposition", "attachment; filename="+packageObj.FileName)
if packageObj.ContentType != "" {
c.Header("Content-Type", packageObj.ContentType)
}
c.DataFromReader(http.StatusOK, packageObj.ContentLength, packageObj.ContentType, packageObj.Body, nil)
}
func pagesUintParam(c *gin.Context, name string) (uint, bool) {
raw := c.Param(name)
if raw == "" {
response.AbortBadRequest(c, "无效的 ID")
return 0, false
@@ -88,10 +88,8 @@ func buildSnapshotPagesDeployment(project *model.PagesProject, activeDeployment
APIProxyPath: strings.TrimSpace(project.APIProxyPath),
APIProxyPass: strings.TrimSpace(project.APIProxyPass),
APIProxyRewrite: strings.TrimSpace(project.APIProxyRewrite),
LocalRoot: fmt.Sprintf(
"%s/deployments/%d/current",
openrestyrender.PagesDirPlaceholder,
activeDeployment.ID,
),
// Root is project-scoped so Agents can swap active packages without
// re-publishing main config (nginx root stays stable).
LocalRoot: openrestyrender.PagesProjectLocalRoot(project.ID),
}
}
@@ -64,7 +64,7 @@ func TestBuildSnapshotRoutesPages(t *testing.T) {
require.NotNil(t, snapshotRoute.PagesDeployment)
assert.Equal(t, deployment.ID, snapshotRoute.PagesDeployment.DeploymentID)
assert.Equal(t, deployment.Checksum, snapshotRoute.PagesDeployment.Checksum)
assert.Equal(t, "__OPENFLARE_PAGES_DIR__/deployments/1/current", snapshotRoute.PagesDeployment.LocalRoot)
assert.Equal(t, "__OPENFLARE_PAGES_DIR__/projects/1/current", snapshotRoute.PagesDeployment.LocalRoot)
_, err = renderSnapshotConfig(bundle.SnapshotJSON, nil)
require.NoError(t, err)
+102 -38
View File
@@ -509,7 +509,8 @@ func ActivateDeployment(ctx context.Context, projectID uint, deploymentID uint)
return GetProject(ctx, project.ID)
}
// GetDeploymentPackageHash returns the SHA-256 hash of the deployment package from upload storage.
// GetDeploymentPackageHash returns the upload SHA-256 hash of the deployment package.
// Prefer GetProjectLatestPackageHash for Agent latest-pointer pulls.
func GetDeploymentPackageHash(ctx context.Context, deploymentID uint) (string, error) {
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
@@ -518,14 +519,94 @@ func GetDeploymentPackageHash(ctx context.Context, deploymentID uint) (string, e
if err = ensureDeploymentInActiveSnapshot(ctx, deployment.ID); err != nil {
return "", err
}
return deploymentPackageHash(ctx, deployment)
}
// GetProjectLatestPackageHash returns the package hash of a project's active deployment.
// This is the Agent "latest" pointer: callers pass project_id only.
func GetProjectLatestPackageHash(ctx context.Context, projectID uint) (uint, string, error) {
deployment, err := resolveProjectActiveDeploymentForAgent(ctx, projectID)
if err != nil {
return 0, "", err
}
hash, err := deploymentPackageHash(ctx, deployment)
if err != nil {
return 0, "", err
}
return deployment.ID, hash, nil
}
// OpenDeploymentPackage opens the deployment artifact from the upload storage framework.
func OpenDeploymentPackage(ctx context.Context, deploymentID uint) (DeploymentPackage, error) {
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
return DeploymentPackage{}, err
}
if err = ensureDeploymentInActiveSnapshot(ctx, deployment.ID); err != nil {
return DeploymentPackage{}, err
}
if deployment.UploadID == 0 {
if err := ensureDeploymentUploadRecord(ctx, deployment); err != nil {
return DeploymentPackage{}, err
}
}
return openDeploymentPackageFromUpload(ctx, deployment.UploadID, deployment.ID)
}
// OpenProjectLatestPackage opens the currently active deployment package for a project.
func OpenProjectLatestPackage(ctx context.Context, projectID uint) (DeploymentPackage, error) {
deployment, err := resolveProjectActiveDeploymentForAgent(ctx, projectID)
if err != nil {
return DeploymentPackage{}, err
}
if deployment.UploadID == 0 {
if err := ensureDeploymentUploadRecord(ctx, deployment); err != nil {
return DeploymentPackage{}, err
}
}
return openDeploymentPackageFromUpload(ctx, deployment.UploadID, deployment.ID)
}
func resolveProjectActiveDeploymentForAgent(ctx context.Context, projectID uint) (*model.PagesDeployment, error) {
if projectID == 0 {
return nil, errors.New(errPagesProjectNotFound)
}
project, err := model.GetPagesProjectByID(ctx, projectID)
if err != nil {
return nil, err
}
if !project.Enabled {
return nil, errors.New(errPagesPackageNotInActiveConfig)
}
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID == 0 {
return nil, errors.New(errPagesPackageNotInActiveConfig)
}
if err := ensureProjectInActiveConfig(ctx, project.ID); err != nil {
return nil, err
}
deployment, err := model.GetPagesDeploymentByID(ctx, *project.ActiveDeploymentID)
if err != nil {
return nil, err
}
if deployment.ProjectID != project.ID {
return nil, errors.New(errPagesDeploymentMismatch)
}
return deployment, nil
}
func deploymentPackageHash(ctx context.Context, deployment *model.PagesDeployment) (string, error) {
if deployment == nil {
return "", errors.New(errPagesDeploymentNotFound)
}
if deployment.UploadID == 0 {
if err := ensureDeploymentUploadRecord(ctx, deployment); err != nil {
return "", err
}
deployment, err = model.GetPagesDeploymentByID(ctx, deploymentID)
reloaded, err := model.GetPagesDeploymentByID(ctx, deployment.ID)
if err != nil {
return "", err
}
deployment = reloaded
}
if deployment.UploadID == 0 {
hash := strings.TrimSpace(deployment.Checksum)
@@ -548,23 +629,6 @@ func GetDeploymentPackageHash(ctx context.Context, deploymentID uint) (string, e
return hash, nil
}
// OpenDeploymentPackage opens the deployment artifact from the upload storage framework.
func OpenDeploymentPackage(ctx context.Context, deploymentID uint) (DeploymentPackage, error) {
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
return DeploymentPackage{}, err
}
if err = ensureDeploymentInActiveSnapshot(ctx, deployment.ID); err != nil {
return DeploymentPackage{}, err
}
if deployment.UploadID == 0 {
if err := ensureDeploymentUploadRecord(ctx, deployment); err != nil {
return DeploymentPackage{}, err
}
}
return openDeploymentPackageFromUpload(ctx, deployment.UploadID, deployment.ID)
}
func openDeploymentPackageFromUpload(ctx context.Context, uploadID uint64, deploymentID uint) (DeploymentPackage, error) {
opened, err := upload.OpenStoredUpload(ctx, uploadID)
if err != nil {
@@ -648,13 +712,9 @@ func hydrateLegacyDeploymentUpload(
return &ingestResult.Upload, nil
}
// ensureDeploymentInActiveSnapshot allows Agent package download when the
// deployment is the project's current active deployment and that project is
// used by at least one pages route in the active main config.
//
// Main config versions pin historical pages_deployment ids for audit only.
// Runtime download always follows the live active Pages deployment (dual
// version control); rolling back main config must not require old packages.
// ensureDeploymentInActiveSnapshot allows download of a specific deployment when
// it is the project's current active deployment and the project is used by the
// active main config.
func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) error {
deployment, err := model.GetPagesDeploymentByID(ctx, deploymentID)
if err != nil {
@@ -667,7 +727,12 @@ func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) er
if project.ActiveDeploymentID == nil || *project.ActiveDeploymentID != deployment.ID {
return errors.New(errPagesPackageNotInActiveConfig)
}
return ensureProjectInActiveConfig(ctx, project.ID)
}
// ensureProjectInActiveConfig checks that the Pages project is referenced by at
// least one pages route in the active main config snapshot.
func ensureProjectInActiveConfig(ctx context.Context, projectID uint) error {
version, err := model.GetActiveConfigVersion(ctx)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
@@ -683,28 +748,27 @@ func ensureDeploymentInActiveSnapshot(ctx context.Context, deploymentID uint) er
if !strings.EqualFold(strings.TrimSpace(route.UpstreamType), "pages") {
continue
}
if route.PagesProjectID != nil && *route.PagesProjectID == project.ID {
if route.PagesProjectID != nil && *route.PagesProjectID == projectID {
return nil
}
if route.PagesDeployment == nil {
continue
}
if route.PagesDeployment.ProjectID == project.ID {
if route.PagesDeployment.ProjectID == projectID {
return nil
}
// Frozen snapshot may only carry deployment_id; resolve project via that row.
if route.PagesDeployment.DeploymentID == 0 {
continue
}
if route.PagesDeployment.DeploymentID == deployment.ID {
return nil
}
snapDeployment, snapErr := model.GetPagesDeploymentByID(ctx, route.PagesDeployment.DeploymentID)
if snapErr != nil {
continue
}
if snapDeployment.ProjectID == project.ID {
return nil
if route.PagesDeployment.DeploymentID != 0 {
// Historical snapshot may only pin deployment_id.
snapDeployment, snapErr := model.GetPagesDeploymentByID(ctx, route.PagesDeployment.DeploymentID)
if snapErr != nil {
continue
}
if snapDeployment.ProjectID == projectID {
return nil
}
}
}
return errors.New(errPagesPackageNotInActiveConfig)
@@ -337,6 +337,16 @@ func TestOpenDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
defer packageObj.Body.Close()
assert.Equal(t, fmt.Sprintf("pages-deployment-%d.zip", deployment.ID), packageObj.FileName)
// Latest-by-project resolves the active package once the project is on active config.
depID, hash, err := GetProjectLatestPackageHash(ctx, project.ID)
require.NoError(t, err)
assert.Equal(t, deployment.ID, depID)
assert.NotEmpty(t, hash)
latestPkg, err := OpenProjectLatestPackage(ctx, project.ID)
require.NoError(t, err)
defer latestPkg.Body.Close()
body, err := io.ReadAll(packageObj.Body)
require.NoError(t, err)
reader, err := zip.NewReader(bytes.NewReader(body), int64(len(body)))
@@ -345,6 +355,53 @@ func TestOpenDeploymentPackageRequiresActiveConfigSnapshot(t *testing.T) {
assert.Equal(t, "index.html", reader.File[0].Name)
}
func TestProjectLatestRejectsWhenNotOnActiveConfigOrNotActive(t *testing.T) {
cleanup := setupPagesTestDB(t)
defer cleanup()
_, disableStorage := setupPagesStorageMock(t)
defer disableStorage()
ctx := context.Background()
project, err := CreateProject(ctx, Input{Name: "Gate", Slug: "gate", Enabled: true})
require.NoError(t, err)
d1, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "a.zip", testPagesZip(t, map[string]string{"index.html": "a"})), "root")
require.NoError(t, err)
d2, err := UploadDeployment(ctx, project.ID, testPagesMultipartFile(t, "b.zip", testPagesZip(t, map[string]string{"index.html": "b"})), "root")
require.NoError(t, err)
_, err = ActivateDeployment(ctx, project.ID, d2.ID)
require.NoError(t, err)
// No active main config → reject.
_, _, err = GetProjectLatestPackageHash(ctx, project.ID)
require.Error(t, err)
require.NoError(t, db.DB(ctx).Create(&model.ConfigVersion{
Version: "v-gate",
SnapshotJSON: fmt.Sprintf(
`{"routes":[{"upstream_type":"pages","pages_project_id":%d,"pages_deployment":{"project_id":%d,"deployment_id":%d}}]}`,
project.ID, project.ID, d2.ID,
),
SupportFilesJSON: "[]",
Checksum: "c-gate",
IsActive: true,
CreatedBy: "test",
}).Error)
// Non-active historical deployment must not be downloadable.
_, err = OpenDeploymentPackage(ctx, d1.ID)
require.Error(t, err)
// Active latest works.
_, hash, err := GetProjectLatestPackageHash(ctx, project.ID)
require.NoError(t, err)
assert.NotEmpty(t, hash)
// Disabled project rejects.
require.NoError(t, db.DB(ctx).Model(&model.PagesProject{}).Where("id = ?", project.ID).Update("enabled", false).Error)
_, _, err = GetProjectLatestPackageHash(ctx, project.ID)
require.Error(t, err)
}
func testPagesZip(t *testing.T, files map[string]string) []byte {
t.Helper()
+1 -5
View File
@@ -203,11 +203,7 @@ func buildLivePagesDeployment(project *model.PagesProject, active *model.PagesDe
APIProxyPath: strings.TrimSpace(project.APIProxyPath),
APIProxyPass: strings.TrimSpace(project.APIProxyPass),
APIProxyRewrite: strings.TrimSpace(project.APIProxyRewrite),
LocalRoot: fmt.Sprintf(
"%s/deployments/%d/current",
openrestyrender.PagesDirPlaceholder,
active.ID,
),
LocalRoot: openrestyrender.PagesProjectLocalRoot(project.ID),
}
}
+1 -1
View File
@@ -59,7 +59,7 @@ func TestRebindSnapshotPagesToCurrentActive(t *testing.T) {
"project_id": project.ID,
"deployment_id": old.ID,
"checksum": "old-checksum",
"local_root": "__OPENFLARE_PAGES_DIR__/deployments/1/current",
"local_root": "__OPENFLARE_PAGES_DIR__/projects/1/current",
},
"extra_keep_me": "yes",
},