├── .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 |
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 | Peer |
10 | Duration |
11 |
12 |
13 | {{ range .PeerResults }}
14 |
15 | {{ .Peer.Addr }} |
16 | {{ .Duration | formatDuration }} |
17 |
18 | {{ end }}
19 |
20 |
21 |
22 | Result
23 |
24 | Duration: {{ .PullDuration | formatDuration }}
25 | Size: {{ .PullSize | formatBytes }}
26 |
27 |
28 |
29 |
30 | Identifier |
31 | Type |
32 | Size |
33 | Duration |
34 |
35 |
36 | {{ range .PullResults }}
37 |
38 | {{ .Identifier }} |
39 | {{ .Type }} |
40 | {{ .Size | formatBytes }} |
41 | {{ .Duration | formatDuration }} |
42 |
43 | {{ end }}
44 |
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 |
--------------------------------------------------------------------------------