From 37e251da855ee2c2b540ed68fe0e18c58265ccde Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 28 Mar 2026 14:04:11 +0300 Subject: [PATCH] feat: auto-discover container images from registries - Add ListImages() to registry interface, implement for Gitea - Add owner field to registry config (needed for Gitea packages API) - GET /api/registries/:id/images endpoint - "Browse Images" button on Projects and Quick Deploy pages - Image dropdown with registry grouping and search - i18n support (EN/RU) for all new UI strings --- internal/api/registries.go | 52 +++++++++++ internal/api/router.go | 1 + internal/registry/gitea.go | 74 +++++++++++++++ internal/registry/registry.go | 11 +++ internal/store/registries.go | 8 +- web/src/lib/api.ts | 5 + web/src/lib/i18n/en.json | 16 +++- web/src/lib/i18n/ru.json | 16 +++- web/src/lib/types.ts | 9 ++ web/src/routes/deploy/+page.svelte | 80 +++++++++++++++- web/src/routes/projects/+page.svelte | 91 ++++++++++++++++++- .../routes/settings/registries/+page.svelte | 10 +- 12 files changed, 355 insertions(+), 18 deletions(-) diff --git a/internal/api/registries.go b/internal/api/registries.go index bd28db6..ee7fbc4 100644 --- a/internal/api/registries.go +++ b/internal/api/registries.go @@ -17,6 +17,7 @@ type registryRequest struct { URL string `json:"url"` Type string `json:"type"` Token string `json:"token"` + Owner string `json:"owner"` } // listRegistries handles GET /api/registries. @@ -34,6 +35,7 @@ func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) { URL string `json:"url"` Type string `json:"type"` HasToken bool `json:"has_token"` + Owner string `json:"owner"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } @@ -46,6 +48,7 @@ func (s *Server) listRegistries(w http.ResponseWriter, r *http.Request) { URL: reg.URL, Type: reg.Type, HasToken: reg.Token != "", + Owner: reg.Owner, CreatedAt: reg.CreatedAt, UpdatedAt: reg.UpdatedAt, } @@ -84,6 +87,7 @@ func (s *Server) createRegistry(w http.ResponseWriter, r *http.Request) { URL: req.URL, Type: req.Type, Token: encToken, + Owner: req.Owner, }) if err != nil { respondError(w, http.StatusInternalServerError, "failed to create registry: "+err.Error()) @@ -125,6 +129,8 @@ func (s *Server) updateRegistry(w http.ResponseWriter, r *http.Request) { if req.Type != "" { updated.Type = req.Type } + // Owner can be set to empty string intentionally, so always update it. + updated.Owner = req.Owner // Only re-encrypt if a new token is provided. if req.Token != "" { @@ -265,3 +271,49 @@ func (s *Server) listRegistryTags(w http.ResponseWriter, r *http.Request) { respondJSON(w, http.StatusOK, tags) } + +// listRegistryImages handles GET /api/registries/{id}/images. +// Returns all container images available in the registry for the configured owner. +func (s *Server) listRegistryImages(w http.ResponseWriter, r *http.Request) { + id := chi.URLParam(r, "id") + + reg, err := s.store.GetRegistryByID(id) + if err != nil { + if errors.Is(err, store.ErrNotFound) { + respondNotFound(w, "registry") + return + } + respondError(w, http.StatusInternalServerError, "failed to get registry: "+err.Error()) + return + } + + if reg.Owner == "" { + respondError(w, http.StatusBadRequest, "registry has no owner configured; set the owner in registry settings") + return + } + + // Decrypt the token. + token := reg.Token + if token != "" { + decrypted, err := crypto.Decrypt(s.encKey, token) + if err != nil { + respondError(w, http.StatusInternalServerError, "failed to decrypt registry token") + return + } + token = decrypted + } + + client, err := registry.NewClient(reg.Type, reg.URL, token) + if err != nil { + respondError(w, http.StatusBadRequest, "unsupported registry type: "+reg.Type) + return + } + + images, err := client.ListImages(r.Context(), reg.Owner) + if err != nil { + respondError(w, http.StatusBadGateway, "failed to list images: "+err.Error()) + return + } + + respondJSON(w, http.StatusOK, images) +} diff --git a/internal/api/router.go b/internal/api/router.go index bc80e41..19144bf 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -171,6 +171,7 @@ func (s *Server) Router() chi.Router { r.Delete("/", s.deleteRegistry) r.Post("/test", s.testRegistry) r.Get("/tags/*", s.listRegistryTags) + r.Get("/images", s.listRegistryImages) }) // Settings endpoints. diff --git a/internal/registry/gitea.go b/internal/registry/gitea.go index 63dc95c..638cc95 100644 --- a/internal/registry/gitea.go +++ b/internal/registry/gitea.go @@ -41,6 +41,80 @@ func NewGiteaClient(baseURL, token string) *GiteaClient { } } +// ListImages returns all container images (packages) for the given owner. +// It queries GET /api/v1/packages/{owner}?type=container and paginates +// through all results, returning a RegistryImage for each unique package. +func (c *GiteaClient) ListImages(ctx context.Context, owner string) ([]RegistryImage, error) { + if owner == "" { + return nil, fmt.Errorf("owner is required for listing images") + } + + // Extract the registry host from baseURL to build full references. + host := c.baseURL + for _, prefix := range []string{"https://", "http://"} { + host = strings.TrimPrefix(host, prefix) + } + host = strings.TrimRight(host, "/") + + var images []RegistryImage + seen := make(map[string]bool) + page := 1 + limit := 50 + + for { + url := fmt.Sprintf("%s/api/v1/packages/%s?type=container&page=%d&limit=%d", + c.baseURL, owner, page, limit) + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("create request: %w", err) + } + + if c.token != "" { + req.Header.Set("Authorization", "token "+c.token) + } + req.Header.Set("Accept", "application/json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("execute request: %w", err) + } + + body, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return nil, fmt.Errorf("read response body: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status %d: %s", resp.StatusCode, string(body)) + } + + var packages []giteaPackageListEntry + if err := json.Unmarshal(body, &packages); err != nil { + return nil, fmt.Errorf("decode package list: %w", err) + } + + for _, p := range packages { + if !seen[p.Name] { + seen[p.Name] = true + images = append(images, RegistryImage{ + Name: p.Name, + Owner: owner, + FullRef: fmt.Sprintf("%s/%s/%s", host, owner, p.Name), + }) + } + } + + if len(packages) < limit { + break + } + page++ + } + + return images, nil +} + // ListTags returns all available tags for the given container image. // The image should be in the format "owner/package-name" or // "registry-host/owner/package-name" (the registry host prefix is stripped). diff --git a/internal/registry/registry.go b/internal/registry/registry.go index 21aa8f1..4471b3b 100644 --- a/internal/registry/registry.go +++ b/internal/registry/registry.go @@ -8,6 +8,13 @@ import ( "strings" ) +// RegistryImage represents a container image discovered from a registry. +type RegistryImage struct { + Name string `json:"name"` + Owner string `json:"owner"` + FullRef string `json:"full_ref"` // e.g., "git.example.com/owner/my-app" +} + // Client defines the interface for interacting with a container image registry. type Client interface { // ListTags returns all available tags for the given image. @@ -16,6 +23,10 @@ type Client interface { // GetLatestTag returns the most recently created tag that matches the given // glob pattern. Returns an empty string and no error if no tags match. GetLatestTag(ctx context.Context, image string, pattern string) (string, error) + + // ListImages returns all container images available in the registry for the + // given owner. Returns an error if the registry does not support image listing. + ListImages(ctx context.Context, owner string) ([]RegistryImage, error) } // DeployTriggerer is called by the poller when a new tag is detected for a diff --git a/internal/store/registries.go b/internal/store/registries.go index bf5c524..08528cc 100644 --- a/internal/store/registries.go +++ b/internal/store/registries.go @@ -60,7 +60,7 @@ func (s *Store) GetRegistryByName(name string) (Registry, error) { // GetAllRegistries returns every registry ordered by name. func (s *Store) GetAllRegistries() ([]Registry, error) { rows, err := s.db.Query( - `SELECT id, name, url, type, token, created_at, updated_at + `SELECT id, name, url, type, token, owner, created_at, updated_at FROM registries ORDER BY name`, ) if err != nil { @@ -71,7 +71,7 @@ func (s *Store) GetAllRegistries() ([]Registry, error) { var registries []Registry for rows.Next() { var r Registry - if err := rows.Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.CreatedAt, &r.UpdatedAt); err != nil { + if err := rows.Scan(&r.ID, &r.Name, &r.URL, &r.Type, &r.Token, &r.Owner, &r.CreatedAt, &r.UpdatedAt); err != nil { return nil, fmt.Errorf("scan registry: %w", err) } registries = append(registries, r) @@ -83,9 +83,9 @@ func (s *Store) GetAllRegistries() ([]Registry, error) { func (s *Store) UpdateRegistry(r Registry) error { r.UpdatedAt = now() result, err := s.db.Exec( - `UPDATE registries SET name=?, url=?, type=?, token=?, updated_at=? + `UPDATE registries SET name=?, url=?, type=?, token=?, owner=?, updated_at=? WHERE id=?`, - r.Name, r.URL, r.Type, r.Token, r.UpdatedAt, r.ID, + r.Name, r.URL, r.Type, r.Token, r.Owner, r.UpdatedAt, r.ID, ) if err != nil { return fmt.Errorf("update registry: %w", err) diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index f6755c1..ec68844 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -7,6 +7,7 @@ import type { Project, ProjectDetail, Registry, + RegistryImage, Settings, StageEnv, Volume @@ -220,6 +221,10 @@ export function listRegistryTags(registryId: string, image: string): Promise(`/api/registries/${registryId}/tags/${encodeURIComponent(image)}`); } +export function listRegistryImages(registryId: string): Promise { + return get(`/api/registries/${registryId}/images`); +} + // ── Settings ──────────────────────────────────────────────────────── export function getSettings(): Promise { diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index 51e19f9..91c1396 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -39,7 +39,12 @@ "healthcheck": "Healthcheck Path", "nameRequired": "Name and image are required.", "loadFailed": "Failed to load projects", - "createFailed": "Failed to create project" + "createFailed": "Failed to create project", + "browseImages": "Browse Images", + "selectImage": "Select an image", + "noImages": "No images found", + "loadingImages": "Loading images...", + "imageLoadFailed": "Failed to load images" }, "projectDetail": { "deleteProject": "Delete Project", @@ -158,7 +163,12 @@ "inspectedSuccess": "Image inspected successfully", "deployedSuccess": "Deployed {name} successfully!", "inspectFailed": "Failed to inspect image", - "deployFailed": "Deployment failed" + "deployFailed": "Deployment failed", + "browseImages": "Browse", + "selectImage": "Select an image from a registry", + "noImages": "No images found", + "loadingImages": "Loading...", + "imageLoadFailed": "Failed to load images" }, "settings": { "title": "Settings", @@ -214,6 +224,8 @@ "token": "Token", "tokenHelpNew": "API token for authentication", "tokenHelpEdit": "Leave empty to keep the existing token", + "owner": "Owner", + "ownerHelp": "Package owner (e.g., username or organization) for image listing", "save": "Save", "saving": "Saving...", "update": "Update", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 48cce5e..3f7f78c 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -39,7 +39,12 @@ "healthcheck": "Путь проверки здоровья", "nameRequired": "Название и образ обязательны.", "loadFailed": "Не удалось загрузить проекты", - "createFailed": "Не удалось создать проект" + "createFailed": "Не удалось создать проект", + "browseImages": "Обзор образов", + "selectImage": "Выберите образ", + "noImages": "Образы не найдены", + "loadingImages": "Загрузка образов...", + "imageLoadFailed": "Не удалось загрузить образы" }, "projectDetail": { "deleteProject": "Удалить проект", @@ -158,7 +163,12 @@ "inspectedSuccess": "Образ успешно проверен", "deployedSuccess": "{name} успешно развёрнут!", "inspectFailed": "Не удалось проверить образ", - "deployFailed": "Развёртывание не удалось" + "deployFailed": "Развёртывание не удалось", + "browseImages": "Обзор", + "selectImage": "Выберите образ из реестра", + "noImages": "Образы не найдены", + "loadingImages": "Загрузка...", + "imageLoadFailed": "Не удалось загрузить образы" }, "settings": { "title": "Настройки", @@ -214,6 +224,8 @@ "token": "Токен", "tokenHelpNew": "API-токен для аутентификации", "tokenHelpEdit": "Оставьте пустым, чтобы сохранить текущий токен", + "owner": "Владелец", + "ownerHelp": "Владелец пакетов (имя пользователя или организации) для списка образов", "save": "Сохранить", "saving": "Сохранение...", "update": "Обновить", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 5955ad0..204398c 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -79,10 +79,19 @@ export interface Registry { url: string; type: string; token: string; + owner: string; + has_token?: boolean; created_at: string; updated_at: string; } +/** A container image discovered from a registry. */ +export interface RegistryImage { + name: string; + owner: string; + full_ref: string; +} + export interface Settings { domain: string; server_ip: string; diff --git a/web/src/routes/deploy/+page.svelte b/web/src/routes/deploy/+page.svelte index 0c093b8..b610a2a 100644 --- a/web/src/routes/deploy/+page.svelte +++ b/web/src/routes/deploy/+page.svelte @@ -1,6 +1,6 @@