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//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 }