Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 4 additions & 2 deletions lib/cleat/cli.ex
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ defmodule Cleat.CLI do
mode: :string,
slug: :string,
runtime: :string,
apt: :string,
binaries: :string,
port: :integer,
yes: :boolean,
Expand Down Expand Up @@ -183,12 +184,13 @@ defmodule Cleat.CLI do
apps list List apps
apps show APP Show one app (id or slug)
apps create --name N --repo O/R \\
--host H --server ID [--runtime R] Create an app
--host H --server ID [--runtime R] [--apt pkg,pkg]
Create an app
apps update APP [--branch B] \\
[--auto-deploy|--no-auto-deploy] \\
[--indexable|--no-indexable] \\
[--host H] [--port N] [--repo O/R] \\
[--runtime R] Edit repo / branch / auto-deploy / indexing / host / port / runtime
[--runtime R] [--apt pkg,pkg] Edit repo / branch / auto-deploy / indexing / host / port / runtime / apt packages
apps logs APP [--tail N] [--since S] [--grep T] [--follow]
Runtime logs (systemd unit)
servers logs ID [--unit U] [--tail N] [--since S] [--grep T] [--follow]
Expand Down
51 changes: 39 additions & 12 deletions lib/cleat/commands/apps.ex
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ defmodule Cleat.Commands.Apps do

alias Cleat.{Client, Commands, Output, Runtime}

@usage "usage: cleat apps list | cleat apps show APP | cleat apps create --name N --repo owner/repo --host H --server ID | cleat apps update APP [--branch B] [--auto-deploy|--no-auto-deploy] [--indexable|--no-indexable] [--host H] [--port N] [--repo owner/repo] [--runtime R] | cleat apps delete APP --yes | cleat apps logs APP [--tail N] [--since S] [--grep T] [--follow]"
@usage "usage: cleat apps list | cleat apps show APP | cleat apps create --name N --repo owner/repo --host H --server ID [--apt pkg,pkg] | cleat apps update APP [--branch B] [--auto-deploy|--no-auto-deploy] [--indexable|--no-indexable] [--host H] [--port N] [--repo owner/repo] [--runtime R] [--apt pkg,pkg] | cleat apps delete APP --yes | cleat apps logs APP [--tail N] [--since S] [--grep T] [--follow]"

def run([], opts), do: list(opts)
def run(["list"], opts), do: list(opts)
Expand Down Expand Up @@ -60,6 +60,7 @@ defmodule Cleat.Commands.Apps do
["Host", data["host"]],
["Port", data["port"]],
["Runtime", data["runtime"]],
["Apt packages", format_apt(data["runtime_apt_packages"])],
["Auto deploy", data["auto_deploy"]],
["Indexable", data["indexable"]],
["Server", server_name(data)],
Expand Down Expand Up @@ -87,16 +88,18 @@ defmodule Cleat.Commands.Apps do
{:error, :missing_host} -> nil
end

attrs = %{
"name" => opts[:name],
"slug" => opts[:slug] || Cleat.Slug.from_name(opts[:name]),
"github_repo" => opts[:repo],
"branch" => opts[:branch] || "main",
"host" => host,
"port" => opts[:port] || 4000,
"runtime" => runtime(opts),
"server_id" => opts[:server]
}
attrs =
%{
"name" => opts[:name],
"slug" => opts[:slug] || Cleat.Slug.from_name(opts[:name]),
"github_repo" => opts[:repo],
"branch" => opts[:branch] || "main",
"host" => host,
"port" => opts[:port] || 4000,
"runtime" => runtime(opts),
"server_id" => opts[:server]
}
|> maybe_put_apt(opts)

