This also changes the api to make both pg_config and pg_bouncer_config optional parameters instead of required parameters for PATCH requests. The code already assumed the parameters were optional. Update the route code to require both pg_config and pgbouncer_config be set for POST requests. This isn't strictly required as committee enforces it, but in general we treat committee as an extra layer of protection and not as the sole validator.
547 lines
19 KiB
Ruby
547 lines
19 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
class Clover
|
|
hash_branch(:project_location_prefix, "postgres") do |r|
|
|
r.get api? do
|
|
postgres_list
|
|
end
|
|
|
|
r.on POSTGRES_RESOURCE_NAME_OR_UBID do |pg_name, pg_ubid|
|
|
if pg_name
|
|
r.post api? do
|
|
check_visible_location
|
|
postgres_post(pg_name)
|
|
end
|
|
|
|
filter = {Sequel[:postgres_resource][:name] => pg_name}
|
|
else
|
|
filter = {Sequel[:postgres_resource][:id] => UBID.to_uuid(pg_ubid)}
|
|
end
|
|
|
|
filter[:location_id] = @location.id
|
|
@pg = pg = @project.postgres_resources_dataset.first(filter)
|
|
check_found_object(pg)
|
|
|
|
r.is do
|
|
r.get do
|
|
authorize("Postgres:view", pg.id)
|
|
response.headers["cache-control"] = "no-store"
|
|
|
|
if api?
|
|
Serializers::Postgres.serialize(pg, {detailed: true})
|
|
else
|
|
r.redirect "#{@project.path}#{pg.path}/overview"
|
|
end
|
|
end
|
|
|
|
r.delete do
|
|
authorize("Postgres:delete", pg.id)
|
|
DB.transaction do
|
|
pg.incr_destroy
|
|
audit_log(pg, "destroy")
|
|
end
|
|
204
|
|
end
|
|
|
|
r.patch do
|
|
authorize("Postgres:edit", pg.id)
|
|
|
|
size = typecast_params.nonempty_str("size", pg.target_vm_size)
|
|
target_storage_size_gib = typecast_params.pos_int("storage_size", pg.target_storage_size_gib)
|
|
ha_type = typecast_params.nonempty_str("ha_type", pg.ha_type)
|
|
tags = typecast_params.array(:Hash, "tags", pg.tags)
|
|
|
|
postgres_params = {
|
|
"flavor" => pg.flavor,
|
|
"location" => pg.location,
|
|
"family" => Option::POSTGRES_SIZE_OPTIONS[size]&.family,
|
|
"size" => size,
|
|
"storage_size" => target_storage_size_gib.to_s,
|
|
"ha_type" => ha_type,
|
|
"version" => pg.version
|
|
}
|
|
|
|
validate_postgres_input(pg.name, postgres_params)
|
|
|
|
if pg.representative_server.nil? || target_storage_size_gib < pg.representative_server.storage_size_gib
|
|
begin
|
|
current_disk_usage = pg.representative_server.vm.sshable.cmd("df --output=used /dev/vdb | tail -n 1").strip.to_i / (1024 * 1024)
|
|
rescue
|
|
fail CloverError.new(400, "InvalidRequest", "Database is not ready for update", {})
|
|
end
|
|
|
|
if target_storage_size_gib * 0.8 < current_disk_usage
|
|
fail Validation::ValidationFailed.new({storage_size: "Insufficient storage size is requested. It is only possible to reduce the storage size if the current usage is less than 80% of the requested size."})
|
|
end
|
|
end
|
|
|
|
current_parsed_size = Option::POSTGRES_SIZE_OPTIONS[pg.target_vm_size]
|
|
current_postgres_vcpu_count = pg.target_server_count * current_parsed_size.vcpu_count
|
|
|
|
requested_parsed_size = Option::POSTGRES_SIZE_OPTIONS[postgres_params["size"]]
|
|
requested_standby_count = Option::POSTGRES_HA_OPTIONS[postgres_params["ha_type"]].standby_count
|
|
requested_postgres_vcpu_count = (requested_standby_count + 1) * requested_parsed_size.vcpu_count
|
|
|
|
Validation.validate_vcpu_quota(@project, "PostgresVCpu", requested_postgres_vcpu_count - current_postgres_vcpu_count)
|
|
|
|
DB.transaction do
|
|
pg.update(target_vm_size: requested_parsed_size.name, target_storage_size_gib:, ha_type:, tags:)
|
|
pg.read_replicas_dataset.update(target_vm_size: requested_parsed_size.name, target_storage_size_gib:)
|
|
audit_log(pg, "update")
|
|
end
|
|
|
|
if api?
|
|
Serializers::Postgres.serialize(pg, {detailed: true})
|
|
else
|
|
flash["notice"] = "'#{pg.name}' will be updated according to requested configuration"
|
|
response["location"] = "#{@project.path}#{pg.path}"
|
|
200
|
|
end
|
|
end
|
|
end
|
|
|
|
r.get web?, %w[overview connection charts networking resize high-availability read-replica backup-restore config settings] do |page|
|
|
authorize("Postgres:view", pg.id)
|
|
|
|
next if pg.read_replica? && %w[resize high-availability read-replica backup-restore].include?(page)
|
|
|
|
response.headers["cache-control"] = "no-store"
|
|
|
|
@page = page
|
|
|
|
view "postgres/show"
|
|
end
|
|
|
|
r.post "restart" do
|
|
authorize("Postgres:edit", pg.id)
|
|
DB.transaction do
|
|
Semaphore.incr(pg.servers_dataset.select(:id), "restart")
|
|
audit_log(pg, "restart")
|
|
end
|
|
|
|
if api?
|
|
Serializers::Postgres.serialize(pg, {detailed: true})
|
|
else
|
|
flash["notice"] = "'#{pg.name}' will be restarted in a few seconds"
|
|
r.redirect "#{@project.path}#{pg.path}/settings"
|
|
end
|
|
end
|
|
|
|
r.on "firewall-rule" do
|
|
r.is do
|
|
r.get api? do
|
|
authorize("Postgres:view", pg.id)
|
|
{
|
|
items: Serializers::PostgresFirewallRule.serialize(pg.firewall_rules),
|
|
count: pg.firewall_rules.count
|
|
}
|
|
end
|
|
|
|
r.post do
|
|
authorize("Postgres:edit", pg.id)
|
|
handle_validation_failure("postgres/show") { @page = "networking" }
|
|
|
|
parsed_cidr = Validation.validate_cidr(typecast_params.nonempty_str!("cidr"))
|
|
|
|
firewall_rule = nil
|
|
DB.transaction do
|
|
pg.incr_update_firewall_rules
|
|
firewall_rule = PostgresFirewallRule.create(
|
|
postgres_resource_id: pg.id,
|
|
cidr: parsed_cidr.to_s,
|
|
description: typecast_params.str("description")&.strip
|
|
)
|
|
audit_log(firewall_rule, "create", pg)
|
|
end
|
|
|
|
if api?
|
|
Serializers::PostgresFirewallRule.serialize(firewall_rule)
|
|
else
|
|
flash["notice"] = "Firewall rule is created"
|
|
r.redirect "#{@project.path}#{pg.path}/networking"
|
|
end
|
|
end
|
|
end
|
|
|
|
r.is :ubid_uuid do |id|
|
|
authorize("Postgres:edit", pg.id)
|
|
fwr = pg.firewall_rules_dataset[id:]
|
|
check_found_object(fwr)
|
|
|
|
r.patch do
|
|
current_cidr = fwr.cidr.to_s
|
|
new_cidr = Validation.validate_cidr(typecast_params.nonempty_str("cidr") || fwr.cidr.to_s).to_s
|
|
description = typecast_params.str("description")&.strip || fwr.description
|
|
|
|
DB.transaction do
|
|
fwr.update(
|
|
cidr: new_cidr,
|
|
description:
|
|
)
|
|
pg.incr_update_firewall_rules if current_cidr != new_cidr
|
|
audit_log(fwr, "update")
|
|
end
|
|
|
|
if api?
|
|
Serializers::PostgresFirewallRule.serialize(fwr)
|
|
else
|
|
204
|
|
end
|
|
end
|
|
|
|
r.delete do
|
|
DB.transaction do
|
|
fwr.destroy
|
|
pg.incr_update_firewall_rules
|
|
audit_log(fwr, "destroy")
|
|
end
|
|
|
|
204
|
|
end
|
|
end
|
|
end
|
|
|
|
r.on "metric-destination" do
|
|
r.post true do
|
|
authorize("Postgres:edit", pg.id)
|
|
handle_validation_failure("postgres/show") { @page = "charts" }
|
|
|
|
password_param = (api? ? "password" : "metric-destination-password")
|
|
url, username, password = typecast_params.nonempty_str!(["url", "username", password_param])
|
|
|
|
Validation.validate_url(url)
|
|
|
|
DB.transaction do
|
|
md = PostgresMetricDestination.create(postgres_resource_id: pg.id, url:, username:, password:)
|
|
pg.servers.each(&:incr_configure_metrics)
|
|
audit_log(md, "create", pg)
|
|
end
|
|
|
|
if api?
|
|
Serializers::Postgres.serialize(pg, {detailed: true})
|
|
else
|
|
flash["notice"] = "Metric destination is created"
|
|
r.redirect "#{@project.path}#{pg.path}/charts"
|
|
end
|
|
end
|
|
|
|
r.delete :ubid_uuid do |id|
|
|
authorize("Postgres:edit", pg.id)
|
|
|
|
if (md = pg.metric_destinations_dataset[id:])
|
|
DB.transaction do
|
|
md.destroy
|
|
pg.servers.each(&:incr_configure_metrics)
|
|
audit_log(md, "destroy")
|
|
end
|
|
end
|
|
|
|
204
|
|
end
|
|
end
|
|
|
|
r.post "read-replica" do
|
|
authorize("Postgres:edit", pg.id)
|
|
handle_validation_failure("postgres/show") { @page = "read-replica" }
|
|
|
|
name = typecast_params.nonempty_str!("name")
|
|
|
|
Validation.validate_name(name)
|
|
|
|
Validation.validate_vcpu_quota(@project, "PostgresVCpu", Option::POSTGRES_SIZE_OPTIONS[pg.target_vm_size].vcpu_count)
|
|
if pg.timeline.earliest_restore_time.nil?
|
|
error_msg = "Parent server is not ready for read replicas. There are no backups, yet."
|
|
fail CloverError.new(400, "InvalidRequest", error_msg)
|
|
end
|
|
|
|
st = nil
|
|
DB.transaction do
|
|
st = Prog::Postgres::PostgresResourceNexus.assemble(
|
|
project_id: @project.id,
|
|
location_id: pg.location_id,
|
|
name:,
|
|
target_vm_size: pg.target_vm_size,
|
|
target_storage_size_gib: pg.target_storage_size_gib,
|
|
ha_type: PostgresResource::HaType::NONE,
|
|
version: pg.version,
|
|
flavor: pg.flavor,
|
|
parent_id: pg.id,
|
|
restore_target: nil
|
|
)
|
|
audit_log(pg, "create_replica", st.subject)
|
|
end
|
|
send_notification_mail_to_partners(st.subject, current_account.email)
|
|
|
|
if api?
|
|
Serializers::Postgres.serialize(st.subject, {detailed: true})
|
|
else
|
|
flash["notice"] = "'#{name}' will be ready in a few minutes"
|
|
r.redirect "#{@project.path}#{st.subject.path}/overview"
|
|
end
|
|
end
|
|
|
|
r.post "promote" do
|
|
authorize("Postgres:edit", pg.id)
|
|
handle_validation_failure("postgres/show") { @page = "settings" }
|
|
|
|
unless pg.read_replica?
|
|
error_msg = "Non read replica servers cannot be promoted."
|
|
fail CloverError.new(400, "InvalidRequest", error_msg)
|
|
end
|
|
|
|
DB.transaction do
|
|
pg.incr_promote
|
|
audit_log(pg, "promote")
|
|
end
|
|
|
|
if api?
|
|
Serializers::Postgres.serialize(pg)
|
|
else
|
|
flash["notice"] = "'#{pg.name}' will be promoted in a few minutes, please refresh the page"
|
|
r.redirect "#{@project.path}#{pg.path}/settings"
|
|
end
|
|
end
|
|
|
|
r.post "restore" do
|
|
authorize("Postgres:create", @project.id)
|
|
authorize("Postgres:view", pg.id)
|
|
handle_validation_failure("postgres/show") { @page = "backup_restore" }
|
|
|
|
name, restore_target = typecast_params.nonempty_str!(["name", "restore_target"])
|
|
st = nil
|
|
|
|
Validation.validate_name(name)
|
|
|
|
Validation.validate_vcpu_quota(@project, "PostgresVCpu", Option::POSTGRES_SIZE_OPTIONS[pg.target_vm_size].vcpu_count)
|
|
|
|
DB.transaction do
|
|
st = Prog::Postgres::PostgresResourceNexus.assemble(
|
|
project_id: @project.id,
|
|
location_id: pg.location_id,
|
|
name:,
|
|
target_vm_size: pg.target_vm_size,
|
|
target_storage_size_gib: pg.target_storage_size_gib,
|
|
version: pg.version,
|
|
flavor: pg.flavor,
|
|
parent_id: pg.id,
|
|
restore_target:
|
|
)
|
|
audit_log(pg, "restore", st.subject)
|
|
end
|
|
send_notification_mail_to_partners(st.subject, current_account.email)
|
|
|
|
if api?
|
|
Serializers::Postgres.serialize(st.subject, {detailed: true})
|
|
else
|
|
flash["notice"] = "'#{name}' will be ready in a few minutes"
|
|
r.redirect "#{@project.path}#{st.subject.path}/overview"
|
|
end
|
|
end
|
|
|
|
r.post "reset-superuser-password" do
|
|
authorize("Postgres:view", pg.id)
|
|
handle_validation_failure("postgres/show") { @page = "settings" }
|
|
|
|
if pg.read_replica?
|
|
raise CloverError.new(400, "InvalidRequest", "Superuser password cannot be updated for read replicas!")
|
|
end
|
|
|
|
password = typecast_params.str!("password")
|
|
repeat_password = typecast_params.str!("repeat_password") if web?
|
|
Validation.validate_postgres_superuser_password(password, repeat_password)
|
|
|
|
DB.transaction do
|
|
pg.update(superuser_password: password)
|
|
pg.representative_server.incr_update_superuser_password
|
|
audit_log(pg, "reset_superuser_password")
|
|
end
|
|
|
|
if api?
|
|
Serializers::Postgres.serialize(pg, {detailed: true})
|
|
else
|
|
flash["notice"] = "The superuser password will be updated in a few seconds"
|
|
r.redirect "#{@project.path}#{pg.path}/settings"
|
|
end
|
|
end
|
|
|
|
r.post "set-maintenance-window" do
|
|
authorize("Postgres:edit", pg.id)
|
|
maintenance_window_start_at = typecast_params.int("maintenance_window_start_at")
|
|
|
|
DB.transaction do
|
|
pg.update(maintenance_window_start_at:)
|
|
audit_log(pg, "set_maintenance_window")
|
|
end
|
|
|
|
if api?
|
|
Serializers::Postgres.serialize(pg, {detailed: true})
|
|
else
|
|
flash["notice"] = "Maintenance window is set"
|
|
r.redirect "#{@project.path}#{pg.path}/settings"
|
|
end
|
|
end
|
|
|
|
r.get "ca-certificates" do
|
|
authorize("Postgres:view", pg.id)
|
|
|
|
next unless (certs = pg.ca_certificates)
|
|
|
|
response.headers["content-disposition"] = "attachment; filename=\"#{pg.name}.pem\""
|
|
response.headers["content-type"] = "application/x-pem-file"
|
|
certs
|
|
end
|
|
|
|
r.get "metrics", r.accepts_json? do
|
|
authorize("Postgres:view", pg.id)
|
|
|
|
start_time, end_time = typecast_params.str(%w[start end])
|
|
start_time ||= (DateTime.now.new_offset(0) - 30.0 / 1440).rfc3339
|
|
start_time = Validation.validate_rfc3339_datetime_str(start_time, "start")
|
|
|
|
end_time ||= DateTime.now.new_offset(0).rfc3339
|
|
end_time = Validation.validate_rfc3339_datetime_str(end_time, "end")
|
|
|
|
start_ts = start_time.to_i
|
|
end_ts = end_time.to_i
|
|
|
|
if end_ts < start_ts
|
|
raise CloverError.new(400, "InvalidRequest", "End timestamp must be greater than start timestamp")
|
|
end
|
|
|
|
if end_ts - start_ts > 31 * 24 * 60 * 60 + 5 * 60
|
|
raise CloverError.new(400, "InvalidRequest", "Maximum time range is 31 days")
|
|
end
|
|
|
|
if start_ts < Time.now.utc.to_i - 31 * 24 * 60 * 60
|
|
raise CloverError.new(400, "InvalidRequest", "Cannot query metrics older than 31 days")
|
|
end
|
|
|
|
metric_key = typecast_params.str("key")&.to_sym
|
|
single_query = !metric_key.nil?
|
|
|
|
if single_query && !Metrics::POSTGRES_METRICS.key?(metric_key)
|
|
raise CloverError.new(400, "InvalidRequest", "Invalid metric name")
|
|
end
|
|
|
|
metric_keys = metric_key ? [metric_key] : Metrics::POSTGRES_METRICS.keys
|
|
|
|
vmr = VictoriaMetricsResource.first(project_id: pg.representative_server.metrics_config[:project_id])
|
|
vms = vmr&.servers_dataset&.first
|
|
tsdb_client = vms&.client || (VictoriaMetrics::Client.new(endpoint: "http://localhost:8428") if Config.development?)
|
|
|
|
if tsdb_client.nil?
|
|
raise CloverError.new(404, "NotFound", "Metrics are not configured for this instance")
|
|
end
|
|
|
|
results = metric_keys.map do |key|
|
|
metric_definition = Metrics::POSTGRES_METRICS[key]
|
|
|
|
series_results = metric_definition.series.filter_map do |s|
|
|
query = s.query.gsub("$ubicloud_resource_id", pg.ubid)
|
|
begin
|
|
series_query_result = tsdb_client.query_range(
|
|
query: query,
|
|
start_ts: start_ts,
|
|
end_ts: end_ts
|
|
)
|
|
|
|
# This can be a two cases:
|
|
# 1. Missing data (e.g. no data for the given time range)
|
|
# 2. No data for the given query (maybe bad query)
|
|
if series_query_result.empty?
|
|
next
|
|
end
|
|
|
|
# Combine labels with configured series labesls.
|
|
series_query_result.each { it["labels"].merge!(s.labels) }
|
|
|
|
series_query_result
|
|
rescue VictoriaMetrics::ClientError => e
|
|
Clog.emit("Could not query VictoriaMetrics") { {error: e.message, query: query} }
|
|
|
|
if single_query
|
|
raise CloverError.new(500, "InternalError", "Internal error while querying metrics", {query: query})
|
|
end
|
|
end
|
|
end
|
|
|
|
{
|
|
key: key.to_s,
|
|
name: metric_definition.name,
|
|
unit: metric_definition.unit,
|
|
description: metric_definition.description,
|
|
series: series_results.flatten
|
|
}
|
|
end
|
|
|
|
{
|
|
metrics: results
|
|
}
|
|
end
|
|
|
|
r.is "config" do
|
|
r.get do
|
|
authorize("Postgres:view", pg.id)
|
|
|
|
{
|
|
pg_config: pg.user_config,
|
|
pgbouncer_config: pg.pgbouncer_user_config
|
|
}
|
|
end
|
|
|
|
r.on method: [:post, :patch] do
|
|
authorize("Postgres:edit", pg.id)
|
|
handle_validation_failure("postgres/show") { @page = "config" }
|
|
|
|
if web?
|
|
pg_keys = typecast_params.array(:str, "pg_config_keys") || []
|
|
pg_values = typecast_params.array(:str, "pg_config_values") || []
|
|
pg_config = config_hash_from_kvs(pg_keys, pg_values)
|
|
|
|
pgbouncer_keys = typecast_params.array(:str, "pgbouncer_config_keys") || []
|
|
pgbouncer_values = typecast_params.array(:str, "pgbouncer_config_values") || []
|
|
pgbouncer_config = config_hash_from_kvs(pgbouncer_keys, pgbouncer_values)
|
|
elsif r.patch?
|
|
pg_config = typecast_params.Hash("pg_config") || {}
|
|
pgbouncer_config = typecast_params.Hash("pgbouncer_config") || {}
|
|
# For PATCH requests, merge with existing config
|
|
pg_config = pg.user_config.merge(pg_config).compact
|
|
pgbouncer_config = pg.pgbouncer_user_config.merge(pgbouncer_config).compact
|
|
else
|
|
pg_config = typecast_params.Hash!("pg_config")
|
|
pgbouncer_config = typecast_params.Hash!("pgbouncer_config")
|
|
end
|
|
|
|
pg_validator = Validation::PostgresConfigValidator.new(pg.version)
|
|
pg_errors = pg_validator.validation_errors(pg_config)
|
|
|
|
pgbouncer_validator = Validation::PostgresConfigValidator.new("pgbouncer")
|
|
pgbouncer_errors = pgbouncer_validator.validation_errors(pgbouncer_config)
|
|
|
|
if pg_errors.any? || pgbouncer_errors.any?
|
|
pg_errors = pg_errors.map { |key, value| ["pg_config.#{key}", value] }.to_h
|
|
pgbouncer_errors = pgbouncer_errors.map { |key, value| ["pgbouncer_config.#{key}", value] }.to_h
|
|
raise Validation::ValidationFailed.new(pg_errors.merge(pgbouncer_errors))
|
|
end
|
|
|
|
pg.update(user_config: pg_config, pgbouncer_user_config: pgbouncer_config)
|
|
|
|
pg.servers.each(&:incr_configure)
|
|
|
|
audit_log(pg, "update")
|
|
|
|
if api?
|
|
{
|
|
pg_config: pg.user_config,
|
|
pgbouncer_config: pg.pgbouncer_user_config
|
|
}
|
|
else
|
|
flash["notice"] = "Configuration updated successfully"
|
|
r.redirect "#{@project.path}#{pg.path}/config"
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|