├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yaml │ ├── config.yml │ └── feature-request.yaml ├── dependabot.yaml └── workflows │ ├── artifacthub.yaml │ ├── e2e.yaml │ ├── go.yaml │ ├── helm.yaml │ └── release.yaml ├── .gitignore ├── .golangci.yaml ├── .goreleaser.yaml ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── charts └── spegel │ ├── .helmignore │ ├── Chart.yaml │ ├── README.md │ ├── README.md.gotmpl │ ├── artifacthub-repo.yml │ ├── monitoring │ └── grafana-dashboard.json │ ├── templates │ ├── _helpers.tpl │ ├── daemonset.yaml │ ├── grafana-dashboard.yaml │ ├── post-delete-hook.yaml │ ├── rbac.yaml │ ├── service.yaml │ ├── servicemonitor.yaml │ └── verticalpodautoscaler.yaml │ └── values.yaml ├── go.mod ├── go.sum ├── internal ├── channel │ └── channel.go ├── cleanup │ ├── cleanup.go │ └── cleanup_test.go └── web │ ├── templates │ ├── index.html │ ├── measure.html │ └── stats.html │ ├── web.go │ └── web_test.go ├── main.go ├── pkg ├── httpx │ ├── httpx.go │ ├── metrics.go │ ├── mux.go │ ├── mux_test.go │ ├── response.go │ ├── response_test.go │ ├── status.go │ └── status_test.go ├── metrics │ ├── metrics.go │ └── metrics_test.go ├── oci │ ├── client.go │ ├── client_test.go │ ├── containerd.go │ ├── containerd_test.go │ ├── distribution.go │ ├── distribution_test.go │ ├── image.go │ ├── image_test.go │ ├── memory.go │ ├── oci.go │ ├── oci_test.go │ └── testdata │ │ ├── blobs │ │ └── sha256 │ │ │ ├── 0ad7c556c55464fa44d4c41e5236715e015b0266daced62140fb5c6b983c946b │ │ │ ├── 3caa2469de2a23cbcc209dd0b9d01cd78ff9a0f88741655991d36baede5b0996 │ │ │ ├── 44cb2cf712c060f69df7310e99339c1eb51a085446f1bb6d44469acff35b4355 │ │ │ ├── 68b8a989a3e08ddbdb3a0077d35c0d0e59c9ecf23d0634584def8bdbb7d6824f │ │ │ ├── 9430beb291fa7b96997711fc486bc46133c719631aefdbeebe58dd3489217bfe │ │ │ ├── 9506c8e7a2d0a098d43cadfd7ecdc3c91697e8188d3a1245943b669f717747b4 │ │ │ ├── addc990c58744bdf96364fe89bd4aab38b1e824d51c688edb36c75247cd45fa9 │ │ │ ├── aec8273a5e5aca369fcaa8cecef7bf6c7959d482f5c8cfa2236a6a16e46bbdcf │ │ │ ├── b6d6089ca6c395fd563c2084f5dd7bc56a2f5e6a81413558c5be0083287a77e9 │ │ │ ├── d8df04365d06181f037251de953aca85cc16457581a8fc168f4957c978e1008b │ │ │ └── dce623533c59af554b85f859e91fc1cbb7f574e873c82f36b9ea05a09feb0b53 │ │ └── images.json ├── registry │ ├── registry.go │ └── registry_test.go ├── routing │ ├── bootstrap.go │ ├── bootstrap_test.go │ ├── memory.go │ ├── memory_test.go │ ├── p2p.go │ ├── p2p_test.go │ └── routing.go └── state │ ├── state.go │ └── state_test.go └── test └── e2e ├── e2e_test.go └── testdata ├── conformance-job.yaml └── test-nginx.yaml /.github/ISSUE_TEMPLATE/bug-report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Create a report to help improve Spegel 3 | labels: ["bug"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for taking the time to fill ot this bug report! Please read the [FAQ](https://spegel.dev/docs/faq/) and check existing issues before submitting a new issue. 9 | - type: input 10 | attributes: 11 | label: Spegel version 12 | placeholder: eg. v0.0.16 13 | validations: 14 | required: true 15 | - type: input 16 | attributes: 17 | label: Kubernetes distribution 18 | placeholder: eg. AKS, EKS, K3S, Kubeadm... 19 | validations: 20 | required: true 21 | - type: input 22 | attributes: 23 | label: Kubernetes version 24 | placeholder: eg. v1.29.0 25 | validations: 26 | required: true 27 | - type: input 28 | attributes: 29 | label: CNI 30 | placeholder: eg. Calico, Cilium, Azure CNI... 31 | validations: 32 | required: true 33 | - type: textarea 34 | attributes: 35 | label: Describe the bug 36 | description: A clear description of what the bug is. 37 | validations: 38 | required: true 39 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/config.yml: -------------------------------------------------------------------------------- 1 | blank_issues_enabled: true 2 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Suggest a new feature for Spegel 3 | labels: ["enhancement"] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: | 8 | Thank you for creating a feature request! Please check existing issues before submitting. 9 | - type: textarea 10 | attributes: 11 | label: Describe the problem to be solved 12 | description: A clear description of the problem that needs to be addressed by this feature request. 13 | validations: 14 | required: true 15 | - type: textarea 16 | attributes: 17 | label: Proposed solution to the problem 18 | description: A clear description of the solution or multiple possible solutions to implement this feature request. 19 | validations: 20 | required: false 21 | -------------------------------------------------------------------------------- /.github/dependabot.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "github-actions" 4 | directory: "/" 5 | schedule: 6 | interval: "daily" 7 | open-pull-requests-limit: 15 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | schedule: 11 | interval: "daily" 12 | open-pull-requests-limit: 15 13 | groups: 14 | k8s: 15 | patterns: 16 | - "k8s.io/*" 17 | -------------------------------------------------------------------------------- /.github/workflows/artifacthub.yaml: -------------------------------------------------------------------------------- 1 | name: artifacthub 2 | on: 3 | push: 4 | branches: ["main"] 5 | paths: 6 | - "charts/spegel/artifacthub-repo.yml" 7 | permissions: 8 | contents: read 9 | packages: write 10 | defaults: 11 | run: 12 | shell: bash 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Clone repo 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 19 | with: 20 | submodules: true 21 | - name: Login to GitHub Container Registry 22 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0 23 | with: 24 | registry: ghcr.io 25 | username: ${{ github.repository_owner }} 26 | password: ${{ secrets.GITHUB_TOKEN }} 27 | - name: Setup ORAS 28 | uses: oras-project/setup-oras@8d34698a59f5ffe24821f0b48ab62a3de8b64b20 #v1.2.3 29 | - name: Push Artifact Hub metadata 30 | run: oras push ghcr.io/spegel-org/helm-charts/spegel:artifacthub.io --config /dev/null:application/vnd.cncf.artifacthub.config.v1+yaml charts/spegel/artifacthub-repo.yml:application/vnd.cncf.artifacthub.repository-metadata.layer.v1.yaml 31 | -------------------------------------------------------------------------------- /.github/workflows/e2e.yaml: -------------------------------------------------------------------------------- 1 | name: e2e 2 | on: 3 | pull_request: 4 | permissions: 5 | contents: read 6 | defaults: 7 | run: 8 | shell: bash 9 | jobs: 10 | test: 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | include: 15 | - proxy-mode: iptables 16 | ip-family: ipv4 17 | - proxy-mode: iptables 18 | ip-family: ipv6 19 | - proxy-mode: ipvs 20 | ip-family: ipv4 21 | steps: 22 | - name: Checkout 23 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 24 | - name: Setup Go 25 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 #v5.5.0 26 | with: 27 | go-version-file: go.mod 28 | - name: Setup GoReleaser 29 | uses: goreleaser/goreleaser-action@v6 30 | with: 31 | install-only: true 32 | - name: Setup Kind 33 | uses: helm/kind-action@a1b0e391336a6ee6713a0583f8c6240d70863de3 #v1.12.0 34 | with: 35 | version: v0.29.0 36 | install_only: true 37 | - name: Run e2e 38 | run: make test-e2e E2E_PROXY_MODE=${{ matrix.proxy-mode }} E2E_IP_FAMILY=${{ matrix.ip-family }} 39 | -------------------------------------------------------------------------------- /.github/workflows/go.yaml: -------------------------------------------------------------------------------- 1 | name: go 2 | on: 3 | pull_request: 4 | permissions: 5 | contents: read 6 | defaults: 7 | run: 8 | shell: bash 9 | jobs: 10 | lint: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 15 | - name: Setup Go 16 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 #v5.5.0 17 | with: 18 | go-version-file: go.mod 19 | - name: Setup golangci-lint 20 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 #v8.0.0 21 | unit: 22 | runs-on: ubuntu-latest 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 26 | - name: Setup Go 27 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 #v5.5.0 28 | with: 29 | go-version-file: go.mod 30 | - name: Run tests 31 | run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... 32 | - name: Upload coverage reports to Codecov 33 | uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 #v5.4.3 34 | with: 35 | token: ${{ secrets.CODECOV_TOKEN }} 36 | -------------------------------------------------------------------------------- /.github/workflows/helm.yaml: -------------------------------------------------------------------------------- 1 | name: helm 2 | on: 3 | pull_request: 4 | permissions: 5 | contents: read 6 | defaults: 7 | run: 8 | shell: bash 9 | jobs: 10 | docs: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout 14 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 15 | - name: Setup Go 16 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 #v5.5.0 17 | with: 18 | go-version-file: go.mod 19 | - name: Run helm-docs 20 | run: make helm-docs 21 | - name: Check if working tree is dirty 22 | run: | 23 | if [[ $(git diff --stat) != '' ]]; then 24 | git diff 25 | echo 'run make helm-docs and commit changes' 26 | exit 1 27 | fi 28 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | on: 3 | push: 4 | tags: 5 | - 'v*' 6 | permissions: 7 | contents: write 8 | packages: write 9 | id-token: write 10 | defaults: 11 | run: 12 | shell: bash 13 | jobs: 14 | release: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Clone repo 18 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 #v4.2.2 19 | - name: Setup Cosign 20 | uses: sigstore/cosign-installer@3454372f43399081ed03b604cb2d021dabca52bb #v3.8.2 21 | - name: Setup Helm 22 | uses: azure/setup-helm@b9e51907a09c216f16ebe8536097933489208112 #v4.3.0 23 | with: 24 | version: v3.17.3 25 | - name: Setup Docker Buildx 26 | id: buildx 27 | uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2 #v3.10.0 28 | - name: Setup yq 29 | uses: frenck/action-setup-yq@c4b5be8b4a215c536a41d436757d9feb92836d4f #v1.0.2 30 | - name: Login to GitHub Container Registry 31 | uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 #v3.4.0 32 | with: 33 | registry: ghcr.io 34 | username: ${{ github.repository_owner }} 35 | password: ${{ secrets.GITHUB_TOKEN }} 36 | - name: Prepare version 37 | id: prep 38 | run: | 39 | VERSION=sha-${GITHUB_SHA::8} 40 | if [[ $GITHUB_REF == refs/tags/* ]]; then 41 | VERSION=${GITHUB_REF/refs\/tags\//} 42 | fi 43 | echo "Refer to the [Changelog](https://github.com/spegel-org/spegel/blob/main/CHANGELOG.md#${VERSION//.}) for list of changes." > ${{ runner.temp }}/NOTES.txt 44 | echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT 45 | - name: Run GoReleaser 46 | uses: goreleaser/goreleaser-action@v6 47 | with: 48 | args: release --clean --release-notes ${{ runner.temp }}/NOTES.txt 49 | env: 50 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 51 | - name: Generate images meta 52 | id: meta 53 | uses: docker/metadata-action@902fa8ec7d6ecbf8d84d538b9b233a880e428804 #v5.7.0 54 | with: 55 | images: ghcr.io/${{ github.repository_owner }}/spegel 56 | tags: type=raw,value=${{ steps.prep.outputs.VERSION }} 57 | - name: Publish multi-arch image 58 | uses: docker/build-push-action@1dc73863535b631f98b2378be8619f83b136f4a0 #v6.17.0 59 | id: build 60 | with: 61 | push: true 62 | builder: ${{ steps.buildx.outputs.name }} 63 | context: . 64 | file: ./Dockerfile 65 | platforms: linux/amd64,linux/arm/v7,linux/arm64 66 | tags: ghcr.io/${{ github.repository_owner }}/spegel:${{ steps.prep.outputs.VERSION }} 67 | labels: ${{ steps.meta.outputs.labels }} 68 | - name: Sign the image with Cosign 69 | run: | 70 | cosign sign --yes ghcr.io/${{ github.repository_owner }}/spegel@${{ steps.build.outputs.DIGEST }} 71 | - name: Publish Helm chart to GHCR 72 | id: helm 73 | run: | 74 | HELM_VERSION=${{ steps.prep.outputs.VERSION }} 75 | HELM_VERSION=${HELM_VERSION#v} 76 | rm charts/spegel/artifacthub-repo.yml 77 | yq -i '.image.digest = "${{ steps.build.outputs.DIGEST }}"' charts/spegel/values.yaml 78 | helm package --app-version ${{ steps.prep.outputs.VERSION }} --version ${HELM_VERSION} charts/spegel 79 | helm push spegel-${HELM_VERSION}.tgz oci://ghcr.io/${{ github.repository_owner }}/helm-charts 2> .digest 80 | DIGEST=$(cat .digest | awk -F "[, ]+" '/Digest/{print $NF}') 81 | echo "DIGEST=${DIGEST}" >> $GITHUB_OUTPUT 82 | - name: Sign the Helm chart with Cosign 83 | run: | 84 | cosign sign --yes ghcr.io/${{ github.repository_owner }}/helm-charts/spegel@${{ steps.helm.outputs.DIGEST }} 85 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # Added by goreleaser init: 24 | dist/ 25 | -------------------------------------------------------------------------------- /.golangci.yaml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | default: none 4 | enable: 5 | - bodyclose 6 | - errcheck 7 | - gocritic 8 | - govet 9 | - importas 10 | - ineffassign 11 | - ireturn 12 | - misspell 13 | - nolintlint 14 | - paralleltest 15 | - perfsprint 16 | - staticcheck 17 | - testifylint 18 | - unused 19 | settings: 20 | errcheck: 21 | disable-default-exclusions: true 22 | check-type-assertions: true 23 | check-blank: true 24 | gocritic: 25 | enable-all: true 26 | disabled-checks: 27 | - importShadow 28 | - hugeParam 29 | - rangeValCopy 30 | - whyNoLint 31 | - unnamedResult 32 | - httpNoBody 33 | govet: 34 | disable: 35 | - shadow 36 | enable-all: true 37 | importas: 38 | alias: 39 | - pkg: io/fs 40 | alias: iofs 41 | - pkg: github.com/go-logr/logr/testing 42 | alias: tlog 43 | - pkg: github.com/pelletier/go-toml/v2/unstable 44 | alias: tomlu 45 | - pkg: github.com/multiformats/go-multiaddr/net 46 | alias: manet 47 | - pkg: github.com/multiformats/go-multiaddr 48 | alias: ma 49 | - pkg: github.com/multiformats/go-multicodec 50 | alias: mc 51 | - pkg: github.com/multiformats/go-multihash 52 | alias: mh 53 | - pkg: github.com/ipfs/go-cid 54 | alias: cid 55 | - pkg: github.com/libp2p/go-libp2p-kad-dht 56 | alias: dht 57 | - pkg: github.com/libp2p/go-libp2p/p2p/net/mock 58 | alias: mocknet 59 | - pkg: go.etcd.io/bbolt 60 | alias: bolt 61 | - pkg: k8s.io/cri-api/pkg/apis/runtime/v1 62 | alias: runtimeapi 63 | - pkg: github.com/containerd/containerd/api/events 64 | alias: eventtypes 65 | - pkg: github.com/opencontainers/go-digest 66 | alias: digest 67 | - pkg: github.com/opencontainers/image-spec/specs-go/v1 68 | alias: ocispec 69 | - pkg: k8s.io/apimachinery/pkg/util/version 70 | alias: utilversion 71 | no-extra-aliases: true 72 | nolintlint: 73 | require-explanation: true 74 | require-specific: true 75 | perfsprint: 76 | strconcat: false 77 | testifylint: 78 | enable-all: true 79 | ireturn: 80 | allow: 81 | - anon 82 | - error 83 | - empty 84 | - stdlib 85 | - github.com/libp2p/go-libp2p/core/crypto.PrivKey 86 | exclusions: 87 | generated: lax 88 | presets: 89 | - comments 90 | - common-false-positives 91 | - legacy 92 | - std-error-handling 93 | paths: 94 | - third_party$ 95 | - builtin$ 96 | - examples$ 97 | formatters: 98 | enable: 99 | - goimports 100 | exclusions: 101 | generated: lax 102 | paths: 103 | - third_party$ 104 | - builtin$ 105 | - examples$ 106 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | project_name: spegel 3 | before: 4 | hooks: 5 | - go mod tidy 6 | builds: 7 | - goos: 8 | - linux 9 | goarch: 10 | - amd64 11 | - arm 12 | - arm64 13 | goarm: 14 | - 7 15 | env: 16 | - CGO_ENABLED=0 17 | flags: 18 | - -trimpath 19 | - -a 20 | no_unique_dist_dir: true 21 | binary: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}/{{ .ProjectName }}" 22 | archives: 23 | - formats: [tar.gz] 24 | name_template: "{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}" 25 | files: 26 | - none* 27 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for considering contributing to Spegel, hopefully this document will make this process easier. 4 | 5 | ## Running tests 6 | 7 | The following tools are required to run the tests properly. 8 | 9 | * go 10 | * [golangci-lint](https://github.com/golangci/golangci-lint) 11 | * [kind](https://github.com/kubernetes-sigs/kind) 12 | * [goreleaser](https://github.com/goreleaser/goreleaser) 13 | * [docker](https://docs.docker.com/get-started/get-docker/) 14 | * [helm](https://github.com/helm/helm) 15 | * [kubectl](https://kubernetes.io/docs/tasks/tools/#kubectl) 16 | 17 | Run the linter and the unit tests to quickly validate changes. 18 | 19 | ```shell 20 | make lint test 21 | ``` 22 | 23 | Run the e2e tests which take a bit more time. 24 | 25 | ```shell 26 | make test-e2e 27 | ``` 28 | 29 | There are e2e tests for the different CNIs iptables, iptables-v6, and ipvs. 30 | 31 | ```shell 32 | make test-e2e E2E_CNI=ipvs 33 | ``` 34 | 35 | ## Building 36 | 37 | Build the Docker image locally. 38 | 39 | ```shell 40 | make build-image 41 | ``` 42 | 43 | It is possible to specify a different image name and tag. 44 | 45 | ```shell 46 | make build-image IMG=example.com/spegel TAG=feature 47 | ``` 48 | 49 | ### Local debugging 50 | 51 | Run the `dev-deploy` recipe which will create a Kind cluster with the proper configuration and deploy Spegel into it. If you run this command a second time the cluster will be kept but Spegel will be updated. 52 | 53 | ```shell 54 | make dev-deploy 55 | ``` 56 | 57 | After the command has run you can get a kubeconfig file to access the cluster and do any debugging. 58 | 59 | ```shell 60 | kind get kubeconfig --name spegel-dev > kubeconfig 61 | export KUBECOONFIG=$(pwd)/kubeconfig 62 | kubectl -n spegel get pods 63 | ``` 64 | 65 | ## Generate Helm documentation 66 | 67 | Changes to the Helm chart values will require the documentation to be regenerated. 68 | 69 | ```shell 70 | make helm-docs 71 | ``` 72 | 73 | ## Acceptance policy 74 | 75 | Pull requests need to fulfill the following requirements to be accepted. 76 | 77 | * New code has tests where applicable. 78 | * The change has been added to the [changelog](./CHANGELOG.md). 79 | * Documentation has been generated if applicable. 80 | * The unit tests pass. 81 | * Linter does not report any errors. 82 | * All end to end tests pass. 83 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM gcr.io/distroless/static:nonroot 2 | ARG TARGETOS 3 | ARG TARGETARCH 4 | COPY ./dist/spegel_${TARGETOS}_${TARGETARCH}/spegel / 5 | USER root:root 6 | ENTRYPOINT ["/spegel"] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 The Spegel Authors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | TAG = $$(git rev-parse --short HEAD) 2 | IMG_NAME ?= ghcr.io/spegel-org/spegel 3 | IMG_REF = $(IMG_NAME):$(TAG) 4 | E2E_PROXY_MODE ?= iptables 5 | E2E_IP_FAMILY ?= ipv4 6 | 7 | lint: 8 | golangci-lint run ./... 9 | 10 | build: 11 | goreleaser build --snapshot --clean --single-target --skip before 12 | 13 | build-image: build 14 | docker build -t ${IMG_REF} . 15 | 16 | test-unit: 17 | go test ./... 18 | 19 | test-e2e: build-image 20 | IMG_REF=${IMG_REF} \ 21 | E2E_PROXY_MODE=${E2E_PROXY_MODE} \ 22 | E2E_IP_FAMILY=${E2E_IP_FAMILY} \ 23 | go test ./test/e2e -v -timeout 200s -tags e2e -count 1 -run TestE2E 24 | 25 | dev-deploy: build-image 26 | IMG_REF=${IMG_REF} go test ./test/e2e -v -timeout 200s -tags e2e -count 1 -run TestDevDeploy 27 | 28 | tools: 29 | GO111MODULE=on go install github.com/norwoodj/helm-docs/cmd/helm-docs 30 | 31 | helm-docs: tools 32 | cd ./charts/spegel && helm-docs 33 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | > [!NOTE] 2 | > We’ve started hosting community meetings every Tuesday at 17:00 CET. Find out how to participate at https://spegel.dev/project/community/#meeting. 3 | 4 | # Spegel 5 | 6 | Spegel, mirror in Swedish, is a stateless cluster local OCI registry mirror. 7 | 8 |

9 | 10 |

11 | 12 | ## Features 13 | 14 | Spegel is for you if you are looking to do any of the following. 15 | 16 | * Locally cache images from external registries with no explicit configuration. 17 | * Avoid cluster failure during external registry downtime. 18 | * Improve image pull speed and pod startup time by pulling images from the local cache first. 19 | * Avoid rate-limiting when pulling images from external registries (e.g. Docker Hub). 20 | * Decrease egressing traffic outside of the clusters network. 21 | * Increase image pull efficiency in edge node deployments. 22 | 23 | ## Getting Started 24 | 25 | Read the [getting started](https://spegel.dev/docs/getting-started/) guide to deploy Spegel. 26 | 27 | ## Contributing 28 | 29 | Read [contribution guidelines](./CONTRIBUTING.md) for instructions on how to build and test Spegel. 30 | 31 | ## Acknowledgements 32 | 33 | Spegel was initially developed at [Xenit AB](https://xenit.se/). 34 | 35 | ## License 36 | 37 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. 38 | -------------------------------------------------------------------------------- /charts/spegel/.helmignore: -------------------------------------------------------------------------------- 1 | # Patterns to ignore when building packages. 2 | # This supports shell glob matching, relative path matching, and 3 | # negation (prefixed with !). Only one pattern per line. 4 | .DS_Store 5 | # Common VCS dirs 6 | .git/ 7 | .gitignore 8 | .bzr/ 9 | .bzrignore 10 | .hg/ 11 | .hgignore 12 | .svn/ 13 | # Common backup files 14 | *.swp 15 | *.bak 16 | *.tmp 17 | *.orig 18 | *~ 19 | # Various IDEs 20 | .project 21 | .idea/ 22 | *.tmproj 23 | .vscode/ 24 | -------------------------------------------------------------------------------- /charts/spegel/Chart.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v2 2 | name: spegel 3 | description: Stateless cluster local OCI registry mirror. 4 | type: application 5 | version: v0.0.1 6 | appVersion: v0.0.1 7 | annotations: 8 | artifacthub.io/category: "integration-delivery" 9 | artifacthub.io/license: "MIT" 10 | artifacthub.io/operator: "false" 11 | artifacthub.io/prerelease: "false" 12 | -------------------------------------------------------------------------------- /charts/spegel/README.md: -------------------------------------------------------------------------------- 1 | # Spegel 2 | 3 | Stateless cluster local OCI registry mirror. 4 | 5 | Read the [getting started](https://spegel.dev/docs/getting-started/) guide to deploy Spegel. 6 | 7 | ## Values 8 | 9 | | Key | Type | Default | Description | 10 | |-----|------|---------|-------------| 11 | | affinity | object | `{}` | Affinity settings for pod assignment. | 12 | | basicAuthSecretName | string | `""` | Name of secret containing basic authentication credentials for registry. | 13 | | clusterDomain | string | `"cluster.local."` | Domain configured for service domain names. | 14 | | commonLabels | object | `{}` | Common labels to apply to all rendered resources. | 15 | | fullnameOverride | string | `""` | Overrides the full name of the chart. | 16 | | grafanaDashboard.annotations | object | `{}` | Annotations that ConfigMaps can have to get configured in Grafana, See: sidecar.dashboards.folderAnnotation for specifying the dashboard folder. https://github.com/grafana/helm-charts/tree/main/charts/grafana | 17 | | grafanaDashboard.enabled | bool | `false` | If true creates a Grafana dashboard. | 18 | | grafanaDashboard.sidecarLabel | string | `"grafana_dashboard"` | Label that ConfigMaps should have to be loaded as dashboards. | 19 | | grafanaDashboard.sidecarLabelValue | string | `"1"` | Label value that ConfigMaps should have to be loaded as dashboards. | 20 | | image.digest | string | `""` | Image digest. | 21 | | image.pullPolicy | string | `"IfNotPresent"` | Image Pull Policy. | 22 | | image.repository | string | `"ghcr.io/spegel-org/spegel"` | Image repository. | 23 | | image.tag | string | `""` | Overrides the image tag whose default is the chart appVersion. | 24 | | imagePullSecrets | list | `[]` | Image Pull Secrets | 25 | | nameOverride | string | `""` | Overrides the name of the chart. | 26 | | namespaceOverride | string | `""` | Overrides the namespace where spegel resources are installed. | 27 | | nodeSelector | object | `{"kubernetes.io/os":"linux"}` | Node selector for pod assignment. | 28 | | podAnnotations | object | `{}` | Annotations to add to the pod. | 29 | | podSecurityContext | object | `{}` | Security context for the pod. | 30 | | priorityClassName | string | `"system-node-critical"` | Priority class name to use for the pod. | 31 | | resources | object | `{"limits":{"memory":"128Mi"},"requests":{"memory":"128Mi"}}` | Resource requests and limits for the Spegel container. | 32 | | revisionHistoryLimit | int | `10` | The number of old history to retain to allow rollback. | 33 | | securityContext | object | `{}` | Security context for the Spegel container. | 34 | | service.cleanup.port | int | `8080` | Port to expose cleanup probe on. | 35 | | service.metrics.port | int | `9090` | Port to expose the metrics via the service. | 36 | | service.registry.hostPort | int | `30020` | Local host port to expose the registry. | 37 | | service.registry.nodeIp | string | `""` | Override the NODE_ID environment variable. It defaults to the field status.hostIP | 38 | | service.registry.nodePort | int | `30021` | Node port to expose the registry via the service. | 39 | | service.registry.port | int | `5000` | Port to expose the registry via the service. | 40 | | service.registry.topologyAwareHintsEnabled | bool | `true` | If true adds topology aware hints annotation to node port service. | 41 | | service.router.port | int | `5001` | Port to expose the router via the service. | 42 | | serviceAccount.annotations | object | `{}` | Annotations to add to the service account | 43 | | serviceAccount.name | string | `""` | The name of the service account to use. If not set and create is true, a name is generated using the fullname template. | 44 | | serviceMonitor.enabled | bool | `false` | If true creates a Prometheus Service Monitor. | 45 | | serviceMonitor.interval | string | `"60s"` | Prometheus scrape interval. | 46 | | serviceMonitor.labels | object | `{}` | Service monitor specific labels for prometheus to discover servicemonitor. | 47 | | serviceMonitor.metricRelabelings | list | `[]` | List of relabeling rules to apply to the samples before ingestion. | 48 | | serviceMonitor.relabelings | list | `[]` | List of relabeling rules to apply the target’s metadata labels. | 49 | | serviceMonitor.scrapeTimeout | string | `"30s"` | Prometheus scrape interval timeout. | 50 | | spegel.additionalMirrorTargets | list | `[]` | Additional target mirror registries other than Spegel. | 51 | | spegel.containerdContentPath | string | `"/var/lib/containerd/io.containerd.content.v1.content"` | Path to Containerd content store.. | 52 | | spegel.containerdMirrorAdd | bool | `true` | If true Spegel will add mirror configuration to the node. | 53 | | spegel.containerdNamespace | string | `"k8s.io"` | Containerd namespace where images are stored. | 54 | | spegel.containerdRegistryConfigPath | string | `"/etc/containerd/certs.d"` | Path to Containerd mirror configuration. | 55 | | spegel.containerdSock | string | `"/run/containerd/containerd.sock"` | Path to Containerd socket. | 56 | | spegel.debugWebEnabled | bool | `false` | When true enables debug web page. | 57 | | spegel.logLevel | string | `"INFO"` | Minimum log level to output. Value should be DEBUG, INFO, WARN, or ERROR. | 58 | | spegel.mirrorResolveRetries | int | `3` | Max amount of mirrors to attempt. | 59 | | spegel.mirrorResolveTimeout | string | `"20ms"` | Max duration spent finding a mirror. | 60 | | spegel.mirroredRegistries | list | `[]` | Registries for which mirror configuration will be created. Empty means all registires will be mirrored. | 61 | | spegel.prependExisting | bool | `false` | When true existing mirror configuration will be kept and Spegel will prepend it's configuration. | 62 | | spegel.resolveLatestTag | bool | `true` | When true latest tags will be resolved to digests. | 63 | | spegel.resolveTags | bool | `true` | When true Spegel will resolve tags to digests. | 64 | | tolerations | list | `[{"key":"CriticalAddonsOnly","operator":"Exists"},{"effect":"NoExecute","operator":"Exists"},{"effect":"NoSchedule","operator":"Exists"}]` | Tolerations for pod assignment. | 65 | | updateStrategy | object | `{}` | An update strategy to replace existing pods with new pods. | 66 | | verticalPodAutoscaler.controlledResources | list | `[]` | List of resources that the vertical pod autoscaler can control. Defaults to cpu and memory | 67 | | verticalPodAutoscaler.controlledValues | string | `"RequestsAndLimits"` | Specifies which resource values should be controlled: RequestsOnly or RequestsAndLimits. | 68 | | verticalPodAutoscaler.enabled | bool | `false` | If true creates a Vertical Pod Autoscaler. | 69 | | verticalPodAutoscaler.maxAllowed | object | `{}` | Define the max allowed resources for the pod | 70 | | verticalPodAutoscaler.minAllowed | object | `{}` | Define the min allowed resources for the pod | 71 | | verticalPodAutoscaler.recommenders | list | `[]` | Recommender responsible for generating recommendation for the object. List should be empty (then the default recommender will generate the recommendation) or contain exactly one recommender. | 72 | | verticalPodAutoscaler.updatePolicy.minReplicas | int | `2` | Specifies minimal number of replicas which need to be alive for VPA Updater to attempt pod eviction | 73 | | verticalPodAutoscaler.updatePolicy.updateMode | string | `"Auto"` | Specifies whether recommended updates are applied when a Pod is started and whether recommended updates are applied during the life of a Pod. Possible values are "Off", "Initial", "Recreate", and "Auto". | 74 | -------------------------------------------------------------------------------- /charts/spegel/README.md.gotmpl: -------------------------------------------------------------------------------- 1 | # Spegel 2 | 3 | {{ template "chart.description" . }} 4 | 5 | Read the [getting started](https://spegel.dev/docs/getting-started/) guide to deploy Spegel. 6 | 7 | {{ template "chart.valuesSection" . }} 8 | -------------------------------------------------------------------------------- /charts/spegel/artifacthub-repo.yml: -------------------------------------------------------------------------------- 1 | repositoryID: 8122016b-c465-4eaf-be87-f51423aa76f1 2 | owners: 3 | - name: Philip Laine 4 | email: philip.laine@gmail.com 5 | -------------------------------------------------------------------------------- /charts/spegel/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* 2 | Expand the name of the chart. 3 | */}} 4 | {{- define "spegel.name" -}} 5 | {{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} 6 | {{- end }} 7 | 8 | {{/* 9 | Create a default fully qualified app name. 10 | We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). 11 | If release name contains chart name it will be used as a full name. 12 | */}} 13 | {{- define "spegel.fullname" -}} 14 | {{- if .Values.fullnameOverride }} 15 | {{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} 16 | {{- else }} 17 | {{- $name := default .Chart.Name .Values.nameOverride }} 18 | {{- if contains $name .Release.Name }} 19 | {{- .Release.Name | trunc 63 | trimSuffix "-" }} 20 | {{- else }} 21 | {{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} 22 | {{- end }} 23 | {{- end }} 24 | {{- end }} 25 | 26 | {{/* 27 | Creates the namespace for the chart. 28 | Defaults to the Release namespace unless the namespaceOverride is defined. 29 | */}} 30 | {{- define "spegel.namespace" -}} 31 | {{- if .Values.namespaceOverride }} 32 | {{- printf "%s" .Values.namespaceOverride -}} 33 | {{- else }} 34 | {{- printf "%s" .Release.Namespace -}} 35 | {{- end }} 36 | {{- end }} 37 | 38 | 39 | {{/* 40 | Create chart name and version as used by the chart label. 41 | */}} 42 | {{- define "spegel.chart" -}} 43 | {{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} 44 | {{- end }} 45 | 46 | {{/* 47 | Common labels 48 | */}} 49 | {{- define "spegel.labels" -}} 50 | helm.sh/chart: {{ include "spegel.chart" . }} 51 | {{ include "spegel.selectorLabels" . }} 52 | {{- if .Chart.AppVersion }} 53 | app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} 54 | {{- end }} 55 | app.kubernetes.io/managed-by: {{ .Release.Service }} 56 | {{- with .Values.commonLabels }} 57 | {{ toYaml . }} 58 | {{- end }} 59 | {{- end }} 60 | 61 | {{/* 62 | {{- end }} 63 | {{- end }} 64 | 65 | {{/* 66 | Selector labels 67 | */}} 68 | {{- define "spegel.selectorLabels" -}} 69 | app.kubernetes.io/name: {{ include "spegel.name" . }} 70 | app.kubernetes.io/instance: {{ .Release.Name }} 71 | {{- end }} 72 | 73 | {{/* 74 | Create the name of the service account to use 75 | */}} 76 | {{- define "spegel.serviceAccountName" -}} 77 | {{- default (include "spegel.fullname" .) .Values.serviceAccount.name }} 78 | {{- end }} 79 | 80 | {{/* 81 | Image reference 82 | */}} 83 | {{- define "spegel.image" -}} 84 | {{- if .Values.image.digest }} 85 | {{- .Values.image.repository }}@{{ .Values.image.digest }} 86 | {{- else }} 87 | {{- .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }} 88 | {{- end }} 89 | {{- end }} 90 | 91 | {{/* 92 | Host networking 93 | */}} 94 | {{- define "networking.nodeIp" -}} 95 | {{- if .Values.service.registry.nodeIp -}} 96 | value: {{ .Values.service.registry.nodeIp }} 97 | {{- else -}} 98 | valueFrom: 99 | fieldRef: 100 | fieldPath: status.hostIP 101 | {{- end -}} 102 | {{- end -}} 103 | -------------------------------------------------------------------------------- /charts/spegel/templates/daemonset.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: DaemonSet 3 | metadata: 4 | name: {{ include "spegel.fullname" . }} 5 | namespace: {{ include "spegel.namespace" . }} 6 | labels: 7 | {{- include "spegel.labels" . | nindent 4 }} 8 | spec: 9 | revisionHistoryLimit: {{ .Values.revisionHistoryLimit }} 10 | updateStrategy: 11 | {{- toYaml .Values.updateStrategy | nindent 4 }} 12 | selector: 13 | matchLabels: 14 | {{- include "spegel.selectorLabels" . | nindent 6 }} 15 | template: 16 | metadata: 17 | {{- with .Values.podAnnotations }} 18 | annotations: 19 | {{- toYaml . | nindent 8 }} 20 | {{- end }} 21 | labels: 22 | {{- include "spegel.selectorLabels" . | nindent 8 }} 23 | {{- with .Values.commonLabels }} 24 | {{- toYaml . | nindent 8 }} 25 | {{- end }} 26 | spec: 27 | {{- with .Values.imagePullSecrets }} 28 | imagePullSecrets: 29 | {{- toYaml . | nindent 8 }} 30 | {{- end }} 31 | serviceAccountName: {{ include "spegel.serviceAccountName" . }} 32 | securityContext: 33 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 34 | priorityClassName: {{ .Values.priorityClassName }} 35 | {{- if .Values.spegel.containerdMirrorAdd }} 36 | initContainers: 37 | - name: configuration 38 | image: "{{ include "spegel.image" . }}" 39 | imagePullPolicy: {{ .Values.image.pullPolicy }} 40 | securityContext: 41 | {{- toYaml .Values.securityContext | nindent 12 }} 42 | args: 43 | - configuration 44 | - --log-level={{ .Values.spegel.logLevel }} 45 | - --containerd-registry-config-path={{ .Values.spegel.containerdRegistryConfigPath }} 46 | {{- with .Values.spegel.mirroredRegistries }} 47 | - --mirrored-registries 48 | {{- range . }} 49 | - {{ . | quote }} 50 | {{- end }} 51 | {{- end }} 52 | - --mirror-targets 53 | - http://$(NODE_IP):{{ .Values.service.registry.hostPort }} 54 | - http://$(NODE_IP):{{ .Values.service.registry.nodePort }} 55 | {{- with .Values.spegel.additionalMirrorTargets }} 56 | {{- range . }} 57 | - {{ . | quote }} 58 | {{- end }} 59 | {{- end }} 60 | - --resolve-tags={{ .Values.spegel.resolveTags }} 61 | - --prepend-existing={{ .Values.spegel.prependExisting }} 62 | env: 63 | - name: NODE_IP 64 | {{- include "networking.nodeIp" . | nindent 10 }} 65 | resources: 66 | {{- toYaml .Values.resources | nindent 10 }} 67 | volumeMounts: 68 | - name: containerd-config 69 | mountPath: {{ .Values.spegel.containerdRegistryConfigPath }} 70 | {{- if .Values.basicAuthSecretName }} 71 | - name: basic-auth 72 | mountPath: "/etc/secrets/basic-auth" 73 | readOnly: true 74 | {{- end }} 75 | {{- end }} 76 | containers: 77 | - name: registry 78 | image: "{{ include "spegel.image" . }}" 79 | imagePullPolicy: {{ .Values.image.pullPolicy }} 80 | securityContext: 81 | {{- toYaml .Values.securityContext | nindent 12 }} 82 | args: 83 | - registry 84 | - --log-level={{ .Values.spegel.logLevel }} 85 | - --mirror-resolve-retries={{ .Values.spegel.mirrorResolveRetries }} 86 | - --mirror-resolve-timeout={{ .Values.spegel.mirrorResolveTimeout }} 87 | - --registry-addr=:{{ .Values.service.registry.port }} 88 | - --router-addr=:{{ .Values.service.router.port }} 89 | - --metrics-addr=:{{ .Values.service.metrics.port }} 90 | {{- with .Values.spegel.mirroredRegistries }} 91 | - --mirrored-registries 92 | {{- range . }} 93 | - {{ . | quote }} 94 | {{- end }} 95 | {{- end }} 96 | - --containerd-sock={{ .Values.spegel.containerdSock }} 97 | - --containerd-namespace={{ .Values.spegel.containerdNamespace }} 98 | - --containerd-registry-config-path={{ .Values.spegel.containerdRegistryConfigPath }} 99 | - --bootstrap-kind=dns 100 | - --dns-bootstrap-domain={{ include "spegel.fullname" . }}-bootstrap.{{ include "spegel.namespace" . }}.svc.{{ .Values.clusterDomain }} 101 | - --resolve-latest-tag={{ .Values.spegel.resolveLatestTag }} 102 | {{- with .Values.spegel.containerdContentPath }} 103 | - --containerd-content-path={{ . }} 104 | {{- end }} 105 | - --debug-web-enabled={{ .Values.spegel.debugWebEnabled }} 106 | env: 107 | {{- if ((.Values.resources).limits).cpu }} 108 | - name: GOMAXPROCS 109 | valueFrom: 110 | resourceFieldRef: 111 | resource: limits.cpu 112 | divisor: 1 113 | {{- end }} 114 | {{- if ((.Values.resources).limits).memory }} 115 | - name: GOMEMLIMIT 116 | valueFrom: 117 | resourceFieldRef: 118 | resource: limits.memory 119 | divisor: 1 120 | {{- end }} 121 | - name: NODE_IP 122 | {{- include "networking.nodeIp" . | nindent 10 }} 123 | ports: 124 | - name: registry 125 | containerPort: {{ .Values.service.registry.port }} 126 | hostPort: {{ .Values.service.registry.hostPort }} 127 | protocol: TCP 128 | - name: router 129 | containerPort: {{ .Values.service.router.port }} 130 | protocol: TCP 131 | - name: metrics 132 | containerPort: {{ .Values.service.metrics.port }} 133 | protocol: TCP 134 | # Startup may take a bit longer on bootsrap as Pods need to find each other. 135 | # This is why the startup proben is a bit more forgiving, while hitting the endpoint more often. 136 | startupProbe: 137 | periodSeconds: 3 138 | failureThreshold: 60 139 | httpGet: 140 | path: /healthz 141 | port: registry 142 | readinessProbe: 143 | httpGet: 144 | path: /healthz 145 | port: registry 146 | volumeMounts: 147 | {{- if .Values.basicAuthSecretName }} 148 | - name: basic-auth 149 | mountPath: "/etc/secrets/basic-auth" 150 | readOnly: true 151 | {{- end }} 152 | - name: containerd-sock 153 | mountPath: {{ .Values.spegel.containerdSock }} 154 | {{- with .Values.spegel.containerdContentPath }} 155 | - name: containerd-content 156 | mountPath: {{ . }} 157 | readOnly: true 158 | {{- end }} 159 | resources: 160 | {{- toYaml .Values.resources | nindent 10 }} 161 | volumes: 162 | {{- with .Values.basicAuthSecretName }} 163 | - name: basic-auth 164 | secret: 165 | secretName: {{ . }} 166 | {{- end }} 167 | - name: containerd-sock 168 | hostPath: 169 | path: {{ .Values.spegel.containerdSock }} 170 | type: Socket 171 | {{- with .Values.spegel.containerdContentPath }} 172 | - name: containerd-content 173 | hostPath: 174 | path: {{ . }} 175 | type: Directory 176 | {{- end }} 177 | {{- if .Values.spegel.containerdMirrorAdd }} 178 | - name: containerd-config 179 | hostPath: 180 | path: {{ .Values.spegel.containerdRegistryConfigPath }} 181 | type: DirectoryOrCreate 182 | {{- end }} 183 | {{- with .Values.nodeSelector }} 184 | nodeSelector: 185 | {{- toYaml . | nindent 8 }} 186 | {{- end }} 187 | {{- with .Values.affinity }} 188 | affinity: 189 | {{- toYaml . | nindent 8 }} 190 | {{- end }} 191 | {{- with .Values.tolerations }} 192 | tolerations: 193 | {{- toYaml . | nindent 8 }} 194 | {{- end }} 195 | -------------------------------------------------------------------------------- /charts/spegel/templates/grafana-dashboard.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.grafanaDashboard.enabled }} 2 | apiVersion: v1 3 | kind: ConfigMap 4 | metadata: 5 | name: {{ include "spegel.fullname" . }}-dashboard 6 | namespace: {{ include "spegel.namespace" . }} 7 | labels: 8 | {{ .Values.grafanaDashboard.sidecarLabel }}: {{ .Values.grafanaDashboard.sidecarLabelValue | quote }} 9 | {{- include "spegel.labels" . | nindent 4 }} 10 | {{- with .Values.grafanaDashboard.annotations }} 11 | annotations: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | data: 15 | spegel.json: |- 16 | {{ .Files.Get "monitoring/grafana-dashboard.json" | indent 6 }} 17 | {{- end }} 18 | -------------------------------------------------------------------------------- /charts/spegel/templates/post-delete-hook.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.spegel.containerdMirrorAdd }} 2 | apiVersion: apps/v1 3 | kind: DaemonSet 4 | metadata: 5 | name: {{ include "spegel.fullname" . }}-cleanup 6 | namespace: {{ include "spegel.namespace" . }} 7 | labels: 8 | app.kubernetes.io/component: cleanup 9 | {{- include "spegel.labels" . | nindent 4 }} 10 | annotations: 11 | helm.sh/hook: "post-delete" 12 | helm.sh/hook-delete-policy: "before-hook-creation, hook-succeeded" 13 | helm.sh/hook-weight: "0" 14 | spec: 15 | selector: 16 | matchLabels: 17 | app.kubernetes.io/component: cleanup 18 | {{- include "spegel.selectorLabels" . | nindent 6 }} 19 | template: 20 | metadata: 21 | labels: 22 | app.kubernetes.io/component: cleanup 23 | {{- include "spegel.selectorLabels" . | nindent 8 }} 24 | spec: 25 | {{- with .Values.imagePullSecrets }} 26 | imagePullSecrets: 27 | {{- toYaml . | nindent 8 }} 28 | {{- end }} 29 | securityContext: 30 | {{- toYaml .Values.podSecurityContext | nindent 8 }} 31 | priorityClassName: {{ .Values.priorityClassName }} 32 | containers: 33 | - name: cleanup 34 | image: "{{ include "spegel.image" . }}" 35 | imagePullPolicy: {{ .Values.image.pullPolicy }} 36 | args: 37 | - cleanup 38 | - --containerd-registry-config-path={{ .Values.spegel.containerdRegistryConfigPath }} 39 | - --addr=:{{ .Values.service.cleanup.port }} 40 | readinessProbe: 41 | httpGet: 42 | path: /healthz 43 | port: readiness 44 | ports: 45 | - name: readiness 46 | containerPort: {{ .Values.service.cleanup.port }} 47 | protocol: TCP 48 | volumeMounts: 49 | - name: containerd-config 50 | mountPath: {{ .Values.spegel.containerdRegistryConfigPath }} 51 | volumes: 52 | - name: containerd-config 53 | hostPath: 54 | path: {{ .Values.spegel.containerdRegistryConfigPath }} 55 | type: DirectoryOrCreate 56 | {{- with .Values.tolerations }} 57 | tolerations: 58 | {{- toYaml . | nindent 8 }} 59 | {{- end }} 60 | --- 61 | apiVersion: v1 62 | kind: Service 63 | metadata: 64 | name: {{ include "spegel.fullname" . }}-cleanup 65 | namespace: {{ include "spegel.namespace" . }} 66 | labels: 67 | app.kubernetes.io/component: cleanup 68 | {{- include "spegel.labels" . | nindent 4 }} 69 | annotations: 70 | helm.sh/hook: "post-delete" 71 | helm.sh/hook-delete-policy: "before-hook-creation, hook-succeeded" 72 | helm.sh/hook-weight: "0" 73 | spec: 74 | selector: 75 | app.kubernetes.io/component: cleanup 76 | {{- include "spegel.selectorLabels" . | nindent 4 }} 77 | clusterIP: None 78 | publishNotReadyAddresses: true 79 | ports: 80 | - name: readiness 81 | port: {{ .Values.service.cleanup.port }} 82 | protocol: TCP 83 | --- 84 | apiVersion: v1 85 | kind: Pod 86 | metadata: 87 | name: {{ include "spegel.fullname" . }}-cleanup-wait 88 | namespace: {{ include "spegel.namespace" . }} 89 | labels: 90 | app.kubernetes.io/component: cleanup-wait 91 | {{- include "spegel.labels" . | nindent 4 }} 92 | annotations: 93 | helm.sh/hook: "post-delete" 94 | helm.sh/hook-delete-policy: "before-hook-creation, hook-succeeded" 95 | helm.sh/hook-weight: "1" 96 | spec: 97 | containers: 98 | - name: cleanup-wait 99 | image: "{{ include "spegel.image" . }}" 100 | imagePullPolicy: {{ .Values.image.pullPolicy }} 101 | args: 102 | - cleanup-wait 103 | - --probe-endpoint={{ include "spegel.fullname" . }}-cleanup.{{ include "spegel.namespace" . }}.svc.{{ .Values.clusterDomain }}:{{ .Values.service.cleanup.port }} 104 | restartPolicy: Never 105 | terminationGracePeriodSeconds: 0 106 | {{- end }} 107 | -------------------------------------------------------------------------------- /charts/spegel/templates/rbac.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: {{ include "spegel.serviceAccountName" . }} 5 | namespace: {{ include "spegel.namespace" . }} 6 | labels: 7 | {{- include "spegel.labels" . | nindent 4 }} 8 | {{- with .Values.serviceAccount.annotations }} 9 | annotations: 10 | {{- toYaml . | nindent 4 }} 11 | {{- end }} 12 | -------------------------------------------------------------------------------- /charts/spegel/templates/service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: {{ include "spegel.fullname" . }} 5 | namespace: {{ include "spegel.namespace" . }} 6 | labels: 7 | app.kubernetes.io/component: metrics 8 | {{- include "spegel.labels" . | nindent 4 }} 9 | spec: 10 | selector: 11 | {{- include "spegel.selectorLabels" . | nindent 4 }} 12 | ports: 13 | - name: metrics 14 | port: {{ .Values.service.metrics.port }} 15 | targetPort: metrics 16 | protocol: TCP 17 | --- 18 | apiVersion: v1 19 | kind: Service 20 | metadata: 21 | name: {{ include "spegel.fullname" . }}-registry 22 | namespace: {{ include "spegel.namespace" . }} 23 | labels: 24 | {{- include "spegel.labels" . | nindent 4 }} 25 | {{- if .Values.service.registry.topologyAwareHintsEnabled }} 26 | annotations: 27 | service.kubernetes.io/topology-mode: "auto" 28 | {{- end }} 29 | spec: 30 | type: NodePort 31 | selector: 32 | {{- include "spegel.selectorLabels" . | nindent 4 }} 33 | ports: 34 | - name: registry 35 | port: {{ .Values.service.registry.port }} 36 | targetPort: registry 37 | nodePort: {{ .Values.service.registry.nodePort }} 38 | protocol: TCP 39 | --- 40 | apiVersion: v1 41 | kind: Service 42 | metadata: 43 | name: {{ include "spegel.fullname" . }}-bootstrap 44 | namespace: {{ include "spegel.namespace" . }} 45 | labels: 46 | {{- include "spegel.labels" . | nindent 4 }} 47 | spec: 48 | selector: 49 | {{- include "spegel.selectorLabels" . | nindent 4 }} 50 | clusterIP: None 51 | publishNotReadyAddresses: true 52 | ports: 53 | - name: router 54 | port: {{ .Values.service.router.port }} 55 | protocol: TCP 56 | -------------------------------------------------------------------------------- /charts/spegel/templates/servicemonitor.yaml: -------------------------------------------------------------------------------- 1 | {{- if .Values.serviceMonitor.enabled }} 2 | apiVersion: monitoring.coreos.com/v1 3 | kind: ServiceMonitor 4 | metadata: 5 | name: {{ include "spegel.fullname" . }} 6 | namespace: {{ include "spegel.namespace" . }} 7 | labels: 8 | {{- include "spegel.labels" . | nindent 4 }} 9 | {{- if .Values.serviceMonitor.labels -}} 10 | {{ toYaml .Values.serviceMonitor.labels | nindent 4}} 11 | {{- end }} 12 | spec: 13 | selector: 14 | matchLabels: 15 | app.kubernetes.io/component: metrics 16 | {{- include "spegel.selectorLabels" . | nindent 6 }} 17 | endpoints: 18 | - port: metrics 19 | interval: {{ .Values.serviceMonitor.interval }} 20 | scrapeTimeout: {{ .Values.serviceMonitor.scrapeTimeout }} 21 | {{- with .Values.serviceMonitor.relabelings }} 22 | relabelings: 23 | {{- toYaml . | nindent 8 }} 24 | {{- end }} 25 | {{- with .Values.serviceMonitor.metricRelabelings }} 26 | metricRelabelings: 27 | {{- toYaml . | nindent 8 }} 28 | {{- end }} 29 | {{- end }} 30 | -------------------------------------------------------------------------------- /charts/spegel/templates/verticalpodautoscaler.yaml: -------------------------------------------------------------------------------- 1 | {{- if and (.Capabilities.APIVersions.Has "autoscaling.k8s.io/v1") (.Values.verticalPodAutoscaler.enabled) }} 2 | apiVersion: autoscaling.k8s.io/v1 3 | kind: VerticalPodAutoscaler 4 | metadata: 5 | name: {{ include "spegel.fullname" . }} 6 | namespace: {{ include "spegel.namespace" . }} 7 | labels: 8 | {{- include "spegel.labels" . | nindent 4 }} 9 | spec: 10 | {{- with .Values.verticalPodAutoscaler.recommenders }} 11 | recommenders: 12 | {{- toYaml . | nindent 4 }} 13 | {{- end }} 14 | resourcePolicy: 15 | containerPolicies: 16 | - containerName: registry 17 | {{- with .Values.verticalPodAutoscaler.controlledResources }} 18 | controlledResources: 19 | {{- toYaml . | nindent 8 }} 20 | {{- end }} 21 | {{- if .Values.verticalPodAutoscaler.controlledValues }} 22 | controlledValues: {{ .Values.verticalPodAutoscaler.controlledValues }} 23 | {{- end }} 24 | {{- if .Values.verticalPodAutoscaler.maxAllowed }} 25 | maxAllowed: 26 | {{- toYaml .Values.verticalPodAutoscaler.maxAllowed | nindent 8 }} 27 | {{- end }} 28 | {{- if .Values.verticalPodAutoscaler.minAllowed }} 29 | minAllowed: 30 | {{- toYaml .Values.verticalPodAutoscaler.minAllowed | nindent 8 }} 31 | {{- end }} 32 | targetRef: 33 | apiVersion: apps/v1 34 | kind: DaemonSet 35 | name: {{ include "spegel.fullname" . }} 36 | {{- with .Values.verticalPodAutoscaler.updatePolicy }} 37 | updatePolicy: 38 | {{- toYaml . | nindent 4 }} 39 | {{- end }} 40 | {{- end }} 41 | -------------------------------------------------------------------------------- /charts/spegel/values.yaml: -------------------------------------------------------------------------------- 1 | image: 2 | # -- Image repository. 3 | repository: ghcr.io/spegel-org/spegel 4 | # -- Image Pull Policy. 5 | pullPolicy: IfNotPresent 6 | # -- Overrides the image tag whose default is the chart appVersion. 7 | tag: "" 8 | # -- Image digest. 9 | digest: "" 10 | 11 | # -- Image Pull Secrets 12 | imagePullSecrets: [] 13 | # -- Overrides the name of the chart. 14 | nameOverride: "" 15 | # -- Overrides the full name of the chart. 16 | fullnameOverride: "" 17 | # -- Overrides the namespace where spegel resources are installed. 18 | namespaceOverride: "" 19 | 20 | serviceAccount: 21 | # -- Annotations to add to the service account 22 | annotations: {} 23 | # -- The name of the service account to use. 24 | # If not set and create is true, a name is generated using the fullname template. 25 | name: "" 26 | 27 | # -- Annotations to add to the pod. 28 | podAnnotations: {} 29 | 30 | # -- Security context for the pod. 31 | podSecurityContext: {} 32 | # fsGroup: 2000 33 | 34 | # -- The number of old history to retain to allow rollback. 35 | revisionHistoryLimit: 10 36 | 37 | # -- Security context for the Spegel container. 38 | securityContext: {} 39 | # capabilities: 40 | # drop: 41 | # - ALL 42 | # readOnlyRootFilesystem: true 43 | # runAsNonRoot: true 44 | # runAsUser: 1000 45 | 46 | service: 47 | registry: 48 | # -- Override the NODE_ID environment variable. It defaults to the field status.hostIP 49 | nodeIp: "" 50 | # -- Port to expose the registry via the service. 51 | port: 5000 52 | # -- Node port to expose the registry via the service. 53 | nodePort: 30021 54 | # -- Local host port to expose the registry. 55 | hostPort: 30020 56 | # -- If true adds topology aware hints annotation to node port service. 57 | topologyAwareHintsEnabled: true 58 | router: 59 | # -- Port to expose the router via the service. 60 | port: 5001 61 | metrics: 62 | # -- Port to expose the metrics via the service. 63 | port: 9090 64 | cleanup: 65 | # -- Port to expose cleanup probe on. 66 | port: 8080 67 | 68 | # -- Resource requests and limits for the Spegel container. 69 | resources: 70 | requests: 71 | memory: 128Mi 72 | limits: 73 | memory: 128Mi 74 | 75 | # -- Node selector for pod assignment. 76 | nodeSelector: 77 | kubernetes.io/os: linux 78 | 79 | # -- An update strategy to replace existing pods with new pods. 80 | updateStrategy: {} 81 | # type: RollingUpdate 82 | # rollingUpdate: 83 | # maxSurge: 0 84 | # maxUnavailable: 1 85 | 86 | # -- Tolerations for pod assignment. 87 | tolerations: 88 | - key: CriticalAddonsOnly 89 | operator: Exists 90 | - effect: NoExecute 91 | operator: Exists 92 | - effect: NoSchedule 93 | operator: Exists 94 | 95 | # -- Affinity settings for pod assignment. 96 | affinity: {} 97 | 98 | # -- Common labels to apply to all rendered resources. 99 | commonLabels: {} 100 | 101 | # -- Domain configured for service domain names. 102 | clusterDomain: cluster.local. 103 | 104 | serviceMonitor: 105 | # -- If true creates a Prometheus Service Monitor. 106 | enabled: false 107 | # -- Prometheus scrape interval. 108 | interval: 60s 109 | # -- Prometheus scrape interval timeout. 110 | scrapeTimeout: 30s 111 | # -- Service monitor specific labels for prometheus to discover servicemonitor. 112 | labels: {} 113 | # -- List of relabeling rules to apply the target’s metadata labels. 114 | relabelings: [] 115 | # -- List of relabeling rules to apply to the samples before ingestion. 116 | metricRelabelings: [] 117 | 118 | grafanaDashboard: 119 | # -- If true creates a Grafana dashboard. 120 | enabled: false 121 | # -- Label that ConfigMaps should have to be loaded as dashboards. 122 | sidecarLabel: "grafana_dashboard" 123 | # -- Label value that ConfigMaps should have to be loaded as dashboards. 124 | sidecarLabelValue: "1" 125 | # -- Annotations that ConfigMaps can have to get configured in Grafana, 126 | # See: sidecar.dashboards.folderAnnotation for specifying the dashboard folder. 127 | # https://github.com/grafana/helm-charts/tree/main/charts/grafana 128 | annotations: {} 129 | 130 | # -- Priority class name to use for the pod. 131 | priorityClassName: system-node-critical 132 | 133 | # -- Name of secret containing basic authentication credentials for registry. 134 | basicAuthSecretName: "" 135 | 136 | spegel: 137 | # -- Minimum log level to output. Value should be DEBUG, INFO, WARN, or ERROR. 138 | logLevel: "INFO" 139 | # -- Registries for which mirror configuration will be created. Empty means all registires will be mirrored. 140 | mirroredRegistries: [] 141 | # - https://docker.io 142 | # - https://ghcr.io 143 | # -- Additional target mirror registries other than Spegel. 144 | additionalMirrorTargets: [] 145 | # -- Max amount of mirrors to attempt. 146 | mirrorResolveRetries: 3 147 | # -- Max duration spent finding a mirror. 148 | mirrorResolveTimeout: "20ms" 149 | # -- Path to Containerd socket. 150 | containerdSock: "/run/containerd/containerd.sock" 151 | # -- Containerd namespace where images are stored. 152 | containerdNamespace: "k8s.io" 153 | # -- Path to Containerd mirror configuration. 154 | containerdRegistryConfigPath: "/etc/containerd/certs.d" 155 | # -- Path to Containerd content store.. 156 | containerdContentPath: "/var/lib/containerd/io.containerd.content.v1.content" 157 | # -- If true Spegel will add mirror configuration to the node. 158 | containerdMirrorAdd: true 159 | # -- When true Spegel will resolve tags to digests. 160 | resolveTags: true 161 | # -- When true latest tags will be resolved to digests. 162 | resolveLatestTag: true 163 | # -- When true existing mirror configuration will be kept and Spegel will prepend it's configuration. 164 | prependExisting: false 165 | # -- When true enables debug web page. 166 | debugWebEnabled: false 167 | 168 | verticalPodAutoscaler: 169 | # -- If true creates a Vertical Pod Autoscaler. 170 | enabled: false 171 | 172 | # -- Recommender responsible for generating recommendation for the object. 173 | # List should be empty (then the default recommender will generate the recommendation) 174 | # or contain exactly one recommender. 175 | recommenders: [] 176 | # - name: custom-recommender-performance 177 | 178 | # -- List of resources that the vertical pod autoscaler can control. Defaults to cpu and memory 179 | controlledResources: [] 180 | # -- Specifies which resource values should be controlled: RequestsOnly or RequestsAndLimits. 181 | controlledValues: RequestsAndLimits 182 | 183 | # -- Define the max allowed resources for the pod 184 | maxAllowed: {} 185 | # cpu: 100m 186 | # memory: 128Mi 187 | # -- Define the min allowed resources for the pod 188 | minAllowed: {} 189 | # cpu: 100m 190 | # memory: 128Mi 191 | 192 | updatePolicy: 193 | # -- Specifies minimal number of replicas which need to be alive for VPA Updater to attempt pod eviction 194 | minReplicas: 2 195 | 196 | # -- Specifies whether recommended updates are applied when a Pod is started and whether recommended updates 197 | # are applied during the life of a Pod. Possible values are "Off", "Initial", "Recreate", and "Auto". 198 | updateMode: Auto 199 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/spegel-org/spegel 2 | 3 | go 1.24.2 4 | 5 | require ( 6 | github.com/alexflint/go-arg v1.5.1 7 | github.com/containerd/containerd/api v1.9.0 8 | github.com/containerd/containerd/v2 v2.1.1 9 | github.com/containerd/errdefs v1.0.0 10 | github.com/containerd/typeurl/v2 v2.2.3 11 | github.com/go-logr/logr v1.4.2 12 | github.com/ipfs/go-cid v0.5.0 13 | github.com/libp2p/go-libp2p v0.41.1 14 | github.com/libp2p/go-libp2p-kad-dht v0.33.1 15 | github.com/multiformats/go-multiaddr v0.15.0 16 | github.com/multiformats/go-multicodec v0.9.0 17 | github.com/multiformats/go-multihash v0.2.3 18 | github.com/opencontainers/go-digest v1.0.0 19 | github.com/opencontainers/image-spec v1.1.1 20 | github.com/pelletier/go-toml/v2 v2.2.4 21 | github.com/prometheus/client_golang v1.22.0 22 | github.com/prometheus/common v0.64.0 23 | github.com/stretchr/testify v1.10.0 24 | go.etcd.io/bbolt v1.4.0 25 | golang.org/x/sync v0.14.0 26 | google.golang.org/grpc v1.72.1 27 | k8s.io/apimachinery v0.33.1 28 | k8s.io/cri-api v0.33.1 29 | k8s.io/klog/v2 v2.130.1 30 | ) 31 | 32 | require ( 33 | dario.cat/mergo v1.0.1 // indirect 34 | github.com/Masterminds/goutils v1.1.1 // indirect 35 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 36 | github.com/Masterminds/sprig/v3 v3.3.0 // indirect 37 | github.com/Microsoft/go-winio v0.6.2 // indirect 38 | github.com/Microsoft/hcsshim v0.13.0 // indirect 39 | github.com/alexflint/go-scalar v1.2.0 // indirect 40 | github.com/benbjohnson/clock v1.3.5 // indirect 41 | github.com/beorn7/perks v1.0.1 // indirect 42 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 43 | github.com/containerd/cgroups v1.1.0 // indirect 44 | github.com/containerd/cgroups/v3 v3.0.5 // indirect 45 | github.com/containerd/continuity v0.4.5 // indirect 46 | github.com/containerd/errdefs/pkg v0.3.0 // indirect 47 | github.com/containerd/fifo v1.1.0 // indirect 48 | github.com/containerd/log v0.1.0 // indirect 49 | github.com/containerd/platforms v1.0.0-rc.1 // indirect 50 | github.com/containerd/plugin v1.0.0 // indirect 51 | github.com/containerd/ttrpc v1.2.7 // indirect 52 | github.com/coreos/go-systemd/v22 v22.5.0 // indirect 53 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 54 | github.com/davidlazar/go-crypto v0.0.0-20200604182044-b73af7476f6c // indirect 55 | github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0 // indirect 56 | github.com/distribution/reference v0.6.0 // indirect 57 | github.com/docker/go-units v0.5.0 // indirect 58 | github.com/elastic/gosigar v0.14.3 // indirect 59 | github.com/felixge/httpsnoop v1.0.4 // indirect 60 | github.com/flynn/noise v1.1.0 // indirect 61 | github.com/francoispqt/gojay v1.2.13 // indirect 62 | github.com/fsnotify/fsnotify v1.9.0 // indirect 63 | github.com/go-logr/stdr v1.2.2 // indirect 64 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 65 | github.com/gobwas/glob v0.2.3 // indirect 66 | github.com/godbus/dbus/v5 v5.1.0 // indirect 67 | github.com/gogo/protobuf v1.3.2 // indirect 68 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 69 | github.com/google/go-cmp v0.7.0 // indirect 70 | github.com/google/gopacket v1.1.19 // indirect 71 | github.com/google/pprof v0.0.0-20250208200701-d0013a598941 // indirect 72 | github.com/google/uuid v1.6.0 // indirect 73 | github.com/gorilla/websocket v1.5.3 // indirect 74 | github.com/hashicorp/golang-lru v1.0.2 // indirect 75 | github.com/hashicorp/hcl v1.0.0 // indirect 76 | github.com/huandu/xstrings v1.5.0 // indirect 77 | github.com/huin/goupnp v1.3.0 // indirect 78 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 79 | github.com/ipfs/boxo v0.30.0 // indirect 80 | github.com/ipfs/go-datastore v0.8.2 // indirect 81 | github.com/ipfs/go-log/v2 v2.6.0 // indirect 82 | github.com/ipld/go-ipld-prime v0.21.0 // indirect 83 | github.com/jackpal/go-nat-pmp v1.0.2 // indirect 84 | github.com/jbenet/go-temp-err-catcher v0.1.0 // indirect 85 | github.com/klauspost/compress v1.18.0 // indirect 86 | github.com/klauspost/cpuid/v2 v2.2.10 // indirect 87 | github.com/koron/go-ssdp v0.0.5 // indirect 88 | github.com/kylelemons/godebug v1.1.0 // indirect 89 | github.com/libp2p/go-buffer-pool v0.1.0 // indirect 90 | github.com/libp2p/go-cidranger v1.1.0 // indirect 91 | github.com/libp2p/go-flow-metrics v0.2.0 // indirect 92 | github.com/libp2p/go-libp2p-asn-util v0.4.1 // indirect 93 | github.com/libp2p/go-libp2p-kbucket v0.7.0 // indirect 94 | github.com/libp2p/go-libp2p-record v0.3.1 // indirect 95 | github.com/libp2p/go-libp2p-routing-helpers v0.7.5 // indirect 96 | github.com/libp2p/go-msgio v0.3.0 // indirect 97 | github.com/libp2p/go-netroute v0.2.2 // indirect 98 | github.com/libp2p/go-reuseport v0.4.0 // indirect 99 | github.com/libp2p/go-yamux/v5 v5.0.0 // indirect 100 | github.com/magiconair/properties v1.8.7 // indirect 101 | github.com/marten-seemann/tcp v0.0.0-20210406111302-dfbc87cc63fd // indirect 102 | github.com/mattn/go-isatty v0.0.20 // indirect 103 | github.com/miekg/dns v1.1.66 // indirect 104 | github.com/mikioh/tcpinfo v0.0.0-20190314235526-30a79bb1804b // indirect 105 | github.com/mikioh/tcpopt v0.0.0-20190314235656-172688c1accc // indirect 106 | github.com/minio/sha256-simd v1.0.1 // indirect 107 | github.com/mitchellh/copystructure v1.2.0 // indirect 108 | github.com/mitchellh/mapstructure v1.5.0 // indirect 109 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 110 | github.com/moby/locker v1.0.1 // indirect 111 | github.com/moby/sys/mountinfo v0.7.2 // indirect 112 | github.com/moby/sys/sequential v0.6.0 // indirect 113 | github.com/moby/sys/signal v0.7.1 // indirect 114 | github.com/moby/sys/user v0.4.0 // indirect 115 | github.com/moby/sys/userns v0.1.0 // indirect 116 | github.com/mr-tron/base58 v1.2.0 // indirect 117 | github.com/multiformats/go-base32 v0.1.0 // indirect 118 | github.com/multiformats/go-base36 v0.2.0 // indirect 119 | github.com/multiformats/go-multiaddr-dns v0.4.1 // indirect 120 | github.com/multiformats/go-multiaddr-fmt v0.1.0 // indirect 121 | github.com/multiformats/go-multibase v0.2.0 // indirect 122 | github.com/multiformats/go-multistream v0.6.0 // indirect 123 | github.com/multiformats/go-varint v0.0.7 // indirect 124 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 125 | github.com/norwoodj/helm-docs v1.14.2 // indirect 126 | github.com/onsi/ginkgo/v2 v2.22.2 // indirect 127 | github.com/opencontainers/runtime-spec v1.2.1 // indirect 128 | github.com/opencontainers/selinux v1.12.0 // indirect 129 | github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 // indirect 130 | github.com/pion/datachannel v1.5.10 // indirect 131 | github.com/pion/dtls/v2 v2.2.12 // indirect 132 | github.com/pion/dtls/v3 v3.0.4 // indirect 133 | github.com/pion/ice/v4 v4.0.8 // indirect 134 | github.com/pion/interceptor v0.1.37 // indirect 135 | github.com/pion/logging v0.2.3 // indirect 136 | github.com/pion/mdns/v2 v2.0.7 // indirect 137 | github.com/pion/randutil v0.1.0 // indirect 138 | github.com/pion/rtcp v1.2.15 // indirect 139 | github.com/pion/rtp v1.8.11 // indirect 140 | github.com/pion/sctp v1.8.37 // indirect 141 | github.com/pion/sdp/v3 v3.0.10 // indirect 142 | github.com/pion/srtp/v3 v3.0.4 // indirect 143 | github.com/pion/stun v0.6.1 // indirect 144 | github.com/pion/stun/v3 v3.0.0 // indirect 145 | github.com/pion/transport/v2 v2.2.10 // indirect 146 | github.com/pion/transport/v3 v3.0.7 // indirect 147 | github.com/pion/turn/v4 v4.0.0 // indirect 148 | github.com/pion/webrtc/v4 v4.0.10 // indirect 149 | github.com/pkg/errors v0.9.1 // indirect 150 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect 151 | github.com/polydawn/refmt v0.89.0 // indirect 152 | github.com/prometheus/client_model v0.6.2 // indirect 153 | github.com/prometheus/procfs v0.16.1 // indirect 154 | github.com/quic-go/qpack v0.5.1 // indirect 155 | github.com/quic-go/quic-go v0.50.1 // indirect 156 | github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect 157 | github.com/raulk/go-watchdog v1.3.0 // indirect 158 | github.com/shopspring/decimal v1.4.0 // indirect 159 | github.com/sirupsen/logrus v1.9.3 // indirect 160 | github.com/spaolacci/murmur3 v1.1.0 // indirect 161 | github.com/spf13/afero v1.14.0 // indirect 162 | github.com/spf13/cast v1.7.0 // indirect 163 | github.com/spf13/cobra v1.8.1 // indirect 164 | github.com/spf13/jwalterweatherman v1.1.0 // indirect 165 | github.com/spf13/pflag v1.0.6 // indirect 166 | github.com/spf13/viper v1.16.0 // indirect 167 | github.com/subosito/gotenv v1.4.2 // indirect 168 | github.com/whyrusleeping/go-keyspace v0.0.0-20160322163242-5b898ac5add1 // indirect 169 | github.com/wlynxg/anet v0.0.5 // indirect 170 | go.opencensus.io v0.24.0 // indirect 171 | go.opentelemetry.io/auto/sdk v1.1.0 // indirect 172 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect 173 | go.opentelemetry.io/otel v1.35.0 // indirect 174 | go.opentelemetry.io/otel/metric v1.35.0 // indirect 175 | go.opentelemetry.io/otel/trace v1.35.0 // indirect 176 | go.uber.org/dig v1.18.0 // indirect 177 | go.uber.org/fx v1.23.0 // indirect 178 | go.uber.org/mock v0.5.0 // indirect 179 | go.uber.org/multierr v1.11.0 // indirect 180 | go.uber.org/zap v1.27.0 // indirect 181 | golang.org/x/crypto v0.38.0 // indirect 182 | golang.org/x/exp v0.0.0-20250506013437-ce4c2cf36ca6 // indirect 183 | golang.org/x/mod v0.24.0 // indirect 184 | golang.org/x/net v0.40.0 // indirect 185 | golang.org/x/sys v0.33.0 // indirect 186 | golang.org/x/text v0.25.0 // indirect 187 | golang.org/x/tools v0.33.0 // indirect 188 | gonum.org/v1/gonum v0.16.0 // indirect 189 | google.golang.org/genproto/googleapis/rpc v0.0.0-20250218202821-56aae31c358a // indirect 190 | google.golang.org/protobuf v1.36.6 // indirect 191 | gopkg.in/ini.v1 v1.67.0 // indirect 192 | gopkg.in/yaml.v3 v3.0.1 // indirect 193 | helm.sh/helm/v3 v3.17.3 // indirect 194 | lukechampine.com/blake3 v1.4.1 // indirect 195 | ) 196 | 197 | tool github.com/norwoodj/helm-docs/cmd/helm-docs 198 | -------------------------------------------------------------------------------- /internal/channel/channel.go: -------------------------------------------------------------------------------- 1 | package channel 2 | 3 | import ( 4 | "sync" 5 | ) 6 | 7 | func Merge[T any](cs ...<-chan T) <-chan T { 8 | var wg sync.WaitGroup 9 | out := make(chan T) 10 | 11 | output := func(c <-chan T) { 12 | for n := range c { 13 | out <- n 14 | } 15 | wg.Done() 16 | } 17 | wg.Add(len(cs)) 18 | for _, c := range cs { 19 | go output(c) 20 | } 21 | 22 | go func() { 23 | wg.Wait() 24 | close(out) 25 | }() 26 | return out 27 | } 28 | -------------------------------------------------------------------------------- /internal/cleanup/cleanup.go: -------------------------------------------------------------------------------- 1 | package cleanup 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "net" 8 | "net/http" 9 | "net/url" 10 | "time" 11 | 12 | "github.com/go-logr/logr" 13 | "golang.org/x/sync/errgroup" 14 | 15 | "github.com/spegel-org/spegel/internal/channel" 16 | "github.com/spegel-org/spegel/pkg/httpx" 17 | "github.com/spegel-org/spegel/pkg/oci" 18 | ) 19 | 20 | func Run(ctx context.Context, addr, configPath string) error { 21 | log := logr.FromContextOrDiscard(ctx) 22 | 23 | err := oci.CleanupMirrorConfiguration(ctx, configPath) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | g, gCtx := errgroup.WithContext(ctx) 29 | 30 | mux := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 31 | if req.Method != http.MethodGet && req.URL.Path != "/healthz" { 32 | log.Error(errors.New("unknown request"), "unsupported probe request", "path", req.URL.Path, "method", req.Method) 33 | rw.WriteHeader(http.StatusNotFound) 34 | return 35 | } 36 | rw.WriteHeader(http.StatusOK) 37 | }) 38 | srv := &http.Server{ 39 | Addr: addr, 40 | Handler: mux, 41 | } 42 | g.Go(func() error { 43 | if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 44 | return err 45 | } 46 | return nil 47 | }) 48 | g.Go(func() error { 49 | <-gCtx.Done() 50 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 51 | defer cancel() 52 | return srv.Shutdown(shutdownCtx) 53 | }) 54 | 55 | log.Info("waiting to be shutdown") 56 | err = g.Wait() 57 | if err != nil { 58 | return err 59 | } 60 | return nil 61 | } 62 | 63 | func Wait(ctx context.Context, probeEndpoint string, period time.Duration, threshold int) error { 64 | log := logr.FromContextOrDiscard(ctx) 65 | resolver := &net.Resolver{} 66 | client := &http.Client{} 67 | 68 | addr, port, err := net.SplitHostPort(probeEndpoint) 69 | if err != nil { 70 | return err 71 | } 72 | 73 | immediateCh := make(chan time.Time, 1) 74 | immediateCh <- time.Now() 75 | close(immediateCh) 76 | ticker := time.NewTicker(period) 77 | defer ticker.Stop() 78 | tickerCh := channel.Merge(immediateCh, ticker.C) 79 | thresholdCount := 0 80 | for { 81 | select { 82 | case <-ctx.Done(): 83 | return ctx.Err() 84 | case <-tickerCh: 85 | start := time.Now() 86 | 87 | log.Info("running probe lookup", "host", addr) 88 | ips, err := resolver.LookupIPAddr(ctx, addr) 89 | if err != nil { 90 | log.Error(err, "cleanup probe lookup failed") 91 | thresholdCount = 0 92 | continue 93 | } 94 | 95 | log.Info("running probe request", "endpoints", len(ips)) 96 | err = probeIPs(ctx, client, ips, port) 97 | if err != nil { 98 | log.Error(err, "cleanup probe request failed") 99 | thresholdCount = 0 100 | continue 101 | } 102 | 103 | thresholdCount += 1 104 | log.Info("probe ran successfully", "threshold", thresholdCount, "duration", time.Since(start).String()) 105 | if thresholdCount == threshold { 106 | log.Info("probe threshold reached") 107 | return nil 108 | } 109 | } 110 | } 111 | } 112 | 113 | func probeIPs(ctx context.Context, client *http.Client, ips []net.IPAddr, port string) error { 114 | g, gCtx := errgroup.WithContext(ctx) 115 | g.SetLimit(10) 116 | for _, ip := range ips { 117 | g.Go(func() error { 118 | u := url.URL{ 119 | Scheme: "http", 120 | Host: net.JoinHostPort(ip.String(), port), 121 | Path: "/healthz", 122 | } 123 | reqCtx, cancel := context.WithTimeout(gCtx, 1*time.Second) 124 | defer cancel() 125 | req, err := http.NewRequestWithContext(reqCtx, http.MethodGet, u.String(), nil) 126 | if err != nil { 127 | return err 128 | } 129 | resp, err := client.Do(req) 130 | if err != nil { 131 | return err 132 | } 133 | defer resp.Body.Close() 134 | _, err = io.Copy(io.Discard, resp.Body) 135 | if err != nil { 136 | return err 137 | } 138 | err = httpx.CheckResponseStatus(resp, http.StatusOK) 139 | if err != nil { 140 | return err 141 | } 142 | return nil 143 | }) 144 | } 145 | err := g.Wait() 146 | if err != nil { 147 | return err 148 | } 149 | return nil 150 | } 151 | -------------------------------------------------------------------------------- /internal/cleanup/cleanup_test.go: -------------------------------------------------------------------------------- 1 | package cleanup 2 | 3 | import ( 4 | "context" 5 | "net" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/url" 9 | "testing" 10 | "time" 11 | 12 | "github.com/stretchr/testify/require" 13 | "golang.org/x/sync/errgroup" 14 | ) 15 | 16 | func TestCleanupFail(t *testing.T) { 17 | t.Parallel() 18 | 19 | srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 20 | rw.WriteHeader(http.StatusInternalServerError) 21 | })) 22 | defer srv.Close() 23 | u, err := url.Parse(srv.URL) 24 | require.NoError(t, err) 25 | timeoutCtx, timeoutCancel := context.WithTimeout(t.Context(), 1*time.Second) 26 | defer timeoutCancel() 27 | err = Wait(timeoutCtx, u.Host, 100*time.Millisecond, 3) 28 | require.EqualError(t, err, "context deadline exceeded") 29 | } 30 | 31 | func TestCleanupSucceed(t *testing.T) { 32 | t.Parallel() 33 | 34 | listener, err := net.Listen("tcp", ":0") 35 | if err != nil { 36 | panic(err) 37 | } 38 | addr := listener.Addr().String() 39 | err = listener.Close() 40 | require.NoError(t, err) 41 | timeoutCtx, timeoutCancel := context.WithTimeout(t.Context(), 1*time.Second) 42 | defer timeoutCancel() 43 | g, gCtx := errgroup.WithContext(timeoutCtx) 44 | g.Go(func() error { 45 | err := Run(gCtx, addr, t.TempDir()) 46 | if err != nil { 47 | return err 48 | } 49 | return nil 50 | }) 51 | g.Go(func() error { 52 | err := Wait(gCtx, addr, 100*time.Microsecond, 3) 53 | if err != nil { 54 | return err 55 | } 56 | return nil 57 | }) 58 | 59 | err = g.Wait() 60 | require.NoError(t, err) 61 | } 62 | -------------------------------------------------------------------------------- /internal/web/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Spegel Debug 8 | 9 | 10 | 11 | 105 | 106 | 107 | 108 |
109 |

Spegel

110 | 111 |
112 | 113 |
114 |

Measure Image Pull

115 |
116 | 117 | 118 |
119 |
120 |
121 |
122 | 123 | 124 | -------------------------------------------------------------------------------- /internal/web/templates/measure.html: -------------------------------------------------------------------------------- 1 | {{ if .PeerResults }} 2 |

Resolved Peers

3 |
4 | Duration: {{ .PeerDuration | formatDuration }} 5 |
6 |
7 | 8 | 9 | 10 | 11 | 12 | 13 | {{ range .PeerResults }} 14 | 15 | 16 | 17 | 18 | {{ end }} 19 |
PeerDuration
{{ .Peer.Addr }}{{ .Duration | formatDuration }}
20 |
21 | 22 |

Result

23 |
24 | Duration: {{ .PullDuration | formatDuration }} 25 | Size: {{ .PullSize | formatBytes }} 26 |
27 |
28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | {{ range .PullResults }} 37 | 38 | 39 | 40 | 41 | 42 | 43 | {{ end }} 44 |
IdentifierTypeSizeDuration
{{ .Identifier }}{{ .Type }}{{ .Size | formatBytes }}{{ .Duration | formatDuration }}
45 |
46 | {{ else }} 47 |

No peers found for image

48 | {{ end }} -------------------------------------------------------------------------------- /internal/web/templates/stats.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
Images
5 |
{{ .ImageCount }}
6 |
7 |
8 |
Layers
9 |
{{ .LayerCount }}
10 |
11 |
12 |
-------------------------------------------------------------------------------- /internal/web/web.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "errors" 6 | "fmt" 7 | "html/template" 8 | "net" 9 | "net/http" 10 | "net/netip" 11 | "time" 12 | 13 | "github.com/go-logr/logr" 14 | "github.com/prometheus/common/expfmt" 15 | 16 | "github.com/spegel-org/spegel/pkg/httpx" 17 | "github.com/spegel-org/spegel/pkg/oci" 18 | "github.com/spegel-org/spegel/pkg/routing" 19 | ) 20 | 21 | //go:embed templates/* 22 | var templatesFS embed.FS 23 | 24 | type Web struct { 25 | router routing.Router 26 | client *oci.Client 27 | tmpls *template.Template 28 | } 29 | 30 | func NewWeb(router routing.Router) (*Web, error) { 31 | funcs := template.FuncMap{ 32 | "formatBytes": formatBytes, 33 | "formatDuration": formatDuration, 34 | } 35 | tmpls, err := template.New("").Funcs(funcs).ParseFS(templatesFS, "templates/*") 36 | if err != nil { 37 | return nil, err 38 | } 39 | return &Web{ 40 | router: router, 41 | client: oci.NewClient(), 42 | tmpls: tmpls, 43 | }, nil 44 | } 45 | 46 | func (w *Web) Handler(log logr.Logger) http.Handler { 47 | m := httpx.NewServeMux(log) 48 | m.Handle("GET /debug/web/", w.indexHandler) 49 | m.Handle("GET /debug/web/stats", w.statsHandler) 50 | m.Handle("GET /debug/web/measure", w.measureHandler) 51 | return m 52 | } 53 | 54 | func (w *Web) indexHandler(rw httpx.ResponseWriter, req *http.Request) { 55 | err := w.tmpls.ExecuteTemplate(rw, "index.html", nil) 56 | if err != nil { 57 | rw.WriteError(http.StatusInternalServerError, err) 58 | return 59 | } 60 | } 61 | 62 | func (w *Web) statsHandler(rw httpx.ResponseWriter, req *http.Request) { 63 | //nolint: errcheck // Ignore error. 64 | srvAddr := req.Context().Value(http.LocalAddrContextKey).(net.Addr) 65 | resp, err := http.Get(fmt.Sprintf("http://%s/metrics", srvAddr.String())) 66 | if err != nil { 67 | rw.WriteError(http.StatusInternalServerError, err) 68 | return 69 | } 70 | defer resp.Body.Close() 71 | parser := expfmt.TextParser{} 72 | metricFamilies, err := parser.TextToMetricFamilies(resp.Body) 73 | if err != nil { 74 | rw.WriteError(http.StatusInternalServerError, err) 75 | return 76 | } 77 | 78 | data := struct { 79 | ImageCount int64 80 | LayerCount int64 81 | }{} 82 | for _, metric := range metricFamilies["spegel_advertised_images"].Metric { 83 | data.ImageCount += int64(*metric.Gauge.Value) 84 | } 85 | for _, metric := range metricFamilies["spegel_advertised_keys"].Metric { 86 | data.LayerCount += int64(*metric.Gauge.Value) 87 | } 88 | err = w.tmpls.ExecuteTemplate(rw, "stats.html", data) 89 | if err != nil { 90 | rw.WriteError(http.StatusInternalServerError, err) 91 | return 92 | } 93 | } 94 | 95 | type measureResult struct { 96 | PeerResults []peerResult 97 | PullResults []pullResult 98 | PeerDuration time.Duration 99 | PullDuration time.Duration 100 | PullSize int64 101 | } 102 | 103 | type peerResult struct { 104 | Peer netip.AddrPort 105 | Duration time.Duration 106 | } 107 | 108 | type pullResult struct { 109 | Identifier string 110 | Type string 111 | Size int64 112 | Duration time.Duration 113 | } 114 | 115 | func (w *Web) measureHandler(rw httpx.ResponseWriter, req *http.Request) { 116 | // Parse image name. 117 | imgName := req.URL.Query().Get("image") 118 | if imgName == "" { 119 | rw.WriteError(http.StatusBadRequest, errors.New("image name cannot be empty")) 120 | return 121 | } 122 | img, err := oci.ParseImage(imgName) 123 | if err != nil { 124 | rw.WriteError(http.StatusBadRequest, err) 125 | return 126 | } 127 | 128 | res := measureResult{} 129 | 130 | // Resolve peers for the given image. 131 | resolveStart := time.Now() 132 | peerCh, err := w.router.Resolve(req.Context(), imgName, 0) 133 | if err != nil { 134 | rw.WriteError(http.StatusInternalServerError, err) 135 | return 136 | } 137 | for peer := range peerCh { 138 | d := time.Since(resolveStart) 139 | res.PeerDuration += d 140 | res.PeerResults = append(res.PeerResults, peerResult{ 141 | Peer: peer, 142 | Duration: d, 143 | }) 144 | } 145 | 146 | if len(res.PeerResults) > 0 { 147 | // Pull the image and measure performance. 148 | pullMetrics, err := w.client.Pull(req.Context(), img, "http://localhost:5000") 149 | if err != nil { 150 | rw.WriteError(http.StatusInternalServerError, err) 151 | return 152 | } 153 | for _, metric := range pullMetrics { 154 | res.PullDuration += metric.Duration 155 | res.PullSize += metric.ContentLength 156 | res.PullResults = append(res.PullResults, pullResult{ 157 | Identifier: metric.Digest.String(), 158 | Type: metric.ContentType, 159 | Size: metric.ContentLength, 160 | Duration: metric.Duration, 161 | }) 162 | } 163 | } 164 | 165 | err = w.tmpls.ExecuteTemplate(rw, "measure.html", res) 166 | if err != nil { 167 | rw.WriteError(http.StatusInternalServerError, err) 168 | return 169 | } 170 | } 171 | 172 | func formatBytes(size int64) string { 173 | const unit = 1024 174 | if size < unit { 175 | return fmt.Sprintf("%d B", size) 176 | } 177 | div, exp := int64(unit), 0 178 | for n := size / unit; n >= unit; n /= unit { 179 | div *= unit 180 | exp++ 181 | } 182 | return fmt.Sprintf("%.1f %cB", float64(size)/float64(div), "KMGTPE"[exp]) 183 | } 184 | 185 | func formatDuration(d time.Duration) string { 186 | if d < time.Millisecond { 187 | return "<1ms" 188 | } 189 | 190 | totalMs := int64(d / time.Millisecond) 191 | minutes := totalMs / 60000 192 | seconds := (totalMs % 60000) / 1000 193 | milliseconds := totalMs % 1000 194 | 195 | out := "" 196 | if minutes > 0 { 197 | out += fmt.Sprintf("%dm", minutes) 198 | } 199 | if seconds > 0 { 200 | out += fmt.Sprintf("%ds", seconds) 201 | } 202 | if milliseconds > 0 { 203 | out += fmt.Sprintf("%dms", milliseconds) 204 | } 205 | return out 206 | } 207 | -------------------------------------------------------------------------------- /internal/web/web_test.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "testing" 5 | "time" 6 | 7 | "github.com/stretchr/testify/require" 8 | ) 9 | 10 | func TestWeb(t *testing.T) { 11 | t.Parallel() 12 | 13 | w, err := NewWeb(nil) 14 | require.NoError(t, err) 15 | require.NotNil(t, w.tmpls) 16 | } 17 | 18 | func TestFormatBytes(t *testing.T) { 19 | t.Parallel() 20 | 21 | tests := []struct { 22 | expected string 23 | size int64 24 | }{ 25 | { 26 | size: 1, 27 | expected: "1 B", 28 | }, 29 | { 30 | size: 19456, 31 | expected: "19.0 KB", 32 | }, 33 | { 34 | size: 1073741824, 35 | expected: "1.0 GB", 36 | }, 37 | } 38 | for _, tt := range tests { 39 | t.Run(tt.expected, func(t *testing.T) { 40 | t.Parallel() 41 | 42 | result := formatBytes(tt.size) 43 | require.Equal(t, tt.expected, result) 44 | }) 45 | } 46 | } 47 | 48 | func TestDuration(t *testing.T) { 49 | t.Parallel() 50 | 51 | tests := []struct { 52 | expected string 53 | duration time.Duration 54 | }{ 55 | { 56 | duration: 36 * time.Millisecond, 57 | expected: "36ms", 58 | }, 59 | { 60 | duration: 5 * time.Microsecond, 61 | expected: "<1ms", 62 | }, 63 | { 64 | duration: 5*time.Minute + 128*time.Second, 65 | expected: "7m8s", 66 | }, 67 | { 68 | duration: 2 * time.Hour, 69 | expected: "120m", 70 | }, 71 | } 72 | for _, tt := range tests { 73 | t.Run(tt.expected, func(t *testing.T) { 74 | t.Parallel() 75 | 76 | result := formatDuration(tt.duration) 77 | require.Equal(t, tt.expected, result) 78 | }) 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /pkg/httpx/httpx.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | const ( 4 | HeaderContentType = "Content-Type" 5 | HeaderContentLength = "Content-Length" 6 | HeaderAcceptRanges = "Accept-Ranges" 7 | ) 8 | -------------------------------------------------------------------------------- /pkg/httpx/metrics.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import "github.com/prometheus/client_golang/prometheus" 4 | 5 | var ( 6 | HttpRequestDurHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 7 | Subsystem: "http", 8 | Name: "request_duration_seconds", 9 | Help: "The latency of the HTTP requests.", 10 | }, []string{"handler", "method", "code"}) 11 | HttpResponseSizeHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 12 | Subsystem: "http", 13 | Name: "response_size_bytes", 14 | Help: "The size of the HTTP responses.", 15 | // 1kB up to 2GB 16 | Buckets: prometheus.ExponentialBuckets(1024, 5, 10), 17 | }, []string{"handler", "method", "code"}) 18 | HttpRequestsInflight = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 19 | Subsystem: "http", 20 | Name: "requests_inflight", 21 | Help: "The number of inflight requests being handled at the same time.", 22 | }, []string{"handler"}) 23 | ) 24 | 25 | func RegisterMetrics(registerer prometheus.Registerer) { 26 | if registerer == nil { 27 | registerer = prometheus.DefaultRegisterer 28 | } 29 | registerer.MustRegister(HttpRequestDurHistogram) 30 | registerer.MustRegister(HttpResponseSizeHistogram) 31 | registerer.MustRegister(HttpRequestsInflight) 32 | } 33 | -------------------------------------------------------------------------------- /pkg/httpx/mux.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "net/http" 7 | "strconv" 8 | "strings" 9 | "time" 10 | 11 | "github.com/go-logr/logr" 12 | ) 13 | 14 | type HandlerFunc func(rw ResponseWriter, req *http.Request) 15 | 16 | type ServeMux struct { 17 | mux *http.ServeMux 18 | log logr.Logger 19 | } 20 | 21 | func NewServeMux(log logr.Logger) *ServeMux { 22 | return &ServeMux{ 23 | mux: http.NewServeMux(), 24 | log: log, 25 | } 26 | } 27 | 28 | func (s *ServeMux) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 29 | h, pattern := s.mux.Handler(req) 30 | if pattern == "" { 31 | kvs := []any{ 32 | "path", req.URL.Path, 33 | "status", http.StatusNotFound, 34 | "method", req.Method, 35 | "ip", GetClientIP(req), 36 | } 37 | s.log.Error(errors.New("page not found"), "", kvs...) 38 | rw.WriteHeader(http.StatusNotFound) 39 | return 40 | } 41 | h.ServeHTTP(rw, req) 42 | } 43 | 44 | func (s *ServeMux) Handle(pattern string, handler HandlerFunc) { 45 | metricsPath := metricsFriendlyPath(pattern) 46 | s.mux.HandleFunc(pattern, func(w http.ResponseWriter, req *http.Request) { 47 | start := time.Now() 48 | rw := &response{ResponseWriter: w} 49 | defer func() { 50 | latency := time.Since(start) 51 | statusCode := strconv.FormatInt(int64(rw.Status()), 10) 52 | 53 | HttpRequestsInflight.WithLabelValues(metricsPath).Add(-1) 54 | HttpRequestDurHistogram.WithLabelValues(metricsPath, req.Method, statusCode).Observe(latency.Seconds()) 55 | HttpResponseSizeHistogram.WithLabelValues(metricsPath, req.Method, statusCode).Observe(float64(rw.Size())) 56 | 57 | // Ignore logging requests to healthz to reduce log noise 58 | if req.URL.Path == "/healthz" { 59 | return 60 | } 61 | 62 | kvs := []any{ 63 | "path", req.URL.Path, 64 | "status", rw.Status(), 65 | "method", req.Method, 66 | "latency", latency.String(), 67 | "ip", GetClientIP(req), 68 | "handler", rw.handler, 69 | } 70 | if rw.Status() >= 200 && rw.Status() < 400 { 71 | s.log.Info("", kvs...) 72 | return 73 | } 74 | s.log.Error(rw.Error(), "", kvs...) 75 | }() 76 | HttpRequestsInflight.WithLabelValues(metricsPath).Add(1) 77 | handler(rw, req) 78 | }) 79 | } 80 | 81 | func GetClientIP(req *http.Request) string { 82 | forwardedFor := req.Header.Get("X-Forwarded-For") 83 | if forwardedFor != "" { 84 | comps := strings.Split(forwardedFor, ",") 85 | if len(comps) > 1 { 86 | return comps[0] 87 | } 88 | return forwardedFor 89 | } 90 | h, _, err := net.SplitHostPort(req.RemoteAddr) 91 | if err != nil { 92 | return "" 93 | } 94 | return h 95 | } 96 | 97 | func metricsFriendlyPath(pattern string) string { 98 | _, path, _ := strings.Cut(pattern, "/") 99 | path = "/" + path 100 | if strings.HasSuffix(path, "/") { 101 | return path + "*" 102 | } 103 | return path 104 | } 105 | -------------------------------------------------------------------------------- /pkg/httpx/mux_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "net/http" 5 | "net/http/httptest" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/go-logr/logr" 10 | "github.com/prometheus/client_golang/prometheus" 11 | "github.com/prometheus/client_golang/prometheus/testutil" 12 | "github.com/stretchr/testify/require" 13 | ) 14 | 15 | func TestServeMux(t *testing.T) { 16 | t.Parallel() 17 | 18 | registerer := prometheus.NewRegistry() 19 | RegisterMetrics(registerer) 20 | 21 | m := NewServeMux(logr.Discard()) 22 | handlersCalled := []string{} 23 | m.Handle("/exact", func(rw ResponseWriter, req *http.Request) { 24 | handlersCalled = append(handlersCalled, "exact") 25 | }) 26 | m.Handle("/prefix/", func(rw ResponseWriter, req *http.Request) { 27 | handlersCalled = append(handlersCalled, "prefix") 28 | }) 29 | paths := []string{"/prefix/", "/exact", "/exact/foo", "/prefix/bar"} 30 | for _, path := range paths { 31 | rw := httptest.NewRecorder() 32 | req := httptest.NewRequest(http.MethodGet, "http://localhost"+path, nil) 33 | m.ServeHTTP(rw, req) 34 | } 35 | 36 | expectedHandlersCalled := []string{"prefix", "exact", "prefix"} 37 | require.Equal(t, expectedHandlersCalled, handlersCalled) 38 | 39 | expectedMetrics := ` 40 | # HELP http_requests_inflight The number of inflight requests being handled at the same time. 41 | # TYPE http_requests_inflight gauge 42 | http_requests_inflight{handler="/exact"} 0 43 | http_requests_inflight{handler="/prefix/*"} 0 44 | ` 45 | err := testutil.CollectAndCompare(HttpRequestsInflight, strings.NewReader(expectedMetrics)) 46 | require.NoError(t, err) 47 | 48 | expectedMetrics = ` 49 | # HELP http_response_size_bytes The size of the HTTP responses. 50 | # TYPE http_response_size_bytes histogram 51 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="1024"} 1 52 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="5120"} 1 53 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="25600"} 1 54 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="128000"} 1 55 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="640000"} 1 56 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="3.2e+06"} 1 57 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="1.6e+07"} 1 58 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="8e+07"} 1 59 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="4e+08"} 1 60 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="2e+09"} 1 61 | http_response_size_bytes_bucket{code="200",handler="/exact",method="GET",le="+Inf"} 1 62 | http_response_size_bytes_sum{code="200",handler="/exact",method="GET"} 0 63 | http_response_size_bytes_count{code="200",handler="/exact",method="GET"} 1 64 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="1024"} 2 65 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="5120"} 2 66 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="25600"} 2 67 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="128000"} 2 68 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="640000"} 2 69 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="3.2e+06"} 2 70 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="1.6e+07"} 2 71 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="8e+07"} 2 72 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="4e+08"} 2 73 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="2e+09"} 2 74 | http_response_size_bytes_bucket{code="200",handler="/prefix/*",method="GET",le="+Inf"} 2 75 | http_response_size_bytes_sum{code="200",handler="/prefix/*",method="GET"} 0 76 | http_response_size_bytes_count{code="200",handler="/prefix/*",method="GET"} 2 77 | ` 78 | err = testutil.CollectAndCompare(HttpResponseSizeHistogram, strings.NewReader(expectedMetrics)) 79 | require.NoError(t, err) 80 | } 81 | 82 | func TestGetClientIP(t *testing.T) { 83 | t.Parallel() 84 | 85 | tests := []struct { 86 | name string 87 | request *http.Request 88 | expected string 89 | }{ 90 | { 91 | name: "x forwarded for single", 92 | request: &http.Request{ 93 | Header: http.Header{ 94 | "X-Forwarded-For": []string{"localhost"}, 95 | }, 96 | }, 97 | expected: "localhost", 98 | }, 99 | { 100 | name: "x forwarded for multiple", 101 | request: &http.Request{ 102 | Header: http.Header{ 103 | "X-Forwarded-For": []string{"localhost,127.0.0.1"}, 104 | }, 105 | }, 106 | expected: "localhost", 107 | }, 108 | { 109 | name: "remote address", 110 | request: &http.Request{ 111 | RemoteAddr: "127.0.0.1:9090", 112 | }, 113 | expected: "127.0.0.1", 114 | }, 115 | } 116 | for _, tt := range tests { 117 | t.Run(tt.name, func(t *testing.T) { 118 | t.Parallel() 119 | 120 | ip := GetClientIP(tt.request) 121 | require.Equal(t, tt.expected, ip) 122 | }) 123 | } 124 | } 125 | 126 | func TestMetricsFriendlyPath(t *testing.T) { 127 | t.Parallel() 128 | 129 | tests := []struct { 130 | pattern string 131 | expected string 132 | }{ 133 | { 134 | pattern: "/", 135 | expected: "/*", 136 | }, 137 | { 138 | pattern: "/exact", 139 | expected: "/exact", 140 | }, 141 | { 142 | pattern: "/prefix/", 143 | expected: "/prefix/*", 144 | }, 145 | { 146 | pattern: "/chats/{id}/message/{index}", 147 | expected: "/chats/{id}/message/{index}", 148 | }, 149 | } 150 | for _, method := range []string{"", "GET ", "HEAD "} { 151 | for _, tt := range tests { 152 | t.Run(tt.pattern, func(t *testing.T) { 153 | t.Parallel() 154 | 155 | metricsPath := metricsFriendlyPath(method + tt.pattern) 156 | require.Equal(t, tt.expected, metricsPath) 157 | }) 158 | } 159 | } 160 | } 161 | -------------------------------------------------------------------------------- /pkg/httpx/response.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "bufio" 5 | "io" 6 | "net" 7 | "net/http" 8 | ) 9 | 10 | type ResponseWriter interface { 11 | http.ResponseWriter 12 | WriteError(statusCode int, err error) 13 | Error() error 14 | Status() int 15 | Size() int64 16 | SetHandler(handler string) 17 | } 18 | 19 | var ( 20 | _ http.ResponseWriter = &response{} 21 | _ http.Flusher = &response{} 22 | _ http.Hijacker = &response{} 23 | _ io.ReaderFrom = &response{} 24 | ) 25 | 26 | type response struct { 27 | http.ResponseWriter 28 | error error 29 | handler string 30 | status int 31 | size int64 32 | writtenHeader bool 33 | } 34 | 35 | func (r *response) WriteHeader(statusCode int) { 36 | if !r.writtenHeader { 37 | r.writtenHeader = true 38 | r.status = statusCode 39 | } 40 | r.ResponseWriter.WriteHeader(statusCode) 41 | } 42 | 43 | func (r *response) Write(b []byte) (int, error) { 44 | r.writtenHeader = true 45 | n, err := r.ResponseWriter.Write(b) 46 | r.size += int64(n) 47 | return n, err 48 | } 49 | 50 | func (r *response) WriteError(statusCode int, err error) { 51 | r.error = err 52 | r.WriteHeader(statusCode) 53 | } 54 | 55 | func (r *response) Flush() { 56 | r.writtenHeader = true 57 | //nolint: errcheck // No method to throw the error. 58 | flusher := r.ResponseWriter.(http.Flusher) 59 | flusher.Flush() 60 | } 61 | 62 | func (r *response) Hijack() (net.Conn, *bufio.ReadWriter, error) { 63 | //nolint: errcheck // No method to throw the error. 64 | hijacker := r.ResponseWriter.(http.Hijacker) 65 | return hijacker.Hijack() 66 | } 67 | 68 | func (r *response) ReadFrom(rd io.Reader) (int64, error) { 69 | n, err := io.Copy(r.ResponseWriter, rd) 70 | r.size += n 71 | return n, err 72 | } 73 | 74 | func (r *response) Unwrap() http.ResponseWriter { 75 | return r.ResponseWriter 76 | } 77 | 78 | func (r *response) Status() int { 79 | if r.status == 0 { 80 | return http.StatusOK 81 | } 82 | return r.status 83 | } 84 | 85 | func (r *response) Error() error { 86 | return r.error 87 | } 88 | 89 | func (r *response) Size() int64 { 90 | return r.size 91 | } 92 | 93 | func (r *response) SetHandler(handler string) { 94 | r.handler = handler 95 | } 96 | -------------------------------------------------------------------------------- /pkg/httpx/response_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "strings" 9 | "testing" 10 | 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | func TestResponseWriter(t *testing.T) { 15 | t.Parallel() 16 | 17 | var httpRw http.ResponseWriter = &response{} 18 | _, ok := httpRw.(io.ReaderFrom) 19 | require.True(t, ok) 20 | 21 | httpRw = httptest.NewRecorder() 22 | rw := &response{ 23 | ResponseWriter: httpRw, 24 | } 25 | require.Equal(t, httpRw, rw.Unwrap()) 26 | require.NoError(t, rw.Error()) 27 | require.Equal(t, int64(0), rw.Size()) 28 | require.Equal(t, http.StatusOK, rw.Status()) 29 | 30 | rw = &response{ 31 | ResponseWriter: httptest.NewRecorder(), 32 | } 33 | rw.WriteHeader(http.StatusNotFound) 34 | require.True(t, rw.writtenHeader) 35 | require.Equal(t, http.StatusNotFound, rw.Status()) 36 | rw.WriteHeader(http.StatusBadGateway) 37 | require.Equal(t, http.StatusNotFound, rw.Status()) 38 | _, err := rw.Write([]byte("foo")) 39 | require.NoError(t, err) 40 | require.Equal(t, http.StatusNotFound, rw.Status()) 41 | 42 | rw = &response{ 43 | ResponseWriter: httptest.NewRecorder(), 44 | } 45 | err = errors.New("some server error") 46 | rw.WriteError(http.StatusInternalServerError, err) 47 | require.Equal(t, err, rw.Error()) 48 | require.Equal(t, http.StatusInternalServerError, rw.Status()) 49 | 50 | rw = &response{ 51 | ResponseWriter: httptest.NewRecorder(), 52 | } 53 | first := "hello world" 54 | n, err := rw.Write([]byte(first)) 55 | require.Equal(t, http.StatusOK, rw.Status()) 56 | require.NoError(t, err) 57 | require.Equal(t, len(first), n) 58 | require.Equal(t, int64(len(first)), rw.Size()) 59 | second := "foo bar" 60 | n, err = rw.Write([]byte(second)) 61 | require.NoError(t, err) 62 | require.Equal(t, len(second), n) 63 | require.Equal(t, int64(len(first)+len(second)), rw.Size()) 64 | 65 | rw = &response{ 66 | ResponseWriter: httptest.NewRecorder(), 67 | } 68 | r := strings.NewReader("reader") 69 | readFromN, err := rw.ReadFrom(r) 70 | require.NoError(t, err) 71 | require.Equal(t, r.Size(), readFromN) 72 | require.Equal(t, r.Size(), rw.Size()) 73 | 74 | rw = &response{ 75 | ResponseWriter: httptest.NewRecorder(), 76 | } 77 | rw.SetHandler("foo") 78 | require.Equal(t, "foo", rw.handler) 79 | } 80 | -------------------------------------------------------------------------------- /pkg/httpx/status.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "slices" 9 | "strings" 10 | ) 11 | 12 | type StatusError struct { 13 | Message string 14 | ExpectedCodes []int 15 | StatusCode int 16 | } 17 | 18 | func (e *StatusError) Error() string { 19 | expectedCodeStrs := []string{} 20 | for _, expected := range e.ExpectedCodes { 21 | expectedCodeStrs = append(expectedCodeStrs, fmt.Sprintf("%d %s", expected, http.StatusText(expected))) 22 | } 23 | msg := fmt.Sprintf("expected one of the following statuses [%s], but received %d %s", strings.Join(expectedCodeStrs, ", "), e.StatusCode, http.StatusText(e.StatusCode)) 24 | if e.Message != "" { 25 | msg += ": " + e.Message 26 | } 27 | return msg 28 | } 29 | 30 | func CheckResponseStatus(resp *http.Response, expectedCodes ...int) error { 31 | if len(expectedCodes) == 0 { 32 | return errors.New("expected codes cannot be empty") 33 | } 34 | if slices.Contains(expectedCodes, resp.StatusCode) { 35 | return nil 36 | } 37 | message, messageErr := getErrorMessage(resp) 38 | statusErr := &StatusError{ 39 | Message: message, 40 | ExpectedCodes: expectedCodes, 41 | StatusCode: resp.StatusCode, 42 | } 43 | return errors.Join(statusErr, messageErr) 44 | } 45 | 46 | func getErrorMessage(resp *http.Response) (string, error) { 47 | defer resp.Body.Close() 48 | if resp.Request.Method == http.MethodHead { 49 | return "", nil 50 | } 51 | contentTypes := []string{ 52 | "text/plain", 53 | "text/html", 54 | "application/json", 55 | "application/xml", 56 | } 57 | if !slices.Contains(contentTypes, resp.Header.Get(HeaderContentType)) { 58 | _, err := io.Copy(io.Discard, resp.Body) 59 | return "", err 60 | } 61 | b, err := io.ReadAll(resp.Body) 62 | return string(b), err 63 | } 64 | -------------------------------------------------------------------------------- /pkg/httpx/status_test.go: -------------------------------------------------------------------------------- 1 | package httpx 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "testing" 9 | 10 | "github.com/stretchr/testify/require" 11 | ) 12 | 13 | func TestStatusError(t *testing.T) { 14 | t.Parallel() 15 | 16 | tests := []struct { 17 | name string 18 | contentType string 19 | body string 20 | expectedError string 21 | requestMethod string 22 | expectedCodes []int 23 | statusCode int 24 | }{ 25 | { 26 | name: "status code matches one of expected", 27 | contentType: "text/plain", 28 | body: "Hello World", 29 | statusCode: http.StatusOK, 30 | expectedCodes: []int{http.StatusNotFound, http.StatusOK}, 31 | requestMethod: http.MethodGet, 32 | expectedError: "", 33 | }, 34 | { 35 | name: "no expected status codes", 36 | contentType: "text/plain", 37 | statusCode: http.StatusOK, 38 | expectedCodes: []int{}, 39 | expectedError: "expected codes cannot be empty", 40 | }, 41 | { 42 | name: "wrong code with text content and GET request", 43 | contentType: "text/plain", 44 | body: "Hello World", 45 | statusCode: http.StatusNotFound, 46 | expectedCodes: []int{http.StatusOK}, 47 | requestMethod: http.MethodGet, 48 | expectedError: "expected one of the following statuses [200 OK], but received 404 Not Found: Hello World", 49 | }, 50 | { 51 | name: "wrong code with text content and HEAD request", 52 | contentType: "text/plain", 53 | body: "Hello World", 54 | statusCode: http.StatusNotFound, 55 | expectedCodes: []int{http.StatusOK, http.StatusPartialContent}, 56 | requestMethod: http.MethodHead, 57 | expectedError: "expected one of the following statuses [200 OK, 206 Partial Content], but received 404 Not Found", 58 | }, 59 | { 60 | name: "wrong code with text content and GET request but octet stream", 61 | contentType: "application/octet-stream", 62 | body: "Hello World", 63 | statusCode: http.StatusNotFound, 64 | expectedCodes: []int{http.StatusOK}, 65 | requestMethod: http.MethodGet, 66 | expectedError: "expected one of the following statuses [200 OK], but received 404 Not Found", 67 | }, 68 | } 69 | 70 | for _, tt := range tests { 71 | t.Run(tt.name, func(t *testing.T) { 72 | t.Parallel() 73 | 74 | rec := httptest.NewRecorder() 75 | rec.WriteHeader(tt.statusCode) 76 | rec.Header().Set(HeaderContentType, tt.contentType) 77 | rec.Body = bytes.NewBufferString(tt.body) 78 | 79 | resp := &http.Response{ 80 | StatusCode: tt.statusCode, 81 | Status: http.StatusText(tt.statusCode), 82 | Header: rec.Header(), 83 | Body: io.NopCloser(rec.Body), 84 | Request: &http.Request{ 85 | Method: tt.requestMethod, 86 | }, 87 | } 88 | 89 | err := CheckResponseStatus(resp, tt.expectedCodes...) 90 | if tt.expectedError == "" { 91 | require.NoError(t, err) 92 | } else { 93 | require.EqualError(t, err, tt.expectedError) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "github.com/prometheus/client_golang/prometheus" 5 | 6 | "github.com/spegel-org/spegel/pkg/httpx" 7 | ) 8 | 9 | var ( 10 | // DefaultRegisterer and DefaultGatherer are the implementations of the 11 | // prometheus Registerer and Gatherer interfaces that all metrics operations 12 | // will use. They are variables so that packages that embed this library can 13 | // replace them at runtime, instead of having to pass around specific 14 | // registries. 15 | DefaultRegisterer = prometheus.DefaultRegisterer 16 | DefaultGatherer = prometheus.DefaultGatherer 17 | ) 18 | 19 | var ( 20 | MirrorRequestsTotal = prometheus.NewCounterVec(prometheus.CounterOpts{ 21 | Name: "spegel_mirror_requests_total", 22 | Help: "Total number of mirror requests.", 23 | }, []string{"registry", "cache"}) 24 | ResolveDurHistogram = prometheus.NewHistogramVec(prometheus.HistogramOpts{ 25 | Name: "spegel_resolve_duration_seconds", 26 | Help: "The duration for router to resolve a peer.", 27 | }, []string{"router"}) 28 | AdvertisedImages = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 29 | Name: "spegel_advertised_images", 30 | Help: "Number of images advertised to be available.", 31 | }, []string{"registry"}) 32 | AdvertisedImageTags = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 33 | Name: "spegel_advertised_image_tags", 34 | Help: "Number of image tags advertised to be available.", 35 | }, []string{"registry"}) 36 | AdvertisedImageDigests = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 37 | Name: "spegel_advertised_image_digests", 38 | Help: "Number of image digests advertised to be available.", 39 | }, []string{"registry"}) 40 | AdvertisedKeys = prometheus.NewGaugeVec(prometheus.GaugeOpts{ 41 | Name: "spegel_advertised_keys", 42 | Help: "Number of keys advertised to be available.", 43 | }, []string{"registry"}) 44 | ) 45 | 46 | func Register() { 47 | DefaultRegisterer.MustRegister(MirrorRequestsTotal) 48 | DefaultRegisterer.MustRegister(ResolveDurHistogram) 49 | DefaultRegisterer.MustRegister(AdvertisedImages) 50 | DefaultRegisterer.MustRegister(AdvertisedImageTags) 51 | DefaultRegisterer.MustRegister(AdvertisedImageDigests) 52 | DefaultRegisterer.MustRegister(AdvertisedKeys) 53 | httpx.RegisterMetrics(DefaultRegisterer) 54 | } 55 | -------------------------------------------------------------------------------- /pkg/metrics/metrics_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | func TestRegister(t *testing.T) { 8 | t.Parallel() 9 | 10 | Register() 11 | } 12 | -------------------------------------------------------------------------------- /pkg/oci/client.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "io" 8 | "net/http" 9 | "net/url" 10 | "runtime" 11 | "strconv" 12 | "strings" 13 | "sync" 14 | "time" 15 | 16 | "github.com/containerd/containerd/v2/core/images" 17 | "github.com/opencontainers/go-digest" 18 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 19 | 20 | "github.com/spegel-org/spegel/pkg/httpx" 21 | ) 22 | 23 | const ( 24 | HeaderDockerDigest = "Docker-Content-Digest" 25 | ) 26 | 27 | type Client struct { 28 | hc *http.Client 29 | tc sync.Map 30 | } 31 | 32 | func NewClient() *Client { 33 | return &Client{ 34 | hc: &http.Client{}, 35 | tc: sync.Map{}, 36 | } 37 | } 38 | 39 | type PullMetric struct { 40 | Digest digest.Digest 41 | ContentType string 42 | ContentLength int64 43 | Duration time.Duration 44 | } 45 | 46 | func (c *Client) Pull(ctx context.Context, img Image, mirror string) ([]PullMetric, error) { 47 | pullMetrics := []PullMetric{} 48 | 49 | queue := []DistributionPath{ 50 | { 51 | Kind: DistributionKindManifest, 52 | Name: img.Repository, 53 | Digest: img.Digest, 54 | Tag: img.Tag, 55 | Registry: img.Registry, 56 | }, 57 | } 58 | for len(queue) > 0 { 59 | dist := queue[0] 60 | queue = queue[1:] 61 | 62 | start := time.Now() 63 | rc, desc, err := c.Get(ctx, dist, mirror) 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | switch dist.Kind { 69 | case DistributionKindBlob: 70 | _, copyErr := io.Copy(io.Discard, rc) 71 | closeErr := rc.Close() 72 | err := errors.Join(copyErr, closeErr) 73 | if err != nil { 74 | return nil, err 75 | } 76 | case DistributionKindManifest: 77 | b, readErr := io.ReadAll(rc) 78 | closeErr := rc.Close() 79 | err = errors.Join(readErr, closeErr) 80 | if err != nil { 81 | return nil, err 82 | } 83 | switch desc.MediaType { 84 | case images.MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex: 85 | var idx ocispec.Index 86 | if err := json.Unmarshal(b, &idx); err != nil { 87 | return nil, err 88 | } 89 | for _, m := range idx.Manifests { 90 | // TODO: Add platform option. 91 | //nolint: staticcheck // Simplify in the future. 92 | if !(m.Platform.OS == runtime.GOOS && m.Platform.Architecture == runtime.GOARCH) { 93 | continue 94 | } 95 | queue = append(queue, DistributionPath{ 96 | Kind: DistributionKindManifest, 97 | Name: dist.Name, 98 | Digest: m.Digest, 99 | Registry: dist.Registry, 100 | }) 101 | } 102 | case images.MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest: 103 | var manifest ocispec.Manifest 104 | err := json.Unmarshal(b, &manifest) 105 | if err != nil { 106 | return nil, err 107 | } 108 | queue = append(queue, DistributionPath{ 109 | Kind: DistributionKindBlob, 110 | Name: dist.Name, 111 | Digest: manifest.Config.Digest, 112 | Registry: dist.Registry, 113 | }) 114 | for _, layer := range manifest.Layers { 115 | queue = append(queue, DistributionPath{ 116 | Kind: DistributionKindBlob, 117 | Name: dist.Name, 118 | Digest: layer.Digest, 119 | Registry: dist.Registry, 120 | }) 121 | } 122 | } 123 | } 124 | 125 | metric := PullMetric{ 126 | Digest: desc.Digest, 127 | Duration: time.Since(start), 128 | ContentType: desc.MediaType, 129 | ContentLength: desc.Size, 130 | } 131 | pullMetrics = append(pullMetrics, metric) 132 | } 133 | 134 | return pullMetrics, nil 135 | } 136 | 137 | func (c *Client) Head(ctx context.Context, dist DistributionPath, mirror string) (ocispec.Descriptor, error) { 138 | rc, desc, err := c.fetch(ctx, http.MethodHead, dist, mirror) 139 | if err != nil { 140 | return ocispec.Descriptor{}, err 141 | } 142 | defer rc.Close() 143 | _, err = io.Copy(io.Discard, rc) 144 | if err != nil { 145 | return ocispec.Descriptor{}, err 146 | } 147 | return desc, nil 148 | } 149 | 150 | func (c *Client) Get(ctx context.Context, dist DistributionPath, mirror string) (io.ReadCloser, ocispec.Descriptor, error) { 151 | rc, desc, err := c.fetch(ctx, http.MethodGet, dist, mirror) 152 | if err != nil { 153 | return nil, ocispec.Descriptor{}, err 154 | } 155 | return rc, desc, nil 156 | } 157 | 158 | func (c *Client) fetch(ctx context.Context, method string, dist DistributionPath, mirror string) (io.ReadCloser, ocispec.Descriptor, error) { 159 | tcKey := dist.Registry + dist.Name 160 | 161 | u := dist.URL() 162 | if mirror != "" { 163 | mirrorUrl, err := url.Parse(mirror) 164 | if err != nil { 165 | return nil, ocispec.Descriptor{}, err 166 | } 167 | u.Scheme = mirrorUrl.Scheme 168 | u.Host = mirrorUrl.Host 169 | } 170 | if u.Host == "docker.io" { 171 | u.Host = "registry-1.docker.io" 172 | } 173 | 174 | for range 2 { 175 | req, err := http.NewRequestWithContext(ctx, method, u.String(), nil) 176 | if err != nil { 177 | return nil, ocispec.Descriptor{}, err 178 | } 179 | req.Header.Set("User-Agent", "spegel") 180 | req.Header.Add("Accept", "application/vnd.oci.image.manifest.v1+json") 181 | req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json") 182 | req.Header.Add("Accept", "application/vnd.oci.image.index.v1+json") 183 | req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.list.v2+json") 184 | token, ok := c.tc.Load(tcKey) 185 | if ok { 186 | //nolint: errcheck // We know it will be a string. 187 | req.Header.Set("Authorization", "Bearer "+token.(string)) 188 | } 189 | resp, err := c.hc.Do(req) 190 | if err != nil { 191 | return nil, ocispec.Descriptor{}, err 192 | } 193 | if resp.StatusCode == http.StatusUnauthorized { 194 | c.tc.Delete(tcKey) 195 | wwwAuth := resp.Header.Get("WWW-Authenticate") 196 | token, err = getBearerToken(ctx, wwwAuth, c.hc) 197 | if err != nil { 198 | return nil, ocispec.Descriptor{}, err 199 | } 200 | c.tc.Store(tcKey, token) 201 | continue 202 | } 203 | err = httpx.CheckResponseStatus(resp, http.StatusOK, http.StatusPartialContent) 204 | if err != nil { 205 | return nil, ocispec.Descriptor{}, err 206 | } 207 | 208 | if resp.Header.Get(HeaderDockerDigest) == "" { 209 | resp.Header.Set(HeaderDockerDigest, dist.Digest.String()) 210 | } 211 | desc, err := DescriptorFromHeader(resp.Header) 212 | if err != nil { 213 | return nil, ocispec.Descriptor{}, err 214 | } 215 | return resp.Body, desc, nil 216 | } 217 | return nil, ocispec.Descriptor{}, errors.New("could not perform request") 218 | } 219 | 220 | func getBearerToken(ctx context.Context, wwwAuth string, client *http.Client) (string, error) { 221 | if !strings.HasPrefix(wwwAuth, "Bearer ") { 222 | return "", errors.New("unsupported auth scheme") 223 | } 224 | 225 | params := map[string]string{} 226 | for _, part := range strings.Split(wwwAuth[len("Bearer "):], ",") { 227 | kv := strings.SplitN(strings.TrimSpace(part), "=", 2) 228 | if len(kv) == 2 { 229 | params[kv[0]] = strings.Trim(kv[1], `"`) 230 | } 231 | } 232 | authURL, err := url.Parse(params["realm"]) 233 | if err != nil { 234 | return "", err 235 | } 236 | q := authURL.Query() 237 | if service, ok := params["service"]; ok { 238 | q.Set("service", service) 239 | } 240 | if scope, ok := params["scope"]; ok { 241 | q.Set("scope", scope) 242 | } 243 | authURL.RawQuery = q.Encode() 244 | 245 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, authURL.String(), nil) 246 | if err != nil { 247 | return "", err 248 | } 249 | resp, err := client.Do(req) 250 | if err != nil { 251 | return "", err 252 | } 253 | err = httpx.CheckResponseStatus(resp, http.StatusOK) 254 | if err != nil { 255 | return "", err 256 | } 257 | defer resp.Body.Close() 258 | b, err := io.ReadAll(resp.Body) 259 | if err != nil { 260 | return "", err 261 | } 262 | tokenResp := struct { 263 | Token string `json:"token"` 264 | }{} 265 | err = json.Unmarshal(b, &tokenResp) 266 | if err != nil { 267 | return "", err 268 | } 269 | return tokenResp.Token, nil 270 | } 271 | 272 | func DescriptorFromHeader(header http.Header) (ocispec.Descriptor, error) { 273 | mediaType := header.Get(httpx.HeaderContentType) 274 | if mediaType == "" { 275 | return ocispec.Descriptor{}, errors.New("content type cannot be empty") 276 | } 277 | contentLength := header.Get(httpx.HeaderContentLength) 278 | if contentLength == "" { 279 | return ocispec.Descriptor{}, errors.New("content length cannot be empty") 280 | } 281 | size, err := strconv.ParseInt(contentLength, 10, 64) 282 | if err != nil { 283 | return ocispec.Descriptor{}, err 284 | } 285 | dgst, err := digest.Parse(header.Get(HeaderDockerDigest)) 286 | if err != nil { 287 | return ocispec.Descriptor{}, err 288 | } 289 | desc := ocispec.Descriptor{ 290 | MediaType: mediaType, 291 | Size: size, 292 | Digest: dgst, 293 | } 294 | return desc, nil 295 | } 296 | 297 | func WriteDescriptorToHeader(desc ocispec.Descriptor, header http.Header) { 298 | header.Set(httpx.HeaderContentType, desc.MediaType) 299 | header.Set(httpx.HeaderContentLength, strconv.FormatInt(desc.Size, 10)) 300 | header.Set(HeaderDockerDigest, desc.Digest.String()) 301 | } 302 | -------------------------------------------------------------------------------- /pkg/oci/client_test.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "net/http" 7 | "net/http/httptest" 8 | "runtime" 9 | "testing" 10 | 11 | "github.com/opencontainers/go-digest" 12 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 13 | "github.com/spegel-org/spegel/pkg/httpx" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestPull(t *testing.T) { 18 | t.Parallel() 19 | 20 | srv := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { 21 | b, mt, err := func() ([]byte, string, error) { 22 | switch req.URL.Path { 23 | case "/v2/test/image/manifests/index": 24 | idx := ocispec.Index{ 25 | Manifests: []ocispec.Descriptor{ 26 | { 27 | Digest: digest.Digest("manifest"), 28 | Platform: &ocispec.Platform{ 29 | OS: runtime.GOOS, 30 | Architecture: runtime.GOARCH, 31 | }, 32 | }, 33 | }, 34 | } 35 | b, err := json.Marshal(&idx) 36 | if err != nil { 37 | return nil, "", err 38 | } 39 | return b, ocispec.MediaTypeImageIndex, nil 40 | case "/v2/test/image/manifests/manifest": 41 | manifest := ocispec.Manifest{ 42 | Config: ocispec.Descriptor{ 43 | Digest: digest.Digest("config"), 44 | }, 45 | Layers: []ocispec.Descriptor{ 46 | { 47 | Digest: digest.Digest("layer"), 48 | }, 49 | }, 50 | } 51 | b, err := json.Marshal(&manifest) 52 | if err != nil { 53 | return nil, "", err 54 | } 55 | return b, ocispec.MediaTypeImageManifest, nil 56 | case "/v2/test/image/blobs/config": 57 | config := ocispec.ImageConfig{ 58 | User: "root", 59 | } 60 | b, err := json.Marshal(&config) 61 | if err != nil { 62 | return nil, "", err 63 | } 64 | return b, ocispec.MediaTypeImageConfig, nil 65 | case "/v2/test/image/blobs/layer": 66 | return []byte("hello world"), ocispec.MediaTypeImageLayer, nil 67 | default: 68 | return nil, "", errors.New("not found") 69 | } 70 | }() 71 | if err != nil { 72 | rw.WriteHeader(http.StatusNotFound) 73 | return 74 | } 75 | 76 | rw.Header().Set(httpx.HeaderContentType, mt) 77 | dgst := digest.SHA256.FromBytes(b) 78 | rw.Header().Set(HeaderDockerDigest, dgst.String()) 79 | rw.WriteHeader(http.StatusOK) 80 | 81 | //nolint: errcheck // Ignore error. 82 | rw.Write(b) 83 | })) 84 | t.Cleanup(func() { 85 | srv.Close() 86 | }) 87 | 88 | img := Image{ 89 | Repository: "test/image", 90 | Digest: digest.Digest("index"), 91 | Registry: "example.com", 92 | } 93 | client := NewClient() 94 | pullResults, err := client.Pull(t.Context(), img, srv.URL) 95 | require.NoError(t, err) 96 | 97 | require.NotEmpty(t, pullResults) 98 | } 99 | 100 | func TestDescriptorHeader(t *testing.T) { 101 | t.Parallel() 102 | 103 | header := http.Header{} 104 | desc := ocispec.Descriptor{ 105 | MediaType: "foo", 106 | Size: 909, 107 | Digest: digest.Digest("sha256:b6d6089ca6c395fd563c2084f5dd7bc56a2f5e6a81413558c5be0083287a77e9"), 108 | } 109 | 110 | WriteDescriptorToHeader(desc, header) 111 | require.Equal(t, "foo", header.Get(httpx.HeaderContentType)) 112 | require.Equal(t, "909", header.Get(httpx.HeaderContentLength)) 113 | require.Equal(t, "sha256:b6d6089ca6c395fd563c2084f5dd7bc56a2f5e6a81413558c5be0083287a77e9", header.Get(HeaderDockerDigest)) 114 | headerDesc, err := DescriptorFromHeader(header) 115 | require.NoError(t, err) 116 | require.Equal(t, desc, headerDesc) 117 | 118 | header = http.Header{} 119 | _, err = DescriptorFromHeader(header) 120 | require.EqualError(t, err, "content type cannot be empty") 121 | header.Set(httpx.HeaderContentType, "test") 122 | _, err = DescriptorFromHeader(header) 123 | require.EqualError(t, err, "content length cannot be empty") 124 | header.Set(httpx.HeaderContentLength, "wrong") 125 | _, err = DescriptorFromHeader(header) 126 | require.EqualError(t, err, "strconv.ParseInt: parsing \"wrong\": invalid syntax") 127 | header.Set(httpx.HeaderContentLength, "250000") 128 | _, err = DescriptorFromHeader(header) 129 | require.EqualError(t, err, "invalid checksum digest format") 130 | header.Set(HeaderDockerDigest, "foobar") 131 | _, err = DescriptorFromHeader(header) 132 | require.EqualError(t, err, "invalid checksum digest format") 133 | } 134 | -------------------------------------------------------------------------------- /pkg/oci/distribution.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "regexp" 8 | 9 | "github.com/opencontainers/go-digest" 10 | ) 11 | 12 | var ( 13 | nameRegex = regexp.MustCompile(`([a-z0-9]+([._-][a-z0-9]+)*(/[a-z0-9]+([._-][a-z0-9]+)*)*)`) 14 | tagRegex = regexp.MustCompile(`([a-zA-Z0-9_][a-zA-Z0-9._-]{0,127})`) 15 | manifestRegexTag = regexp.MustCompile(`/v2/` + nameRegex.String() + `/manifests/` + tagRegex.String() + `$`) 16 | manifestRegexDigest = regexp.MustCompile(`/v2/` + nameRegex.String() + `/manifests/(.*)`) 17 | blobsRegexDigest = regexp.MustCompile(`/v2/` + nameRegex.String() + `/blobs/(.*)`) 18 | ) 19 | 20 | // DistributionKind represents the kind of content. 21 | type DistributionKind string 22 | 23 | const ( 24 | DistributionKindManifest = "manifests" 25 | DistributionKindBlob = "blobs" 26 | ) 27 | 28 | // DistributionPath contains the individual parameters from a OCI distribution spec request. 29 | type DistributionPath struct { 30 | Kind DistributionKind 31 | Name string 32 | Digest digest.Digest 33 | Tag string 34 | Registry string 35 | } 36 | 37 | // Reference returns the digest if set or alternatively if not the full image reference with the tag. 38 | func (d DistributionPath) Reference() string { 39 | if d.Digest != "" { 40 | return d.Digest.String() 41 | } 42 | return fmt.Sprintf("%s/%s:%s", d.Registry, d.Name, d.Tag) 43 | } 44 | 45 | // IsLatestTag returns true if the tag has the value latest. 46 | func (d DistributionPath) IsLatestTag() bool { 47 | return d.Tag == "latest" 48 | } 49 | 50 | // URL returns the reconstructed URL containing the path and query parameters. 51 | func (d DistributionPath) URL() *url.URL { 52 | ref := d.Digest.String() 53 | if ref == "" { 54 | ref = d.Tag 55 | } 56 | return &url.URL{ 57 | Scheme: "https", 58 | Host: d.Registry, 59 | Path: fmt.Sprintf("/v2/%s/%s/%s", d.Name, d.Kind, ref), 60 | RawQuery: fmt.Sprintf("ns=%s", d.Registry), 61 | } 62 | } 63 | 64 | // ParseDistributionPath gets the parameters from a URL which conforms with the OCI distribution spec. 65 | // It returns a distribution path which contains all the individual parameters. 66 | // https://github.com/opencontainers/distribution-spec/blob/main/spec.md 67 | func ParseDistributionPath(u *url.URL) (DistributionPath, error) { 68 | registry := u.Query().Get("ns") 69 | comps := manifestRegexTag.FindStringSubmatch(u.Path) 70 | if len(comps) == 6 { 71 | if registry == "" { 72 | return DistributionPath{}, errors.New("registry parameter needs to be set for tag references") 73 | } 74 | dist := DistributionPath{ 75 | Kind: DistributionKindManifest, 76 | Name: comps[1], 77 | Tag: comps[5], 78 | Registry: registry, 79 | } 80 | return dist, nil 81 | } 82 | comps = manifestRegexDigest.FindStringSubmatch(u.Path) 83 | if len(comps) == 6 { 84 | dgst, err := digest.Parse(comps[5]) 85 | if err != nil { 86 | return DistributionPath{}, err 87 | } 88 | dist := DistributionPath{ 89 | Kind: DistributionKindManifest, 90 | Name: comps[1], 91 | Digest: dgst, 92 | Registry: registry, 93 | } 94 | return dist, nil 95 | } 96 | comps = blobsRegexDigest.FindStringSubmatch(u.Path) 97 | if len(comps) == 6 { 98 | dgst, err := digest.Parse(comps[5]) 99 | if err != nil { 100 | return DistributionPath{}, err 101 | } 102 | dist := DistributionPath{ 103 | Kind: DistributionKindBlob, 104 | Name: comps[1], 105 | Digest: dgst, 106 | Registry: registry, 107 | } 108 | return dist, nil 109 | } 110 | return DistributionPath{}, errors.New("distribution path could not be parsed") 111 | } 112 | -------------------------------------------------------------------------------- /pkg/oci/distribution_test.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "testing" 7 | 8 | "github.com/opencontainers/go-digest" 9 | "github.com/stretchr/testify/require" 10 | ) 11 | 12 | func TestParseDistributionPath(t *testing.T) { 13 | t.Parallel() 14 | 15 | tests := []struct { 16 | name string 17 | registry string 18 | path string 19 | expectedName string 20 | expectedDgst digest.Digest 21 | expectedTag string 22 | expectedRef string 23 | expectedKind DistributionKind 24 | execptedIsLatestTag bool 25 | }{ 26 | { 27 | name: "manifest tag", 28 | registry: "example.com", 29 | path: "/v2/foo/bar/manifests/hello-world", 30 | expectedName: "foo/bar", 31 | expectedDgst: "", 32 | expectedTag: "hello-world", 33 | expectedRef: "example.com/foo/bar:hello-world", 34 | expectedKind: DistributionKindManifest, 35 | execptedIsLatestTag: false, 36 | }, 37 | { 38 | name: "manifest with latest tag", 39 | registry: "example.com", 40 | path: "/v2/test/manifests/latest", 41 | expectedName: "test", 42 | expectedDgst: "", 43 | expectedTag: "latest", 44 | expectedRef: "example.com/test:latest", 45 | expectedKind: DistributionKindManifest, 46 | execptedIsLatestTag: true, 47 | }, 48 | { 49 | name: "manifest digest", 50 | registry: "docker.io", 51 | path: "/v2/library/nginx/manifests/sha256:0a404ca8e119d061cdb2dceee824c914cdc69b31bc7b5956ef5a520436a80d39", 52 | expectedName: "library/nginx", 53 | expectedDgst: digest.Digest("sha256:0a404ca8e119d061cdb2dceee824c914cdc69b31bc7b5956ef5a520436a80d39"), 54 | expectedTag: "", 55 | expectedRef: "sha256:0a404ca8e119d061cdb2dceee824c914cdc69b31bc7b5956ef5a520436a80d39", 56 | expectedKind: DistributionKindManifest, 57 | execptedIsLatestTag: false, 58 | }, 59 | { 60 | name: "blob digest", 61 | registry: "docker.io", 62 | path: "/v2/library/nginx/blobs/sha256:295c7be079025306c4f1d65997fcf7adb411c88f139ad1d34b537164aa060369", 63 | expectedName: "library/nginx", 64 | expectedDgst: digest.Digest("sha256:295c7be079025306c4f1d65997fcf7adb411c88f139ad1d34b537164aa060369"), 65 | expectedTag: "", 66 | expectedRef: "sha256:295c7be079025306c4f1d65997fcf7adb411c88f139ad1d34b537164aa060369", 67 | expectedKind: DistributionKindBlob, 68 | execptedIsLatestTag: false, 69 | }, 70 | } 71 | for _, tt := range tests { 72 | t.Run(tt.name, func(t *testing.T) { 73 | t.Parallel() 74 | 75 | u := &url.URL{ 76 | Path: tt.path, 77 | RawQuery: fmt.Sprintf("ns=%s", tt.registry), 78 | } 79 | dist, err := ParseDistributionPath(u) 80 | require.NoError(t, err) 81 | require.Equal(t, tt.expectedName, dist.Name) 82 | require.Equal(t, tt.expectedDgst, dist.Digest) 83 | require.Equal(t, tt.expectedTag, dist.Tag) 84 | require.Equal(t, tt.expectedRef, dist.Reference()) 85 | require.Equal(t, tt.expectedKind, dist.Kind) 86 | require.Equal(t, tt.registry, dist.Registry) 87 | require.Equal(t, tt.path, dist.URL().Path) 88 | require.Equal(t, tt.registry, dist.URL().Query().Get("ns")) 89 | require.Equal(t, tt.execptedIsLatestTag, dist.IsLatestTag()) 90 | }) 91 | } 92 | } 93 | 94 | func TestParseDistributionPathErrors(t *testing.T) { 95 | t.Parallel() 96 | 97 | tests := []struct { 98 | name string 99 | url *url.URL 100 | expectedError string 101 | }{ 102 | { 103 | name: "invalid path", 104 | url: &url.URL{ 105 | Path: "/v2/spegel-org/spegel/v0.0.1", 106 | RawQuery: "ns=example.com", 107 | }, 108 | expectedError: "distribution path could not be parsed", 109 | }, 110 | { 111 | name: "blob with tag reference", 112 | url: &url.URL{ 113 | Path: "/v2/spegel-org/spegel/blobs/v0.0.1", 114 | RawQuery: "ns=example.com", 115 | }, 116 | expectedError: "invalid checksum digest format", 117 | }, 118 | { 119 | name: "blob with invalid digest", 120 | url: &url.URL{ 121 | Path: "/v2/spegel-org/spegel/blobs/sha256:123", 122 | RawQuery: "ns=example.com", 123 | }, 124 | expectedError: "invalid checksum digest length", 125 | }, 126 | { 127 | name: "manifest tag with missing registry", 128 | url: &url.URL{ 129 | Path: "/v2/spegel-org/spegel/manifests/v0.0.1", 130 | }, 131 | expectedError: "registry parameter needs to be set for tag references", 132 | }, 133 | { 134 | name: "manifest with invalid digest", 135 | url: &url.URL{ 136 | Path: "/v2/spegel-org/spegel/manifests/sha253:foobar", 137 | }, 138 | expectedError: "unsupported digest algorithm", 139 | }, 140 | } 141 | for _, tt := range tests { 142 | t.Run(tt.name, func(t *testing.T) { 143 | t.Parallel() 144 | 145 | _, err := ParseDistributionPath(tt.url) 146 | require.EqualError(t, err, tt.expectedError) 147 | }) 148 | } 149 | } 150 | -------------------------------------------------------------------------------- /pkg/oci/image.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/url" 7 | "regexp" 8 | "strings" 9 | 10 | digest "github.com/opencontainers/go-digest" 11 | ) 12 | 13 | type Image struct { 14 | Registry string 15 | Repository string 16 | Tag string 17 | Digest digest.Digest 18 | } 19 | 20 | func NewImage(registry, repository, tag string, dgst digest.Digest) (Image, error) { 21 | if registry == "" { 22 | return Image{}, errors.New("image needs to contain a registry") 23 | } 24 | if repository == "" { 25 | return Image{}, errors.New("image needs to contain a repository") 26 | } 27 | if dgst != "" { 28 | if err := dgst.Validate(); err != nil { 29 | return Image{}, err 30 | } 31 | } 32 | return Image{ 33 | Registry: registry, 34 | Repository: repository, 35 | Tag: tag, 36 | Digest: dgst, 37 | }, nil 38 | } 39 | 40 | func (i Image) IsLatestTag() bool { 41 | return i.Tag == "latest" 42 | } 43 | 44 | func (i Image) String() string { 45 | tag := "" 46 | if i.Tag != "" { 47 | tag = ":" + i.Tag 48 | } 49 | digest := "" 50 | if i.Digest != "" { 51 | digest = "@" + i.Digest.String() 52 | } 53 | return fmt.Sprintf("%s/%s%s%s", i.Registry, i.Repository, tag, digest) 54 | } 55 | 56 | func (i Image) TagName() (string, bool) { 57 | if i.Tag == "" { 58 | return "", false 59 | } 60 | return fmt.Sprintf("%s/%s:%s", i.Registry, i.Repository, i.Tag), true 61 | } 62 | 63 | var splitRe = regexp.MustCompile(`[:@]`) 64 | 65 | func ParseImage(s string) (Image, error) { 66 | if strings.Contains(s, "://") { 67 | return Image{}, errors.New("invalid reference") 68 | } 69 | u, err := url.Parse("dummy://" + s) 70 | if err != nil { 71 | return Image{}, err 72 | } 73 | if u.Scheme != "dummy" { 74 | return Image{}, errors.New("invalid reference") 75 | } 76 | if u.Host == "" { 77 | return Image{}, errors.New("hostname required") 78 | } 79 | var object string 80 | if idx := splitRe.FindStringIndex(u.Path); idx != nil { 81 | // This allows us to retain the @ to signify digests or shortened digests in 82 | // the object. 83 | object = u.Path[idx[0]:] 84 | if object[:1] == ":" { 85 | object = object[1:] 86 | } 87 | u.Path = u.Path[:idx[0]] 88 | } 89 | tag, dgst := splitObject(object) 90 | tag, _, _ = strings.Cut(tag, "@") 91 | repository := strings.TrimPrefix(u.Path, "/") 92 | 93 | img, err := NewImage(u.Host, repository, tag, dgst) 94 | if err != nil { 95 | return Image{}, err 96 | } 97 | return img, nil 98 | } 99 | 100 | func ParseImageRequireDigest(s string, dgst digest.Digest) (Image, error) { 101 | img, err := ParseImage(s) 102 | if err != nil { 103 | return Image{}, err 104 | } 105 | if img.Digest != "" && dgst == "" { 106 | return img, nil 107 | } 108 | if img.Digest == "" && dgst == "" { 109 | return Image{}, errors.New("image needs to contain a digest") 110 | } 111 | if img.Digest == "" && dgst != "" { 112 | return NewImage(img.Registry, img.Repository, img.Tag, dgst) 113 | } 114 | if img.Digest != dgst { 115 | return Image{}, fmt.Errorf("invalid digest set does not match parsed digest: %v %v", s, img.Digest) 116 | } 117 | return img, nil 118 | } 119 | 120 | func splitObject(obj string) (tag string, dgst digest.Digest) { 121 | parts := strings.SplitAfterN(obj, "@", 2) 122 | if len(parts) < 2 { 123 | return parts[0], "" 124 | } 125 | return parts[0], digest.Digest(parts[1]) 126 | } 127 | -------------------------------------------------------------------------------- /pkg/oci/image_test.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | digest "github.com/opencontainers/go-digest" 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestParseImageRequireDigest(t *testing.T) { 12 | t.Parallel() 13 | 14 | tests := []struct { 15 | name string 16 | image string 17 | expectedRepository string 18 | expectedTag string 19 | expectedString string 20 | expectedDigest digest.Digest 21 | expectedIsLatest bool 22 | digestInImage bool 23 | }{ 24 | { 25 | name: "Latest tag", 26 | image: "library/ubuntu:latest", 27 | digestInImage: false, 28 | expectedRepository: "library/ubuntu", 29 | expectedTag: "latest", 30 | expectedDigest: digest.Digest("sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda"), 31 | expectedIsLatest: true, 32 | expectedString: "library/ubuntu:latest@sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda", 33 | }, 34 | { 35 | name: "Only tag", 36 | image: "library/alpine:3.18.0", 37 | digestInImage: false, 38 | expectedRepository: "library/alpine", 39 | expectedTag: "3.18.0", 40 | expectedDigest: digest.Digest("sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda"), 41 | expectedIsLatest: false, 42 | expectedString: "library/alpine:3.18.0@sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda", 43 | }, 44 | { 45 | name: "Tag and digest", 46 | image: "jetstack/cert-manager-controller:3.18.0@sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda", 47 | digestInImage: true, 48 | expectedRepository: "jetstack/cert-manager-controller", 49 | expectedTag: "3.18.0", 50 | expectedDigest: digest.Digest("sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda"), 51 | expectedIsLatest: false, 52 | expectedString: "jetstack/cert-manager-controller:3.18.0@sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda", 53 | }, 54 | { 55 | name: "Only digest", 56 | image: "fluxcd/helm-controller@sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda", 57 | digestInImage: true, 58 | expectedRepository: "fluxcd/helm-controller", 59 | expectedTag: "", 60 | expectedDigest: digest.Digest("sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda"), 61 | expectedIsLatest: false, 62 | expectedString: "fluxcd/helm-controller@sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda", 63 | }, 64 | { 65 | name: "Digest only in extra digest", 66 | image: "foo/bar", 67 | digestInImage: false, 68 | expectedRepository: "foo/bar", 69 | expectedDigest: digest.Digest("sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda"), 70 | expectedIsLatest: false, 71 | expectedString: "foo/bar@sha256:c0669ef34cdc14332c0f1ab0c2c01acb91d96014b172f1a76f3a39e63d1f0bda", 72 | }, 73 | } 74 | registries := []string{"docker.io", "quay.io", "ghcr.com", "127.0.0.1"} 75 | for _, registry := range registries { 76 | for _, tt := range tests { 77 | t.Run(fmt.Sprintf("%s_%s", tt.name, registry), func(t *testing.T) { 78 | t.Parallel() 79 | 80 | for _, extraDgst := range []string{tt.expectedDigest.String(), ""} { 81 | img, err := ParseImageRequireDigest(fmt.Sprintf("%s/%s", registry, tt.image), digest.Digest(extraDgst)) 82 | if !tt.digestInImage && extraDgst == "" { 83 | require.EqualError(t, err, "image needs to contain a digest") 84 | continue 85 | } 86 | require.NoError(t, err) 87 | require.Equal(t, registry, img.Registry) 88 | require.Equal(t, tt.expectedRepository, img.Repository) 89 | require.Equal(t, tt.expectedTag, img.Tag) 90 | require.Equal(t, tt.expectedDigest, img.Digest) 91 | require.Equal(t, tt.expectedIsLatest, img.IsLatestTag()) 92 | tagName, ok := img.TagName() 93 | if tt.expectedTag == "" { 94 | require.False(t, ok) 95 | require.Empty(t, tagName) 96 | } else { 97 | require.True(t, ok) 98 | require.Equal(t, registry+"/"+tt.expectedRepository+":"+tt.expectedTag, tagName) 99 | } 100 | require.Equal(t, fmt.Sprintf("%s/%s", registry, tt.expectedString), img.String()) 101 | } 102 | }) 103 | } 104 | } 105 | } 106 | 107 | func TestParseImageRequireDigestErrors(t *testing.T) { 108 | t.Parallel() 109 | 110 | tests := []struct { 111 | name string 112 | s string 113 | dgst digest.Digest 114 | expectedError string 115 | }{ 116 | { 117 | name: "digests do not match", 118 | s: "quay.io/jetstack/cert-manager-webhook@sha256:13fd9eaadb4e491ef0e1d82de60cb199f5ad2ea5a3f8e0c19fdf31d91175b9cb", 119 | dgst: digest.Digest("sha256:ec4306b243d98cce7c3b1f994f2dae660059ef521b2b24588cfdc950bd816d4c"), 120 | expectedError: "invalid digest set does not match parsed digest: quay.io/jetstack/cert-manager-webhook@sha256:13fd9eaadb4e491ef0e1d82de60cb199f5ad2ea5a3f8e0c19fdf31d91175b9cb sha256:13fd9eaadb4e491ef0e1d82de60cb199f5ad2ea5a3f8e0c19fdf31d91175b9cb", 121 | }, 122 | { 123 | name: "no tag or digest", 124 | s: "ghcr.io/spegel-org/spegel", 125 | dgst: "", 126 | expectedError: "image needs to contain a digest", 127 | }, 128 | { 129 | name: "reference contains protocol", 130 | s: "https://example.com/test:latest", 131 | dgst: "", 132 | expectedError: "invalid reference", 133 | }, 134 | { 135 | name: "unparsable url", 136 | s: "example%#$.com/foo", 137 | dgst: "", 138 | expectedError: "parse \"dummy://example%\": invalid URL escape \"%\"", 139 | }, 140 | } 141 | for _, tt := range tests { 142 | t.Run(tt.name, func(t *testing.T) { 143 | t.Parallel() 144 | 145 | _, err := ParseImageRequireDigest(tt.s, tt.dgst) 146 | require.EqualError(t, err, tt.expectedError) 147 | }) 148 | } 149 | } 150 | 151 | func TestNewImageErrors(t *testing.T) { 152 | t.Parallel() 153 | 154 | // TODO (phillebaba): Add test case for no digest or tag. One needs to be set. 155 | tests := []struct { 156 | name string 157 | registry string 158 | repository string 159 | tag string 160 | dgst digest.Digest 161 | expectedError string 162 | }{ 163 | { 164 | name: "missing registry", 165 | registry: "", 166 | repository: "foo/bar", 167 | tag: "latest", 168 | dgst: digest.Digest("sha256:ec4306b243d98cce7c3b1f994f2dae660059ef521b2b24588cfdc950bd816d4c"), 169 | expectedError: "image needs to contain a registry", 170 | }, 171 | { 172 | name: "missing repository", 173 | registry: "example.com", 174 | repository: "", 175 | tag: "latest", 176 | dgst: digest.Digest("sha256:ec4306b243d98cce7c3b1f994f2dae660059ef521b2b24588cfdc950bd816d4c"), 177 | expectedError: "image needs to contain a repository", 178 | }, 179 | { 180 | name: "invalid digest", 181 | registry: "example.com", 182 | repository: "foo/bar", 183 | tag: "latest", 184 | dgst: digest.Digest("test"), 185 | expectedError: "invalid checksum digest format", 186 | }, 187 | } 188 | for _, tt := range tests { 189 | t.Run(tt.name, func(t *testing.T) { 190 | t.Parallel() 191 | 192 | _, err := NewImage(tt.registry, tt.repository, tt.tag, tt.dgst) 193 | require.EqualError(t, err, tt.expectedError) 194 | }) 195 | } 196 | } 197 | -------------------------------------------------------------------------------- /pkg/oci/memory.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "sync" 10 | 11 | "github.com/opencontainers/go-digest" 12 | ) 13 | 14 | var _ Store = &Memory{} 15 | 16 | type Memory struct { 17 | blobs map[digest.Digest][]byte 18 | tags map[string]digest.Digest 19 | images []Image 20 | mx sync.RWMutex 21 | } 22 | 23 | func NewMemory() *Memory { 24 | return &Memory{ 25 | images: []Image{}, 26 | tags: map[string]digest.Digest{}, 27 | blobs: map[digest.Digest][]byte{}, 28 | } 29 | } 30 | 31 | func (m *Memory) Name() string { 32 | return "memory" 33 | } 34 | 35 | func (m *Memory) Verify(ctx context.Context) error { 36 | return nil 37 | } 38 | 39 | func (m *Memory) Subscribe(ctx context.Context) (<-chan OCIEvent, error) { 40 | return nil, nil 41 | } 42 | 43 | func (m *Memory) ListImages(ctx context.Context) ([]Image, error) { 44 | m.mx.RLock() 45 | defer m.mx.RUnlock() 46 | 47 | return m.images, nil 48 | } 49 | 50 | func (m *Memory) Resolve(ctx context.Context, ref string) (digest.Digest, error) { 51 | m.mx.RLock() 52 | defer m.mx.RUnlock() 53 | 54 | dgst, ok := m.tags[ref] 55 | if !ok { 56 | return "", fmt.Errorf("could not resolve tag %s to a digest", ref) 57 | } 58 | return dgst, nil 59 | } 60 | 61 | func (m *Memory) ListContents(ctx context.Context) ([]Content, error) { 62 | m.mx.RLock() 63 | defer m.mx.RUnlock() 64 | 65 | contents := []Content{} 66 | for k := range m.blobs { 67 | contents = append(contents, Content{Digest: k}) 68 | } 69 | return contents, nil 70 | } 71 | 72 | func (m *Memory) Size(ctx context.Context, dgst digest.Digest) (int64, error) { 73 | m.mx.RLock() 74 | defer m.mx.RUnlock() 75 | 76 | b, ok := m.blobs[dgst] 77 | if !ok { 78 | return 0, errors.Join(ErrNotFound, fmt.Errorf("size information for digest %s not found", dgst)) 79 | } 80 | return int64(len(b)), nil 81 | } 82 | 83 | func (m *Memory) GetManifest(ctx context.Context, dgst digest.Digest) ([]byte, string, error) { 84 | m.mx.RLock() 85 | defer m.mx.RUnlock() 86 | 87 | b, ok := m.blobs[dgst] 88 | if !ok { 89 | return nil, "", errors.Join(ErrNotFound, fmt.Errorf("manifest with digest %s not found", dgst)) 90 | } 91 | mt, err := DetermineMediaType(b) 92 | if err != nil { 93 | return nil, "", err 94 | } 95 | return b, mt, nil 96 | } 97 | 98 | func (m *Memory) GetBlob(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) { 99 | m.mx.RLock() 100 | defer m.mx.RUnlock() 101 | 102 | b, ok := m.blobs[dgst] 103 | if !ok { 104 | return nil, errors.Join(ErrNotFound, fmt.Errorf("blob with digest %s not found", dgst)) 105 | } 106 | rc := io.NewSectionReader(bytes.NewReader(b), 0, int64(len(b))) 107 | return struct { 108 | io.ReadSeeker 109 | io.Closer 110 | }{ 111 | ReadSeeker: rc, 112 | Closer: io.NopCloser(nil), 113 | }, nil 114 | } 115 | 116 | func (m *Memory) AddImage(img Image) { 117 | m.mx.Lock() 118 | defer m.mx.Unlock() 119 | 120 | m.images = append(m.images, img) 121 | tagName, ok := img.TagName() 122 | if !ok { 123 | return 124 | } 125 | m.tags[tagName] = img.Digest 126 | } 127 | 128 | func (m *Memory) AddBlob(b []byte, dgst digest.Digest) { 129 | m.mx.Lock() 130 | defer m.mx.Unlock() 131 | 132 | m.blobs[dgst] = b 133 | } 134 | -------------------------------------------------------------------------------- /pkg/oci/oci.go: -------------------------------------------------------------------------------- 1 | package oci 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "errors" 7 | "fmt" 8 | "io" 9 | 10 | "github.com/containerd/containerd/v2/core/images" 11 | "github.com/opencontainers/go-digest" 12 | "github.com/opencontainers/image-spec/specs-go" 13 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 14 | ) 15 | 16 | var ( 17 | ErrNotFound = errors.New("content not found") 18 | ) 19 | 20 | type EventType string 21 | 22 | const ( 23 | CreateEvent EventType = "CREATE" 24 | DeleteEvent EventType = "DELETE" 25 | ) 26 | 27 | type OCIEvent struct { 28 | Type EventType 29 | Key string 30 | } 31 | 32 | type Content struct { 33 | Digest digest.Digest 34 | Registires []string 35 | } 36 | 37 | type Store interface { 38 | // Name returns the name of the store implementation. 39 | Name() string 40 | 41 | // Verify checks that all expected configuration is set. 42 | Verify(ctx context.Context) error 43 | 44 | // Subscribe will notify for any image events ocuring in the store backend. 45 | Subscribe(ctx context.Context) (<-chan OCIEvent, error) 46 | 47 | // ListImages returns a list of all local images. 48 | ListImages(ctx context.Context) ([]Image, error) 49 | 50 | // Resolve returns the digest for the tagged image name reference. 51 | // The ref is expected to be in the format `registry/name:tag`. 52 | Resolve(ctx context.Context, ref string) (digest.Digest, error) 53 | 54 | // ListContents returns a list of all the contents. 55 | ListContents(ctx context.Context) ([]Content, error) 56 | 57 | // Size returns the content byte size for the given digest. 58 | // Will return ErrNotFound if the digest cannot be found. 59 | Size(ctx context.Context, dgst digest.Digest) (int64, error) 60 | 61 | // GetManifest returns the manifest content for the given digest. 62 | // Will return ErrNotFound if the digest cannot be found. 63 | GetManifest(ctx context.Context, dgst digest.Digest) ([]byte, string, error) 64 | 65 | // GetBlob returns a stream of the blob content for the given digest. 66 | // Will return ErrNotFound if the digest cannot be found. 67 | GetBlob(ctx context.Context, dgst digest.Digest) (io.ReadSeekCloser, error) 68 | } 69 | 70 | type UnknownDocument struct { 71 | MediaType string `json:"mediaType"` 72 | specs.Versioned 73 | } 74 | 75 | func DetermineMediaType(b []byte) (string, error) { 76 | var ud UnknownDocument 77 | if err := json.Unmarshal(b, &ud); err != nil { 78 | return "", err 79 | } 80 | if ud.SchemaVersion == 2 && ud.MediaType != "" { 81 | return ud.MediaType, nil 82 | } 83 | data := map[string]json.RawMessage{} 84 | if err := json.Unmarshal(b, &data); err != nil { 85 | return "", err 86 | } 87 | _, architectureOk := data["architecture"] 88 | _, osOk := data["os"] 89 | _, rootfsOk := data["rootfs"] 90 | if architectureOk && osOk && rootfsOk { 91 | return ocispec.MediaTypeImageConfig, nil 92 | } 93 | _, manifestsOk := data["manifests"] 94 | if ud.SchemaVersion == 2 && manifestsOk { 95 | return ocispec.MediaTypeImageIndex, nil 96 | } 97 | _, configOk := data["config"] 98 | if ud.SchemaVersion == 2 && configOk { 99 | return ocispec.MediaTypeImageManifest, nil 100 | } 101 | return "", errors.New("not able to determine media type") 102 | } 103 | 104 | func WalkImage(ctx context.Context, store Store, img Image) ([]digest.Digest, error) { 105 | dgsts := []digest.Digest{} 106 | err := walk(ctx, []digest.Digest{img.Digest}, func(dgst digest.Digest) ([]digest.Digest, error) { 107 | b, mt, err := store.GetManifest(ctx, dgst) 108 | if err != nil { 109 | return nil, err 110 | } 111 | dgsts = append(dgsts, dgst) 112 | switch mt { 113 | case images.MediaTypeDockerSchema2ManifestList, ocispec.MediaTypeImageIndex: 114 | var idx ocispec.Index 115 | if err := json.Unmarshal(b, &idx); err != nil { 116 | return nil, err 117 | } 118 | manifestDgsts := []digest.Digest{} 119 | for _, m := range idx.Manifests { 120 | _, err := store.Size(ctx, m.Digest) 121 | if errors.Is(err, ErrNotFound) { 122 | continue 123 | } 124 | if err != nil { 125 | return nil, err 126 | } 127 | manifestDgsts = append(manifestDgsts, m.Digest) 128 | } 129 | if len(manifestDgsts) == 0 { 130 | return nil, fmt.Errorf("could not find any platforms with local content in manifest %s", dgst) 131 | } 132 | return manifestDgsts, nil 133 | case images.MediaTypeDockerSchema2Manifest, ocispec.MediaTypeImageManifest: 134 | var manifest ocispec.Manifest 135 | err := json.Unmarshal(b, &manifest) 136 | if err != nil { 137 | return nil, err 138 | } 139 | dgsts = append(dgsts, manifest.Config.Digest) 140 | for _, layer := range manifest.Layers { 141 | dgsts = append(dgsts, layer.Digest) 142 | } 143 | return nil, nil 144 | default: 145 | return nil, fmt.Errorf("unexpected media type %s for digest %s", mt, dgst) 146 | } 147 | }) 148 | if err != nil { 149 | return nil, fmt.Errorf("failed to walk image manifests: %w", err) 150 | } 151 | if len(dgsts) == 0 { 152 | return nil, errors.New("no image digests found") 153 | } 154 | return dgsts, nil 155 | } 156 | 157 | func walk(ctx context.Context, dgsts []digest.Digest, handler func(dgst digest.Digest) ([]digest.Digest, error)) error { 158 | for _, dgst := range dgsts { 159 | children, err := handler(dgst) 160 | if err != nil { 161 | return err 162 | } 163 | if len(children) == 0 { 164 | continue 165 | } 166 | err = walk(ctx, children, handler) 167 | if err != nil { 168 | return err 169 | } 170 | } 171 | return nil 172 | } 173 | -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/0ad7c556c55464fa44d4c41e5236715e015b0266daced62140fb5c6b983c946b: -------------------------------------------------------------------------------- 1 | { 2 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 3 | "schemaVersion": 2, 4 | "config": { 5 | "mediaType": "application/vnd.oci.image.config.v1+json", 6 | "digest": "sha256:1079836371d57a148a0afa5abfe00bd91825c869fcc6574a418f4371d53cab4c", 7 | "size": 2855 8 | }, 9 | "layers": [ 10 | { 11 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 12 | "digest": "sha256:b437b30b8b4cc4e02865517b5ca9b66501752012a028e605da1c98beb0ed9f50", 13 | "size": 103732 14 | }, 15 | { 16 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 17 | "digest": "sha256:fe5ca62666f04366c8e7f605aa82997d71320183e99962fa76b3209fdfbb8b58", 18 | "size": 21202 19 | }, 20 | { 21 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 22 | "digest": "sha256:b02a7525f878e61fc1ef8a7405a2cc17f866e8de222c1c98fd6681aff6e509db", 23 | "size": 716491 24 | }, 25 | { 26 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 27 | "digest": "sha256:fcb6f6d2c9986d9cd6a2ea3cc2936e5fc613e09f1af9042329011e43057f3265", 28 | "size": 317 29 | }, 30 | { 31 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 32 | "digest": "sha256:e8c73c638ae9ec5ad70c49df7e484040d889cca6b4a9af056579c3d058ea93f0", 33 | "size": 198 34 | }, 35 | { 36 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 37 | "digest": "sha256:1e3d9b7d145208fa8fa3ee1c9612d0adaac7255f1bbc9ddea7e461e0b317805c", 38 | "size": 113 39 | }, 40 | { 41 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 42 | "digest": "sha256:4aa0ea1413d37a58615488592a0b827ea4b2e48fa5a77cf707d0e35f025e613f", 43 | "size": 385 44 | }, 45 | { 46 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 47 | "digest": "sha256:7c881f9ab25e0d86562a123b5fb56aebf8aa0ddd7d48ef602faf8d1e7cf43d8c", 48 | "size": 355 49 | }, 50 | { 51 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 52 | "digest": "sha256:5627a970d25e752d971a501ec7e35d0d6fdcd4a3ce9e958715a686853024794a", 53 | "size": 130562 54 | }, 55 | { 56 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 57 | "digest": "sha256:01d28554416aa05390e2827a653a1289a2a549e46cc78d65915a75377c6008ba", 58 | "size": 34318536 59 | }, 60 | { 61 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 62 | "digest": "sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1", 63 | "size": 32 64 | } 65 | ] 66 | } -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/3caa2469de2a23cbcc209dd0b9d01cd78ff9a0f88741655991d36baede5b0996: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spegel-org/spegel/cdff80e41f37ff97cfe9fe152875fbe141f8afca/pkg/oci/testdata/blobs/sha256/3caa2469de2a23cbcc209dd0b9d01cd78ff9a0f88741655991d36baede5b0996 -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/44cb2cf712c060f69df7310e99339c1eb51a085446f1bb6d44469acff35b4355: -------------------------------------------------------------------------------- 1 | { 2 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 3 | "schemaVersion": 2, 4 | "config": { 5 | "mediaType": "application/vnd.oci.image.config.v1+json", 6 | "digest": "sha256:d715ba0d85ee7d37da627d0679652680ed2cb23dde6120f25143a0b8079ee47e", 7 | "size": 2842 8 | }, 9 | "layers": [ 10 | { 11 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 12 | "digest": "sha256:a7ca0d9ba68fdce7e15bc0952d3e898e970548ca24d57698725836c039086639", 13 | "size": 103732 14 | }, 15 | { 16 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 17 | "digest": "sha256:fe5ca62666f04366c8e7f605aa82997d71320183e99962fa76b3209fdfbb8b58", 18 | "size": 21202 19 | }, 20 | { 21 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 22 | "digest": "sha256:b02a7525f878e61fc1ef8a7405a2cc17f866e8de222c1c98fd6681aff6e509db", 23 | "size": 716491 24 | }, 25 | { 26 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 27 | "digest": "sha256:fcb6f6d2c9986d9cd6a2ea3cc2936e5fc613e09f1af9042329011e43057f3265", 28 | "size": 317 29 | }, 30 | { 31 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 32 | "digest": "sha256:e8c73c638ae9ec5ad70c49df7e484040d889cca6b4a9af056579c3d058ea93f0", 33 | "size": 198 34 | }, 35 | { 36 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 37 | "digest": "sha256:1e3d9b7d145208fa8fa3ee1c9612d0adaac7255f1bbc9ddea7e461e0b317805c", 38 | "size": 113 39 | }, 40 | { 41 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 42 | "digest": "sha256:4aa0ea1413d37a58615488592a0b827ea4b2e48fa5a77cf707d0e35f025e613f", 43 | "size": 385 44 | }, 45 | { 46 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 47 | "digest": "sha256:7c881f9ab25e0d86562a123b5fb56aebf8aa0ddd7d48ef602faf8d1e7cf43d8c", 48 | "size": 355 49 | }, 50 | { 51 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 52 | "digest": "sha256:5627a970d25e752d971a501ec7e35d0d6fdcd4a3ce9e958715a686853024794a", 53 | "size": 130562 54 | }, 55 | { 56 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 57 | "digest": "sha256:76f3a495ffdc00c612747ba0c59fc56d0a2610d2785e80e9edddbf214c2709ef", 58 | "size": 36529876 59 | }, 60 | { 61 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 62 | "digest": "sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1", 63 | "size": 32 64 | } 65 | ] 66 | } -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/68b8a989a3e08ddbdb3a0077d35c0d0e59c9ecf23d0634584def8bdbb7d6824f: -------------------------------------------------------------------------------- 1 | {"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"WorkingDir":"/","OnBuild":null},"created":"2024-02-13T20:57:33.241342971+01:00","history":[{"created":"2024-02-13T20:57:33.241342971+01:00","created_by":"COPY test.txt . # buildkit","comment":"buildkit.dockerfile.v0"}],"moby.buildkit.buildinfo.v1":"eyJmcm9udGVuZCI6ImRvY2tlcmZpbGUudjAifQ==","os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:788bbd044fb43c00246a6351dae95d8a3d2510ba680dd404d5bae5e80479484d"]}} -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/9430beb291fa7b96997711fc486bc46133c719631aefdbeebe58dd3489217bfe: -------------------------------------------------------------------------------- 1 | { 2 | "mediaType": "application/vnd.oci.image.index.v1+json", 3 | "schemaVersion": 2, 4 | "manifests": [ 5 | { 6 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 7 | "digest": "sha256:aec8273a5e5aca369fcaa8cecef7bf6c7959d482f5c8cfa2236a6a16e46bbdcf", 8 | "size": 476, 9 | "platform": { 10 | "architecture": "amd64", 11 | "os": "linux" 12 | } 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/9506c8e7a2d0a098d43cadfd7ecdc3c91697e8188d3a1245943b669f717747b4: -------------------------------------------------------------------------------- 1 | { 2 | "mediaType": "application/vnd.oci.image.index.v1+json", 3 | "schemaVersion": 2, 4 | "manifests": [ 5 | { 6 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 7 | "digest": "sha256:32cb2cf712c060f69df7310e99339c1eb51a085446f1bb6d44469acff35b4355", 8 | "size": 2372, 9 | "platform": { 10 | "architecture": "fake", 11 | "os": "linux" 12 | } 13 | }, 14 | { 15 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 16 | "digest": "sha256:44cb2cf712c060f69df7310e99339c1eb51a085446f1bb6d44469acff35b4355", 17 | "size": 2372, 18 | "platform": { 19 | "architecture": "amd64", 20 | "os": "linux" 21 | } 22 | }, 23 | { 24 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 25 | "digest": "sha256:0ad7c556c55464fa44d4c41e5236715e015b0266daced62140fb5c6b983c946b", 26 | "size": 2372, 27 | "platform": { 28 | "architecture": "arm", 29 | "os": "linux", 30 | "variant": "v7" 31 | } 32 | }, 33 | { 34 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 35 | "digest": "sha256:dce623533c59af554b85f859e91fc1cbb7f574e873c82f36b9ea05a09feb0b53", 36 | "size": 2372, 37 | "platform": { 38 | "architecture": "arm64", 39 | "os": "linux" 40 | } 41 | }, 42 | { 43 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 44 | "digest": "sha256:73af5483f4d2d636275dcef14d5443ff96d7347a0720ca5a73a32c73855c4aac", 45 | "size": 566, 46 | "annotations": { 47 | "vnd.docker.reference.digest": "sha256:44cb2cf712c060f69df7310e99339c1eb51a085446f1bb6d44469acff35b4355", 48 | "vnd.docker.reference.type": "attestation-manifest" 49 | }, 50 | "platform": { 51 | "architecture": "unknown", 52 | "os": "unknown" 53 | } 54 | }, 55 | { 56 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 57 | "digest": "sha256:36e11bf470af256febbdfad9d803e60b7290b0268218952991b392be9e8153bd", 58 | "size": 566, 59 | "annotations": { 60 | "vnd.docker.reference.digest": "sha256:0ad7c556c55464fa44d4c41e5236715e015b0266daced62140fb5c6b983c946b", 61 | "vnd.docker.reference.type": "attestation-manifest" 62 | }, 63 | "platform": { 64 | "architecture": "unknown", 65 | "os": "unknown" 66 | } 67 | }, 68 | { 69 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 70 | "digest": "sha256:42d1c43f2285e8e3d39f80b8eed8e4c5c28b8011c942b5413ecc6a0050600609", 71 | "size": 566, 72 | "annotations": { 73 | "vnd.docker.reference.digest": "sha256:dce623533c59af554b85f859e91fc1cbb7f574e873c82f36b9ea05a09feb0b53", 74 | "vnd.docker.reference.type": "attestation-manifest" 75 | }, 76 | "platform": { 77 | "architecture": "unknown", 78 | "os": "unknown" 79 | } 80 | } 81 | ] 82 | } -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/addc990c58744bdf96364fe89bd4aab38b1e824d51c688edb36c75247cd45fa9: -------------------------------------------------------------------------------- 1 | { 2 | "mediaType": "application/vnd.oci.image.index.v1+json", 3 | "schemaVersion": 2, 4 | "manifests": [ 5 | { 6 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 7 | "digest": "sha256:2fc401df92a31e32189eb27d9bd1e2fa649ff42a665557ec4aa3eaf5de2685df", 8 | "size": 2372, 9 | "platform": { 10 | "architecture": "amd64", 11 | "os": "linux" 12 | } 13 | }, 14 | { 15 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 16 | "digest": "sha256:f9fc991c2afd0fa22cb4999b1134a937f6e15820a58426ba288a0aec8207cf07", 17 | "size": 2372, 18 | "platform": { 19 | "architecture": "arm", 20 | "os": "linux", 21 | "variant": "v7" 22 | } 23 | }, 24 | { 25 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 26 | "digest": "sha256:dbd95b715407e29b42cd0366e593b545a81c901dec93427860c87d7a87044441", 27 | "size": 2372, 28 | "platform": { 29 | "architecture": "arm64", 30 | "os": "linux" 31 | } 32 | } 33 | ] 34 | } -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/aec8273a5e5aca369fcaa8cecef7bf6c7959d482f5c8cfa2236a6a16e46bbdcf: -------------------------------------------------------------------------------- 1 | { 2 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 3 | "schemaVersion": 2, 4 | "config": { 5 | "mediaType": "application/vnd.oci.image.config.v1+json", 6 | "digest": "sha256:68b8a989a3e08ddbdb3a0077d35c0d0e59c9ecf23d0634584def8bdbb7d6824f", 7 | "size": 529 8 | }, 9 | "layers": [ 10 | { 11 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 12 | "digest": "sha256:3caa2469de2a23cbcc209dd0b9d01cd78ff9a0f88741655991d36baede5b0996", 13 | "size": 118 14 | } 15 | ] 16 | } -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/b6d6089ca6c395fd563c2084f5dd7bc56a2f5e6a81413558c5be0083287a77e9: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 2, 3 | "config": { 4 | "mediaType": "application/vnd.oci.image.config.v1+json", 5 | "digest": "sha256:68b8a989a3e08ddbdb3a0077d35c0d0e59c9ecf23d0634584def8bdbb7d6824f", 6 | "size": 529 7 | }, 8 | "layers": [ 9 | { 10 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 11 | "digest": "sha256:3caa2469de2a23cbcc209dd0b9d01cd78ff9a0f88741655991d36baede5b0996", 12 | "size": 118 13 | } 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/d8df04365d06181f037251de953aca85cc16457581a8fc168f4957c978e1008b: -------------------------------------------------------------------------------- 1 | { 2 | "schemaVersion": 2, 3 | "manifests": [ 4 | { 5 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 6 | "digest": "sha256:32cb2cf712c060f69df7310e99339c1eb51a085446f1bb6d44469acff35b4355", 7 | "size": 2372, 8 | "platform": { 9 | "architecture": "fake", 10 | "os": "linux" 11 | } 12 | }, 13 | { 14 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 15 | "digest": "sha256:44cb2cf712c060f69df7310e99339c1eb51a085446f1bb6d44469acff35b4355", 16 | "size": 2372, 17 | "platform": { 18 | "architecture": "amd64", 19 | "os": "linux" 20 | } 21 | }, 22 | { 23 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 24 | "digest": "sha256:0ad7c556c55464fa44d4c41e5236715e015b0266daced62140fb5c6b983c946b", 25 | "size": 2372, 26 | "platform": { 27 | "architecture": "arm", 28 | "os": "linux", 29 | "variant": "v7" 30 | } 31 | }, 32 | { 33 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 34 | "digest": "sha256:dce623533c59af554b85f859e91fc1cbb7f574e873c82f36b9ea05a09feb0b53", 35 | "size": 2372, 36 | "platform": { 37 | "architecture": "arm64", 38 | "os": "linux" 39 | } 40 | }, 41 | { 42 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 43 | "digest": "sha256:73af5483f4d2d636275dcef14d5443ff96d7347a0720ca5a73a32c73855c4aac", 44 | "size": 566, 45 | "annotations": { 46 | "vnd.docker.reference.digest": "sha256:44cb2cf712c060f69df7310e99339c1eb51a085446f1bb6d44469acff35b4355", 47 | "vnd.docker.reference.type": "attestation-manifest" 48 | }, 49 | "platform": { 50 | "architecture": "unknown", 51 | "os": "unknown" 52 | } 53 | }, 54 | { 55 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 56 | "digest": "sha256:36e11bf470af256febbdfad9d803e60b7290b0268218952991b392be9e8153bd", 57 | "size": 566, 58 | "annotations": { 59 | "vnd.docker.reference.digest": "sha256:0ad7c556c55464fa44d4c41e5236715e015b0266daced62140fb5c6b983c946b", 60 | "vnd.docker.reference.type": "attestation-manifest" 61 | }, 62 | "platform": { 63 | "architecture": "unknown", 64 | "os": "unknown" 65 | } 66 | }, 67 | { 68 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 69 | "digest": "sha256:42d1c43f2285e8e3d39f80b8eed8e4c5c28b8011c942b5413ecc6a0050600609", 70 | "size": 566, 71 | "annotations": { 72 | "vnd.docker.reference.digest": "sha256:dce623533c59af554b85f859e91fc1cbb7f574e873c82f36b9ea05a09feb0b53", 73 | "vnd.docker.reference.type": "attestation-manifest" 74 | }, 75 | "platform": { 76 | "architecture": "unknown", 77 | "os": "unknown" 78 | } 79 | } 80 | ] 81 | } -------------------------------------------------------------------------------- /pkg/oci/testdata/blobs/sha256/dce623533c59af554b85f859e91fc1cbb7f574e873c82f36b9ea05a09feb0b53: -------------------------------------------------------------------------------- 1 | { 2 | "mediaType": "application/vnd.oci.image.manifest.v1+json", 3 | "schemaVersion": 2, 4 | "config": { 5 | "mediaType": "application/vnd.oci.image.config.v1+json", 6 | "digest": "sha256:c73129c9fb699b620aac2df472196ed41797fd0f5a90e1942bfbf19849c4a1c9", 7 | "size": 2842 8 | }, 9 | "layers": [ 10 | { 11 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 12 | "digest": "sha256:0b41f743fd4d78cb50ba86dd3b951b51458744109e1f5063a76bc5a792c3d8e7", 13 | "size": 103732 14 | }, 15 | { 16 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 17 | "digest": "sha256:fe5ca62666f04366c8e7f605aa82997d71320183e99962fa76b3209fdfbb8b58", 18 | "size": 21202 19 | }, 20 | { 21 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 22 | "digest": "sha256:b02a7525f878e61fc1ef8a7405a2cc17f866e8de222c1c98fd6681aff6e509db", 23 | "size": 716491 24 | }, 25 | { 26 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 27 | "digest": "sha256:fcb6f6d2c9986d9cd6a2ea3cc2936e5fc613e09f1af9042329011e43057f3265", 28 | "size": 317 29 | }, 30 | { 31 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 32 | "digest": "sha256:e8c73c638ae9ec5ad70c49df7e484040d889cca6b4a9af056579c3d058ea93f0", 33 | "size": 198 34 | }, 35 | { 36 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 37 | "digest": "sha256:1e3d9b7d145208fa8fa3ee1c9612d0adaac7255f1bbc9ddea7e461e0b317805c", 38 | "size": 113 39 | }, 40 | { 41 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 42 | "digest": "sha256:4aa0ea1413d37a58615488592a0b827ea4b2e48fa5a77cf707d0e35f025e613f", 43 | "size": 385 44 | }, 45 | { 46 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 47 | "digest": "sha256:7c881f9ab25e0d86562a123b5fb56aebf8aa0ddd7d48ef602faf8d1e7cf43d8c", 48 | "size": 355 49 | }, 50 | { 51 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 52 | "digest": "sha256:5627a970d25e752d971a501ec7e35d0d6fdcd4a3ce9e958715a686853024794a", 53 | "size": 130562 54 | }, 55 | { 56 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 57 | "digest": "sha256:0dc769edeab7d9f622b9703579f6c89298a4cf45a84af1908e26fffca55341e1", 58 | "size": 34168923 59 | }, 60 | { 61 | "mediaType": "application/vnd.oci.image.layer.v1.tar+gzip", 62 | "digest": "sha256:4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1", 63 | "size": 32 64 | } 65 | ] 66 | } -------------------------------------------------------------------------------- /pkg/oci/testdata/images.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "name": "example.com/org/scratch:latest", 4 | "mediaType": "application/vnd.oci.image.index.v1+json", 5 | "digest": "sha256:9430beb291fa7b96997711fc486bc46133c719631aefdbeebe58dd3489217bfe" 6 | }, 7 | { 8 | "name": "ghcr.io/spegel-org/spegel:v0.0.8", 9 | "mediaType": "application/vnd.oci.image.index.v1+json", 10 | "digest": "sha256:9506c8e7a2d0a098d43cadfd7ecdc3c91697e8188d3a1245943b669f717747b4" 11 | }, 12 | { 13 | "name": "ghcr.io/spegel-org/spegel:v0.0.8-with-media-type", 14 | "mediaType": "application/vnd.oci.image.index.v1+json", 15 | "digest": "sha256:9506c8e7a2d0a098d43cadfd7ecdc3c91697e8188d3a1245943b669f717747b4" 16 | }, 17 | { 18 | "name": "ghcr.io/spegel-org/spegel:v0.0.8-without-media-type", 19 | "mediaType": "application/vnd.oci.image.index.v1+json", 20 | "digest": "sha256:d8df04365d06181f037251de953aca85cc16457581a8fc168f4957c978e1008b" 21 | }, 22 | { 23 | "name": "example.com/org/no-platform:test", 24 | "mediaType": "application/vnd.oci.image.index.v1+json", 25 | "digest": "sha256:addc990c58744bdf96364fe89bd4aab38b1e824d51c688edb36c75247cd45fa9" 26 | } 27 | ] -------------------------------------------------------------------------------- /pkg/registry/registry.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "net/netip" 10 | "net/url" 11 | "path" 12 | "strconv" 13 | "sync" 14 | "time" 15 | 16 | "github.com/go-logr/logr" 17 | 18 | "github.com/spegel-org/spegel/pkg/httpx" 19 | "github.com/spegel-org/spegel/pkg/metrics" 20 | "github.com/spegel-org/spegel/pkg/oci" 21 | "github.com/spegel-org/spegel/pkg/routing" 22 | ) 23 | 24 | const ( 25 | HeaderSpegelMirrored = "X-Spegel-Mirrored" 26 | ) 27 | 28 | type RegistryConfig struct { 29 | Client *http.Client 30 | Log logr.Logger 31 | Username string 32 | Password string 33 | ResolveRetries int 34 | ResolveLatestTag bool 35 | ResolveTimeout time.Duration 36 | } 37 | 38 | func (cfg *RegistryConfig) Apply(opts ...RegistryOption) error { 39 | for _, opt := range opts { 40 | if opt == nil { 41 | continue 42 | } 43 | if err := opt(cfg); err != nil { 44 | return err 45 | } 46 | } 47 | return nil 48 | } 49 | 50 | type RegistryOption func(cfg *RegistryConfig) error 51 | 52 | func WithResolveRetries(resolveRetries int) RegistryOption { 53 | return func(cfg *RegistryConfig) error { 54 | cfg.ResolveRetries = resolveRetries 55 | return nil 56 | } 57 | } 58 | 59 | func WithResolveLatestTag(resolveLatestTag bool) RegistryOption { 60 | return func(cfg *RegistryConfig) error { 61 | cfg.ResolveLatestTag = resolveLatestTag 62 | return nil 63 | } 64 | } 65 | 66 | func WithResolveTimeout(resolveTimeout time.Duration) RegistryOption { 67 | return func(cfg *RegistryConfig) error { 68 | cfg.ResolveTimeout = resolveTimeout 69 | return nil 70 | } 71 | } 72 | 73 | func WithTransport(transport http.RoundTripper) RegistryOption { 74 | return func(cfg *RegistryConfig) error { 75 | if cfg.Client == nil { 76 | cfg.Client = &http.Client{} 77 | } 78 | cfg.Client.Transport = transport 79 | return nil 80 | } 81 | } 82 | 83 | func WithLogger(log logr.Logger) RegistryOption { 84 | return func(cfg *RegistryConfig) error { 85 | cfg.Log = log 86 | return nil 87 | } 88 | } 89 | 90 | func WithBasicAuth(username, password string) RegistryOption { 91 | return func(cfg *RegistryConfig) error { 92 | cfg.Username = username 93 | cfg.Password = password 94 | return nil 95 | } 96 | } 97 | 98 | type Registry struct { 99 | client *http.Client 100 | bufferPool *sync.Pool 101 | log logr.Logger 102 | ociStore oci.Store 103 | router routing.Router 104 | username string 105 | password string 106 | resolveRetries int 107 | resolveTimeout time.Duration 108 | resolveLatestTag bool 109 | } 110 | 111 | func NewRegistry(ociStore oci.Store, router routing.Router, opts ...RegistryOption) (*Registry, error) { 112 | transport, ok := http.DefaultTransport.(*http.Transport) 113 | if !ok { 114 | return nil, errors.New("default transporn is not of type http.Transport") 115 | } 116 | cfg := RegistryConfig{ 117 | Client: &http.Client{ 118 | Transport: transport.Clone(), 119 | }, 120 | Log: logr.Discard(), 121 | ResolveRetries: 3, 122 | ResolveLatestTag: true, 123 | ResolveTimeout: 20 * time.Millisecond, 124 | } 125 | err := cfg.Apply(opts...) 126 | if err != nil { 127 | return nil, err 128 | } 129 | 130 | bufferPool := &sync.Pool{ 131 | New: func() any { 132 | buf := make([]byte, 32*1024) 133 | return &buf 134 | }, 135 | } 136 | r := &Registry{ 137 | ociStore: ociStore, 138 | router: router, 139 | client: cfg.Client, 140 | log: cfg.Log, 141 | resolveRetries: cfg.ResolveRetries, 142 | resolveLatestTag: cfg.ResolveLatestTag, 143 | resolveTimeout: cfg.ResolveTimeout, 144 | username: cfg.Username, 145 | password: cfg.Password, 146 | bufferPool: bufferPool, 147 | } 148 | return r, nil 149 | } 150 | 151 | func (r *Registry) Server(addr string) (*http.Server, error) { 152 | m := httpx.NewServeMux(r.log) 153 | m.Handle("GET /healthz", r.readyHandler) 154 | m.Handle("GET /v2/", r.registryHandler) 155 | m.Handle("HEAD /v2/", r.registryHandler) 156 | srv := &http.Server{ 157 | Addr: addr, 158 | Handler: m, 159 | } 160 | return srv, nil 161 | } 162 | 163 | func (r *Registry) readyHandler(rw httpx.ResponseWriter, req *http.Request) { 164 | rw.SetHandler("ready") 165 | ok, err := r.router.Ready(req.Context()) 166 | if err != nil { 167 | rw.WriteError(http.StatusInternalServerError, fmt.Errorf("could not determine router readiness: %w", err)) 168 | return 169 | } 170 | if !ok { 171 | rw.WriteHeader(http.StatusInternalServerError) 172 | return 173 | } 174 | } 175 | 176 | func (r *Registry) registryHandler(rw httpx.ResponseWriter, req *http.Request) { 177 | rw.SetHandler("registry") 178 | 179 | // Check basic authentication 180 | if r.username != "" || r.password != "" { 181 | username, password, _ := req.BasicAuth() 182 | if r.username != username || r.password != password { 183 | rw.WriteError(http.StatusUnauthorized, errors.New("invalid basic authentication")) 184 | return 185 | } 186 | } 187 | 188 | // Quickly return 200 for /v2 to indicate that registry supports v2. 189 | if path.Clean(req.URL.Path) == "/v2" { 190 | rw.SetHandler("v2") 191 | rw.WriteHeader(http.StatusOK) 192 | return 193 | } 194 | 195 | // Parse out path components from request. 196 | dist, err := oci.ParseDistributionPath(req.URL) 197 | if err != nil { 198 | rw.WriteError(http.StatusNotFound, fmt.Errorf("could not parse path according to OCI distribution spec: %w", err)) 199 | return 200 | } 201 | 202 | // Request with mirror header are proxied. 203 | if req.Header.Get(HeaderSpegelMirrored) != "true" { 204 | // Set mirrored header in request to stop infinite loops 205 | req.Header.Set(HeaderSpegelMirrored, "true") 206 | 207 | // If content is present locally we should skip the mirroring and just serve it. 208 | var ociErr error 209 | if dist.Digest == "" { 210 | _, ociErr = r.ociStore.Resolve(req.Context(), dist.Reference()) 211 | } else { 212 | _, ociErr = r.ociStore.Size(req.Context(), dist.Digest) 213 | } 214 | if ociErr != nil { 215 | rw.SetHandler("mirror") 216 | r.handleMirror(rw, req, dist) 217 | return 218 | } 219 | } 220 | 221 | // Serve registry endpoints. 222 | switch dist.Kind { 223 | case oci.DistributionKindManifest: 224 | rw.SetHandler("manifest") 225 | r.handleManifest(rw, req, dist) 226 | return 227 | case oci.DistributionKindBlob: 228 | rw.SetHandler("blob") 229 | r.handleBlob(rw, req, dist) 230 | return 231 | default: 232 | rw.WriteError(http.StatusNotFound, fmt.Errorf("unknown distribution path kind %s", dist.Kind)) 233 | return 234 | } 235 | } 236 | 237 | func (r *Registry) handleMirror(rw httpx.ResponseWriter, req *http.Request, dist oci.DistributionPath) { 238 | log := r.log.WithValues("ref", dist.Reference(), "path", req.URL.Path) 239 | 240 | defer func() { 241 | cacheType := "hit" 242 | if rw.Status() != http.StatusOK { 243 | cacheType = "miss" 244 | } 245 | metrics.MirrorRequestsTotal.WithLabelValues(dist.Registry, cacheType).Inc() 246 | }() 247 | 248 | if !r.resolveLatestTag && dist.IsLatestTag() { 249 | r.log.V(4).Info("skipping mirror request for image with latest tag", "image", dist.Reference()) 250 | rw.WriteHeader(http.StatusNotFound) 251 | return 252 | } 253 | 254 | // Resolve mirror with the requested reference 255 | resolveCtx, cancel := context.WithTimeout(req.Context(), r.resolveTimeout) 256 | defer cancel() 257 | resolveCtx = logr.NewContext(resolveCtx, log) 258 | peerCh, err := r.router.Resolve(resolveCtx, dist.Reference(), r.resolveRetries) 259 | if err != nil { 260 | rw.WriteError(http.StatusInternalServerError, fmt.Errorf("error occurred when attempting to resolve mirrors: %w", err)) 261 | return 262 | } 263 | 264 | mirrorAttempts := 0 265 | for { 266 | select { 267 | case <-req.Context().Done(): 268 | // Request has been closed by server or client. No use continuing. 269 | rw.WriteError(http.StatusNotFound, fmt.Errorf("mirroring for image component %s has been cancelled: %w", dist.Reference(), resolveCtx.Err())) 270 | return 271 | case peer, ok := <-peerCh: 272 | // Channel closed means no more mirrors will be received and max retries has been reached. 273 | if !ok { 274 | err = fmt.Errorf("mirror with image component %s could not be found", dist.Reference()) 275 | if mirrorAttempts > 0 { 276 | err = errors.Join(err, fmt.Errorf("requests to %d mirrors failed, all attempts have been exhausted or timeout has been reached", mirrorAttempts)) 277 | } 278 | rw.WriteError(http.StatusNotFound, err) 279 | return 280 | } 281 | 282 | mirrorAttempts++ 283 | 284 | err := forwardRequest(r.client, r.bufferPool, req, rw, peer) 285 | if err != nil { 286 | log.Error(err, "request to mirror failed", "attempt", mirrorAttempts, "path", req.URL.Path, "mirror", peer) 287 | continue 288 | } 289 | log.V(4).Info("mirrored request", "path", req.URL.Path, "mirror", peer) 290 | return 291 | } 292 | } 293 | } 294 | 295 | func (r *Registry) handleManifest(rw httpx.ResponseWriter, req *http.Request, dist oci.DistributionPath) { 296 | if dist.Digest == "" { 297 | dgst, err := r.ociStore.Resolve(req.Context(), dist.Reference()) 298 | if err != nil { 299 | rw.WriteError(http.StatusNotFound, fmt.Errorf("could not get digest for image %s: %w", dist.Reference(), err)) 300 | return 301 | } 302 | dist.Digest = dgst 303 | } 304 | b, mediaType, err := r.ociStore.GetManifest(req.Context(), dist.Digest) 305 | if err != nil { 306 | rw.WriteError(http.StatusNotFound, fmt.Errorf("could not get manifest content for digest %s: %w", dist.Digest.String(), err)) 307 | return 308 | } 309 | rw.Header().Set(httpx.HeaderContentType, mediaType) 310 | rw.Header().Set(httpx.HeaderContentLength, strconv.FormatInt(int64(len(b)), 10)) 311 | rw.Header().Set(oci.HeaderDockerDigest, dist.Digest.String()) 312 | if req.Method == http.MethodHead { 313 | return 314 | } 315 | _, err = rw.Write(b) 316 | if err != nil { 317 | r.log.Error(err, "error occurred when writing manifest") 318 | return 319 | } 320 | } 321 | 322 | func (r *Registry) handleBlob(rw httpx.ResponseWriter, req *http.Request, dist oci.DistributionPath) { 323 | size, err := r.ociStore.Size(req.Context(), dist.Digest) 324 | if err != nil { 325 | rw.WriteError(http.StatusInternalServerError, fmt.Errorf("could not determine size of blob with digest %s: %w", dist.Digest.String(), err)) 326 | return 327 | } 328 | rw.Header().Set(httpx.HeaderAcceptRanges, "bytes") 329 | rw.Header().Set(httpx.HeaderContentType, "application/octet-stream") 330 | rw.Header().Set(httpx.HeaderContentLength, strconv.FormatInt(size, 10)) 331 | rw.Header().Set(oci.HeaderDockerDigest, dist.Digest.String()) 332 | if req.Method == http.MethodHead { 333 | return 334 | } 335 | 336 | rc, err := r.ociStore.GetBlob(req.Context(), dist.Digest) 337 | if err != nil { 338 | rw.WriteError(http.StatusInternalServerError, fmt.Errorf("could not get reader for blob with digest %s: %w", dist.Digest.String(), err)) 339 | return 340 | } 341 | defer rc.Close() 342 | 343 | http.ServeContent(rw, req, "", time.Time{}, rc) 344 | } 345 | 346 | func forwardRequest(client *http.Client, bufferPool *sync.Pool, req *http.Request, rw http.ResponseWriter, addrPort netip.AddrPort) error { 347 | // Do request to mirror. 348 | forwardScheme := "http" 349 | if req.TLS != nil { 350 | forwardScheme = "https" 351 | } 352 | u := &url.URL{ 353 | Scheme: forwardScheme, 354 | Host: addrPort.String(), 355 | Path: req.URL.Path, 356 | RawQuery: req.URL.RawQuery, 357 | } 358 | forwardReq, err := http.NewRequestWithContext(req.Context(), req.Method, u.String(), nil) 359 | if err != nil { 360 | return err 361 | } 362 | copyHeader(forwardReq.Header, req.Header) 363 | forwardResp, err := client.Do(forwardReq) 364 | if err != nil { 365 | return err 366 | } 367 | defer forwardResp.Body.Close() 368 | 369 | // Clear body and try next if non 200 response. 370 | //nolint:staticcheck // Keep things readable. 371 | if !(forwardResp.StatusCode == http.StatusOK || forwardResp.StatusCode == http.StatusPartialContent) { 372 | _, err = io.Copy(io.Discard, forwardResp.Body) 373 | if err != nil { 374 | return err 375 | } 376 | return fmt.Errorf("expected mirror to respond with 200 OK but received: %s", forwardResp.Status) 377 | } 378 | 379 | // TODO (phillebaba): Is it possible to retry if copy fails half way through? 380 | // Copy forward response to response writer. 381 | copyHeader(rw.Header(), forwardResp.Header) 382 | rw.WriteHeader(http.StatusOK) 383 | //nolint: errcheck // Ignore 384 | buf := bufferPool.Get().(*[]byte) 385 | defer bufferPool.Put(buf) 386 | _, err = io.CopyBuffer(rw, forwardResp.Body, *buf) 387 | if err != nil { 388 | return err 389 | } 390 | return nil 391 | } 392 | 393 | func copyHeader(dst, src http.Header) { 394 | for k, vv := range src { 395 | for _, v := range vv { 396 | dst.Add(k, v) 397 | } 398 | } 399 | } 400 | -------------------------------------------------------------------------------- /pkg/registry/registry_test.go: -------------------------------------------------------------------------------- 1 | package registry 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "net/http/httptest" 8 | "net/netip" 9 | "testing" 10 | "time" 11 | 12 | "github.com/go-logr/logr" 13 | "github.com/stretchr/testify/require" 14 | 15 | "github.com/spegel-org/spegel/pkg/oci" 16 | "github.com/spegel-org/spegel/pkg/routing" 17 | ) 18 | 19 | func TestRegistryOptions(t *testing.T) { 20 | t.Parallel() 21 | 22 | transport := &http.Transport{} 23 | log := logr.Discard() 24 | opts := []RegistryOption{ 25 | WithResolveRetries(5), 26 | WithResolveLatestTag(true), 27 | WithResolveTimeout(10 * time.Minute), 28 | WithTransport(transport), 29 | WithLogger(log), 30 | WithBasicAuth("foo", "bar"), 31 | } 32 | cfg := RegistryConfig{} 33 | err := cfg.Apply(opts...) 34 | require.NoError(t, err) 35 | require.Equal(t, 5, cfg.ResolveRetries) 36 | require.True(t, cfg.ResolveLatestTag) 37 | require.Equal(t, 10*time.Minute, cfg.ResolveTimeout) 38 | require.Equal(t, transport, cfg.Client.Transport) 39 | require.Equal(t, log, cfg.Log) 40 | require.Equal(t, "foo", cfg.Username) 41 | require.Equal(t, "bar", cfg.Password) 42 | } 43 | 44 | func TestReadyHandler(t *testing.T) { 45 | t.Parallel() 46 | 47 | router := routing.NewMemoryRouter(map[string][]netip.AddrPort{}, netip.MustParseAddrPort("127.0.0.1:8080")) 48 | reg, err := NewRegistry(nil, router) 49 | require.NoError(t, err) 50 | srv, err := reg.Server("") 51 | require.NoError(t, err) 52 | 53 | rw := httptest.NewRecorder() 54 | req := httptest.NewRequest(http.MethodGet, "http://localhost/healthz", nil) 55 | srv.Handler.ServeHTTP(rw, req) 56 | require.Equal(t, http.StatusInternalServerError, rw.Result().StatusCode) 57 | 58 | router.Add("foo", netip.MustParseAddrPort("127.0.0.1:9090")) 59 | rw = httptest.NewRecorder() 60 | req = httptest.NewRequest(http.MethodGet, "http://localhost/healthz", nil) 61 | srv.Handler.ServeHTTP(rw, req) 62 | require.Equal(t, http.StatusOK, rw.Result().StatusCode) 63 | } 64 | 65 | func TestBasicAuth(t *testing.T) { 66 | t.Parallel() 67 | 68 | tests := []struct { 69 | name string 70 | username string 71 | password string 72 | reqUsername string 73 | reqPassword string 74 | expected int 75 | }{ 76 | { 77 | name: "no registry authentication", 78 | expected: http.StatusOK, 79 | }, 80 | { 81 | name: "unnecessary authentication", 82 | reqUsername: "foo", 83 | reqPassword: "bar", 84 | expected: http.StatusOK, 85 | }, 86 | { 87 | name: "correct authentication", 88 | username: "foo", 89 | password: "bar", 90 | reqUsername: "foo", 91 | reqPassword: "bar", 92 | expected: http.StatusOK, 93 | }, 94 | { 95 | name: "invalid username", 96 | username: "foo", 97 | password: "bar", 98 | reqUsername: "wrong", 99 | reqPassword: "bar", 100 | expected: http.StatusUnauthorized, 101 | }, 102 | { 103 | name: "invalid password", 104 | username: "foo", 105 | password: "bar", 106 | reqUsername: "foo", 107 | reqPassword: "wrong", 108 | expected: http.StatusUnauthorized, 109 | }, 110 | { 111 | name: "missing authentication", 112 | username: "foo", 113 | password: "bar", 114 | expected: http.StatusUnauthorized, 115 | }, 116 | { 117 | name: "missing authentication", 118 | username: "foo", 119 | password: "bar", 120 | expected: http.StatusUnauthorized, 121 | }, 122 | } 123 | for _, tt := range tests { 124 | t.Run(tt.name, func(t *testing.T) { 125 | t.Parallel() 126 | 127 | reg, err := NewRegistry(nil, nil, WithBasicAuth(tt.username, tt.password)) 128 | require.NoError(t, err) 129 | rw := httptest.NewRecorder() 130 | req := httptest.NewRequest(http.MethodGet, "http://localhost/v2/", nil) 131 | req.SetBasicAuth(tt.reqUsername, tt.reqPassword) 132 | srv, err := reg.Server("") 133 | require.NoError(t, err) 134 | srv.Handler.ServeHTTP(rw, req) 135 | 136 | require.Equal(t, tt.expected, rw.Result().StatusCode) 137 | }) 138 | } 139 | } 140 | 141 | func TestMirrorHandler(t *testing.T) { 142 | t.Parallel() 143 | 144 | badSvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 145 | w.WriteHeader(http.StatusInternalServerError) 146 | w.Header().Set("foo", "bar") 147 | if r.Method == http.MethodGet { 148 | //nolint:errcheck // ignore 149 | w.Write([]byte("hello world")) 150 | } 151 | })) 152 | t.Cleanup(func() { 153 | badSvr.Close() 154 | }) 155 | badAddrPort := netip.MustParseAddrPort(badSvr.Listener.Addr().String()) 156 | goodSvr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 157 | w.Header().Set("foo", "bar") 158 | if r.Method == http.MethodGet { 159 | //nolint:errcheck // ignore 160 | w.Write([]byte("hello world")) 161 | } 162 | })) 163 | t.Cleanup(func() { 164 | goodSvr.Close() 165 | }) 166 | goodAddrPort := netip.MustParseAddrPort(goodSvr.Listener.Addr().String()) 167 | unreachableAddrPort := netip.MustParseAddrPort("127.0.0.1:0") 168 | 169 | resolver := map[string][]netip.AddrPort{ 170 | // No working peers 171 | "sha256:c3e30fbcf3b231356a1efbd30a8ccec75134a7a8b45217ede97f4ff483540b04": {badAddrPort, unreachableAddrPort, badAddrPort}, 172 | // First Peer 173 | "sha256:3b8a55c543ccc7ae01c47b1d35af5826a6439a9b91ab0ca96de9967759279896": {goodAddrPort, badAddrPort, badAddrPort}, 174 | // First peer error 175 | "sha256:a0daab85ec30e2809a38c32fa676515aba22f481c56fda28637ae964ff398e3d": {unreachableAddrPort, goodAddrPort}, 176 | // Last peer working 177 | "sha256:11242d2a347bf8ab30b9f92d5ca219bbbedf95df5a8b74631194561497c1fae8": {badAddrPort, badAddrPort, goodAddrPort}, 178 | } 179 | router := routing.NewMemoryRouter(resolver, netip.AddrPort{}) 180 | reg, err := NewRegistry(oci.NewMemory(), router) 181 | require.NoError(t, err) 182 | 183 | tests := []struct { 184 | expectedHeaders map[string][]string 185 | name string 186 | key string 187 | expectedBody string 188 | expectedStatus int 189 | }{ 190 | { 191 | name: "request should timeout when no peers exists", 192 | key: "no-peers", 193 | expectedStatus: http.StatusNotFound, 194 | expectedBody: "", 195 | expectedHeaders: nil, 196 | }, 197 | { 198 | name: "request should not timeout and give 404 if all peers fail", 199 | key: "sha256:c3e30fbcf3b231356a1efbd30a8ccec75134a7a8b45217ede97f4ff483540b04", 200 | expectedStatus: http.StatusNotFound, 201 | expectedBody: "", 202 | expectedHeaders: nil, 203 | }, 204 | { 205 | name: "request should work when first peer responds", 206 | key: "sha256:3b8a55c543ccc7ae01c47b1d35af5826a6439a9b91ab0ca96de9967759279896", 207 | expectedStatus: http.StatusOK, 208 | expectedBody: "hello world", 209 | expectedHeaders: map[string][]string{"foo": {"bar"}}, 210 | }, 211 | { 212 | name: "second peer should respond when first gives error", 213 | key: "sha256:a0daab85ec30e2809a38c32fa676515aba22f481c56fda28637ae964ff398e3d", 214 | expectedStatus: http.StatusOK, 215 | expectedBody: "hello world", 216 | expectedHeaders: map[string][]string{"foo": {"bar"}}, 217 | }, 218 | { 219 | name: "last peer should respond when two first fail", 220 | key: "sha256:11242d2a347bf8ab30b9f92d5ca219bbbedf95df5a8b74631194561497c1fae8", 221 | expectedStatus: http.StatusOK, 222 | expectedBody: "hello world", 223 | expectedHeaders: map[string][]string{"foo": {"bar"}}, 224 | }, 225 | } 226 | for _, tt := range tests { 227 | for _, method := range []string{http.MethodGet, http.MethodHead} { 228 | t.Run(fmt.Sprintf("%s-%s", method, tt.name), func(t *testing.T) { 229 | t.Parallel() 230 | 231 | target := fmt.Sprintf("http://example.com/v2/foo/bar/blobs/%s", tt.key) 232 | rw := httptest.NewRecorder() 233 | req := httptest.NewRequest(method, target, nil) 234 | srv, err := reg.Server("") 235 | require.NoError(t, err) 236 | srv.Handler.ServeHTTP(rw, req) 237 | 238 | resp := rw.Result() 239 | defer resp.Body.Close() 240 | b, err := io.ReadAll(resp.Body) 241 | require.NoError(t, err) 242 | require.Equal(t, tt.expectedStatus, resp.StatusCode) 243 | 244 | if method == http.MethodGet { 245 | require.Equal(t, tt.expectedBody, string(b)) 246 | } 247 | if method == http.MethodHead { 248 | require.Empty(t, b) 249 | } 250 | 251 | if tt.expectedHeaders == nil { 252 | require.Empty(t, resp.Header) 253 | } 254 | for k, v := range tt.expectedHeaders { 255 | require.Equal(t, v, resp.Header.Values(k)) 256 | } 257 | }) 258 | } 259 | } 260 | } 261 | 262 | func TestCopyHeader(t *testing.T) { 263 | t.Parallel() 264 | 265 | src := http.Header{ 266 | "foo": []string{"2", "1"}, 267 | } 268 | dst := http.Header{} 269 | copyHeader(dst, src) 270 | 271 | require.Equal(t, []string{"2", "1"}, dst.Values("foo")) 272 | } 273 | -------------------------------------------------------------------------------- /pkg/routing/bootstrap.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "io" 7 | "net" 8 | "net/http" 9 | "slices" 10 | "strings" 11 | "sync" 12 | "time" 13 | 14 | "golang.org/x/sync/errgroup" 15 | 16 | "github.com/libp2p/go-libp2p/core/peer" 17 | ma "github.com/multiformats/go-multiaddr" 18 | manet "github.com/multiformats/go-multiaddr/net" 19 | ) 20 | 21 | // Bootstrapper resolves peers to bootstrap with for the P2P router. 22 | type Bootstrapper interface { 23 | // Run starts the bootstrap process. Should be blocking even if not needed. 24 | Run(ctx context.Context, id string) error 25 | // Get returns a list of peers that should be used as bootstrap nodes. 26 | // If the peer ID is empty it will be resolved. 27 | // If the address is missing a port the P2P router port will be used. 28 | Get(ctx context.Context) ([]peer.AddrInfo, error) 29 | } 30 | 31 | var _ Bootstrapper = &StaticBootstrapper{} 32 | 33 | type StaticBootstrapper struct { 34 | peers []peer.AddrInfo 35 | mx sync.RWMutex 36 | } 37 | 38 | func NewStaticBootstrapperFromStrings(peerStrs []string) (*StaticBootstrapper, error) { 39 | peers := []peer.AddrInfo{} 40 | for _, peerStr := range peerStrs { 41 | peer, err := peer.AddrInfoFromString(peerStr) 42 | if err != nil { 43 | return nil, err 44 | } 45 | peers = append(peers, *peer) 46 | } 47 | return NewStaticBootstrapper(peers), nil 48 | } 49 | 50 | func NewStaticBootstrapper(peers []peer.AddrInfo) *StaticBootstrapper { 51 | return &StaticBootstrapper{ 52 | peers: peers, 53 | } 54 | } 55 | 56 | func (b *StaticBootstrapper) Run(ctx context.Context, id string) error { 57 | <-ctx.Done() 58 | return nil 59 | } 60 | 61 | func (b *StaticBootstrapper) Get(ctx context.Context) ([]peer.AddrInfo, error) { 62 | b.mx.RLock() 63 | defer b.mx.RUnlock() 64 | return b.peers, nil 65 | } 66 | 67 | func (b *StaticBootstrapper) SetPeers(peers []peer.AddrInfo) { 68 | b.mx.Lock() 69 | defer b.mx.Unlock() 70 | b.peers = peers 71 | } 72 | 73 | var _ Bootstrapper = &DNSBootstrapper{} 74 | 75 | type DNSBootstrapper struct { 76 | resolver *net.Resolver 77 | host string 78 | limit int 79 | } 80 | 81 | func NewDNSBootstrapper(host string, limit int) *DNSBootstrapper { 82 | return &DNSBootstrapper{ 83 | resolver: &net.Resolver{}, 84 | host: host, 85 | limit: limit, 86 | } 87 | } 88 | 89 | func (b *DNSBootstrapper) Run(ctx context.Context, id string) error { 90 | <-ctx.Done() 91 | return nil 92 | } 93 | 94 | func (b *DNSBootstrapper) Get(ctx context.Context) ([]peer.AddrInfo, error) { 95 | ips, err := b.resolver.LookupIPAddr(ctx, b.host) 96 | if err != nil { 97 | return nil, err 98 | } 99 | if len(ips) == 0 { 100 | return nil, err 101 | } 102 | slices.SortFunc(ips, func(a, b net.IPAddr) int { 103 | return strings.Compare(a.String(), b.String()) 104 | }) 105 | addrInfos := []peer.AddrInfo{} 106 | for _, ip := range ips { 107 | addr, err := manet.FromIPAndZone(ip.IP, ip.Zone) 108 | if err != nil { 109 | return nil, err 110 | } 111 | addrInfos = append(addrInfos, peer.AddrInfo{ 112 | ID: "", 113 | Addrs: []ma.Multiaddr{addr}, 114 | }) 115 | } 116 | limit := min(len(addrInfos), b.limit) 117 | return addrInfos[:limit], nil 118 | } 119 | 120 | var _ Bootstrapper = &HTTPBootstrapper{} 121 | 122 | type HTTPBootstrapper struct { 123 | addr string 124 | peer string 125 | } 126 | 127 | func NewHTTPBootstrapper(addr, peer string) *HTTPBootstrapper { 128 | return &HTTPBootstrapper{ 129 | addr: addr, 130 | peer: peer, 131 | } 132 | } 133 | 134 | func (bs *HTTPBootstrapper) Run(ctx context.Context, id string) error { 135 | g, ctx := errgroup.WithContext(ctx) 136 | mux := http.NewServeMux() 137 | mux.HandleFunc("/id", func(w http.ResponseWriter, r *http.Request) { 138 | w.WriteHeader(http.StatusOK) 139 | //nolint:errcheck // ignore 140 | w.Write([]byte(id)) 141 | }) 142 | srv := http.Server{ 143 | Addr: bs.addr, 144 | Handler: mux, 145 | } 146 | g.Go(func() error { 147 | if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { 148 | return err 149 | } 150 | return nil 151 | }) 152 | g.Go(func() error { 153 | <-ctx.Done() 154 | shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second) 155 | defer cancel() 156 | return srv.Shutdown(shutdownCtx) 157 | }) 158 | return g.Wait() 159 | } 160 | 161 | func (bs *HTTPBootstrapper) Get(ctx context.Context) ([]peer.AddrInfo, error) { 162 | resp, err := http.DefaultClient.Get(bs.peer) 163 | if err != nil { 164 | return nil, err 165 | } 166 | defer resp.Body.Close() 167 | b, err := io.ReadAll(resp.Body) 168 | if err != nil { 169 | return nil, err 170 | } 171 | addr, err := ma.NewMultiaddr(string(b)) 172 | if err != nil { 173 | return nil, err 174 | } 175 | addrInfo, err := peer.AddrInfoFromP2pAddr(addr) 176 | if err != nil { 177 | return nil, err 178 | } 179 | return []peer.AddrInfo{*addrInfo}, nil 180 | } 181 | -------------------------------------------------------------------------------- /pkg/routing/bootstrap_test.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | 9 | "golang.org/x/sync/errgroup" 10 | 11 | "github.com/libp2p/go-libp2p/core/peer" 12 | ma "github.com/multiformats/go-multiaddr" 13 | manet "github.com/multiformats/go-multiaddr/net" 14 | "github.com/stretchr/testify/require" 15 | ) 16 | 17 | func TestStaticBootstrap(t *testing.T) { 18 | t.Parallel() 19 | 20 | peers := []peer.AddrInfo{ 21 | { 22 | ID: "foo", 23 | Addrs: []ma.Multiaddr{ma.StringCast("/ip4/192.168.1.1")}, 24 | }, 25 | { 26 | ID: "bar", 27 | Addrs: []ma.Multiaddr{manet.IP6Loopback}, 28 | }, 29 | } 30 | bs := NewStaticBootstrapper(peers) 31 | 32 | ctx, cancel := context.WithCancel(t.Context()) 33 | g, gCtx := errgroup.WithContext(ctx) 34 | g.Go(func() error { 35 | return bs.Run(gCtx, "") 36 | }) 37 | 38 | bsPeers, err := bs.Get(t.Context()) 39 | require.NoError(t, err) 40 | require.ElementsMatch(t, peers, bsPeers) 41 | 42 | cancel() 43 | err = g.Wait() 44 | require.NoError(t, err) 45 | } 46 | 47 | func TestHTTPBootstrap(t *testing.T) { 48 | t.Parallel() 49 | 50 | id := "/ip4/104.131.131.82/tcp/4001/ipfs/QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ" 51 | svr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 52 | //nolint:errcheck // ignore 53 | w.Write([]byte(id)) 54 | })) 55 | defer svr.Close() 56 | 57 | bs := NewHTTPBootstrapper(":", svr.URL) 58 | 59 | ctx, cancel := context.WithCancel(t.Context()) 60 | g, gCtx := errgroup.WithContext(ctx) 61 | g.Go(func() error { 62 | return bs.Run(gCtx, "") 63 | }) 64 | 65 | addrInfos, err := bs.Get(t.Context()) 66 | require.NoError(t, err) 67 | require.Len(t, addrInfos, 1) 68 | addrInfo := addrInfos[0] 69 | require.Len(t, addrInfo.Addrs, 1) 70 | require.Equal(t, "/ip4/104.131.131.82/tcp/4001", addrInfo.Addrs[0].String()) 71 | require.Equal(t, "QmaCpDMGvV2BGHeYERUEnRQAwe3N8SzbUtfsmvsqQLuvuJ", addrInfo.ID.String()) 72 | 73 | cancel() 74 | err = g.Wait() 75 | require.NoError(t, err) 76 | } 77 | -------------------------------------------------------------------------------- /pkg/routing/memory.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import ( 4 | "context" 5 | "net/netip" 6 | "slices" 7 | "sync" 8 | ) 9 | 10 | var _ Router = &MemoryRouter{} 11 | 12 | type MemoryRouter struct { 13 | resolver map[string][]netip.AddrPort 14 | self netip.AddrPort 15 | mx sync.RWMutex 16 | } 17 | 18 | func NewMemoryRouter(resolver map[string][]netip.AddrPort, self netip.AddrPort) *MemoryRouter { 19 | return &MemoryRouter{ 20 | resolver: resolver, 21 | self: self, 22 | } 23 | } 24 | 25 | func (m *MemoryRouter) Ready(ctx context.Context) (bool, error) { 26 | m.mx.RLock() 27 | defer m.mx.RUnlock() 28 | 29 | return len(m.resolver) > 0, nil 30 | } 31 | 32 | func (m *MemoryRouter) Resolve(ctx context.Context, key string, count int) (<-chan netip.AddrPort, error) { 33 | m.mx.RLock() 34 | peers, ok := m.resolver[key] 35 | m.mx.RUnlock() 36 | 37 | peerCh := make(chan netip.AddrPort, count) 38 | // If no peers exist close the channel to stop any consumer. 39 | if !ok { 40 | close(peerCh) 41 | return peerCh, nil 42 | } 43 | go func() { 44 | for _, peer := range peers { 45 | peerCh <- peer 46 | } 47 | close(peerCh) 48 | }() 49 | return peerCh, nil 50 | } 51 | 52 | func (m *MemoryRouter) Advertise(ctx context.Context, keys []string) error { 53 | for _, key := range keys { 54 | m.Add(key, m.self) 55 | } 56 | return nil 57 | } 58 | 59 | func (m *MemoryRouter) Add(key string, ap netip.AddrPort) { 60 | m.mx.Lock() 61 | defer m.mx.Unlock() 62 | 63 | v, ok := m.resolver[key] 64 | if !ok { 65 | m.resolver[key] = []netip.AddrPort{ap} 66 | return 67 | } 68 | if slices.Contains(v, ap) { 69 | return 70 | } 71 | m.resolver[key] = append(v, ap) 72 | } 73 | 74 | func (m *MemoryRouter) Lookup(key string) ([]netip.AddrPort, bool) { 75 | m.mx.RLock() 76 | defer m.mx.RUnlock() 77 | 78 | v, ok := m.resolver[key] 79 | return v, ok 80 | } 81 | -------------------------------------------------------------------------------- /pkg/routing/memory_test.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import ( 4 | "net/netip" 5 | "testing" 6 | "time" 7 | 8 | "github.com/stretchr/testify/require" 9 | ) 10 | 11 | func TestMemoryRouter(t *testing.T) { 12 | t.Parallel() 13 | 14 | r := NewMemoryRouter(map[string][]netip.AddrPort{}, netip.AddrPort{}) 15 | 16 | isReady, err := r.Ready(t.Context()) 17 | require.NoError(t, err) 18 | require.False(t, isReady) 19 | err = r.Advertise(t.Context(), []string{"foo"}) 20 | require.NoError(t, err) 21 | isReady, err = r.Ready(t.Context()) 22 | require.NoError(t, err) 23 | require.True(t, isReady) 24 | 25 | r.Add("foo", netip.MustParseAddrPort("127.0.0.1:9090")) 26 | peerCh, err := r.Resolve(t.Context(), "foo", 2) 27 | require.NoError(t, err) 28 | peers := []netip.AddrPort{} 29 | for peer := range peerCh { 30 | peers = append(peers, peer) 31 | } 32 | require.Len(t, peers, 2) 33 | peers, ok := r.Lookup("foo") 34 | require.True(t, ok) 35 | require.Len(t, peers, 2) 36 | 37 | peerCh, err = r.Resolve(t.Context(), "bar", 1) 38 | require.NoError(t, err) 39 | time.Sleep(1 * time.Second) 40 | select { 41 | case <-peerCh: 42 | default: 43 | t.Error("expected peer channel to be closed") 44 | } 45 | _, ok = r.Lookup("bar") 46 | require.False(t, ok) 47 | } 48 | -------------------------------------------------------------------------------- /pkg/routing/p2p_test.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | "time" 8 | 9 | "github.com/go-logr/logr" 10 | tlog "github.com/go-logr/logr/testing" 11 | "github.com/libp2p/go-libp2p" 12 | "github.com/libp2p/go-libp2p/core/host" 13 | "github.com/libp2p/go-libp2p/core/peer" 14 | mocknet "github.com/libp2p/go-libp2p/p2p/net/mock" 15 | ma "github.com/multiformats/go-multiaddr" 16 | "github.com/stretchr/testify/require" 17 | "golang.org/x/sync/errgroup" 18 | ) 19 | 20 | func TestP2PRouterOptions(t *testing.T) { 21 | t.Parallel() 22 | 23 | libp2pOpts := []libp2p.Option{ 24 | libp2p.ListenAddrStrings("foo"), 25 | } 26 | opts := []P2PRouterOption{ 27 | WithLibP2POptions(libp2pOpts...), 28 | WithDataDir("foobar"), 29 | } 30 | cfg := P2PRouterConfig{} 31 | err := cfg.Apply(opts...) 32 | require.NoError(t, err) 33 | require.Equal(t, libp2pOpts, cfg.Libp2pOpts) 34 | require.Equal(t, "foobar", cfg.DataDir) 35 | } 36 | 37 | func TestP2PRouter(t *testing.T) { 38 | t.Parallel() 39 | 40 | ctx, cancel := context.WithCancel(t.Context()) 41 | 42 | bs := NewStaticBootstrapper(nil) 43 | router, err := NewP2PRouter(ctx, "localhost:0", bs, "9090") 44 | require.NoError(t, err) 45 | 46 | g, gCtx := errgroup.WithContext(ctx) 47 | g.Go(func() error { 48 | return router.Run(gCtx) 49 | }) 50 | 51 | // TODO (phillebaba): There is a test flake that sometime occurs sometimes if code runs too fast. 52 | // Flake results in a peer being returned without an address. Revisit in Go 1.24 to see if this can be solved better. 53 | time.Sleep(1 * time.Second) 54 | 55 | err = router.Advertise(ctx, nil) 56 | require.NoError(t, err) 57 | peerCh, err := router.Resolve(ctx, "foo", 1) 58 | require.NoError(t, err) 59 | peer := <-peerCh 60 | require.False(t, peer.IsValid()) 61 | 62 | err = router.Advertise(ctx, []string{"foo"}) 63 | require.NoError(t, err) 64 | peerCh, err = router.Resolve(ctx, "foo", 1) 65 | require.NoError(t, err) 66 | peer = <-peerCh 67 | require.True(t, peer.IsValid()) 68 | 69 | cancel() 70 | err = g.Wait() 71 | require.NoError(t, err) 72 | } 73 | 74 | func TestReady(t *testing.T) { 75 | t.Parallel() 76 | 77 | bs := NewStaticBootstrapper(nil) 78 | router, err := NewP2PRouter(t.Context(), "localhost:0", bs, "9090") 79 | require.NoError(t, err) 80 | 81 | // Should not be ready if no peers are found. 82 | isReady, err := router.Ready(t.Context()) 83 | require.NoError(t, err) 84 | require.False(t, isReady) 85 | 86 | // Should be ready if only peer is host. 87 | bs.SetPeers([]peer.AddrInfo{*host.InfoFromHost(router.host)}) 88 | isReady, err = router.Ready(t.Context()) 89 | require.NoError(t, err) 90 | require.True(t, isReady) 91 | 92 | // Shouldd be not ready with multiple peers but empty routing table. 93 | bs.SetPeers([]peer.AddrInfo{{}, {}}) 94 | isReady, err = router.Ready(t.Context()) 95 | require.NoError(t, err) 96 | require.False(t, isReady) 97 | 98 | // Should be ready with multiple peers and populated routing table. 99 | newPeer, err := router.kdht.RoutingTable().GenRandPeerID(0) 100 | require.NoError(t, err) 101 | ok, err := router.kdht.RoutingTable().TryAddPeer(newPeer, false, false) 102 | require.NoError(t, err) 103 | require.True(t, ok) 104 | bs.SetPeers([]peer.AddrInfo{{}, {}}) 105 | isReady, err = router.Ready(t.Context()) 106 | require.NoError(t, err) 107 | require.True(t, isReady) 108 | } 109 | 110 | func TestBootstrapFunc(t *testing.T) { 111 | t.Parallel() 112 | 113 | log := tlog.NewTestLogger(t) 114 | ctx := logr.NewContext(t.Context(), log) 115 | 116 | mn, err := mocknet.WithNPeers(2) 117 | require.NoError(t, err) 118 | 119 | tests := []struct { 120 | name string 121 | peers []peer.AddrInfo 122 | expected []string 123 | }{ 124 | { 125 | name: "no peers", 126 | peers: []peer.AddrInfo{}, 127 | expected: []string{}, 128 | }, 129 | { 130 | name: "nothing missing", 131 | peers: []peer.AddrInfo{ 132 | { 133 | ID: "foo", 134 | Addrs: []ma.Multiaddr{ma.StringCast("/ip4/192.168.1.1/tcp/8080")}, 135 | }, 136 | }, 137 | expected: []string{"/ip4/192.168.1.1/tcp/8080/p2p/foo"}, 138 | }, 139 | { 140 | name: "only self", 141 | peers: []peer.AddrInfo{ 142 | { 143 | ID: mn.Hosts()[0].ID(), 144 | Addrs: []ma.Multiaddr{ma.StringCast("/ip4/192.168.1.1/tcp/8080")}, 145 | }, 146 | }, 147 | expected: []string{}, 148 | }, 149 | { 150 | name: "missing port", 151 | peers: []peer.AddrInfo{ 152 | { 153 | ID: "foo", 154 | Addrs: []ma.Multiaddr{ma.StringCast("/ip4/192.168.1.1")}, 155 | }, 156 | }, 157 | expected: []string{"/ip4/192.168.1.1/tcp/4242/p2p/foo"}, 158 | }, 159 | } 160 | for _, tt := range tests { 161 | t.Run(tt.name, func(t *testing.T) { 162 | t.Parallel() 163 | 164 | bs := NewStaticBootstrapper(tt.peers) 165 | f := bootstrapFunc(ctx, bs, mn.Hosts()[0]) 166 | peers := f() 167 | 168 | peerStrs := []string{} 169 | for _, p := range peers { 170 | id, err := p.ID.Marshal() 171 | require.NoError(t, err) 172 | peerStrs = append(peerStrs, fmt.Sprintf("%s/p2p/%s", p.Addrs[0].String(), string(id))) 173 | } 174 | require.ElementsMatch(t, tt.expected, peerStrs) 175 | }) 176 | } 177 | } 178 | 179 | func TestListenMultiaddrs(t *testing.T) { 180 | t.Parallel() 181 | 182 | tests := []struct { 183 | name string 184 | addr string 185 | expected []string 186 | }{ 187 | { 188 | name: "listen address type not specified", 189 | addr: ":9090", 190 | expected: []string{"/ip6/::/tcp/9090", "/ip4/0.0.0.0/tcp/9090"}, 191 | }, 192 | { 193 | name: "ipv4 only", 194 | addr: "0.0.0.0:9090", 195 | expected: []string{"/ip4/0.0.0.0/tcp/9090"}, 196 | }, 197 | { 198 | name: "ipv6 only", 199 | addr: "[::]:9090", 200 | expected: []string{"/ip6/::/tcp/9090"}, 201 | }, 202 | } 203 | for _, tt := range tests { 204 | t.Run(tt.name, func(t *testing.T) { 205 | t.Parallel() 206 | 207 | multiAddrs, err := listenMultiaddrs(tt.addr) 208 | require.NoError(t, err) 209 | //nolint: testifylint // This is easier to read and understand. 210 | require.Equal(t, len(tt.expected), len(multiAddrs)) 211 | for i, e := range tt.expected { 212 | require.Equal(t, e, multiAddrs[i].String()) 213 | } 214 | }) 215 | } 216 | } 217 | 218 | func TestIsIp6(t *testing.T) { 219 | t.Parallel() 220 | 221 | m, err := ma.NewMultiaddr("/ip6/::") 222 | require.NoError(t, err) 223 | require.True(t, isIp6(m)) 224 | m, err = ma.NewMultiaddr("/ip4/0.0.0.0") 225 | require.NoError(t, err) 226 | require.False(t, isIp6(m)) 227 | } 228 | 229 | func TestCreateCid(t *testing.T) { 230 | t.Parallel() 231 | 232 | c, err := createCid("foobar") 233 | require.NoError(t, err) 234 | require.Equal(t, "bafkreigdvoh7cnza5cwzar65hfdgwpejotszfqx2ha6uuolaofgk54ge6i", c.String()) 235 | } 236 | 237 | func TestHostMatches(t *testing.T) { 238 | t.Parallel() 239 | 240 | tests := []struct { 241 | name string 242 | host peer.AddrInfo 243 | addrInfo peer.AddrInfo 244 | expected bool 245 | }{ 246 | { 247 | name: "ID match", 248 | host: peer.AddrInfo{ 249 | ID: "foo", 250 | Addrs: []ma.Multiaddr{}, 251 | }, 252 | addrInfo: peer.AddrInfo{ 253 | ID: "foo", 254 | Addrs: []ma.Multiaddr{}, 255 | }, 256 | expected: true, 257 | }, 258 | { 259 | name: "ID do not match", 260 | host: peer.AddrInfo{ 261 | ID: "foo", 262 | Addrs: []ma.Multiaddr{}, 263 | }, 264 | addrInfo: peer.AddrInfo{ 265 | ID: "bar", 266 | Addrs: []ma.Multiaddr{}, 267 | }, 268 | expected: false, 269 | }, 270 | { 271 | name: "IP4 match", 272 | host: peer.AddrInfo{ 273 | ID: "", 274 | Addrs: []ma.Multiaddr{ma.StringCast("/ip4/192.168.1.1")}, 275 | }, 276 | addrInfo: peer.AddrInfo{ 277 | ID: "", 278 | Addrs: []ma.Multiaddr{ma.StringCast("/ip4/192.168.1.1")}, 279 | }, 280 | expected: true, 281 | }, 282 | { 283 | name: "IP4 do not match", 284 | host: peer.AddrInfo{ 285 | ID: "", 286 | Addrs: []ma.Multiaddr{ma.StringCast("/ip4/192.168.1.1")}, 287 | }, 288 | addrInfo: peer.AddrInfo{ 289 | ID: "", 290 | Addrs: []ma.Multiaddr{ma.StringCast("/ip4/192.168.1.2")}, 291 | }, 292 | expected: false, 293 | }, 294 | { 295 | name: "IP6 match", 296 | host: peer.AddrInfo{ 297 | ID: "", 298 | Addrs: []ma.Multiaddr{ma.StringCast("/ip6/c3c9:152b:73d1:dad0:e2f9:a521:6356:88ba")}, 299 | }, 300 | addrInfo: peer.AddrInfo{ 301 | ID: "", 302 | Addrs: []ma.Multiaddr{ma.StringCast("/ip6/c3c9:152b:73d1:dad0:e2f9:a521:6356:88ba")}, 303 | }, 304 | expected: true, 305 | }, 306 | } 307 | for _, tt := range tests { 308 | t.Run(tt.name, func(t *testing.T) { 309 | t.Parallel() 310 | 311 | matches, err := hostMatches(tt.host, tt.addrInfo) 312 | require.NoError(t, err) 313 | require.Equal(t, tt.expected, matches) 314 | }) 315 | } 316 | } 317 | 318 | func TestLoadOrCreatePrivateKey(t *testing.T) { 319 | t.Parallel() 320 | 321 | tmpDir := t.TempDir() 322 | data := []byte("hello world") 323 | 324 | firstPrivKey, err := loadOrCreatePrivateKey(t.Context(), tmpDir) 325 | require.NoError(t, err) 326 | sig, err := firstPrivKey.Sign(data) 327 | require.NoError(t, err) 328 | secondPrivKey, err := loadOrCreatePrivateKey(t.Context(), tmpDir) 329 | require.NoError(t, err) 330 | ok, err := secondPrivKey.GetPublic().Verify(data, sig) 331 | require.NoError(t, err) 332 | require.True(t, ok) 333 | require.True(t, firstPrivKey.Equals(secondPrivKey)) 334 | } 335 | -------------------------------------------------------------------------------- /pkg/routing/routing.go: -------------------------------------------------------------------------------- 1 | package routing 2 | 3 | import ( 4 | "context" 5 | "net/netip" 6 | ) 7 | 8 | // Router implements the discovery of content. 9 | type Router interface { 10 | // Ready returns true when the router is ready. 11 | Ready(ctx context.Context) (bool, error) 12 | // Resolve asynchronously discovers addresses that can serve the content defined by the give key. 13 | Resolve(ctx context.Context, key string, count int) (<-chan netip.AddrPort, error) 14 | // Advertise broadcasts that the current router can serve the content. 15 | Advertise(ctx context.Context, keys []string) error 16 | } 17 | -------------------------------------------------------------------------------- /pkg/state/state.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "time" 7 | 8 | "github.com/go-logr/logr" 9 | 10 | "github.com/spegel-org/spegel/internal/channel" 11 | "github.com/spegel-org/spegel/pkg/metrics" 12 | "github.com/spegel-org/spegel/pkg/oci" 13 | "github.com/spegel-org/spegel/pkg/routing" 14 | ) 15 | 16 | func Track(ctx context.Context, ociStore oci.Store, router routing.Router, resolveLatestTag bool) error { 17 | log := logr.FromContextOrDiscard(ctx) 18 | eventCh, err := ociStore.Subscribe(ctx) 19 | if err != nil { 20 | return err 21 | } 22 | immediateCh := make(chan time.Time, 1) 23 | immediateCh <- time.Now() 24 | close(immediateCh) 25 | expirationTicker := time.NewTicker(routing.KeyTTL - time.Minute) 26 | defer expirationTicker.Stop() 27 | tickerCh := channel.Merge(immediateCh, expirationTicker.C) 28 | for { 29 | select { 30 | case <-ctx.Done(): 31 | return nil 32 | case <-tickerCh: 33 | log.Info("running state update") 34 | err := tick(ctx, ociStore, router, resolveLatestTag) 35 | if err != nil { 36 | log.Error(err, "received errors when updating all images") 37 | continue 38 | } 39 | case event, ok := <-eventCh: 40 | if !ok { 41 | return errors.New("event channel closed") 42 | } 43 | log.Info("OCI event", "key", event.Key, "type", event.Type) 44 | err := handle(ctx, router, event) 45 | if err != nil { 46 | log.Error(err, "could not handle event") 47 | continue 48 | } 49 | } 50 | } 51 | } 52 | 53 | func tick(ctx context.Context, ociStore oci.Store, router routing.Router, resolveLatest bool) error { 54 | advertisedImages := map[string]float64{} 55 | advertisedImageDigests := map[string]float64{} 56 | advertisedImageTags := map[string]float64{} 57 | advertisedKeys := map[string]float64{} 58 | 59 | imgs, err := ociStore.ListImages(ctx) 60 | if err != nil { 61 | return err 62 | } 63 | for _, img := range imgs { 64 | advertisedImages[img.Registry] += 1 65 | advertisedImageDigests[img.Registry] += 1 66 | if !resolveLatest && img.IsLatestTag() { 67 | continue 68 | } 69 | tagName, ok := img.TagName() 70 | if !ok { 71 | continue 72 | } 73 | err := router.Advertise(ctx, []string{tagName}) 74 | if err != nil { 75 | return err 76 | } 77 | advertisedImageTags[img.Registry] += 1 78 | advertisedKeys[img.Registry] += 1 79 | } 80 | 81 | contents, err := ociStore.ListContents(ctx) 82 | if err != nil { 83 | return err 84 | } 85 | for _, content := range contents { 86 | err := router.Advertise(ctx, []string{content.Digest.String()}) 87 | if err != nil { 88 | return err 89 | } 90 | for _, registry := range content.Registires { 91 | advertisedKeys[registry] += 1 92 | } 93 | } 94 | 95 | for k, v := range advertisedImages { 96 | metrics.AdvertisedImages.WithLabelValues(k).Set(v) 97 | } 98 | for k, v := range advertisedImageDigests { 99 | metrics.AdvertisedImageDigests.WithLabelValues(k).Set(v) 100 | } 101 | for k, v := range advertisedImageTags { 102 | metrics.AdvertisedImageTags.WithLabelValues(k).Set(v) 103 | } 104 | for k, v := range advertisedKeys { 105 | metrics.AdvertisedKeys.WithLabelValues(k).Set(v) 106 | } 107 | return nil 108 | } 109 | 110 | func handle(ctx context.Context, router routing.Router, event oci.OCIEvent) error { 111 | if event.Type != oci.CreateEvent { 112 | return nil 113 | } 114 | err := router.Advertise(ctx, []string{event.Key}) 115 | if err != nil { 116 | return err 117 | } 118 | return nil 119 | } 120 | -------------------------------------------------------------------------------- /pkg/state/state_test.go: -------------------------------------------------------------------------------- 1 | package state 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "encoding/json" 7 | "math/rand/v2" 8 | "net/netip" 9 | "strconv" 10 | "testing" 11 | "time" 12 | 13 | "golang.org/x/sync/errgroup" 14 | 15 | "github.com/go-logr/logr" 16 | tlog "github.com/go-logr/logr/testing" 17 | "github.com/opencontainers/go-digest" 18 | "github.com/opencontainers/image-spec/specs-go" 19 | ocispec "github.com/opencontainers/image-spec/specs-go/v1" 20 | "github.com/stretchr/testify/require" 21 | 22 | "github.com/spegel-org/spegel/pkg/oci" 23 | "github.com/spegel-org/spegel/pkg/routing" 24 | ) 25 | 26 | func TestTrack(t *testing.T) { 27 | t.Parallel() 28 | ociStore := oci.NewMemory() 29 | 30 | imgRefs := []string{ 31 | "docker.io/library/ubuntu:latest", 32 | "ghcr.io/spegel-org/spegel:v0.0.9", 33 | } 34 | imgs := []oci.Image{} 35 | for _, imageStr := range imgRefs { 36 | manifest := ocispec.Manifest{ 37 | Versioned: specs.Versioned{ 38 | SchemaVersion: 2, 39 | }, 40 | MediaType: ocispec.MediaTypeImageManifest, 41 | Annotations: map[string]string{ 42 | "random": strconv.Itoa(rand.Int()), 43 | }, 44 | } 45 | b, err := json.Marshal(&manifest) 46 | require.NoError(t, err) 47 | hash := sha256.New() 48 | _, err = hash.Write(b) 49 | require.NoError(t, err) 50 | dgst := digest.NewDigest(digest.SHA256, hash) 51 | ociStore.AddBlob(b, dgst) 52 | img, err := oci.ParseImageRequireDigest(imageStr, dgst) 53 | require.NoError(t, err) 54 | ociStore.AddImage(img) 55 | 56 | imgs = append(imgs, img) 57 | } 58 | 59 | tests := []struct { 60 | name string 61 | resolveLatestTag bool 62 | }{ 63 | { 64 | name: "resolve latest", 65 | resolveLatestTag: true, 66 | }, 67 | { 68 | name: "do not resolve latest", 69 | resolveLatestTag: false, 70 | }, 71 | } 72 | for _, tt := range tests { 73 | t.Run(tt.name, func(t *testing.T) { 74 | t.Parallel() 75 | 76 | log := tlog.NewTestLogger(t) 77 | ctx := logr.NewContext(t.Context(), log) 78 | ctx, cancel := context.WithCancel(ctx) 79 | 80 | router := routing.NewMemoryRouter(map[string][]netip.AddrPort{}, netip.MustParseAddrPort("127.0.0.1:5000")) 81 | g, gCtx := errgroup.WithContext(ctx) 82 | g.Go(func() error { 83 | return Track(gCtx, ociStore, router, tt.resolveLatestTag) 84 | }) 85 | time.Sleep(100 * time.Millisecond) 86 | 87 | for _, img := range imgs { 88 | peers, ok := router.Lookup(img.Digest.String()) 89 | require.True(t, ok) 90 | require.Len(t, peers, 1) 91 | tagName, ok := img.TagName() 92 | if !ok { 93 | continue 94 | } 95 | peers, ok = router.Lookup(tagName) 96 | if img.IsLatestTag() && !tt.resolveLatestTag { 97 | require.False(t, ok) 98 | continue 99 | } 100 | require.True(t, ok) 101 | require.Len(t, peers, 1) 102 | } 103 | 104 | cancel() 105 | err := g.Wait() 106 | require.NoError(t, err) 107 | }) 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /test/e2e/testdata/conformance-job.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: batch/v1 2 | kind: Job 3 | metadata: 4 | name: conformance 5 | spec: 6 | backoffLimit: 0 7 | template: 8 | spec: 9 | restartPolicy: Never 10 | containers: 11 | - name: conformance 12 | image: ghcr.io/spegel-org/conformance:583e014 13 | env: 14 | - name: OCI_TEST_PULL 15 | value: "1" 16 | - name: "OCI_ROOT_URL" 17 | value: "http://spegel-registry.spegel.svc.cluster.local.:5000" 18 | - name: "OCI_MIRROR_URL" 19 | value: "docker.io" 20 | - name: "OCI_NAMESPACE" 21 | value: "library/nginx" 22 | - name: "OCI_TAG_NAME" 23 | value: "1.23.0" 24 | - name: "OCI_MANIFEST_DIGEST" 25 | value: "sha256:db345982a2f2a4257c6f699a499feb1d79451a1305e8022f16456ddc3ad6b94c" 26 | - name: "OCI_BLOB_DIGEST" 27 | value: "sha256:461246efe0a75316d99afdbf348f7063b57b0caeee8daab775f1f08152ea36f4" 28 | -------------------------------------------------------------------------------- /test/e2e/testdata/test-nginx.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: nginx 5 | --- 6 | apiVersion: apps/v1 7 | kind: Deployment 8 | metadata: 9 | name: nginx-tag 10 | namespace: nginx 11 | labels: 12 | app: nginx-tag 13 | spec: 14 | replicas: 3 15 | selector: 16 | matchLabels: 17 | app: nginx-tag 18 | template: 19 | metadata: 20 | labels: 21 | app: nginx-tag 22 | spec: 23 | containers: 24 | - name: nginx 25 | image: docker.io/library/nginx:1.23.0 26 | imagePullPolicy: Always 27 | ports: 28 | - containerPort: 80 29 | nodeSelector: 30 | test: "true" 31 | --- 32 | apiVersion: apps/v1 33 | kind: Deployment 34 | metadata: 35 | name: nginx-digest 36 | namespace: nginx 37 | labels: 38 | app: nginx-digest 39 | spec: 40 | replicas: 3 41 | selector: 42 | matchLabels: 43 | app: nginx-digest 44 | template: 45 | metadata: 46 | labels: 47 | app: nginx-digest 48 | spec: 49 | containers: 50 | - name: nginx 51 | image: docker.io/library/nginx@sha256:b3a676a9145dc005062d5e79b92d90574fb3bf2396f4913dc1732f9065f55c4b # 1.22.0 52 | imagePullPolicy: Always 53 | ports: 54 | - containerPort: 80 55 | nodeSelector: 56 | test: "true" 57 | --- 58 | apiVersion: apps/v1 59 | kind: Deployment 60 | metadata: 61 | name: nginx-tag-and-digest 62 | namespace: nginx 63 | labels: 64 | app: nginx-tag-and-digest 65 | spec: 66 | replicas: 3 67 | selector: 68 | matchLabels: 69 | app: nginx-tag-and-digest 70 | template: 71 | metadata: 72 | labels: 73 | app: nginx-tag-and-digest 74 | spec: 75 | containers: 76 | - name: nginx 77 | image: docker.io/library/nginx:1.21.0@sha256:2f1cd90e00fe2c991e18272bb35d6a8258eeb27785d121aa4cc1ae4235167cfd 78 | imagePullPolicy: Always 79 | ports: 80 | - containerPort: 80 81 | nodeSelector: 82 | test: "true" 83 | --- 84 | apiVersion: apps/v1 85 | kind: Deployment 86 | metadata: 87 | name: nginx-not-present 88 | namespace: nginx 89 | labels: 90 | app: nginx-not-present 91 | spec: 92 | replicas: 3 93 | selector: 94 | matchLabels: 95 | app: nginx-not-present 96 | template: 97 | metadata: 98 | labels: 99 | app: nginx-not-present 100 | spec: 101 | containers: 102 | - name: nginx 103 | image: docker.io/library/nginx:1.1.0-bullseye-perl 104 | imagePullPolicy: Always 105 | ports: 106 | - containerPort: 80 107 | nodeSelector: 108 | test: "true" 109 | --------------------------------------------------------------------------------