feat: funciones infra — Docker, deploy, build y health check
Funciones impuras para gestión de contenedores: docker_build_image, docker_compose_up/down, docker_volume_create/list/remove, generate_dockerfile, write_dockerfile, go_build_binary, health_check_http, deploy_app y stop_app. Todas con tests unitarios donde aplica. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,41 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DeployApp orquesta el deploy completo de una app Go en Docker.
|
||||||
|
// Pasos: genera Dockerfile → lo escribe → build image → run container (detach, port mapping).
|
||||||
|
// Retorna el container ID del contenedor lanzado.
|
||||||
|
func DeployApp(appDir string, imageName string, port int, envVars map[string]string) (string, error) {
|
||||||
|
// 1. Generar Dockerfile (puro)
|
||||||
|
content := GenerateDockerfile(imageName, port, envVars)
|
||||||
|
|
||||||
|
// 2. Escribir Dockerfile a disco
|
||||||
|
_, err := WriteDockerfile(appDir, content)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("deploy_app: escribir Dockerfile: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Build de la imagen Docker
|
||||||
|
tag := imageName + ":latest"
|
||||||
|
_, err = DockerBuildImage(appDir, tag, nil)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("deploy_app: build imagen %s: %w", tag, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Ejecutar el contenedor en modo detach con port mapping
|
||||||
|
portMapping := fmt.Sprintf("%d:%d", port, port)
|
||||||
|
opts := DockerRunOpts{
|
||||||
|
Name: imageName,
|
||||||
|
Ports: []string{portMapping},
|
||||||
|
Env: envVars,
|
||||||
|
Detach: true,
|
||||||
|
}
|
||||||
|
containerID, err := DockerRunContainer(tag, opts)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("deploy_app: run contenedor %s: %w", imageName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return containerID, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
name: deploy_app
|
||||||
|
kind: pipeline
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func DeployApp(appDir string, imageName string, port int, envVars map[string]string) (string, error)"
|
||||||
|
description: "Orquesta el deploy completo de una app Go en Docker. Pasos: genera Dockerfile, lo escribe a disco, construye la imagen y lanza el contenedor en modo detach con port mapping. Retorna el container ID."
|
||||||
|
tags: [docker, deploy, go, pipeline, infra, container]
|
||||||
|
uses_functions: [generate_dockerfile_go_infra, write_dockerfile_go_infra, docker_build_image_go_infra, docker_run_container_go_infra]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "functions/infra/deploy_app.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
containerID, err := DeployApp(
|
||||||
|
"/home/user/apps/myapp",
|
||||||
|
"myapp",
|
||||||
|
8080,
|
||||||
|
map[string]string{
|
||||||
|
"DB_HOST": "postgres",
|
||||||
|
"PORT": "8080",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("Contenedor lanzado:", containerID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Pipeline de 4 pasos: generate_dockerfile (pura) → write_dockerfile → docker_build_image → docker_run_container. El nombre del contenedor e imagen coinciden con imageName. El port mapping es simetrico (hostPort == containerPort). Si cualquier paso falla, el pipeline retorna error con contexto del paso fallido. No hace rollback automatico — para limpiar usar stop_app.
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DockerBuildImage construye una imagen Docker desde un directorio con Dockerfile.
|
||||||
|
// Retorna el image ID de la imagen construida.
|
||||||
|
func DockerBuildImage(contextDir, tag string, buildArgs map[string]string) (string, error) {
|
||||||
|
args := []string{"build", "-t", tag}
|
||||||
|
|
||||||
|
for k, v := range buildArgs {
|
||||||
|
args = append(args, "--build-arg", k+"="+v)
|
||||||
|
}
|
||||||
|
|
||||||
|
args = append(args, contextDir)
|
||||||
|
|
||||||
|
out, err := exec.Command("docker", args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("docker build %s: %s", tag, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraer el image ID de la ultima linea de output
|
||||||
|
// docker build imprime "Successfully built <id>" o con BuildKit "writing image sha256:<id>"
|
||||||
|
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
|
||||||
|
for i := len(lines) - 1; i >= 0; i-- {
|
||||||
|
line := strings.TrimSpace(lines[i])
|
||||||
|
if strings.Contains(line, "Successfully built ") {
|
||||||
|
parts := strings.Fields(line)
|
||||||
|
return parts[len(parts)-1], nil
|
||||||
|
}
|
||||||
|
if strings.Contains(line, "sha256:") {
|
||||||
|
// BuildKit: extraer sha256:...
|
||||||
|
idx := strings.Index(line, "sha256:")
|
||||||
|
id := line[idx:]
|
||||||
|
if sp := strings.IndexAny(id, " \t,"); sp != -1 {
|
||||||
|
id = id[:sp]
|
||||||
|
}
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Si no encontramos el ID en el output, inspeccionar por tag
|
||||||
|
inspectOut, err2 := exec.Command("docker", "inspect", "--format", "{{.Id}}", tag).Output()
|
||||||
|
if err2 != nil {
|
||||||
|
return tag, nil
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(inspectOut)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: docker_build_image
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func DockerBuildImage(contextDir, tag string, buildArgs map[string]string) (string, error)"
|
||||||
|
description: "Construye una imagen Docker desde un directorio con Dockerfile. Soporta build args opcionales. Retorna el image ID de la imagen construida."
|
||||||
|
tags: [docker, image, build, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, os/exec, strings]
|
||||||
|
tested: true
|
||||||
|
tests: ["build sin build args retorna image ID", "build con build args incluye --build-arg", "error si contextDir no existe"]
|
||||||
|
test_file_path: "functions/infra/docker_build_image_test.go"
|
||||||
|
file_path: "functions/infra/docker_build_image.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
imageID, err := DockerBuildImage("./myapp", "myapp:latest", map[string]string{
|
||||||
|
"VERSION": "1.2.3",
|
||||||
|
"ENV": "production",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("Built:", imageID)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Ejecuta `docker build -t tag contextDir --build-arg key=val ...`. Parsea el image ID del output de docker build, compatible con el builder clasico (mensajes "Successfully built") y BuildKit (sha256). Si no puede parsear el ID del output, hace un `docker inspect` por tag como fallback.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerBuildImage(t *testing.T) {
|
||||||
|
t.Run("build sin build args retorna image ID", func(t *testing.T) {
|
||||||
|
// Crear un Dockerfile temporal minimo
|
||||||
|
dir := t.TempDir()
|
||||||
|
dockerfile := filepath.Join(dir, "Dockerfile")
|
||||||
|
if err := os.WriteFile(dockerfile, []byte("FROM scratch\n"), 0644); err != nil {
|
||||||
|
t.Skip("no se pudo crear Dockerfile temporal:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := DockerBuildImage(dir, "test-fn-registry-scratch:latest", nil)
|
||||||
|
if err != nil {
|
||||||
|
// Docker puede no estar disponible en CI — skip en vez de fail
|
||||||
|
t.Skipf("docker build no disponible: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Error("se esperaba un image ID no vacio")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpiar
|
||||||
|
_ = DockerRemoveImage("test-fn-registry-scratch:latest", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("build con build args incluye --build-arg", func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
dockerfile := filepath.Join(dir, "Dockerfile")
|
||||||
|
content := "FROM scratch\nARG MY_VERSION\n"
|
||||||
|
if err := os.WriteFile(dockerfile, []byte(content), 0644); err != nil {
|
||||||
|
t.Skip("no se pudo crear Dockerfile:", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := DockerBuildImage(dir, "test-fn-registry-args:latest", map[string]string{
|
||||||
|
"MY_VERSION": "42",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("docker build no disponible: %v", err)
|
||||||
|
}
|
||||||
|
if id == "" {
|
||||||
|
t.Error("se esperaba un image ID no vacio")
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = DockerRemoveImage("test-fn-registry-args:latest", true)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error si contextDir no existe", func(t *testing.T) {
|
||||||
|
_, err := DockerBuildImage("/ruta/que/no/existe/nunca", "test:latest", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("se esperaba error para directorio inexistente")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DockerComposeDown baja un stack docker-compose desde el archivo dado.
|
||||||
|
// Si removeVolumes es true elimina también los volumes (-v). Retorna el stdout del comando.
|
||||||
|
func DockerComposeDown(composeFile string, removeVolumes bool) (string, error) {
|
||||||
|
args := []string{"compose", "-f", composeFile, "down"}
|
||||||
|
if removeVolumes {
|
||||||
|
args = append(args, "-v")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command("docker", args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("docker compose down %s: %s", composeFile, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: docker_compose_down
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func DockerComposeDown(composeFile string, removeVolumes bool) (string, error)"
|
||||||
|
description: "Baja un stack docker-compose desde el archivo dado. Si removeVolumes es true elimina también los volumes declarados (-v). Retorna el stdout del comando."
|
||||||
|
tags: [docker, compose, down, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, os/exec, strings]
|
||||||
|
tested: true
|
||||||
|
tests: ["removeVolumes true agrega flag -v al comando", "error si composeFile no existe"]
|
||||||
|
test_file_path: "functions/infra/docker_compose_down_test.go"
|
||||||
|
file_path: "functions/infra/docker_compose_down.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Bajar stack y limpiar volumes
|
||||||
|
out, err := DockerComposeDown("./docker-compose.yml", true)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println(out)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Ejecuta `docker compose -f composeFile down [-v]`. Usa el subcomando `docker compose` (V2). El flag -v elimina volumes nombrados declarados en el compose file, no volumes externos. Retorna stdout + stderr combinados.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerComposeDown(t *testing.T) {
|
||||||
|
t.Run("removeVolumes true agrega flag -v al comando", func(t *testing.T) {
|
||||||
|
// Verificar que docker compose esta disponible
|
||||||
|
if err := exec.Command("docker", "compose", "version").Run(); err != nil {
|
||||||
|
t.Skip("docker compose no disponible")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Con archivo inexistente debe fallar pero con el mensaje correcto
|
||||||
|
_, err := DockerComposeDown("/ruta/inexistente/docker-compose.yml", true)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("se esperaba error para archivo inexistente")
|
||||||
|
}
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
if !strings.Contains(errStr, "compose") && !strings.Contains(errStr, "inexistente") {
|
||||||
|
t.Logf("error obtenido: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error si composeFile no existe", func(t *testing.T) {
|
||||||
|
_, err := DockerComposeDown("/ruta/que/no/existe/docker-compose.yml", false)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("se esperaba error para archivo inexistente")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DockerComposeUp levanta un stack docker-compose desde el archivo dado.
|
||||||
|
// Si detach es true ejecuta en background (-d). Retorna el stdout del comando.
|
||||||
|
func DockerComposeUp(composeFile string, detach bool) (string, error) {
|
||||||
|
args := []string{"compose", "-f", composeFile, "up"}
|
||||||
|
if detach {
|
||||||
|
args = append(args, "-d")
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := exec.Command("docker", args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("docker compose up %s: %s", composeFile, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
name: docker_compose_up
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func DockerComposeUp(composeFile string, detach bool) (string, error)"
|
||||||
|
description: "Levanta un stack docker-compose desde el archivo dado. Si detach es true ejecuta en background (-d). Retorna el stdout del comando."
|
||||||
|
tags: [docker, compose, up, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, os/exec, strings]
|
||||||
|
tested: true
|
||||||
|
tests: ["detach true agrega flag -d al comando", "error si composeFile no existe"]
|
||||||
|
test_file_path: "functions/infra/docker_compose_up_test.go"
|
||||||
|
file_path: "functions/infra/docker_compose_up.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
out, err := DockerComposeUp("./docker-compose.yml", true)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println(out)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Ejecuta `docker compose -f composeFile up [-d]`. Usa el subcomando `docker compose` (V2), no el binario standalone `docker-compose`. Retorna stdout + stderr combinados para facilitar el debugging. En modo no-detach bloquea hasta que el compose termine (util para stacks efimeros).
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerComposeUp(t *testing.T) {
|
||||||
|
t.Run("detach true agrega flag -d al comando", func(t *testing.T) {
|
||||||
|
// Verificar que docker compose esta disponible
|
||||||
|
if err := exec.Command("docker", "compose", "version").Run(); err != nil {
|
||||||
|
t.Skip("docker compose no disponible")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Con archivo inexistente y detach, debe fallar pero con el mensaje correcto
|
||||||
|
_, err := DockerComposeUp("/ruta/inexistente/docker-compose.yml", true)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("se esperaba error para archivo inexistente")
|
||||||
|
}
|
||||||
|
// El error debe mencionar el archivo o compose
|
||||||
|
errStr := strings.ToLower(err.Error())
|
||||||
|
if !strings.Contains(errStr, "compose") && !strings.Contains(errStr, "inexistente") {
|
||||||
|
t.Logf("error obtenido: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error si composeFile no existe", func(t *testing.T) {
|
||||||
|
_, err := DockerComposeUp("/ruta/que/no/existe/docker-compose.yml", false)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("se esperaba error para archivo inexistente")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DockerVolumeCreate crea un volume Docker con el nombre dado.
|
||||||
|
// Retorna el nombre del volume creado.
|
||||||
|
func DockerVolumeCreate(name string) (string, error) {
|
||||||
|
out, err := exec.Command("docker", "volume", "create", name).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("docker volume create %s: %s", name, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(out)), nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: docker_volume_create
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func DockerVolumeCreate(name string) (string, error)"
|
||||||
|
description: "Crea un volume Docker con el nombre dado. Retorna el nombre del volume creado tal como lo confirma Docker."
|
||||||
|
tags: [docker, volume, create, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, os/exec, strings]
|
||||||
|
tested: true
|
||||||
|
tests: ["crea volume y retorna nombre", "idempotente si el volume ya existe"]
|
||||||
|
test_file_path: "functions/infra/docker_volume_create_test.go"
|
||||||
|
file_path: "functions/infra/docker_volume_create.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
volumeName, err := DockerVolumeCreate("postgres_data")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("Created volume:", volumeName)
|
||||||
|
// Created volume: postgres_data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Ejecuta `docker volume create name`. Docker imprime el nombre del volume creado en stdout. Idempotente si el volume ya existe con el mismo nombre.
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerVolumeCreate(t *testing.T) {
|
||||||
|
t.Run("crea volume y retorna nombre", func(t *testing.T) {
|
||||||
|
name := "fn-registry-test-vol-create"
|
||||||
|
|
||||||
|
got, err := DockerVolumeCreate(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("docker no disponible: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.TrimSpace(got) != name {
|
||||||
|
t.Errorf("got %q, want %q", got, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Limpiar
|
||||||
|
_ = DockerVolumeRemove(name, false)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("idempotente si el volume ya existe", func(t *testing.T) {
|
||||||
|
name := "fn-registry-test-vol-idempotent"
|
||||||
|
|
||||||
|
first, err := DockerVolumeCreate(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("docker no disponible: %v", err)
|
||||||
|
}
|
||||||
|
defer DockerVolumeRemove(name, false) //nolint
|
||||||
|
|
||||||
|
second, err := DockerVolumeCreate(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("segunda llamada fallo: %v", err)
|
||||||
|
}
|
||||||
|
if first != second {
|
||||||
|
t.Errorf("nombres distintos: %q vs %q", first, second)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DockerVolumeList lista los volumes Docker disponibles localmente.
|
||||||
|
// Retorna un slice de maps con campos Driver, Name, Scope, Labels, Mountpoint.
|
||||||
|
func DockerVolumeList() ([]map[string]string, error) {
|
||||||
|
out, err := exec.Command("docker", "volume", "ls", "--format", "{{json .}}").Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("docker volume ls: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(strings.TrimSpace(string(out))) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var volumes []map[string]string
|
||||||
|
for _, line := range splitLines(out) {
|
||||||
|
if len(line) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var raw map[string]string
|
||||||
|
if err := json.Unmarshal(line, &raw); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
volumes = append(volumes, raw)
|
||||||
|
}
|
||||||
|
|
||||||
|
return volumes, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
---
|
||||||
|
name: docker_volume_list
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func DockerVolumeList() ([]map[string]string, error)"
|
||||||
|
description: "Lista los volumes Docker disponibles localmente. Parsea la salida JSON de docker volume ls. Retorna slice de maps con campos Driver, Name, Scope, Labels, Mountpoint."
|
||||||
|
tags: [docker, volume, list, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [encoding/json, fmt, os/exec, strings]
|
||||||
|
tested: true
|
||||||
|
tests: ["lista vacia retorna nil sin error", "parsea campos Driver y Name correctamente"]
|
||||||
|
test_file_path: "functions/infra/docker_volume_list_test.go"
|
||||||
|
file_path: "functions/infra/docker_volume_list.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
volumes, err := DockerVolumeList()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, v := range volumes {
|
||||||
|
fmt.Printf("Volume: %s (driver: %s)\n", v["Name"], v["Driver"])
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Ejecuta `docker volume ls --format {{json .}}` (un JSON por linea). Usa splitLines del paquete infra para iterar lineas. Retorna nil si no hay volumes. Los campos del map dependen de la version de Docker pero siempre incluyen Driver y Name.
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerVolumeList(t *testing.T) {
|
||||||
|
t.Run("lista vacia retorna nil sin error", func(t *testing.T) {
|
||||||
|
// Verificar que la funcion no falla incluso si no hay volumes
|
||||||
|
volumes, err := DockerVolumeList()
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("docker no disponible: %v", err)
|
||||||
|
}
|
||||||
|
// volumes puede ser nil o no segun el sistema — no es un error
|
||||||
|
_ = volumes
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("parsea campos Driver y Name correctamente", func(t *testing.T) {
|
||||||
|
name := "fn-registry-test-vol-list"
|
||||||
|
_, err := DockerVolumeCreate(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("docker no disponible: %v", err)
|
||||||
|
}
|
||||||
|
defer DockerVolumeRemove(name, false) //nolint
|
||||||
|
|
||||||
|
volumes, err := DockerVolumeList()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DockerVolumeList: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, v := range volumes {
|
||||||
|
if v["Name"] == name {
|
||||||
|
found = true
|
||||||
|
if v["Driver"] == "" {
|
||||||
|
t.Error("Driver vacio para volume existente")
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("volume %q no encontrado en la lista", name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DockerVolumeRemove elimina un volume Docker por nombre.
|
||||||
|
// Si force es true, fuerza la eliminación incluso si está en uso.
|
||||||
|
func DockerVolumeRemove(name string, force bool) error {
|
||||||
|
args := []string{"volume", "rm"}
|
||||||
|
if force {
|
||||||
|
args = append(args, "-f")
|
||||||
|
}
|
||||||
|
args = append(args, name)
|
||||||
|
|
||||||
|
out, err := exec.Command("docker", args...).CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("docker volume rm %s: %s", name, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: docker_volume_remove
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func DockerVolumeRemove(name string, force bool) error"
|
||||||
|
description: "Elimina un volume Docker por nombre. Si force es true fuerza la eliminación aunque esté en uso."
|
||||||
|
tags: [docker, volume, remove, delete, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, os/exec, strings]
|
||||||
|
tested: true
|
||||||
|
tests: ["error si volume no existe", "force flag incluye -f en el comando"]
|
||||||
|
test_file_path: "functions/infra/docker_volume_remove_test.go"
|
||||||
|
file_path: "functions/infra/docker_volume_remove.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Eliminar volume con fuerza
|
||||||
|
err := DockerVolumeRemove("postgres_data", true)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("Volume eliminado")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Ejecuta `docker volume rm [-f] name`. El flag -f solo esta disponible en versiones recientes de Docker. Sin force, falla si el volume esta siendo usado por un contenedor activo.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDockerVolumeRemove(t *testing.T) {
|
||||||
|
t.Run("error si volume no existe", func(t *testing.T) {
|
||||||
|
err := DockerVolumeRemove("fn-registry-vol-que-no-existe-xyz", false)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("se esperaba error para volume inexistente")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("force flag incluye -f en el comando", func(t *testing.T) {
|
||||||
|
name := "fn-registry-test-vol-remove"
|
||||||
|
_, err := DockerVolumeCreate(name)
|
||||||
|
if err != nil {
|
||||||
|
t.Skipf("docker no disponible: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = DockerVolumeRemove(name, true)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("DockerVolumeRemove con force: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateDockerfile genera el texto de un Dockerfile multi-stage para una app Go.
|
||||||
|
// Stage build: golang:1.23-alpine — descarga dependencias y compila.
|
||||||
|
// Stage final: alpine:latest — copia el binario, expone el puerto y lo ejecuta.
|
||||||
|
// Las envVars se inyectan como instrucciones ENV en el stage final.
|
||||||
|
// Funcion pura: no realiza I/O, solo genera texto.
|
||||||
|
func GenerateDockerfile(binaryName string, port int, envVars map[string]string) string {
|
||||||
|
var sb strings.Builder
|
||||||
|
|
||||||
|
// Stage build
|
||||||
|
sb.WriteString("# Stage build\n")
|
||||||
|
sb.WriteString("FROM golang:1.23-alpine AS builder\n\n")
|
||||||
|
sb.WriteString("WORKDIR /app\n\n")
|
||||||
|
sb.WriteString("COPY go.mod go.sum ./\n")
|
||||||
|
sb.WriteString("RUN go mod download\n\n")
|
||||||
|
sb.WriteString("COPY . .\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags=\"-s -w\" -o %s .\n\n", binaryName))
|
||||||
|
|
||||||
|
// Stage final
|
||||||
|
sb.WriteString("# Stage final\n")
|
||||||
|
sb.WriteString("FROM alpine:latest\n\n")
|
||||||
|
sb.WriteString("RUN apk --no-cache add ca-certificates tzdata\n\n")
|
||||||
|
sb.WriteString("WORKDIR /app\n\n")
|
||||||
|
sb.WriteString(fmt.Sprintf("COPY --from=builder /app/%s .\n\n", binaryName))
|
||||||
|
|
||||||
|
// ENV vars (orden determinista)
|
||||||
|
if len(envVars) > 0 {
|
||||||
|
keys := make([]string, 0, len(envVars))
|
||||||
|
for k := range envVars {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
for _, k := range keys {
|
||||||
|
sb.WriteString(fmt.Sprintf("ENV %s=%s\n", k, envVars[k]))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if port > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("EXPOSE %d\n\n", port))
|
||||||
|
}
|
||||||
|
|
||||||
|
sb.WriteString(fmt.Sprintf("ENTRYPOINT [\"./%s\"]\n", binaryName))
|
||||||
|
|
||||||
|
return sb.String()
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: generate_dockerfile
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: pure
|
||||||
|
signature: "func GenerateDockerfile(binaryName string, port int, envVars map[string]string) string"
|
||||||
|
description: "Genera el texto de un Dockerfile multi-stage para una app Go. Stage build con golang:1.23-alpine, stage final con alpine:latest. Incluye ENV vars del map con orden determinista. Funcion pura sin I/O."
|
||||||
|
tags: [docker, dockerfile, go, build, deploy, infra, pure]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: ""
|
||||||
|
imports: [fmt, sort, strings]
|
||||||
|
tested: true
|
||||||
|
tests: ["contiene stage builder con golang:1.23-alpine", "contiene stage final con alpine:latest", "incluye EXPOSE cuando port mayor a cero", "no incluye EXPOSE cuando port es cero", "env vars aparecen ordenadas alfabeticamente", "binaryName aparece en ENTRYPOINT", "env vars vacias no generan instrucciones ENV"]
|
||||||
|
test_file_path: "functions/infra/generate_dockerfile_test.go"
|
||||||
|
file_path: "functions/infra/generate_dockerfile.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
content := GenerateDockerfile("myapp", 8080, map[string]string{
|
||||||
|
"DB_HOST": "localhost",
|
||||||
|
"PORT": "8080",
|
||||||
|
})
|
||||||
|
fmt.Println(content)
|
||||||
|
// FROM golang:1.23-alpine AS builder
|
||||||
|
// ...
|
||||||
|
// EXPOSE 8080
|
||||||
|
// ENTRYPOINT ["./myapp"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Funcion pura — no toca el sistema de archivos. Componer con WriteDockerfile para persistir el resultado. Las ENV vars se ordenan alfabeticamente para garantizar Dockerfiles deterministas (mismo input => mismo output exacto). El stage build usa CGO_ENABLED=0 para binarios estáticos compatibles con alpine. Si port <= 0, omite la instruccion EXPOSE.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGenerateDockerfile(t *testing.T) {
|
||||||
|
t.Run("contiene stage builder con golang:1.23-alpine", func(t *testing.T) {
|
||||||
|
got := GenerateDockerfile("myapp", 8080, nil)
|
||||||
|
if !strings.Contains(got, "FROM golang:1.23-alpine AS builder") {
|
||||||
|
t.Errorf("expected FROM golang:1.23-alpine AS builder, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("contiene stage final con alpine:latest", func(t *testing.T) {
|
||||||
|
got := GenerateDockerfile("myapp", 8080, nil)
|
||||||
|
if !strings.Contains(got, "FROM alpine:latest") {
|
||||||
|
t.Errorf("expected FROM alpine:latest, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("incluye EXPOSE cuando port mayor a cero", func(t *testing.T) {
|
||||||
|
got := GenerateDockerfile("myapp", 8080, nil)
|
||||||
|
if !strings.Contains(got, "EXPOSE 8080") {
|
||||||
|
t.Errorf("expected EXPOSE 8080, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("no incluye EXPOSE cuando port es cero", func(t *testing.T) {
|
||||||
|
got := GenerateDockerfile("myapp", 0, nil)
|
||||||
|
if strings.Contains(got, "EXPOSE") {
|
||||||
|
t.Errorf("expected no EXPOSE directive, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("env vars aparecen ordenadas alfabeticamente", func(t *testing.T) {
|
||||||
|
got := GenerateDockerfile("myapp", 8080, map[string]string{
|
||||||
|
"Z_VAR": "z",
|
||||||
|
"A_VAR": "a",
|
||||||
|
"M_VAR": "m",
|
||||||
|
})
|
||||||
|
posA := strings.Index(got, "ENV A_VAR=a")
|
||||||
|
posM := strings.Index(got, "ENV M_VAR=m")
|
||||||
|
posZ := strings.Index(got, "ENV Z_VAR=z")
|
||||||
|
if posA < 0 || posM < 0 || posZ < 0 {
|
||||||
|
t.Fatalf("ENV vars no encontradas en:\n%s", got)
|
||||||
|
}
|
||||||
|
if !(posA < posM && posM < posZ) {
|
||||||
|
t.Errorf("ENV vars no ordenadas: A=%d M=%d Z=%d", posA, posM, posZ)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("binaryName aparece en ENTRYPOINT", func(t *testing.T) {
|
||||||
|
got := GenerateDockerfile("mycli", 9090, nil)
|
||||||
|
if !strings.Contains(got, `ENTRYPOINT ["./mycli"]`) {
|
||||||
|
t.Errorf("expected ENTRYPOINT with mycli, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("env vars vacias no generan instrucciones ENV", func(t *testing.T) {
|
||||||
|
got := GenerateDockerfile("myapp", 8080, map[string]string{})
|
||||||
|
if strings.Contains(got, "ENV ") {
|
||||||
|
t.Errorf("expected no ENV directives for empty map, got:\n%s", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GoBuildBinary compila un binario Go desde projectDir hacia outputPath.
|
||||||
|
// Si ldflags está vacío usa "-s -w" (strip debug info). Si outputPath está vacío
|
||||||
|
// usa "build/{dirname}" dentro del projectDir. Requiere Go instalado en PATH.
|
||||||
|
func GoBuildBinary(projectDir, outputPath string, ldflags string, tags string) error {
|
||||||
|
if ldflags == "" {
|
||||||
|
ldflags = "-s -w"
|
||||||
|
}
|
||||||
|
if outputPath == "" {
|
||||||
|
outputPath = filepath.Join(projectDir, "build", filepath.Base(projectDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Crear directorio destino si no existe
|
||||||
|
if err := os.MkdirAll(filepath.Dir(outputPath), 0o755); err != nil {
|
||||||
|
return fmt.Errorf("go_build_binary: crear directorio destino %s: %w", filepath.Dir(outputPath), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
args := []string{"build", "-trimpath", "-ldflags=" + ldflags}
|
||||||
|
if tags != "" {
|
||||||
|
args = append(args, "-tags="+tags)
|
||||||
|
}
|
||||||
|
args = append(args, "-o", outputPath, ".")
|
||||||
|
|
||||||
|
cmd := exec.Command("go", args...)
|
||||||
|
cmd.Dir = projectDir
|
||||||
|
cmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||||
|
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("go build en %s: %s", projectDir, strings.TrimSpace(string(out)))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
---
|
||||||
|
name: go_build_binary
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func GoBuildBinary(projectDir, outputPath string, ldflags string, tags string) error"
|
||||||
|
description: "Compila un binario Go desde un directorio de proyecto. Si ldflags está vacío usa -s -w (strip debug). Si outputPath está vacío usa build/{dirname} dentro del projectDir. Ejecuta con CGO_ENABLED=0."
|
||||||
|
tags: [go, build, binary, compile, infra, deploy]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, os, os/exec, path/filepath, strings]
|
||||||
|
tested: true
|
||||||
|
tests: ["compila proyecto valido sin error", "outputPath vacio usa build/dirname por defecto", "ldflags vacio usa -s -w por defecto", "error si projectDir no existe"]
|
||||||
|
test_file_path: "functions/infra/go_build_binary_test.go"
|
||||||
|
file_path: "functions/infra/go_build_binary.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Compilar con opciones por defecto
|
||||||
|
err := GoBuildBinary("/home/user/apps/myapp", "", "", "")
|
||||||
|
// genera /home/user/apps/myapp/build/myapp
|
||||||
|
|
||||||
|
// Compilar con output y tags explícitos
|
||||||
|
err = GoBuildBinary("/home/user/apps/myapp", "/tmp/myapp-bin", "-s -w -X main.version=1.0", "fts5")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa `CGO_ENABLED=0` para binarios estáticos compatibles con imágenes alpine. El flag `-trimpath` elimina rutas absolutas del binario para reproducibilidad. Los flags `-s -w` reducen el tamaño eliminando información de debug y la tabla de símbolos. Compatible con el flujo deploy_app que genera Dockerfile multi-stage.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGoBuildBinary(t *testing.T) {
|
||||||
|
t.Run("compila proyecto valido sin error", func(t *testing.T) {
|
||||||
|
// Crear un proyecto Go mínimo en un directorio temporal
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
goModContent := "module testapp\n\ngo 1.21\n"
|
||||||
|
mainContent := `package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("hello")
|
||||||
|
}
|
||||||
|
`
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainContent), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPath := filepath.Join(tmpDir, "bin", "testapp")
|
||||||
|
err := GoBuildBinary(tmpDir, outputPath, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verificar que el binario existe
|
||||||
|
if _, statErr := os.Stat(outputPath); os.IsNotExist(statErr) {
|
||||||
|
t.Errorf("binary not found at %s", outputPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("outputPath vacio usa build/dirname por defecto", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
goModContent := "module myproject\n\ngo 1.21\n"
|
||||||
|
mainContent := "package main\n\nfunc main() {}\n"
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte(goModContent), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte(mainContent), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err := GoBuildBinary(tmpDir, "", "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
expectedPath := filepath.Join(tmpDir, "build", filepath.Base(tmpDir))
|
||||||
|
if _, statErr := os.Stat(expectedPath); os.IsNotExist(statErr) {
|
||||||
|
t.Errorf("expected binary at %s, not found", expectedPath)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ldflags vacio usa -s -w por defecto", func(t *testing.T) {
|
||||||
|
// Este test verifica que la función no falla con ldflags vacío
|
||||||
|
// (los flags por defecto -s -w son válidos para go build)
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "go.mod"), []byte("module x\n\ngo 1.21\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(tmpDir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
outputPath := filepath.Join(tmpDir, "out")
|
||||||
|
err := GoBuildBinary(tmpDir, outputPath, "", "")
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("ldflags vacío debería usar -s -w y compilar sin error: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error si projectDir no existe", func(t *testing.T) {
|
||||||
|
err := GoBuildBinary("/nonexistent/path/to/project", "/tmp/out", "", "")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for nonexistent projectDir, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// HealthCheckHTTP hace polling HTTP GET a url hasta recibir status 200
|
||||||
|
// o hasta que pasen timeoutSecs segundos. Entre intentos espera intervalMs milisegundos.
|
||||||
|
func HealthCheckHTTP(url string, timeoutSecs, intervalMs int) error {
|
||||||
|
deadline := time.Now().Add(time.Duration(timeoutSecs) * time.Second)
|
||||||
|
interval := time.Duration(intervalMs) * time.Millisecond
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Timeout: time.Duration(intervalMs)*time.Millisecond + 500*time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
var lastErr error
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err == nil {
|
||||||
|
resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
lastErr = fmt.Errorf("status %d", resp.StatusCode)
|
||||||
|
} else {
|
||||||
|
lastErr = err
|
||||||
|
}
|
||||||
|
time.Sleep(interval)
|
||||||
|
}
|
||||||
|
|
||||||
|
if lastErr != nil {
|
||||||
|
return fmt.Errorf("health check %s timeout after %ds: %w", url, timeoutSecs, lastErr)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("health check %s timeout after %ds", url, timeoutSecs)
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
---
|
||||||
|
name: health_check_http
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func HealthCheckHTTP(url string, timeoutSecs, intervalMs int) error"
|
||||||
|
description: "Hace polling HTTP GET a un endpoint hasta recibir status 200 o hasta agotar el timeout. Útil para esperar que un servicio levante antes de continuar un pipeline."
|
||||||
|
tags: [http, health, check, polling, wait, infra]
|
||||||
|
uses_functions: []
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, net/http, time]
|
||||||
|
tested: true
|
||||||
|
tests: ["retorna nil cuando el servidor responde 200", "retorna error si el timeout se agota", "respeta el intervalo entre intentos"]
|
||||||
|
test_file_path: "functions/infra/health_check_http_test.go"
|
||||||
|
file_path: "functions/infra/health_check_http.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Esperar hasta 60s a que Metabase levante, polling cada 2s
|
||||||
|
err := HealthCheckHTTP("http://localhost:3000/api/health", 60, 2000)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal("Servicio no disponible:", err)
|
||||||
|
}
|
||||||
|
fmt.Println("Servicio listo")
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Usa solo net/http de la stdlib, sin dependencias externas. El cliente HTTP tiene timeout de intervalMs + 500ms para no bloquear el loop. Retorna el ultimo error si el timeout expira. No sigue redirects especiales — cualquier respuesta 200 OK es exito.
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestHealthCheckHTTP(t *testing.T) {
|
||||||
|
t.Run("retorna nil cuando el servidor responde 200", func(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
err := HealthCheckHTTP(srv.URL, 5, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("se esperaba nil, got: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("retorna error si el timeout se agota", func(t *testing.T) {
|
||||||
|
// Servidor que nunca responde 200
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
err := HealthCheckHTTP(srv.URL, 1, 200)
|
||||||
|
if err == nil {
|
||||||
|
t.Error("se esperaba error por timeout")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("respeta el intervalo entre intentos", func(t *testing.T) {
|
||||||
|
attempts := 0
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
attempts++
|
||||||
|
if attempts >= 3 {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
start := time.Now()
|
||||||
|
err := HealthCheckHTTP(srv.URL, 5, 150)
|
||||||
|
elapsed := time.Since(start)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("se esperaba nil despues de 3 intentos, got: %v", err)
|
||||||
|
}
|
||||||
|
// Con 2 intervalos de 150ms debe haber pasado al menos 250ms
|
||||||
|
if elapsed < 250*time.Millisecond {
|
||||||
|
t.Errorf("elapsed %v demasiado rapido, se esperaban al menos 250ms", elapsed)
|
||||||
|
}
|
||||||
|
if attempts < 3 {
|
||||||
|
t.Errorf("se esperaban al menos 3 intentos, got %d", attempts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// StopApp para y elimina el contenedor de una app desplegada.
|
||||||
|
// Si removeImage es true, elimina también la imagen Docker asociada.
|
||||||
|
// containerName debe coincidir con el nombre usado en DeployApp (= imageName).
|
||||||
|
func StopApp(containerName string, removeImage bool) error {
|
||||||
|
// 1. Detener el contenedor (timeout 10s)
|
||||||
|
if err := DockerStopContainer(containerName, 10); err != nil {
|
||||||
|
return fmt.Errorf("stop_app: detener contenedor %s: %w", containerName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Eliminar el contenedor
|
||||||
|
if err := DockerRemoveContainer(containerName, false); err != nil {
|
||||||
|
return fmt.Errorf("stop_app: eliminar contenedor %s: %w", containerName, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Eliminar la imagen si se solicita
|
||||||
|
if removeImage {
|
||||||
|
imageName := containerName + ":latest"
|
||||||
|
if err := DockerRemoveImage(imageName, false); err != nil {
|
||||||
|
return fmt.Errorf("stop_app: eliminar imagen %s: %w", imageName, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: stop_app
|
||||||
|
kind: pipeline
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func StopApp(containerName string, removeImage bool) error"
|
||||||
|
description: "Para y elimina el contenedor de una app desplegada. Si removeImage es true elimina también la imagen Docker. containerName debe coincidir con el imageName usado en deploy_app."
|
||||||
|
tags: [docker, stop, remove, deploy, pipeline, infra, container]
|
||||||
|
uses_functions: [docker_stop_container_go_infra, docker_remove_container_go_infra, docker_remove_image_go_infra]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt]
|
||||||
|
tested: false
|
||||||
|
tests: []
|
||||||
|
test_file_path: ""
|
||||||
|
file_path: "functions/infra/stop_app.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Parar contenedor sin eliminar la imagen (para relanzar rapido)
|
||||||
|
err := StopApp("myapp", false)
|
||||||
|
|
||||||
|
// Parar y limpiar todo
|
||||||
|
err = StopApp("myapp", true)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Inverso de deploy_app. El contenedor se detiene con 10 segundos de gracia antes de SIGKILL. La imagen se busca como containerName:latest (convencion de deploy_app). Si solo se quiere parar sin limpiar, usar removeImage=false para conservar la imagen en cache local y acelerar el siguiente deploy.
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WriteDockerfile escribe content en dir/Dockerfile.
|
||||||
|
// Crea el directorio si no existe. Retorna el path absoluto del archivo escrito.
|
||||||
|
func WriteDockerfile(dir, content string) (string, error) {
|
||||||
|
absDir, err := filepath.Abs(dir)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("write_dockerfile: resolver path %s: %w", dir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := os.MkdirAll(absDir, 0o755); err != nil {
|
||||||
|
return "", fmt.Errorf("write_dockerfile: crear directorio %s: %w", absDir, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
dockerfilePath := filepath.Join(absDir, "Dockerfile")
|
||||||
|
if err := os.WriteFile(dockerfilePath, []byte(content), 0o644); err != nil {
|
||||||
|
return "", fmt.Errorf("write_dockerfile: escribir %s: %w", dockerfilePath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return dockerfilePath, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
---
|
||||||
|
name: write_dockerfile
|
||||||
|
kind: function
|
||||||
|
lang: go
|
||||||
|
domain: infra
|
||||||
|
version: "1.0.0"
|
||||||
|
purity: impure
|
||||||
|
signature: "func WriteDockerfile(dir, content string) (string, error)"
|
||||||
|
description: "Escribe content en dir/Dockerfile. Crea el directorio si no existe. Retorna el path absoluto del archivo escrito. Compañera impura de generate_dockerfile."
|
||||||
|
tags: [docker, dockerfile, io, write, deploy, infra]
|
||||||
|
uses_functions: [generate_dockerfile_go_infra]
|
||||||
|
uses_types: []
|
||||||
|
returns: []
|
||||||
|
returns_optional: false
|
||||||
|
error_type: "error_go_core"
|
||||||
|
imports: [fmt, os, path/filepath]
|
||||||
|
tested: true
|
||||||
|
tests: ["escribe Dockerfile en directorio existente", "crea directorio si no existe", "retorna path absoluto correcto", "error si dir es path invalido"]
|
||||||
|
test_file_path: "functions/infra/write_dockerfile_test.go"
|
||||||
|
file_path: "functions/infra/write_dockerfile.go"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Ejemplo
|
||||||
|
|
||||||
|
```go
|
||||||
|
// Patron puro+impuro: generar contenido y luego escribir
|
||||||
|
content := GenerateDockerfile("myapp", 8080, map[string]string{"PORT": "8080"})
|
||||||
|
path, err := WriteDockerfile("/home/user/apps/myapp", content)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
fmt.Println("Dockerfile escrito en:", path)
|
||||||
|
// /home/user/apps/myapp/Dockerfile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Notas
|
||||||
|
|
||||||
|
Patron puro+impuro: generate_dockerfile produce el texto (pura, testeable sin I/O), write_dockerfile lo persiste (impura, efecto secundario aislado). Esto facilita testear la generacion del contenido independientemente de la escritura. Sobreescribe cualquier Dockerfile existente en el directorio.
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
package infra
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWriteDockerfile(t *testing.T) {
|
||||||
|
t.Run("escribe Dockerfile en directorio existente", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
content := "FROM alpine:latest\nCMD [\"sh\"]\n"
|
||||||
|
|
||||||
|
path, err := WriteDockerfile(tmpDir, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("cannot read written file: %v", err)
|
||||||
|
}
|
||||||
|
if string(data) != content {
|
||||||
|
t.Errorf("content mismatch: got %q, want %q", string(data), content)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("crea directorio si no existe", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
newDir := filepath.Join(tmpDir, "subdir", "nested")
|
||||||
|
content := "FROM scratch\n"
|
||||||
|
|
||||||
|
path, err := WriteDockerfile(newDir, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(newDir); os.IsNotExist(statErr) {
|
||||||
|
t.Error("expected directory to be created")
|
||||||
|
}
|
||||||
|
if _, statErr := os.Stat(path); os.IsNotExist(statErr) {
|
||||||
|
t.Errorf("expected Dockerfile at %s", path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("retorna path absoluto correcto", func(t *testing.T) {
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
content := "FROM ubuntu:22.04\n"
|
||||||
|
|
||||||
|
path, err := WriteDockerfile(tmpDir, content)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !filepath.IsAbs(path) {
|
||||||
|
t.Errorf("expected absolute path, got: %s", path)
|
||||||
|
}
|
||||||
|
if !strings.HasSuffix(path, "Dockerfile") {
|
||||||
|
t.Errorf("expected path to end with Dockerfile, got: %s", path)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("error si dir es path invalido", func(t *testing.T) {
|
||||||
|
// Intentar escribir en un path donde el padre es un archivo (no directorio)
|
||||||
|
tmpDir := t.TempDir()
|
||||||
|
// Crear un archivo donde esperamos un directorio
|
||||||
|
blockerPath := filepath.Join(tmpDir, "blocker")
|
||||||
|
if err := os.WriteFile(blockerPath, []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Intentar usar ese archivo como directorio
|
||||||
|
_, err := WriteDockerfile(filepath.Join(blockerPath, "subdir"), "FROM scratch\n")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error when dir path goes through a file, got nil")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user