diff --git a/assets/src/generated/graphql.ts b/assets/src/generated/graphql.ts index 36c267fcf8..07a77b37f2 100644 --- a/assets/src/generated/graphql.ts +++ b/assets/src/generated/graphql.ts @@ -1958,6 +1958,8 @@ export type HttpIngressRule = { export type InfrastructureStack = { __typename?: 'InfrastructureStack'; + /** the actor of this stack (defaults to root console user) */ + actor?: Maybe; /** whether to require approval */ approval?: Maybe; /** why this run was cancelled */ @@ -1966,6 +1968,8 @@ export type InfrastructureStack = { cluster?: Maybe; /** version/image config for the tool you're using */ configuration: StackConfiguration; + /** the run that physically destroys the stack */ + deleteRun?: Maybe; /** whether this stack was previously deleted and is pending cleanup */ deletedAt?: Maybe; /** environment variables for this stack */ @@ -3145,6 +3149,15 @@ export type PluralContext = { domains?: Maybe>>; }; +/** temporary credentials for the user attached to this stack */ +export type PluralCreds = { + __typename?: 'PluralCreds'; + /** authentication token to use for gql requests */ + token?: Maybe; + /** the api url of this instance */ + url?: Maybe; +}; + export type PluralGitRepository = { __typename?: 'PluralGitRepository'; events?: Maybe>>; @@ -6583,6 +6596,8 @@ export type Stack = { }; export type StackAttributes = { + /** user id to use for default Plural authentication in this stack */ + actorId?: InputMaybe; /** whether to require approval */ approval?: InputMaybe; /** The cluster on which the terraform will be applied */ @@ -6691,6 +6706,8 @@ export type StackOutputAttributes = { export type StackRun = { __typename?: 'StackRun'; + /** the actor of this run (defaults to root console user) */ + actor?: Maybe; /** whether to require approval */ approval?: Maybe; /** when this run was approved */ @@ -6711,6 +6728,8 @@ export type StackRun = { git: GitRef; id: Scalars['ID']['output']; insertedAt?: Maybe; + /** the kubernetes job for this run (useful for debugging if issues arise) */ + job?: Maybe; /** optional k8s job configuration for the job that will apply this stack */ jobSpec?: Maybe; /** whether you want Plural to manage the state of this stack */ @@ -6719,12 +6738,15 @@ export type StackRun = { message?: Maybe; /** the most recent output for this stack */ output?: Maybe>>; + /** temporary plural creds usable for terraform authentication */ + pluralCreds?: Maybe; /** the git repository you're sourcing IaC from */ repository?: Maybe; /** the stack attached to this run */ stack?: Maybe; /** the most recent state of this stack */ state?: Maybe; + stateUrls?: Maybe; /** The status of this run */ status: StackStatus; /** The steps to perform when running this stack */ @@ -6743,6 +6765,8 @@ export type StackRunAttributes = { cancellationReason?: InputMaybe; /** Any errors detected when trying to run this stack */ errors?: InputMaybe>>; + /** the reference to the k8s job running this stack */ + jobRef?: InputMaybe; /** Output generated by this run */ output?: InputMaybe>>; /** The state from this runs plan or apply */ @@ -6840,6 +6864,12 @@ export type StageServiceAttributes = { serviceId?: InputMaybe; }; +/** grab-bag of state configuration urls for supported tools */ +export type StateUrls = { + __typename?: 'StateUrls'; + terraform?: Maybe; +}; + export type StatefulSet = { __typename?: 'StatefulSet'; events?: Maybe>>; @@ -6980,6 +7010,17 @@ export type TerminatedState = { startedAt?: Maybe; }; +/** Urls for configuring terraform HTTP remote state */ +export type TerraformStateUrls = { + __typename?: 'TerraformStateUrls'; + /** GET and POST urls for uploadnig state */ + address?: Maybe; + /** POST url to lock state */ + lock?: Maybe; + /** POST url to unlock state */ + unlock?: Maybe; +}; + export enum Tool { Helm = 'HELM', Terraform = 'TERRAFORM' diff --git a/lib/console/deployments/pubsub/recurse.ex b/lib/console/deployments/pubsub/recurse.ex index 01da6be8dc..4cf655789d 100644 --- a/lib/console/deployments/pubsub/recurse.ex +++ b/lib/console/deployments/pubsub/recurse.ex @@ -114,7 +114,7 @@ end defimpl Console.PubSub.Recurse, for: Console.PubSub.StackDeleted do alias Console.Deployments.Stacks - def process(%{item: stack}), do: Stacks.create_run(stack, stack.sha) + def process(%{item: stack}), do: Stacks.create_run(stack, stack.sha, %{message: "destroying stack #{stack.name}"}) end defimpl Console.PubSub.Recurse, for: Console.PubSub.StackRunUpdated do diff --git a/lib/console/deployments/stacks.ex b/lib/console/deployments/stacks.ex index 906b1f2e49..d78f8fc571 100644 --- a/lib/console/deployments/stacks.ex +++ b/lib/console/deployments/stacks.ex @@ -3,8 +3,10 @@ defmodule Console.Deployments.Stacks do import Console.Deployments.Policies import Console.Deployments.Stacks.Commands alias Console.PubSub + alias Console.Deployments.{Services, Clusters} alias Console.Deployments.Git.Discovery alias Console.Deployments.Pr.Dispatcher + alias Kazan.Apis.Batch.V1, as: BatchV1 alias Console.Schema.{ User, Cluster, @@ -46,6 +48,40 @@ defmodule Console.Deployments.Stacks do |> allow(cluster, :read) end + @doc """ + If a user has write access to the run, fetches temporary plural creds for use in the given stack run. + + To be set in the environment by the stack harness process + """ + @spec plural_creds(StackRun.t, User.t | Cluster.t) :: {:ok, %{token: binary, url: binary}} + def plural_creds(%StackRun{} = run, actor) do + with {:ok, run} <- allow(run, actor, :state), + do: plural_creds(run) + end + + def plural_creds(%StackRun{} = run) do + case Repo.preload(run, [:actor]) do + %{actor: %User{} = actor} -> + with {:ok, token, _} <- Console.Guardian.encode_and_sign(actor, %{}, ttl: {1, :day}), + do: {:ok, %{token: token, url: Console.graphql_endpoint()}} + _ -> {:ok, nil} + end + end + + @doc """ + Generates remote state urls for supported tools (only terraform for now) + """ + @spec state_urls(StackRun.t) :: %{terraform: map} + def state_urls(%StackRun{stack_id: id}) do + %{ + terraform: %{ + address: Services.api_url("v1/states/terraform/#{id}"), + lock: Services.api_url("v1/states/terraform/#{id}/lock"), + unlock: Services.api_url("v1/states/terraform/#{id}/unlock") + } + } + end + @doc """ Creates a new stack if a user can write to its cluster """ @@ -405,6 +441,17 @@ defmodule Console.Deployments.Stacks do def kick(id, user) when is_binary(id), do: kick(get_stack!(id), user) + @doc """ + Fetches the k8s job resource from the stack runs configured cluster via KAS + """ + @spec run_job(StackRun.t) :: {:ok, BatchV1.Job.t} | error + def run_job(%StackRun{job_ref: %{namespace: ns, name: name}} = run) do + %{cluster: cluster} = Repo.preload(run, [:cluster]) + BatchV1.read_namespaced_job!(ns, name) + |> Kazan.run(server: Clusters.control_plane(cluster)) + end + def run_job(_), do: {:ok, nil} + defp notify({:ok, %Stack{} = stack}, :create, actor), do: handle_notify(PubSub.StackCreated, stack, actor: actor) defp notify({:ok, %Stack{} = stack}, :update, actor), diff --git a/lib/console/graphql/deployments/stack.ex b/lib/console/graphql/deployments/stack.ex index 54611a1e5e..cca3a2e66c 100644 --- a/lib/console/graphql/deployments/stack.ex +++ b/lib/console/graphql/deployments/stack.ex @@ -1,6 +1,7 @@ defmodule Console.GraphQl.Deployments.Stack do use Console.GraphQl.Schema.Base alias Console.Schema.{Stack, RunStep} + alias Console.Deployments.Stacks alias Console.GraphQl.Resolvers.{Deployments, User} ecto_enum :stack_status, Stack.Status @@ -19,6 +20,7 @@ defmodule Console.GraphQl.Deployments.Stack do field :approval, :boolean, description: "whether to require approval" field :manage_state, :boolean, description: "whether you want Plural to manage your terraform state for this stack" field :workdir, :string, description: "the subdirectory you want to run the stack's commands w/in" + field :actor_id, :id, description: "user id to use for default Plural authentication in this stack" field :read_bindings, list_of(:policy_binding_attributes) field :write_bindings, list_of(:policy_binding_attributes) @@ -43,6 +45,7 @@ defmodule Console.GraphQl.Deployments.Stack do input_object :stack_run_attributes do field :status, non_null(:stack_status), description: "The status of this run" + field :job_ref, :namespaced_name, description: "the reference to the k8s job running this stack" field :state, :stack_state_attributes, description: "The state from this runs plan or apply" field :output, list_of(:stack_output_attributes), description: "Output generated by this run" field :errors, list_of(:service_error_attributes), description: "Any errors detected when trying to run this stack" @@ -115,18 +118,39 @@ defmodule Console.GraphQl.Deployments.Stack do field :observable_metrics, list_of(:observable_metric), resolve: dataloader(Deployments), description: "a list of metrics to poll to determine if a stack run should be cancelled" + field :delete_run, :stack_run, resolve: dataloader(Deployments), description: "the run that physically destroys the stack" field :output, list_of(:stack_output), resolve: dataloader(Deployments), description: "the most recent output for this stack" field :state, :stack_state, resolve: dataloader(Deployments), description: "the most recent state of this stack" field :cluster, :cluster, resolve: dataloader(Deployments), description: "the cluster this stack runs on" field :repository, :git_repository, resolve: dataloader(Deployments), description: "the git repository you're sourcing IaC from" + field :actor, :user, resolve: dataloader(User), description: "the actor of this stack (defaults to root console user)" + field :read_bindings, list_of(:policy_binding), resolve: dataloader(Deployments) field :write_bindings, list_of(:policy_binding), resolve: dataloader(Deployments) timestamps() end + @desc "grab-bag of state configuration urls for supported tools" + object :state_urls do + field :terraform, :terraform_state_urls + end + + @desc "Urls for configuring terraform HTTP remote state" + object :terraform_state_urls do + field :address, :string, description: "GET and POST urls for uploadnig state" + field :lock, :string, description: "POST url to lock state" + field :unlock, :string, description: "POST url to unlock state" + end + + @desc "temporary credentials for the user attached to this stack" + object :plural_creds do + field :token, :string, description: "authentication token to use for gql requests" + field :url, :string, description: "the api url of this instance" + end + object :stack_configuration do field :image, :string, description: "optional custom image you might want to use" field :version, non_null(:string), description: "the semver of the tool you wish to use" @@ -153,9 +177,22 @@ defmodule Console.GraphQl.Deployments.Stack do field :workdir, :string, description: "the subdirectory you want to run the stack's commands w/in" field :manage_state, :boolean, description: "whether you want Plural to manage the state of this stack" + field :state_urls, :state_urls, resolve: fn + run, _, _ -> {:ok, Stacks.state_urls(run)} + end + + @desc "the kubernetes job for this run (useful for debugging if issues arise)" + field :job, :job do + resolve fn run, _, _ -> Console.Deployments.Stacks.run_job(run) end + middleware ErrorHandler + end + + field :plural_creds, :plural_creds, resolve: &Deployments.plural_creds/3, description: "temporary plural creds usable for terraform authentication" + field :tarball, non_null(:string), resolve: &Deployments.stack_tarball/3, description: "https url to fetch the latest tarball of stack IaC" field :approver, :user, resolve: dataloader(User), description: "the approver of this job" + field :actor, :user, resolve: dataloader(User), description: "the actor of this run (defaults to root console user)" field :steps, list_of(:run_step), resolve: dataloader(Deployments), description: "The steps to perform when running this stack" diff --git a/lib/console/graphql/resolvers/deployments/stack.ex b/lib/console/graphql/resolvers/deployments/stack.ex index 079abf0442..18bb7646bb 100644 --- a/lib/console/graphql/resolvers/deployments/stack.ex +++ b/lib/console/graphql/resolvers/deployments/stack.ex @@ -86,4 +86,6 @@ defmodule Console.GraphQl.Resolvers.Deployments.Stack do do: Stacks.add_run_logs(attrs, id, cluster) def stack_tarball(%{id: id}, _, _), do: {:ok, Services.api_url("v1/git/stacks/tarballs?id=#{id}")} + + def plural_creds(run, _, ctx), do: Stacks.plural_creds(run, actor(ctx)) end diff --git a/lib/console/schema/stack.ex b/lib/console/schema/stack.ex index 7d68f036f2..e03946ddef 100644 --- a/lib/console/schema/stack.ex +++ b/lib/console/schema/stack.ex @@ -80,6 +80,7 @@ defmodule Console.Schema.Stack do belongs_to :cluster, Cluster belongs_to :delete_run, StackRun belongs_to :connection, ScmConnection + belongs_to :actor, User has_one :state, StackState, on_replace: :update, @@ -129,7 +130,7 @@ defmodule Console.Schema.Stack do def stream(query \\ __MODULE__), do: ordered(query, asc: :id) - @valid ~w(name type paused workdir manage_state status approval connection_id repository_id cluster_id)a + @valid ~w(name type paused actor_id workdir manage_state status approval connection_id repository_id cluster_id)a def changeset(model, attrs \\ %{}) do model @@ -145,6 +146,7 @@ defmodule Console.Schema.Stack do |> foreign_key_constraint(:repository_id) |> foreign_key_constraint(:cluster_id) |> foreign_key_constraint(:connection_id) + |> foreign_key_constraint(:actor_id) |> unique_constraint(:name) |> put_new_change(:write_policy_id, &Ecto.UUID.generate/0) |> put_new_change(:read_policy_id, &Ecto.UUID.generate/0) diff --git a/lib/console/schema/stack_run.ex b/lib/console/schema/stack_run.ex index 69478953d9..6092db30de 100644 --- a/lib/console/schema/stack_run.ex +++ b/lib/console/schema/stack_run.ex @@ -32,6 +32,11 @@ defmodule Console.Schema.StackRun do embeds_one :job_spec, JobSpec, on_replace: :update embeds_one :configuration, Stack.Configuration, on_replace: :update + embeds_one :job_ref, JobRef, on_replace: :update do + field :name, :string + field :namespace, :string + end + has_one :state, StackState, on_replace: :update, foreign_key: :run_id @@ -59,6 +64,7 @@ defmodule Console.Schema.StackRun do belongs_to :stack, Stack belongs_to :approver, User belongs_to :pull_request, PullRequest + belongs_to :actor, User timestamps() end @@ -103,7 +109,7 @@ defmodule Console.Schema.StackRun do from(r in query, order_by: ^order) end - @valid ~w(type status workdir manage_state message approval dry_run repository_id pull_request_id cluster_id stack_id)a + @valid ~w(type status workdir actor_id manage_state message approval dry_run repository_id pull_request_id cluster_id stack_id)a def changeset(model, attrs \\ %{}) do model @@ -111,6 +117,7 @@ defmodule Console.Schema.StackRun do |> cast_embed(:git) |> cast_embed(:job_spec) |> cast_embed(:configuration) + |> cast_embed(:job_ref, with: &job_ref_changeset/2) |> cast_assoc(:state) |> cast_assoc(:environment) |> cast_assoc(:steps) @@ -119,6 +126,7 @@ defmodule Console.Schema.StackRun do |> foreign_key_constraint(:repository_id) |> foreign_key_constraint(:cluster_id) |> foreign_key_constraint(:stack_id) + |> foreign_key_constraint(:actor_id) |> validate_required(~w(type status)a) end @@ -145,4 +153,10 @@ defmodule Console.Schema.StackRun do |> cast(attrs, @approve) |> validate_required(@approve) end + + defp job_ref_changeset(model, attrs) do + model + |> cast(attrs, ~w(name namespace)a) + |> validate_required(~w(name namespace)a) + end end diff --git a/lib/console_web/plugs/token.ex b/lib/console_web/plugs/token.ex index b52a77ebf6..cd8ba3af13 100644 --- a/lib/console_web/plugs/token.ex +++ b/lib/console_web/plugs/token.ex @@ -35,6 +35,15 @@ defmodule ConsoleWeb.Plugs.Token do end end + def fetch_resource(:bearer, token, {conn, opts}) do + key = Guardian.Plug.Pipeline.fetch_key(conn, opts) + with {:ok, claims} <- Console.Guardian.decode_and_verify(token) do + conn + |> Guardian.Plug.put_current_token(token, key: key) + |> Guardian.Plug.put_current_claims(claims, key: key) + end + end + def broadcast(token) do Console.PubSub.Broadcaster.notify(%Console.PubSub.AccessTokenUsage{ item: token, @@ -49,6 +58,7 @@ defmodule ConsoleWeb.Plugs.Token do case get_req_header(conn, "authorization") do ["Token deploy-" <> _ = token | _] -> match_and_extract(~r/^Token\:?\s+(.*)$/, String.trim(token), :deploy) ["Token console-" <> _ = token | _] -> match_and_extract(~r/^Token\:?\s+(.*)$/, String.trim(token), :user) + ["Token " <> _ = token | _] -> match_and_extract(~r/^Token\:?\s+(.*)$/, String.trim(token), :bearer) _ -> {:error, :unauthorized} end end diff --git a/priv/repo/migrations/20240527223318_add_stack_actor.exs b/priv/repo/migrations/20240527223318_add_stack_actor.exs new file mode 100644 index 0000000000..49ead927b9 --- /dev/null +++ b/priv/repo/migrations/20240527223318_add_stack_actor.exs @@ -0,0 +1,14 @@ +defmodule Console.Repo.Migrations.AddStackActor do + use Ecto.Migration + + def change do + alter table(:stacks) do + add :actor_id, references(:watchman_users, type: :uuid, on_delete: :nilify_all) + end + + alter table(:stack_runs) do + add :actor_id, references(:watchman_users, type: :uuid, on_delete: :nilify_all) + add :job_ref, :map + end + end +end diff --git a/schema/schema.graphql b/schema/schema.graphql index 37cf77a4cf..af204e4fb6 100644 --- a/schema/schema.graphql +++ b/schema/schema.graphql @@ -990,6 +990,9 @@ input StackAttributes { "the subdirectory you want to run the stack's commands w\/in" workdir: String + "user id to use for default Plural authentication in this stack" + actorId: ID + readBindings: [PolicyBindingAttributes] writeBindings: [PolicyBindingAttributes] @@ -1030,6 +1033,9 @@ input StackRunAttributes { "The status of this run" status: StackStatus! + "the reference to the k8s job running this stack" + jobRef: NamespacedName + "The state from this runs plan or apply" state: StackStateAttributes @@ -1139,6 +1145,9 @@ type InfrastructureStack { "a list of metrics to poll to determine if a stack run should be cancelled" observableMetrics: [ObservableMetric] + "the run that physically destroys the stack" + deleteRun: StackRun + "the most recent output for this stack" output: [StackOutput] @@ -1151,6 +1160,9 @@ type InfrastructureStack { "the git repository you're sourcing IaC from" repository: GitRepository + "the actor of this stack (defaults to root console user)" + actor: User + readBindings: [PolicyBinding] writeBindings: [PolicyBinding] @@ -1160,6 +1172,32 @@ type InfrastructureStack { updatedAt: DateTime } +"grab-bag of state configuration urls for supported tools" +type StateUrls { + terraform: TerraformStateUrls +} + +"Urls for configuring terraform HTTP remote state" +type TerraformStateUrls { + "GET and POST urls for uploadnig state" + address: String + + "POST url to lock state" + lock: String + + "POST url to unlock state" + unlock: String +} + +"temporary credentials for the user attached to this stack" +type PluralCreds { + "authentication token to use for gql requests" + token: String + + "the api url of this instance" + url: String +} + type StackConfiguration { "optional custom image you might want to use" image: String @@ -1218,12 +1256,23 @@ type StackRun { "whether you want Plural to manage the state of this stack" manageState: Boolean + stateUrls: StateUrls + + "the kubernetes job for this run (useful for debugging if issues arise)" + job: Job + + "temporary plural creds usable for terraform authentication" + pluralCreds: PluralCreds + "https url to fetch the latest tarball of stack IaC" tarball: String! "the approver of this job" approver: User + "the actor of this run (defaults to root console user)" + actor: User + "The steps to perform when running this stack" steps: [RunStep] diff --git a/test/console/deployments/pubsub/recurse_test.exs b/test/console/deployments/pubsub/recurse_test.exs index 0094031e5a..dd5c22ce7c 100644 --- a/test/console/deployments/pubsub/recurse_test.exs +++ b/test/console/deployments/pubsub/recurse_test.exs @@ -407,6 +407,7 @@ defmodule Console.Deployments.PubSub.RecurseTest do assert run.git.ref == "last-sha" assert run.stack_id == stack.id + assert run.message =~ "destroying stack #{stack.name}" %{steps: steps} = Console.Repo.preload(run, [:steps]) %{"init" => init, "destroy" => destroy} = Map.new(steps, & {&1.name, &1}) diff --git a/test/console/graphql/queries/deployments/stack_queries_test.exs b/test/console/graphql/queries/deployments/stack_queries_test.exs index 5b89c59ec3..ce2ef976f6 100644 --- a/test/console/graphql/queries/deployments/stack_queries_test.exs +++ b/test/console/graphql/queries/deployments/stack_queries_test.exs @@ -101,11 +101,51 @@ defmodule Console.GraphQl.Deployments.StackQueriesTest do query StackRun($id: ID!) { stackRun(id: $id) { id + stateUrls { + terraform { address lock unlock } + } } } """, %{"id" => run.id}, %{cluster: cluster}) assert found["id"] == run.id + + assert found["stateUrls"]["terraform"]["address"] =~ "/ext/v1/states/terraform/#{run.stack_id}" + assert found["stateUrls"]["terraform"]["lock"] =~ "/ext/v1/states/terraform/#{run.stack_id}/lock" + assert found["stateUrls"]["terraform"]["unlock"] =~ "/ext/v1/states/terraform/#{run.stack_id}/unlock" + end + + test "clusters can fetch plural creds if actor is present" do + cluster = insert(:cluster) + run = insert(:stack_run, cluster: cluster, actor: build(:user)) + + {:ok, %{data: %{"stackRun" => found}}} = run_query(""" + query StackRun($id: ID!) { + stackRun(id: $id) { + id + pluralCreds { token url } + } + } + """, %{"id" => run.id}, %{cluster: cluster}) + + assert found["id"] == run.id + + assert found["pluralCreds"]["token"] + assert found["pluralCreds"]["url"] + end + + test "incorrect clusters cannot fetch plural creds if actor is present" do + cluster = insert(:cluster) + run = insert(:stack_run, cluster: cluster, actor: build(:user)) + + {:ok, %{errors: [_ | _]}} = run_query(""" + query StackRun($id: ID!) { + stackRun(id: $id) { + id + pluralCreds { token url } + } + } + """, %{"id" => run.id}, %{cluster: insert(:cluster)}) end test "users can fetch stack runs" do