Skip to content

Commit

Permalink
Implement dedicated cloud console types
Browse files Browse the repository at this point in the history
This provisions a plural console in its own cluster managed entirely by a plural stack.  Since it's cost-inefficient,
only allow enterprise accounts to use these.
  • Loading branch information
michaeljguarino committed Sep 18, 2024
1 parent 6f1ef89 commit 2d39c8a
Show file tree
Hide file tree
Showing 19 changed files with 318 additions and 10 deletions.
63 changes: 63 additions & 0 deletions apps/core/lib/core/clients/console.ex
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
defmodule Core.Clients.Console do
require Logger

@me_q """
query {
me { id }
}
"""

@clusters_q """
query {
clusters(first: 100) {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -74,6 +111,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")
Expand All @@ -90,6 +137,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
Expand Down
8 changes: 8 additions & 0 deletions apps/core/lib/core/policies/cloud.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 16 additions & 4 deletions apps/core/lib/core/schema/console_instance.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,24 @@ 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,
database_created: 1,
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
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
10 changes: 8 additions & 2 deletions apps/core/lib/core/services/cloud.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
21 changes: 21 additions & 0 deletions apps/core/lib/core/services/cloud/configuration.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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: true,
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}
Expand Down
22 changes: 20 additions & 2 deletions apps/core/lib/core/services/cloud/poller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand All @@ -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}}
Expand All @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions apps/core/lib/core/services/cloud/workflow.ex
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,14 @@ defmodule Core.Services.Cloud.Workflow do

require Logger

def sync(%ConsoleInstance{type: :dedicated, 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(%ConsoleInstance{external_id: id} = instance) when is_binary(id) do
instance = Repo.preload(instance, [:cluster, :postgres])
Console.update_service(console(), id, %{
Expand Down Expand Up @@ -39,6 +47,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))
Expand All @@ -49,6 +58,37 @@ defmodule Core.Services.Cloud.Workflow do
|> finalize(:down)
end

defp up(%ConsoleInstance{status: :pending, type: :dedicated} = 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

defp up(%ConsoleInstance{type: :dedicated, 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"}} ->
ConsoleInstance.changeset(inst, %{status: :provisioned})
|> Repo.update()
status ->
Logger.info "stack not ready yet, sleeping: #{inspect(status)}"
:timer.sleep(:timer.minutes(1))
{:ok, inst}
end
end)
end

defp up(%ConsoleInstance{status: :deployment_created, url: url} = inst) do
case {DNS.resolve(url), DNS.resolve(url, :cname)} do
{{:ok, [_ | _]}, _} -> mark_provisioned(inst)
Expand Down Expand Up @@ -97,6 +137,15 @@ defmodule Core.Services.Cloud.Workflow do
end
end

def up(inst), do: {:ok, inst}

defp down(%ConsoleInstance{type: :dedicated, 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

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}", []),
Expand Down Expand Up @@ -139,6 +188,18 @@ defmodule Core.Services.Cloud.Workflow do
|> execute(extract: :inst)
end

defp finalize(%ConsoleInstance{type: :dedicated} = 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

defp finalize(inst, _) do
Logger.warn "failed to finalize console instance: #{inst.id}"
{:ok, inst}
Expand Down Expand Up @@ -172,4 +233,6 @@ defmodule Core.Services.Cloud.Workflow do
end

defp console(), do: Console.new(Core.conf(:console_url), Core.conf(:console_token))

defp dedicated_console(), do: Console.new(Core.conf(:console_url), Core.conf(:dedicated_console_token))
end
Loading

0 comments on commit 2d39c8a

Please sign in to comment.