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 }