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:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user