Files
miandmhoennig f9c1ae835d Api socket (#5)
Co-authored-by: mhoennig <michael@hoennig.de>
Reviewed-on: #5
2026-09-10 22:09:58 +02:00

223 lines
6.0 KiB
Go

package api
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"werkdock/internal/registry"
"werkdock/internal/store"
)
// imageID is the id an API client sees: docker's "sha256:<64 hex>".
// werkdock's store has no config digest, so the id is a hash of the
// image metadata — the same one `images` shortens to twelve characters
// (store.ImageMeta.ID) — widened to docker's shape so that a client
// can hand it back to us.
func imageID(meta store.ImageMeta) string {
meta.Name = ""
data, err := json.Marshal(meta)
if err != nil {
return ""
}
sum := sha256.Sum256(data)
return "sha256:" + hex.EncodeToString(sum[:])
}
// resolveImage finds an image by reference or by the id imageID hands
// out, and returns its short reference and metadata.
func (s *Server) resolveImage(name string) (string, store.ImageMeta, error) {
name = strings.TrimPrefix(name, "docker.io/library/")
if !strings.HasPrefix(name, "sha256:") {
meta, err := s.Store.Meta(name)
if err != nil {
return "", store.ImageMeta{}, err
}
ref, err := store.ParseReference(name)
if err != nil {
return "", store.ImageMeta{}, err
}
return ref.Short(), meta, nil
}
refs, err := s.Store.List()
if err != nil {
return "", store.ImageMeta{}, err
}
for _, ref := range refs {
meta, err := s.Store.Meta(ref)
if err != nil {
continue
}
if imageID(meta) == name {
return ref, meta, nil
}
}
return "", store.ImageMeta{}, fmt.Errorf("no such image: %s", name)
}
func (s *Server) imagesList(w http.ResponseWriter, r *http.Request) {
refs, err := s.Store.List()
if err != nil {
writeError(w, http.StatusInternalServerError, "%v", err)
return
}
out := []map[string]interface{}{}
for _, ref := range refs {
meta, err := s.Store.Meta(ref)
if err != nil {
continue
}
size, _ := s.Store.Size(ref)
parsed, err := store.ParseReference(ref)
if err != nil {
continue
}
out = append(out, map[string]interface{}{
"Id": imageID(meta),
"ParentId": "",
"RepoTags": []string{parsed.String()},
"RepoDigests": []string{},
"Created": meta.CreatedAt.Unix(),
"Size": size,
"VirtualSize": size,
"SharedSize": 0,
"Containers": -1,
"Labels": meta.Config.Labels,
})
}
writeJSON(w, http.StatusOK, out)
}
// imageInspect answers GET /images/{name}/json. A 404 here is what
// makes a client pull, so a missing image must not be an error.
func (s *Server) imageInspect(w http.ResponseWriter, r *http.Request) {
name := strings.TrimPrefix(r.URL.Path, "/images/")
name = strings.TrimSuffix(name, "/json")
if name == "" || strings.HasSuffix(r.URL.Path, "/history") {
s.notImplemented(w, r)
return
}
ref, meta, err := s.resolveImage(name)
if err != nil {
writeError(w, http.StatusNotFound, "no such image: %s", name)
return
}
parsed, _ := store.ParseReference(ref)
size, _ := s.Store.Size(ref)
exposed := map[string]struct{}{}
writeJSON(w, http.StatusOK, map[string]interface{}{
"Id": imageID(meta),
"RepoTags": []string{parsed.String()},
"RepoDigests": []string{},
"Parent": "",
"Created": meta.CreatedAt,
"Architecture": orDefault(meta.Architecture, "amd64"),
"Os": orDefault(meta.OS, "linux"),
"Size": size,
"VirtualSize": size,
"Config": map[string]interface{}{
"Image": parsed.String(),
"User": meta.Config.User,
"Env": meta.Config.Env,
"Cmd": meta.Config.Cmd,
"Entrypoint": meta.Config.Entrypoint,
"WorkingDir": meta.Config.WorkingDir,
"Labels": meta.Config.Labels,
"Volumes": meta.Config.Volumes,
"ExposedPorts": exposed,
"Tty": false,
},
"GraphDriver": map[string]interface{}{"Name": "flat", "Data": nil},
"RootFS": map[string]interface{}{"Type": "layers", "Layers": []string{}},
})
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
// imagesCreate is docker's pull: the progress stream a client reads is
// crane's output, wrapped one JSON object per line as docker writes it.
func (s *Server) imagesCreate(w http.ResponseWriter, r *http.Request) {
q := r.URL.Query()
from := q.Get("fromImage")
if from == "" {
writeError(w, http.StatusBadRequest, "images/create needs fromImage (werkdock has no build and no import over the API)")
return
}
if tag := q.Get("tag"); tag != "" && !strings.Contains(from, "@") {
from += ":" + tag
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
flusher, _ := w.(http.Flusher)
enc := json.NewEncoder(w)
status := func(format string, a ...interface{}) {
enc.Encode(map[string]interface{}{"status": fmt.Sprintf(format, a...)})
if flusher != nil {
flusher.Flush()
}
}
status("Pulling from %s", from)
progress := &lineWriter{emit: func(line string) { status("%s", line) }}
loaded, err := registry.Pull(s.Store, from, q.Get("platform"), progress)
progress.flush()
if err != nil {
// The stream has begun, so the error travels in it, as docker's
// does; the status code was 200 before anyone knew.
s.logf("images/create %s: %v", from, err)
enc.Encode(map[string]interface{}{
"error": err.Error(),
"errorDetail": map[string]string{"message": err.Error()},
})
if flusher != nil {
flusher.Flush()
}
return
}
for _, name := range loaded {
status("Pulled: %s", name)
}
status("Status: Downloaded newer image for %s", from)
}
// lineWriter turns crane's byte stream into whole lines.
type lineWriter struct {
buf strings.Builder
emit func(string)
}
func (l *lineWriter) Write(p []byte) (int, error) {
l.buf.Write(p)
s := l.buf.String()
for {
i := strings.IndexAny(s, "\r\n")
if i < 0 {
break
}
if line := strings.TrimSpace(s[:i]); line != "" {
l.emit(line)
}
s = s[i+1:]
}
l.buf.Reset()
l.buf.WriteString(s)
return len(p), nil
}
func (l *lineWriter) flush() {
if line := strings.TrimSpace(l.buf.String()); line != "" {
l.emit(line)
}
l.buf.Reset()
}
var _ io.Writer = (*lineWriter)(nil)