[#1689] object: Make APE middleware form container system attributes
* Extract container system attributes into request info; * Form APE-resource proprties from the extracted attributes; * Fix unit-test. Change-Id: I8fbd9a167ad05af0e75df350ac882b866da7bdcb Signed-off-by: Airat Arifullin <a.arifullin@yadro.com>
This commit is contained in:
parent
a5f76a609d
commit
c2a495814f
5 changed files with 128 additions and 88 deletions
|
@ -64,6 +64,9 @@ type Prm struct {
|
||||||
// An encoded container's owner user ID.
|
// An encoded container's owner user ID.
|
||||||
ContainerOwner user.ID
|
ContainerOwner user.ID
|
||||||
|
|
||||||
|
// Attributes defined for the container.
|
||||||
|
ContainerAttributes map[string]string
|
||||||
|
|
||||||
// The request's bearer token. It is used in order to check APE overrides with the token.
|
// The request's bearer token. It is used in order to check APE overrides with the token.
|
||||||
BearerToken *bearer.Token
|
BearerToken *bearer.Token
|
||||||
|
|
||||||
|
|
|
@ -63,6 +63,8 @@ type RequestInfo struct {
|
||||||
|
|
||||||
ContainerOwner user.ID
|
ContainerOwner user.ID
|
||||||
|
|
||||||
|
ContainerAttributes map[string]string
|
||||||
|
|
||||||
// Namespace defines to which namespace a container is belonged.
|
// Namespace defines to which namespace a container is belonged.
|
||||||
Namespace string
|
Namespace string
|
||||||
|
|
||||||
|
@ -131,6 +133,11 @@ func (e *extractor) GetRequestInfo(ctx context.Context, m Metadata, method strin
|
||||||
ri.Role = nativeSchemaRole(res.Role)
|
ri.Role = nativeSchemaRole(res.Role)
|
||||||
ri.ContainerOwner = cnr.Value.Owner()
|
ri.ContainerOwner = cnr.Value.Owner()
|
||||||
|
|
||||||
|
ri.ContainerAttributes = map[string]string{}
|
||||||
|
for key, val := range cnr.Value.Attributes() {
|
||||||
|
ri.ContainerAttributes[key] = val
|
||||||
|
}
|
||||||
|
|
||||||
cnrNamespace, hasNamespace := strings.CutSuffix(cnrSDK.ReadDomain(cnr.Value).Zone(), ".ns")
|
cnrNamespace, hasNamespace := strings.CutSuffix(cnrSDK.ReadDomain(cnr.Value).Zone(), ".ns")
|
||||||
if hasNamespace {
|
if hasNamespace {
|
||||||
ri.Namespace = cnrNamespace
|
ri.Namespace = cnrNamespace
|
||||||
|
|
|
@ -57,11 +57,16 @@ func resourceName(cid cid.ID, oid *oid.ID, namespace string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// objectProperties collects object properties from address parameters and a header if it is passed.
|
// objectProperties collects object properties from address parameters and a header if it is passed.
|
||||||
func objectProperties(cnr cid.ID, oid *oid.ID, cnrOwner user.ID, header *objectV2.Header) map[string]string {
|
func objectProperties(cnr cid.ID, oid *oid.ID, cnrOwner user.ID, cnrAttrs map[string]string, header *objectV2.Header) map[string]string {
|
||||||
objectProps := map[string]string{
|
objectProps := map[string]string{
|
||||||
nativeschema.PropertyKeyObjectContainerID: cnr.EncodeToString(),
|
nativeschema.PropertyKeyObjectContainerID: cnr.EncodeToString(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
for attrName, attrValue := range cnrAttrs {
|
||||||
|
prop := fmt.Sprintf(nativeschema.PropertyKeyFormatObjectContainerAttribute, attrName)
|
||||||
|
objectProps[prop] = attrValue
|
||||||
|
}
|
||||||
|
|
||||||
objectProps[nativeschema.PropertyKeyContainerOwnerID] = cnrOwner.EncodeToString()
|
objectProps[nativeschema.PropertyKeyContainerOwnerID] = cnrOwner.EncodeToString()
|
||||||
|
|
||||||
if oid != nil {
|
if oid != nil {
|
||||||
|
@ -155,7 +160,7 @@ func (c *checkerImpl) newAPERequest(ctx context.Context, prm Prm) (aperequest.Re
|
||||||
prm.Method,
|
prm.Method,
|
||||||
aperequest.NewResource(
|
aperequest.NewResource(
|
||||||
resourceName(prm.Container, prm.Object, prm.Namespace),
|
resourceName(prm.Container, prm.Object, prm.Namespace),
|
||||||
objectProperties(prm.Container, prm.Object, prm.ContainerOwner, header),
|
objectProperties(prm.Container, prm.Object, prm.ContainerOwner, prm.ContainerAttributes, header),
|
||||||
),
|
),
|
||||||
reqProps,
|
reqProps,
|
||||||
), nil
|
), nil
|
||||||
|
|
|
@ -7,6 +7,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
aperequest "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/request"
|
aperequest "git.frostfs.info/TrueCloudLab/frostfs-node/pkg/ape/request"
|
||||||
|
cnrV2 "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/api/container"
|
||||||
objectV2 "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/api/object"
|
objectV2 "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/api/object"
|
||||||
checksumtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/checksum/test"
|
checksumtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/checksum/test"
|
||||||
objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object"
|
objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object"
|
||||||
|
@ -22,8 +23,17 @@ const (
|
||||||
testOwnerID = "NURFM8PWbLA2aLt2vrD8q4FyfAdgESwM8y"
|
testOwnerID = "NURFM8PWbLA2aLt2vrD8q4FyfAdgESwM8y"
|
||||||
|
|
||||||
incomingIP = "192.92.33.1"
|
incomingIP = "192.92.33.1"
|
||||||
|
|
||||||
|
testSysAttrName = "unittest"
|
||||||
|
|
||||||
|
testSysAttrZone = "eggplant"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var containerAttrs = map[string]string{
|
||||||
|
cnrV2.SysAttributeName: testSysAttrName,
|
||||||
|
cnrV2.SysAttributeZone: testSysAttrZone,
|
||||||
|
}
|
||||||
|
|
||||||
func ctxWithPeerInfo() context.Context {
|
func ctxWithPeerInfo() context.Context {
|
||||||
return peer.NewContext(context.Background(), &peer.Peer{
|
return peer.NewContext(context.Background(), &peer.Peer{
|
||||||
Addr: &net.TCPAddr{
|
Addr: &net.TCPAddr{
|
||||||
|
@ -105,7 +115,7 @@ func TestObjectProperties(t *testing.T) {
|
||||||
var testCnrOwner user.ID
|
var testCnrOwner user.ID
|
||||||
require.NoError(t, testCnrOwner.DecodeString(testOwnerID))
|
require.NoError(t, testCnrOwner.DecodeString(testOwnerID))
|
||||||
|
|
||||||
props := objectProperties(cnr, obj, testCnrOwner, header.ToV2().GetHeader())
|
props := objectProperties(cnr, obj, testCnrOwner, containerAttrs, header.ToV2().GetHeader())
|
||||||
require.Equal(t, test.container, props[nativeschema.PropertyKeyObjectContainerID])
|
require.Equal(t, test.container, props[nativeschema.PropertyKeyObjectContainerID])
|
||||||
require.Equal(t, testOwnerID, props[nativeschema.PropertyKeyContainerOwnerID])
|
require.Equal(t, testOwnerID, props[nativeschema.PropertyKeyContainerOwnerID])
|
||||||
|
|
||||||
|
@ -124,6 +134,8 @@ func TestObjectProperties(t *testing.T) {
|
||||||
require.Equal(t, test.header.typ.String(), props[nativeschema.PropertyKeyObjectType])
|
require.Equal(t, test.header.typ.String(), props[nativeschema.PropertyKeyObjectType])
|
||||||
require.Equal(t, test.header.payloadChecksum.String(), props[nativeschema.PropertyKeyObjectPayloadHash])
|
require.Equal(t, test.header.payloadChecksum.String(), props[nativeschema.PropertyKeyObjectPayloadHash])
|
||||||
require.Equal(t, test.header.payloadHomomorphicHash.String(), props[nativeschema.PropertyKeyObjectHomomorphicHash])
|
require.Equal(t, test.header.payloadHomomorphicHash.String(), props[nativeschema.PropertyKeyObjectHomomorphicHash])
|
||||||
|
require.Equal(t, containerAttrs[cnrV2.SysAttributeName], props[fmt.Sprintf(nativeschema.PropertyKeyFormatObjectContainerAttribute, cnrV2.SysAttributeName)])
|
||||||
|
require.Equal(t, containerAttrs[cnrV2.SysAttributeZone], props[fmt.Sprintf(nativeschema.PropertyKeyFormatObjectContainerAttribute, cnrV2.SysAttributeZone)])
|
||||||
|
|
||||||
for _, attr := range test.header.attributes {
|
for _, attr := range test.header.attributes {
|
||||||
require.Equal(t, attr.val, props[attr.key])
|
require.Equal(t, attr.val, props[attr.key])
|
||||||
|
@ -245,6 +257,10 @@ func TestNewAPERequest(t *testing.T) {
|
||||||
Role: role,
|
Role: role,
|
||||||
SenderKey: senderKey,
|
SenderKey: senderKey,
|
||||||
ContainerOwner: testCnrOwner,
|
ContainerOwner: testCnrOwner,
|
||||||
|
ContainerAttributes: map[string]string{
|
||||||
|
cnrV2.SysAttributeZone: testSysAttrZone,
|
||||||
|
cnrV2.SysAttributeName: testSysAttrName,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
headerSource := newHeaderProviderMock()
|
headerSource := newHeaderProviderMock()
|
||||||
|
@ -277,7 +293,7 @@ func TestNewAPERequest(t *testing.T) {
|
||||||
method,
|
method,
|
||||||
aperequest.NewResource(
|
aperequest.NewResource(
|
||||||
resourceName(cnr, obj, prm.Namespace),
|
resourceName(cnr, obj, prm.Namespace),
|
||||||
objectProperties(cnr, obj, testCnrOwner, func() *objectV2.Header {
|
objectProperties(cnr, obj, testCnrOwner, containerAttrs, func() *objectV2.Header {
|
||||||
if headerObjSDK != nil {
|
if headerObjSDK != nil {
|
||||||
return headerObjSDK.ToV2().GetHeader()
|
return headerObjSDK.ToV2().GetHeader()
|
||||||
}
|
}
|
||||||
|
|
|
@ -86,16 +86,17 @@ func (g *getStreamBasicChecker) Send(resp *objectV2.GetResponse) error {
|
||||||
}
|
}
|
||||||
|
|
||||||
prm := Prm{
|
prm := Prm{
|
||||||
Namespace: g.reqInfo.Namespace,
|
Namespace: g.reqInfo.Namespace,
|
||||||
Container: cnrID,
|
Container: cnrID,
|
||||||
Object: objID,
|
Object: objID,
|
||||||
Header: partInit.GetHeader(),
|
Header: partInit.GetHeader(),
|
||||||
Method: nativeschema.MethodGetObject,
|
Method: nativeschema.MethodGetObject,
|
||||||
SenderKey: g.reqInfo.SenderKey,
|
SenderKey: g.reqInfo.SenderKey,
|
||||||
ContainerOwner: g.reqInfo.ContainerOwner,
|
ContainerOwner: g.reqInfo.ContainerOwner,
|
||||||
Role: g.reqInfo.Role,
|
ContainerAttributes: g.reqInfo.ContainerAttributes,
|
||||||
BearerToken: g.metadata.BearerToken,
|
Role: g.reqInfo.Role,
|
||||||
XHeaders: resp.GetMetaHeader().GetXHeaders(),
|
BearerToken: g.metadata.BearerToken,
|
||||||
|
XHeaders: resp.GetMetaHeader().GetXHeaders(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := g.apeChecker.CheckAPE(g.Context(), prm); err != nil {
|
if err := g.apeChecker.CheckAPE(g.Context(), prm); err != nil {
|
||||||
|
@ -142,16 +143,17 @@ func (p *putStreamBasicChecker) Send(ctx context.Context, request *objectV2.PutR
|
||||||
}
|
}
|
||||||
|
|
||||||
prm := Prm{
|
prm := Prm{
|
||||||
Namespace: reqInfo.Namespace,
|
Namespace: reqInfo.Namespace,
|
||||||
Container: md.Container,
|
Container: md.Container,
|
||||||
Object: md.Object,
|
Object: md.Object,
|
||||||
Header: partInit.GetHeader(),
|
Header: partInit.GetHeader(),
|
||||||
Method: nativeschema.MethodPutObject,
|
Method: nativeschema.MethodPutObject,
|
||||||
SenderKey: reqInfo.SenderKey,
|
SenderKey: reqInfo.SenderKey,
|
||||||
ContainerOwner: reqInfo.ContainerOwner,
|
ContainerOwner: reqInfo.ContainerOwner,
|
||||||
Role: reqInfo.Role,
|
ContainerAttributes: reqInfo.ContainerAttributes,
|
||||||
BearerToken: md.BearerToken,
|
Role: reqInfo.Role,
|
||||||
XHeaders: md.MetaHeader.GetXHeaders(),
|
BearerToken: md.BearerToken,
|
||||||
|
XHeaders: md.MetaHeader.GetXHeaders(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.apeChecker.CheckAPE(ctx, prm); err != nil {
|
if err := p.apeChecker.CheckAPE(ctx, prm); err != nil {
|
||||||
|
@ -200,15 +202,16 @@ func (p *patchStreamBasicChecker) Send(ctx context.Context, request *objectV2.Pa
|
||||||
}
|
}
|
||||||
|
|
||||||
prm := Prm{
|
prm := Prm{
|
||||||
Namespace: reqInfo.Namespace,
|
Namespace: reqInfo.Namespace,
|
||||||
Container: md.Container,
|
Container: md.Container,
|
||||||
Object: md.Object,
|
Object: md.Object,
|
||||||
Method: nativeschema.MethodPatchObject,
|
Method: nativeschema.MethodPatchObject,
|
||||||
SenderKey: reqInfo.SenderKey,
|
SenderKey: reqInfo.SenderKey,
|
||||||
ContainerOwner: reqInfo.ContainerOwner,
|
ContainerOwner: reqInfo.ContainerOwner,
|
||||||
Role: reqInfo.Role,
|
ContainerAttributes: reqInfo.ContainerAttributes,
|
||||||
BearerToken: md.BearerToken,
|
Role: reqInfo.Role,
|
||||||
XHeaders: md.MetaHeader.GetXHeaders(),
|
BearerToken: md.BearerToken,
|
||||||
|
XHeaders: md.MetaHeader.GetXHeaders(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := p.apeChecker.CheckAPE(ctx, prm); err != nil {
|
if err := p.apeChecker.CheckAPE(ctx, prm); err != nil {
|
||||||
|
@ -268,16 +271,17 @@ func (c *Service) Head(ctx context.Context, request *objectV2.HeadRequest) (*obj
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.apeChecker.CheckAPE(ctx, Prm{
|
err = c.apeChecker.CheckAPE(ctx, Prm{
|
||||||
Namespace: reqInfo.Namespace,
|
Namespace: reqInfo.Namespace,
|
||||||
Container: md.Container,
|
Container: md.Container,
|
||||||
Object: md.Object,
|
Object: md.Object,
|
||||||
Header: header,
|
Header: header,
|
||||||
Method: nativeschema.MethodHeadObject,
|
Method: nativeschema.MethodHeadObject,
|
||||||
Role: reqInfo.Role,
|
Role: reqInfo.Role,
|
||||||
SenderKey: reqInfo.SenderKey,
|
SenderKey: reqInfo.SenderKey,
|
||||||
ContainerOwner: reqInfo.ContainerOwner,
|
ContainerOwner: reqInfo.ContainerOwner,
|
||||||
BearerToken: md.BearerToken,
|
ContainerAttributes: reqInfo.ContainerAttributes,
|
||||||
XHeaders: md.MetaHeader.GetXHeaders(),
|
BearerToken: md.BearerToken,
|
||||||
|
XHeaders: md.MetaHeader.GetXHeaders(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, toStatusErr(err)
|
return nil, toStatusErr(err)
|
||||||
|
@ -296,14 +300,15 @@ func (c *Service) Search(request *objectV2.SearchRequest, stream objectSvc.Searc
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.apeChecker.CheckAPE(stream.Context(), Prm{
|
err = c.apeChecker.CheckAPE(stream.Context(), Prm{
|
||||||
Namespace: reqInfo.Namespace,
|
Namespace: reqInfo.Namespace,
|
||||||
Container: md.Container,
|
Container: md.Container,
|
||||||
Method: nativeschema.MethodSearchObject,
|
Method: nativeschema.MethodSearchObject,
|
||||||
Role: reqInfo.Role,
|
Role: reqInfo.Role,
|
||||||
SenderKey: reqInfo.SenderKey,
|
SenderKey: reqInfo.SenderKey,
|
||||||
ContainerOwner: reqInfo.ContainerOwner,
|
ContainerOwner: reqInfo.ContainerOwner,
|
||||||
BearerToken: md.BearerToken,
|
ContainerAttributes: reqInfo.ContainerAttributes,
|
||||||
XHeaders: md.MetaHeader.GetXHeaders(),
|
BearerToken: md.BearerToken,
|
||||||
|
XHeaders: md.MetaHeader.GetXHeaders(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return toStatusErr(err)
|
return toStatusErr(err)
|
||||||
|
@ -323,15 +328,16 @@ func (c *Service) Delete(ctx context.Context, request *objectV2.DeleteRequest) (
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.apeChecker.CheckAPE(ctx, Prm{
|
err = c.apeChecker.CheckAPE(ctx, Prm{
|
||||||
Namespace: reqInfo.Namespace,
|
Namespace: reqInfo.Namespace,
|
||||||
Container: md.Container,
|
Container: md.Container,
|
||||||
Object: md.Object,
|
Object: md.Object,
|
||||||
Method: nativeschema.MethodDeleteObject,
|
Method: nativeschema.MethodDeleteObject,
|
||||||
Role: reqInfo.Role,
|
Role: reqInfo.Role,
|
||||||
SenderKey: reqInfo.SenderKey,
|
SenderKey: reqInfo.SenderKey,
|
||||||
ContainerOwner: reqInfo.ContainerOwner,
|
ContainerOwner: reqInfo.ContainerOwner,
|
||||||
BearerToken: md.BearerToken,
|
ContainerAttributes: reqInfo.ContainerAttributes,
|
||||||
XHeaders: md.MetaHeader.GetXHeaders(),
|
BearerToken: md.BearerToken,
|
||||||
|
XHeaders: md.MetaHeader.GetXHeaders(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, toStatusErr(err)
|
return nil, toStatusErr(err)
|
||||||
|
@ -356,15 +362,16 @@ func (c *Service) GetRange(request *objectV2.GetRangeRequest, stream objectSvc.G
|
||||||
}
|
}
|
||||||
|
|
||||||
err = c.apeChecker.CheckAPE(stream.Context(), Prm{
|
err = c.apeChecker.CheckAPE(stream.Context(), Prm{
|
||||||
Namespace: reqInfo.Namespace,
|
Namespace: reqInfo.Namespace,
|
||||||
Container: md.Container,
|
Container: md.Container,
|
||||||
Object: md.Object,
|
Object: md.Object,
|
||||||
Method: nativeschema.MethodRangeObject,
|
Method: nativeschema.MethodRangeObject,
|
||||||
Role: reqInfo.Role,
|
Role: reqInfo.Role,
|
||||||
SenderKey: reqInfo.SenderKey,
|
SenderKey: reqInfo.SenderKey,
|
||||||
ContainerOwner: reqInfo.ContainerOwner,
|
ContainerOwner: reqInfo.ContainerOwner,
|
||||||
BearerToken: md.BearerToken,
|
ContainerAttributes: reqInfo.ContainerAttributes,
|
||||||
XHeaders: md.MetaHeader.GetXHeaders(),
|
BearerToken: md.BearerToken,
|
||||||
|
XHeaders: md.MetaHeader.GetXHeaders(),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return toStatusErr(err)
|
return toStatusErr(err)
|
||||||
|
@ -384,15 +391,16 @@ func (c *Service) GetRangeHash(ctx context.Context, request *objectV2.GetRangeHa
|
||||||
}
|
}
|
||||||
|
|
||||||
prm := Prm{
|
prm := Prm{
|
||||||
Namespace: reqInfo.Namespace,
|
Namespace: reqInfo.Namespace,
|
||||||
Container: md.Container,
|
Container: md.Container,
|
||||||
Object: md.Object,
|
Object: md.Object,
|
||||||
Method: nativeschema.MethodHashObject,
|
Method: nativeschema.MethodHashObject,
|
||||||
Role: reqInfo.Role,
|
Role: reqInfo.Role,
|
||||||
SenderKey: reqInfo.SenderKey,
|
SenderKey: reqInfo.SenderKey,
|
||||||
ContainerOwner: reqInfo.ContainerOwner,
|
ContainerOwner: reqInfo.ContainerOwner,
|
||||||
BearerToken: md.BearerToken,
|
ContainerAttributes: reqInfo.ContainerAttributes,
|
||||||
XHeaders: md.MetaHeader.GetXHeaders(),
|
BearerToken: md.BearerToken,
|
||||||
|
XHeaders: md.MetaHeader.GetXHeaders(),
|
||||||
}
|
}
|
||||||
|
|
||||||
resp, err := c.next.GetRangeHash(ctx, request)
|
resp, err := c.next.GetRangeHash(ctx, request)
|
||||||
|
@ -417,16 +425,17 @@ func (c *Service) PutSingle(ctx context.Context, request *objectV2.PutSingleRequ
|
||||||
}
|
}
|
||||||
|
|
||||||
prm := Prm{
|
prm := Prm{
|
||||||
Namespace: reqInfo.Namespace,
|
Namespace: reqInfo.Namespace,
|
||||||
Container: md.Container,
|
Container: md.Container,
|
||||||
Object: md.Object,
|
Object: md.Object,
|
||||||
Header: request.GetBody().GetObject().GetHeader(),
|
Header: request.GetBody().GetObject().GetHeader(),
|
||||||
Method: nativeschema.MethodPutObject,
|
Method: nativeschema.MethodPutObject,
|
||||||
Role: reqInfo.Role,
|
Role: reqInfo.Role,
|
||||||
SenderKey: reqInfo.SenderKey,
|
SenderKey: reqInfo.SenderKey,
|
||||||
ContainerOwner: reqInfo.ContainerOwner,
|
ContainerOwner: reqInfo.ContainerOwner,
|
||||||
BearerToken: md.BearerToken,
|
ContainerAttributes: reqInfo.ContainerAttributes,
|
||||||
XHeaders: md.MetaHeader.GetXHeaders(),
|
BearerToken: md.BearerToken,
|
||||||
|
XHeaders: md.MetaHeader.GetXHeaders(),
|
||||||
}
|
}
|
||||||
|
|
||||||
if err = c.apeChecker.CheckAPE(ctx, prm); err != nil {
|
if err = c.apeChecker.CheckAPE(ctx, prm); err != nil {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue