Compare commits
1 Commits
7f569cdcdc
...
v3.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e91a5fb72 |
@@ -6,28 +6,38 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
v2 "git.unkin.net/unkin/artifactapi/internal/api/v2"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/database"
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/provider"
|
"git.unkin.net/unkin/artifactapi/internal/provider"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
"git.unkin.net/unkin/artifactapi/internal/proxy"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||||
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
"git.unkin.net/unkin/artifactapi/internal/virtual"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var semverRe = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?$`)
|
||||||
|
|
||||||
type ProxyHandler struct {
|
type ProxyHandler struct {
|
||||||
engine *proxy.Engine
|
engine *proxy.Engine
|
||||||
virtualEngine *virtual.Engine
|
virtualEngine *virtual.Engine
|
||||||
db *database.DB
|
db *database.DB
|
||||||
|
store *storage.S3
|
||||||
|
local *v2.LocalHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB) *ProxyHandler {
|
func NewProxyHandler(engine *proxy.Engine, virtualEngine *virtual.Engine, db *database.DB, store *storage.S3, local *v2.LocalHandler) *ProxyHandler {
|
||||||
return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db}
|
return &ProxyHandler{engine: engine, virtualEngine: virtualEngine, db: db, store: store, local: local}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *ProxyHandler) Routes() chi.Router {
|
func (h *ProxyHandler) Routes() chi.Router {
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
r.Get("/remote/{remoteName}/*", h.handleProxy)
|
||||||
|
r.Get("/local/{localName}/*", h.handleLocal)
|
||||||
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
|
r.Get("/virtual/{virtualName}/*", h.handleVirtual)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -95,6 +105,78 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) {
|
|||||||
w.Write(body)
|
w.Write(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
localName := chi.URLParam(r, "localName")
|
||||||
|
path := chi.URLParam(r, "*")
|
||||||
|
|
||||||
|
remote, err := h.db.GetRemote(r.Context(), localName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("local %q not found", localName), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if remote.PackageType == models.PackageTerraform {
|
||||||
|
if h.serveTerraformMirror(w, r, remote, path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.serveLocalFile(w, r, localName, path)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) serveTerraformMirror(w http.ResponseWriter, r *http.Request, remote *models.Remote, path string) bool {
|
||||||
|
parts := strings.Split(path, "/")
|
||||||
|
if len(parts) < 3 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace, typeName := parts[0], parts[1]
|
||||||
|
tail := parts[2]
|
||||||
|
|
||||||
|
if tail == "index.json" {
|
||||||
|
h.local.ServeTerraformIndex(w, r, remote.Name, namespace, typeName)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(tail, ".json") {
|
||||||
|
version := strings.TrimSuffix(tail, ".json")
|
||||||
|
if semverRe.MatchString(version) {
|
||||||
|
h.local.ServeTerraformVersionDoc(w, r, remote.Name, namespace, typeName, version)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ProxyHandler) serveLocalFile(w http.ResponseWriter, r *http.Request, repoName, path string) {
|
||||||
|
file, err := h.db.GetLocalFile(r.Context(), repoName, path)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("local file lookup failed", "repo", repoName, "path", path, "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file == nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Key := storage.BlobKey(file.ContentHash[len("sha256:"):])
|
||||||
|
reader, info, err := h.store.Download(r.Context(), s3Key)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("local file download failed", "repo", repoName, "path", path, "error", err)
|
||||||
|
http.Error(w, "internal error", http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", info.ContentType)
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size))
|
||||||
|
w.Header().Set("X-Artifact-Source", "local")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
io.Copy(w, reader)
|
||||||
|
}
|
||||||
|
|
||||||
func scheme(r *http.Request) string {
|
func scheme(r *http.Request) string {
|
||||||
if r.TLS != nil {
|
if r.TLS != nil {
|
||||||
return "https"
|
return "https"
|
||||||
|
|||||||
@@ -0,0 +1,296 @@
|
|||||||
|
package v2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/database"
|
||||||
|
"git.unkin.net/unkin/artifactapi/internal/storage"
|
||||||
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
var providerZipRe = regexp.MustCompile(
|
||||||
|
`^terraform-provider-([a-zA-Z0-9_-]+)_([0-9]+\.[0-9]+\.[0-9]+(?:-[a-zA-Z0-9.]+)?)_([a-z0-9]+)_([a-z0-9]+)\.zip$`,
|
||||||
|
)
|
||||||
|
|
||||||
|
type LocalHandler struct {
|
||||||
|
db *database.DB
|
||||||
|
store *storage.S3
|
||||||
|
cas *storage.CAS
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewLocalHandler(db *database.DB, store *storage.S3) *LocalHandler {
|
||||||
|
return &LocalHandler{
|
||||||
|
db: db,
|
||||||
|
store: store,
|
||||||
|
cas: storage.NewCAS(store),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LocalHandler) Routes() chi.Router {
|
||||||
|
r := chi.NewRouter()
|
||||||
|
r.Put("/*", h.upload)
|
||||||
|
r.Get("/*", h.download)
|
||||||
|
r.Delete("/*", h.remove)
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LocalHandler) upload(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoName := chi.URLParam(r, "name")
|
||||||
|
filePath := chi.URLParam(r, "*")
|
||||||
|
|
||||||
|
if filePath == "" {
|
||||||
|
http.Error(w, "file path required", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
remote, err := h.db.GetRemote(r.Context(), repoName)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("remote %q not found", repoName), http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if remote.RepoType != models.RepoTypeLocal {
|
||||||
|
http.Error(w, "upload only allowed for local repository types", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if remote.PackageType == models.PackageTerraform {
|
||||||
|
h.uploadTerraformProvider(w, r, remote, filePath)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.uploadGeneric(w, r, remote, filePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LocalHandler) uploadTerraformProvider(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
|
||||||
|
parts := strings.Split(filePath, "/")
|
||||||
|
if len(parts) != 3 {
|
||||||
|
http.Error(w, "path must be {namespace}/{type}/{filename}.zip", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
namespace, typeName, filename := parts[0], parts[1], parts[2]
|
||||||
|
|
||||||
|
m := providerZipRe.FindStringSubmatch(filename)
|
||||||
|
if m == nil {
|
||||||
|
http.Error(w, fmt.Sprintf(
|
||||||
|
"filename %q does not match terraform-provider-{type}_{version}_{os}_{arch}.zip",
|
||||||
|
filename,
|
||||||
|
), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fileType, version, os, arch := m[1], m[2], m[3], m[4]
|
||||||
|
|
||||||
|
if fileType != typeName {
|
||||||
|
http.Error(w, fmt.Sprintf(
|
||||||
|
"provider type in filename %q does not match path type %q",
|
||||||
|
fileType, typeName,
|
||||||
|
), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
storagePath := fmt.Sprintf("%s/%s/%s", namespace, typeName, filename)
|
||||||
|
|
||||||
|
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, storagePath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
http.Error(w, fmt.Sprintf(
|
||||||
|
"provider %s/%s version %s for %s_%s already exists; overwrites are not allowed",
|
||||||
|
namespace, typeName, version, os, arch,
|
||||||
|
), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.cas.Store(r.Context(), r.Body, "application/zip")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, "application/zip"); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("record blob: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.CreateLocalFile(r.Context(), remote.Name, storagePath, result.ContentHash); err != nil {
|
||||||
|
if errors.Is(err, database.ErrAlreadyExists) {
|
||||||
|
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", storagePath), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, fmt.Sprintf("record file: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"namespace": namespace,
|
||||||
|
"type": typeName,
|
||||||
|
"version": version,
|
||||||
|
"os": os,
|
||||||
|
"arch": arch,
|
||||||
|
"content_hash": result.ContentHash,
|
||||||
|
"size_bytes": result.SizeBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LocalHandler) uploadGeneric(w http.ResponseWriter, r *http.Request, remote *models.Remote, filePath string) {
|
||||||
|
existing, err := h.db.GetLocalFile(r.Context(), remote.Name, filePath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if existing != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", filePath), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
contentType := "application/octet-stream"
|
||||||
|
if ct := r.Header.Get("Content-Type"); ct != "" && ct != "application/octet-stream" {
|
||||||
|
contentType = ct
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.cas.Store(r.Context(), r.Body, contentType)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("store failed: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.UpsertBlob(r.Context(), result.ContentHash, result.S3Key, result.SizeBytes, contentType); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("record blob: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.db.CreateLocalFile(r.Context(), remote.Name, filePath, result.ContentHash); err != nil {
|
||||||
|
if errors.Is(err, database.ErrAlreadyExists) {
|
||||||
|
http.Error(w, fmt.Sprintf("file %q already exists; overwrites are not allowed", filePath), http.StatusConflict)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
http.Error(w, fmt.Sprintf("record file: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, map[string]any{
|
||||||
|
"path": filePath,
|
||||||
|
"content_hash": result.ContentHash,
|
||||||
|
"size_bytes": result.SizeBytes,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LocalHandler) download(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoName := chi.URLParam(r, "name")
|
||||||
|
filePath := chi.URLParam(r, "*")
|
||||||
|
|
||||||
|
file, err := h.db.GetLocalFile(r.Context(), repoName, filePath)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if file == nil {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
s3Key := storage.BlobKey(file.ContentHash[len("sha256:"):])
|
||||||
|
reader, info, err := h.store.Download(r.Context(), s3Key)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("download failed: %v", err), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer reader.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", info.ContentType)
|
||||||
|
w.Header().Set("Content-Length", fmt.Sprintf("%d", info.Size))
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
io.Copy(w, reader)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LocalHandler) remove(w http.ResponseWriter, r *http.Request) {
|
||||||
|
repoName := chi.URLParam(r, "name")
|
||||||
|
filePath := chi.URLParam(r, "*")
|
||||||
|
|
||||||
|
if err := h.db.DeleteLocalFile(r.Context(), repoName, filePath); err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusNoContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
type terraformIndex struct {
|
||||||
|
Versions map[string]json.RawMessage `json:"versions"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type terraformVersionDoc struct {
|
||||||
|
Archives map[string]terraformArchive `json:"archives"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type terraformArchive struct {
|
||||||
|
URL string `json:"url"`
|
||||||
|
Hashes []string `json:"hashes,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LocalHandler) ServeTerraformIndex(w http.ResponseWriter, r *http.Request, repoName, namespace, typeName string) {
|
||||||
|
prefix := fmt.Sprintf("%s/%s/", namespace, typeName)
|
||||||
|
files, err := h.db.ListLocalFilesByPrefix(r.Context(), repoName, prefix)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
versions := map[string]json.RawMessage{}
|
||||||
|
for _, f := range files {
|
||||||
|
filename := strings.TrimPrefix(f.FilePath, prefix)
|
||||||
|
m := providerZipRe.FindStringSubmatch(filename)
|
||||||
|
if m == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
versions[m[2]] = json.RawMessage(`{}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(versions) == 0 {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(terraformIndex{Versions: versions})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *LocalHandler) ServeTerraformVersionDoc(w http.ResponseWriter, r *http.Request, repoName, namespace, typeName, version string) {
|
||||||
|
prefix := fmt.Sprintf("%s/%s/terraform-provider-%s_%s_", namespace, typeName, typeName, version)
|
||||||
|
files, err := h.db.ListLocalFilesByPrefix(r.Context(), repoName, prefix)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
archives := map[string]terraformArchive{}
|
||||||
|
for _, f := range files {
|
||||||
|
filename := strings.TrimPrefix(f.FilePath, fmt.Sprintf("%s/%s/", namespace, typeName))
|
||||||
|
m := providerZipRe.FindStringSubmatch(filename)
|
||||||
|
if m == nil || m[2] != version {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
platform := m[3] + "_" + m[4]
|
||||||
|
archive := terraformArchive{URL: filename}
|
||||||
|
if f.ContentHash != "" {
|
||||||
|
archive.Hashes = []string{"zh:" + strings.TrimPrefix(f.ContentHash, "sha256:")}
|
||||||
|
}
|
||||||
|
archives[platform] = archive
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(archives) == 0 {
|
||||||
|
http.Error(w, "not found", http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(terraformVersionDoc{Archives: archives})
|
||||||
|
}
|
||||||
@@ -58,6 +58,17 @@ func (h *RemotesHandler) create(w http.ResponseWriter, r *http.Request) {
|
|||||||
http.Error(w, fmt.Sprintf("invalid package type: %q", remote.PackageType), http.StatusBadRequest)
|
http.Error(w, fmt.Sprintf("invalid package type: %q", remote.PackageType), http.StatusBadRequest)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if remote.RepoType == "" {
|
||||||
|
remote.RepoType = models.RepoTypeRemote
|
||||||
|
}
|
||||||
|
if !remote.RepoType.Valid() {
|
||||||
|
http.Error(w, fmt.Sprintf("invalid repo type: %q", remote.RepoType), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if remote.RepoType == models.RepoTypeRemote && remote.BaseURL == "" {
|
||||||
|
http.Error(w, "base_url is required for remote repositories", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
if err := h.db.CreateRemote(r.Context(), &remote); err != nil {
|
if err := h.db.CreateRemote(r.Context(), &remote); err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgconn"
|
||||||
|
)
|
||||||
|
|
||||||
|
type LocalFile struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
RepoName string `json:"repo_name"`
|
||||||
|
FilePath string `json:"file_path"`
|
||||||
|
ContentHash string `json:"content_hash"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var ErrAlreadyExists = fmt.Errorf("file already exists")
|
||||||
|
|
||||||
|
func (db *DB) CreateLocalFile(ctx context.Context, repoName, filePath, contentHash string) error {
|
||||||
|
_, err := db.Pool.Exec(ctx, `
|
||||||
|
INSERT INTO local_files (repo_name, file_path, content_hash)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
`, repoName, filePath, contentHash)
|
||||||
|
if err != nil {
|
||||||
|
var pgErr *pgconn.PgError
|
||||||
|
if errors.As(err, &pgErr) && pgErr.Code == "23505" {
|
||||||
|
return ErrAlreadyExists
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) GetLocalFile(ctx context.Context, repoName, filePath string) (*LocalFile, error) {
|
||||||
|
row := db.Pool.QueryRow(ctx, `
|
||||||
|
SELECT id, repo_name, file_path, content_hash, created_at
|
||||||
|
FROM local_files
|
||||||
|
WHERE repo_name = $1 AND file_path = $2
|
||||||
|
`, repoName, filePath)
|
||||||
|
|
||||||
|
var f LocalFile
|
||||||
|
if err := row.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil {
|
||||||
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return &f, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListLocalFiles(ctx context.Context, repoName string, limit, offset int) ([]LocalFile, error) {
|
||||||
|
rows, err := db.Pool.Query(ctx, `
|
||||||
|
SELECT id, repo_name, file_path, content_hash, created_at
|
||||||
|
FROM local_files
|
||||||
|
WHERE repo_name = $1
|
||||||
|
ORDER BY file_path
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
`, repoName, limit, offset)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var files []LocalFile
|
||||||
|
for rows.Next() {
|
||||||
|
var f LocalFile
|
||||||
|
if err := rows.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) ListLocalFilesByPrefix(ctx context.Context, repoName, prefix string) ([]LocalFile, error) {
|
||||||
|
rows, err := db.Pool.Query(ctx, `
|
||||||
|
SELECT id, repo_name, file_path, content_hash, created_at
|
||||||
|
FROM local_files
|
||||||
|
WHERE repo_name = $1 AND file_path LIKE $2
|
||||||
|
ORDER BY file_path
|
||||||
|
`, repoName, prefix+"%")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var files []LocalFile
|
||||||
|
for rows.Next() {
|
||||||
|
var f LocalFile
|
||||||
|
if err := rows.Scan(&f.ID, &f.RepoName, &f.FilePath, &f.ContentHash, &f.CreatedAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
files = append(files, f)
|
||||||
|
}
|
||||||
|
return files, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (db *DB) DeleteLocalFile(ctx context.Context, repoName, filePath string) error {
|
||||||
|
_, err := db.Pool.Exec(ctx, `DELETE FROM local_files WHERE repo_name = $1 AND file_path = $2`, repoName, filePath)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@@ -42,7 +42,8 @@ func (db *DB) migrate() error {
|
|||||||
CREATE TABLE IF NOT EXISTS remotes (
|
CREATE TABLE IF NOT EXISTS remotes (
|
||||||
name TEXT PRIMARY KEY,
|
name TEXT PRIMARY KEY,
|
||||||
package_type TEXT NOT NULL,
|
package_type TEXT NOT NULL,
|
||||||
base_url TEXT NOT NULL,
|
repo_type TEXT DEFAULT 'remote',
|
||||||
|
base_url TEXT NOT NULL DEFAULT '',
|
||||||
description TEXT DEFAULT '',
|
description TEXT DEFAULT '',
|
||||||
username TEXT DEFAULT '',
|
username TEXT DEFAULT '',
|
||||||
password TEXT DEFAULT '',
|
password TEXT DEFAULT '',
|
||||||
@@ -121,6 +122,8 @@ func (db *DB) migrate() error {
|
|||||||
);
|
);
|
||||||
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at);
|
CREATE INDEX IF NOT EXISTS idx_access_log_remote_time ON access_log(remote_name, created_at);
|
||||||
|
|
||||||
|
ALTER TABLE remotes ADD COLUMN IF NOT EXISTS repo_type TEXT DEFAULT 'remote';
|
||||||
`)
|
`)
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"git.unkin.net/unkin/artifactapi/pkg/models"
|
"git.unkin.net/unkin/artifactapi/pkg/models"
|
||||||
)
|
)
|
||||||
|
|
||||||
const remoteCols = `name, package_type, base_url, description, username, password,
|
const remoteCols = `name, package_type, repo_type, base_url, description, username, password,
|
||||||
immutable_ttl, mutable_ttl, check_mutable,
|
immutable_ttl, mutable_ttl, check_mutable,
|
||||||
patterns, blocklist, mutable_patterns, immutable_patterns,
|
patterns, blocklist, mutable_patterns, immutable_patterns,
|
||||||
ban_tags_enabled, ban_tags,
|
ban_tags_enabled, ban_tags,
|
||||||
@@ -15,7 +15,7 @@ const remoteCols = `name, package_type, base_url, description, username, passwor
|
|||||||
|
|
||||||
func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error {
|
func scanRemote(scanner interface{ Scan(...any) error }, r *models.Remote) error {
|
||||||
return scanner.Scan(
|
return scanner.Scan(
|
||||||
&r.Name, &r.PackageType, &r.BaseURL, &r.Description, &r.Username, &r.Password,
|
&r.Name, &r.PackageType, &r.RepoType, &r.BaseURL, &r.Description, &r.Username, &r.Password,
|
||||||
&r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable,
|
&r.ImmutableTTL, &r.MutableTTL, &r.CheckMutable,
|
||||||
&r.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns,
|
&r.Patterns, &r.Blocklist, &r.MutablePatterns, &r.ImmutablePatterns,
|
||||||
&r.BanTagsEnabled, &r.BanTags,
|
&r.BanTagsEnabled, &r.BanTags,
|
||||||
@@ -54,15 +54,15 @@ func (db *DB) ListRemotes(ctx context.Context) ([]models.Remote, error) {
|
|||||||
func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
|
func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
|
||||||
_, err := db.Pool.Exec(ctx, `
|
_, err := db.Pool.Exec(ctx, `
|
||||||
INSERT INTO remotes (
|
INSERT INTO remotes (
|
||||||
name, package_type, base_url, description, username, password,
|
name, package_type, repo_type, base_url, description, username, password,
|
||||||
immutable_ttl, mutable_ttl, check_mutable,
|
immutable_ttl, mutable_ttl, check_mutable,
|
||||||
patterns, blocklist, mutable_patterns, immutable_patterns,
|
patterns, blocklist, mutable_patterns, immutable_patterns,
|
||||||
ban_tags_enabled, ban_tags,
|
ban_tags_enabled, ban_tags,
|
||||||
quarantine_enabled, quarantine_days, stale_on_error,
|
quarantine_enabled, quarantine_days, stale_on_error,
|
||||||
releases_remote, managed_by
|
releases_remote, managed_by
|
||||||
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20)
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
|
||||||
`,
|
`,
|
||||||
r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password,
|
r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Description, r.Username, r.Password,
|
||||||
r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
|
r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
|
||||||
r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns,
|
r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns,
|
||||||
r.BanTagsEnabled, r.BanTags,
|
r.BanTagsEnabled, r.BanTags,
|
||||||
@@ -75,15 +75,15 @@ func (db *DB) CreateRemote(ctx context.Context, r *models.Remote) error {
|
|||||||
func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
|
func (db *DB) UpdateRemote(ctx context.Context, r *models.Remote) error {
|
||||||
_, err := db.Pool.Exec(ctx, `
|
_, err := db.Pool.Exec(ctx, `
|
||||||
UPDATE remotes SET
|
UPDATE remotes SET
|
||||||
package_type=$2, base_url=$3, description=$4, username=$5, password=$6,
|
package_type=$2, repo_type=$3, base_url=$4, description=$5, username=$6, password=$7,
|
||||||
immutable_ttl=$7, mutable_ttl=$8, check_mutable=$9,
|
immutable_ttl=$8, mutable_ttl=$9, check_mutable=$10,
|
||||||
patterns=$10, blocklist=$11, mutable_patterns=$12, immutable_patterns=$13,
|
patterns=$11, blocklist=$12, mutable_patterns=$13, immutable_patterns=$14,
|
||||||
ban_tags_enabled=$14, ban_tags=$15,
|
ban_tags_enabled=$15, ban_tags=$16,
|
||||||
quarantine_enabled=$16, quarantine_days=$17, stale_on_error=$18,
|
quarantine_enabled=$17, quarantine_days=$18, stale_on_error=$19,
|
||||||
releases_remote=$19, managed_by=$20, updated_at=NOW()
|
releases_remote=$20, managed_by=$21, updated_at=NOW()
|
||||||
WHERE name=$1
|
WHERE name=$1
|
||||||
`,
|
`,
|
||||||
r.Name, r.PackageType, r.BaseURL, r.Description, r.Username, r.Password,
|
r.Name, r.PackageType, r.RepoType, r.BaseURL, r.Description, r.Username, r.Password,
|
||||||
r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
|
r.ImmutableTTL, r.MutableTTL, r.CheckMutable,
|
||||||
r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns,
|
r.Patterns, r.Blocklist, r.MutablePatterns, r.ImmutablePatterns,
|
||||||
r.BanTagsEnabled, r.BanTags,
|
r.BanTagsEnabled, r.BanTags,
|
||||||
|
|||||||
@@ -91,7 +91,9 @@ func (s *Server) routes() chi.Router {
|
|||||||
r.Get("/health", s.handleHealth)
|
r.Get("/health", s.handleHealth)
|
||||||
r.Get("/", s.handleRoot)
|
r.Get("/", s.handleRoot)
|
||||||
|
|
||||||
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db)
|
localHandler := v2.NewLocalHandler(s.db, s.store)
|
||||||
|
|
||||||
|
proxyHandler := v1.NewProxyHandler(s.engine, s.virtEngine, s.db, s.store, localHandler)
|
||||||
r.Mount("/api/v1", proxyHandler.Routes())
|
r.Mount("/api/v1", proxyHandler.Routes())
|
||||||
|
|
||||||
remotesHandler := v2.NewRemotesHandler(s.db)
|
remotesHandler := v2.NewRemotesHandler(s.db)
|
||||||
@@ -114,6 +116,12 @@ func (s *Server) routes() chi.Router {
|
|||||||
r.Get("/", objHandler.Routes().ServeHTTP)
|
r.Get("/", objHandler.Routes().ServeHTTP)
|
||||||
r.Delete("/*", objHandler.Routes().ServeHTTP)
|
r.Delete("/*", objHandler.Routes().ServeHTTP)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
r.Route("/remotes/{name}/files", func(r chi.Router) {
|
||||||
|
r.Put("/*", localHandler.Routes().ServeHTTP)
|
||||||
|
r.Get("/*", localHandler.Routes().ServeHTTP)
|
||||||
|
r.Delete("/*", localHandler.Routes().ServeHTTP)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
return r
|
return r
|
||||||
|
|||||||
+33
-1
@@ -1,10 +1,42 @@
|
|||||||
package models
|
package models
|
||||||
|
|
||||||
import "time"
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type RepoType string
|
||||||
|
|
||||||
|
const (
|
||||||
|
RepoTypeRemote RepoType = "remote"
|
||||||
|
RepoTypeLocal RepoType = "local"
|
||||||
|
)
|
||||||
|
|
||||||
|
var validRepoTypes = map[RepoType]bool{
|
||||||
|
RepoTypeRemote: true,
|
||||||
|
RepoTypeLocal: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RepoType) Valid() bool {
|
||||||
|
return validRepoTypes[r]
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r RepoType) String() string {
|
||||||
|
return string(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParseRepoType(s string) (RepoType, error) {
|
||||||
|
rt := RepoType(s)
|
||||||
|
if !rt.Valid() {
|
||||||
|
return "", fmt.Errorf("unknown repo type: %q", s)
|
||||||
|
}
|
||||||
|
return rt, nil
|
||||||
|
}
|
||||||
|
|
||||||
type Remote struct {
|
type Remote struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
PackageType PackageType `json:"package_type"`
|
PackageType PackageType `json:"package_type"`
|
||||||
|
RepoType RepoType `json:"repo_type"`
|
||||||
BaseURL string `json:"base_url"`
|
BaseURL string `json:"base_url"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
Username string `json:"-"`
|
Username string `json:"-"`
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
export interface Remote {
|
export interface Remote {
|
||||||
name: string;
|
name: string;
|
||||||
package_type: string;
|
package_type: string;
|
||||||
|
repo_type: string;
|
||||||
base_url: string;
|
base_url: string;
|
||||||
description: string;
|
description: string;
|
||||||
username?: string;
|
username?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user