Skip to content

Commit 435a668

Browse files
authored
Introducing Git file storage (#3056)
1 parent 8405d96 commit 435a668

32 files changed

+1528
-140
lines changed

.github/workflows/test.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ jobs:
4141
run: mix compile --warnings-as-errors
4242
- name: Run tests
4343
run: mix test
44+
env:
45+
TEST_GIT_SSH_KEY: ${{ secrets.TEST_GIT_SSH_KEY }}
4446
- name: Install Node
4547
uses: actions/setup-node@v4
4648
with:
@@ -105,6 +107,8 @@ jobs:
105107
run: mix deps.get
106108
- name: Run tests
107109
run: mix test
110+
env:
111+
TEST_GIT_SSH_KEY: ${{ secrets.TEST_GIT_SSH_KEY }}
108112
- name: Build the app
109113
run: bash .github/scripts/app/build_windows.sh
110114

Dockerfile

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,8 @@ RUN apt-get update && apt-get upgrade -y && \
8282
build-essential ca-certificates libncurses5-dev \
8383
# In case someone uses `Mix.install/2` and point to a git repo
8484
git \
85+
# In case someone uses the Git file storage
86+
openssh-client \
8587
# Additional standard tools
8688
wget \
8789
# In case someone uses Torchx for Nx

lib/livebook/application.ex

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ defmodule Livebook.Application do
5858
{DynamicSupervisor, name: Livebook.HubsSupervisor, strategy: :one_for_one},
5959
# Run startup logic relying on the supervision tree
6060
{Livebook.Utils.SupervisionStep, {:boot, boot(create_teams_hub)}},
61+
# Start the server responsible for initializing
62+
# mountable file systems. We do it after boot, because
63+
# file systems and this depends on hubs being started
64+
Livebook.FileSystem.Mounter,
6165
# App manager supervision tree. We do it after boot, because
6266
# permanent apps are going to be started right away and this
6367
# depends on hubs being started

lib/livebook/file_system.ex

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,4 +246,16 @@ defprotocol Livebook.FileSystem do
246246
"""
247247
@spec external_metadata(t()) :: %{name: String.t(), error_field: String.t()}
248248
def external_metadata(file_system)
249+
250+
@doc """
251+
Mounts the given file system to be used furthermore.
252+
"""
253+
@spec mount(t()) :: :ok | {:error, error()}
254+
def mount(file_system)
255+
256+
@doc """
257+
Unmounts the given file system from being used.
258+
"""
259+
@spec unmount(t()) :: :ok | {:error, error()}
260+
def unmount(file_system)
249261
end

lib/livebook/file_system/git.ex

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
defmodule Livebook.FileSystem.Git do
2+
use Ecto.Schema
3+
import Ecto.Changeset
4+
5+
alias Livebook.FileSystem
6+
7+
# File system backed by an Git repository.
8+
9+
@type t :: %__MODULE__{
10+
id: String.t(),
11+
repo_url: String.t(),
12+
branch: String.t(),
13+
key: String.t(),
14+
external_id: String.t() | nil,
15+
hub_id: String.t()
16+
}
17+
18+
embedded_schema do
19+
field :repo_url, :string
20+
field :branch, :string
21+
field :key, :string, redact: true
22+
field :external_id, :string
23+
field :hub_id, :string
24+
end
25+
26+
@doc """
27+
Returns an `%Ecto.Changeset{}` for tracking file system changes.
28+
"""
29+
@spec change_file_system(t(), map()) :: Ecto.Changeset.t()
30+
def change_file_system(git, attrs \\ %{}) do
31+
changeset(git, attrs)
32+
end
33+
34+
defp changeset(git, attrs) do
35+
git
36+
|> cast(attrs, [:repo_url, :branch, :key, :external_id, :hub_id])
37+
|> validate_format(:repo_url, ~r/^git@[\w\.\-]+:(?:v\d+\/)?[\w\.\-\/]+\.git$/,
38+
message: "must be a valid repo URL"
39+
)
40+
|> validate_required([:repo_url, :branch, :key, :hub_id])
41+
|> update_change(:key, fn key ->
42+
if key =~ "\n" do
43+
key
44+
else
45+
String.replace(
46+
key,
47+
~r/ (?!(?:OPENSSH |RSA |EC |DSA |PRIVATE )?(?:PRIVATE )?KEY-----)/,
48+
"\n"
49+
)
50+
end
51+
end)
52+
|> put_id()
53+
end
54+
55+
defp put_id(changeset) do
56+
hub_id = get_field(changeset, :hub_id)
57+
repo_url = get_field(changeset, :repo_url)
58+
59+
if get_field(changeset, :id) do
60+
changeset
61+
else
62+
put_change(changeset, :id, FileSystem.Utils.id("git", hub_id, repo_url))
63+
end
64+
end
65+
66+
@doc false
67+
def git_dir(%__MODULE__{id: id}), do: Path.join(Livebook.Config.tmp_path(), id)
68+
69+
@doc false
70+
def key_path(%__MODULE__{id: id}), do: Path.join(Livebook.Config.tmp_path(), "#{id}_key")
71+
end
72+
73+
defimpl Livebook.FileSystem, for: Livebook.FileSystem.Git do
74+
alias Livebook.FileSystem
75+
alias Livebook.FileSystem.Git
76+
77+
def type(_file_system) do
78+
:global
79+
end
80+
81+
def default_path(_file_system) do
82+
"/"
83+
end
84+
85+
def list(file_system, path, _recursive) do
86+
with {:ok, []} <- Git.Client.list_files(file_system, path) do
87+
FileSystem.Utils.posix_error(:enoent)
88+
end
89+
end
90+
91+
def read(file_system, path) do
92+
Git.Client.read_file(file_system, path)
93+
end
94+
95+
def write(_file_system, _path, _content), do: raise("not implemented")
96+
97+
def access(_file_system, _path) do
98+
{:ok, :read}
99+
end
100+
101+
def create_dir(_file_system, _path), do: raise("not implemented")
102+
103+
def remove(_file_system, _path), do: raise("not implemented")
104+
105+
def copy(_file_system, _source_path, _destination_path), do: raise("not implemented")
106+
107+
def rename(_file_system, _source_path, _destination_path), do: raise("not implemented")
108+
109+
def etag_for(file_system, path) do
110+
FileSystem.Utils.assert_regular_path!(path)
111+
Git.Client.etag(file_system, path)
112+
end
113+
114+
def exists?(file_system, path) do
115+
Git.Client.exists?(file_system, path)
116+
end
117+
118+
def resolve_path(_file_system, dir_path, subject) do
119+
FileSystem.Utils.resolve_unix_like_path(dir_path, subject)
120+
end
121+
122+
def write_stream_init(_file_system, _path, _opts), do: raise("not implemented")
123+
124+
def write_stream_chunk(_file_system, _state, _chunk), do: raise("not implemented")
125+
126+
def write_stream_finish(_file_system, _state), do: raise("not implemented")
127+
128+
def write_stream_halt(_file_system, _state), do: raise("not implemented")
129+
130+
def read_stream_into(_file_system, _path, _collectable), do: raise("not implemented")
131+
132+
def load(file_system, %{"hub_id" => _} = fields) do
133+
load(file_system, %{
134+
id: fields["id"],
135+
repo_url: fields["repo_url"],
136+
branch: fields["branch"],
137+
key: fields["key"],
138+
external_id: fields["external_id"],
139+
hub_id: fields["hub_id"]
140+
})
141+
end
142+
143+
def load(file_system, fields) do
144+
%{
145+
file_system
146+
| id: fields.id,
147+
repo_url: fields.repo_url,
148+
branch: fields.branch,
149+
key: fields.key,
150+
external_id: fields.external_id,
151+
hub_id: fields.hub_id
152+
}
153+
end
154+
155+
def dump(file_system) do
156+
file_system
157+
|> Map.from_struct()
158+
|> Map.take([:id, :repo_url, :branch, :key, :external_id, :hub_id])
159+
end
160+
161+
def external_metadata(file_system) do
162+
%{name: file_system.repo_url, error_field: "repo_url"}
163+
end
164+
165+
def mount(file_system) do
166+
if mounted?(file_system) do
167+
FileSystem.Git.Client.fetch(file_system)
168+
else
169+
FileSystem.Git.Client.init(file_system)
170+
end
171+
end
172+
173+
def unmount(file_system) do
174+
path = FileSystem.Git.git_dir(file_system)
175+
key_path = FileSystem.Git.key_path(file_system)
176+
177+
if File.exists?(key_path) do
178+
File.rm!(key_path)
179+
end
180+
181+
case File.rm_rf(path) do
182+
{:ok, _} -> :ok
183+
{:error, reason, _file} -> FileSystem.Utils.posix_error(reason)
184+
end
185+
end
186+
187+
defp mounted?(file_system) do
188+
file_system
189+
|> FileSystem.Git.git_dir()
190+
|> File.exists?()
191+
end
192+
end

0 commit comments

Comments
 (0)