Monorepo for Tangled tangled.org

appview,knotserver: support immutable nix flakeref link header

This will allow users to directly request archive from the knot without
using xrpc.
Xrpc doesn't fit here because it strips out the http headers which might
include valuable metadata like download filename or immutable link.

- implement archive on knot as `/{owner}/{repo}/archive/{ref}`
endpoint
- appview proxies the request to knot on `/archive` like it is doing for
git http endpoints.
if knot version isn't compatible, it will fallback to legacy xrpc
endpoint.
- rename the `git_http.go` file to generalized `proxy_knot.go` filaname

xrpc method `sh.tangled.repo.archive` will be deprecated in future

added `go-version` depenedency to make version constraints

Close: <https://tangled.org/tangled.org/core/issues/231>
Signed-off-by: Seongmin Lee <git@boltless.me>

boltless.me d8b6a8f5 233dd7f6

verified
Changed files
+269 -168
appview
knotserver
nix
-49
appview/repo/archive.go
··· 1 - package repo 2 - 3 - import ( 4 - "fmt" 5 - "net/http" 6 - "net/url" 7 - "strings" 8 - 9 - "tangled.org/core/api/tangled" 10 - xrpcclient "tangled.org/core/appview/xrpcclient" 11 - 12 - indigoxrpc "github.com/bluesky-social/indigo/xrpc" 13 - "github.com/go-chi/chi/v5" 14 - "github.com/go-git/go-git/v5/plumbing" 15 - ) 16 - 17 - func (rp *Repo) DownloadArchive(w http.ResponseWriter, r *http.Request) { 18 - l := rp.logger.With("handler", "DownloadArchive") 19 - ref := chi.URLParam(r, "ref") 20 - ref, _ = url.PathUnescape(ref) 21 - f, err := rp.repoResolver.Resolve(r) 22 - if err != nil { 23 - l.Error("failed to get repo and knot", "err", err) 24 - return 25 - } 26 - scheme := "http" 27 - if !rp.config.Core.Dev { 28 - scheme = "https" 29 - } 30 - host := fmt.Sprintf("%s://%s", scheme, f.Knot) 31 - xrpcc := &indigoxrpc.Client{ 32 - Host: host, 33 - } 34 - didSlashRepo := f.DidSlashRepo() 35 - archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, didSlashRepo) 36 - if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 37 - l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 38 - rp.pages.Error503(w) 39 - return 40 - } 41 - // Set headers for file download, just pass along whatever the knot specifies 42 - safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 43 - filename := fmt.Sprintf("%s-%s.tar.gz", f.Name, safeRefFilename) 44 - w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 45 - w.Header().Set("Content-Type", "application/gzip") 46 - w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 47 - // Write the archive data directly 48 - w.Write(archiveBytes) 49 - }
···
-4
appview/repo/router.go
··· 40 r.Get("/blob/{ref}/*", rp.Blob) 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 43 - // intentionally doesn't use /* as this isn't 44 - // a file path 45 - r.Get("/archive/{ref}", rp.DownloadArchive) 46 - 47 r.Route("/fork", func(r chi.Router) { 48 r.Use(middleware.AuthMiddleware(rp.oauth)) 49 r.Get("/", rp.ForkRepo)
··· 40 r.Get("/blob/{ref}/*", rp.Blob) 41 r.Get("/raw/{ref}/*", rp.RepoBlobRaw) 42 43 r.Route("/fork", func(r chi.Router) { 44 r.Use(middleware.AuthMiddleware(rp.oauth)) 45 r.Get("/", rp.ForkRepo)
-114
appview/state/git_http.go
··· 1 - package state 2 - 3 - import ( 4 - "fmt" 5 - "io" 6 - "maps" 7 - "net/http" 8 - 9 - "github.com/bluesky-social/indigo/atproto/identity" 10 - "github.com/go-chi/chi/v5" 11 - "tangled.org/core/appview/models" 12 - ) 13 - 14 - func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 15 - user := r.Context().Value("resolvedId").(identity.Identity) 16 - repo := r.Context().Value("repo").(*models.Repo) 17 - 18 - scheme := "https" 19 - if s.config.Core.Dev { 20 - scheme = "http" 21 - } 22 - 23 - targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 24 - s.proxyRequest(w, r, targetURL) 25 - 26 - } 27 - 28 - func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 29 - user, ok := r.Context().Value("resolvedId").(identity.Identity) 30 - if !ok { 31 - http.Error(w, "failed to resolve user", http.StatusInternalServerError) 32 - return 33 - } 34 - repo := r.Context().Value("repo").(*models.Repo) 35 - 36 - scheme := "https" 37 - if s.config.Core.Dev { 38 - scheme = "http" 39 - } 40 - 41 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 42 - s.proxyRequest(w, r, targetURL) 43 - } 44 - 45 - func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 46 - user, ok := r.Context().Value("resolvedId").(identity.Identity) 47 - if !ok { 48 - http.Error(w, "failed to resolve user", http.StatusInternalServerError) 49 - return 50 - } 51 - repo := r.Context().Value("repo").(*models.Repo) 52 - 53 - scheme := "https" 54 - if s.config.Core.Dev { 55 - scheme = "http" 56 - } 57 - 58 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 59 - s.proxyRequest(w, r, targetURL) 60 - } 61 - 62 - func (s *State) ReceivePack(w http.ResponseWriter, r *http.Request) { 63 - user, ok := r.Context().Value("resolvedId").(identity.Identity) 64 - if !ok { 65 - http.Error(w, "failed to resolve user", http.StatusInternalServerError) 66 - return 67 - } 68 - repo := r.Context().Value("repo").(*models.Repo) 69 - 70 - scheme := "https" 71 - if s.config.Core.Dev { 72 - scheme = "http" 73 - } 74 - 75 - targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 76 - s.proxyRequest(w, r, targetURL) 77 - } 78 - 79 - func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) { 80 - client := &http.Client{} 81 - 82 - // Create new request 83 - proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) 84 - if err != nil { 85 - http.Error(w, err.Error(), http.StatusInternalServerError) 86 - return 87 - } 88 - 89 - // Copy original headers 90 - proxyReq.Header = r.Header 91 - 92 - repoOwnerHandle := chi.URLParam(r, "user") 93 - proxyReq.Header.Add("x-tangled-repo-owner-handle", repoOwnerHandle) 94 - 95 - // Execute request 96 - resp, err := client.Do(proxyReq) 97 - if err != nil { 98 - http.Error(w, err.Error(), http.StatusInternalServerError) 99 - return 100 - } 101 - defer resp.Body.Close() 102 - 103 - // Copy response headers 104 - maps.Copy(w.Header(), resp.Header) 105 - 106 - // Set response status code 107 - w.WriteHeader(resp.StatusCode) 108 - 109 - // Copy response body 110 - if _, err := io.Copy(w, resp.Body); err != nil { 111 - http.Error(w, err.Error(), http.StatusInternalServerError) 112 - return 113 - } 114 - }
···
+185
appview/state/proxy_knot.go
···
··· 1 + package state 2 + 3 + import ( 4 + "fmt" 5 + "io" 6 + "maps" 7 + "net/http" 8 + "strings" 9 + 10 + "github.com/bluesky-social/indigo/atproto/identity" 11 + indigoxrpc "github.com/bluesky-social/indigo/xrpc" 12 + "github.com/go-chi/chi/v5" 13 + "github.com/go-git/go-git/v5/plumbing" 14 + "github.com/hashicorp/go-version" 15 + "tangled.org/core/api/tangled" 16 + "tangled.org/core/appview/models" 17 + xrpcclient "tangled.org/core/appview/xrpcclient" 18 + ) 19 + 20 + func (s *State) InfoRefs(w http.ResponseWriter, r *http.Request) { 21 + user := r.Context().Value("resolvedId").(identity.Identity) 22 + repo := r.Context().Value("repo").(*models.Repo) 23 + 24 + scheme := "https" 25 + if s.config.Core.Dev { 26 + scheme = "http" 27 + } 28 + 29 + targetURL := fmt.Sprintf("%s://%s/%s/%s/info/refs?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 30 + s.proxyRequest(w, r, targetURL) 31 + 32 + } 33 + 34 + func (s *State) UploadArchive(w http.ResponseWriter, r *http.Request) { 35 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 36 + if !ok { 37 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 38 + return 39 + } 40 + repo := r.Context().Value("repo").(*models.Repo) 41 + 42 + scheme := "https" 43 + if s.config.Core.Dev { 44 + scheme = "http" 45 + } 46 + 47 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-archive?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 48 + s.proxyRequest(w, r, targetURL) 49 + } 50 + 51 + func (s *State) UploadPack(w http.ResponseWriter, r *http.Request) { 52 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 53 + if !ok { 54 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 55 + return 56 + } 57 + repo := r.Context().Value("repo").(*models.Repo) 58 + 59 + scheme := "https" 60 + if s.config.Core.Dev { 61 + scheme = "http" 62 + } 63 + 64 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-upload-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 65 + s.proxyRequest(w, r, targetURL) 66 + } 67 + 68 + func (s *State) ReceivePack(w http.ResponseWriter, r *http.Request) { 69 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 70 + if !ok { 71 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 72 + return 73 + } 74 + repo := r.Context().Value("repo").(*models.Repo) 75 + 76 + scheme := "https" 77 + if s.config.Core.Dev { 78 + scheme = "http" 79 + } 80 + 81 + targetURL := fmt.Sprintf("%s://%s/%s/%s/git-receive-pack?%s", scheme, repo.Knot, user.DID, repo.Name, r.URL.RawQuery) 82 + s.proxyRequest(w, r, targetURL) 83 + } 84 + 85 + var knotVersionDownloadArchiveConstraint = version.MustConstraints(version.NewConstraint(">= 1.12.0-alpha")) 86 + 87 + func (s *State) DownloadArchive(w http.ResponseWriter, r *http.Request) { 88 + l := s.logger.With("handler", "DownloadArchive") 89 + ref := chi.URLParam(r, "ref") 90 + 91 + user, ok := r.Context().Value("resolvedId").(identity.Identity) 92 + if !ok { 93 + l.Error("failed to resolve user") 94 + http.Error(w, "failed to resolve user", http.StatusInternalServerError) 95 + return 96 + } 97 + repo := r.Context().Value("repo").(*models.Repo) 98 + 99 + scheme := "https" 100 + if s.config.Core.Dev { 101 + scheme = "http" 102 + } 103 + 104 + host := fmt.Sprintf("%s://%s", scheme, repo.Knot) 105 + xrpcc := &indigoxrpc.Client{ 106 + Host: host, 107 + } 108 + l = l.With("knot", repo.Knot) 109 + 110 + isCompatible := func() bool { 111 + out, err := tangled.KnotVersion(r.Context(), xrpcc) 112 + if err != nil { 113 + l.Warn("failed to get knot version", "err", err) 114 + return false 115 + } 116 + 117 + v, err := version.NewVersion(out.Version) 118 + if err != nil { 119 + l.Warn("failed to parse knot version", "version", out.Version, "err", err) 120 + return false 121 + } 122 + 123 + if !knotVersionDownloadArchiveConstraint.Check(v) { 124 + l.Warn("knot version incompatible.", "version", v) 125 + return false 126 + } 127 + return true 128 + }() 129 + l.Debug("knot compatibility check", "isCompatible", isCompatible) 130 + if isCompatible { 131 + targetURL := fmt.Sprintf("%s://%s/%s/%s/archive/%s", scheme, repo.Knot, user.DID, repo.Name, ref) 132 + s.proxyRequest(w, r, targetURL) 133 + } else { 134 + l.Debug("requesting xrpc/sh.tangled.repo.archive") 135 + archiveBytes, err := tangled.RepoArchive(r.Context(), xrpcc, "tar.gz", "", ref, repo.DidSlashRepo()) 136 + if xrpcerr := xrpcclient.HandleXrpcErr(err); xrpcerr != nil { 137 + l.Error("failed to call XRPC repo.archive", "err", xrpcerr) 138 + s.pages.Error503(w) 139 + return 140 + } 141 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 142 + filename := fmt.Sprintf("%s-%s.tar.gz", repo.Name, safeRefFilename) 143 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 144 + w.Header().Set("Content-Type", "application/gzip") 145 + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(archiveBytes))) 146 + w.Write(archiveBytes) 147 + } 148 + } 149 + 150 + func (s *State) proxyRequest(w http.ResponseWriter, r *http.Request, targetURL string) { 151 + client := &http.Client{} 152 + 153 + // Create new request 154 + proxyReq, err := http.NewRequest(r.Method, targetURL, r.Body) 155 + if err != nil { 156 + http.Error(w, err.Error(), http.StatusInternalServerError) 157 + return 158 + } 159 + 160 + // Copy original headers 161 + proxyReq.Header = r.Header 162 + 163 + repoOwnerHandle := chi.URLParam(r, "user") 164 + proxyReq.Header.Add("x-tangled-repo-owner-handle", repoOwnerHandle) 165 + 166 + // Execute request 167 + resp, err := client.Do(proxyReq) 168 + if err != nil { 169 + http.Error(w, err.Error(), http.StatusInternalServerError) 170 + return 171 + } 172 + defer resp.Body.Close() 173 + 174 + // Copy response headers 175 + maps.Copy(w.Header(), resp.Header) 176 + 177 + // Set response status code 178 + w.WriteHeader(resp.StatusCode) 179 + 180 + // Copy response body 181 + if _, err := io.Copy(w, resp.Body); err != nil { 182 + http.Error(w, err.Error(), http.StatusInternalServerError) 183 + return 184 + } 185 + }
+3 -1
appview/state/router.go
··· 104 r.Post("/git-upload-archive", s.UploadArchive) 105 r.Post("/git-upload-pack", s.UploadPack) 106 r.Post("/git-receive-pack", s.ReceivePack) 107 - 108 }) 109 }) 110
··· 104 r.Post("/git-upload-archive", s.UploadArchive) 105 r.Post("/git-upload-pack", s.UploadPack) 106 r.Post("/git-receive-pack", s.ReceivePack) 107 + // intentionally doesn't use /* as this isn't 108 + // a file path 109 + r.Get("/archive/{ref}", s.DownloadArchive) 110 }) 111 }) 112
+1
go.mod
··· 131 github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 132 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 133 github.com/hashicorp/go-sockaddr v1.0.7 // indirect 134 github.com/hashicorp/golang-lru v1.0.2 // indirect 135 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 136 github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
··· 131 github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 // indirect 132 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 // indirect 133 github.com/hashicorp/go-sockaddr v1.0.7 // indirect 134 + github.com/hashicorp/go-version v1.8.0 // indirect 135 github.com/hashicorp/golang-lru v1.0.2 // indirect 136 github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect 137 github.com/hashicorp/hcl v1.0.1-vault-7 // indirect
+2
go.sum
··· 264 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 265 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 266 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 267 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 268 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 269 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
··· 264 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= 265 github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= 266 github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= 267 + github.com/hashicorp/go-version v1.8.0 h1:KAkNb1HAiZd1ukkxDFGmokVZe1Xy9HG6NUp+bPle2i4= 268 + github.com/hashicorp/go-version v1.8.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 269 github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c= 270 github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4= 271 github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+69
knotserver/archive.go
···
··· 1 + package knotserver 2 + 3 + import ( 4 + "compress/gzip" 5 + "fmt" 6 + "net/http" 7 + "strings" 8 + 9 + securejoin "github.com/cyphar/filepath-securejoin" 10 + "github.com/go-chi/chi/v5" 11 + "github.com/go-git/go-git/v5/plumbing" 12 + "tangled.org/core/knotserver/git" 13 + ) 14 + 15 + func (h *Knot) Archive(w http.ResponseWriter, r *http.Request) { 16 + var ( 17 + did = chi.URLParam(r, "did") 18 + name = chi.URLParam(r, "name") 19 + ref = chi.URLParam(r, "ref") 20 + ) 21 + repo, err := securejoin.SecureJoin(did, name) 22 + if err != nil { 23 + gitError(w, "repository not found", http.StatusNotFound) 24 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 25 + return 26 + } 27 + 28 + repoPath, err := securejoin.SecureJoin(h.c.Repo.ScanPath, repo) 29 + if err != nil { 30 + gitError(w, "repository not found", http.StatusNotFound) 31 + h.l.Error("git: failed to secure join repo path", "handler", "InfoRefs", "error", err) 32 + return 33 + } 34 + 35 + gr, err := git.Open(repoPath, ref) 36 + 37 + immutableLink := fmt.Sprintf( 38 + "https://%s/%s/%s/archive/%s", 39 + h.c.Server.Hostname, 40 + did, 41 + name, 42 + gr.Hash(), 43 + ) 44 + 45 + safeRefFilename := strings.ReplaceAll(plumbing.ReferenceName(ref).Short(), "/", "-") 46 + filename := fmt.Sprintf("%s-%s.tar.gz", name, safeRefFilename) 47 + w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) 48 + w.Header().Set("Content-Type", "application/gzip") 49 + w.Header().Set("Link", fmt.Sprintf("<%s>; rel=\"immutable\"", immutableLink)) 50 + 51 + gw := gzip.NewWriter(w) 52 + defer gw.Close() 53 + 54 + err = gr.WriteTar(gw, "") 55 + if err != nil { 56 + // once we start writing to the body we can't report error anymore 57 + // so we are only left with logging the error 58 + h.l.Error("writing tar file", "error", err) 59 + return 60 + } 61 + 62 + err = gw.Flush() 63 + if err != nil { 64 + // once we start writing to the body we can't report error anymore 65 + // so we are only left with logging the error 66 + h.l.Error("flushing", "error", err.Error()) 67 + return 68 + } 69 + }
+4
knotserver/git/git.go
··· 76 return &g, nil 77 } 78 79 // re-open a repository and update references 80 func (g *GitRepo) Refresh() error { 81 refreshed, err := PlainOpen(g.path)
··· 76 return &g, nil 77 } 78 79 + func (g *GitRepo) Hash() plumbing.Hash { 80 + return g.h 81 + } 82 + 83 // re-open a repository and update references 84 func (g *GitRepo) Refresh() error { 85 refreshed, err := PlainOpen(g.path)
+2
knotserver/router.go
··· 85 r.Post("/git-upload-archive", h.UploadArchive) 86 r.Post("/git-upload-pack", h.UploadPack) 87 r.Post("/git-receive-pack", h.ReceivePack) 88 }) 89 }) 90
··· 85 r.Post("/git-upload-archive", h.UploadArchive) 86 r.Post("/git-upload-pack", h.UploadPack) 87 r.Post("/git-receive-pack", h.ReceivePack) 88 + // convenience routes 89 + r.Get("/archive/{ref}", h.Archive) 90 }) 91 }) 92
+3
nix/gomod2nix.toml
··· 304 [mod."github.com/hashicorp/go-sockaddr"] 305 version = "v1.0.7" 306 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 307 [mod."github.com/hashicorp/golang-lru"] 308 version = "v1.0.2" 309 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="
··· 304 [mod."github.com/hashicorp/go-sockaddr"] 305 version = "v1.0.7" 306 hash = "sha256-p6eDOrGzN1jMmT/F/f/VJMq0cKNFhUcEuVVwTE6vSrs=" 307 + [mod."github.com/hashicorp/go-version"] 308 + version = "v1.8.0" 309 + hash = "sha256-KXtqERmYrWdpqPCViWcHbe6jnuH7k16bvBIcuJuevj8=" 310 [mod."github.com/hashicorp/golang-lru"] 311 version = "v1.0.2" 312 hash = "sha256-yy+5botc6T5wXgOe2mfNXJP3wr+MkVlUZ2JBkmmrA48="