Jaypore CI

> Jaypore CI: Minimal, Offline, Local CI system.
Log | Files | Refs | README | LICENSE

docker_runtime.go (6870B)


      1 package jci
      2 
      3 import (
      4 	"bytes"
      5 	"context"
      6 	"encoding/json"
      7 	"fmt"
      8 	"io"
      9 	"log"
     10 	"net"
     11 	"net/http"
     12 	"strings"
     13 	"time"
     14 )
     15 
     16 const dockerSocketPath = "/var/run/docker.sock"
     17 
     18 // dockerRuntime implements ContainerRuntime by talking directly to the Docker
     19 // Engine API over its Unix socket. No external dependencies are required.
     20 type dockerRuntime struct {
     21 	client *http.Client
     22 }
     23 
     24 func newDockerRuntime() *dockerRuntime {
     25 	return &dockerRuntime{
     26 		client: &http.Client{
     27 			Transport: &http.Transport{
     28 				DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
     29 					return net.Dial("unix", dockerSocketPath)
     30 				},
     31 			},
     32 		},
     33 	}
     34 }
     35 
     36 // doRequest performs an HTTP request against the Docker Engine API.
     37 // The base URL is always http://localhost (the host part is ignored for Unix
     38 // socket connections but must be present for Go's http.Client).
     39 func (d *dockerRuntime) doRequest(method, path string, body interface{}) (*http.Response, error) {
     40 	var bodyReader io.Reader
     41 	if body != nil {
     42 		b, err := json.Marshal(body)
     43 		if err != nil {
     44 			return nil, fmt.Errorf("marshal request: %w", err)
     45 		}
     46 		bodyReader = bytes.NewReader(b)
     47 	}
     48 	req, err := http.NewRequest(method, "http://localhost"+path, bodyReader)
     49 	if err != nil {
     50 		return nil, err
     51 	}
     52 	if body != nil {
     53 		req.Header.Set("Content-Type", "application/json")
     54 	}
     55 	return d.client.Do(req)
     56 }
     57 
     58 // StartContainer implements ContainerRuntime.
     59 // It calls POST /containers/create then POST /containers/{id}/start.
     60 func (d *dockerRuntime) StartContainer(spec ContainerSpec) (string, error) {
     61 	log.Printf("docker: creating container image=%s binds=%v autoRemove=%v", spec.Image, spec.Binds, spec.AutoRemove)
     62 
     63 	payload := map[string]interface{}{
     64 		"Image":  spec.Image,
     65 		"Cmd":    spec.Command,
     66 		"Env":    spec.Env,
     67 		"Labels": spec.Labels,
     68 		"HostConfig": map[string]interface{}{
     69 			"Binds": spec.Binds,
     70 			// AutoRemove is intentionally omitted here so that containers
     71 			// remain visible via `docker ps -a` for post-mortem inspection
     72 			// even when they exit or fail to start. The reaper handles cleanup.
     73 		},
     74 	}
     75 
     76 	resp, err := d.doRequest("POST", "/v1.41/containers/create", payload)
     77 	if err != nil {
     78 		return "", fmt.Errorf("containers/create: %w", err)
     79 	}
     80 	defer resp.Body.Close()
     81 	raw, _ := io.ReadAll(resp.Body)
     82 	log.Printf("docker: containers/create → HTTP %d body: %s", resp.StatusCode, raw)
     83 	if resp.StatusCode != http.StatusCreated {
     84 		return "", fmt.Errorf("containers/create: status %d: %s", resp.StatusCode, raw)
     85 	}
     86 
     87 	var created struct {
     88 		ID       string   `json:"Id"`
     89 		Warnings []string `json:"Warnings"`
     90 	}
     91 	if err := json.Unmarshal(raw, &created); err != nil {
     92 		return "", fmt.Errorf("containers/create decode: %w", err)
     93 	}
     94 	if len(created.Warnings) > 0 {
     95 		log.Printf("docker: containers/create warnings for %s: %v", created.ID[:12], created.Warnings)
     96 	}
     97 	log.Printf("docker: container created id=%s", created.ID[:12])
     98 
     99 	log.Printf("docker: starting container id=%s", created.ID[:12])
    100 	resp2, err := d.doRequest("POST", "/v1.41/containers/"+created.ID+"/start", nil)
    101 	if err != nil {
    102 		return "", fmt.Errorf("containers/start: %w", err)
    103 	}
    104 	raw2, _ := io.ReadAll(resp2.Body)
    105 	resp2.Body.Close()
    106 	log.Printf("docker: containers/start → HTTP %d body: %s", resp2.StatusCode, raw2)
    107 	if resp2.StatusCode != http.StatusNoContent && resp2.StatusCode != http.StatusOK {
    108 		return "", fmt.Errorf("containers/start: status %d: %s", resp2.StatusCode, raw2)
    109 	}
    110 
    111 	log.Printf("docker: container started id=%s", created.ID[:12])
    112 	return created.ID, nil
    113 }
    114 
    115 // ListContainers implements ContainerRuntime.
    116 // Calls GET /containers/json?all=true&filters={"label":["<labelFilter>"]}
    117 // all=true is required so that stopped/exited containers are also returned and
    118 // can be reaped if they exceeded their timeout.
    119 func (d *dockerRuntime) ListContainers(labelFilter string) ([]ContainerInfo, error) {
    120 	filters, _ := json.Marshal(map[string][]string{"label": {labelFilter}})
    121 	path := "/v1.41/containers/json?all=true&filters=" + string(filters)
    122 
    123 	resp, err := d.doRequest("GET", path, nil)
    124 	if err != nil {
    125 		return nil, fmt.Errorf("containers/json: %w", err)
    126 	}
    127 	defer resp.Body.Close()
    128 	raw, _ := io.ReadAll(resp.Body)
    129 	if resp.StatusCode != http.StatusOK {
    130 		return nil, fmt.Errorf("containers/json: status %d: %s", resp.StatusCode, raw)
    131 	}
    132 
    133 	var list []struct {
    134 		ID     string            `json:"Id"`
    135 		Labels map[string]string `json:"Labels"`
    136 	}
    137 	if err := json.Unmarshal(raw, &list); err != nil {
    138 		return nil, fmt.Errorf("containers/json decode: %w", err)
    139 	}
    140 
    141 	out := make([]ContainerInfo, len(list))
    142 	for i, c := range list {
    143 		out[i] = ContainerInfo{ID: c.ID, Labels: c.Labels}
    144 	}
    145 	return out, nil
    146 }
    147 
    148 // InspectStartedAt implements ContainerRuntime.
    149 // Calls GET /containers/{id}/json and parses State.StartedAt.
    150 func (d *dockerRuntime) InspectStartedAt(id string) (time.Time, error) {
    151 	resp, err := d.doRequest("GET", "/v1.41/containers/"+id+"/json", nil)
    152 	if err != nil {
    153 		return time.Time{}, fmt.Errorf("containers/inspect: %w", err)
    154 	}
    155 	defer resp.Body.Close()
    156 	raw, _ := io.ReadAll(resp.Body)
    157 	if resp.StatusCode != http.StatusOK {
    158 		return time.Time{}, fmt.Errorf("containers/inspect: status %d: %s", resp.StatusCode, raw)
    159 	}
    160 
    161 	var info struct {
    162 		State struct {
    163 			StartedAt string `json:"StartedAt"`
    164 		} `json:"State"`
    165 	}
    166 	if err := json.Unmarshal(raw, &info); err != nil {
    167 		return time.Time{}, fmt.Errorf("containers/inspect decode: %w", err)
    168 	}
    169 	return time.Parse(time.RFC3339Nano, info.State.StartedAt)
    170 }
    171 
    172 // RemoveContainer implements ContainerRuntime.
    173 // Calls DELETE /containers/{id}?force=true
    174 func (d *dockerRuntime) RemoveContainer(id string) error {
    175 	log.Printf("docker: removing container %s", id[:12])
    176 	resp, err := d.doRequest("DELETE", "/v1.41/containers/"+id+"?force=true", nil)
    177 	if err != nil {
    178 		log.Printf("docker: containers/remove %s → error: %v", id[:12], err)
    179 		return fmt.Errorf("containers/remove: %w", err)
    180 	}
    181 	raw, _ := io.ReadAll(resp.Body)
    182 	resp.Body.Close()
    183 	log.Printf("docker: containers/remove %s → HTTP %d: %s", id[:12], resp.StatusCode, raw)
    184 	if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
    185 		return fmt.Errorf("containers/remove: status %d: %s", resp.StatusCode, raw)
    186 	}
    187 	return nil
    188 }
    189 
    190 // logContainerCmd returns a human-readable "docker run …" style string for
    191 // logging purposes. Callers are responsible for masking sensitive values
    192 // (e.g. credentials in Command) before passing the spec.
    193 func logContainerCmd(spec ContainerSpec) string {
    194 	var b strings.Builder
    195 	b.WriteString("docker run -d")
    196 	for _, v := range spec.Binds {
    197 		b.WriteString(" -v " + v)
    198 	}
    199 	for _, e := range spec.Env {
    200 		b.WriteString(" -e " + e)
    201 	}
    202 	for k, v := range spec.Labels {
    203 		b.WriteString(" --label " + k + "=" + v)
    204 	}
    205 	b.WriteString(" " + spec.Image)
    206 	for _, c := range spec.Command {
    207 		b.WriteString(" " + c)
    208 	}
    209 	return b.String()
    210 }