Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b4a358f
Generate protos
VeljkoMaksimovic Sep 29, 2025
a56df83
Add empty server implementation, tested locally
VeljkoMaksimovic Sep 30, 2025
10f0d4f
Explicitly set mapped port in docker-compose
VeljkoMaksimovic Oct 2, 2025
721d3d1
Add endpoint and intercepters
VeljkoMaksimovic Oct 2, 2025
16415e7
Generated protobufs
VeljkoMaksimovic Oct 2, 2025
e34c50b
Util from converting protos to map
VeljkoMaksimovic Oct 2, 2025
6493f5d
Add service for inserting new types
VeljkoMaksimovic Oct 2, 2025
e5a3f30
Create elixir map to proto converter
VeljkoMaksimovic Oct 2, 2025
59845a2
Generate new protobufs
VeljkoMaksimovic Oct 22, 2025
08f365e
rename last_modified_by to last_updated_by
VeljkoMaksimovic Oct 22, 2025
df76afe
Add proto helper which will convert from proto to map and vice versa
VeljkoMaksimovic Oct 22, 2025
a8b8fb9
Make sure name us unique per org
VeljkoMaksimovic Oct 22, 2025
98e35a3
Tests for create endpoint
VeljkoMaksimovic Oct 22, 2025
1bafa4c
Change proto interceptor to automatically convert to the Reponse struct
VeljkoMaksimovic Oct 22, 2025
9837661
Implement and test list endpoint
VeljkoMaksimovic Oct 22, 2025
13a4d1d
Write and test describe endpoint
VeljkoMaksimovic Oct 22, 2025
bdf64c1
Implement and test update endpoint
VeljkoMaksimovic Oct 22, 2025
ffb6efd
Fix linting warnings
VeljkoMaksimovic Oct 23, 2025
62511a8
Fix failing tests
VeljkoMaksimovic Oct 23, 2025
8cc02d5
Remove empty dummy test
VeljkoMaksimovic Oct 23, 2025
c3d44d0
Fix test reports
VeljkoMaksimovic Oct 23, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion ee/ephemeral_environments/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ export MIX_ENV?=dev
include ../../Makefile

APP_NAME := $(shell grep 'app:' mix.exs | cut -d ':' -f3 | cut -d ',' -f1)
INTERNAL_API_BRANCH?=master
INTERNAL_API_BRANCH=master
PROTOC_TAG=1.18.4-3.20.1-0.13.0
TMP_REPO_DIR?=/tmp/internal_api

CONTAINER_ENV_VARS = $(shell [ -f .env ] && sed 's/^/-e /' .env | tr '\n' ' ')
ifdef CI
CONTAINER_ENV_VARS := $(shell echo "$(CONTAINER_ENV_VARS)" | sed 's/-e POSTGRES_DB_HOST=[^ ]*/-e POSTGRES_DB_HOST=0.0.0.0/g')
CONTAINER_ENV_VARS += -e CI=true
$(info CI detected - CONTAINER_ENV_VARS: $(CONTAINER_ENV_VARS))
endif

Expand All @@ -23,6 +24,9 @@ ifdef CI
sem-service start postgres 9.6 --db=$(POSTGRES_DB_NAME) --user=$(POSTGRES_DB_USER) --password=$(POSTGRES_DB_PASSWORD)
endif

console.ex:
docker compose run --service-ports $(CONTAINER_ENV_VARS) --rm app sh -c "mix ecto.create && mix ecto.migrate && iex -S mix"

migration.gen:
@if [ -z "$(NAME)" ]; then \
echo "Usage: make migration.gen NAME={migration_name}"; \
Expand Down
4 changes: 2 additions & 2 deletions ee/ephemeral_environments/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ services:
args:
- BUILD_ENV=test
ports:
- "50051"
- 60051:50051
env_file:
- .env
volumes:
Expand Down Expand Up @@ -41,4 +41,4 @@ services:

volumes:
postgres-data:
driver: local
driver: local
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,13 @@ defmodule EphemeralEnvironments.Application do
"Starting EphemeralEnvironments in '#{Application.get_env(:ephemeral_environments, :env)}' environment"
)

children = [EphemeralEnvironments.Repo]
children = [
EphemeralEnvironments.Repo,
{GRPC.Server.Supervisor,
endpoint: EphemeralEnvironments.Grpc.Endpoint,
port: Application.fetch_env!(:ephemeral_environments, :grpc_listen_port),
start_server: true}
]

