Skip to content

Commit 2e7dbf1

Browse files
fix(modules): SFTP Server Container (testcontainers#629)
# New Container Fixes testcontainers#628 # PR Checklist - [x] Your PR title follows the [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) syntax as we make use of this for detecting Semantic Versioning changes. - [x] Your PR allows maintainers to edit your branch, this will speed up resolving minor issues! - [x] The new container is implemented under `modules/*` - Your module follows [PEP 420](https://peps.python.org/pep-0420/) with implicit namespace packages (if unsure, look at other existing community modules) - Your package namespacing follows `testcontainers.<modulename>.*` and you DO NOT have an `__init__.py` above your module's level. - Your module has it's own tests under `modules/*/tests` - Your module has a `README.rst` and hooks in the `.. auto-class` and `.. title` of your container - Implement the new feature (typically in `__init__.py`) and corresponding tests. - [x] Your module is added in `pyproject.toml` - it is declared under `tool.poetry.packages` - see other community modules - it is declared under `tool.poetry.extras` with the same name as your module name, we still prefer adding _NO EXTRA DEPENDENCIES_, meaning `mymodule = []` is the preferred addition (see the notes at the bottom) - [x] ~The `INDEX.rst` at the project root includes your module under the `.. toctree` directive~ - [x] Your branch is up to date (or we'll use GH's "update branch" function through the UI)
1 parent 16f6ca4 commit 2e7dbf1

File tree

7 files changed

+534
-2
lines changed

7 files changed

+534
-2
lines changed

.github/settings.yml

+1
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ labels:
7979
- { name: '📦 package: postgres', color: '#0052CC', description: '' }
8080
- { name: '📦 package: rabbitmq', color: '#0052CC', description: '' }
8181
- { name: '📦 package: selenium', color: '#0052CC', description: '' }
82+
- { name: '📦 package: sftp', color: '#0052CC', description: '' }
8283
- { name: '🔀 requires triage', color: '#bfdadc', description: '' }
8384
- { name: '🔧 maintenance', color: '#c2f759', description: '' }
8485
- { name: '🚀 enhancement', color: '#84b6eb', description: '' }

modules/sftp/README.rst

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.. autoclass:: testcontainers.sftp.SFTPContainer
2+
.. autoclass:: testcontainers.sftp.SFTPUser
3+
.. title:: testcontainers.sftp.SFTPContainer
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
#
2+
# Licensed under the Apache License, Version 2.0 (the "License"); you may
3+
# not use this file except in compliance with the License. You may obtain
4+
# a copy of the License at
5+
#
6+
# http://www.apache.org/licenses/LICENSE-2.0
7+
#
8+
# Unless required by applicable law or agreed to in writing, software
9+
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
10+
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
11+
# License for the specific language governing permissions and limitations
12+
# under the License.
13+
from __future__ import annotations
14+
15+
import os
16+
import tempfile
17+
from typing import TYPE_CHECKING, Any, NamedTuple
18+
19+
from cryptography.hazmat.primitives import serialization
20+
from cryptography.hazmat.primitives.asymmetric import rsa
21+
22+
from testcontainers.core.container import DockerContainer
23+
from testcontainers.core.waiting_utils import wait_for_logs
24+
25+
if TYPE_CHECKING:
26+
from typing_extensions import Self
27+
28+
29+
class SFTPUser:
30+
"""
31+
Helper class to define a user for SFTPContainer authentication.
32+
33+
Constructor args/kwargs:
34+
35+
* ``name``: (req.) username
36+
* ``public_key``: (opt.) bytes of publickey
37+
* ``private_key``: (opt.) bytes of privatekey (useful if you want to access \
38+
them later in test code)
39+
* ``password``: (opt.) password
40+
* ``uid``: (opt.) user ID
41+
* ``gid``: (opt.) group ID
42+
* ``folders``: (opt.) folders to create inside the user's directory (e.g. upload/)
43+
* ``mount_dir``: (opt.) a local folder to mount to the user's root directory
44+
45+
Properties:
46+
47+
* ``public_key_file``: str path of public key tempfile (gets mounted to \
48+
SFTPContainer as a volume)
49+
* ``private_key_file``: str path of private key tempfile (useful to pass to \
50+
paramiko when connecting to the sftp server using ssh
51+
52+
Methods:
53+
54+
* ``with_keypair``: classmethod to create a new user with an auto-generated RSA keypair
55+
* ``conf``: str configuration string to register user on server
56+
57+
58+
Example:
59+
60+
.. doctest::
61+
62+
>>> from testcontainers.sftp import SFTPUser
63+
64+
>>> users = [
65+
... SFTPUser("jane", password="secret"),
66+
... SFTPUser.with_keypair("ron", folders=["stuff"]),
67+
... ]
68+
69+
>>> for user in users:
70+
... print(user.name, user.folders[0])
71+
...
72+
jane upload
73+
ron stuff
74+
75+
>>> assert users[0].password == "secret"
76+
77+
>>> assert users[1].public_key is not None
78+
79+
>>> assert users[1].public_key.decode().startswith("ssh-rsa ")
80+
81+
>>> assert users[1].private_key is not None
82+
83+
>>> assert users[1].private_key.decode().startswith("-----BEGIN RSA PRIVATE KEY-----")
84+
"""
85+
86+
def __init__(
87+
self,
88+
name: str,
89+
*,
90+
public_key: bytes | None = None,
91+
private_key: bytes | None = None,
92+
password: str | None = None,
93+
uid: str | None = None,
94+
gid: str | None = None,
95+
folders: list[str] | None = None,
96+
mount_dir: str | None = None,
97+
) -> None:
98+
if folders is None:
99+
folders = ["upload"]
100+
self.name = name
101+
self.public_key = public_key
102+
self.private_key = private_key
103+
self.password = password
104+
self.uid = uid
105+
self.gid = gid
106+
self.folders = folders
107+
self.mount_dir = mount_dir
108+
109+
self.public_key_file: str | None = None
110+
if self.public_key is not None:
111+
with tempfile.NamedTemporaryFile(delete=False) as f:
112+
f.write(self.public_key)
113+
self.public_key_file = f.name
114+
115+
self.private_key_file: str | None = None
116+
if self.private_key is not None:
117+
with tempfile.NamedTemporaryFile(delete=False) as f:
118+
f.write(self.private_key)
119+
self.private_key_file = f.name
120+
121+
def __del__(self) -> None:
122+
"""Clean up keypair temp files"""
123+
if self.public_key_file is not None:
124+
os.unlink(self.public_key_file)
125+
if self.private_key_file is not None:
126+
os.unlink(self.private_key_file)
127+
128+
@property
129+
def conf(self) -> str:
130+
"""Configuration string to register user on server"""
131+
return ":".join(
132+
[
133+
self.name,
134+
self.password or "",
135+
self.uid or "",
136+
self.gid or "",
137+
",".join(self.folders),
138+
]
139+
)
140+
141+
@classmethod
142+
def with_keypair(
143+
cls,
144+
name: str,
145+
password: str | None = None,
146+
uid: str | None = None,
147+
gid: str | None = None,
148+
folders: list[str] | None = None,
149+
mount_dir: str | None = None,
150+
) -> SFTPUser:
151+
"""Construct a new SFTPUser with an auto-generated RSA keypair"""
152+
keypair = _generate_keypair()
153+
return SFTPUser(
154+
name=name,
155+
public_key=keypair.public_key,
156+
private_key=keypair.private_key,
157+
password=password,
158+
uid=uid,
159+
gid=gid,
160+
folders=folders,
161+
mount_dir=mount_dir,
162+
)
163+
164+
def __repr__(self) -> str:
165+
return (
166+
f"SFTPUser({self.name}, password={self.password}, uid={self.uid},"
167+
f" gid={self.gid}, folders={self.folders},"
168+
f" public_key_file={self.public_key_file},"
169+
f" private_key_file={self.private_key_file})"
170+
)
171+
172+
173+
class SFTPContainer(DockerContainer):
174+
"""Test container for an SFTP server.
175+
176+
Default configuration creates two users, ``basic:password`` and ``keypair``
177+
which has no password but should use the private key accessible at
178+
``my_container.users[1].private_key``.
179+
180+
**Users can only download from their root user folder, but can upload &
181+
download from any subfolder** (``upload/`` by default).
182+
183+
Options:
184+
185+
* ``users = [SFTPUser("jane", password="secret"), SFTPUser.with_keypair("ron")]`` \
186+
creates ``jane:secret`` or ``ron`` who uses the private key accessible at \
187+
``users[1].private_key``.
188+
189+
Simple example with basic auth:
190+
191+
.. doctest::
192+
193+
>>> import paramiko
194+
195+
>>> from testcontainers.sftp import SFTPContainer
196+
197+
>>> with SFTPContainer() as sftp_container:
198+
... host_ip = sftp_container.get_container_host_ip()
199+
... host_port = sftp_container.get_exposed_sftp_port()
200+
... ssh = paramiko.SSHClient()
201+
... ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
202+
... ssh.connect(host_ip, host_port, "basic", "password")
203+
... # ssh.get(...)
204+
... # ssh.listdir()
205+
... # ssh.chdir("upload")
206+
... # ssh.put(...)
207+
208+
Example with keypair auth:
209+
210+
.. doctest::
211+
212+
>>> import paramiko
213+
214+
>>> from testcontainers.sftp import SFTPContainer
215+
216+
>>> with SFTPContainer() as sftp_container:
217+
... host_ip = sftp_container.get_container_host_ip()
218+
... host_port = sftp_container.get_exposed_sftp_port()
219+
... ssh = paramiko.SSHClient()
220+
... ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
221+
... private_key_file = sftp_container.users[1].private_key_file
222+
... ssh.connect(host_ip, host_port, "keypair", key_filename=private_key_file)
223+
... # ssh.listdir()
224+
... # ssh.get(...)
225+
... # ssh.chdir("upload")
226+
... # ssh.put(...)
227+
"""
228+
229+
def __init__(
230+
self,
231+
image: str = "atmoz/sftp:alpine",
232+
port: int = 22,
233+
*,
234+
users: list[SFTPUser] | None = None,
235+
**kwargs: Any,
236+
) -> None:
237+
if users is None:
238+
users = [
239+
SFTPUser(name="basic", password="password"),
240+
SFTPUser.with_keypair(name="keypair"),
241+
]
242+
243+
super().__init__(image=image, **kwargs)
244+
self.port = port
245+
self.users = users
246+
247+
@property
248+
def _users_conf(self) -> str:
249+
return " ".join(user.conf for user in self.users)
250+
251+
def _configure(self) -> None:
252+
for user in self.users:
253+
if user.public_key_file is not None:
254+
self.with_volume_mapping(
255+
user.public_key_file,
256+
f"/home/{user.name}/.ssh/keys/{user.name}.pub",
257+
)
258+
if user.mount_dir is not None:
259+
self.with_volume_mapping(
260+
user.mount_dir,
261+
f"/home/{user.name}/",
262+
"rw",
263+
)
264+
self.with_env("SFTP_USERS", self._users_conf)
265+
self.with_exposed_ports(self.port)
266+
267+
def start(self) -> Self:
268+
super().start()
269+
wait_for_logs(self, f".*Server listening on 0.0.0.0 port {self.port}.*")
270+
return self
271+
272+
def get_exposed_sftp_port(self) -> int:
273+
return int(self.get_exposed_port(self.port))
274+
275+
276+
class _Keypair(NamedTuple):
277+
"""RSA keypair as bytes"""
278+
279+
private_key: bytes
280+
public_key: bytes
281+
282+
283+
def _generate_keypair() -> _Keypair:
284+
"""Generate RSA keypair as bytes in OpenSSH format."""
285+
private_key = rsa.generate_private_key(
286+
public_exponent=65537,
287+
key_size=4096,
288+
)
289+
private_key_bytes = private_key.private_bytes(
290+
encoding=serialization.Encoding.PEM,
291+
format=serialization.PrivateFormat.TraditionalOpenSSL,
292+
encryption_algorithm=serialization.NoEncryption(),
293+
)
294+
public_key_bytes = private_key.public_key().public_bytes(
295+
encoding=serialization.Encoding.OpenSSH, # paramiko flakiness fix
296+
format=serialization.PublicFormat.OpenSSH,
297+
)
298+
return _Keypair(
299+
private_key=private_key_bytes,
300+
public_key=public_key_bytes,
301+
)

modules/sftp/testcontainers/sftp/py.typed

Whitespace-only changes.

0 commit comments

Comments
 (0)