feat: support image registry mock server (#425)

Co-authored-by: rick <linuxsuren@users.noreply.github.com>
This commit is contained in:
Rick 2024-05-14 12:23:44 +08:00 committed by GitHub
parent 76c60b3e06
commit ea033d94b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 220 additions and 45 deletions

View File

@ -17,7 +17,6 @@ limitations under the License.
package cmd
import (
"errors"
"os"
"os/signal"
"syscall"
@ -29,37 +28,29 @@ import (
type mockOption struct {
port int
prefix string
files []string
}
func createMockCmd() (c *cobra.Command) {
opt := &mockOption{}
c = &cobra.Command{
Use: "mock",
Short: "Start a mock server",
PreRunE: opt.preRunE,
RunE: opt.runE,
Use: "mock",
Short: "Start a mock server",
Args: cobra.ExactArgs(1),
RunE: opt.runE,
}
flags := c.Flags()
flags.IntVarP(&opt.port, "port", "", 6060, "The mock server port")
flags.StringVarP(&opt.prefix, "prefix", "", "/mock", "The mock server API prefix")
flags.StringSliceVarP(&opt.files, "files", "", nil, "The mock config files")
return
}
func (o *mockOption) preRunE(c *cobra.Command, args []string) (err error) {
if len(o.files) == 0 {
err = errors.New("at least one file is required")
}
return
}
func (o *mockOption) runE(c *cobra.Command, args []string) (err error) {
reader := mock.NewLocalFileReader(o.files[0])
reader := mock.NewLocalFileReader(args[0])
server := mock.NewInMemoryServer(o.port)
c.Println("start listen", o.port)
if err = server.Start(reader, o.prefix); err != nil {
return
}

View File

@ -204,7 +204,8 @@
"POST",
"PUT",
"PATCH",
"DELETE"
"DELETE",
"HEAD"
]
},
"query": {

13
docs/mock-server.md Normal file
View File

@ -0,0 +1,13 @@
## Get started
You can start a mock server of [container registry](https://distribution.github.io/distribution/) with below command:
```shell
atest mock --prefix / mock/image-registry.yaml
```
then, you can pull images from it:
```shell
docker pull localhost:6060/repo/name:tag
```

View File

@ -0,0 +1,87 @@
items:
- name: config
request:
path: /v2/{repo}/{name}/blobs/sha256:4e53321e14aaf87b17329102a21d4388fd9bea986277a78a8aa13bd300c9e3f9
response:
header:
Accept-Ranges: bytes
Content-Type: application/octet-stream
Docker-Content-Digest: "sha256:{{ sha256sum .Response.Body }}"
Docker-Distribution-Api-Version: registry/2.0
body: |
{"architecture":"amd64","config":{"Env":["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"],"Cmd":["/bin/sh"],"OnBuild":null},"created":"2024-01-27T00:30:48.743965523Z","history":[{"created":"2024-01-27T00:30:48.624602109Z","created_by":"/bin/sh -c #(nop) ADD file:37a76ec18f9887751cd8473744917d08b7431fc4085097bb6a09d81b41775473 in / "},{"created":"2024-01-27T00:30:48.743965523Z","created_by":"/bin/sh -c #(nop) CMD [\"/bin/sh\"]","empty_layer":true}],"os":"linux","rootfs":{"type":"layers","diff_ids":["sha256:d4fc045c9e3a848011de66f34b81f052d4f2c15a17bb196d637e526349601820"]}}
- name: digest
request:
path: /v2/{repo}/{name}/manifests/sha256:43b13813161da7f0ded631e38111c4210167109c4d87bda0cae4f5e974e93f83
response:
header:
Content-Type: application/vnd.oci.image.manifest.v1+json
Docker-Content-Digest: "sha256:{{ sha256sum .Response.Body }}"
Docker-Distribution-Api-Version: registry/2.0
body: |
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": {
"mediaType": "application/vnd.oci.image.config.v1+json",
"digest": "sha256:4e53321e14aaf87b17329102a21d4388fd9bea986277a78a8aa13bd300c9e3f9",
"size": 600
},
"layers": [
{
"mediaType": "application/vnd.oci.image.layer.v1.tar+gzip",
"digest": "sha256:4abcf20661432fb2d719aaf90656f55c287f8ca915dc1c92ec14ff61e67fbaf8",
"size": 3408729
}
]
}
- name: manifests
request:
path: /v2/{repo}/{name}/manifests/{digest}
method: GET,HEAD
response:
header:
Content-Type: application/vnd.oci.image.index.v1+json
Docker-Distribution-Api-Version: registry/2.0
Docker-Content-Digest: "sha256:{{ sha256sum .Response.Body }}"
body: |
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.index.v1+json",
"manifests": [
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:43b13813161da7f0ded631e38111c4210167109c4d87bda0cae4f5e974e93f83",
"size": 480,
"platform": {
"architecture": "amd64",
"os": "linux"
}
},
{
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"digest": "sha256:d19bf9196ad6eb527acdbabfd664954ccb7a0752f218312b6c910b8590d170e2",
"size": 566,
"annotations": {
"vnd.docker.reference.digest": "sha256:43b13813161da7f0ded631e38111c4210167109c4d87bda0cae4f5e974e93f83",
"vnd.docker.reference.type": "attestation-manifest"
},
"platform": {
"architecture": "unknown",
"os": "unknown"
}
}
]
}
- name: layer
request:
path: /v2/{repo}/{name}/blobs/{digest}
response:
header:
Accept-Ranges: bytes
Cache-Control: max-age=31536000
Content-Type: application/octet-stream
Docker-Content-Digest: "sha256:{{ sha256sumBytes .Response.BodyData }}"
Docker-Distribution-Api-Version: registry/2.0
body: https://gitee.com/linuxsuren/api-testing-hub/raw/master/data/alpine-3.19.1.tar.gz
encoder: url