opts = [strategy: :one_for_one, name: EphemeralEnvironments.Supervisor]
Enum.each(children, fn child -> Logger.info("Starting #{inspect(child)}") end)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
defmodule EphemeralEnvironments.Grpc.Endpoint do
use GRPC.Endpoint

run(EphemeralEnvironments.Grpc.EphemeralEnvironmentsServer,
interceptors: [
EphemeralEnvironments.Grpc.Interceptor.Metrics,
EphemeralEnvironments.Grpc.Interceptor.ProtoConverter
]
)
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
defmodule EphemeralEnvironments.Grpc.EphemeralEnvironmentsServer do
use GRPC.Server, service: InternalApi.EphemeralEnvironments.EphemeralEnvironments.Service

alias EphemeralEnvironments.Service.EphemeralEnvironmentType

def list(request, _stream) do
{:ok, environment_types} = EphemeralEnvironmentType.list(request.org_id)
%{environment_types: environment_types}
end

def describe(request, _stream) do
case EphemeralEnvironmentType.describe(request.id, request.org_id) do
{:ok, environment_type} ->
# Note: instances field will be added once we implement instance management
%{environment_type: environment_type, instances: []}

{:error, :not_found} ->
raise GRPC.RPCError, status: :not_found, message: "Environment type not found"

{:error, error_message} ->
raise GRPC.RPCError, status: :unknown, message: error_message
end
end

def create(request, _stream) do
case EphemeralEnvironmentType.create(request.environment_type) do
{:ok, ret} ->
%{environment_type: ret}

{:error, error_message} ->
raise GRPC.RPCError, status: :unknown, message: error_message
end
end

def update(request, _stream) do
case EphemeralEnvironmentType.update(request.environment_type) do
{:ok, environment_type} ->
%{environment_type: environment_type}

{:error, :not_found} ->
raise GRPC.RPCError, status: :not_found, message: "Environment type not found"

{:error, error_message} ->
raise GRPC.RPCError, status: :unknown, message: error_message
end
end

def delete(_request, _stream) do
end

def cordon(_request, _stream) do
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
defmodule EphemeralEnvironments.Grpc.Interceptor.Metrics do
@behaviour GRPC.ServerInterceptor
require Logger

def init(options) do
options
end

def call(request, stream, next, _) do
Logger.debug("Metrics intercepter #{inspect(request)}")
next.(request, stream)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
defmodule EphemeralEnvironments.Grpc.Interceptor.ProtoConverter do
@behaviour GRPC.ServerInterceptor
require Logger

def init(options) do
options
end

def call(request, stream, next, _) do
Logger.debug("Proto intercepter - Request: #{inspect(request)}")
converted_request = EphemeralEnvironments.Utils.Proto.to_map(request)
{:ok, stream, response} = next.(converted_request, stream)

converted_response = EphemeralEnvironments.Utils.Proto.from_map(response, stream.response_mod)
Logger.debug("Proto intercepter - Response: #{inspect(converted_response)}")
{:ok, stream, converted_response}
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
defmodule EphemeralEnvironments.Repo.EphemeralEnvironmentType do
use Ecto.Schema
import Ecto.Changeset

@primary_key {:id, :binary_id, autogenerate: true}
@foreign_key_type :binary_id

schema "ephemeral_environment_types" do
field(:org_id, :binary_id)
field(:name, :string)
field(:description, :string)
field(:created_by, :binary_id)
field(:last_updated_by, :binary_id)
field(:state, Ecto.Enum, values: [:draft, :ready, :cordoned, :deleted])
field(:max_number_of_instances, :integer)

timestamps()
end

def changeset(ephemeral_environment_type, attrs) do
ephemeral_environment_type
|> cast(attrs, [
:org_id,
:name,
:description,
:created_by,
:last_updated_by,
:state,
:max_number_of_instances
])
|> validate_required([:org_id, :name, :created_by, :last_updated_by, :state])
|> validate_uuid(:org_id)
|> validate_uuid(:created_by)
|> validate_uuid(:last_updated_by)
|> validate_length(:name, min: 1, max: 255)
|> validate_length(:description, max: 1000)
|> validate_number(:max_number_of_instances, greater_than: 0)
|> unique_constraint(:duplicate_name,
name: :ephemeral_environment_types_org_id_name_index,
message: "ephemeral environment name has already been taken"
)
end

defp validate_uuid(changeset, field) do
validate_change(changeset, field, fn ^field, value ->
case Ecto.UUID.cast(value) do
{:ok, _} -> []
:error -> [{field, "must be a valid UUID"}]
end
end)
end
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
defmodule EphemeralEnvironments.Service.EphemeralEnvironmentType do
import Ecto.Query
alias EphemeralEnvironments.Repo
alias EphemeralEnvironments.Repo.EphemeralEnvironmentType, as: Schema