case missing(attrs, [
{"name", "--name"},
Expand Down Expand Up @@ -127,6 +130,7 @@ defmodule Cleat.Commands.Apps do
|> maybe_put_port(opts)
|> maybe_put_repo(opts)
|> maybe_put_runtime(opts)
|> maybe_put_apt(opts)
|> with_host(opts)

case attrs do
Expand All @@ -135,7 +139,7 @@ defmodule Cleat.Commands.Apps do

attrs when map_size(attrs) == 0 ->
{:error,
"nothing to update: pass --branch, --auto-deploy, --indexable, --host, --port, --repo or --runtime"}
"nothing to update: pass --branch, --auto-deploy, --indexable, --host, --port, --repo, --runtime or --apt"}

attrs ->
with {:ok, client} <- Commands.client(opts),
Expand Down Expand Up @@ -289,6 +293,29 @@ defmodule Cleat.Commands.Apps do
end
end

defp maybe_put_apt(attrs, opts) do
case apt_packages(opts[:apt]) do
nil -> attrs
packages -> Map.put(attrs, "runtime_apt_packages", packages)
end
end

defp apt_packages(nil), do: nil

defp apt_packages(packages) when is_list(packages), do: packages

defp apt_packages(packages) when is_binary(packages) do
packages
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end

defp format_apt(packages) when is_list(packages) and packages != [],
do: Enum.join(packages, ", ")

defp format_apt(_), do: "—"

defp missing(attrs, required) do
required
|> Enum.filter(fn {key, _flag} -> attrs[key] in [nil, ""] end)
Expand Down
22 changes: 17 additions & 5 deletions lib/cleat/mcp/tools.ex
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,13 @@ defmodule Cleat.MCP.Tools do
@token %{"type" => "string", "description" => "Bearer token override"}
@app %{"type" => "string", "description" => "App id or slug"}

@runtime_apt_packages %{
"type" => "array",
"items" => %{"type" => "string"},
"description" =>
"apt packages to install on the VM at deploy. Alternative: commit .cleat_deploy/runtime-packages (or .cleat_deploy/post-install.sh) in the repo"
}

def list, do: Enum.map(tools(), &Map.take(&1, ["name", "description", "inputSchema"]))

def call(name, args) when is_map(args) do
Expand Down Expand Up @@ -296,7 +303,8 @@ defmodule Cleat.MCP.Tools do
},
%{
"name" => "apps_create",
"description" => "Create an app",
"description" =>
"Create an app. runtime_apt_packages installs apt deps on the VM; alternatively commit .cleat_deploy/runtime-packages",
"inputSchema" => %{
"type" => "object",
"properties" => %{
Expand All @@ -308,6 +316,7 @@ defmodule Cleat.MCP.Tools do
"slug" => %{"type" => "string"},
"branch" => %{"type" => "string"},
"port" => %{"type" => "integer"},
"runtime_apt_packages" => @runtime_apt_packages,
"panel" => @panel,
"token" => @token
},
Expand All @@ -323,7 +332,8 @@ defmodule Cleat.MCP.Tools do
"slug" => args["slug"],
"branch" => args["branch"],
"port" => args["port"],
"runtime" => args["runtime"]
"runtime" => args["runtime"],
"runtime_apt_packages" => args["runtime_apt_packages"]
}
|> Map.reject(fn {_k, v} -> is_nil(v) or v == "" end)

Expand All @@ -335,7 +345,7 @@ defmodule Cleat.MCP.Tools do
%{
"name" => "apps_update",
"description" =>
"Edit an app (branch, host, port, repo, runtime, auto_deploy, indexable)",
"Edit an app (branch, host, port, repo, runtime, auto_deploy, indexable, runtime_apt_packages). Alternative for apt deps: .cleat_deploy/runtime-packages",
"inputSchema" => %{
"type" => "object",
"properties" => %{
Expand All @@ -350,6 +360,7 @@ defmodule Cleat.MCP.Tools do
"type" => "boolean",
"description" => "Allow search engines to index a static site"
},
"runtime_apt_packages" => @runtime_apt_packages,
"panel" => @panel,
"token" => @token
},
Expand All @@ -364,13 +375,14 @@ defmodule Cleat.MCP.Tools do
"github_repo" => args["repo"],
"runtime" => args["runtime"],
"auto_deploy" => args["auto_deploy"],
"indexable" => args["indexable"]
"indexable" => args["indexable"],
"runtime_apt_packages" => args["runtime_apt_packages"]
}
|> Map.reject(fn {_k, v} -> is_nil(v) end)

if attrs == %{} do
{:error,
"nothing to update: pass branch, host, port, repo, runtime, auto_deploy or indexable"}
"nothing to update: pass branch, host, port, repo, runtime, auto_deploy, indexable or runtime_apt_packages"}
else
with_client(args, fn client ->
with {:ok, body} <- Client.update_app(client, args["app"], attrs),
Expand Down
51 changes: 51 additions & 0 deletions test/cleat/commands/apps_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,57 @@ defmodule Cleat.Commands.AppsTest do
assert :ok = Apps.run(["update", "my-app"], Map.put(@conn, :runtime, "node"))
end

test "updates runtime_apt_packages from --apt" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "PATCH"

assert Jason.decode!(Req.Test.raw_body(conn)) == %{
"runtime_apt_packages" => ["ffmpeg", "webp"]
}

Req.Test.json(conn, %{
"data" => %{
"slug" => "my-app",
"runtime_apt_packages" => ["ffmpeg", "webp"],
"github_repo" => "owner/app",
"branch" => "main",
"auto_deploy" => true,
"indexable" => false,
"host" => "my-app.example.com",
"port" => 4000,
"runtime" => "node"
}
})
end)

