Compare commits

...

3 Commits

Author SHA1 Message Date
unkinben 603be5b989 fix: report actual version instead of hardcoded 3.0.0-dev (#63)
ci/woodpecker/tag/docker Pipeline was successful
The / endpoint was hardcoded to return 3.0.0-dev. Now uses the git tag version set via ldflags at build time.

Reviewed-on: #63
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 00:51:26 +10:00
unkinben 9eba49500c feat: forward Accept header and fix Content-Type for Docker proxying (#62)
## Problems
1. Docker daemon sends specific Accept headers to negotiate manifest format, but the proxy dropped them — registries defaulted to OCI format, causing "mediaType should be manifest.v2+json not oci.image.index" errors
2. Upstream Content-Type was only used when the provider returned "application/octet-stream" — Docker manifests got the wrong Content-Type

## Fixes
- Forward client Accept header to upstream (both initial request and Bearer token retry)
- Always prefer upstream Content-Type when present
- Fetch signature now accepts variadic clientHeaders for backwards compat

## E2E tested
- DockerHub: redis:7-alpine, alpine:3 — skopeo inspect OK
- GHCR: OCI-only images work with docker pull (GHCR 404s Docker v2 Accept, which is expected)
- Quay: prometheus/node-exporter — skopeo inspect OK

Reviewed-on: #62
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 00:45:23 +10:00
unkinben 0083d67272 fix: nginx config for UI serving under base path (#61)
Vite's \`base: /ui\` makes HTML reference \`/ui/assets/...\` but files are at \`/usr/share/nginx/html/assets/\` (no \`ui/\` subdir). The previous \`location /ui { try_files ... }\` couldn't find the files.

Fix: rewrite strips the base path prefix before try_files, so \`/ui/assets/foo.js\` resolves to \`/usr/share/nginx/html/assets/foo.js\`.
Reviewed-on: #61
Co-authored-by: Ben Vincent <ben@unkin.net>
Co-committed-by: Ben Vincent <ben@unkin.net>
2026-06-27 00:43:45 +10:00
8 changed files with 38 additions and 11 deletions
+2
View File
@@ -8,6 +8,8 @@ steps:
settings: settings:
registry: git.unkin.net registry: git.unkin.net
repo: git.unkin.net/unkin/artifactapi repo: git.unkin.net/unkin/artifactapi
build_args:
VERSION: ${CI_COMMIT_TAG}
username: droneci username: droneci
password: password:
from_secret: DRONECI_PASSWORD from_secret: DRONECI_PASSWORD
+2 -1
View File
@@ -9,7 +9,8 @@ RUN go mod download
COPY . . COPY . .
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o artifactapi ./cmd/artifactapi ARG VERSION=dev
RUN CGO_ENABLED=0 go build -ldflags="-s -w -X main.version=${VERSION}" -o artifactapi ./cmd/artifactapi
FROM gcr.io/distroless/static-debian12:nonroot FROM gcr.io/distroless/static-debian12:nonroot
+1 -1
View File
@@ -12,7 +12,7 @@ check-go:
fi fi
build: check-go tidy build: check-go tidy
go build -ldflags="-s -w" -o $(BINARY) ./cmd/artifactapi go build -ldflags="-s -w -X main.version=$(VERSION)" -o $(BINARY) ./cmd/artifactapi
test: check-go test: check-go
go test -race -count=1 ./pkg/... ./internal/... go test -race -count=1 ./pkg/... ./internal/...
+3 -1
View File
@@ -13,6 +13,8 @@ import (
"git.unkin.net/unkin/artifactapi/internal/tui" "git.unkin.net/unkin/artifactapi/internal/tui"
) )
var version = "dev"
func main() { func main() {
if len(os.Args) > 1 && os.Args[1] == "tui" { if len(os.Args) > 1 && os.Args[1] == "tui" {
endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT") endpoint := os.Getenv("ARTIFACTAPI_ENDPOINT")
@@ -42,7 +44,7 @@ func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel() defer cancel()
srv, err := server.New(cfg) srv, err := server.New(cfg, version)
if err != nil { if err != nil {
slog.Error("failed to create server", "error", err) slog.Error("failed to create server", "error", err)
os.Exit(1) os.Exit(1)
+1 -1
View File
@@ -67,7 +67,7 @@ func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) {
return return
} }
result, err := h.engine.Fetch(r.Context(), *remote, path, prov) result, err := h.engine.Fetch(r.Context(), *remote, path, prov, r.Header)
if err != nil { if err != nil {
var proxyErr *proxy.ProxyError var proxyErr *proxy.ProxyError
if errors.As(err, &proxyErr) { if errors.As(err, &proxyErr) {
+19 -4
View File
@@ -44,7 +44,7 @@ type FetchResult struct {
Source string // "cache" or "remote" Source string // "cache" or "remote"
} }
func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider) (*FetchResult, error) { func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, prov provider.Provider, clientHeaders ...http.Header) (*FetchResult, error) {
classifier := NewClassifier(prov) classifier := NewClassifier(prov)
class := classifier.Classify(remote, path) class := classifier.Classify(remote, path)
@@ -105,8 +105,13 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
} }
} }
var fwdHeaders http.Header
if len(clientHeaders) > 0 && clientHeaders[0] != nil {
fwdHeaders = clientHeaders[0]
}
start := time.Now() start := time.Now()
result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl) result, err := e.fetchFromUpstream(ctx, remote, path, prov, class, ttl, fwdHeaders)
upstreamMS := int(time.Since(start).Milliseconds()) upstreamMS := int(time.Since(start).Milliseconds())
if err != nil { if err != nil {
if remote.StaleOnError && isNetworkError(err) { if remote.StaleOnError && isNetworkError(err) {
@@ -126,7 +131,7 @@ func (e *Engine) Fetch(ctx context.Context, remote models.Remote, path string, p
return result, nil return result, nil
} }
func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration) (*FetchResult, error) { func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, path string, prov provider.Provider, class Classification, ttl time.Duration, clientHeaders http.Header) (*FetchResult, error) {
url := prov.UpstreamURL(remote, path) url := prov.UpstreamURL(remote, path)
authHeaders, err := prov.AuthHeaders(ctx, remote) authHeaders, err := prov.AuthHeaders(ctx, remote)
@@ -143,6 +148,11 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
req.Header.Add(k, v) req.Header.Add(k, v)
} }
} }
if clientHeaders != nil {
if accept := clientHeaders.Get("Accept"); accept != "" {
req.Header.Set("Accept", accept)
}
}
resp, err := http.DefaultClient.Do(req) resp, err := http.DefaultClient.Do(req)
if err != nil { if err != nil {
@@ -155,6 +165,11 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
if err == nil && token != "" { if err == nil && token != "" {
req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) req2, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
req2.Header.Set("Authorization", "Bearer "+token) req2.Header.Set("Authorization", "Bearer "+token)
if clientHeaders != nil {
if accept := clientHeaders.Get("Accept"); accept != "" {
req2.Header.Set("Accept", accept)
}
}
resp, err = http.DefaultClient.Do(req2) resp, err = http.DefaultClient.Do(req2)
if err != nil { if err != nil {
return nil, &UpstreamError{Err: err} return nil, &UpstreamError{Err: err}
@@ -184,7 +199,7 @@ func (e *Engine) fetchFromUpstream(ctx context.Context, remote models.Remote, pa
} }
contentType := prov.ContentType(path) contentType := prov.ContentType(path)
if ct := resp.Header.Get("Content-Type"); ct != "" && contentType == "application/octet-stream" { if ct := resp.Header.Get("Content-Type"); ct != "" {
contentType = ct contentType = ct
} }
+4 -2
View File
@@ -35,6 +35,7 @@ import (
type Server struct { type Server struct {
cfg *config.Config cfg *config.Config
version string
router chi.Router router chi.Router
db *database.DB db *database.DB
cache *cache.Redis cache *cache.Redis
@@ -45,7 +46,7 @@ type Server struct {
gc *gc.Collector gc *gc.Collector
} }
func New(cfg *config.Config) (*Server, error) { func New(cfg *config.Config, version string) (*Server, error) {
db, err := database.New(cfg.DatabaseDSN()) db, err := database.New(cfg.DatabaseDSN())
if err != nil { if err != nil {
return nil, fmt.Errorf("database: %w", err) return nil, fmt.Errorf("database: %w", err)
@@ -68,6 +69,7 @@ func New(cfg *config.Config) (*Server, error) {
s := &Server{ s := &Server{
cfg: cfg, cfg: cfg,
version: version,
db: db, db: db,
cache: redis, cache: redis,
store: s3, store: s3,
@@ -138,7 +140,7 @@ func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) { func (s *Server) handleRoot(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
fmt.Fprint(w, `{"name":"artifactapi","version":"3.0.0-dev"}`) fmt.Fprintf(w, `{"name":"artifactapi","version":"%s"}`, s.version)
} }
func (s *Server) newHTTPServer() *http.Server { func (s *Server) newHTTPServer() *http.Server {
+6 -1
View File
@@ -5,7 +5,12 @@ server {
root /usr/share/nginx/html; root /usr/share/nginx/html;
index index.html; index index.html;
location ${BASE_PATH} { location ${BASE_PATH}/ {
rewrite ^${BASE_PATH}(/.*)$ $1 break;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
location = ${BASE_PATH} {
return 301 ${BASE_PATH}/;
}
} }