From 07113b1ae1157008f6885838ae20b676e4ea1b82 Mon Sep 17 00:00:00 2001 From: michaeljguarino Date: Fri, 20 Sep 2024 13:10:26 -0400 Subject: [PATCH] Implement dedicated cloud console types (#1363) --- apps/core/lib/core/clients/console.ex | 68 ++++++++ apps/core/lib/core/policies/cloud.ex | 8 + apps/core/lib/core/schema/console_instance.ex | 20 ++- apps/core/lib/core/services/cloud.ex | 10 +- .../lib/core/services/cloud/configuration.ex | 21 +++ apps/core/lib/core/services/cloud/poller.ex | 22 ++- apps/core/lib/core/services/cloud/utils.ex | 14 ++ apps/core/lib/core/services/cloud/workflow.ex | 152 +++--------------- .../core/services/cloud/workflow/dedicated.ex | 79 +++++++++ .../core/services/cloud/workflow/shared.ex | 136 ++++++++++++++++ apps/core/lib/core/services/payments.ex | 17 ++ .../20240917232002_add_dedicated_console.exs | 9 ++ .../test/services/cloud/workflow_test.exs | 59 +++++++ apps/core/test/services/cloud_test.exs | 51 ++++++ apps/core/test/support/factory.ex | 4 + apps/graphql/lib/graphql/schema/cloud.ex | 3 + .../test/mutations/cloud_mutations_test.exs | 1 + config/config.exs | 3 + rel/config/config.exs | 3 + schema/schema.graphql | 13 ++ .../steps/ConfigureCloudInstanceStep.tsx | 2 + www/src/generated/graphql.ts | 13 +- 22 files changed, 567 insertions(+), 141 deletions(-) create mode 100644 apps/core/lib/core/services/cloud/utils.ex create mode 100644 apps/core/lib/core/services/cloud/workflow/dedicated.ex create mode 100644 apps/core/lib/core/services/cloud/workflow/shared.ex create mode 100644 apps/core/priv/repo/migrations/20240917232002_add_dedicated_console.exs diff --git a/apps/core/lib/core/clients/console.ex b/apps/core/lib/core/clients/console.ex index b3fe4f6d2..a8050c7ad 100644 --- a/apps/core/lib/core/clients/console.ex +++ b/apps/core/lib/core/clients/console.ex @@ -1,6 +1,12 @@ defmodule Core.Clients.Console do require Logger + @me_q """ + query { + me { id } + } + """ + @clusters_q """ query { clusters(first: 100) { @@ -33,6 +39,30 @@ defmodule Core.Clients.Console do } """ + @create_stack_q """ + mutation Create($attributes: StackAttributes!) { + createStack(attributes: $attributes) { + id + } + } + """ + + @update_stack_q """ + mutation update($id: ID!, $attributes: StackAttributes!) { + updateStack(id: $id, attributes: $attributes) { + id + } + } + """ + + @delete_stack_q """ + mutation delete($id: ID!) { + deleteStack(id: $id) { + id + } + } + """ + @repo_q """ query Repo($url: String!) { gitRepository(url: $url) { @@ -41,10 +71,17 @@ defmodule Core.Clients.Console do } """ + @project_q """ + query Project($name: String!) { + project(name: $name) { id } + } + """ + @stack_q """ query Stack($id: ID!) { infrastructureStack(id: $id) { id + status output { name value @@ -53,6 +90,11 @@ defmodule Core.Clients.Console do } """ + def queries(:me_q), do: @me_q + def queries(:stack_q), do: @stack_q + def queries(:stack_create), do: @create_stack_q + def queries(:stack_delete), do: @delete_stack_q + def new(url, token) do Req.new(base_url: with_gql(url), auth: "Token #{token}") |> AbsintheClient.attach() @@ -74,6 +116,16 @@ defmodule Core.Clients.Console do |> service_resp("gitRepository") end + def me(client) do + Req.post(client, graphql: {@me_q, %{}}) + |> service_resp("me") + end + + def project(client, name) do + Req.post(client, graphql: {@project_q, %{name: name}}) + |> service_resp("project") + end + def create_service(client, cluster_id, attrs) do Req.post(client, graphql: {@create_svc_q, %{clusterId: cluster_id, attributes: attrs}}) |> service_resp("createServiceDeployment") @@ -90,6 +142,22 @@ defmodule Core.Clients.Console do |> ignore_not_found() end + def create_stack(client, attrs) do + Req.post(client, graphql: {@create_stack_q, %{attributes: attrs}}) + |> service_resp("createStack") + end + + def update_stack(client, id, attrs) do + Req.post(client, graphql: {@update_stack_q, %{id: id, attributes: attrs}}) + |> service_resp("updateStack") + end + + def delete_stack(client, id) do + Req.post(client, graphql: {@delete_stack_q, %{id: id}}) + |> service_resp("deleteStack") + |> ignore_not_found() + end + def stack(client, id) do Req.post(client, graphql: {@stack_q, %{id: id}}) |> case do diff --git a/apps/core/lib/core/policies/cloud.ex b/apps/core/lib/core/policies/cloud.ex index b63f23c0e..5360ed453 100644 --- a/apps/core/lib/core/policies/cloud.ex +++ b/apps/core/lib/core/policies/cloud.ex @@ -3,6 +3,14 @@ defmodule Core.Policies.Cloud do alias Core.Schema.{User, ConsoleInstance} alias Core.Services.Payments + def can?(%User{} = user, %ConsoleInstance{type: :dedicated}, :create) do + case Payments.enterprise?(user) do + true -> :pass + _ -> + {:error, "you must be on an enterprise plan to create a dedicated Plural cluster"} + end + end + def can?(%User{} = user, %ConsoleInstance{}, :create) do case Payments.has_feature?(user, :cd) do true -> :pass diff --git a/apps/core/lib/core/schema/console_instance.ex b/apps/core/lib/core/schema/console_instance.ex index 80efe5903..4f532dbbb 100644 --- a/apps/core/lib/core/schema/console_instance.ex +++ b/apps/core/lib/core/schema/console_instance.ex @@ -3,6 +3,7 @@ defmodule Core.Schema.ConsoleInstance do alias Piazza.Ecto.EncryptedString alias Core.Schema.{PostgresCluster, CloudCluster, User} + defenum Type, shared: 0, dedicated: 1 defenum Size, small: 0, medium: 1, large: 2 defenum Status, pending: 0, @@ -10,13 +11,16 @@ defmodule Core.Schema.ConsoleInstance do deployment_created: 2, provisioned: 3, deployment_deleted: 4, - database_deleted: 5 + database_deleted: 5, + stack_created: 6, + stack_deleted: 7 @region_map %{ aws: ~w(us-east-1) } schema "console_instances" do + field :type, Type, default: :shared field :name, :string field :status, Status field :subdomain, :string @@ -33,6 +37,7 @@ defmodule Core.Schema.ConsoleInstance do embeds_one :instance_status, InstanceStatus, on_replace: :update do field :db, :boolean, default: false field :svc, :boolean, default: false + field :stack, :boolean, default: false end embeds_one :configuration, Configuration, on_replace: :update do @@ -96,14 +101,14 @@ defmodule Core.Schema.ConsoleInstance do def regions(), do: @region_map - @valid ~w(name cloud size region status subdomain url external_id postgres_id cluster_id owner_id)a + @valid ~w(name type cloud size region status subdomain url external_id postgres_id cluster_id owner_id)a def changeset(model, attrs \\ %{}) do model |> cast(attrs, @valid) |> cast_embed(:configuration, with: &configuration_changeset/2) |> cast_embed(:instance_status, with: &status_changeset/2) - |> validate_required(@valid -- [:external_id]) + |> validate_required(@valid -- ~w(external_id postgres_id cluster_id)a) |> foreign_key_constraint(:cluster_id) |> foreign_key_constraint(:postgres_id) |> foreign_key_constraint(:owner_id) @@ -118,6 +123,13 @@ defmodule Core.Schema.ConsoleInstance do |> cast(attrs, @valid) |> validate_required(~w(name region status cloud size)a) |> validate_region() + |> foreign_key_constraint(:cluster_id) + |> foreign_key_constraint(:postgres_id) + |> foreign_key_constraint(:owner_id) + |> unique_constraint(:subdomain) + |> unique_constraint(:name) + |> validate_format(:name, ~r/[a-z][a-z0-9]{5,10}/, message: "must be an alphanumeric string between 5 and 11 characters") + |> validate_region() end defp validate_region(cs) do @@ -146,6 +158,6 @@ defmodule Core.Schema.ConsoleInstance do defp status_changeset(model, attrs) do model - |> cast(attrs, ~w(db svc)a) + |> cast(attrs, ~w(db svc stack)a) end end diff --git a/apps/core/lib/core/services/cloud.ex b/apps/core/lib/core/services/cloud.ex index 08f386ec5..eb5fe96fe 100644 --- a/apps/core/lib/core/services/cloud.ex +++ b/apps/core/lib/core/services/cloud.ex @@ -47,8 +47,14 @@ defmodule Core.Services.Cloud do |> allow(user, :create) |> when_ok(:insert) end) - |> add_operation(:cluster, fn _ -> select_cluster(attrs[:cloud], attrs[:region]) end) - |> add_operation(:postgres, fn _ -> select_roach(attrs[:cloud]) end) + |> add_operation(:cluster, fn + %{inst: %ConsoleInstance{type: :dedicated}} -> {:ok, %{id: nil}} + _ -> select_cluster(attrs[:cloud], attrs[:region]) + end) + |> add_operation(:postgres, fn + %{inst: %ConsoleInstance{type: :dedicated}} -> {:ok, %{id: nil}} + _ -> select_roach(attrs[:cloud]) + end) |> add_operation(:sa, fn _ -> Accounts.create_service_account(%{ name: "#{name}-cloud-sa", diff --git a/apps/core/lib/core/services/cloud/configuration.ex b/apps/core/lib/core/services/cloud/configuration.ex index b16dfa7a7..b5764bcc0 100644 --- a/apps/core/lib/core/services/cloud/configuration.ex +++ b/apps/core/lib/core/services/cloud/configuration.ex @@ -25,11 +25,32 @@ defmodule Core.Services.Cloud.Configuration do size: "#{size}", }) |> Map.put(:size, "#{size}") + |> Enum.filter(fn {_, v} -> is_binary(v) end) |> Enum.map(fn {k, v} -> %{name: k, value: v} end) end + def stack_attributes(%ConsoleInstance{name: name} = inst, attrs) do + Map.merge(attrs, %{ + name: "dedicated-#{name}", + cluster_id: Core.conf(:mgmt_cluster), + type: "TERRAFORM", + manageState: true, + approval: false, + configuration: %{version: "1.8"}, + git: %{ref: "main", folder: "terraform/modules/dedicated/#{inst.cloud}"}, + environment: Enum.map([ + region: inst.region, + development: String.contains?(Core.conf(:dedicated_project), "dev"), + cluster_name: "dedicated-#{inst.name}", + size: inst.size, + service_secrets: build(inst) |> Map.new(fn %{name: n, value: v} -> {n, v} end) |> Jason.encode!() + ], fn {k, v} -> %{name: "TF_VAR_#{k}", value: "#{v}"} end) + }) + end + # defp certificate(%ConsoleInstance{postgres: %PostgresCluster{certificate: cert}}), do: cert + defp build_pg_url(%ConsoleInstance{type: :dedicated}), do: nil defp build_pg_url(%ConsoleInstance{ configuration: %{dbuser: u, dbpassword: p, database: database}, postgres: %PostgresCluster{host: host} diff --git a/apps/core/lib/core/services/cloud/poller.ex b/apps/core/lib/core/services/cloud/poller.ex index 396a0fca0..d9b46bf1b 100644 --- a/apps/core/lib/core/services/cloud/poller.ex +++ b/apps/core/lib/core/services/cloud/poller.ex @@ -6,7 +6,7 @@ defmodule Core.Services.Cloud.Poller do @poll :timer.minutes(2) - defmodule State, do: defstruct [:client, :repo] + defmodule State, do: defstruct [:client, :dedicated_client, :repo, :project] def start_link(_) do GenServer.start_link(__MODULE__, :ok, name: __MODULE__) @@ -16,15 +16,24 @@ defmodule Core.Services.Cloud.Poller do :timer.send_interval(@poll, :clusters) :timer.send_interval(@poll, :pgs) send self(), :repo - {:ok, %State{client: Console.new(Core.conf(:console_url), Core.conf(:console_token))}} + send self(), :project + client = Console.new(Core.conf(:console_url), Core.conf(:console_token)) + dedicated_client = Console.new(Core.conf(:console_url), Core.conf(:dedicated_console_token)) + {:ok, %State{client: client, dedicated_client: dedicated_client}} end def repository(), do: GenServer.call(__MODULE__, :repo) + def project(), do: GenServer.call(__MODULE__, :project) + def handle_call(:repo, _, %{repo: id} = state) when is_binary(id), do: {:reply, {:ok, id}, state} def handle_call(:repo, _, state), do: {:reply, {:error, "repo not pulled"}, state} + def handle_call(:project, _, %{project: id} = state) when is_binary(id), + do: {:reply, {:ok, id}, state} + def handle_call(:project, _, state), do: {:reply, {:error, "project not pulled"}, state} + def handle_info(:repo, %{client: client} = state) do case Console.repo(client, Core.conf(:mgmt_repo)) do {:ok, id} -> {:noreply, %{state | repo: id}} @@ -34,6 +43,15 @@ defmodule Core.Services.Cloud.Poller do end end + def handle_info(:project, %{dedicated_client: client} = state) do + case Console.project(client, Core.conf(:dedicated_project)) do + {:ok, id} -> {:noreply, %{state | project: id}} + err -> + Logger.warn "failed to find dedicated project: #{inspect(err)}" + {:noreply, state} + end + end + def handle_info(:clusters, %{client: client} = state) do with {:ok, clusters} <- Console.clusters(client) do Enum.each(clusters, &upsert_cluster/1) diff --git a/apps/core/lib/core/services/cloud/utils.ex b/apps/core/lib/core/services/cloud/utils.ex new file mode 100644 index 000000000..4e775e09b --- /dev/null +++ b/apps/core/lib/core/services/cloud/utils.ex @@ -0,0 +1,14 @@ +defmodule Core.Services.Cloud.Utils do + alias Core.Repo + alias Core.Clients.Console + alias Core.Schema.{ConsoleInstance} + + def mark_provisioned(inst) do + ConsoleInstance.changeset(inst, %{status: :provisioned}) + |> Repo.update() + end + + def console(), do: Console.new(Core.conf(:console_url), Core.conf(:console_token)) + + def dedicated_console(), do: Console.new(Core.conf(:console_url), Core.conf(:dedicated_console_token)) +end diff --git a/apps/core/lib/core/services/cloud/workflow.ex b/apps/core/lib/core/services/cloud/workflow.ex index 98e6cd63c..311871bbf 100644 --- a/apps/core/lib/core/services/cloud/workflow.ex +++ b/apps/core/lib/core/services/cloud/workflow.ex @@ -1,25 +1,24 @@ defmodule Core.Services.Cloud.Workflow do use Core.Services.Base - alias Core.Clients.Console - alias Core.Services.{Cloud, Users} - alias Core.Services.Cloud.{Poller, Configuration} - alias Core.Schema.{ConsoleInstance, PostgresCluster, User} alias Core.Repo + alias Core.Clients.Console + alias Core.Schema.{ConsoleInstance} + alias Core.Services.Cloud.Workflow.{Dedicated, Shared} require Logger - def sync(%ConsoleInstance{external_id: id} = instance) when is_binary(id) do - instance = Repo.preload(instance, [:cluster, :postgres]) - Console.update_service(console(), id, %{ - configuration: Configuration.build(instance) - }) - end - def sync(_), do: :ok + @type error :: {:error, term} + @type resp :: {:ok, ConsoleInstance.t} | error + + @callback sync(inst :: ConsoleInstance.t) :: :ok | {:ok, term} | error + @callback up(inst :: ConsoleInstance.t) :: resp + @callback down(inst :: ConsoleInstance.t) :: resp + @callback finalize(inst :: ConsoleInstance.t, :up | :down) :: resp def provision(%ConsoleInstance{} = instance) do instance = Repo.preload(instance, [:postgres, :cluster]) - Enum.reduce_while(0..10, instance, fn _, acc -> + Enum.reduce_while(0..20, instance, fn _, acc -> case up(acc) do {:ok, %ConsoleInstance{status: :provisioned} = inst} -> {:halt, inst} {:ok, inst} -> {:cont, inst} @@ -39,6 +38,7 @@ defmodule Core.Services.Cloud.Workflow do case down(acc) do {:ok, %ConsoleInstance{status: :pending} = inst} -> {:halt, inst} {:ok, %ConsoleInstance{status: :database_deleted} = inst} -> {:halt, inst} + {:ok, %ConsoleInstance{status: :stack_deleted} = inst} -> {:halt, inst} {:ok, inst} -> {:cont, inst} err -> :timer.sleep(:timer.seconds(10)) @@ -49,127 +49,15 @@ defmodule Core.Services.Cloud.Workflow do |> finalize(:down) end - defp up(%ConsoleInstance{status: :deployment_created, url: url} = inst) do - case {DNS.resolve(url), DNS.resolve(url, :cname)} do - {{:ok, [_ | _]}, _} -> mark_provisioned(inst) - {_, {:ok, [_ | _]}} -> mark_provisioned(inst) - {{:error, err}, _} -> {:error, "cannot resolve #{url}: #{inspect(err)}"} - end - end - - defp up(%ConsoleInstance{status: :pending, postgres: pg, configuration: conf} = inst) do - with {:ok, pid} <- connect(pg), - {:ok, _} <- Postgrex.query(pid, "CREATE DATABASE #{conf.database}", []), - {:ok, _} <- Postgrex.transaction(pid, fn conn -> - Postgrex.query!(conn, "CREATE USER #{conf.dbuser} WITH PASSWORD '#{conf.dbpassword}'", []) - Postgrex.query!(conn, "GRANT ALL ON DATABASE #{conf.database} TO #{conf.dbuser}", []) - end) do - ConsoleInstance.changeset(inst, %{ - instance_status: %{db: true}, - status: :database_created, - }) - |> Repo.update() - end - end - - defp up(%ConsoleInstance{instance_status: %{db: true}, name: name, cluster: cluster} = inst) do - with {:ok, id} <- Poller.repository(), - {:ok, svc_id} <- Console.create_service(console(), cluster.external_id, %{ - name: "console-cloud-#{name}", - namespace: "plrl-cloud-#{name}", - helm: %{ - url: "https://pluralsh.github.io/console", - chart: "console-rapid", - release: "console", - version: "x.x.x", - valuesFiles: ["console.yaml.liquid"] - }, - repository_id: id, - git: %{ref: "main", folder: "helm"}, - configuration: Configuration.build(inst), - }) do - ConsoleInstance.changeset(inst, %{ - external_id: svc_id, - instance_status: %{svc: true}, - status: :deployment_created - }) - |> Repo.update() - end - end - - defp down(%ConsoleInstance{instance_status: %{svc: false, db: true}, configuration: conf, postgres: pg} = inst) do - with {:ok, pid} <- connect(pg), - {:ok, _} <- Postgrex.query(pid, "DROP DATABASE IF EXISTS #{conf.database}", []), - {:ok, _} <- Postgrex.transaction(pid, fn conn -> - Postgrex.query!(conn, "DROP USER IF EXISTS #{conf.dbuser}", []) - end) do - ConsoleInstance.changeset(inst, %{ - instance_status: %{db: false}, - status: :database_deleted, - }) - |> Repo.update() - end - end - - defp down(%ConsoleInstance{instance_status: %{svc: true}} = inst) do - with {:ok, _} <- Console.delete_service(console(), inst.external_id) do - ConsoleInstance.changeset(inst, %{ - instance_status: %{svc: false, db: true}, - status: :deployment_deleted, - }) - |> Repo.update() - end - end - - defp down(inst), do: {:ok, inst} + def sync(%ConsoleInstance{type: :dedicated} = inst), do: Dedicated.sync(inst) + def sync(inst), do: Shared.sync(inst) - defp finalize(%ConsoleInstance{status: :provisioned} = inst, :up), do: {:ok, inst} + defp up(%ConsoleInstance{type: :dedicated} = inst), do: Dedicated.up(inst) + defp up(inst), do: Shared.up(inst) - defp finalize(%ConsoleInstance{status: :database_deleted, cluster: cluster, postgres: pg} = inst, :down) do - start_transaction() - |> add_operation(:inst, fn _ -> Repo.delete(inst) end) - |> add_operation(:cluster, fn _ -> Cloud.dec(cluster) end) - |> add_operation(:pg, fn _ -> Cloud.dec(pg) end) - |> add_operation(:sa, fn %{inst: %{name: name}} -> - case Users.get_user_by_email("#{name}-cloud-sa@srv.plural.sh") do - %User{} = u -> Repo.delete(u) - _ -> {:ok, nil} - end - end) - |> execute(extract: :inst) - end - - defp finalize(inst, _) do - Logger.warn "failed to finalize console instance: #{inst.id}" - {:ok, inst} - end - - defp connect(%PostgresCluster{} = roach) do - uri = URI.parse(roach.url) - user = userinfo(uri) - Postgrex.start_link( - database: uri.path && String.trim_leading(uri.path, "/"), - username: user[:username], - password: user[:password], - hostname: uri.host, - port: uri.port, - ssl: Core.conf(:bootstrap_ssl) - ) - end - - defp userinfo(%URI{userinfo: info}) when is_binary(info) do - case String.split(info, ":") do - [user, pwd] -> %{username: user, password: pwd} - [user] -> %{username: user} - _ -> %{} - end - end - defp userinfo(_), do: %{} - - defp mark_provisioned(inst) do - ConsoleInstance.changeset(inst, %{status: :provisioned}) - |> Repo.update() - end + defp down(%ConsoleInstance{type: :dedicated} = inst), do: Dedicated.down(inst) + defp down(inst), do: Shared.down(inst) - defp console(), do: Console.new(Core.conf(:console_url), Core.conf(:console_token)) + defp finalize(%ConsoleInstance{type: :dedicated} = inst, direction), do: Dedicated.finalize(inst, direction) + defp finalize(%ConsoleInstance{} = inst, direction), do: Shared.finalize(inst, direction) end diff --git a/apps/core/lib/core/services/cloud/workflow/dedicated.ex b/apps/core/lib/core/services/cloud/workflow/dedicated.ex new file mode 100644 index 000000000..c6736e864 --- /dev/null +++ b/apps/core/lib/core/services/cloud/workflow/dedicated.ex @@ -0,0 +1,79 @@ +defmodule Core.Services.Cloud.Workflow.Dedicated do + use Core.Services.Base + import Core.Services.Cloud.Utils + + alias Core.Clients.Console + alias Core.Services.{Users} + alias Core.Services.Cloud.{Poller, Configuration} + alias Core.Schema.{ConsoleInstance, User} + alias Core.Repo + + require Logger + + @behaviour Core.Services.Cloud.Workflow + + def sync(%ConsoleInstance{external_id: id} = inst) when is_binary(id) do + with {:ok, project_id} <- Poller.project(), + {:ok, repo_id} <- Poller.repository(), + {:ok, actor} <- Console.me(dedicated_console()), + attrs = %{actor_id: actor, project_id: project_id, repository_id: repo_id}, + do: Console.update_stack(dedicated_console(), id, Configuration.stack_attributes(inst, attrs)) + end + + def sync(_), do: :ok + + def up(%ConsoleInstance{status: :pending} = inst) do + with {:ok, id} <- Poller.project(), + {:ok, repo_id} <- Poller.repository(), + {:ok, actor} <- Console.me(dedicated_console()), + attrs = %{actor_id: actor, project_id: id, repository_id: repo_id}, + {:ok, stack_id} <- Console.create_stack(dedicated_console(), Configuration.stack_attributes(inst, attrs)) do + ConsoleInstance.changeset(inst, %{ + instance_status: %{stack: true}, + status: :stack_created, + external_id: stack_id + }) + |> Repo.update() + end + end + + def up(%ConsoleInstance{status: :stack_created, external_id: id} = inst) do + Enum.reduce_while(0..120, inst, fn _, inst -> + dedicated_console() + |> Console.stack(id) + |> case do + {:ok, %{"status" => "SUCCESSFUL"}} -> + {:halt, mark_provisioned(inst)} + status -> + Logger.info "stack not ready yet, sleeping: #{inspect(status)}" + :timer.sleep(:timer.minutes(1)) + {:cont, inst} + end + end) + end + + def up(inst), do: {:ok, inst} + + def down(%ConsoleInstance{instance_status: %{stack: true}, external_id: id} = inst) do + with {:ok, _} <- Console.delete_stack(dedicated_console(), id) do + ConsoleInstance.changeset(inst, %{status: :stack_deleted}) + |> Repo.update() + end + end + + def down(inst), do: {:ok, inst} + + def finalize(%ConsoleInstance{} = inst, :down) do + start_transaction() + |> add_operation(:inst, fn _ -> Repo.delete(inst) end) + |> add_operation(:sa, fn %{inst: %{name: name}} -> + case Users.get_user_by_email("#{name}-cloud-sa@srv.plural.sh") do + %User{} = u -> Repo.delete(u) + _ -> {:ok, nil} + end + end) + |> execute(extract: :inst) + end + + def finalize(inst, _), do: {:ok, inst} +end diff --git a/apps/core/lib/core/services/cloud/workflow/shared.ex b/apps/core/lib/core/services/cloud/workflow/shared.ex new file mode 100644 index 000000000..f9d804b26 --- /dev/null +++ b/apps/core/lib/core/services/cloud/workflow/shared.ex @@ -0,0 +1,136 @@ +defmodule Core.Services.Cloud.Workflow.Shared do + use Core.Services.Base + import Core.Services.Cloud.Utils + + alias Core.Clients.Console + alias Core.Services.{Cloud, Users} + alias Core.Services.Cloud.{Poller, Configuration} + alias Core.Schema.{ConsoleInstance, PostgresCluster, User} + alias Core.Repo + + @behaviour Core.Services.Cloud.Workflow + + def sync(%ConsoleInstance{external_id: id} = instance) when is_binary(id) do + instance = Repo.preload(instance, [:cluster, :postgres]) + Console.update_service(console(), id, %{ + configuration: Configuration.build(instance) + }) + end + + def sync(_), do: :ok + + def up(%ConsoleInstance{status: :deployment_created, url: url} = inst) do + :timer.sleep(:timer.seconds(5)) + case {DNS.resolve(url), DNS.resolve(url, :cname)} do + {{:ok, [_ | _]}, _} -> mark_provisioned(inst) + {_, {:ok, [_ | _]}} -> mark_provisioned(inst) + {{:error, err}, _} -> {:error, "cannot resolve #{url}: #{inspect(err)}"} + end + end + + def up(%ConsoleInstance{status: :pending, postgres: pg, configuration: conf} = inst) do + with {:ok, pid} <- connect(pg), + {:ok, _} <- Postgrex.query(pid, "CREATE DATABASE #{conf.database}", []), + {:ok, _} <- Postgrex.transaction(pid, fn conn -> + Postgrex.query!(conn, "CREATE USER #{conf.dbuser} WITH PASSWORD '#{conf.dbpassword}'", []) + Postgrex.query!(conn, "GRANT ALL ON DATABASE #{conf.database} TO #{conf.dbuser}", []) + end) do + ConsoleInstance.changeset(inst, %{ + instance_status: %{db: true}, + status: :database_created, + }) + |> Repo.update() + end + end + + def up(%ConsoleInstance{instance_status: %{db: true}, name: name, cluster: cluster} = inst) do + with {:ok, id} <- Poller.repository(), + {:ok, svc_id} <- Console.create_service(console(), cluster.external_id, %{ + name: "console-cloud-#{name}", + namespace: "plrl-cloud-#{name}", + helm: %{ + url: "https://pluralsh.github.io/console", + chart: "console-rapid", + release: "console", + version: "x.x.x", + valuesFiles: ["console.yaml.liquid"] + }, + repository_id: id, + git: %{ref: "main", folder: "helm"}, + configuration: Configuration.build(inst), + }) do + ConsoleInstance.changeset(inst, %{ + external_id: svc_id, + instance_status: %{svc: true}, + status: :deployment_created + }) + |> Repo.update() + end + end + + def up(inst), do: {:ok, inst} + + def down(%ConsoleInstance{instance_status: %{svc: false, db: true}, configuration: conf, postgres: pg} = inst) do + with {:ok, pid} <- connect(pg), + {:ok, _} <- Postgrex.query(pid, "DROP DATABASE IF EXISTS #{conf.database}", []), + {:ok, _} <- Postgrex.transaction(pid, fn conn -> + Postgrex.query!(conn, "DROP USER IF EXISTS #{conf.dbuser}", []) + end) do + ConsoleInstance.changeset(inst, %{ + instance_status: %{db: false}, + status: :database_deleted, + }) + |> Repo.update() + end + end + + def down(%ConsoleInstance{instance_status: %{svc: true}} = inst) do + with {:ok, _} <- Console.delete_service(console(), inst.external_id) do + ConsoleInstance.changeset(inst, %{ + instance_status: %{svc: false, db: true}, + status: :deployment_deleted, + }) + |> Repo.update() + end + end + + def down(inst), do: {:ok, inst} + + def finalize(%ConsoleInstance{status: :database_deleted, cluster: cluster, postgres: pg} = inst, :down) do + start_transaction() + |> add_operation(:inst, fn _ -> Repo.delete(inst) end) + |> add_operation(:cluster, fn _ -> Cloud.dec(cluster) end) + |> add_operation(:pg, fn _ -> Cloud.dec(pg) end) + |> add_operation(:sa, fn %{inst: %{name: name}} -> + case Users.get_user_by_email("#{name}-cloud-sa@srv.plural.sh") do + %User{} = u -> Repo.delete(u) + _ -> {:ok, nil} + end + end) + |> execute(extract: :inst) + end + + def finalize(inst, _), do: {:ok, inst} + + defp connect(%PostgresCluster{} = roach) do + uri = URI.parse(roach.url) + user = userinfo(uri) + Postgrex.start_link( + database: uri.path && String.trim_leading(uri.path, "/"), + username: user[:username], + password: user[:password], + hostname: uri.host, + port: uri.port, + ssl: Core.conf(:bootstrap_ssl) + ) + end + + defp userinfo(%URI{userinfo: info}) when is_binary(info) do + case String.split(info, ":") do + [user, pwd] -> %{username: user, password: pwd} + [user] -> %{username: user} + _ -> %{} + end + end + defp userinfo(_), do: %{} +end diff --git a/apps/core/lib/core/services/payments.ex b/apps/core/lib/core/services/payments.ex index 01e60e257..204723d81 100644 --- a/apps/core/lib/core/services/payments.ex +++ b/apps/core/lib/core/services/payments.ex @@ -262,6 +262,23 @@ defmodule Core.Services.Payments do end def limited?(_, _), do: false + @doc """ + Determines if an account is on an enterprise plan + """ + @spec enterprise?(Account.t | User.t) :: boolean + def enterprise?(%Account{} = account) do + case Core.Repo.preload(account, [subscription: :plan]) do + %Account{subscription: %PlatformSubscription{plan: %PlatformPlan{enterprise: ent}}} -> ent + _ -> false + end + end + + def enterprise?(%User{} = user) do + preload(user) + |> Map.get(:account) + |> enterprise?() + end + @doc """ Determine's if a user's account has access to the given feature. Returns `true` if enforcement is not enabled yet. """ diff --git a/apps/core/priv/repo/migrations/20240917232002_add_dedicated_console.exs b/apps/core/priv/repo/migrations/20240917232002_add_dedicated_console.exs new file mode 100644 index 000000000..a38bebe80 --- /dev/null +++ b/apps/core/priv/repo/migrations/20240917232002_add_dedicated_console.exs @@ -0,0 +1,9 @@ +defmodule Core.Repo.Migrations.AddDedicatedConsole do + use Ecto.Migration + + def change do + alter table(:console_instances) do + add :type, :integer, default: 0 + end + end +end diff --git a/apps/core/test/services/cloud/workflow_test.exs b/apps/core/test/services/cloud/workflow_test.exs index 4c006841c..766108830 100644 --- a/apps/core/test/services/cloud/workflow_test.exs +++ b/apps/core/test/services/cloud/workflow_test.exs @@ -1,6 +1,7 @@ defmodule Core.Services.Cloud.WorkflowTest do use Core.SchemaCase, async: true use Mimic + alias Core.Clients.Console alias Core.Services.{Cloud, Cloud.Workflow} describe "up and down" do @@ -47,5 +48,63 @@ defmodule Core.Services.Cloud.WorkflowTest do assert refetch(roach).count == 0 assert refetch(cluster).count == 0 end + + test "it can handle setup and teardown of a dedicated cloud instance" do + account = insert(:account) + enterprise_plan(account) + user = admin_user(account) + insert(:repository, name: "console") + + expect(HTTPoison, :post, fn _, _, _ -> + {:ok, %{status_code: 200, body: Jason.encode!(%{client_id: "123", client_secret: "secret"})}} + end) + + {:ok, instance} = Cloud.create_instance(%{ + name: "plrltest", + cloud: :aws, + region: "us-east-1", + size: :small, + type: :dedicated + }, user) + + expect(Core.Services.Cloud.Poller, :repository, fn -> {:ok, "repo-id"} end) + expect(Core.Services.Cloud.Poller, :project, fn -> {:ok, "proj-id"} end) + stack_id = Ecto.UUID.generate() + stack_q = Console.queries(:stack_q) + me_q = Console.queries(:me_q) + expect(Req, :post, 3, fn + _, [graphql: {^me_q, _}] -> {:ok, %Req.Response{status: 200, body: %{"data" => %{"me" => %{"id" => "me-id"}}}}} + _, [graphql: {_, %{attributes: attrs}}] -> + send self(), {:attributes, attrs} + {:ok, %Req.Response{status: 200, body: %{"data" => %{"createStack" => %{"id" => stack_id}}}}} + _, [graphql: {^stack_q, %{id: ^stack_id}}] -> + {:ok, %Req.Response{ + status: 200, + body: %{"data" => %{"infrastructureStack" => %{"id" => stack_id, "status" => "SUCCESSFUL"}}} + }} + end) + + {:ok, %{external_id: stack_id} = instance} = Workflow.provision(instance) + + assert instance.status == :provisioned + assert instance.instance_status.stack + + assert_receive {:attributes, attrs} + + assert attrs.project_id == "proj-id" + assert attrs.actor_id == "me-id" + assert attrs.repository_id == "repo-id" + assert attrs.git.ref == "main" + assert attrs.git.folder == "terraform/modules/dedicated/aws" + + del_q = Console.queries(:stack_delete) + expect(Req, :post, fn _, [graphql: {^del_q, %{id: ^stack_id}}] -> + {:ok, %Req.Response{status: 200, body: %{"data" => %{"deleteStack" => %{"id" => stack_id}}}}} + end) + + {:ok, instance} = Workflow.deprovision(instance) + + refute refetch(instance) + end end end diff --git a/apps/core/test/services/cloud_test.exs b/apps/core/test/services/cloud_test.exs index 8393264bc..6c9fbfffb 100644 --- a/apps/core/test/services/cloud_test.exs +++ b/apps/core/test/services/cloud_test.exs @@ -39,6 +39,57 @@ defmodule Core.Services.CloudTest do assert_receive {:event, %PubSub.ConsoleInstanceCreated{item: ^instance}} end + test "enterprise accounts can create dedicated console instances" do + account = insert(:account) + enterprise_plan(account) + user = admin_user(account) + insert(:repository, name: "console") + + expect(HTTPoison, :post, fn _, _, _ -> + {:ok, %{status_code: 200, body: Jason.encode!(%{client_id: "123", client_secret: "secret"})}} + end) + + {:ok, instance} = Cloud.create_instance(%{ + type: :dedicated, + name: "plrltest", + cloud: :aws, + region: "us-east-1", + size: :small + }, user) + + assert instance.name == "plrltest" + assert instance.cloud == :aws + assert instance.region == "us-east-1" + assert instance.size == :small + refute instance.postgres_id + refute instance.cluster_id + + sa = Core.Services.Users.get_user_by_email("plrltest-cloud-sa@srv.plural.sh") + %{impersonation_policy: %{bindings: [binding]}} = Core.Repo.preload(sa, [impersonation_policy: :bindings]) + assert binding.user_id == user.id + + assert_receive {:event, %PubSub.ConsoleInstanceCreated{item: ^instance}} + end + + test "nonenterprise plans cannot create a dedicated cloud console instance" do + account = insert(:account) + enable_features(account, [:cd]) + user = admin_user(account) + insert(:cloud_cluster) + insert(:postgres_cluster) + insert(:repository, name: "console") + + {:error, err} = Cloud.create_instance(%{ + type: :dedicated, + name: "plrltest", + cloud: :aws, + region: "us-east-1", + size: :small + }, user) + + assert err =~ "enterprise" + end + test "unpaid users cannot create instances" do account = insert(:account) user = admin_user(account) diff --git a/apps/core/test/support/factory.ex b/apps/core/test/support/factory.ex index ce872362e..1ac413155 100644 --- a/apps/core/test/support/factory.ex +++ b/apps/core/test/support/factory.ex @@ -687,6 +687,10 @@ defmodule Core.Factory do insert(:platform_subscription, account: account, plan: build(:platform_plan, features: features)) end + def enterprise_plan(account) do + insert(:platform_subscription, account: account, plan: build(:platform_plan, enterprise: true)) + end + def bound_service_account(user, args \\ []) do sa = insert(:user, args ++ [service_account: true, account: user.account]) insert(:impersonation_policy_binding, diff --git a/apps/graphql/lib/graphql/schema/cloud.ex b/apps/graphql/lib/graphql/schema/cloud.ex index 6f49f4c21..9ae2661c1 100644 --- a/apps/graphql/lib/graphql/schema/cloud.ex +++ b/apps/graphql/lib/graphql/schema/cloud.ex @@ -6,8 +6,10 @@ defmodule GraphQl.Schema.Cloud do ecto_enum :cloud_provider, CloudCluster.Cloud ecto_enum :console_instance_status, ConsoleInstance.Status ecto_enum :console_size, ConsoleInstance.Size + ecto_enum :console_instance_type, ConsoleInstance.Type input_object :console_instance_attributes do + field :type, non_null(:console_instance_type), description: "the type of console instance" field :name, non_null(:string), description: "the name of this instance (globally unique)" field :size, non_null(:console_size), description: "a heuristic size of this instance" field :cloud, non_null(:cloud_provider), description: "the cloud provider to deploy to" @@ -25,6 +27,7 @@ defmodule GraphQl.Schema.Cloud do object :console_instance do field :id, non_null(:id) + field :type, non_null(:console_instance_type), description: "whether this is a shared or dedicated console" field :name, non_null(:string), description: "the name of this instance (globally unique)" field :subdomain, non_null(:string), description: "the subdomain this instance lives under" field :url, non_null(:string), description: "full console url of this instance" diff --git a/apps/graphql/test/mutations/cloud_mutations_test.exs b/apps/graphql/test/mutations/cloud_mutations_test.exs index ee718507a..2000861e0 100644 --- a/apps/graphql/test/mutations/cloud_mutations_test.exs +++ b/apps/graphql/test/mutations/cloud_mutations_test.exs @@ -27,6 +27,7 @@ defmodule GraphQl.CloudMutationsTest do } } """, %{"attrs" => %{ + "type" => "SHARED", "name" => "plrltest", "cloud" => "AWS", "size" => "SMALL", diff --git a/config/config.exs b/config/config.exs index 2de4589a6..d8dc3b7a2 100644 --- a/config/config.exs +++ b/config/config.exs @@ -143,6 +143,9 @@ config :core, trial_plan: "Pro Trial", console_token: "bogus", console_url: "https://console.example.com", + dedicated_console_token: "bogus", + mgmt_cluster: "dummy", + dedicated_project: "dedicated", sysbox_emails: [], mgmt_repo: "https://github.com/pluralsh/plural.git", cockroach_parameters: [], diff --git a/rel/config/config.exs b/rel/config/config.exs index 591ce374a..65aaa07c1 100644 --- a/rel/config/config.exs +++ b/rel/config/config.exs @@ -92,6 +92,9 @@ config :core, stripe_webhook_secret: get_env("STRIPE_WEBHOOK_SECRET"), github_demo_token: get_env("GITHUB_DEMO_TOKEN"), console_token: get_env("CONSOLE_SA_TOKEN"), + dedicated_console_token: get_env("CONSOLE_DEDICATED_SA_TOKEN"), + dedicated_project: get_env("CONSOLE_DEDICATED_PROJECT"), + mgmt_cluster: get_env("MGMT_CLUSTER_ID"), console_url: get_env("CONSOLE_URL"), mgmt_repo: get_env("CONSOLE_MGMT_REPO"), stack_id: get_env("CONSOLE_CLOUD_STACK_ID"), diff --git a/schema/schema.graphql b/schema/schema.graphql index d5239d105..06a0a8f2b 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -564,6 +564,8 @@ enum ConsoleInstanceStatus { PROVISIONED DEPLOYMENT_DELETED DATABASE_DELETED + STACK_CREATED + STACK_DELETED } enum ConsoleSize { @@ -572,7 +574,15 @@ enum ConsoleSize { LARGE } +enum ConsoleInstanceType { + SHARED + DEDICATED +} + input ConsoleInstanceAttributes { + "the type of console instance" + type: ConsoleInstanceType! + "the name of this instance (globally unique)" name: String! @@ -598,6 +608,9 @@ input ConsoleConfigurationUpdateAttributes { type ConsoleInstance { id: ID! + "whether this is a shared or dedicated console" + type: ConsoleInstanceType! + "the name of this instance (globally unique)" name: String! diff --git a/www/src/components/create-cluster/steps/ConfigureCloudInstanceStep.tsx b/www/src/components/create-cluster/steps/ConfigureCloudInstanceStep.tsx index 33e04ba77..285dcc48d 100644 --- a/www/src/components/create-cluster/steps/ConfigureCloudInstanceStep.tsx +++ b/www/src/components/create-cluster/steps/ConfigureCloudInstanceStep.tsx @@ -9,6 +9,7 @@ import { } from '@pluralsh/design-system' import { CloudProvider, + ConsoleInstanceType, ConsoleSize, useCreateConsoleInstanceMutation, } from 'generated/graphql' @@ -48,6 +49,7 @@ export function ConfigureCloudInstanceStep() { size, cloud, region, + type: ConsoleInstanceType.Shared, }, }, onCompleted: (data) => { diff --git a/www/src/generated/graphql.ts b/www/src/generated/graphql.ts index 9f2452b51..449c62cb7 100644 --- a/www/src/generated/graphql.ts +++ b/www/src/generated/graphql.ts @@ -584,6 +584,8 @@ export type ConsoleInstance = { status: ConsoleInstanceStatus; /** the subdomain this instance lives under */ subdomain: Scalars['String']['output']; + /** whether this is a shared or dedicated console */ + type: ConsoleInstanceType; updatedAt?: Maybe; /** full console url of this instance */ url: Scalars['String']['output']; @@ -598,6 +600,8 @@ export type ConsoleInstanceAttributes = { region: Scalars['String']['input']; /** a heuristic size of this instance */ size: ConsoleSize; + /** the type of console instance */ + type: ConsoleInstanceType; }; export type ConsoleInstanceConnection = { @@ -618,7 +622,14 @@ export enum ConsoleInstanceStatus { DeploymentCreated = 'DEPLOYMENT_CREATED', DeploymentDeleted = 'DEPLOYMENT_DELETED', Pending = 'PENDING', - Provisioned = 'PROVISIONED' + Provisioned = 'PROVISIONED', + StackCreated = 'STACK_CREATED', + StackDeleted = 'STACK_DELETED' +} + +export enum ConsoleInstanceType { + Dedicated = 'DEDICATED', + Shared = 'SHARED' } export type ConsoleInstanceUpdateAttributes = {