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
This commit is contained in:
2026-03-28 14:04:11 +03:00
parent 77251c540b
commit 37e251da85
12 changed files with 355 additions and 18 deletions
+52
View File
@@ -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)
}
+1
View File
@@ -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.
+74
View File
@@ -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).
+11
View File
@@ -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
+4 -4
View File
@@ -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)