2022-03-22 11:03:15 -04:00
|
|
|
package client
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"errors"
|
|
|
|
"fmt"
|
|
|
|
"strings"
|
|
|
|
"time"
|
|
|
|
|
2022-05-20 03:43:31 -04:00
|
|
|
"github.com/edgelesssys/constellation/coordinator/role"
|
2022-06-08 02:17:52 -04:00
|
|
|
"github.com/edgelesssys/constellation/internal/cloud/cloudtypes"
|
2022-03-22 11:03:15 -04:00
|
|
|
"google.golang.org/api/iterator"
|
|
|
|
computepb "google.golang.org/genproto/googleapis/cloud/compute/v1"
|
|
|
|
"google.golang.org/protobuf/proto"
|
|
|
|
)
|
|
|
|
|
|
|
|
// CreateInstances creates instances (virtual machines) on Google Compute Engine.
|
|
|
|
//
|
|
|
|
// A separate managed instance group is created for coordinators and nodes, the function
|
|
|
|
// waits until the instances are up and stores the public and private IPs of the instances
|
|
|
|
// in the client. If the client's network must be set before instances can be created.
|
|
|
|
func (c *Client) CreateInstances(ctx context.Context, input CreateInstancesInput) error {
|
|
|
|
if c.network == "" {
|
|
|
|
return errors.New("client has no network")
|
|
|
|
}
|
|
|
|
ops := []Operation{}
|
|
|
|
|
|
|
|
nodeTemplateInput := insertInstanceTemplateInput{
|
|
|
|
Name: c.name + "-worker-" + c.uid,
|
|
|
|
Network: c.network,
|
|
|
|
SecondarySubnetworkRangeName: c.secondarySubnetworkRange,
|
|
|
|
Subnetwork: c.subnetwork,
|
|
|
|
ImageId: input.ImageId,
|
|
|
|
InstanceType: input.InstanceType,
|
2022-04-05 08:25:49 -04:00
|
|
|
StateDiskSizeGB: int64(input.StateDiskSizeGB),
|
2022-05-20 03:43:31 -04:00
|
|
|
Role: role.Node.String(),
|
2022-03-22 11:03:15 -04:00
|
|
|
KubeEnv: input.KubeEnv,
|
|
|
|
Project: c.project,
|
|
|
|
Zone: c.zone,
|
|
|
|
Region: c.region,
|
|
|
|
UID: c.uid,
|
|
|
|
}
|
|
|
|
op, err := c.insertInstanceTemplate(ctx, nodeTemplateInput)
|
|
|
|
if err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("inserting instanceTemplate: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
ops = append(ops, op)
|
|
|
|
c.nodeTemplate = nodeTemplateInput.Name
|
|
|
|
|
|
|
|
coordinatorTemplateInput := insertInstanceTemplateInput{
|
2022-05-24 04:04:42 -04:00
|
|
|
Name: c.name + "-control-plane-" + c.uid,
|
|
|
|
Network: c.network,
|
|
|
|
Subnetwork: c.subnetwork,
|
|
|
|
SecondarySubnetworkRangeName: c.secondarySubnetworkRange,
|
|
|
|
ImageId: input.ImageId,
|
|
|
|
InstanceType: input.InstanceType,
|
|
|
|
StateDiskSizeGB: int64(input.StateDiskSizeGB),
|
|
|
|
Role: role.Coordinator.String(),
|
|
|
|
KubeEnv: input.KubeEnv,
|
|
|
|
Project: c.project,
|
|
|
|
Zone: c.zone,
|
|
|
|
Region: c.region,
|
|
|
|
UID: c.uid,
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
op, err = c.insertInstanceTemplate(ctx, coordinatorTemplateInput)
|
|
|
|
if err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("inserting instanceTemplate: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
ops = append(ops, op)
|
|
|
|
c.coordinatorTemplate = coordinatorTemplateInput.Name
|
|
|
|
if err := c.waitForOperations(ctx, ops); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
ops = []Operation{}
|
|
|
|
|
2022-05-24 04:04:42 -04:00
|
|
|
coordinatorGroupInput := instanceGroupManagerInput{
|
|
|
|
Count: input.CountCoordinators,
|
|
|
|
Name: strings.Join([]string{c.name, "control-plane", c.uid}, "-"),
|
|
|
|
Template: c.coordinatorTemplate,
|
2022-03-22 11:03:15 -04:00
|
|
|
UID: c.uid,
|
|
|
|
Project: c.project,
|
|
|
|
Zone: c.zone,
|
|
|
|
}
|
2022-05-24 04:04:42 -04:00
|
|
|
op, err = c.insertInstanceGroupManger(ctx, coordinatorGroupInput)
|
2022-03-22 11:03:15 -04:00
|
|
|
if err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("inserting instanceGroupManager: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
ops = append(ops, op)
|
2022-05-24 04:04:42 -04:00
|
|
|
c.coordinatorInstanceGroup = coordinatorGroupInput.Name
|
2022-03-22 11:03:15 -04:00
|
|
|
|
2022-05-24 04:04:42 -04:00
|
|
|
nodeGroupInput := instanceGroupManagerInput{
|
|
|
|
Count: input.CountNodes,
|
|
|
|
Name: strings.Join([]string{c.name, "worker", c.uid}, "-"),
|
|
|
|
Template: c.nodeTemplate,
|
2022-03-22 11:03:15 -04:00
|
|
|
UID: c.uid,
|
|
|
|
Project: c.project,
|
|
|
|
Zone: c.zone,
|
|
|
|
}
|
2022-05-24 04:04:42 -04:00
|
|
|
op, err = c.insertInstanceGroupManger(ctx, nodeGroupInput)
|
2022-03-22 11:03:15 -04:00
|
|
|
if err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("inserting instanceGroupManager: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
ops = append(ops, op)
|
2022-05-24 04:04:42 -04:00
|
|
|
c.nodesInstanceGroup = nodeGroupInput.Name
|
|
|
|
|
2022-03-22 11:03:15 -04:00
|
|
|
if err := c.waitForOperations(ctx, ops); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := c.waitForInstanceGroupScaling(ctx, c.nodesInstanceGroup); err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("waiting for instanceGroupScaling: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
if err := c.waitForInstanceGroupScaling(ctx, c.coordinatorInstanceGroup); err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("waiting for instanceGroupScaling: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
if err := c.getInstanceIPs(ctx, c.nodesInstanceGroup, c.nodes); err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("getting instanceIPs: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
if err := c.getInstanceIPs(ctx, c.coordinatorInstanceGroup, c.coordinators); err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("getting instanceIPs: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
|
|
|
|
// TerminateInstances terminates the clients instances.
|
|
|
|
func (c *Client) TerminateInstances(ctx context.Context) error {
|
|
|
|
ops := []Operation{}
|
|
|
|
if c.nodesInstanceGroup != "" {
|
|
|
|
op, err := c.deleteInstanceGroupManager(ctx, c.nodesInstanceGroup)
|
|
|
|
if err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("deleting instanceGroupManager '%s': %w", c.nodesInstanceGroup, err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
ops = append(ops, op)
|
|
|
|
c.nodesInstanceGroup = ""
|
2022-06-07 05:35:08 -04:00
|
|
|
c.nodes = make(cloudtypes.Instances)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
if c.coordinatorInstanceGroup != "" {
|
|
|
|
op, err := c.deleteInstanceGroupManager(ctx, c.coordinatorInstanceGroup)
|
|
|
|
if err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("deleting instanceGroupManager '%s': %w", c.coordinatorInstanceGroup, err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
ops = append(ops, op)
|
|
|
|
c.coordinatorInstanceGroup = ""
|
2022-06-07 05:35:08 -04:00
|
|
|
c.coordinators = make(cloudtypes.Instances)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
if err := c.waitForOperations(ctx, ops); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
ops = []Operation{}
|
|
|
|
|
|
|
|
if c.nodeTemplate != "" {
|
|
|
|
op, err := c.deleteInstanceTemplate(ctx, c.nodeTemplate)
|
|
|
|
if err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("deleting instanceTemplate: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
ops = append(ops, op)
|
|
|
|
c.nodeTemplate = ""
|
|
|
|
}
|
|
|
|
if c.coordinatorTemplate != "" {
|
|
|
|
op, err := c.deleteInstanceTemplate(ctx, c.coordinatorTemplate)
|
|
|
|
if err != nil {
|
2022-06-09 10:04:30 -04:00
|
|
|
return fmt.Errorf("deleting instanceTemplate: %w", err)
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
ops = append(ops, op)
|
|
|
|
c.coordinatorTemplate = ""
|
|
|
|
}
|
|
|
|
return c.waitForOperations(ctx, ops)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *Client) insertInstanceTemplate(ctx context.Context, input insertInstanceTemplateInput) (Operation, error) {
|
|
|
|
req := input.insertInstanceTemplateRequest()
|
|
|
|
return c.instanceTemplateAPI.Insert(ctx, req)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *Client) deleteInstanceTemplate(ctx context.Context, name string) (Operation, error) {
|
|
|
|
req := &computepb.DeleteInstanceTemplateRequest{
|
|
|
|
InstanceTemplate: name,
|
|
|
|
Project: c.project,
|
|
|
|
}
|
|
|
|
return c.instanceTemplateAPI.Delete(ctx, req)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *Client) insertInstanceGroupManger(ctx context.Context, input instanceGroupManagerInput) (Operation, error) {
|
|
|
|
req := input.InsertInstanceGroupManagerRequest()
|
|
|
|
return c.instanceGroupManagersAPI.Insert(ctx, &req)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *Client) deleteInstanceGroupManager(ctx context.Context, instanceGroupManagerName string) (Operation, error) {
|
|
|
|
req := &computepb.DeleteInstanceGroupManagerRequest{
|
|
|
|
InstanceGroupManager: instanceGroupManagerName,
|
|
|
|
Project: c.project,
|
|
|
|
Zone: c.zone,
|
|
|
|
}
|
|
|
|
return c.instanceGroupManagersAPI.Delete(ctx, req)
|
|
|
|
}
|
|
|
|
|
|
|
|
func (c *Client) waitForInstanceGroupScaling(ctx context.Context, groupId string) error {
|
|
|
|
for {
|
|
|
|
if err := ctx.Err(); err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
listReq := &computepb.ListManagedInstancesInstanceGroupManagersRequest{
|
|
|
|
InstanceGroupManager: groupId,
|
|
|
|
Project: c.project,
|
|
|
|
Zone: c.zone,
|
|
|
|
}
|
|
|
|
it := c.instanceGroupManagersAPI.ListManagedInstances(ctx, listReq)
|
|
|
|
for {
|
|
|
|
resp, err := it.Next()
|
|
|
|
if errors.Is(err, iterator.Done) {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if resp.CurrentAction == nil {
|
|
|
|
return errors.New("currentAction is nil")
|
|
|
|
}
|
|
|
|
if *resp.CurrentAction != computepb.ManagedInstance_NONE.String() {
|
|
|
|
time.Sleep(5 * time.Second)
|
|
|
|
break
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// getInstanceIPs requests the IPs of the client's instances.
|
2022-06-07 05:35:08 -04:00
|
|
|
func (c *Client) getInstanceIPs(ctx context.Context, groupId string, list cloudtypes.Instances) error {
|
2022-03-22 11:03:15 -04:00
|
|
|
req := &computepb.ListInstancesRequest{
|
|
|
|
Filter: proto.String("name=" + groupId + "*"),
|
|
|
|
Project: c.project,
|
|
|
|
Zone: c.zone,
|
|
|
|
}
|
|
|
|
it := c.instanceAPI.List(ctx, req)
|
|
|
|
for {
|
|
|
|
resp, err := it.Next()
|
|
|
|
if errors.Is(err, iterator.Done) {
|
|
|
|
return nil
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
if resp.Name == nil {
|
|
|
|
return errors.New("instance name is nil pointer")
|
|
|
|
}
|
|
|
|
if len(resp.NetworkInterfaces) == 0 {
|
|
|
|
return errors.New("network interface is empty")
|
|
|
|
}
|
|
|
|
if resp.NetworkInterfaces[0].NetworkIP == nil {
|
|
|
|
return errors.New("networkIP is nil")
|
|
|
|
}
|
|
|
|
if len(resp.NetworkInterfaces[0].AccessConfigs) == 0 {
|
|
|
|
return errors.New("access configs is empty")
|
|
|
|
}
|
|
|
|
if resp.NetworkInterfaces[0].AccessConfigs[0].NatIP == nil {
|
|
|
|
return errors.New("natIP is nil")
|
|
|
|
}
|
2022-06-07 05:35:08 -04:00
|
|
|
instance := cloudtypes.Instance{
|
2022-03-22 11:03:15 -04:00
|
|
|
PrivateIP: *resp.NetworkInterfaces[0].NetworkIP,
|
|
|
|
PublicIP: *resp.NetworkInterfaces[0].AccessConfigs[0].NatIP,
|
|
|
|
}
|
|
|
|
list[*resp.Name] = instance
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
type instanceGroupManagerInput struct {
|
|
|
|
Count int
|
|
|
|
Name string
|
|
|
|
Template string
|
|
|
|
Project string
|
|
|
|
Zone string
|
|
|
|
UID string
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i *instanceGroupManagerInput) InsertInstanceGroupManagerRequest() computepb.InsertInstanceGroupManagerRequest {
|
|
|
|
return computepb.InsertInstanceGroupManagerRequest{
|
|
|
|
InstanceGroupManagerResource: &computepb.InstanceGroupManager{
|
|
|
|
BaseInstanceName: proto.String(i.Name),
|
|
|
|
InstanceTemplate: proto.String("projects/" + i.Project + "/global/instanceTemplates/" + i.Template),
|
|
|
|
Name: proto.String(i.Name),
|
|
|
|
TargetSize: proto.Int32(int32(i.Count)),
|
|
|
|
},
|
|
|
|
Project: i.Project,
|
|
|
|
Zone: i.Zone,
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// CreateInstancesInput is the input for a CreatInstances operation.
|
|
|
|
type CreateInstancesInput struct {
|
2022-04-04 10:44:15 -04:00
|
|
|
CountNodes int
|
|
|
|
CountCoordinators int
|
|
|
|
ImageId string
|
|
|
|
InstanceType string
|
|
|
|
StateDiskSizeGB int
|
|
|
|
KubeEnv string
|
2022-03-22 11:03:15 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
type insertInstanceTemplateInput struct {
|
|
|
|
Name string
|
|
|
|
Network string
|
|
|
|
Subnetwork string
|
|
|
|
SecondarySubnetworkRangeName string
|
|
|
|
ImageId string
|
|
|
|
InstanceType string
|
2022-04-05 08:25:49 -04:00
|
|
|
StateDiskSizeGB int64
|
2022-05-20 03:43:31 -04:00
|
|
|
Role string
|
2022-03-22 11:03:15 -04:00
|
|
|
KubeEnv string
|
|
|
|
Project string
|
|
|
|
Zone string
|
|
|
|
Region string
|
|
|
|
UID string
|
|
|
|
}
|
|
|
|
|
|
|
|
func (i insertInstanceTemplateInput) insertInstanceTemplateRequest() *computepb.InsertInstanceTemplateRequest {
|
|
|
|
req := computepb.InsertInstanceTemplateRequest{
|
|
|
|
InstanceTemplateResource: &computepb.InstanceTemplate{
|
2022-05-04 03:13:46 -04:00
|
|
|
Description: proto.String("This instance belongs to a Constellation cluster."),
|
2022-03-22 11:03:15 -04:00
|
|
|
Name: proto.String(i.Name),
|
|
|
|
Properties: &computepb.InstanceProperties{
|
|
|
|
ConfidentialInstanceConfig: &computepb.ConfidentialInstanceConfig{
|
2022-04-29 10:27:03 -04:00
|
|
|
EnableConfidentialCompute: proto.Bool(true),
|
2022-03-22 11:03:15 -04:00
|
|
|
},
|
2022-05-04 03:13:46 -04:00
|
|
|
Description: proto.String("This instance belongs to a Constellation cluster."),
|
2022-03-22 11:03:15 -04:00
|
|
|
Disks: []*computepb.AttachedDisk{
|
|
|
|
{
|
|
|
|
InitializeParams: &computepb.AttachedDiskInitializeParams{
|
|
|
|
DiskSizeGb: proto.Int64(10),
|
2022-05-09 11:07:12 -04:00
|
|
|
SourceImage: proto.String(i.ImageId),
|
2022-03-22 11:03:15 -04:00
|
|
|
},
|
|
|
|
AutoDelete: proto.Bool(true),
|
|
|
|
Boot: proto.Bool(true),
|
|
|
|
Mode: proto.String(computepb.AttachedDisk_READ_WRITE.String()),
|
|
|
|
},
|
2022-04-05 08:25:49 -04:00
|
|
|
{
|
|
|
|
InitializeParams: &computepb.AttachedDiskInitializeParams{
|
|
|
|
DiskSizeGb: proto.Int64(i.StateDiskSizeGB),
|
|
|
|
},
|
|
|
|
AutoDelete: proto.Bool(true),
|
|
|
|
DeviceName: proto.String("state-disk"),
|
|
|
|
Mode: proto.String(computepb.AttachedDisk_READ_WRITE.String()),
|
|
|
|
Type: proto.String(computepb.AttachedDisk_PERSISTENT.String()),
|
|
|
|
},
|
2022-03-22 11:03:15 -04:00
|
|
|
},
|
|
|
|
MachineType: proto.String(i.InstanceType),
|
|
|
|
Metadata: &computepb.Metadata{
|
|
|
|
Items: []*computepb.Items{
|
|
|
|
{
|
|
|
|
Key: proto.String("kube-env"),
|
|
|
|
Value: proto.String(i.KubeEnv),
|
|
|
|
},
|
|
|
|
{
|
|
|
|
Key: proto.String("constellation-uid"),
|
|
|
|
Value: proto.String(i.UID),
|
|
|
|
},
|
2022-05-20 03:43:31 -04:00
|
|
|
{
|
|
|
|
Key: proto.String("constellation-role"),
|
|
|
|
Value: proto.String(i.Role),
|
|
|
|
},
|
2022-03-22 11:03:15 -04:00
|
|
|
},
|
|
|
|
},
|
|
|
|
NetworkInterfaces: []*computepb.NetworkInterface{
|
|
|
|
{
|
|
|
|
Network: proto.String("projects/" + i.Project + "/global/networks/" + i.Network),
|
|
|
|
Subnetwork: proto.String("regions/" + i.Region + "/subnetworks/" + i.Subnetwork),
|
|
|
|
AccessConfigs: []*computepb.AccessConfig{
|
|
|
|
{Type: proto.String(computepb.AccessConfig_ONE_TO_ONE_NAT.String())},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
Scheduling: &computepb.Scheduling{
|
|
|
|
OnHostMaintenance: proto.String(computepb.Scheduling_TERMINATE.String()),
|
|
|
|
},
|
|
|
|
ServiceAccounts: []*computepb.ServiceAccount{
|
|
|
|
{
|
|
|
|
Scopes: []string{
|
|
|
|
"https://www.googleapis.com/auth/compute",
|
|
|
|
"https://www.googleapis.com/auth/servicecontrol",
|
|
|
|
"https://www.googleapis.com/auth/service.management.readonly",
|
|
|
|
"https://www.googleapis.com/auth/devstorage.read_only",
|
|
|
|
"https://www.googleapis.com/auth/logging.write",
|
|
|
|
"https://www.googleapis.com/auth/monitoring.write",
|
|
|
|
"https://www.googleapis.com/auth/trace.append",
|
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
ShieldedInstanceConfig: &computepb.ShieldedInstanceConfig{
|
|
|
|
EnableIntegrityMonitoring: proto.Bool(true),
|
|
|
|
EnableSecureBoot: proto.Bool(true),
|
|
|
|
EnableVtpm: proto.Bool(true),
|
|
|
|
},
|
|
|
|
Tags: &computepb.Tags{
|
2022-05-24 04:04:42 -04:00
|
|
|
Items: []string{"constellation-" + i.UID},
|
2022-03-22 11:03:15 -04:00
|
|
|
},
|
|
|
|
},
|
|
|
|
},
|
|
|
|
Project: i.Project,
|
|
|
|
}
|
|
|
|
|
|
|
|
// if there is an secondary IP range defined, we use it as an alias IP range
|
|
|
|
if i.SecondarySubnetworkRangeName != "" {
|
|
|
|
req.InstanceTemplateResource.Properties.NetworkInterfaces[0].AliasIpRanges = []*computepb.AliasIpRange{
|
|
|
|
{
|
|
|
|
IpCidrRange: proto.String("/24"),
|
|
|
|
SubnetworkRangeName: proto.String(i.SecondarySubnetworkRangeName),
|
|
|
|
},
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return &req
|
|
|
|
}
|