Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions model/gcp_vpc.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ class GcpVpc < Sequel::Model

plugin ResourceMethods
plugin SemaphoreMethods, :destroy, :update_firewall_rules

def management?
dedicated_for_private_subnet&.gcp_management? || false
end
end

# Table: gcp_vpc
Expand Down
22 changes: 22 additions & 0 deletions model/private_subnet.rb
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ class PrivateSubnet < Sequel::Model
DEFAULT_AWS_SUBNET_PREFIX_LEN = 16
DEFAULT_SUBNET_PREFIX_LEN = 26

# Name of the subnet that holds the management NICs of GCP Postgres VMs,
# one per location in the Postgres service project.
GCP_MANAGEMENT_SUBNET_NAME = "ubicloud-pg-mgmt"

dataset_module Pagination
include ObjectTag::Cleanup

Expand Down Expand Up @@ -66,6 +70,10 @@ def dedicated_mgmt_security_group?
!ps_aws.mgmt_security_group_id.nil? && ps_aws.mgmt_security_group_id != ps_aws.user_security_group_id
end

def gcp_management?
name == GCP_MANAGEMENT_SUBNET_NAME && project_id == Config.postgres_service_project_id
end

def before_destroy
PrivateSubnetFirewall.where(private_subnet_id: id).destroy
super
Expand Down Expand Up @@ -135,6 +143,20 @@ def random_private_ipv4
addr
end

# A single address anywhere in net4, outside the provider's reserved
# addresses. The GCP management subnet holds far more NICs than the
# 2**8 blocks that random_private_ipv4 hands out.
def random_host_ipv4
Prog::Vnet::SubnetNexus.until_random_ip("Could not find random IPv4 after 1000 iterations") { _random_host_ipv4 }
end

private def _random_host_ipv4
leading, trailing = ipv4_reservation
addr = net4.nth(SecureRandom.random_number(net4.len - leading - trailing) + leading)
host = NetAddr::IPv4Net.new(addr, NetAddr::Mask32.new(32))
host if nics_dataset.where(private_ipv4: host.to_s).empty?
end

def random_private_ipv6
addr = net6.nth_subnet(79, SecureRandom.random_number(2**(79 - net6.netmask.prefix_len) - 2).to_i + 1)
return random_private_ipv6 if nics.any? { |nic| nic.private_ipv6.to_s == addr.to_s }
Expand Down
2 changes: 2 additions & 0 deletions prog/vnet/gcp/nic_nexus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ class Prog::Vnet::Gcp::NicNexus < Prog::Base
include GcpLro

subject_is :nic
frame_reader :use_eip
frame_accessor :gcp_address_name

label def start
Expand All @@ -19,6 +20,7 @@ class Prog::Vnet::Gcp::NicNexus < Prog::Base
subnet_name: "ubicloud-#{ps.ubid}",
)

hop_wait if use_eip == false
hop_allocate_static_ip
end

Expand Down
37 changes: 35 additions & 2 deletions prog/vnet/gcp/subnet_nexus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,40 @@ class Prog::Vnet::Gcp::SubnetNexus < Prog::Base
# See doc/gcp_firewall_architecture.md for the full design.
ALLOW_SUBNET_BASE_PRIORITY = 1000

# Outside RFC 1918, so it never overlaps a customer subnet: GCE rejects
# an instance whose NICs have overlapping subnet ranges, also across VPCs.
MANAGEMENT_NET4 = "100.64.0.0/20"

# Idempotent: the operator runs it once per location, the E2E setup once
# per run.
def self.assemble_management(location_id)
unless (location = Location[location_id])&.gcp?
fail "No existing GCP location"
end

unless (project = Project[Config.postgres_service_project_id])
fail "No existing Postgres service project"
end

key = {project_id: project.id, location_id: location.id, name: PrivateSubnet::GCP_MANAGEMENT_SUBNET_NAME}
DB.transaction do
PrivateSubnet.dataset.insert_conflict(target: key.keys).insert(
id: PrivateSubnet.generate_uuid,
net4: MANAGEMENT_NET4,
net6: Prog::Vnet::SubnetNexus.random_private_ipv6(location, project).to_s,
state: "waiting",
**key,
)
ps = PrivateSubnet.first(key)
ps.strand || Strand.create_with_id(ps, prog: "Vnet::Gcp::SubnetNexus", label: "start")
end
end

label def start
register_deadline("wait", 5 * 60)

gcp_vpc = private_subnet.gcp_vpc ||
(private_subnet.project.gcp_dedicated_subnet_vpcs ? ensure_dedicated_vpc : ensure_shared_vpc)
((private_subnet.gcp_management? || private_subnet.project.gcp_dedicated_subnet_vpcs) ? ensure_dedicated_vpc : ensure_shared_vpc)
unless private_subnet.gcp_vpc
gcp_vpc.add_private_subnet(private_subnet)
# Firewalls attached to this subnet (or to VMs whose NICs live in
Expand Down Expand Up @@ -65,7 +94,7 @@ class Prog::Vnet::Gcp::SubnetNexus < Prog::Base
description: "Ubicloud subnet for #{private_subnet.ubid} [Ubicloud=#{Config.provider_resource_tag_value}]",
ip_cidr_range: private_subnet.net4.to_s,
network: "projects/#{gcp_project_id}/global/networks/#{private_subnet.gcp_vpc.name}",
private_ip_google_access: true,
private_ip_google_access: !private_subnet.gcp_management?,
stack_type: "IPV4_IPV6",
ipv6_access_type: "EXTERNAL",
),
Expand Down Expand Up @@ -93,6 +122,10 @@ class Prog::Vnet::Gcp::SubnetNexus < Prog::Base
end

