Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ require (
github.com/mattn/go-isatty v0.0.22 // indirect
github.com/mfridman/interpolate v0.0.2 // indirect
github.com/oklog/ulid/v2 v2.1.1 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/go-digest v1.0.0
github.com/pelletier/go-toml/v2 v2.3.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
Expand Down
45 changes: 45 additions & 0 deletions internal/satellite/proxy/doc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
// Package proxy provides Satellite's HTTP boundary for OCI Distribution
// requests.
//
// A Process handles one parsed request, while a Processor decorates a Process:
//
// type Process func(*Request) error
// type Processor func(Process) Process
//
// New accepts the terminal process. Wrap immediately decorates the current process,
// WrapAll applies several processors in argument order, and Handler adapts the
// composed process to http.Handler:
//
// handler := proxy.New(forwarder).
// Wrap(authenticate).
// Wrap(logger).
// Handler()
//
// This composition executes logger(authenticate(forwarder)): the last processor
// wrapped is the outermost. A processor may run work before and after the supplied
// Process, return an error without calling it, or satisfy the request directly.
// Continuation is an ordinary function call; Request does not contain orchestration
// state.
//
// Handler captures the composed process and creates one Request for every HTTP
// exchange. It validates and classifies the request before application processing
// starts. A nil terminal process leaves http.DefaultServeMux as the resulting
// handler, and nil processors are ignored.
//
// Request retains the original *http.Request, normalized OCI endpoint fields,
// and a private response writer. HTTPRequest exposes the original request without
// cloning it. ResponseHeader, Write, WriteResponse, and WriteError are the response
// boundary. WriteResponse streams an upstream body without buffering the complete
// response. An error returned after output starts does not append another response.
//
// Paths are classified by scanning fixed OCI endpoint suffixes from right to left,
// preserving repository names of arbitrary valid depth. Repository names, tags,
// digests, upload identifiers, relevant query parameters, and methods are validated
// before the process chain runs. The package recognizes the OCI Distribution v1.1.1
// endpoint set and excludes extensions such as the catalog endpoint.
//
// Process errors returned before response output are serialized with standard OCI
// Distribution v1.1.1 error codes. Unexpected errors receive a plain HTTP 500
// response. Configure a proxy before serving it; shared state captured by a Process
// or Processor remains responsible for its own concurrency safety.
package proxy
134 changes: 134 additions & 0 deletions internal/satellite/proxy/endpoint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
package proxy

import "strings"

// Query contains the validated query parameters defined by OCI Distribution
// endpoints. Pointers distinguish an absent parameter from a valid zero or empty
// primitive value; endpoint validation rejects invalid empty values.
type Query struct {
N *int
Last *string
Mount *string
From *string
ArtifactType *string
Digest *string
}

// endpointKeywords records the static words found at the end of an OCI path.
// Combinations distinguish endpoint families without depending on repository depth.
type endpointKeywords struct {
ping bool
manifests bool
blobs bool
uploads bool
tags bool
list bool
referrers bool
}

func (keywords endpointKeywords) known() bool {
return keywords.ping || keywords.manifests || keywords.blobs ||
keywords.tags || keywords.referrers
}

// endpointDescriptor is the result of one reverse scan of the path. Reference
// is a manifest reference, digest, or upload identifier.
type endpointDescriptor struct {
keywords endpointKeywords
repository string
reference string
}

// describeEndpoint scans from the fixed right-hand side of the path. Repository
// components are never split or counted, so arbitrary valid repository depth is
// retained as one substring.
func describeEndpoint(requestPath string) endpointDescriptor {
endpoint := endpointDescriptor{}
if requestPath == "/v2/" {
endpoint.keywords.ping = true
return endpoint
}
if !strings.HasPrefix(requestPath, apiPrefix) {
return endpoint
}

remainder := requestPath[len(apiPrefix):]
parent, last, found := cutLastSegment(remainder)
if !found {
return endpoint
}

switch last {
case "":
// /v2/<name>/blobs/uploads/: end-4a, end-4b, end-11
return describeUploadStart(parent, endpoint)
case "list":
// /v2/<name>/tags/list: end-8a, end-8b
repository, keyword, ok := cutLastSegment(parent)
if ok && keyword == "tags" {
endpoint.keywords.tags = true
endpoint.keywords.list = true
endpoint.repository = repository
}
return endpoint
default:
return describeValueEndpoint(parent, last, endpoint)
}
}

func describeUploadStart(parent string, endpoint endpointDescriptor) endpointDescriptor {
repositoryAndBlobs, keyword, found := cutLastSegment(parent)
if !found || keyword != "uploads" {
return endpoint
}
repository, keyword, found := cutLastSegment(repositoryAndBlobs)
if !found || keyword != "blobs" {
return endpoint
}
endpoint.keywords.blobs = true
endpoint.keywords.uploads = true
endpoint.repository = repository
return endpoint
}

func describeValueEndpoint(
parent string,
reference string,
endpoint endpointDescriptor,
) endpointDescriptor {
repository, keyword, found := cutLastSegment(parent)
if !found {
return endpoint
}
switch keyword {
case "manifests":
endpoint.keywords.manifests = true
endpoint.repository = repository
endpoint.reference = reference
case "blobs":
endpoint.keywords.blobs = true
endpoint.repository = repository
endpoint.reference = reference
case "referrers":
endpoint.keywords.referrers = true
endpoint.repository = repository
endpoint.reference = reference
case "uploads":
repository, keyword, found = cutLastSegment(repository)
if found && keyword == "blobs" {
endpoint.keywords.blobs = true
endpoint.keywords.uploads = true
endpoint.repository = repository
endpoint.reference = reference
}
}
return endpoint
}

func cutLastSegment(value string) (parent, last string, found bool) {
index := strings.LastIndexByte(value, '/')
if index < 0 {
return "", "", false
}
return value[:index], value[index+1:], true
}
Loading
Loading