Files
threshold-plugin-azure/plugin/plugin.go
Jeff Mitchell 3542fae64d Fix tag filtering (#1)
It turns out you can't filter on both resource type and tags at the same
time, which means checking type needs to happen in code. Additionally,
you can't request provisioning state because populating $expand also
does bad things when using tags in filters.
2021-09-30 00:38:37 -04:00

513 lines
16 KiB
Go

package plugin
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Azure/azure-sdk-for-go/profiles/latest/network/mgmt/network"
"github.com/Azure/azure-sdk-for-go/profiles/latest/resources/mgmt/resources"
"github.com/kr/pretty"
"google.golang.org/protobuf/types/known/structpb"
"github.com/hashicorp/boundary/sdk/pbs/controller/api/resources/hostcatalogs"
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
)
// AzurePlugin implements the HostPluginServiceServer interface
type AzurePlugin struct {
pb.UnimplementedHostPluginServiceServer
}
// Ensure that we are implementing HostPluginServiceServer
var (
_ pb.HostPluginServiceServer = (*AzurePlugin)(nil)
)
func rotateCredFromCallback(ctx context.Context, catalog *hostcatalogs.HostCatalog) (*pb.HostCatalogPersisted, error) {
authConfig, err := getWrappedHamiltonConfig(catalog, nil)
if err != nil {
return nil, fmt.Errorf("error getting hauth config when creating catalog: %w", err)
}
newPass, err := rotateCredential(ctx, authConfig)
if err != nil {
return nil, fmt.Errorf("error rotating credentials when creating catalog: %w", err)
}
if newPass == nil {
return nil, errors.New("new credential back from rotate is nil")
}
newSecrets, err := structpb.NewStruct(map[string]interface{}{
constSecretId: *newPass.KeyId,
constSecretValue: *newPass.SecretText,
constCredsLastRotatedTime: time.Now().Format(time.RFC3339Nano),
})
if err != nil {
return nil, fmt.Errorf("error formatting new credential data as struct")
}
return &pb.HostCatalogPersisted{
Secrets: newSecrets,
}, err
}
func (p *AzurePlugin) OnCreateCatalog(ctx context.Context, req *pb.OnCreateCatalogRequest) (*pb.OnCreateCatalogResponse, error) {
catalog := req.GetCatalog()
if catalog == nil {
return nil, errors.New("catalog is nil")
}
secrets := catalog.GetSecrets()
if secrets == nil {
// Secrets will have to be provided later, on update
return nil, nil
}
sanitizeSecretsFields(secrets)
// Check if rotation is skipped. Always prefer attempting rotation unless we
// explicitly are told not to
var skipRotation bool
if attrs := catalog.GetAttributes(); attrs != nil {
if fields := attrs.GetFields(); fields != nil {
if field := fields[constDisableCredentialRotation]; field != nil {
if field.GetBoolValue() {
skipRotation = true
}
}
}
}
if skipRotation {
return &pb.OnCreateCatalogResponse{
Persisted: &pb.HostCatalogPersisted{
Secrets: secrets,
},
}, nil
}
newPersisted, err := rotateCredFromCallback(ctx, catalog)
if err != nil {
return nil, fmt.Errorf("error formatting new persisted data: %w", err)
}
ret := &pb.OnCreateCatalogResponse{
Persisted: newPersisted,
}
return ret, nil
}
func (p *AzurePlugin) OnUpdateCatalog(ctx context.Context, req *pb.OnUpdateCatalogRequest) (*pb.OnUpdateCatalogResponse, error) {
newCatalog := req.GetNewCatalog()
if newCatalog == nil {
// Should never happen
return nil, errors.New("new catalog is nil")
}
currentCatalog := req.GetCurrentCatalog()
if currentCatalog == nil {
// Should never happen
return nil, errors.New("current catalog is nil")
}
secrets := newCatalog.GetSecrets()
if secrets == nil {
// If new secrets weren't passed in, don't rotate what we have on
// update.
//
// TODO: There may be combinations of attributes where those values
// changing (or being added) should trigger rotation.
return nil, nil
}
sanitizeSecretsFields(secrets)
// If the old credential had been rotated, attempt to delete the old
// credential using itself; if it fails, proceed anyways, since in theory
// only Boundary knows this value so simply forgetting it is safe enough (if
// not clean).
var field *structpb.Value
var pdFields map[string]*structpb.Value
var err error
var authConfig *WrappedHamiltonConfig
persisted := req.GetPersisted()
if persisted == nil {
goto after_revocation
}
if persisted.GetSecrets() == nil {
goto after_revocation
}
pdFields = persisted.GetSecrets().GetFields()
if pdFields == nil {
goto after_revocation
}
field = pdFields[constCredsLastRotatedTime]
if field == nil {
goto after_revocation
}
// If the field exists, it is a timestamp here indicating last rotation time
authConfig, err = getWrappedHamiltonConfig(currentCatalog, req.GetPersisted())
if err != nil {
// TODO: warn in some way?
goto after_revocation
}
if err := removeCredential(ctx, authConfig); err != nil {
// TODO: warn
goto after_revocation
}
after_revocation:
// Check if rotation is skipped. Always prefer attempting rotation unless we
// explicitly are told not to
var skipRotation bool
if attrs := newCatalog.GetAttributes(); attrs != nil {
if fields := attrs.GetFields(); fields != nil {
if field := fields[constDisableCredentialRotation]; field != nil {
if field.GetBoolValue() {
skipRotation = true
}
}
}
}
if skipRotation {
return &pb.OnUpdateCatalogResponse{
Persisted: &pb.HostCatalogPersisted{
Secrets: secrets,
},
}, nil
}
newPersisted, err := rotateCredFromCallback(ctx, req.GetNewCatalog())
if err != nil {
return nil, fmt.Errorf("error formatting new persisted data: %w", err)
}
ret := &pb.OnUpdateCatalogResponse{
Persisted: newPersisted,
}
return ret, nil
}
func (p *AzurePlugin) OnDeleteCatalog(ctx context.Context, req *pb.OnDeleteCatalogRequest) (*pb.OnDeleteCatalogResponse, error) {
if req.GetCatalog() == nil {
return nil, errors.New("catalog is nil")
}
if req.GetPersisted() == nil || req.GetPersisted().GetSecrets() == nil {
return nil, nil
}
// Figure out if creds were rotated
pdFields := req.GetPersisted().GetSecrets().GetFields()
if pdFields == nil {
// No data
return nil, nil
}
field := pdFields[constCredsLastRotatedTime]
if field == nil {
return nil, nil
}
// The field existing means it is a string containing the last time it was
// rotated. Delete the credential using itself.
authConfig, err := getWrappedHamiltonConfig(req.GetCatalog(), req.GetPersisted())
if err != nil {
return nil, fmt.Errorf("error getting auth config on catalog deletion: %w", err)
}
if err := removeCredential(ctx, authConfig); err != nil {
if err != nil {
return nil, fmt.Errorf("error revoking credential on catalog deletion: %w, authConfig is %s", err, pretty.Sprint(authConfig))
}
}
return nil, nil
}
func (p *AzurePlugin) OnCreateSet(ctx context.Context, req *pb.OnCreateSetRequest) (*pb.OnCreateSetResponse, error) {
// Nothing yet
return nil, nil
}
func (p *AzurePlugin) OnUpdateSet(ctx context.Context, req *pb.OnUpdateSetRequest) (*pb.OnUpdateSetResponse, error) {
// Nothing yet
return nil, nil
}
func (p *AzurePlugin) OnDeleteSet(ctx context.Context, req *pb.OnDeleteSetRequest) (*pb.OnDeleteSetResponse, error) {
// Nothing yet
return nil, nil
}
func (p *AzurePlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*pb.ListHostsResponse, error) {
if len(req.GetSets()) == 0 {
// Nothing to fetch
return nil, nil
}
catalog := req.GetCatalog()
if catalog == nil {
return nil, errors.New("catalog is nil")
}
catalogAttrs := catalog.GetAttributes()
if catalogAttrs == nil {
return nil, errors.New("catalog has no attributes")
}
// Create an authorizer
var secrets *structpb.Struct
if req.GetPersisted() != nil {
secrets = req.GetPersisted().GetSecrets()
}
subscriptionId, authorizer, err := fetchAuthorizer(catalog, secrets)
if err != nil {
return nil, fmt.Errorf("error fetching subscription id and authorizer: %w", err)
}
if subscriptionId == "" {
return nil, errors.New("subscription id not discovered")
}
if authorizer == nil {
return nil, errors.New("fetched authorizer is nil")
}
commonOpts := make([]Option, 0, 3)
commonOpts = append(commonOpts,
WithSubscriptionId(subscriptionId),
WithAuthorizer(authorizer),
)
// Getting authorizer will fail if the attr fields are not populated, so
// it's safe to access directly at this point.
//
// TODO: plumb base URL everywhere, not just here
catalogAttrFields := catalogAttrs.GetFields()
if catalogAttrFields[constBaseUrl] != nil {
if baseUrl := catalogAttrFields[constBaseUrl].GetStringValue(); baseUrl != "" {
commonOpts = append(commonOpts, WithBaseUrl(baseUrl))
}
}
// Create clients
resClient, err := getResourcesClient(commonOpts...)
if err != nil {
return nil, fmt.Errorf("error fetching resources client: %w", err)
}
if resClient == nil {
return nil, errors.New("resource client is nil")
}
vmClient, err := getVirtualMachinesClient(commonOpts...)
if err != nil {
return nil, fmt.Errorf("error fetching virtual machines client: %w", err)
}
if vmClient == nil {
return nil, errors.New("virtual machines client is nil")
}
ifClient, err := getNetworkInterfacesClient(commonOpts...)
if err != nil {
return nil, fmt.Errorf("error fetching network interfaces client: %w", err)
}
if ifClient == nil {
return nil, errors.New("network interfaces client is nil")
}
pipClient, err := getPublicIpAddressesClient(commonOpts...)
if err != nil {
return nil, fmt.Errorf("error fetching public ip address client: %w", err)
}
if pipClient == nil {
return nil, errors.New("public ip address client is nil")
}
// This next section finds resource IDs that match to the filter in each
// host set, and stores a mapping of, for each resource ID, which host sets
// it belongs to
resourceToSetMap := make(map[string][]string, len(req.GetSets())*10)
var resourceInfos []resources.GenericResourceExpanded
for _, set := range req.GetSets() {
if set == nil {
return nil, errors.New("set is nil")
}
setAttrs := set.GetAttributes()
if setAttrs == nil {
return nil, fmt.Errorf("set %s has no attributes", set.GetId())
}
setAttrFields := setAttrs.GetFields()
if len(setAttrFields) == 0 {
return nil, fmt.Errorf("set %s attributes has no filter", set.GetId())
}
filter := constDefaultFilter
if setAttrFields[constFilter] != nil {
if filter = setAttrFields[constFilter].GetStringValue(); filter == "" {
return nil, fmt.Errorf("set %s filter is empty", set.GetId())
}
}
// List values matching the filter; ask for provisioning state information
// to ensure we are seeing only fully provisioned machines
{
iter, err := resClient.ListComplete(ctx, filter, "", nil)
if err != nil {
return nil, fmt.Errorf("error listing resources: %w", err)
}
for iter.NotDone() {
val := iter.Value()
if err := iter.NextWithContext(ctx); err != nil {
return nil, fmt.Errorf("error iterating resources: %w", err)
}
if val.ID == nil { // something went wrong, likely iterator has advanced beyond the end
continue
}
if val.Type == nil || *val.Type != "Microsoft.Compute/virtualMachines" {
// no point continuing if we can't validate that it's a VM
continue
}
resourceInfos = append(resourceInfos, val)
resourceToSetMap[*val.ID] = append(resourceToSetMap[*val.ID], set.GetId())
}
}
}
// At this point we have a list of distinct resources and a mapping between
// them and the set(s) they belong to. Iterate through them and fetch
// information.
//
// Listing returns IDs which are basically URLs that have key-value
// pairs but not the individual components. Get requires the individual
// components, and won't accept the ID...
//
// Anyways, now we find VM details for those resources
vmToIfaceMap := make(map[string][]network.Interface)
{
for _, res := range resourceInfos {
resourceGroup, name, err := splitId(*res.ID, constMsComputeService, constVirtualMachinesResource)
if err != nil {
return nil, fmt.Errorf("error splitting vm id %q: %w", *res.ID, err)
}
vm, err := vmClient.Get(ctx, resourceGroup, name, "")
if err != nil {
return nil, fmt.Errorf("error fetching vm with id %q: %w", *res.ID, err)
}
iv, err := vmClient.InstanceView(ctx, resourceGroup, name)
if err != nil {
return nil, fmt.Errorf("error fetching instance view for vm with id %q: %w", *res.ID, err)
}
if iv.Statuses == nil {
return nil, fmt.Errorf("instance view statuses returned for vm with id %q is null", *res.ID)
}
var running bool
for _, status := range *iv.Statuses {
if status.Code == nil {
continue
}
state := strings.ToLower(*status.Code)
prefix := "powerstate/"
if !strings.HasPrefix(state, prefix) {
continue
}
state = strings.TrimPrefix(state, prefix)
if state == "running" {
running = true
break
}
}
if !running {
continue
}
props := vm.VirtualMachineProperties
if props == nil {
return nil, fmt.Errorf("error fetching properties for vm with id %q: %w", *res.ID, err)
}
if props.NetworkProfile == nil {
return nil, fmt.Errorf("error fetching network profile for vm with id %q", *res.ID)
}
// Within the VM, catalog the various interfaces
var ifaces []network.Interface
for _, ifaceRef := range *props.NetworkProfile.NetworkInterfaces {
if ifaceRef.ID == nil {
return nil, fmt.Errorf("nil ID for network interface for vm with id %q", *res.ID)
}
ifResGroup, ifName, err := splitId(*ifaceRef.ID, constMsNetworkService, constNetworkInterfacesResource)
if err != nil {
return nil, fmt.Errorf("error splitting network interface id %q: %w", *ifaceRef.ID, err)
}
iface, err := ifClient.Get(ctx, ifResGroup, ifName, "")
if err != nil {
return nil, fmt.Errorf("error fetching network interface with id %q: %w", *ifaceRef.ID, err)
}
if iface.InterfacePropertiesFormat == nil || iface.InterfacePropertiesFormat.IPConfigurations == nil {
continue
}
ifaces = append(ifaces, iface)
}
vmToIfaceMap[*res.ID] = ifaces
}
}
// Now, fetch IP details for each of the interfaces for each VM
type networkInfo struct {
IpAddresses []string
}
vmToNetworkMap := make(map[string]networkInfo)
{
for vmId, ifaces := range vmToIfaceMap {
var netInfo networkInfo
for _, iface := range ifaces {
for _, ipconf := range *iface.InterfacePropertiesFormat.IPConfigurations {
if ipconf.PrivateIPAddress != nil {
netInfo.IpAddresses = append(netInfo.IpAddresses, *ipconf.PrivateIPAddress)
}
if ipconf.PublicIPAddress != nil {
if ipconf.PublicIPAddress.ID == nil {
return nil, fmt.Errorf("ip configuration %q has public IP address info but nil id", *ipconf.ID)
}
ipResGroup, ipName, err := splitId(*ipconf.PublicIPAddress.ID, constMsNetworkService, constPublicIpAddressesResource)
if err != nil {
return nil, fmt.Errorf("error splitting public ip address id %q: %w", *ipconf.PublicIPAddress.ID, err)
}
pipInfo, err := pipClient.Get(ctx, ipResGroup, ipName, "")
if err != nil {
if err != nil {
return nil, fmt.Errorf("error fetching public IP information with resource group %q and name %q: %w", ipResGroup, ipName, err)
}
}
if pipInfo.PublicIPAddressPropertiesFormat == nil {
return nil, fmt.Errorf("nil public ip address properties format for public ip %q", *ipconf.PublicIPAddress.ID)
}
if pipInfo.PublicIPAddressPropertiesFormat.IPAddress != nil {
netInfo.IpAddresses = append(netInfo.IpAddresses, *pipInfo.PublicIPAddressPropertiesFormat.IPAddress)
}
}
}
}
vmToNetworkMap[vmId] = netInfo
}
}
ret := &pb.ListHostsResponse{}
for k, v := range vmToNetworkMap {
host := &pb.ListHostsResponseHost{
ExternalId: k,
IpAddresses: v.IpAddresses,
SetIds: resourceToSetMap[k],
}
ret.Hosts = append(ret.Hosts, host)
}
return ret, nil
}
func splitId(in, expectedService, expectedResource string) (string, string, error) {
// Note: this could be stolen from the helpers directory in the TF AzureRM
// provider but I'm not at the moment because it does a bunch of trickery
// for some Azure APIs and we have a much more limited scope. I may come to
// regret this.
splitId := strings.Split(strings.TrimLeft(in, "/"), "/")
// Run through some sanity checks
if len(splitId) != 8 ||
splitId[0] != constSubscriptions ||
splitId[2] != constResourceGroups ||
splitId[4] != constProviders ||
splitId[5] != expectedService ||
splitId[6] != expectedResource {
return "", "", fmt.Errorf("unexpected format of resource ID: %v", splitId)
}
return splitId[3], splitId[7], nil
}
func sanitizeSecretsFields(in *structpb.Struct) {
// Only we are allowed to set this value
if fields := in.GetFields(); fields != nil {
delete(fields, constCredsLastRotatedTime)
}
}