package web
import (
"embed"
"encoding/json"
"errors"
"fmt"
"html/template"
"io"
"net"
"net/http"
"net/netip"
"net/url"
"runtime"
"strconv"
"time"
"github.com/containerd/containerd/v2/core/images"
"github.com/go-logr/logr"
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/prometheus/common/expfmt"
"clyde/pkg/oci"
"clyde/pkg/routing"
)
var templatesFS embed.FS
type Web struct {
router routing.Router
client *http.Client
tmpls *template.Template
}
func NewWeb(router routing.Router) (*Web, error) {
tmpls, err := template.New("").ParseFS(templatesFS, "templates/*")
if err != nil {
return nil, err
}
return &Web{
router: router,
client: &http.Client{},
tmpls: tmpls,
}, nil
}
func (w *Web) Handler(log logr.Logger) http.Handler {
log = log.WithName("web")
handlers := map[string]func(*http.Request) (string, any, error){
"/debug/web/": func(r *http.Request) (string, any, error) {
return "index", nil, nil
},
"/debug/web/stats": w.stats,
"/debug/web/measure": w.measure,
}
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
h, ok := handlers[req.URL.Path]
if !ok {
rw.WriteHeader(http.StatusNotFound)
return
}
t, data, err := h(req)
if err != nil {
log.Error(err, "error when running handler", "path", req.URL.Path)
rw.WriteHeader(http.StatusInternalServerError)
return
}
err = w.tmpls.ExecuteTemplate(rw, t+".html", data)
if err != nil {
log.Error(err, "error rendering page", "path", req.URL.Path)
rw.WriteHeader(http.StatusInternalServerError)
return
}
})
}
func (w *Web) stats(req *http.Request) (string, any, error) {
srvAddr := req.Context().Value(http.LocalAddrContextKey).(net.Addr)
resp, err := http.Get(fmt.Sprintf("http://%s/metrics", srvAddr.String()))
if err != nil {
return "", nil, err
}
defer resp.Body.Close()
parser := expfmt.TextParser{}
metricFamilies, err := parser.TextToMetricFamilies(resp.Body)
if err != nil {
return "", nil, err
}
data := struct {
ImageCount int64
LayerCount int64
}{}
for _, metric := range metricFamilies["clyde_advertised_images"].Metric {
data.ImageCount += int64(*metric.Gauge.Value)
}
for _, metric := range metricFamilies["clyde_advertised_keys"].Metric {
data.LayerCount += int64(*metric.Gauge.Value)
}
return "stats", data, nil
}
type measureResult struct {
PeerResults []peerResult
PullResults []pullResult
}
type peerResult struct {
Peer netip.AddrPort
Duration time.Duration
}
type pullResult struct {
Identifier string
ContentType string
ContentLength string
Duration time.Duration
}
func (w *Web) measure(req *http.Request) (string, any, error) {
imgName := req.URL.Query().Get("image")
if imgName == "" {
return "", nil, errors.New("image name cannot be empty")
}
img, err := oci.ParseImage(imgName)
if err != nil {
return "", nil, err
}
res := measureResult{}
resolveStart := time.Now()
peerCh, err := w.router.Resolve(req.Context(), imgName, 0)
if err != nil {
return "", nil, err
}
for peer := range peerCh {
res.PeerResults = append(res.PeerResults, peerResult{
Peer: peer,
Duration: time.Since(resolveStart),
})
}
if len(res.PeerResults) == 0 {
return "measure", res, nil
}
pullResults, err := measureImagePull(w.client, "http://localhost:5000", img)
if err != nil {
return "", nil, err
}
res.PullResults = pullResults
return "measure", res, nil
}
func measureImagePull(client *http.Client, regURL string, img oci.Image) ([]pullResult, error) {
pullResults := []pullResult{}
queue := []oci.DistributionPath{
{
Kind: oci.DistributionKindManifest,
Name: img.Repository,
Digest: img.Digest,
Tag: img.Tag,
Registry: img.Registry,
},
}
for {
if len(queue) == 0 {
break
}
pr, dists, err := fetchDistributionPath(client, regURL, queue[0])
if err != nil {
return nil, err
}
queue = queue[1:]
queue = append(queue, dists...)
pullResults = append(pullResults, pr)
}
return pullResults, nil
}
func fetchDistributionPath(client *http.Client, regURL string, dist oci.DistributionPath) (pullResult, []oci.DistributionPath, error) {
regU, err := url.Parse(regURL)
if err != nil {
return pullResult{}, nil, err
}
u := dist.URL()
u.Scheme = regU.Scheme
u.Host = regU.Host
pullStart := time.Now()
pullReq, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return pullResult{}, nil, err
}
pullResp, err := client.Do(pullReq)
if err != nil {
return pullResult{}, nil, err
}
defer pullResp.Body.Close()
if pullResp.StatusCode != http.StatusOK {
_, err = io.Copy(io.Discard, pullResp.Body)
if err != nil {
return pullResult{}, nil, err
}
return pullResult{}, nil, fmt.Errorf("request returned unexpected status code %s", pullResp.Status)
}
queue := []oci.DistributionPath{}
ct := pullResp.Header.Get("Content-Type")
switch dist.Kind {
case oci.DistributionKindBlob:
_, err = io.Copy(io.Discard, pullResp.Body)
if err != nil {
return pullResult{}, nil, err
}
ct = "Layer"
case oci.DistributionKindManifest:
b, err := io.ReadAll(pullResp.Body)
if err != nil {
return pullResult{}, nil, err
}
switch ct {
case images.MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex:
var idx ocispec.Index
if err := json.Unmarshal(b, &idx); err != nil {
return pullResult{}, nil, err
}
for _, m := range idx.Manifests {
if !(m.Platform.OS == runtime.GOOS && m.Platform.Architecture == runtime.GOARCH) {
continue
}
queue = append(queue, oci.DistributionPath{
Kind: oci.DistributionKindManifest,
Name: dist.Name,
Digest: m.Digest,
Registry: dist.Registry,
})
}
ct = "Index"
case images.MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest:
var manifest ocispec.Manifest
err := json.Unmarshal(b, &manifest)
if err != nil {
return pullResult{}, nil, err
}
queue = append(queue, oci.DistributionPath{
Kind: oci.DistributionKindManifest,
Name: dist.Name,
Digest: manifest.Config.Digest,
Registry: dist.Registry,
})
for _, layer := range manifest.Layers {
queue = append(queue, oci.DistributionPath{
Kind: oci.DistributionKindBlob,
Name: dist.Name,
Digest: layer.Digest,
Registry: dist.Registry,
})
}
ct = "Manifest"
case ocispec.MediaTypeImageConfig:
ct = "Config"
}
}
pullResp.Body.Close()
i, err := strconv.ParseInt(pullResp.Header.Get("Content-Length"), 10, 0)
if err != nil {
return pullResult{}, nil, err
}
return pullResult{
Identifier: dist.Reference(),
ContentType: ct,
ContentLength: formatByteSize(i),
Duration: time.Since(pullStart),
}, queue, nil
}
func formatByteSize(size int64) string {
const unit = 1000
if size < unit {
return fmt.Sprintf("%d B", size)
}
div, exp := int64(unit), 0
for n := size / unit; n >= unit; n /= unit {
div *= unit
exp++
}
return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "kMGTPE"[exp])
}