diff --git a/README.md b/README.md index 0416aae..d8dba9a 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Registry-TUI -**Registry-TUI** es una interfaz de usuario en terminal (TUI) ligera, rápida y de alto contraste escrita en `Go`. +**Registry-TUI** es una interfaz de usuario en terminal (TUI) ligera, rápida y de alto contraste escrita en `Go` que permite gestionar las imágenes de contenedores tanto locales como de otros Registry. Está inspirada en la filosofía de *Midnight Commander* y diseñada específicamente para la gestión bidireccional y eficiente de imágenes Docker locales y registros remotos (Docker Registry v2). @@ -15,15 +15,24 @@ Está inspirada en la filosofía de *Midnight Commander* y diseñada específica * **Tageado / Reestructuración (`t`):** Capacidad de duplicar o reetiquetar imágenes con rutas y versiones personalizadas. * **Push Automatizado (`p`):** Envío directo de imágenes locales al Registry utilizando la CLI nativa de Docker para garantizar la sincronización de capas (*Cross-Repository Blob Mount*) y limpieza de etiquetas temporales. * **Refresco Dinámico:** Recálculo y sincronización de datos en tiempo real al cambiar de modo. +* **Control de conexión a Registry:** Si no hay conexión al Registry indicado, se puede usar para la gestión de la imagenes locales al equipo. --- ## Capturas de Pantalla +* Uso de Registry v2 + ![](capturas/registry-tui_REGISTRY.png) +* Imágenes locales del equipo + ![](capturas/registry-tui_LOCAL.png) +* Control de fallo de conexión con Registry + +![](capturas/registry-tui_ERROR.png) + --- ## Requisitos Técnicos @@ -43,7 +52,7 @@ Está inspirada en la filosofía de *Midnight Commander* y diseñada específica 2. Compila el binario ejecutable: -```Bash +```bash go build -o registry-tui main.go ``` @@ -54,13 +63,19 @@ sudo mv registry-tui /usr/local/bin/ ### Forma de Invocación y Uso -Ejecuta la herramienta directamente desde la terminal especificando opcionalmente la URL de tu registro (por defecto suele apuntar a http://localhost:5000): -```Bash -./registry-tui [opciones] +Ejecuta la herramienta directamente desde la terminal, se intentará conectar al registry local que se esté ejecutando en el puerto `5000` (por defecto suele apuntar a http://localhost:5000) + +```bash +./registry-tui +``` + +Si necesitas usar otra URL de un servidor de registro (simplemente indicalo en las opciones): +```bash +./registry-tui http://192.168.1.50:5000 ``` Opciones de Ayuda y CLIMuestra el panel de ayuda y flags disponibles: -```Bash +```bash ./registry-tui --help ``` @@ -96,4 +111,18 @@ Una vez dentro de la interfaz TUI, dispones de una barra de acciones inferior es * Selecciona la imagen o versión específica que deseas eliminar. * Presiona la tecla `d`. * Aparecerá una pantalla de confirmación advirtiendo sobre la irreversibilidad de la acción. - * Pulsa `y` para confirmar el borrado, o `n` para cancelar. \ No newline at end of file + * Pulsa `y` para confirmar el borrado, o `n` para cancelar. + + +## Uso de un Registry local para pruebas + +### Iniciar + +Puede usar el script `start_Registry.sh` se encarga de crear (si no existe) un directorio en el que almacenar las imagenes y de iniciar un `registry:2` de forma local. + +Este *Registry* lo puede usar desde la consola con los comandos `docker push` o `docker pull` de forma normal pero atacando a http://localhost:5000 + +### Detener + +Para detenerlo es tan sencillo como ejecutar el script `stop_Registry.sh` + diff --git a/capturas/registry-tui_ERROR.png b/capturas/registry-tui_ERROR.png new file mode 100644 index 0000000..0f8d90d Binary files /dev/null and b/capturas/registry-tui_ERROR.png differ diff --git a/client/registry.go b/client/registry.go new file mode 100644 index 0000000..65ba06c --- /dev/null +++ b/client/registry.go @@ -0,0 +1,353 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "strings" + "os/exec" +) + +type RegistryClient struct { + BaseURL string + HTTPClient *http.Client +} + +func NewRegistryClient(baseURL string) *RegistryClient { + return &RegistryClient{ + BaseURL: baseURL, + HTTPClient: &http.Client{}, + } +} + +type CatalogResponse struct { + Repositories []string `json:"repositories"` +} + +type TagsResponse struct { + Name string `json:"name"` + Tags []string `json:"tags"` +} + +type ManifestV2 struct { + Config struct { + Digest string `json:"digest"` + } `json:"config"` + Layers []struct { + Digest string `json:"digest"` + } `json:"layers"` +} + +type LocalImageAPI struct { + RepoTags []string `json:"RepoTags"` +} + +type ImageRowData struct { + Project string + RepoName string + ImageName string + Tag string +} + +func (c *RegistryClient) GetCatalog() ([]string, error) { + url := fmt.Sprintf("%s/v2/_catalog", c.BaseURL) + resp, err := c.HTTPClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("el registro devolvió el código de estado %d", resp.StatusCode) + } + + var catalog CatalogResponse + if err := json.NewDecoder(resp.Body).Decode(&catalog); err != nil { + return nil, err + } + + return catalog.Repositories, nil +} + +func (c *RegistryClient) GetTags(repo string) ([]string, error) { + url := fmt.Sprintf("%s/v2/%s/tags/list", c.BaseURL, repo) + resp, err := c.HTTPClient.Get(url) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("el registro devolvió el código de estado %d", resp.StatusCode) + } + + var tagsResp TagsResponse + if err := json.NewDecoder(resp.Body).Decode(&tagsResp); err != nil { + return nil, err + } + + return tagsResp.Tags, nil +} + +func (c *RegistryClient) DeleteManifest(repo, tag string) error { + manifestURL := fmt.Sprintf("%s/v2/%s/manifests/%s", c.BaseURL, repo, tag) + req, err := http.NewRequest("HEAD", manifestURL, nil) + if err != nil { + return err + } + req.Header.Set("Accept", "application/vnd.docker.distribution.manifest.v2+json") + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("no se pudo obtener el manifest para borrar, estado: %d", resp.StatusCode) + } + + digest := resp.Header.Get("Docker-Content-Digest") + if digest == "" { + return fmt.Errorf("el registro no devolvió el hash (Digest) necesario para el borrado") + } + + deleteURL := fmt.Sprintf("%s/v2/%s/manifests/%s", c.BaseURL, repo, digest) + delReq, err := http.NewRequest("DELETE", deleteURL, nil) + if err != nil { + return err + } + + delResp, err := c.HTTPClient.Do(delReq) + if err != nil { + return err + } + defer delResp.Body.Close() + + if delResp.StatusCode != http.StatusAccepted && delResp.StatusCode != http.StatusOK { + return fmt.Errorf("el servidor rechazó el borrado (Estado HTTP %d)", delResp.StatusCode) + } + + return nil +} + +func (c *RegistryClient) CopyManifest(sourceRepo, sourceTag, destRepo, destTag string) error { + manifestURL := fmt.Sprintf("%s/v2/%s/manifests/%s", c.BaseURL, sourceRepo, sourceTag) + req, err := http.NewRequest("GET", manifestURL, nil) + if err != nil { + return err + } + + manifestType := "application/vnd.docker.distribution.manifest.v2+json" + req.Header.Set("Accept", manifestType) + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("no se pudo obtener el manifest origen, estado: %d", resp.StatusCode) + } + + bodyBytes, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + + var manifest ManifestV2 + if err := json.Unmarshal(bodyBytes, &manifest); err != nil { + return err + } + + var digests []string + if manifest.Config.Digest != "" { + digests = append(digests, manifest.Config.Digest) + } + for _, layer := range manifest.Layers { + if layer.Digest != "" { + digests = append(digests, layer.Digest) + } + } + + for _, digest := range digests { + mountURL := fmt.Sprintf("%s/v2/%s/blobs/uploads/?mount=%s&from=%s", c.BaseURL, destRepo, digest, sourceRepo) + mountReq, err := http.NewRequest("POST", mountURL, nil) + if err != nil { + return err + } + + mountResp, err := c.HTTPClient.Do(mountReq) + if err != nil { + return err + } + mountResp.Body.Close() + } + + putURL := fmt.Sprintf("%s/v2/%s/manifests/%s", c.BaseURL, destRepo, destTag) + putReq, err := http.NewRequest("PUT", putURL, bytes.NewReader(bodyBytes)) + if err != nil { + return err + } + putReq.Header.Set("Content-Type", manifestType) + + putResp, err := c.HTTPClient.Do(putReq) + if err != nil { + return err + } + defer putResp.Body.Close() + + if putResp.StatusCode != http.StatusCreated && putResp.StatusCode != http.StatusOK { + return fmt.Errorf("no se pudo reestructurar la imagen, estado: %d", putResp.StatusCode) + } + + return nil +} + +func (c *RegistryClient) GetLocalImages() ([]ImageRowData, error) { + dockerClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", "/var/run/docker.sock") + }, + }, + } + + resp, err := dockerClient.Get("http://unix/images/json") + if err != nil { + return nil, fmt.Errorf("no se pudo conectar con el demonio Docker: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("error al listar imágenes locales, estado: %d", resp.StatusCode) + } + + var images []LocalImageAPI + if err := json.NewDecoder(resp.Body).Decode(&images); err != nil { + return nil, err + } + + var rows []ImageRowData + for _, img := range images { + for _, tagFull := range img.RepoTags { + if tagFull == ":" { + continue + } + lastColon := strings.LastIndex(tagFull, ":") + if lastColon == -1 { + continue + } + repo := tagFull[:lastColon] + tag := tagFull[lastColon+1:] + + var project, imageName string + parts := strings.Split(repo, "/") + if len(parts) > 1 { + project = parts[0] + imageName = strings.Join(parts[1:], "/") + } else { + project = "" + imageName = repo + } + + rows = append(rows, ImageRowData{ + Project: project, + RepoName: repo, + ImageName: imageName, + Tag: tag, + }) + } + } + return rows, nil +} + +func (c *RegistryClient) DeleteLocalImage(repo, tag string) error { + dockerClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", "/var/run/docker.sock") + }, + }, + } + + imageRef := fmt.Sprintf("%s:%s", repo, tag) + reqURL := fmt.Sprintf("http://unix/images/%s", imageRef) + req, err := http.NewRequest("DELETE", reqURL, nil) + if err != nil { + return err + } + + resp, err := dockerClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("no se pudo borrar la imagen local, estado: %d", resp.StatusCode) + } + return nil +} + +func (c *RegistryClient) TagLocalImage(sourceRepo, sourceTag, destRepo, destTag string) error { + dockerClient := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + return net.Dial("unix", "/var/run/docker.sock") + }, + }, + } + + sourceRef := fmt.Sprintf("%s:%s", sourceRepo, sourceTag) + reqURL := fmt.Sprintf("http://unix/images/%s/tag?repo=%s&tag=%s", sourceRef, destRepo, destTag) + req, err := http.NewRequest("POST", reqURL, nil) + if err != nil { + return err + } + + resp, err := dockerClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { + return fmt.Errorf("no se pudo etiquetar la imagen local, estado: %d", resp.StatusCode) + } + return nil +} + +func (c *RegistryClient) PushLocalImage(repo, tag string) error { + registryHost := strings.TrimPrefix(c.BaseURL, "http://") + registryHost = strings.TrimPrefix(registryHost, "https://") + + sourceRef := fmt.Sprintf("%s:%s", repo, tag) + targetRef := fmt.Sprintf("%s/%s:%s", registryHost, repo, tag) + + // 1. Etiquetar la imagen local para el registro + cmdTag := exec.Command("docker", "tag", sourceRef, targetRef) + if err := cmdTag.Run(); err != nil { + return fmt.Errorf("error al etiquetar la imagen para el registro: %v", err) + } + + // 2. Ejecutar el push nativo de la CLI de Docker + cmdPush := exec.Command("docker", "push", targetRef) + if err := cmdPush.Run(); err != nil { + return fmt.Errorf("error al ejecutar docker push: %v", err) + } + + // 3. Limpiar/eliminar la etiqueta temporal del registro creada en local + cmdRmi := exec.Command("docker", "rmi", targetRef) + if err := cmdRmi.Run(); err != nil { + // No devolvemos error crítico aquí para evitar interrumpir si el push ya fue exitoso, + // pero la etiqueta temporal se limpia de forma automática. + } + + return nil +} diff --git a/compilar.sh b/compilar.sh new file mode 100755 index 0000000..19907b7 --- /dev/null +++ b/compilar.sh @@ -0,0 +1 @@ +go build -o registry-tui main.go diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f01c53f --- /dev/null +++ b/go.mod @@ -0,0 +1,30 @@ +module registry-tui + +go 1.24.4 + +require ( + github.com/atotto/clipboard v0.1.4 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/bubbles v1.0.0 // indirect + github.com/charmbracelet/bubbletea v1.3.10 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..48170d0 --- /dev/null +++ b/go.sum @@ -0,0 +1,65 @@ +github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= +github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k= +golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..1f37041 --- /dev/null +++ b/main.go @@ -0,0 +1,44 @@ +package main + +import ( + "fmt" + "os" + "registry-tui/ui" + + tea "github.com/charmbracelet/bubbletea" +) + +const AppVersion = "1.3 SoloConLinux" + +func printHelp() { + fmt.Printf("Registry-TUI v%s - Gestor TUI para Docker Registry v2\n\n", AppVersion) + fmt.Println("Uso:") + fmt.Println(" ./registry-tui [URL_DEL_REGISTRO]") + fmt.Println("\nEjemplos:") + fmt.Println(" ./registry-tui (Usa por defecto http://localhost:5000)") + fmt.Println(" ./registry-tui http://192.168.1.50:5000 (Conecta a un registro remoto o personalizado)") + fmt.Println("\nOpciones:") + fmt.Println(" -h, --help Muestra esta ayuda y sale") +} + +func main() { + // Comprobar si se solicita ayuda por argumentos + if len(os.Args) > 1 && (os.Args[1] == "--help" || os.Args[1] == "-h") { + printHelp() + os.Exit(0) + } + + registryURL := "http://localhost:5000" + if len(os.Args) > 1 { + registryURL = os.Args[1] + } + + initialModel := ui.NewModel(registryURL, AppVersion) + + p := tea.NewProgram(initialModel, tea.WithAltScreen()) + + if _, err := p.Run(); err != nil { + fmt.Printf("Error al ejecutar la aplicación: %v\n", err) + os.Exit(1) + } +} diff --git a/registry-tui b/registry-tui new file mode 100755 index 0000000..51f4700 Binary files /dev/null and b/registry-tui differ diff --git a/start_Registry.sh b/start_Registry.sh new file mode 100755 index 0000000..139f484 --- /dev/null +++ b/start_Registry.sh @@ -0,0 +1,17 @@ +#!/bin/bash +# Crear directorio de almacenamiento de imagenes +mkdir -p "$(pwd)/data-images" 2> /dev/null +# Detener y borrar contenedor previo +./stop_Registry.sh +# Iniciar en local +echo "Iniciando REGISTRY en el puerto 5000" +docker run -d \ + -p 5000:5000 \ + --restart always \ + --name local-registry \ + -e REGISTRY_STORAGE_DELETE_ENABLED=true \ + -v "$(pwd)/data-images:/var/lib/registry" \ + registry:2 +echo +echo "Registry v.2 iniciado en puerto local 5000" +echo diff --git a/stop_Registry.sh b/stop_Registry.sh new file mode 100755 index 0000000..95c0c9a --- /dev/null +++ b/stop_Registry.sh @@ -0,0 +1,5 @@ +#!/bin/bash +echo "Parando y borrando Registry Local" +docker stop local-registry 2> /dev/null +docker rm local-registry 2> /dev/null + diff --git a/ui/model.go b/ui/model.go new file mode 100644 index 0000000..98cf89a --- /dev/null +++ b/ui/model.go @@ -0,0 +1,179 @@ +package ui + +import ( + "registry-tui/client" + "sort" + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +type ViewState int + +const ( + StateLoading ViewState = iota + StateMainTable + StateConfirmDelete + StateNewTag + StateError + StateRegistryDown +) + +type AppMode int + +const ( + ModeRegistry AppMode = iota + ModeLocal +) + +type ImageRow = client.ImageRowData + +type Model struct { + state ViewState + mode AppMode + client *client.RegistryClient + rows []ImageRow + localRows []ImageRow + registryRows []ImageRow + cursor int + err error + statusMsg string + textInput textinput.Model + version string +} + +func NewModel(registryURL string, version string) Model { + ti := textinput.New() + ti.Placeholder = "ej. proyecto-web/alpine:v1.0" + ti.CharLimit = 128 + ti.Width = 35 + + return Model{ + state: StateLoading, + mode: ModeRegistry, + client: client.NewRegistryClient(registryURL), + textInput: ti, + version: version, + } +} + +func (m Model) Init() tea.Cmd { + return fetchRegistryImagesCmd(m.client) +} + +type imagesMsg struct { + rows []ImageRow + mode AppMode +} +type errorMsg error +type successMsg string + +func fetchRegistryImagesCmd(c *client.RegistryClient) tea.Cmd { + return func() tea.Msg { + repos, err := c.GetCatalog() + if err != nil { + return errorMsg(err) + } + + var rows []ImageRow + for _, repo := range repos { + tags, err := c.GetTags(repo) + if err != nil { + continue + } + + var project, imageName string + parts := strings.Split(repo, "/") + if len(parts) > 1 { + project = parts[0] + imageName = strings.Join(parts[1:], "/") + } else { + project = "" + imageName = repo + } + + for _, tag := range tags { + rows = append(rows, ImageRow{ + Project: project, + RepoName: repo, + ImageName: imageName, + Tag: tag, + }) + } + } + + sortRows(rows) + return imagesMsg{rows: rows, mode: ModeRegistry} + } +} + +func fetchLocalImagesCmd(c *client.RegistryClient) tea.Cmd { + return func() tea.Msg { + rows, err := c.GetLocalImages() + if err != nil { + return errorMsg(err) + } + sortRows(rows) + return imagesMsg{rows: rows, mode: ModeLocal} + } +} + +func sortRows(rows []ImageRow) { + sort.Slice(rows, func(i, j int) bool { + if rows[i].Project == rows[j].Project { + return rows[i].RepoName < rows[j].RepoName + } + return rows[i].Project < rows[j].Project + }) +} + +func deleteTagCmd(c *client.RegistryClient, repo, tag string) tea.Cmd { + return func() tea.Msg { + err := c.DeleteManifest(repo, tag) + if err != nil { + return errorMsg(err) + } + return successMsg("Tag borrado con éxito del registro") + } +} + +func deleteLocalImageCmd(c *client.RegistryClient, repo, tag string) tea.Cmd { + return func() tea.Msg { + err := c.DeleteLocalImage(repo, tag) + if err != nil { + return errorMsg(err) + } + return successMsg("Imagen local borrada con éxito") + } +} + +func copyImageCmd(c *client.RegistryClient, sourceRepo, sourceTag, destRepo, destTag string) tea.Cmd { + return func() tea.Msg { + err := c.CopyManifest(sourceRepo, sourceTag, destRepo, destTag) + if err != nil { + return errorMsg(err) + } + return successMsg("Imagen reestructurada con éxito en el registro") + } +} + +func tagLocalImageCmd(c *client.RegistryClient, sourceRepo, sourceTag, destRepo, destTag string) tea.Cmd { + return func() tea.Msg { + err := c.TagLocalImage(sourceRepo, sourceTag, destRepo, destTag) + if err != nil { + return errorMsg(err) + } + return successMsg("Imagen local etiquetada con éxito") + } +} + +func pushImageCmd(c *client.RegistryClient, repo, tag string) tea.Cmd { + return func() tea.Msg { + err := c.PushLocalImage(repo, tag) + if err != nil { + return errorMsg(err) + } + return successMsg("Imagen subida (Push) al registro con éxito") + } +} diff --git a/ui/update.go b/ui/update.go new file mode 100644 index 0000000..1e3f061 --- /dev/null +++ b/ui/update.go @@ -0,0 +1,175 @@ +package ui + +import ( + "strings" + + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" +) + +func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + // 1. Manejo especial cuando el Registry está caído (StateRegistryDown) + if m.state == StateRegistryDown { + if keyMsg, ok := msg.(tea.KeyMsg); ok { + switch keyMsg.String() { + case "enter", "L", "l": + m.state = StateLoading + m.mode = ModeLocal + m.statusMsg = "" + return m, fetchLocalImagesCmd(m.client) + case "q", "ctrl+c", "CTRL+C": + return m, tea.Quit + } + } + return m, nil + } + + switch msg := msg.(type) { + case tea.KeyMsg: + if m.state == StateNewTag { + switch msg.String() { + case "esc": + m.state = StateMainTable + m.textInput.Reset() + return m, nil + case "enter": + inputVal := m.textInput.Value() + if inputVal != "" { + lastColon := strings.LastIndex(inputVal, ":") + var destRepo, destTag string + if lastColon != -1 { + destRepo = inputVal[:lastColon] + destTag = inputVal[lastColon+1:] + } else { + destRepo = inputVal + destTag = "latest" + } + + if destRepo != "" && destTag != "" { + selectedRow := m.rows[m.cursor] + m.state = StateLoading + m.textInput.Reset() + if m.mode == ModeLocal { + return m, tagLocalImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag, destRepo, destTag) + } + return m, copyImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag, destRepo, destTag) + } + } + return m, nil + } + var cmd tea.Cmd + m.textInput, cmd = m.textInput.Update(msg) + return m, cmd + } + + switch msg.String() { + case "ctrl+c", "q": + if m.state == StateConfirmDelete { + m.state = StateMainTable + return m, nil + } + return m, tea.Quit + + case "up", "k": + if m.cursor > 0 { + m.cursor-- + } + + case "down", "j": + if m.cursor < len(m.rows)-1 { + m.cursor++ + } + + case "r": + if m.state == StateMainTable && m.mode != ModeRegistry { + m.state = StateLoading + m.cursor = 0 + m.statusMsg = "" + m.mode = ModeRegistry + return m, fetchRegistryImagesCmd(m.client) + } + + case "l": + if m.state == StateMainTable && m.mode != ModeLocal { + m.state = StateLoading + m.cursor = 0 + m.statusMsg = "" + m.mode = ModeLocal + return m, fetchLocalImagesCmd(m.client) + } + + case "d": + if m.state == StateMainTable && len(m.rows) > 0 { + m.state = StateConfirmDelete + } + + case "t": + if m.state == StateMainTable && len(m.rows) > 0 { + m.state = StateNewTag + m.textInput.Focus() + return m, textinput.Blink + } + + case "p": + if m.state == StateMainTable && m.mode == ModeLocal && len(m.rows) > 0 { + selectedRow := m.rows[m.cursor] + m.state = StateLoading + return m, pushImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag) + } + + case "y": + if m.state == StateConfirmDelete { + selectedRow := m.rows[m.cursor] + m.state = StateLoading + if m.mode == ModeLocal { + return m, deleteLocalImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag) + } + return m, deleteTagCmd(m.client, selectedRow.RepoName, selectedRow.Tag) + } + + case "n": + if m.state == StateConfirmDelete { + m.state = StateMainTable + } + } + + case imagesMsg: + m.rows = msg.rows + if msg.mode == ModeRegistry { + m.registryRows = msg.rows + } else { + m.localRows = msg.rows + } + m.state = StateMainTable + m.cursor = 0 + + case successMsg: + m.statusMsg = string(msg) + m.state = StateLoading + if m.mode == ModeRegistry { + return m, fetchRegistryImagesCmd(m.client) + } else { + return m, fetchLocalImagesCmd(m.client) + } + + case errorMsg: + errVal := error(msg) + errMsgStr := errVal.Error() + + // Comprobamos si el error es de conexión con el registro + if strings.Contains(errMsgStr, "connection refused") || + strings.Contains(errMsgStr, "dial tcp") || + strings.Contains(errMsgStr, "Get \"http://localhost:5000") { + m.state = StateRegistryDown + m.statusMsg = errMsgStr + return m, nil + } + + // Error estándar para otros casos + m.err = errVal + m.state = StateError + return m, nil + } + + return m, nil +} diff --git a/ui/view.go b/ui/view.go new file mode 100644 index 0000000..20f5f70 --- /dev/null +++ b/ui/view.go @@ -0,0 +1,198 @@ +package ui + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" +) + +var ( + titleStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#900C3F")). + Padding(0, 1) + + headerBgStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#1F618D")) + + localHeaderBgStyle = lipgloss.NewStyle(). + Bold(true). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#B7950B")) + + selectedRowStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#2980B9")). + Bold(true) + + normalProjectStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#F4D03F")). + Bold(true) + + normalImageStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#D5D8DC")) + + fullNameStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#00FFFF")). + Bold(true) + + footerKeyStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#000000")). + Background(lipgloss.Color("#F4D03F")). + Bold(true) + + footerDescStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FFFFFF")). + Background(lipgloss.Color("#2C3E50")) + + errorBoxStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#FF5F56")). + Bold(true) + + successStyle = lipgloss.NewStyle(). + Foreground(lipgloss.Color("#2ECC71")). + Bold(true) +) + +func (m Model) View() string { + if m.state == StateRegistryDown { + return m.viewRegistryDown() + } + + switch m.state { + case StateLoading: + modeText := "del Registro Docker" + if m.mode == ModeLocal { + modeText = "Locales de Docker" + } + return fmt.Sprintf("\n Cargando imágenes %s...\n", modeText) + + case StateError: + return fmt.Sprintf("\n %s\n\n [Presiona 'q' para salir / 'r' o 'l' para cambiar de modo]\n", errorBoxStyle.Render("Error: "+m.err.Error())) + + case StateMainTable: + s := "" + modeStr := "REGISTRY" + styleHeader := headerBgStyle + if m.mode == ModeLocal { + modeStr = "LOCAL" + styleHeader = localHeaderBgStyle + } + + titleBar := fmt.Sprintf(" REGISTRY-TUI v%s [%s] ", m.version, modeStr) + s += styleHeader.Width(70).Render(titleBar) + "\n" + + s += "┌───────────────────────┬──────────────────────────────────────────┐\n" + s += fmt.Sprintf("│ %-21s │ %-40s │\n", "PROYECTO / RUTA", "IMAGEN : VERSION") + s += "├───────────────────────┼──────────────────────────────────────────┤\n" + + if len(m.rows) == 0 { + msgEmpty := "No hay imágenes en el registro" + if m.mode == ModeLocal { + msgEmpty = "No se encontraron imágenes locales" + } + s += fmt.Sprintf("│ %-21s │ %-40s │\n", "", msgEmpty) + s += "└───────────────────────┴──────────────────────────────────────────┘\n" + } else { + for i, row := range m.rows { + displayProj := row.Project + if displayProj == "(raíz)" { + displayProj = "" + } + + imageVersion := fmt.Sprintf("%s:%s", row.ImageName, row.Tag) + isSelected := (m.cursor == i) + + var lineContent string + if isSelected { + projFormatted := fmt.Sprintf(" %-21s ", displayProj) + imgFormatted := fmt.Sprintf(" %-40s ", imageVersion) + + renderedProj := selectedRowStyle.Render(projFormatted) + renderedImg := selectedRowStyle.Render(imgFormatted) + + lineContent = fmt.Sprintf("│%s│%s│", renderedProj, renderedImg) + } else { + projFormatted := normalProjectStyle.Render(fmt.Sprintf("%-21s", displayProj)) + imgFormatted := normalImageStyle.Render(fmt.Sprintf("%-40s", imageVersion)) + + lineContent = fmt.Sprintf("│ %s │ %s │", projFormatted, imgFormatted) + } + + s += lineContent + "\n" + + if i < len(m.rows)-1 { + s += "├───────────────────────┼──────────────────────────────────────────┤\n" + } + } + s += "└───────────────────────┴──────────────────────────────────────────┘\n" + } + + // Ruta completa seleccionada + if len(m.rows) > 0 { + selected := m.rows[m.cursor] + fullRef := fmt.Sprintf("%s:%s", selected.RepoName, selected.Tag) + s += fmt.Sprintf(" Ruta completa: %s\n", fullNameStyle.Render(fullRef)) + } else { + s += "\n" + } + + total := len(m.rows) + statusInfo := fmt.Sprintf(" Total: %d imágenes (%s)", total, modeStr) + if m.statusMsg != "" { + statusInfo = " " + successStyle.Render("✔ "+m.statusMsg) + } + s += fmt.Sprintf("%-70s\n\n", statusInfo) + + // Barra de botones inferior estilo MC uniforme para ambos modos + btnR := footerKeyStyle.Render(" R ") + footerDescStyle.Render(" Registry ") + btnL := footerKeyStyle.Render(" L ") + footerDescStyle.Render(" Local ") + btnT := footerKeyStyle.Render(" t ") + footerDescStyle.Render(" Tagear ") + btnD := footerKeyStyle.Render(" d ") + footerDescStyle.Render(" Borrar ") + btnP := footerKeyStyle.Render(" p ") + footerDescStyle.Render(" Push ") + btnQ := footerKeyStyle.Render(" q ") + footerDescStyle.Render(" Salir ") + + if m.mode == ModeLocal { + s += fmt.Sprintf(" %s %s %s %s %s %s\n", btnR, btnL, btnP, btnT, btnD, btnQ) + } else { + s += fmt.Sprintf(" %s %s %s %s %s\n", btnR, btnL, btnT, btnD, btnQ) + } + + return s + + case StateConfirmDelete: + selectedRow := m.rows[m.cursor] + s := headerBgStyle.Width(50).Render(" Confirmar Borrado ") + "\n\n" + s += fmt.Sprintf(" ¿Estás seguro de que deseas borrar la imagen:\n -> %s:%s?\n\n", selectedRow.RepoName, selectedRow.Tag) + if m.mode == ModeLocal { + s += errorBoxStyle.Render(" ¡Esta acción eliminará la imagen de tu sistema local!\n\n") + } else { + s += errorBoxStyle.Render(" ¡Esta acción eliminará el manifest permanentemente del registro!\n\n") + } + s += footerKeyStyle.Render(" y ") + footerDescStyle.Render(" Sí, borrar ") + " " + footerKeyStyle.Render(" n ") + footerDescStyle.Render(" Cancelar ") + return s + + case StateNewTag: + selectedRow := m.rows[m.cursor] + s := headerBgStyle.Width(50).Render(" Reestructurar / Tagear Imagen ") + "\n\n" + s += fmt.Sprintf(" Origen: %s:%s\n\n", selectedRow.RepoName, selectedRow.Tag) + s += " Introduce la ruta completa de destino\n (ej: proyecto-web/alpine:v1.0):\n\n" + s += " " + m.textInput.View() + "\n\n" + s += footerKeyStyle.Render(" Enter ") + footerDescStyle.Render(" Confirmar ") + " " + footerKeyStyle.Render(" Esc ") + footerDescStyle.Render(" Cancelar ") + return s + } + + return "" +} + +func (m Model) viewRegistryDown() string { + s := titleStyle.Render(" ⚠️ ADVERTENCIA DE CONEXIÓN - REGISTRY-TUI ") + "\n\n" + s += fmt.Sprintf(" No se puede conectar al Registry local:\n %s\n\n", m.statusMsg) + s += " ─────────────────────────────────────────────────────────\n" + s += " [ENTER / L] Cambiar directamente al modo LOCAL\n" + s += " [ q ] Salir de la aplicación\n" + return lipgloss.NewStyle().Margin(2, 4).Render(s) +}