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
+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