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)