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:
2026-03-30 14:24:12 +02:00
parent b5a6711c64
commit 90693fb32f
34 changed files with 1386 additions and 0 deletions
+41
View File
@@ -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
}
+43
View File
@@ -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.
+51
View File
@@ -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
}
+38
View File
@@ -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")
}
})
}
+22
View File
@@ -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
}
+36
View File
@@ -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")
}
})
}
+22
View File
@@ -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
}
+35
View File
@@ -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).
+34
View File
@@ -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")
}
})
}
+17
View File
@@ -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
}
+36
View File
@@ -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)
}
})
}
+35
View File
@@ -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
}
+37
View File
@@ -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)
}
})
}
+23
View File
@@ -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
}
+36
View File
@@ -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)
}
})
}
+53
View File
@@ -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()
}
+39
View File
@@ -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)
}
})
}
+42
View File
@@ -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
}
+39
View File
@@ -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.
+87
View File
@@ -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")
}
})
}
+38
View File
@@ -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)
}
+36
View File
@@ -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.
+63
View File
@@ -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)
}
})
}
+30
View File
@@ -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
}
+38
View File
@@ -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.
+27
View File
@@ -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
}
+38
View File
@@ -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.
+78
View File
@@ -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")
}
})
}