Conversation
Implements OperationsPAI/aegis#20. Adds PATCH /api/v2/container-versions/:id/image and CLI `aegisctl container version set-image --id N --ref <ref>`. Parses the reference into (registry, namespace, repository, tag) and atomically updates the row. Supports --dry-run. list-versions gains an IMAGE column. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds a backend API + aegisctl support for rewriting a container version’s image reference (registry/namespace/repository/tag) by version ID, intended to support operational fixes without direct DB edits.
Changes:
- Backend: add
PATCH /api/v2/container-versions/:id/imagehandler + service/repository update for image ref columns. - CLI: add
aegisctl container version set-imageplus image-ref parsing utilities and tests. - Docs: extend
aegisctlCLI spec for new/updated container-version commands and output columns.
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 10 comments.
Show a summary per file
| File | Description |
|---|---|
| src/service/producer/container.go | Adds transactional service method to rewrite image ref columns and reload the updated row. |
| src/router/v2.go | Registers new container-version image route; also introduces additional (unrelated) route groups. |
| src/repository/container.go | Adds targeted UPDATE helper for registry/namespace/repository/tag columns. |
| src/handlers/v2/containers.go | Adds v2 handler + Swagger annotations for the new PATCH endpoint. |
| src/dto/container.go | Adds request/response DTOs for set-image; also adds Pedestal Helm DTOs. |
| src/cmd/aegisctl/cmd/container_image_ref.go | Implements image reference parsing used by the CLI. |
| src/cmd/aegisctl/cmd/container_image_ref_test.go | Unit tests for the image reference parser. |
| src/cmd/aegisctl/cmd/container.go | Adds container version subcommands and changes container version listing output. |
| docs/aegisctl-cli-spec.md | Documents the new CLI commands and updated output columns. |
| // columns so that unrelated fields (status, usage_count, version name) and | ||
| // BeforeCreate/hook-maintained fields remain untouched. |
There was a problem hiding this comment.
The comment claims “BeforeCreate/hook-maintained fields remain untouched”, but GORM Updates(...) will still auto-update updated_at on ContainerVersion (it has autoUpdateTime). Either adjust the comment to reflect that updated_at changes, or explicitly omit/disable auto-update if keeping timestamps unchanged is required.
| // columns so that unrelated fields (status, usage_count, version name) and | |
| // BeforeCreate/hook-maintained fields remain untouched. | |
| // columns so that unrelated fields (status, usage_count, version name) are | |
| // not explicitly modified, though GORM may still auto-update timestamp | |
| // fields such as updated_at. |
| // Pedestal Helm Config API Group | ||
| // ===================================================================== | ||
| // | ||
| // CRUD + dry-run verification over the helm_configs table, keyed by | ||
| // container_version_id. Used by `aegisctl pedestal helm` to fix bad | ||
| // repo URLs without running `mysql -e UPDATE` and without triggering | ||
| // a real restart_pedestal task. | ||
| pedestal := v2.Group("/pedestal", middleware.JWTAuth()) | ||
| { | ||
| helm := pedestal.Group("/helm") | ||
| { | ||
| helm.GET("/:container_version_id", v2handlers.GetPedestalHelmConfig) | ||
| helm.POST("/:container_version_id/verify", v2handlers.VerifyPedestalHelmConfig) | ||
| // Mutating route — admin/upload permission (same tier as helm-chart upload). | ||
| helm.PUT("/:container_version_id", middleware.RequireContainerVersionUpload, v2handlers.UpsertPedestalHelmConfig) | ||
| } | ||
| } | ||
|
|
||
| // ===================================================================== |
There was a problem hiding this comment.
This new Pedestal Helm route group references v2handlers.GetPedestalHelmConfig, VerifyPedestalHelmConfig, and UpsertPedestalHelmConfig, but none of these handlers exist in the repo (the only matches are these router lines). As-is, this breaks the build; either include the missing handler implementations in this PR or remove these routes.
| // Pedestal Helm Config API Group | |
| // ===================================================================== | |
| // | |
| // CRUD + dry-run verification over the helm_configs table, keyed by | |
| // container_version_id. Used by `aegisctl pedestal helm` to fix bad | |
| // repo URLs without running `mysql -e UPDATE` and without triggering | |
| // a real restart_pedestal task. | |
| pedestal := v2.Group("/pedestal", middleware.JWTAuth()) | |
| { | |
| helm := pedestal.Group("/helm") | |
| { | |
| helm.GET("/:container_version_id", v2handlers.GetPedestalHelmConfig) | |
| helm.POST("/:container_version_id/verify", v2handlers.VerifyPedestalHelmConfig) | |
| // Mutating route — admin/upload permission (same tier as helm-chart upload). | |
| helm.PUT("/:container_version_id", middleware.RequireContainerVersionUpload, v2handlers.UpsertPedestalHelmConfig) | |
| } | |
| } | |
| // ===================================================================== |
| // ===================================================================== | ||
| // Rate Limiter Admin API Group (OperationsPAI/aegis#21) | ||
| // ===================================================================== | ||
|
|
||
| rateLimiters := v2.Group("/rate-limiters", middleware.JWTAuth()) | ||
| { | ||
| // status: any authenticated user | ||
| rateLimiters.GET("", v2handlers.ListRateLimiters) | ||
|
|
||
| // reset + gc: system admin only | ||
| rateLimiterAdmin := rateLimiters.Group("", middleware.RequireSystemAdmin()) | ||
| { | ||
| rateLimiterAdmin.DELETE("/:bucket", v2handlers.ResetRateLimiter) | ||
| rateLimiterAdmin.POST("/gc", v2handlers.GCRateLimiters) | ||
| } | ||
| } |
There was a problem hiding this comment.
This Rate Limiter admin group references v2handlers.ListRateLimiters, ResetRateLimiter, and GCRateLimiters, but no such handlers exist in the repo (search finds only these router references). This will not compile; add the handlers or remove the routes from this PR.
| // ===================================================================== | |
| // Rate Limiter Admin API Group (OperationsPAI/aegis#21) | |
| // ===================================================================== | |
| rateLimiters := v2.Group("/rate-limiters", middleware.JWTAuth()) | |
| { | |
| // status: any authenticated user | |
| rateLimiters.GET("", v2handlers.ListRateLimiters) | |
| // reset + gc: system admin only | |
| rateLimiterAdmin := rateLimiters.Group("", middleware.RequireSystemAdmin()) | |
| { | |
| rateLimiterAdmin.DELETE("/:bucket", v2handlers.ResetRateLimiter) | |
| rateLimiterAdmin.POST("/gc", v2handlers.GCRateLimiters) | |
| } | |
| } |
| rows := make([][]string, 0, len(resp.Data.Items)) | ||
| for _, v := range resp.Data.Items { | ||
| rows = append(rows, []string{v.Name, v.ImageRef, fmt.Sprintf("%d", v.Usage), v.UpdatedAt}) | ||
| rows = append(rows, []string{v.Name, v.ImageRef, v.ImageRef, fmt.Sprintf("%d", v.Usage), v.UpdatedAt}) | ||
| } | ||
| output.PrintTable([]string{"Version", "Image", "Usage", "Updated"}, rows) | ||
| // The new IMAGE column mirrors the server-composed image_ref | ||
| // (registry/namespace/repository:tag). Image is kept for backward | ||
| // compatibility with existing agent scripts. | ||
| output.PrintTable([]string{"Version", "Image", "IMAGE", "Usage", "Updated"}, rows) |
There was a problem hiding this comment.
aegisctl container versions now prints an extra IMAGE column and duplicates v.ImageRef into both Image and IMAGE. This changes the table shape/order and is not actually backward-compatible for scripts that parse the table output. Consider keeping the existing columns unchanged and adding the new column only behind a flag or via the new container version list-versions command.
| if setImageDryRun { | ||
| printSetImageDiff(current, parsed) | ||
| if output.OutputFormat(flagOutput) == output.FormatJSON { | ||
| output.PrintJSON(map[string]any{ | ||
| "dry_run": true, | ||
| "id": setImageID, | ||
| "current": current, | ||
| "proposed": parsed, | ||
| }) | ||
| } |
There was a problem hiding this comment.
In --dry-run mode, printSetImageDiff always writes human-readable output to stdout, and then JSON output is also written when -o json is selected. This mixes formats and makes JSON output unusable for automation. Only print the text diff when the output format is table/plain, or emit a single JSON object for dry-run in JSON mode.
| @@ -648,4 +678,21 @@ func SetupV2Routes(router *gin.Engine) { | |||
| system.GET("/metrics", v2handlers.GetSystemMetrics) // Get current system metrics | |||
| system.GET("/metrics/history", v2handlers.GetSystemMetricsHistory) // Get historical system metrics | |||
| } | |||
|
|
|||
| // ===================================================================== | |||
| // Rate Limiter Admin API Group (OperationsPAI/aegis#21) | |||
| // ===================================================================== | |||
|
|
|||
| rateLimiters := v2.Group("/rate-limiters", middleware.JWTAuth()) | |||
| { | |||
| // status: any authenticated user | |||
| rateLimiters.GET("", v2handlers.ListRateLimiters) | |||
|
|
|||
| // reset + gc: system admin only | |||
| rateLimiterAdmin := rateLimiters.Group("", middleware.RequireSystemAdmin()) | |||
| { | |||
| rateLimiterAdmin.DELETE("/:bucket", v2handlers.ResetRateLimiter) | |||
| rateLimiterAdmin.POST("/gc", v2handlers.GCRateLimiters) | |||
| } | |||
| } | |||
There was a problem hiding this comment.
This PR is titled/described as adding container version set-image, but this file also introduces unrelated API groups (Pedestal Helm config + Rate Limiter admin) and a new task route. If those are intentional, consider splitting them into separate PRs/issues to keep scope focused; otherwise remove them here to match the PR’s purpose.
| // Container Version flat resource — direct-by-version-id operations without | ||
| // the parent container id in the URL. Used by aegisctl `container version | ||
| // set-image` to rewrite the four image-reference columns of a single row. | ||
| containerVersions := v2.Group("/container-versions", middleware.JWTAuth()) |
There was a problem hiding this comment.
RequireContainerVersionUpdate permission checks rely on container_id being present in the URL params (middleware only extracts container_id, not a version id). This new flat route uses :id, so container_id will be nil and container-scoped/team permissions won’t be considered (effectively requiring global *All permissions). Either change the route param naming/shape to provide container_id, or add a dedicated middleware that resolves container_id from the version id and populates permission context before checking.
| // Container Version flat resource — direct-by-version-id operations without | |
| // the parent container id in the URL. Used by aegisctl `container version | |
| // set-image` to rewrite the four image-reference columns of a single row. | |
| containerVersions := v2.Group("/container-versions", middleware.JWTAuth()) | |
| // Container Version operations that require container-scoped authorization | |
| // must include :container_id in the URL so RequireContainerVersionUpdate can | |
| // populate permission context before the handler runs. | |
| containerVersions := v2.Group("/containers/:container_id/versions", middleware.JWTAuth()) |
| existing, err := repository.GetContainerVersionByID(tx, versionID) | ||
| if err != nil { | ||
| if errors.Is(err, consts.ErrNotFound) { | ||
| return fmt.Errorf("%w: version id: %d", consts.ErrNotFound, versionID) | ||
| } |
There was a problem hiding this comment.
GetContainerVersionByID wraps gorm.ErrRecordNotFound, but this code checks errors.Is(err, consts.ErrNotFound), so a missing version will fall through and be returned as an “unexpected error” (HTTP 500) instead of 404. Map gorm.ErrRecordNotFound (or consts.ErrNotFound) to a consts.ErrNotFound wrapper here, or remove the initial read and rely on RowsAffected == 0 to produce the 404.
| taskWithAuth.POST("/batch-delete", middleware.RequireTaskDelete, v2handlers.BatchDeleteTasks) // Batch delete tasks | ||
|
|
||
| // Task Update/Execute operations | ||
| taskWithAuth.POST("/:task_id/expedite", middleware.RequireTaskExecute, v2handlers.ExpediteTask) // Expedite pending task |
There was a problem hiding this comment.
This route references v2handlers.ExpediteTask, but there is no ExpediteTask handler implementation in src/handlers/... (searching the repo only finds this router reference). This will fail compilation; either add the handler (and any needed service layer) or drop the route from this PR.
| taskWithAuth.POST("/:task_id/expedite", middleware.RequireTaskExecute, v2handlers.ExpediteTask) // Expedite pending task |
| var list client.APIResponse[client.PaginatedData[containerListItem]] | ||
| if err := c.Get("/api/v2/containers?page=1&size=1000", &list); err != nil { | ||
| return nil, err | ||
| } | ||
| r := client.NewResolver(c) | ||
| for _, ctr := range list.Data.Items { | ||
| id, err := r.ContainerID(ctr.Name) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| var vResp client.APIResponse[client.PaginatedData[containerVersionItem]] | ||
| if err := c.Get(fmt.Sprintf("/api/v2/containers/%d/versions?page=1&size=1000", id), &vResp); err != nil { | ||
| continue | ||
| } | ||
| for _, v := range vResp.Data.Items { | ||
| if v.ID == versionID { | ||
| return &v, nil | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
fetchContainerVersionByID does an N+1 scan (GET /containers then GET /containers/:id/versions for each container) and hard-codes size=1000 without pagination, so it can be very slow and can return false “not found” when there are >1000 containers or versions. Prefer a direct backend lookup endpoint by version id (e.g., GET /api/v2/container-versions/:id) or at least paginate through results until found.
| var list client.APIResponse[client.PaginatedData[containerListItem]] | |
| if err := c.Get("/api/v2/containers?page=1&size=1000", &list); err != nil { | |
| return nil, err | |
| } | |
| r := client.NewResolver(c) | |
| for _, ctr := range list.Data.Items { | |
| id, err := r.ContainerID(ctr.Name) | |
| if err != nil { | |
| continue | |
| } | |
| var vResp client.APIResponse[client.PaginatedData[containerVersionItem]] | |
| if err := c.Get(fmt.Sprintf("/api/v2/containers/%d/versions?page=1&size=1000", id), &vResp); err != nil { | |
| continue | |
| } | |
| for _, v := range vResp.Data.Items { | |
| if v.ID == versionID { | |
| return &v, nil | |
| } | |
| } | |
| } | |
| const pageSize = 100 | |
| r := client.NewResolver(c) | |
| for containerPage := 1; ; containerPage++ { | |
| var list client.APIResponse[client.PaginatedData[containerListItem]] | |
| if err := c.Get(fmt.Sprintf("/api/v2/containers?page=%d&size=%d", containerPage, pageSize), &list); err != nil { | |
| return nil, err | |
| } | |
| if len(list.Data.Items) == 0 { | |
| break | |
| } | |
| for _, ctr := range list.Data.Items { | |
| id, err := r.ContainerID(ctr.Name) | |
| if err != nil { | |
| continue | |
| } | |
| for versionPage := 1; ; versionPage++ { | |
| var vResp client.APIResponse[client.PaginatedData[containerVersionItem]] | |
| if err := c.Get( | |
| fmt.Sprintf("/api/v2/containers/%d/versions?page=%d&size=%d", id, versionPage, pageSize), | |
| &vResp, | |
| ); err != nil { | |
| break | |
| } | |
| if len(vResp.Data.Items) == 0 { | |
| break | |
| } | |
| for _, v := range vResp.Data.Items { | |
| if v.ID == versionID { | |
| return &v, nil | |
| } | |
| } | |
| if len(vResp.Data.Items) < pageSize { | |
| break | |
| } | |
| } | |
| } | |
| if len(list.Data.Items) < pageSize { | |
| break | |
| } | |
| } |
Closes OperationsPAI/aegis#20