@doc """
Lists all ephemeral environment types for a given organization.

## Parameters
- org_id: String UUID of the organization

## Returns
- {:ok, list of maps} on success
"""
def list(org_id) when is_binary(org_id) do
environment_types =
Schema
|> where([e], e.org_id == ^org_id)
|> Repo.all()
|> Enum.map(&struct_to_map/1)

{:ok, environment_types}
end

@doc """
Describes a specific ephemeral environment type by ID and org_id.

## Parameters
- id: String UUID of the environment type
- org_id: String UUID of the organization

## Returns
- {:ok, map} on success
- {:error, :not_found} if the environment type doesn't exist
"""
def describe(id, org_id) when is_binary(id) and is_binary(org_id) do
Schema
|> where([e], e.id == ^id and e.org_id == ^org_id)
|> Repo.one()
|> case do
nil -> {:error, :not_found}
record -> {:ok, struct_to_map(record)}
end
end

@doc """
Updates an existing ephemeral environment type.

## Parameters
- attrs: Map with keys:
- id (required)
- org_id (required)
- last_updated_by (required)
- name (optional)
- description (optional)
- max_number_of_instances (optional)
- state (optional)

## Returns
- {:ok, map} on success
- {:error, :not_found} if the environment type doesn't exist
- {:error, String.t()} on validation failure
"""
def update(attrs) do
# Filter out proto default values that shouldn't be updated
attrs = filter_proto_defaults(attrs)

with {:ok, record} <- get_record(attrs[:id], attrs[:org_id]),
{:ok, updated_record} <- update_record(record, attrs) do
{:ok, struct_to_map(updated_record)}
end
end

# Remove proto default values that indicate "not set" rather than explicit values
defp filter_proto_defaults(attrs) do
attrs
|> Enum.reject(fn
# Empty strings from proto mean "not set"
{_key, ""} -> true
# :unspecified enum means "not set"
{:state, :unspecified} -> true
# 0 for max_number_of_instances means "not set" (since validation requires > 0)
{:max_number_of_instances, 0} -> true
# Keep everything else
_ -> false
end)
|> Map.new()
end

defp get_record(id, org_id) when is_binary(id) and is_binary(org_id) do
Schema
|> where([e], e.id == ^id and e.org_id == ^org_id)
|> Repo.one()
|> case do
nil -> {:error, :not_found}
record -> {:ok, record}
end
end

defp update_record(record, attrs) do
record
|> Schema.changeset(attrs)
|> Repo.update()
|> case do
{:ok, updated_record} -> {:ok, updated_record}
{:error, changeset} -> {:error, format_errors(changeset)}
end
end

@doc """
Creates a new ephemeral environment type.

## Parameters
- attrs: Map with keys:
- org_id (required)
- name (required)
- max_number_of_instances (required)
- created_by (required)
- description (optional)

## Returns
- {:ok, map} on success
- {:error, String.t()} on validation failure
"""
def create(attrs) do
attrs = Map.put(attrs, :last_updated_by, attrs[:created_by])
attrs = Map.put(attrs, :state, :draft)

%Schema{}
|> Schema.changeset(attrs)
|> Repo.insert()
|> case do
{:ok, record} -> {:ok, struct_to_map(record)}
{:error, changeset} -> {:error, format_errors(changeset)}
end
end

###
### Helper functions
###

defp struct_to_map(struct) do
struct
|> Map.from_struct()
|> Map.drop([:__meta__])
|> rename_timestamp_fields()
end

# Rename Ecto's inserted_at to created_at to match proto definition
defp rename_timestamp_fields(map) do
map
|> Map.put(:created_at, map[:inserted_at])
|> Map.delete(:inserted_at)
end

defp format_errors(changeset) do
Ecto.Changeset.traverse_errors(changeset, fn {msg, opts} ->
Enum.reduce(opts, msg, fn {key, value}, acc ->
String.replace(acc, "%{#{key}}", safe_to_string(value))
end)
end)
|> Enum.map_join("; ", fn {field, errors} ->
"#{field}: #{Enum.join(errors, ", ")}"
end)
end

# Safely convert values to strings, handling complex types
defp safe_to_string(value) when is_binary(value), do: value
defp safe_to_string(value) when is_atom(value), do: to_string(value)
defp safe_to_string(value) when is_number(value), do: to_string(value)
defp safe_to_string(value) when is_list(value), do: inspect(value)
defp safe_to_string(value), do: inspect(value)
end
Loading