defmodule GitGudWeb.ActionsCacheController do
@moduledoc """
GitHub Actions cache server v1, used by the `actions/cache` action.
Endpoints under `/api/actions/_apis/artifactcache/`:
* `GET cache?keys=...&version=...` → 200 with archive URL on hit / 204 on miss
* `POST caches` → 201 with `cacheId` reserving a row
* `PATCH caches/:id` → append bytes for `cacheId`
* `POST caches/:id` → finalize the upload
Auth via the per-task `RunnerJwt` plug. The JWT carries
`pid` (repository id) and `rid` (workflow run id); we use the run to
resolve the current ref and the repo to resolve the default-branch
fallback.
"""
use GitGudWeb, :controller
alias GitGud.Repositories
alias GitGud.Workflows.ActionsCache
def get_cache(conn, params) do
repo_id = jwt_repo_id(conn)
keys = parse_keys(params["keys"])
version = params["version"] || ""
{ref, default_ref} = refs_for(conn)
cond do
is_nil(repo_id) or keys == [] or version == "" ->
send_resp(conn, 204, "")
true ->
case ActionsCache.lookup(repo_id, version, keys, ref, default_ref) do
nil ->
send_resp(conn, 204, "")
%{entry: entry, matched_key: matched} ->
case ActionsCache.download_url(entry) do
{:ok, url} ->
body =
Jason.encode!(%{
"result" => "hit",
"archiveLocation" => url,
"cacheKey" => matched,
"scope" => entry.ref || ""
})
conn
|> put_resp_content_type("application/json", nil)
|> send_resp(200, body)
_ ->
send_resp(conn, 204, "")
end
end
end
end
def reserve_cache(conn, %{"key" => key, "version" => version} = _params) do
repo_id = jwt_repo_id(conn)
with repo_id when is_integer(repo_id) <- repo_id,
repo = Repositories.get_repository!(repo_id),
{ref, _default} <- refs_for(conn),
{:ok, entry} <- ActionsCache.reserve(repo, key, version, ref) do
body =
Jason.encode!(%{
"cacheId" => entry.id,
"key" => key,
"version" => version,
"uploadUrl" => upload_url(conn, entry.id)
})
conn
|> put_resp_content_type("application/json", nil)
|> send_resp(201, body)
else
_ -> send_resp(conn, 400, "")
end
end
def upload_chunk(conn, %{"id" => id} = _params) do
{:ok, body, conn} = Plug.Conn.read_body(conn, length: 200_000_000)
entry = ActionsCache.get!(String.to_integer(id))
case ActionsCache.append(entry, body) do
:ok ->
send_resp(conn, 204, "")
{:error, reason} ->
msg = Jason.encode!(%{"error" => inspect(reason)})
conn
|> put_resp_content_type("application/json", nil)
|> send_resp(500, msg)
end
end
def finalize(conn, %{"id" => id} = params) do
entry = ActionsCache.get!(String.to_integer(id))
size = parse_int(params["size"]) || guess_size(entry)
{:ok, _} = ActionsCache.finalize(entry, size || 0)
send_resp(conn, 204, "")
end
# ── helpers ────────────────────────────────────────────────────────
defp jwt_repo_id(conn) do
case conn.assigns[:runner_jwt_claims] do
%{"pid" => pid} when is_integer(pid) -> pid
_ -> nil
end
end
defp refs_for(conn) do
case conn.assigns[:runner_jwt_claims] do
%{"rid" => rid} when is_integer(rid) ->
run = GitGud.Repo.get(GitGud.Workflows.WorkflowRun, rid)
repo = run && GitGud.Repo.get(GitGud.Repositories.Repository, run.repository_id)
default = repo && "refs/heads/" <> (repo.default_branch || "main")
{run && run.head_ref, default}
_ ->
{nil, nil}
end
end
defp parse_keys(nil), do: []
defp parse_keys(csv) when is_binary(csv) do
csv
|> String.split(",", trim: true)
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
end
defp parse_int(nil), do: nil
defp parse_int(n) when is_integer(n), do: n
defp parse_int(s) when is_binary(s) do
case Integer.parse(s) do
{n, _} -> n
_ -> nil
end
end
defp guess_size(entry) do
case GitGud.Storage.get(ActionsCache.bucket(), entry.storage_key) do
{:ok, bin} -> byte_size(bin)
_ -> nil
end
end
defp upload_url(_conn, cache_id) do
GitGudWeb.Endpoint.url() <>
"/api/actions/_apis/artifactcache/caches/" <> Integer.to_string(cache_id)
end
end
4.6 KiB · text
5af55d9