View File

@ -17,6 +17,7 @@ package mock
import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
@ -108,7 +109,9 @@ func (s *inMemoryServer) Start(reader Reader, prefix string) (err error) {
return
}
s.listener, err = net.Listen("tcp", fmt.Sprintf(":%d", s.port))
if s.listener, err = net.Listen("tcp", fmt.Sprintf(":%d", s.port)); err != nil {
return
}
go func() {
err = http.Serve(s.listener, handler)
}()
@ -247,24 +250,66 @@ func (s *inMemoryServer) startObject(obj Object) {
}
func (s *inMemoryServer) startItem(item Item) {
s.mux.HandleFunc(item.Request.Path, func(w http.ResponseWriter, req *http.Request) {
item.Response.Header[headerMockServer] = fmt.Sprintf("api-testing: %s", version.GetVersion())
item.Response.Header["content-length"] = fmt.Sprintf("%d", len(item.Response.Body))
for k, v := range item.Response.Header {
w.Header().Set(k, v)
method := util.EmptyThenDefault(item.Request.Method, http.MethodGet)
memLogger.Info("register mock service", "method", method, "path", item.Request.Path, "encoder", item.Response.Encoder)
var headerSlices []string
for k, v := range item.Request.Header {
headerSlices = append(headerSlices, k, v)
}
adHandler := &advanceHandler{item: &item}
s.mux.HandleFunc(item.Request.Path, adHandler.handle).Methods(strings.Split(method, ",")...).Headers(headerSlices...)
}
type advanceHandler struct {
item *Item
}
func (h *advanceHandler) handle(w http.ResponseWriter, req *http.Request) {
memLogger.Info("receiving mock request", "name", h.item.Name, "method", req.Method, "path", req.URL.Path,
"encoder", h.item.Response.Encoder)
var err error
if h.item.Response.Encoder == "base64" {
h.item.Response.BodyData, err = base64.StdEncoding.DecodeString(h.item.Response.Body)
} else if h.item.Response.Encoder == "url" {
var resp *http.Response
if resp, err = http.Get(h.item.Response.Body); err == nil {
h.item.Response.BodyData, err = io.ReadAll(resp.Body)
}
body, err := render.RenderAsBytes("start-item", item.Response.Body, req)
writeResponse(w, body, err)
w.WriteHeader(util.ZeroThenDefault(item.Response.StatusCode, http.StatusOK))
}).Methods(util.EmptyThenDefault(item.Request.Method, http.MethodGet))
} else {
h.item.Response.BodyData, err = render.RenderAsBytes("start-item", h.item.Response.Body, h.item)
}
if err == nil {
h.item.Param = mux.Vars(req)
if h.item.Response.Header == nil {
h.item.Response.Header = make(map[string]string)
}
h.item.Response.Header[headerMockServer] = fmt.Sprintf("api-testing: %s", version.GetVersion())
h.item.Response.Header["content-length"] = fmt.Sprintf("%d", len(h.item.Response.BodyData))
for k, v := range h.item.Response.Header {
hv, hErr := render.Render("mock-server-header", v, &h.item)
if hErr != nil {
hv = v
memLogger.Error(hErr, "failed render mock-server-header", "value", v)
}
w.Header().Set(k, hv)
}
w.WriteHeader(util.ZeroThenDefault(h.item.Response.StatusCode, http.StatusOK))
}
writeResponse(w, h.item.Response.BodyData, err)
}
func writeResponse(w http.ResponseWriter, data []byte, err error) {
if err == nil {
w.Write(data)
} else {
w.Write([]byte(err.Error()))
w.WriteHeader(http.StatusBadRequest)
w.Write([]byte(err.Error()))
}
}

View File

@ -129,15 +129,36 @@ func TestInMemoryServer(t *testing.T) {
})
t.Run("mock item", func(t *testing.T) {
resp, err := http.Get(api + "/v1/repos/test/prs")
req, err := http.NewRequest(http.MethodGet, api+"/v1/repos/test/prs", nil)
assert.NoError(t, err)
req.Header.Set("name", "rick")
resp, err = http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusOK, resp.StatusCode)
assert.Equal(t, "mock", resp.Header.Get("server"))
assert.Equal(t, "176", resp.Header.Get("Content-Length"))
assert.Equal(t, "mock", resp.Header.Get("Server"))
assert.NotEmpty(t, resp.Header.Get(headerMockServer))
data, _ := io.ReadAll(resp.Body)
assert.True(t, strings.Contains(string(data), `"message": "mock"`), string(data))
})
assert.True(t, strings.Contains(string(data), `"message": "gzip"`), string(data))
t.Run("miss match header", func(t *testing.T) {
req, err := http.NewRequest(http.MethodGet, api+"/v1/repos/test/prs", nil)
assert.NoError(t, err)
resp, err = http.DefaultClient.Do(req)
assert.NoError(t, err)
assert.Equal(t, http.StatusNotFound, resp.StatusCode)
})
t.Run("base64 encoder", func(t *testing.T) {
resp, err = http.Get(api + "/v1/base64")
assert.NoError(t, err)
data, _ := io.ReadAll(resp.Body)
assert.Equal(t, "hello", string(data))
})
t.Run("not found config file", func(t *testing.T) {

View File

@ -22,24 +22,17 @@ objects:
"color": "{{ randEnum "blue" "read" "pink" }}"
}
items:
- name: repoList
- name: base64
request:
path: /v1/teams/{team}/repos
method: GET
path: /v1/base64
response:
statusCode: 200
body: |
{
"status": 0,
"count": 1,
"items": [{
"name": "fake-name",
"url": "https://fake.com"
}]
}
body: aGVsbG8=
encoder: base64
- name: prList
request:
path: /v1/repos/{repo}/prs
header:
name: rick
response:
header:
server: mock
@ -49,7 +42,7 @@ items:
"items": [{
"title": "fix: there is a bug on page {{ randEnum "one" }}",
"number": 123,
"message": "{{index (index .Header "Accept-Encoding") 0}}",
"message": "{{.Response.Header.server}}",
"author": "someone",
"status": "success"
}]

View File

@ -31,6 +31,7 @@ type Item struct {
Name string `yaml:"name"`
Request Request `yaml:"request"`
Response Response `yaml:"response"`
Param map[string]string
}
type Request struct {
@ -41,9 +42,11 @@ type Request struct {
}
type Response struct {
Encoder string `yaml:"encoder"`
Body string `yaml:"body"`
Header map[string]string `yaml:"header"`
StatusCode int `yaml:"statusCode"`
BodyData []byte
}
type Webhook struct {

View File

@ -19,6 +19,7 @@ import (
"bytes"
"context"
"crypto/md5"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
@ -147,6 +148,13 @@ var advancedFuncs = []AdvancedFunc{{
return err.Error()
}
},
}, {
FuncName: "sha256sumBytes",
Func: func(data []byte) string {
h := sha256.New()
h.Write(data)
return hex.EncodeToString(h.Sum(nil))
},
}, {
FuncName: "randEnum",
Func: func(items ...string) string {

View File

@ -89,6 +89,15 @@ func TestRender(t *testing.T) {
assert.Contains(t, s, "@")
assert.Contains(t, s, ".com")
},
}, {
name: "sha256 bytes",
text: `{{sha256sumBytes .data}}`,
ctx: map[string]interface{}{
"data": []byte("hello"),
},
verify: func(t *testing.T, s string) {
assert.Equal(t, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", s)
},
}}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {

View File

@ -369,7 +369,7 @@ func runJob(job *testing.Job, ctx interface{}, current interface{}) (err error)
// isNonBinaryContent detect if the content belong to binary
func isNonBinaryContent(contentType string) bool {
switch contentType {
case util.JSON, util.YAML, util.Plain:
case util.JSON, util.YAML, util.Plain, util.OCIImageIndex:
return true
default:
return false

View File

@ -572,6 +572,9 @@ func TestIsStructContent(t *testing.T) {
}, {
contentType: util.OctetStream,
expectOk: false,
}, {
contentType: util.OCIImageIndex,
expectOk: true,
}}
for _, tt := range tests {
t.Run(tt.contentType, func(t *testing.T) {

View File

@ -73,6 +73,7 @@ const (
MultiPartFormData = "multipart/form-data"
Form = "application/x-www-form-urlencoded"
JSON = "application/json"
OCIImageIndex = "application/vnd.oci.image.index.v1+json"
YAML = "application/yaml"
ZIP = "application/zip"
OctetStream = "application/octet-stream"