label def create_tag_resources
# Management NICs carry no firewall tags: the management VPC's policy
# applies its rules to every NIC in it.
hop_wait if private_subnet.gcp_management?

self.tag_key_name ||= ensure_tag_key
# Emit on every entry (including frame re-reads) so a strand that
# crashed between creating the tag key and the next nap still surfaces
Expand Down
38 changes: 38 additions & 0 deletions prog/vnet/gcp/vpc_nexus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ class Prog::Vnet::Gcp::VpcNexus < Prog::Base
RFC1918_RANGES = ["10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"].freeze
GCE_INTERNAL_IPV6_RANGES = ["fd20::/20"].freeze
DENY_RULE_BASE_PRIORITY = 65534
# IPv4 rule at the base priority, IPv6 rule at base + 1.
MANAGEMENT_SSH_ALLOW_PRIORITY = 1000
MANAGEMENT_EGRESS_DENY_PRIORITY = 1002
DENY_RULE_DIRECTIONS = {"INGRESS" => :src_ip_ranges, "EGRESS" => :dest_ip_ranges}.freeze
VERIFY_ASSOC_MAX_TRIES = 5
DELETE_TAG_VALUE_MAX_TRIES = 30
Expand Down Expand Up @@ -173,6 +176,8 @@ def self.assemble(project_id, location_id, dedicated_for_subnet_id: nil)
end

label def create_vpc_deny_rules
hop_create_management_rules if gcp_vpc.management?

# 2 address families x 2 directions = 4 rules. Priorities walk down
# from DENY_RULE_BASE_PRIORITY in the order emitted (IPv4 ingress,
# IPv4 egress, IPv6 ingress, IPv6 egress).
Expand All @@ -187,6 +192,27 @@ def self.assemble(project_id, location_id, dedicated_for_subnet_id: nil)
hop_wait
end

# The management VPC holds only management NICs and gets no tag-targeted
# rules: SSH from the control plane in, nothing out. The firewall is
# stateful, so SSH replies still pass the egress deny.
label def create_management_rules
ipv6_cidrs, ipv4_cidrs = Config.control_plane_outbound_cidrs.partition { it.include?(":") }
[ipv4_cidrs, ipv6_cidrs].each_with_index do |cidrs, i|
priority = MANAGEMENT_SSH_ALLOW_PRIORITY + i
if cidrs.empty?
remove_firewall_policy_rule(priority)
else
ensure_firewall_policy_rule(priority:, direction: "INGRESS", action: "allow", src_ip_ranges: cidrs, layer4_configs: [{ip_protocol: "tcp", ports: ["22"]}])
end
end

[["0.0.0.0/0"], ["::/0"]].each_with_index do |ranges, i|
ensure_firewall_policy_rule(priority: MANAGEMENT_EGRESS_DENY_PRIORITY + i, direction: "EGRESS", action: "deny", dest_ip_ranges: ranges, layer4_configs: [{ip_protocol: "all"}])
end

hop_wait
end

label def wait
when_destroy_set? do
hop_destroy
Expand All @@ -207,6 +233,7 @@ def self.assemble(project_id, location_id, dedicated_for_subnet_id: nil)
end

decr_update_firewall_rules
hop_create_management_rules if gcp_vpc.management?
push Prog::Vnet::Gcp::VpcUpdateFirewallRules, {}, "update_firewall_rules"
end

Expand Down Expand Up @@ -548,6 +575,17 @@ def firewall_policy_name
gcp_vpc.name
end

def remove_firewall_policy_rule(priority)
credential.network_firewall_policies_client.remove_rule(
project: gcp_project_id,
firewall_policy: firewall_policy_name,
priority:,
)
rescue Google::Cloud::NotFoundError, Google::Cloud::InvalidArgumentError
# No rule at this priority.
nil
end

