feat: support image registry mock server (#425)
Co-authored-by: rick <linuxsuren@users.noreply.github.com>
This commit is contained in:
parent
76c60b3e06
commit
ea033d94b6
21
cmd/mock.go
21
cmd/mock.go
|
@ -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
|
||||
}
|
||||
|
|
|
@ -204,7 +204,8 @@
|
|||
"POST",
|
||||
"PUT",
|
||||
"PATCH",
|
||||
"DELETE"
|
||||
"DELETE",
|
||||
"HEAD"
|
||||
]
|
||||
},
|
||||
"query": {
|
||||
|
|
|
@ -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
|
||||
```
|
|
@ -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
|
|
@ -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()))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
}]
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue