diff --git a/Dockerfile b/Dockerfile index ac87f7e..3bbb74a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,9 @@ +# syntax=docker/dockerfile:1.6.0 # ------------------------------------------------------------------------ -from mcluseau/golang-builder:1.21.6 as build +from mcluseau/golang-builder:1.23.2 as build # ------------------------------------------------------------------------ -from alpine:3.19 +from alpine:3.20 volume /srv/dkl-store entrypoint ["/bin/dkl-store"] copy --from=build /go/bin/ /bin/ diff --git a/cmd/dkl-store/main.go b/cmd/dkl-store/main.go index 4bcac06..eafd623 100644 --- a/cmd/dkl-store/main.go +++ b/cmd/dkl-store/main.go @@ -3,15 +3,20 @@ package main import ( "crypto/sha1" "encoding/hex" + "encoding/json" "flag" "fmt" "io" - "io/ioutil" "log" "net/http" "os" + "path" "path/filepath" + "sort" + "strings" "time" + + "github.com/coreos/go-semver/semver" ) var ( @@ -31,23 +36,56 @@ func main() { } func handleHTTP(w http.ResponseWriter, req *http.Request) { - filePath := filepath.Join(*storeDir, req.URL.Path) + filePath := filepath.Join(*storeDir, path.Clean(req.URL.Path)) l := fmt.Sprintf("%s %s", req.Method, filePath) log.Print(l) defer log.Print(l, " done") - stat, err := os.Stat(filePath) - if err != nil && !os.IsNotExist(err) { - writeErr(err, w) - return - } else if err == nil && stat.Mode().IsDir() { - http.NotFound(w, req) - return - } - switch req.Method { case "GET", "HEAD": + stat, err := os.Stat(filePath) + if err != nil { + if !os.IsNotExist(err) { + writeErr(err, w) + } else { + http.NotFound(w, req) + } + return + } + if stat.Mode().IsDir() { + entries, err := os.ReadDir(filePath) + if err != nil { + writeErr(err, w) + return + } + + w.Header().Set("Content-Type", "application/json") + + resp := struct { + Versions map[string]*VersionInfo `json:",omitempty"` + Names []string + }{ + Versions: make(map[string]*VersionInfo, len(entries)), + Names: make([]string, 0, len(entries)), + } + + for _, e := range entries { + name := e.Name() + if strings.HasSuffix(name, ".sha1") { + continue + } + + resp.Names = append(resp.Names, e.Name()) + } + + resp.Versions = aggregateVersions(resp.Names) + + sort.Strings(resp.Names) + json.NewEncoder(w).Encode(resp) + return + } + sha1Hex, err := hashOf(filePath) if err != nil { writeErr(err, w) @@ -106,7 +144,7 @@ func handleHTTP(w http.ResponseWriter, req *http.Request) { os.Rename(tmpOut, filePath) - if err := ioutil.WriteFile(filePath+".sha1", []byte(sha1Hex), 0644); err != nil { + if err := os.WriteFile(filePath+".sha1", []byte(sha1Hex), 0644); err != nil { writeErr(err, w) return } @@ -144,7 +182,7 @@ func hashOf(filePath string) (sha1Hex string, err error) { if err == nil { if sha1Stat.ModTime().After(fileStat.ModTime()) { // cached value is up-to-date - sha1HexBytes, readErr := ioutil.ReadFile(sha1Path) + sha1HexBytes, readErr := os.ReadFile(sha1Path) if readErr == nil { sha1Hex = string(sha1HexBytes) @@ -178,9 +216,97 @@ func hashOf(filePath string) (sha1Hex string, err error) { log.Print("hashing ", filePath, " took ", time.Since(start).Truncate(time.Millisecond)) - if writeErr := ioutil.WriteFile(sha1Path, []byte(sha1Hex), 0644); writeErr != nil { + if writeErr := os.WriteFile(sha1Path, []byte(sha1Hex), 0644); writeErr != nil { log.Printf("WARNING: failed to cache SHA1: %v", writeErr) } return } + +func aggregateVersions(names []string) map[string]*VersionInfo { + versions := make([]VersionName, 0, len(names)) + + for _, name := range names { + rem := name + + segments := make([]semver.Version, 0, 5) + for len(rem) != 0 { + var s string + s, rem, _ = strings.Cut(rem, "_") + + // remove non-number prefix chars + s = strings.TrimFunc(s, func(c rune) bool { + return !('0' <= c && c <= '9') + }) + + ver, err := semver.NewVersion(s) + if err != nil { + continue + } + + segments = append(segments, *ver) + } + + if len(segments) == 0 { + continue + } + + versions = append(versions, VersionName{segments, name}) + } + + sort.Slice(versions, func(i, j int) bool { + return versions[i].LessThan(versions[j]) + }) + + ret := make(map[string]*VersionInfo, len(versions)) + for _, vi := range versions { + v := vi.segments[0] + name := vi.name + for _, key := range []string{ + fmt.Sprintf("%d", v.Major), + fmt.Sprintf("%d.%d", v.Major, v.Minor), + } { + agg, ok := ret[key] + if !ok { + agg = &VersionInfo{} + ret[key] = agg + } + agg.Latest = name + agg.All = append(agg.All, name) + } + } + return ret +} + +type VersionInfo struct { + Latest string + All []string +} + +type VersionName struct { + segments []semver.Version + name string +} + +func (a VersionName) LessThan(b VersionName) bool { + n := len(a.segments) + if l := len(b.segments); l > n { + n = l + } + + for i := 0; i != n; i++ { + if i >= len(a.segments) { + return true + } + if i >= len(b.segments) { + return false + } + + va, vb := a.segments[i], b.segments[i] + if !va.Equal(vb) { + return va.LessThan(vb) + } + } + + return a.name < b.name +} diff --git a/cmd/dkl-store/versions_test.go b/cmd/dkl-store/versions_test.go new file mode 100644 index 0000000..15cf209 --- /dev/null +++ b/cmd/dkl-store/versions_test.go @@ -0,0 +1,88 @@ +package main + +import ( + "encoding/json" + "math/rand" + "os" +) + +func ExampleAggregateVersions() { + names := []string{ + "dlkjsgljk", + "v1.20.16", + "v1.19.8_containerd.1.4.4", + "v1.20.15_containerd.1.4.13", + "v1.20.1_containerd.1.4.3", + "lkjzsfgj", + "v1.20.9_containerd.1.4.8", + "v1.21.0_containerd.1.4.4", + "v1.21.10_containerd.1.4.12", + "v1.21.10_containerd.1.4.13", + "v1.21.10_containerd.1.5.10", + "v1.21.10_containerd.1.5.9", + "v1.21.11_containerd.1.4.13", + "v1.21.14_containerd.1.5.16", + "v1.21.9_containerd.1.5.9", + } + + rand.Shuffle(len(names), func(i, j int) { + names[i], names[j] = names[j], names[i] + }) + + agg := aggregateVersions(names) + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + enc.Encode(agg) + + // Output: + // { + // "1": { + // "Latest": "v1.21.14_containerd.1.5.16", + // "All": [ + // "v1.19.8_containerd.1.4.4", + // "v1.20.1_containerd.1.4.3", + // "v1.20.9_containerd.1.4.8", + // "v1.20.15_containerd.1.4.13", + // "v1.20.16", + // "v1.21.0_containerd.1.4.4", + // "v1.21.9_containerd.1.5.9", + // "v1.21.10_containerd.1.4.12", + // "v1.21.10_containerd.1.4.13", + // "v1.21.10_containerd.1.5.9", + // "v1.21.10_containerd.1.5.10", + // "v1.21.11_containerd.1.4.13", + // "v1.21.14_containerd.1.5.16" + // ] + // }, + // "1.19": { + // "Latest": "v1.19.8_containerd.1.4.4", + // "All": [ + // "v1.19.8_containerd.1.4.4" + // ] + // }, + // "1.20": { + // "Latest": "v1.20.16", + // "All": [ + // "v1.20.1_containerd.1.4.3", + // "v1.20.9_containerd.1.4.8", + // "v1.20.15_containerd.1.4.13", + // "v1.20.16" + // ] + // }, + // "1.21": { + // "Latest": "v1.21.14_containerd.1.5.16", + // "All": [ + // "v1.21.0_containerd.1.4.4", + // "v1.21.9_containerd.1.5.9", + // "v1.21.10_containerd.1.4.12", + // "v1.21.10_containerd.1.4.13", + // "v1.21.10_containerd.1.5.9", + // "v1.21.10_containerd.1.5.10", + // "v1.21.11_containerd.1.4.13", + // "v1.21.14_containerd.1.5.16" + // ] + // } + // } + +} diff --git a/go.mod b/go.mod index a3a4e92..e593301 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module novit.nc/direktil/store -go 1.13 +go 1.23 + +require github.com/coreos/go-semver v0.3.1 diff --git a/go.sum b/go.sum index e69de29..4d73d33 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,6 @@ +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=