def emit_vpc_created
Clog.emit("GCP VPC created", {gcp_vpc_created: gcp_vpc.name})
end
Expand Down
3 changes: 2 additions & 1 deletion prog/vnet/nic_nexus.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,8 @@ def self.assemble(private_subnet_id, name: nil, ipv6_addr: nil, ipv4_addr: nil,
ipv4 = ipv4_addr || (allocate_ipv4_from_aws_subnet(subnet, aws_subnet) if create_network_interface)
["Vnet::Aws::NicNexus", ipv4&.to_s, nil, "active", aws_subnet&.id]
elsif subnet.location.gcp?
["Vnet::Gcp::NicNexus", (ipv4_addr || subnet.random_private_ipv4).to_s, nil, "active", nil]
ipv4 = ipv4_addr || (subnet.gcp_management? ? subnet.random_host_ipv4 : subnet.random_private_ipv4)
["Vnet::Gcp::NicNexus", ipv4.to_s, nil, "active", nil]
else
["Vnet::Metal::NicNexus", (ipv4_addr || subnet.random_private_ipv4).to_s, gen_mac, "initializing", nil]
end
Expand Down
26 changes: 26 additions & 0 deletions spec/model/gcp_vpc_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,32 @@
expect(gcp_vpc.private_subnets.map(&:id)).to eq([ps.id])
end

describe "#management?" do
let(:ps) {
PrivateSubnet.create(
name: PrivateSubnet::GCP_MANAGEMENT_SUBNET_NAME, location_id: location.id, project_id: project.id,
net6: "fd10:9b0b:6b4b:8fbb::/64", net4: "100.64.0.0/20", state: "waiting",
)
}

before { allow(Config).to receive(:postgres_service_project_id).and_return(project.id) }

it "is false for a shared VPC" do
expect(gcp_vpc.management?).to be false
end

it "is false for the dedicated VPC of another subnet" do
ps.update(name: "ps")
gcp_vpc.update(dedicated_for_subnet_id: ps.id)
expect(gcp_vpc.management?).to be false
end

it "is true for the dedicated VPC of the management subnet" do
gcp_vpc.update(dedicated_for_subnet_id: ps.id)
expect(gcp_vpc.management?).to be true
end
end

it "has destroy semaphore" do
Strand.create_with_id(gcp_vpc, prog: "Vnet::Gcp::VpcNexus", label: "start")
gcp_vpc.incr_destroy
Expand Down
44 changes: 44 additions & 0 deletions spec/model/private_subnet_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,25 @@
end
end

describe "#gcp_management?" do
before { allow(Config).to receive(:postgres_service_project_id).and_return(private_subnet.project_id) }

it "is true for the reserved name in the Postgres service project" do
private_subnet.update(name: described_class::GCP_MANAGEMENT_SUBNET_NAME)
expect(private_subnet.gcp_management?).to be true
end

it "is false for another name in the Postgres service project" do
expect(private_subnet.gcp_management?).to be false
end

it "is false for the reserved name in another project" do
private_subnet.update(name: described_class::GCP_MANAGEMENT_SUBNET_NAME)
allow(Config).to receive(:postgres_service_project_id).and_return(Project.generate_uuid)
expect(private_subnet.gcp_management?).to be false
end
end

describe "random ip generation" do
it "returns random private ipv4 on metal (skips first 4 + last 1, same as AWS)" do
private_subnet
Expand Down Expand Up @@ -100,6 +119,31 @@
expect(private_subnet.random_private_ipv6.to_s).to eq "fd1b:9793:dcef:cd0a:c::/79"
end

describe "#random_host_ipv4" do
let(:gcp_ps) {
loc = Location.create(name: "gcp-us-central1", provider: "gcp",
display_name: "GCP US Central 1", ui_name: "GCP US Central 1", visible: true)
described_class.create(name: "gcp-host-ps", location_id: loc.id,
net6: "fd1b:9793:dcef:cd0c::/64", net4: "100.64.0.0/20",
state: "waiting", project_id: Project.create(name: "gcp-host-prj").id)
}

it "returns a single address outside the GCP reserved addresses" do
gcp_ps
expect(SecureRandom).to receive(:random_number).with(4092).and_return(0, 4091)
expect(gcp_ps.random_host_ipv4.to_s).to eq "100.64.0.2/32"
expect(gcp_ps.random_host_ipv4.to_s).to eq "100.64.15.253/32"
end

it "skips an address that a NIC holds" do
Nic.create(private_subnet_id: gcp_ps.id, private_ipv4: "100.64.0.7/32",
private_ipv6: "fd1b:9793:dcef:cd0c:2::/79", mac: "00:00:00:00:00:02",
name: "held-nic", state: "active")
expect(SecureRandom).to receive(:random_number).with(4092).and_return(5, 6)
expect(gcp_ps.random_host_ipv4.to_s).to eq "100.64.0.8/32"
end
end

context "when ip exists" do
before do
private_subnet.save_changes
Expand Down
8 changes: 8 additions & 0 deletions spec/prog/vnet/gcp/nic_nexus_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@
expect(gcp_res.vpc_name).to eq("ubicloud-#{private_subnet.project.ubid}-#{private_subnet.location.ubid}")
expect(gcp_res.subnet_name).to eq("ubicloud-#{private_subnet.ubid}")
end

it "skips the static IP of a NIC assembled with use_eip false" do
refresh_frame(nx, new_values: {"use_eip" => false})
expect(addresses_client).not_to receive(:insert)

expect { nx.start }.to hop("wait")
expect(NicGcpResource[nic.id].static_ip).to be_nil
end
end

describe "#allocate_static_ip" do
Expand Down
Loading
Loading