621e8895c9
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
303 lines
8.5 KiB
Go
303 lines
8.5 KiB
Go
package infra
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// dockerHTTPClient devuelve un http.Client configurado para hablar con el daemon Docker.
|
|
// host puede ser "" (unix socket por defecto), "unix:///ruta/al/socket" o "tcp://host:port".
|
|
func dockerHTTPClient(host string) (*http.Client, string, error) {
|
|
if host == "" {
|
|
host = "unix:///var/run/docker.sock"
|
|
}
|
|
|
|
u, err := url.Parse(host)
|
|
if err != nil {
|
|
return nil, "", fmt.Errorf("docker host URL invalida %q: %w", host, err)
|
|
}
|
|
|
|
var transport http.RoundTripper
|
|
var baseURL string
|
|
|
|
switch u.Scheme {
|
|
case "unix":
|
|
socketPath := u.Path
|
|
if socketPath == "" {
|
|
socketPath = u.Host // algunos parsers meten el path en Host para unix://
|
|
}
|
|
transport = &http.Transport{
|
|
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
|
return (&net.Dialer{}).DialContext(ctx, "unix", socketPath)
|
|
},
|
|
}
|
|
baseURL = "http://localhost"
|
|
case "tcp", "http":
|
|
transport = http.DefaultTransport
|
|
baseURL = "http://" + u.Host
|
|
case "https":
|
|
transport = http.DefaultTransport
|
|
baseURL = "https://" + u.Host
|
|
default:
|
|
return nil, "", fmt.Errorf("docker host scheme no soportado: %q", u.Scheme)
|
|
}
|
|
|
|
return &http.Client{Transport: transport, Timeout: 0}, baseURL, nil
|
|
}
|
|
|
|
// dockerLogsURL construye la URL para GET /containers/<id>/logs con los parametros dados.
|
|
func dockerLogsURL(baseURL, containerID string, opts DockerLogsOpts, follow bool) string {
|
|
tail := opts.Tail
|
|
if tail == 0 {
|
|
tail = 100
|
|
}
|
|
|
|
tailStr := fmt.Sprintf("%d", tail)
|
|
if tail < 0 {
|
|
tailStr = "all"
|
|
}
|
|
|
|
stdout := opts.Stdout
|
|
stderr := opts.Stderr
|
|
if !stdout && !stderr {
|
|
stdout = true
|
|
stderr = true
|
|
}
|
|
|
|
params := url.Values{}
|
|
params.Set("stdout", boolParam(stdout))
|
|
params.Set("stderr", boolParam(stderr))
|
|
params.Set("tail", tailStr)
|
|
params.Set("timestamps", boolParam(opts.Timestamps))
|
|
if follow {
|
|
params.Set("follow", "1")
|
|
} else {
|
|
params.Set("follow", "0")
|
|
}
|
|
if opts.Since != "" {
|
|
params.Set("since", opts.Since)
|
|
}
|
|
|
|
return fmt.Sprintf("%s/containers/%s/logs?%s", baseURL, url.PathEscape(containerID), params.Encode())
|
|
}
|
|
|
|
func boolParam(b bool) string {
|
|
if b {
|
|
return "1"
|
|
}
|
|
return "0"
|
|
}
|
|
|
|
// dockerDemuxFrame lee un frame del protocolo de multiplexion de Docker.
|
|
// El frame header tiene 8 bytes: [stream_type(1), 0,0,0, size(4 big-endian)].
|
|
// Retorna (streamType, payload, error). streamType: 1=stdout, 2=stderr.
|
|
// Retorna io.EOF cuando no hay mas frames.
|
|
func dockerDemuxFrame(r io.Reader) (uint8, []byte, error) {
|
|
var header [8]byte
|
|
if _, err := io.ReadFull(r, header[:]); err != nil {
|
|
return 0, nil, err // io.EOF si el stream termino limpiamente
|
|
}
|
|
|
|
streamType := header[0]
|
|
size := binary.BigEndian.Uint32(header[4:8])
|
|
|
|
if size == 0 {
|
|
return streamType, nil, nil
|
|
}
|
|
|
|
payload := make([]byte, size)
|
|
if _, err := io.ReadFull(r, payload); err != nil {
|
|
return 0, nil, fmt.Errorf("leyendo payload del frame docker: %w", err)
|
|
}
|
|
|
|
return streamType, payload, nil
|
|
}
|
|
|
|
// dockerStreamToString convierte el streamType del frame header al string "stdout"/"stderr".
|
|
func dockerStreamToString(t uint8) string {
|
|
if t == 2 {
|
|
return "stderr"
|
|
}
|
|
return "stdout"
|
|
}
|
|
|
|
// dockerParsePayload convierte el payload de un frame en DockerLogLines.
|
|
// Cada linea del payload se convierte en una DockerLogLine separada.
|
|
// Si timestamps esta habilitado, Docker prefija cada linea con "2006-01-02T15:04:05.000000000Z ".
|
|
func dockerParsePayload(streamType uint8, payload []byte, timestamps bool) []DockerLogLine {
|
|
raw := strings.TrimRight(string(payload), "\n")
|
|
rawLines := strings.Split(raw, "\n")
|
|
stream := dockerStreamToString(streamType)
|
|
|
|
lines := make([]DockerLogLine, 0, len(rawLines))
|
|
for _, l := range rawLines {
|
|
if l == "" {
|
|
continue
|
|
}
|
|
line := DockerLogLine{Stream: stream}
|
|
if timestamps {
|
|
// Docker antepone timestamp seguido de espacio: "2026-05-23T12:00:00.000Z texto"
|
|
idx := strings.Index(l, " ")
|
|
if idx > 0 {
|
|
line.Timestamp = l[:idx]
|
|
line.Line = l[idx+1:]
|
|
} else {
|
|
line.Line = l
|
|
}
|
|
} else {
|
|
line.Line = l
|
|
}
|
|
lines = append(lines, line)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
// DockerContainerLogs obtiene los logs de un contenedor en modo snapshot (sin follow).
|
|
// Retorna hasta opts.Tail lineas (default 100, -1 = todas). Demuxea stdout/stderr.
|
|
func DockerContainerLogs(opts DockerLogsOpts) ([]DockerLogLine, error) {
|
|
client, baseURL, err := dockerHTTPClient(opts.DockerHost)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
reqURL := dockerLogsURL(baseURL, opts.ContainerID, opts, false)
|
|
|
|
req, err := http.NewRequestWithContext(context.Background(), http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("construyendo request logs: %w", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("docker logs %s: %w", opts.ContainerID, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return nil, fmt.Errorf("container %q no encontrado", opts.ContainerID)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return nil, fmt.Errorf("docker logs HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
var result []DockerLogLine
|
|
for {
|
|
streamType, payload, err := dockerDemuxFrame(resp.Body)
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return result, fmt.Errorf("leyendo frame docker logs: %w", err)
|
|
}
|
|
if len(payload) == 0 {
|
|
continue
|
|
}
|
|
lines := dockerParsePayload(streamType, payload, opts.Timestamps)
|
|
result = append(result, lines...)
|
|
}
|
|
|
|
return result, nil
|
|
}
|
|
|
|
// DockerContainerLogsStream hace follow de los logs de un contenedor en modo streaming.
|
|
// Por cada linea recibida llama cb. Si cb retorna error, el stream se cancela.
|
|
// ctx permite cancelacion externa. No hace reconexion automatica — el caller decide si reintentar.
|
|
func DockerContainerLogsStream(ctx context.Context, opts DockerLogsOpts, cb func(DockerLogLine) error) error {
|
|
client, baseURL, err := dockerHTTPClient(opts.DockerHost)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Para streaming necesitamos un cliente sin timeout de lectura global.
|
|
client.Timeout = 0
|
|
|
|
reqURL := dockerLogsURL(baseURL, opts.ContainerID, opts, true)
|
|
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
|
|
if err != nil {
|
|
return fmt.Errorf("construyendo request logs stream: %w", err)
|
|
}
|
|
|
|
resp, err := client.Do(req)
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return fmt.Errorf("docker logs stream %s: %w", opts.ContainerID, err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if resp.StatusCode == http.StatusNotFound {
|
|
return fmt.Errorf("container %q no encontrado", opts.ContainerID)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
return fmt.Errorf("docker logs stream HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
|
|
}
|
|
|
|
// Leer frames hasta ctx cancel, EOF o error de cb.
|
|
for {
|
|
// Verificar cancelacion antes de cada lectura.
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
streamType, payload, err := dockerDemuxFrame(resp.Body)
|
|
if err == io.EOF {
|
|
return nil // contenedor termino o daemon cerro el stream
|
|
}
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
return ctx.Err()
|
|
}
|
|
return fmt.Errorf("leyendo frame docker logs stream: %w", err)
|
|
}
|
|
if len(payload) == 0 {
|
|
continue
|
|
}
|
|
|
|
lines := dockerParsePayload(streamType, payload, opts.Timestamps)
|
|
for _, line := range lines {
|
|
if err := cb(line); err != nil {
|
|
return err // caller cancela via error
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// dockerSince convierte duracion tipo "10m" a unix timestamp string para la API de Docker.
|
|
// La API acepta directamente duraciones asi que esta funcion es solo documentacion del contrato.
|
|
// Docker Engine acepta: unix timestamp int, RFC3339 timestamp, o Go duration string ("10m", "1h30m").
|
|
func dockerSince(_ string) string {
|
|
// Documentacion: Docker acepta directamente la string. No necesitamos conversion.
|
|
return ""
|
|
}
|
|
|
|
// dockerClientWithTimeout crea un http.Client con timeout de conexion pero sin timeout de lectura.
|
|
// Util para detectar rapido si el daemon no responde.
|
|
func dockerClientWithTimeout(host string, connectTimeout time.Duration) (*http.Client, string, error) {
|
|
client, baseURL, err := dockerHTTPClient(host)
|
|
if err != nil {
|
|
return nil, "", err
|
|
}
|
|
if transport, ok := client.Transport.(*http.Transport); ok {
|
|
origDial := transport.DialContext
|
|
transport.DialContext = func(ctx context.Context, network, addr string) (net.Conn, error) {
|
|
dialCtx, cancel := context.WithTimeout(ctx, connectTimeout)
|
|
defer cancel()
|
|
return origDial(dialCtx, network, addr)
|
|
}
|
|
}
|
|
return client, baseURL, nil
|
|
}
|