assert :ok = Apps.run(["update", "my-app"], Map.put(@conn, :apt, "ffmpeg,webp"))
end

test "creates an app with --apt packages" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "POST"
assert Jason.decode!(Req.Test.raw_body(conn))["runtime_apt_packages"] == ["ffmpeg", "webp"]

conn
|> Plug.Conn.put_status(201)
|> Req.Test.json(%{"data" => %{"id" => 9, "slug" => "gowa"}})
end)

assert :ok =
Apps.run(
["create"],
%{
panel: "https://panel.test",
token: "tok",
name: "gowa",
repo: "owner/gowa",
host: "gowa.example.com",
server: "5",
apt: "ffmpeg, webp"
}
)
end

test "creates an app using the detected runtime" do
dir =
Path.join(System.tmp_dir!(), "cleat-apps-proj-#{System.unique_integer([:positive])}")
Expand Down
2 changes: 1 addition & 1 deletion test/cleat/contract_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ defmodule Cleat.ContractTest do
# Keys the CLI reads from each resource type.
@used %{
"app" =>
~w(id name slug github_repo branch host port runtime auto_deploy indexable systemd_unit release_path data_dir server),
~w(id name slug github_repo branch host port runtime runtime_apt_packages auto_deploy indexable systemd_unit release_path data_dir server),
"server" =>
~w(id name host_ip ssh_user region provider deploy_mode instance_status bundle_name cpu_count ram_mb disk_gb),
"deployment" =>
Expand Down
53 changes: 53 additions & 0 deletions test/cleat/mcp/tools_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,59 @@ defmodule Cleat.MCP.ToolsTest do
})
end

test "apps_create posts runtime_apt_packages" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "POST"
body = Jason.decode!(Req.Test.raw_body(conn))
assert body["runtime_apt_packages"] == ["ffmpeg", "webp"]
Req.Test.json(conn, %{"data" => %{"id" => 1, "slug" => "gowa"}})
end)

assert {:ok, _text} =
Tools.call("apps_create", %{
"name" => "gowa",
"repo" => "owner/gowa",
"host" => "gowa.example.com",
"server" => "5",
"runtime_apt_packages" => ["ffmpeg", "webp"],
"panel" => "https://panel.test",
"token" => "tok"
})
end

test "apps_update patches runtime_apt_packages" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "PATCH"
assert conn.request_path == "/api/v1/apps/gowa"

assert Jason.decode!(Req.Test.raw_body(conn)) == %{
"runtime_apt_packages" => ["ffmpeg", "webp"]
}

Req.Test.json(conn, %{
"data" => %{"slug" => "gowa", "runtime_apt_packages" => ["ffmpeg", "webp"]}
})
end)

assert {:ok, _text} =
Tools.call("apps_update", %{
"app" => "gowa",
"runtime_apt_packages" => ["ffmpeg", "webp"],
"panel" => "https://panel.test",
"token" => "tok"
})
end

test "apps_create and apps_update schemas document runtime_apt_packages" do
for name <- ["apps_create", "apps_update"] do
tool = Enum.find(Tools.list(), &(&1["name"] == name))
field = tool["inputSchema"]["properties"]["runtime_apt_packages"]
assert field["type"] == "array"
assert field["items"] == %{"type" => "string"}
assert field["description"] =~ ".cleat_deploy/runtime-packages"
end
end

test "env_set PUTs vars to the app env endpoint" do
Req.Test.stub(__MODULE__, fn conn ->
assert conn.method == "PUT"
Expand Down
Loading