open-consul/command/connect/expose/expose.go

254 lines
7.4 KiB
Go

// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package expose
import (
"flag"
"fmt"
"strings"
"github.com/mitchellh/cli"
"github.com/hashicorp/consul/agent"
"github.com/hashicorp/consul/api"
"github.com/hashicorp/consul/command/flags"
"github.com/hashicorp/consul/command/intention"
)
func New(ui cli.Ui) *cmd {
c := &cmd{UI: ui}
c.init()
return c
}
type cmd struct {
UI cli.Ui
flags *flag.FlagSet
http *flags.HTTPFlags
help string
// flags
ingressGateway string
service string
port int
protocol string
hosts flags.AppendSliceValue
}
func (c *cmd) init() {
c.flags = flag.NewFlagSet("", flag.ContinueOnError)
c.flags.StringVar(&c.ingressGateway, "ingress-gateway", "",
"(Required) The name of the ingress gateway service to use. Namespace and partition "+
"can optionally be specified as a prefix via the 'partition/namespace/service' format.")
c.flags.StringVar(&c.service, "service", "",
"(Required) The name of destination service to expose. Namespace and partition "+
"can optionally be specified as a prefix via the 'partition/namespace/service' format.")
c.flags.IntVar(&c.port, "port", 0,
"(Required) The listener port to use for the service on the Ingress gateway.")
c.flags.StringVar(&c.protocol, "protocol", "tcp",
"The protocol for the service. Defaults to 'tcp'.")
c.flags.Var(&c.hosts, "host", "Additional DNS hostname to use for routing to this service."+
"Can be specified multiple times.")
c.http = &flags.HTTPFlags{}
flags.Merge(c.flags, c.http.ClientFlags())
flags.Merge(c.flags, c.http.ServerFlags())
c.help = flags.Usage(help, c.flags)
}
func (c *cmd) Run(args []string) int {
if err := c.flags.Parse(args); err != nil {
if err == flag.ErrHelp {
return 0
}
c.UI.Error(fmt.Sprintf("Failed to parse args: %v", err))
return 1
}
// Set up a client.
client, err := c.http.APIClient()
if err != nil {
c.UI.Error(fmt.Sprintf("Error initializing client: %s", err))
return 1
}
// Check for any missing or invalid flag values.
if c.service == "" {
c.UI.Error("A service name must be given via the -service flag.")
return 1
}
svc, svcNS, svcPart, err := intention.ParseIntentionTarget(c.service)
if err != nil {
c.UI.Error(fmt.Sprintf("Invalid service name: %s", err))
return 1
}
if c.ingressGateway == "" {
c.UI.Error("An ingress gateway service must be given via the -ingress-gateway flag.")
return 1
}
gateway, gatewayNS, gatewayPart, err := intention.ParseIntentionTarget(c.ingressGateway)
if err != nil {
c.UI.Error(fmt.Sprintf("Invalid ingress gateway name: %s", err))
return 1
}
if c.port == 0 {
c.UI.Error("A port must be provided via the -port flag.")
return 1
}
// First get the config entry for the ingress gateway, if it exists. Don't error if it's a 404 as that
// just means we'll need to create a new config entry.
conf, _, err := client.ConfigEntries().Get(
api.IngressGateway, gateway, &api.QueryOptions{Partition: gatewayPart, Namespace: gatewayNS},
)
if err != nil && !strings.Contains(err.Error(), agent.ConfigEntryNotFoundErr) {
c.UI.Error(fmt.Sprintf("Error fetching existing ingress gateway configuration: %s", err))
return 1
}
if conf == nil {
conf = &api.IngressGatewayConfigEntry{
Kind: api.IngressGateway,
Name: gateway,
Namespace: gatewayNS,
Partition: gatewayPart,
}
}
// Make sure the flags don't conflict with existing config.
ingressConf, ok := conf.(*api.IngressGatewayConfigEntry)
if !ok {
// This should never happen
c.UI.Error(fmt.Sprintf("Config entry is an invalid type: %T", conf))
return 1
}
listenerIdx := -1
serviceIdx := -1
newService := api.IngressService{
Name: svc,
Namespace: svcNS,
Partition: svcPart,
Hosts: c.hosts,
}
for i, listener := range ingressConf.Listeners {
// Find the listener for the specified port, if one exists.
if listener.Port != c.port {
continue
}
// Make sure the given protocol matches the existing one.
listenerIdx = i
if listener.Protocol != c.protocol {
c.UI.Error(fmt.Sprintf("Listener on port %d already configured with conflicting protocol %q", listener.Port, listener.Protocol))
return 1
}
// Make sure the service isn't already exposed in this gateway
for j, service := range listener.Services {
if service.Name == svc && entMetaMatch(service.Namespace, service.Partition, svcNS, svcPart) {
serviceIdx = j
c.UI.Output(fmt.Sprintf("Updating service definition for %q on listener with port %d", c.service, listener.Port))
break
}
}
}
// Add a service to the existing listener for the port if one exists, or make a new listener.
if listenerIdx >= 0 {
if serviceIdx >= 0 {
ingressConf.Listeners[listenerIdx].Services[serviceIdx] = newService
} else {
ingressConf.Listeners[listenerIdx].Services = append(ingressConf.Listeners[listenerIdx].Services, newService)
}
} else {
ingressConf.Listeners = append(ingressConf.Listeners, api.IngressListener{
Port: c.port,
Protocol: c.protocol,
Services: []api.IngressService{newService},
})
}
// Write the updated config entry using a check-and-set, so it fails if the entry
// has been changed since we looked it up.
succeeded, _, err := client.ConfigEntries().CAS(ingressConf, ingressConf.GetModifyIndex(), &api.WriteOptions{Partition: gatewayPart, Namespace: gatewayNS})
if err != nil {
c.UI.Error(fmt.Sprintf("Error writing ingress config entry: %v", err))
return 1
}
if !succeeded {
c.UI.Error("Ingress config entry was changed while attempting to update, please try again.")
return 1
}
c.UI.Output(fmt.Sprintf("Successfully updated config entry for ingress service %q", gateway))
// Check for an existing intention.
existing, _, err := client.Connect().IntentionGetExact(c.ingressGateway, c.service, nil)
if err != nil {
c.UI.Error(fmt.Sprintf("Error looking up existing intention: %s", err))
return 1
}
if existing != nil && existing.Action == api.IntentionActionAllow {
c.UI.Output(fmt.Sprintf("Intention already exists for %q -> %q", c.ingressGateway, c.service))
return 0
}
// Add the intention between the gateway service and the destination.
ixn := &api.Intention{
SourceName: gateway,
SourceNS: gatewayNS,
SourcePartition: gatewayPart,
DestinationName: svc,
DestinationNS: svcNS,
DestinationPartition: svcPart,
SourceType: api.IntentionSourceConsul,
Action: api.IntentionActionAllow,
}
if _, err = client.Connect().IntentionUpsert(ixn, nil); err != nil {
c.UI.Error(fmt.Sprintf("Error upserting intention: %s", err))
return 1
}
c.UI.Output(fmt.Sprintf("Successfully set up intention for %q -> %q", c.ingressGateway, c.service))
return 0
}
func entMetaMatch(nsA, partitionA, nsB, partitionB string) bool {
if nsA == "" {
nsA = api.IntentionDefaultNamespace
}
if partitionA == "" {
partitionA = api.PartitionDefaultName
}
if nsB == "" {
nsB = api.IntentionDefaultNamespace
}
if partitionB == "" {
partitionB = api.PartitionDefaultName
}
return strings.EqualFold(partitionA, partitionB) && strings.EqualFold(nsA, nsB)
}
func (c *cmd) Synopsis() string {
return synopsis
}
func (c *cmd) Help() string {
return c.help
}
const synopsis = "Expose a Connect-enabled service through an Ingress gateway"
const help = `
Usage: consul connect expose [options]
Exposes a Connect-enabled service through the given ingress gateway, using the
given protocol and port.
`