Skip to content

Commit cd856e8

Browse files
authored
Allow service tags to be specified on public IP addresses (#261)
1 parent d9b23d5 commit cd856e8

File tree

7 files changed

+196
-17
lines changed

7 files changed

+196
-17
lines changed

cli/internal/install/cloudinstall/cloud-config-pretty.tpl

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,23 @@ cloud:
106106
# osSku: defaults to AzureLinux
107107
{{- end }}
108108
{{- end }}
109+
110+
{{- if .InboundIpServiceTags }}
111+
112+
inboundIpServiceTags:
113+
{{- range .InboundIpServiceTags }}
114+
- type: {{ .Type }}
115+
tag: {{ .Tag }}
116+
{{- end }}
117+
{{- end }}
118+
{{- if .OutboundIpServiceTags }}
119+
120+
outboundIpServiceTags:
121+
{{- range .OutboundIpServiceTags }}
122+
- type: {{ .Type }}
123+
tag: {{ .Tag }}
124+
{{- end }}
125+
{{- end }}
109126
{{- end }}
110127

111128
# These are the principals that will have the ability to run `tyger api install`.

cli/internal/install/cloudinstall/cloudconfig.go

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -159,18 +159,25 @@ func (c *ComputeConfig) GetApiHostCluster() *ClusterConfig {
159159
panic("API host cluster not found - this should have been caught during validation")
160160
}
161161

162+
type IpServiceTag struct {
163+
Type string `yaml:"type"`
164+
Tag string `yaml:"tag"`
165+
}
166+
162167
type ClusterConfig struct {
163-
Name string `yaml:"name"`
164-
ApiHost bool `yaml:"apiHost"`
165-
Location string `yaml:"location"`
166-
Sku armcontainerservice.ManagedClusterSKUTier `yaml:"sku"`
167-
KubernetesVersion string `yaml:"kubernetesVersion,omitempty"`
168-
ExistingSubnet *SubnetReference `yaml:"existingSubnet,omitempty"`
169-
SystemNodePool *NodePoolConfig `yaml:"systemNodePool"`
170-
UserNodePools []*NodePoolConfig `yaml:"userNodePools"`
171-
PodCidr string `yaml:"podCidr,omitempty"`
172-
ServiceCidr string `yaml:"serviceCidr,omitempty"`
173-
DnsServiceIp string `yaml:"dnsServiceIp,omitempty"`
168+
Name string `yaml:"name"`
169+
ApiHost bool `yaml:"apiHost"`
170+
Location string `yaml:"location"`
171+
Sku armcontainerservice.ManagedClusterSKUTier `yaml:"sku"`
172+
KubernetesVersion string `yaml:"kubernetesVersion,omitempty"`
173+
ExistingSubnet *SubnetReference `yaml:"existingSubnet,omitempty"`
174+
SystemNodePool *NodePoolConfig `yaml:"systemNodePool"`
175+
UserNodePools []*NodePoolConfig `yaml:"userNodePools"`
176+
PodCidr string `yaml:"podCidr,omitempty"`
177+
ServiceCidr string `yaml:"serviceCidr,omitempty"`
178+
DnsServiceIp string `yaml:"dnsServiceIp,omitempty"`
179+
InboundIpServiceTags []IpServiceTag `yaml:"inboundIpServiceTags,omitempty"`
180+
OutboundIpServiceTags []IpServiceTag `yaml:"outboundIpServiceTags,omitempty"`
174181
}
175182

176183
type SubnetReference struct {

cli/internal/install/cloudinstall/compute.go

Lines changed: 101 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,11 @@ func (inst *Installer) getCluster(ctx context.Context, clusterConfig *ClusterCon
4646
}
4747

4848
func (inst *Installer) createCluster(ctx context.Context, clusterConfig *ClusterConfig) (*armcontainerservice.ManagedCluster, error) {
49+
outboundIpAddress, err := inst.createOutboundIpAddress(ctx, clusterConfig)
50+
if err != nil {
51+
return nil, fmt.Errorf("failed to create outbound IP address: %w", err)
52+
}
53+
4954
var tags map[string]*string
5055

5156
existingCluster, err := inst.getCluster(ctx, clusterConfig)
@@ -122,7 +127,9 @@ func (inst *Installer) createCluster(ctx context.Context, clusterConfig *Cluster
122127
Enabled: Ptr(true),
123128
},
124129
},
125-
NetworkProfile: &armcontainerservice.NetworkProfile{},
130+
NetworkProfile: &armcontainerservice.NetworkProfile{
131+
LoadBalancerProfile: &armcontainerservice.ManagedClusterLoadBalancerProfile{},
132+
},
126133
},
127134
SKU: &armcontainerservice.ManagedClusterSKU{
128135
Name: Ptr(armcontainerservice.ManagedClusterSKUNameBase),
@@ -148,6 +155,12 @@ func (inst *Installer) createCluster(ctx context.Context, clusterConfig *Cluster
148155
}
149156
}
150157

158+
if outboundIpAddress != nil {
159+
cluster.Properties.NetworkProfile.LoadBalancerProfile.OutboundIPs = &armcontainerservice.ManagedClusterLoadBalancerProfileOutboundIPs{
160+
PublicIPs: []*armcontainerservice.ResourceReference{{ID: outboundIpAddress.ID}},
161+
}
162+
}
163+
151164
if workspace := inst.Config.Cloud.LogAnalyticsWorkspace; workspace != nil {
152165
oic, err := armoperationalinsights.NewWorkspacesClient(inst.Config.Cloud.SubscriptionID, inst.Credential, nil)
153166
if err != nil {
@@ -459,9 +472,77 @@ func (inst *Installer) createCluster(ctx context.Context, clusterConfig *Cluster
459472
}
460473
}
461474

475+
if outboundIpAddress != nil {
476+
if err := assignRbacRole(ctx, []string{*createdCluster.Identity.PrincipalID}, false, *outboundIpAddress.ID, "Network Contributor", inst.Config.Cloud.SubscriptionID, inst.Credential); err != nil {
477+
return nil, fmt.Errorf("failed to assign RBAC role on outbound IP address: %w", err)
478+
}
479+
}
480+
462481
return &createdCluster, nil
463482
}
464483

484+
func (inst *Installer) createOutboundIpAddress(ctx context.Context, cluster *ClusterConfig) (*armnetwork.PublicIPAddress, error) {
485+
if len(cluster.OutboundIpServiceTags) == 0 {
486+
// We let AKS manage the IP for us
487+
return nil, nil
488+
}
489+
490+
log.Ctx(ctx).Info().Msg("Creating outbound IP address")
491+
publicIPAddressesClient, err := armnetwork.NewPublicIPAddressesClient(inst.Config.Cloud.SubscriptionID, inst.Credential, nil)
492+
if err != nil {
493+
log.Fatal().Err(err).Msg("failed to create public IP addresses client")
494+
}
495+
496+
ipAddressName := fmt.Sprintf("%s-outbound-ip", cluster.Name)
497+
498+
var tags map[string]*string
499+
if resp, err := publicIPAddressesClient.Get(ctx, inst.Config.Cloud.ResourceGroup, ipAddressName, nil); err == nil {
500+
if existingTag, ok := resp.Tags[TagKey]; ok {
501+
if *existingTag != inst.Config.EnvironmentName {
502+
return nil, fmt.Errorf("public IP address '%s' is already in use by environment '%s'", ipAddressName, *existingTag)
503+
}
504+
tags = resp.Tags
505+
}
506+
}
507+
508+
if tags == nil {
509+
tags = make(map[string]*string)
510+
}
511+
tags[TagKey] = &inst.Config.EnvironmentName
512+
513+
ipTags := []*armnetwork.IPTag{}
514+
for _, t := range cluster.OutboundIpServiceTags {
515+
ipTags = append(ipTags, &armnetwork.IPTag{
516+
IPTagType: &t.Type,
517+
Tag: &t.Tag,
518+
})
519+
}
520+
521+
ipAddress := armnetwork.PublicIPAddress{
522+
Location: &cluster.Location,
523+
SKU: &armnetwork.PublicIPAddressSKU{
524+
Name: Ptr(armnetwork.PublicIPAddressSKUNameStandard),
525+
Tier: Ptr(armnetwork.PublicIPAddressSKUTierRegional),
526+
},
527+
Properties: &armnetwork.PublicIPAddressPropertiesFormat{
528+
PublicIPAllocationMethod: Ptr(armnetwork.IPAllocationMethodStatic),
529+
IPTags: ipTags,
530+
},
531+
}
532+
533+
poller, err := publicIPAddressesClient.BeginCreateOrUpdate(ctx, inst.Config.Cloud.ResourceGroup, ipAddressName, ipAddress, nil)
534+
if err != nil {
535+
return nil, fmt.Errorf("failed to create output IP address: %w", err)
536+
}
537+
538+
res, err := poller.PollUntilDone(ctx, nil)
539+
if err != nil {
540+
return nil, fmt.Errorf("failed to create output IP address: %w", err)
541+
}
542+
543+
return &res.PublicIPAddress, nil
544+
}
545+
465546
func clusterNeedsUpdating(cluster, existingCluster armcontainerservice.ManagedCluster) (hasChanges bool, onlyScaleDown bool) {
466547
if *existingCluster.Properties.ProvisioningState != "Succeeded" {
467548
return true, false
@@ -628,6 +709,25 @@ func clusterNeedsUpdating(cluster, existingCluster armcontainerservice.ManagedCl
628709
return true, false
629710
}
630711

712+
desiredOutputIpCount := 0
713+
if cluster.Properties.NetworkProfile.LoadBalancerProfile.OutboundIPs != nil {
714+
desiredOutputIpCount = len(cluster.Properties.NetworkProfile.LoadBalancerProfile.OutboundIPs.PublicIPs)
715+
}
716+
717+
existingOutputIpCount := 0
718+
if existingCluster.Properties.NetworkProfile.LoadBalancerProfile.OutboundIPs != nil {
719+
existingOutputIpCount = len(existingCluster.Properties.NetworkProfile.LoadBalancerProfile.OutboundIPs.PublicIPs)
720+
}
721+
722+
if desiredOutputIpCount != existingOutputIpCount ||
723+
(desiredOutputIpCount > 0 &&
724+
!slices.EqualFunc(
725+
cluster.Properties.NetworkProfile.LoadBalancerProfile.OutboundIPs.PublicIPs,
726+
existingCluster.Properties.NetworkProfile.LoadBalancerProfile.OutboundIPs.PublicIPs,
727+
func(a, b *armcontainerservice.ResourceReference) bool { return *a.ID == *b.ID })) {
728+
return true, false
729+
}
730+
631731
return hasChanges, onlyScaleDown
632732
}
633733

cli/internal/install/cloudinstall/helm.go

Lines changed: 49 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"context"
88
"errors"
99
"fmt"
10+
"maps"
1011
"net/http"
1112
"os"
1213
"reflect"
@@ -58,19 +59,28 @@ func (inst *Installer) installTraefik(ctx context.Context, restConfigPromise *in
5859
return nil, fmt.Errorf("failed to ensure Traefik dynamic ConfigMap: %w", err)
5960
}
6061

61-
var annotations map[string]any
62+
var serviceAnnotations map[string]any
6263
if inst.Config.Cloud.PrivateNetworking {
63-
annotations = map[string]any{
64+
serviceAnnotations = map[string]any{
6465
"service.beta.kubernetes.io/azure-load-balancer-internal": "true",
6566
"service.beta.kubernetes.io/azure-pls-create": "true",
6667
"service.beta.kubernetes.io/azure-pls-name": TraefikPrivateLinkServiceName,
6768
}
6869
} else {
69-
annotations = map[string]any{
70+
serviceAnnotations = map[string]any{
7071
"service.beta.kubernetes.io/azure-dns-label-name": inst.Config.Cloud.Compute.DnsLabel,
7172
}
7273
}
7374

75+
if ipServiceTags := inst.Config.Cloud.Compute.GetApiHostCluster().InboundIpServiceTags; len(ipServiceTags) > 0 {
76+
pairs := []string{}
77+
for _, t := range ipServiceTags {
78+
pairs = append(pairs, fmt.Sprintf("%s=%s", t.Type, t.Tag))
79+
}
80+
81+
serviceAnnotations["service.beta.kubernetes.io/azure-pip-ip-tags"] = strings.Join(pairs, ",")
82+
}
83+
7484
traefikConfig := HelmChartConfig{
7585
RepoName: "traefik",
7686
Namespace: TraefikNamespace,
@@ -94,7 +104,7 @@ func (inst *Installer) installTraefik(ctx context.Context, restConfigPromise *in
94104
},
95105
},
96106
"service": map[string]any{
97-
"annotations": annotations,
107+
"annotations": serviceAnnotations,
98108
"spec": map[string]any{
99109
"externalTrafficPolicy": "Local", // in order to preserve client IP addresses
100110
},
@@ -156,6 +166,41 @@ func (inst *Installer) installTraefik(ctx context.Context, restConfigPromise *in
156166
overrides = inst.Config.Cloud.Compute.Helm.Traefik
157167
}
158168

169+
// Check if there is an existing release to see if should be removed first.
170+
helmOptions := helmclient.RestConfClientOptions{
171+
RestConfig: restConfig,
172+
Options: &helmclient.Options{
173+
DebugLog: func(format string, v ...any) {
174+
log.Debug().Msgf(format, v...)
175+
},
176+
Namespace: traefikConfig.Namespace,
177+
},
178+
}
179+
180+
helmClient, err := helmclient.NewClientFromRestConf(&helmOptions)
181+
if err != nil {
182+
return false, fmt.Errorf("failed to create helm client: %w", err)
183+
}
184+
if existingRelease, err := helmClient.GetRelease(traefikConfig.ReleaseName); err != nil {
185+
if !errors.Is(err, driver.ErrReleaseNotFound) {
186+
return nil, fmt.Errorf("failed to get existing Traefik release: %w", err)
187+
}
188+
} else {
189+
var existingServiceAnnotations map[string]any
190+
if svc, _ := existingRelease.Config["service"].(map[string]any); svc != nil {
191+
existingServiceAnnotations, _ = svc["annotations"].(map[string]any)
192+
}
193+
194+
if !maps.Equal(existingServiceAnnotations, serviceAnnotations) {
195+
log.Ctx(ctx).Warn().Msg("Existing Traefik installation has different service annotations, uninstalling it first")
196+
if err = helmClient.UninstallReleaseByName(traefikConfig.ReleaseName); err != nil {
197+
log.Warn().Msgf("Failed to uninstall existing Traefik release: %v", err)
198+
} else {
199+
time.Sleep(2 * time.Minute) // Give some time for Azure resources to be removed
200+
}
201+
}
202+
}
203+
159204
startTime := time.Now().Add(-10 * time.Second)
160205
if _, _, err := installHelmChart(ctx, restConfig, &traefikConfig, overrides, false); err != nil {
161206
installErr := err

cli/internal/install/cloudinstall/storage.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ func (inst *Installer) CreateStorageAccount(ctx context.Context,
3232
if resp, err := storageClient.GetProperties(ctx, resourceGroupName, storageAccountConfig.Name, nil); err == nil {
3333
if existingTag, ok := resp.Tags[TagKey]; ok {
3434
if *existingTag != inst.Config.EnvironmentName {
35-
return nil, fmt.Errorf("storage account '%s' is already in use by enrironment '%s'", storageAccountConfig.Name, *existingTag)
35+
return nil, fmt.Errorf("storage account '%s' is already in use by environment '%s'", storageAccountConfig.Name, *existingTag)
3636
}
3737
tags = resp.Tags
3838
}

deploy/config/microsoft/cloudconfig-private-link.yml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,8 @@ organizations:
214214
objectId: 15bf92f7-9210-480f-a6ad-8d576d0baac7
215215
displayName: tyger-client-contributor
216216

217+
miseImage: eminence.azurecr.io/mise-sidecar:dev-1-37-0
218+
217219
buffers:
218220
# TTL for active buffers before they are automatically soft-deleted (D.HH:MM:SS) (0 = never expire)
219221
activeLifetime: 0.00:00

deploy/config/microsoft/cloudconfig.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ cloud:
7676
maxCount: 10
7777
# osSku: defaults to AzureLinux
7878

79+
inboundIpServiceTags:
80+
- type: FirstPartyUsage
81+
tag: /NonProd
82+
83+
outboundIpServiceTags:
84+
- type: FirstPartyUsage
85+
tag: /NonProd
86+
7987
# These are the principals that will have the ability to run `tyger api install`.
8088
# They will have access to the "tyger" namespace in each cluster and will have
8189
# the necessary Azure RBAC role assignments.

0 commit comments

Comments
 (0)