git mv Ingress ingress
This commit is contained in:
parent
34b949c134
commit
3da4e74e5a
2185 changed files with 754743 additions and 0 deletions
242
controllers/gce/backends/backends.go
Normal file
242
controllers/gce/backends/backends.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/*
|
||||
Copyright 2015 The Kubernetes Authors All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package backends
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"k8s.io/kubernetes/pkg/util/sets"
|
||||
|
||||
"github.com/golang/glog"
|
||||
compute "google.golang.org/api/compute/v1"
|
||||
"k8s.io/contrib/ingress/controllers/gce/healthchecks"
|
||||
"k8s.io/contrib/ingress/controllers/gce/instances"
|
||||
"k8s.io/contrib/ingress/controllers/gce/storage"
|
||||
"k8s.io/contrib/ingress/controllers/gce/utils"
|
||||
)
|
||||
|
||||
// Backends implements BackendPool.
|
||||
type Backends struct {
|
||||
cloud BackendServices
|
||||
nodePool instances.NodePool
|
||||
healthChecker healthchecks.HealthChecker
|
||||
snapshotter storage.Snapshotter
|
||||
namer utils.Namer
|
||||
}
|
||||
|
||||
func portKey(port int64) string {
|
||||
return fmt.Sprintf("%d", port)
|
||||
}
|
||||
|
||||
// NewBackendPool returns a new backend pool.
|
||||
// - cloud: implements BackendServices and syncs backends with a cloud provider
|
||||
// - nodePool: implements NodePool, used to create/delete new instance groups.
|
||||
func NewBackendPool(
|
||||
cloud BackendServices,
|
||||
healthChecker healthchecks.HealthChecker,
|
||||
nodePool instances.NodePool, namer utils.Namer) *Backends {
|
||||
return &Backends{
|
||||
cloud: cloud,
|
||||
nodePool: nodePool,
|
||||
snapshotter: storage.NewInMemoryPool(),
|
||||
healthChecker: healthChecker,
|
||||
namer: namer,
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a single backend.
|
||||
func (b *Backends) Get(port int64) (*compute.BackendService, error) {
|
||||
be, err := b.cloud.GetBackendService(b.namer.BeName(port))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.snapshotter.Add(portKey(port), be)
|
||||
return be, nil
|
||||
}
|
||||
|
||||
func (b *Backends) create(ig *compute.InstanceGroup, namedPort *compute.NamedPort, name string) (*compute.BackendService, error) {
|
||||
// Create a new health check
|
||||
if err := b.healthChecker.Add(namedPort.Port, ""); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
hc, err := b.healthChecker.Get(namedPort.Port)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Create a new backend
|
||||
backend := &compute.BackendService{
|
||||
Name: name,
|
||||
Protocol: "HTTP",
|
||||
Backends: []*compute.Backend{
|
||||
{
|
||||
Group: ig.SelfLink,
|
||||
},
|
||||
},
|
||||
// Api expects one, means little to kubernetes.
|
||||
HealthChecks: []string{hc.SelfLink},
|
||||
Port: namedPort.Port,
|
||||
PortName: namedPort.Name,
|
||||
}
|
||||
if err := b.cloud.CreateBackendService(backend); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return b.Get(namedPort.Port)
|
||||
}
|
||||
|
||||
// Add will get or create a Backend for the given port.
|
||||
func (b *Backends) Add(port int64) error {
|
||||
// We must track the port even if creating the backend failed, because
|
||||
// we might've created a health-check for it.
|
||||
be := &compute.BackendService{}
|
||||
defer func() { b.snapshotter.Add(portKey(port), be) }()
|
||||
|
||||
ig, namedPort, err := b.nodePool.AddInstanceGroup(b.namer.IGName(), port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
be, _ = b.Get(port)
|
||||
if be == nil {
|
||||
glog.Infof("Creating backend for instance group %v port %v named port %v",
|
||||
ig.Name, port, namedPort)
|
||||
be, err = b.create(ig, namedPort, b.namer.BeName(port))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := b.edgeHop(be, ig); err != nil {
|
||||
return err
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// Delete deletes the Backend for the given port.
|
||||
func (b *Backends) Delete(port int64) (err error) {
|
||||
name := b.namer.BeName(port)
|
||||
glog.Infof("Deleting backend %v", name)
|
||||
defer func() {
|
||||
if utils.IsHTTPErrorCode(err, http.StatusNotFound) {
|
||||
err = nil
|
||||
}
|
||||
if err == nil {
|
||||
b.snapshotter.Delete(portKey(port))
|
||||
}
|
||||
}()
|
||||
// Try deleting health checks even if a backend is not found.
|
||||
if err = b.cloud.DeleteBackendService(name); err != nil &&
|
||||
!utils.IsHTTPErrorCode(err, http.StatusNotFound) {
|
||||
return err
|
||||
}
|
||||
if err = b.healthChecker.Delete(port); err != nil &&
|
||||
!utils.IsHTTPErrorCode(err, http.StatusNotFound) {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// List lists all backends.
|
||||
func (b *Backends) List() (*compute.BackendServiceList, error) {
|
||||
// TODO: for consistency with the rest of this sub-package this method
|
||||
// should return a list of backend ports.
|
||||
return b.cloud.ListBackendServices()
|
||||
}
|
||||
|
||||
// edgeHop checks the links of the given backend by executing an edge hop.
|
||||
// It fixes broken links.
|
||||
func (b *Backends) edgeHop(be *compute.BackendService, ig *compute.InstanceGroup) error {
|
||||
if len(be.Backends) == 1 &&
|
||||
utils.CompareLinks(be.Backends[0].Group, ig.SelfLink) {
|
||||
return nil
|
||||
}
|
||||
glog.Infof("Backend %v has a broken edge, adding link to %v",
|
||||
be.Name, ig.Name)
|
||||
be.Backends = []*compute.Backend{
|
||||
{Group: ig.SelfLink},
|
||||
}
|
||||
if err := b.cloud.UpdateBackendService(be); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Sync syncs backend services corresponding to ports in the given list.
|
||||
func (b *Backends) Sync(svcNodePorts []int64) error {
|
||||
glog.V(3).Infof("Sync: backends %v", svcNodePorts)
|
||||
|
||||
// create backends for new ports, perform an edge hop for existing ports
|
||||
for _, port := range svcNodePorts {
|
||||
if err := b.Add(port); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GC garbage collects services corresponding to ports in the given list.
|
||||
func (b *Backends) GC(svcNodePorts []int64) error {
|
||||
knownPorts := sets.NewString()
|
||||
for _, port := range svcNodePorts {
|
||||
knownPorts.Insert(portKey(port))
|
||||
}
|
||||
pool := b.snapshotter.Snapshot()
|
||||
for port := range pool {
|
||||
p, err := strconv.Atoi(port)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
nodePort := int64(p)
|
||||
if knownPorts.Has(portKey(nodePort)) {
|
||||
continue
|
||||
}
|
||||
glog.V(3).Infof("GCing backend for port %v", p)
|
||||
if err := b.Delete(nodePort); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(svcNodePorts) == 0 {
|
||||
glog.Infof("Deleting instance group %v", b.namer.IGName())
|
||||
if err := b.nodePool.DeleteInstanceGroup(b.namer.IGName()); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Shutdown deletes all backends and the default backend.
|
||||
// This will fail if one of the backends is being used by another resource.
|
||||
func (b *Backends) Shutdown() error {
|
||||
if err := b.GC([]int64{}); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Status returns the status of the given backend by name.
|
||||
func (b *Backends) Status(name string) string {
|
||||
backend, err := b.cloud.GetBackendService(name)
|
||||
if err != nil {
|
||||
return "Unknown"
|
||||
}
|
||||
// TODO: Include port, ip in the status, since it's in the health info.
|
||||
hs, err := b.cloud.GetHealth(name, backend.Backends[0].Group)
|
||||
if err != nil || len(hs.HealthStatus) == 0 || hs.HealthStatus[0] == nil {
|
||||
return "Unknown"
|
||||
}
|
||||
// TODO: State transition are important, not just the latest.
|
||||
return hs.HealthStatus[0].HealthState
|
||||
}
|
||||
126
controllers/gce/backends/backends_test.go
Normal file
126
controllers/gce/backends/backends_test.go
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
/*
|
||||
Copyright 2015 The Kubernetes Authors All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package backends
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"k8s.io/contrib/ingress/controllers/gce/healthchecks"
|
||||
"k8s.io/contrib/ingress/controllers/gce/instances"
|
||||
"k8s.io/contrib/ingress/controllers/gce/utils"
|
||||
"k8s.io/kubernetes/pkg/util/sets"
|
||||
)
|
||||
|
||||
func newBackendPool(f BackendServices, fakeIGs instances.InstanceGroups) BackendPool {
|
||||
namer := utils.Namer{}
|
||||
return NewBackendPool(
|
||||
f,
|
||||
healthchecks.NewHealthChecker(healthchecks.NewFakeHealthChecks(), "/", namer),
|
||||
instances.NewNodePool(fakeIGs, "default-zone"), namer)
|
||||
}
|
||||
|
||||
func TestBackendPoolAdd(t *testing.T) {
|
||||
f := NewFakeBackendServices()
|
||||
fakeIGs := instances.NewFakeInstanceGroups(sets.NewString())
|
||||
pool := newBackendPool(f, fakeIGs)
|
||||
namer := utils.Namer{}
|
||||
|
||||
// Add a backend for a port, then re-add the same port and
|
||||
// make sure it corrects a broken link from the backend to
|
||||
// the instance group.
|
||||
nodePort := int64(8080)
|
||||
pool.Add(nodePort)
|
||||
beName := namer.BeName(nodePort)
|
||||
|
||||
// Check that the new backend has the right port
|
||||
be, err := f.GetBackendService(beName)
|
||||
if err != nil {
|
||||
t.Fatalf("Did not find expected backend %v", beName)
|
||||
}
|
||||
if be.Port != nodePort {
|
||||
t.Fatalf("Backend %v has wrong port %v, expected %v", be.Name, be.Port, nodePort)
|
||||
}
|
||||
// Check that the instance group has the new port
|
||||
var found bool
|
||||
for _, port := range fakeIGs.Ports {
|
||||
if port == nodePort {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("Port %v not added to instance group", nodePort)
|
||||
}
|
||||
|
||||
// Mess up the link between backend service and instance group.
|
||||
// This simulates a user doing foolish things through the UI.
|
||||
f.calls = []int{}
|
||||
be, err = f.GetBackendService(beName)
|
||||
be.Backends[0].Group = "test edge hop"
|
||||
f.UpdateBackendService(be)
|
||||
|
||||
pool.Add(nodePort)
|
||||
for _, call := range f.calls {
|
||||
if call == utils.Create {
|
||||
t.Fatalf("Unexpected create for existing backend service")
|
||||
}
|
||||
}
|
||||
gotBackend, _ := f.GetBackendService(beName)
|
||||
gotGroup, _ := fakeIGs.GetInstanceGroup(namer.IGName(), "default-zone")
|
||||
if gotBackend.Backends[0].Group != gotGroup.SelfLink {
|
||||
t.Fatalf(
|
||||
"Broken instance group link: %v %v",
|
||||
gotBackend.Backends[0].Group,
|
||||
gotGroup.SelfLink)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackendPoolSync(t *testing.T) {
|
||||
|
||||
// Call sync on a backend pool with a list of ports, make sure the pool
|
||||
// creates/deletes required ports.
|
||||
svcNodePorts := []int64{81, 82, 83}
|
||||
f := NewFakeBackendServices()
|
||||
fakeIGs := instances.NewFakeInstanceGroups(sets.NewString())
|
||||
pool := newBackendPool(f, fakeIGs)
|
||||
pool.Add(81)
|
||||
pool.Add(90)
|
||||
pool.Sync(svcNodePorts)
|
||||
pool.GC(svcNodePorts)
|
||||
if _, err := pool.Get(90); err == nil {
|
||||
t.Fatalf("Did not expect to find port 90")
|
||||
}
|
||||
for _, port := range svcNodePorts {
|
||||
if _, err := pool.Get(port); err != nil {
|
||||
t.Fatalf("Expected to find port %v", port)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestBackendPoolShutdown(t *testing.T) {
|
||||
f := NewFakeBackendServices()
|
||||
fakeIGs := instances.NewFakeInstanceGroups(sets.NewString())
|
||||
pool := newBackendPool(f, fakeIGs)
|
||||
namer := utils.Namer{}
|
||||
|
||||
pool.Add(80)
|
||||
pool.Shutdown()
|
||||
if _, err := f.GetBackendService(namer.BeName(80)); err == nil {
|
||||
t.Fatalf("%v", err)
|
||||
}
|
||||
|
||||
}
|
||||
145
controllers/gce/backends/fakes.go
Normal file
145
controllers/gce/backends/fakes.go
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
Copyright 2015 The Kubernetes Authors All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package backends
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
compute "google.golang.org/api/compute/v1"
|
||||
"k8s.io/contrib/ingress/controllers/gce/utils"
|
||||
)
|
||||
|
||||
// NewFakeBackendServices creates a new fake backend services manager.
|
||||
func NewFakeBackendServices() *FakeBackendServices {
|
||||
return &FakeBackendServices{
|
||||
backendServices: []*compute.BackendService{},
|
||||
}
|
||||
}
|
||||
|
||||
// FakeBackendServices fakes out GCE backend services.
|
||||
type FakeBackendServices struct {
|
||||
backendServices []*compute.BackendService
|
||||
calls []int
|
||||
}
|
||||
|
||||
// GetBackendService fakes getting a backend service from the cloud.
|
||||
func (f *FakeBackendServices) GetBackendService(name string) (*compute.BackendService, error) {
|
||||
f.calls = append(f.calls, utils.Get)
|
||||
for i := range f.backendServices {
|
||||
if name == f.backendServices[i].Name {
|
||||
return f.backendServices[i], nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("Backend service %v not found", name)
|
||||
}
|
||||
|
||||
// CreateBackendService fakes backend service creation.
|
||||
func (f *FakeBackendServices) CreateBackendService(be *compute.BackendService) error {
|
||||
f.calls = append(f.calls, utils.Create)
|
||||
be.SelfLink = be.Name
|
||||
f.backendServices = append(f.backendServices, be)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBackendService fakes backend service deletion.
|
||||
func (f *FakeBackendServices) DeleteBackendService(name string) error {
|
||||
f.calls = append(f.calls, utils.Delete)
|
||||
newBackends := []*compute.BackendService{}
|
||||
for i := range f.backendServices {
|
||||
if name != f.backendServices[i].Name {
|
||||
newBackends = append(newBackends, f.backendServices[i])
|
||||
}
|
||||
}
|
||||
f.backendServices = newBackends
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListBackendServices fakes backend service listing.
|
||||
func (f *FakeBackendServices) ListBackendServices() (*compute.BackendServiceList, error) {
|
||||
return &compute.BackendServiceList{Items: f.backendServices}, nil
|
||||
}
|
||||
|
||||
// UpdateBackendService fakes updating a backend service.
|
||||
func (f *FakeBackendServices) UpdateBackendService(be *compute.BackendService) error {
|
||||
f.calls = append(f.calls, utils.Update)
|
||||
for i := range f.backendServices {
|
||||
if f.backendServices[i].Name == be.Name {
|
||||
f.backendServices[i] = be
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHealth fakes getting backend service health.
|
||||
func (f *FakeBackendServices) GetHealth(name, instanceGroupLink string) (*compute.BackendServiceGroupHealth, error) {
|
||||
be, err := f.GetBackendService(name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
states := []*compute.HealthStatus{
|
||||
{
|
||||
HealthState: "HEALTHY",
|
||||
IpAddress: "",
|
||||
Port: be.Port,
|
||||
},
|
||||
}
|
||||
return &compute.BackendServiceGroupHealth{
|
||||
HealthStatus: states}, nil
|
||||
}
|
||||
|
||||
// NewFakeHealthChecks returns a health check fake.
|
||||
func NewFakeHealthChecks() *FakeHealthChecks {
|
||||
return &FakeHealthChecks{hc: []*compute.HttpHealthCheck{}}
|
||||
}
|
||||
|
||||
// FakeHealthChecks fakes out health checks.
|
||||
type FakeHealthChecks struct {
|
||||
hc []*compute.HttpHealthCheck
|
||||
}
|
||||
|
||||
// CreateHttpHealthCheck fakes health check creation.
|
||||
func (f *FakeHealthChecks) CreateHttpHealthCheck(hc *compute.HttpHealthCheck) error {
|
||||
f.hc = append(f.hc, hc)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetHttpHealthCheck fakes getting a http health check.
|
||||
func (f *FakeHealthChecks) GetHttpHealthCheck(name string) (*compute.HttpHealthCheck, error) {
|
||||
for _, h := range f.hc {
|
||||
if h.Name == name {
|
||||
return h, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("Health check %v not found.", name)
|
||||
}
|
||||
|
||||
// DeleteHttpHealthCheck fakes deleting a http health check.
|
||||
func (f *FakeHealthChecks) DeleteHttpHealthCheck(name string) error {
|
||||
healthChecks := []*compute.HttpHealthCheck{}
|
||||
exists := false
|
||||
for _, h := range f.hc {
|
||||
if h.Name == name {
|
||||
exists = true
|
||||
continue
|
||||
}
|
||||
healthChecks = append(healthChecks, h)
|
||||
}
|
||||
if !exists {
|
||||
return fmt.Errorf("Failed to find health check %v", name)
|
||||
}
|
||||
f.hc = healthChecks
|
||||
return nil
|
||||
}
|
||||
58
controllers/gce/backends/interfaces.go
Normal file
58
controllers/gce/backends/interfaces.go
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
/*
|
||||
Copyright 2015 The Kubernetes Authors All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
package backends
|
||||
|
||||
import (
|
||||
compute "google.golang.org/api/compute/v1"
|
||||
)
|
||||
|
||||
// BackendPool is an interface to manage a pool of kubernetes nodePort services
|
||||
// as gce backendServices, and sync them through the BackendServices interface.
|
||||
type BackendPool interface {
|
||||
Add(port int64) error
|
||||
Get(port int64) (*compute.BackendService, error)
|
||||
Delete(port int64) error
|
||||
Sync(ports []int64) error
|
||||
GC(ports []int64) error
|
||||
Shutdown() error
|
||||
Status(name string) string
|
||||
List() (*compute.BackendServiceList, error)
|
||||
}
|
||||
|
||||
// BackendServices is an interface for managing gce backend services.
|
||||
type BackendServices interface {
|
||||
GetBackendService(name string) (*compute.BackendService, error)
|
||||
UpdateBackendService(bg *compute.BackendService) error
|
||||
CreateBackendService(bg *compute.BackendService) error
|
||||
DeleteBackendService(name string) error
|
||||
ListBackendServices() (*compute.BackendServiceList, error)
|
||||
GetHealth(name, instanceGroupLink string) (*compute.BackendServiceGroupHealth, error)
|
||||
}
|
||||
|
||||
// SingleHealthCheck is an interface to manage a single GCE health check.
|
||||
type SingleHealthCheck interface {
|
||||
CreateHttpHealthCheck(hc *compute.HttpHealthCheck) error
|
||||
DeleteHttpHealthCheck(name string) error
|
||||
GetHttpHealthCheck(name string) (*compute.HttpHealthCheck, error)
|
||||
}
|
||||
|
||||
// HealthChecker is an interface to manage cloud HTTPHealthChecks.
|
||||
type HealthChecker interface {
|
||||
Add(port int64, path string) error
|
||||
Delete(port int64) error
|
||||
Get(port int64) (*compute.HttpHealthCheck, error)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue