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
1 change: 1 addition & 0 deletions .licenserc.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,5 @@ header:
- 'LICENSE'
- 'pyproject.toml'
- 'lib/**'
- 'tests/integration/test-ssh-key'
comment: on-failure
5 changes: 5 additions & 0 deletions charmcraft.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,11 @@ config:
This user must be a member of ~git-ubuntu-import.
default: "git-ubuntu-bot"
type: string
lpuser_ssh_key:
description: |
An ssh private key that matches with a public key associated with the
lpuser account on Launchpad.
type: secret
node_id:
description: |
The ID of this git-ubuntu operator node, must be unique in the network.
Expand Down
33 changes: 31 additions & 2 deletions src/charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@ def _num_workers(self) -> int:
return num_workers
return 0

@property
def _lpuser_ssh_key(self) -> str | None:
try:
secret_id = str(self.config["lpuser_ssh_key"])
ssh_key_secret = self.model.get_secret(id=secret_id)
ssh_key_data = ssh_key_secret.get_content().get("sshkey")

if ssh_key_data is not None:
return str(ssh_key_data)

except (KeyError, ops.SecretNotFoundError, ops.model.ModelError):
pass

return None

def _refresh_importer_node(self) -> None:
"""Remove old and install new git-ubuntu services."""
self.unit.status = ops.MaintenanceStatus("Refreshing git-ubuntu services.")
Expand All @@ -103,13 +118,27 @@ def _refresh_importer_node(self) -> None:
self.unit.status = ops.BlockedStatus("Failed to remove old git-ubuntu services.")
return

will_publish = self._is_publishing_active
ssh_key_data = self._lpuser_ssh_key

if will_publish:
if ssh_key_data is None:
logger.warning(
"ssh private key unavailable, blocking publishing to Launchpad for now."
)
will_publish = False
else:
usr.update_ssh_private_key(
GIT_UBUNTU_SYSTEM_USER_USERNAME, GIT_UBUNTU_USER_HOME_DIR, ssh_key_data
)

if self._is_primary:
if not node.setup_primary_node(
GIT_UBUNTU_USER_HOME_DIR,
self._node_id,
self._num_workers,
GIT_UBUNTU_SYSTEM_USER_USERNAME,
self._is_publishing_active,
will_publish,
self._controller_port,
):
self.unit.status = ops.BlockedStatus("Failed to install git-ubuntu services.")
Expand All @@ -121,7 +150,7 @@ def _refresh_importer_node(self) -> None:
self._node_id,
self._num_workers,
GIT_UBUNTU_SYSTEM_USER_USERNAME,
self._is_publishing_active,
will_publish,
self._controller_port,
self._controller_ip,
):
Expand Down
36 changes: 28 additions & 8 deletions src/git_ubuntu.py
Original file line number Diff line number Diff line change
Expand Up @@ -338,16 +338,22 @@ def destroy_services(service_folder: str) -> bool:
Returns:
True if all services were deleted successfully, False otherwise.
"""
service_list = _get_services_list(service_folder)

if service_list is None:
return False

service_folder_path = pathops.LocalPath(service_folder)
services_removed = False
systemd_service_path = pathops.LocalPath("/etc/systemd/system/")

services_folder_services_removed = False
systemd_folder_services_removed = False

# Remove from service folder
try:
for service_file in service_folder_path.iterdir():
if service_file.suffix == ".service":
service_file.unlink(missing_ok=True)
else:
logger.debug("Skipping removal of non-service file %s", service_file.name)
services_removed = True
for service in service_list:
pathops.LocalPath(service_folder_path, service).unlink(missing_ok=True)
services_folder_services_removed = True
except NotADirectoryError:
logger.error("The provided location %s is not a directory.", service_folder)
except PermissionError as e:
Expand All @@ -357,4 +363,18 @@ def destroy_services(service_folder: str) -> bool:
except (IOError, OSError) as e:
logger.error("Failed to remove a service file due to error: %s", str(e))

return services_removed
# Remove from /etc/systemd/system
try:
for service in service_list:
pathops.LocalPath(systemd_service_path, service).unlink(missing_ok=True)
systemd_folder_services_removed = True
except NotADirectoryError:
logger.error("/etc/systemd/system is not a directory.")
except PermissionError as e:
logger.error("Failed to start services due to permission issues: %s", str(e))
except FileNotFoundError:
logger.error("/etc/systemd/system folder not found.")
except (IOError, OSError) as e:
logger.error("Failed to remove a service file due to error: %s", str(e))

return services_folder_services_removed and systemd_folder_services_removed
61 changes: 61 additions & 0 deletions src/user_management.py
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,67 @@ def setup_git_ubuntu_user_files(user: str, home_dir: str, git_ubuntu_source_url:
return _write_python_keyring_config_file(user, home_dir)


def update_ssh_private_key(user: str, home_dir: str, ssh_key_data: str) -> bool:
"""Create or refresh the .ssh/id private key file for launchpad access.

Args:
user: The git-ubuntu user.
home_dir: The home directory for the user.
ssh_key_data: The private key data.

Returns:
True if directory and file creation succeeded, False otherwise.
"""
ssh_key_file = pathops.LocalPath(home_dir, ".ssh/id")

parent_dir = ssh_key_file.parent
ssh_dir_success = False

try:
parent_dir.mkdir(mode=0o700, parents=True, user=user, group=user)
ssh_dir_success = True
except FileExistsError:
logger.info("ssh directory %s already exists.", parent_dir.as_posix())
ssh_dir_success = True
except NotADirectoryError:
logger.error(
"User ssh directory location %s already exists as a file.", parent_dir.as_posix()
)
except PermissionError:
logger.error(
"Unable to create user ssh directory %s: permission denied.",
parent_dir.as_posix(),
)
except LookupError:
logger.error(
"Unable to create user ssh directory %s: unknown user/group %s",
parent_dir.as_posix(),
user,
)

if not ssh_dir_success:
return False

key_success = False

try:
ssh_key_file.write_text(
ssh_key_data,
mode=0o600,
user=user,
group=user,
)
key_success = True
except (FileNotFoundError, NotADirectoryError) as e:
logger.error("Failed to create ssh private key due to directory issues: %s", str(e))
except LookupError as e:
logger.error("Failed to create ssh private key due to issues with root user: %s", str(e))
except PermissionError as e:
logger.error("Failed to create ssh private key due to permission issues: %s", str(e))

return key_success


def set_snap_homedirs(home_dir: str) -> bool:
"""Allow snaps to run for a user with a given home directory.

Expand Down
2 changes: 1 addition & 1 deletion tests/integration/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ def charm():
@pytest.fixture(scope="module")
def app(juju: jubilant.Juju, charm: Path):
"""Deploy git-ubuntu charm with publishing off."""
juju.deploy(f"./{charm}", config={"publish": False})
juju.deploy(f"./{charm}")
juju.wait(lambda status: jubilant.all_active(status, APP_NAME))

yield APP_NAME
49 changes: 49 additions & 0 deletions tests/integration/test-ssh-key
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAACFwAAAAdzc2gtcn
NhAAAAAwEAAQAAAgEAwt4U9t0POkyRdJaFwkSeWLy9+ZBx9NQoA/rLk5M+lIEuOPOSqD9q
Bmfi/AipkbEFNIrze0fkSvGyTeL1vEWID4OqLkIrzc0TFD3yyHPSLHw7FoxjdsP3bLin/F
75nxK/3wwuPmCEh4e7De7w7xzeGs0Z/xBEXmjXJBNkrs0qJPhwklzCs9eOhQZuoMOZ1NTU
GlmVvU6r3PMO+AEdhZl2x0ruvh5hMXuDIe6TjcWCKxCEO+0NbFjt+UcZYMXDRm1XzIlwVa
r7h1ugGBx66Tz6hR7eqUQd76WInomcjyc5jx9LEm1tZOdYOGejXbTGO2C9+nNKNUlbkqaL
BHcp4voToSUAiTQliLFVaYtd8KC9DkTV0YamaORaux1vXuUttk57It9BrVwR4Hm6PItCKB
h+nb+ryQGUQVvO5RnzT1m5GuaEi75OlaY1aoL2/z8q7WkHftjiJV3evpZ5kH7C3nzhx5Fj
K6zXeXR+Cg2CAoO204N4pF4IQLBkDA22cNXdpZiyCNGiAJgF7jbK6elKgyebZ6FDsophdD
qd7vUVFuORYHlw/tcbkTGwOC4yMg9Mb6zovc2+FoZH5Nn2VifFEPV8mgvbW+tTYD/QtAK2
tcqBQdYHZsLGY7ugIv615hoQUqahIObyw2i42gUvsG2gR7pPk+oQzsv+ZBrR2Q6oeTN7es
sAAAdIeQ4SyXkOEskAAAAHc3NoLXJzYQAAAgEAwt4U9t0POkyRdJaFwkSeWLy9+ZBx9NQo
A/rLk5M+lIEuOPOSqD9qBmfi/AipkbEFNIrze0fkSvGyTeL1vEWID4OqLkIrzc0TFD3yyH
PSLHw7FoxjdsP3bLin/F75nxK/3wwuPmCEh4e7De7w7xzeGs0Z/xBEXmjXJBNkrs0qJPhw
klzCs9eOhQZuoMOZ1NTUGlmVvU6r3PMO+AEdhZl2x0ruvh5hMXuDIe6TjcWCKxCEO+0NbF
jt+UcZYMXDRm1XzIlwVar7h1ugGBx66Tz6hR7eqUQd76WInomcjyc5jx9LEm1tZOdYOGej
XbTGO2C9+nNKNUlbkqaLBHcp4voToSUAiTQliLFVaYtd8KC9DkTV0YamaORaux1vXuUttk
57It9BrVwR4Hm6PItCKBh+nb+ryQGUQVvO5RnzT1m5GuaEi75OlaY1aoL2/z8q7WkHftji
JV3evpZ5kH7C3nzhx5FjK6zXeXR+Cg2CAoO204N4pF4IQLBkDA22cNXdpZiyCNGiAJgF7j
bK6elKgyebZ6FDsophdDqd7vUVFuORYHlw/tcbkTGwOC4yMg9Mb6zovc2+FoZH5Nn2VifF
EPV8mgvbW+tTYD/QtAK2tcqBQdYHZsLGY7ugIv615hoQUqahIObyw2i42gUvsG2gR7pPk+
oQzsv+ZBrR2Q6oeTN7essAAAADAQABAAACAEI5mFVXRkQRXoOJXjbp/Ah7SHLmoJvMeDb2
BjaGnYd5qXxIZwiP2JgJuU3fIcW2K+cx69JzWcYQgwDOR6Yu2TEwlWb4LQvZjIjeDsMJ/Z
0JsUkV9/2WVuGXlduCaYIirJuAd7zSL2gh/DOdhzs0C9V+671PyXRuW+NSRe7UIeUDm7pa
FP9qm/BggbtYDlES7WxxEp8N/AFwhx9IvcA4KfJL6HwrNevLpoKjInlVbMn26IG3K88tH9
8ORzUosJcgNOz5rngmzimvvWZEU48tnpQcLB7JXAUkNBxw9wh9k6WsxGYkUz+uEcDFk/MW
KyJl29qgUNFwN1NND0S6vLz7MS0EYlp5O2CiY3ujD5I45Hxptm1IqpjIbKtqunoI2IZBxd
IfeCpxzF/6vwLRYKl4ZUKXggy1Ait7GV5cwRfrFX1K8UJWHTVTPgeI8wXLxB+eApsH9wb4
CONFywYFxs7QXSoizJw04ofyZ2lYObgB4EvKc3j8ql7LKxlNjphzX+wSo7Re2/W9tXmMBn
KYXPbxQNjoEd/EleBJhD7AB0S8pQ5QfYO6sdTdSWxgVmGF/7msxWEjtLm+95g/uW0PV5zW
vqNCVOKyYysbmjSMhvc1b7KdPBi4KjORj6004rNvbqG0SleEUPz7lfsDoczfgUWIUg173z
QupdaowJ4JkdTtX/6RAAABADIc5M6ppGtaK29kXw5PJ/dFlUjljiXnEXePL3jKxX5mhy11
UgiHIXd6Pgjn+XMlqjSoqsPV3DU6DvmJHTt5i2P2gnWqWC5kMnKbkGdPKJgMyL9QArH94v
+PrSO7RtTsXb5WWlTgw/ibE9TBvXLnS3EGLB7sD3rFbmBrYZWha/eRO6LlfXJAnSgxJf4Q
lVy9wEF2Ie9h4asUn3v/TRr7vGmGnEcSGThq/pehkWiPW10pks3b/SO6fWUrKUCxTAMnj5
SZlt8FolNl9FMPmO/IJ3am97YEomf9YvgEdHmcrLF7VJGbQwYxzwLP5gMJ8HYeB/ft4y0F
NU6BR04yEMvOceUAAAEBAOKwp5SorEhql2PsnLDLVC56EuDTZSAO/shqBTU5QJ3dsGzRyX
aR0pNpjyGKIPfNDopiPnmF0D1I96PkhXuDgH8U0TSqOxLcf0F7hawXbkLGo6MyyW2DtVJ/
3ud9c5xAzjvgvxoAUw8TDym5vCBHQpF6x/KPS37SMmvd0XmK6DjkPdlFYM3LIO+9BN8xQf
Y91w67FvXjzk1xsGQKx+C9KkEnkuow6n4v93C2v9B509VdY/pjJbvFLNDbJ4kk2Z0EfDFy
P194SKsV7fXa8FExpZM0Gy353UUuIVJVQuCm6hLauC5plH7022yavaXg8p15ULq6XNo3AH
2cPon7O6pjjtsAAAEBANwQHUI0Ra4mZlWK7ulGfGEpw1v1oSRuoh2QR+5fVGfXBBQOesdQ
NRTQcCsiYLYUEZ8mrWjJ1ZdE8OQFdY1Ao4fzzY5RzfzCrAoACb8B03TS4CJSm+5VlaJEyT
357IQo7MhyU6Muofl/JLWHnlnSxlIPqQFuN5Ue1fa981p09rYY/IgSu9+j+YeJIEyFkg/k
QtJ9HZhGxAq9onamXAvWPa9HxQk20MGfsV3oHLJtKqBMPn9mHno7jp0Azk14c1z1N5sWqL
Tp9yRNn/q875+cK9s12GQj16or1p8zYFVbzz4UvivPIGO+/NvwU1fzCyRY6UMz8IMkp1L9
TvRgek7BrtEAAAAQdXNlckBleGFtcGxlLmNvbQECAw==
-----END OPENSSH PRIVATE KEY-----
26 changes: 26 additions & 0 deletions tests/integration/test_charm.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,33 @@ def test_installed_dump_files(app: str, juju: jubilant.Juju):
app: The app in charge of this unit.
juju: The juju model in charge of the app.
"""
juju.wait(jubilant.all_active)

debian_keyring_status = juju.ssh(
f"{app}/0", "test -f /etc/git-ubuntu/debian-archive-keyring.gpg | echo $?", ""
).strip()
assert debian_keyring_status == "0"


def test_update_config_with_ssh_key(app: str, juju: jubilant.Juju):
"""Wait on other tests, then update config with an ssh key and test that it exists.

Args:
app: The app in charge of this unit.
juju: The juju model in charge of the app.
"""
juju.wait(jubilant.all_active)
sleep(60)

with open("tests/integration/test-ssh-key", "r") as file:
file_content = file.read()

secret_uri = juju.add_secret("lpuser-ssh-key", {"sshkey": file_content})
juju.grant_secret("lpuser-ssh-key", app)

juju.config(app, {"lpuser_ssh_key": secret_uri})
juju.wait(jubilant.all_active)

ssh_key = juju.ssh(f"{app}/0", "sudo -u git-ubuntu cat /var/local/git-ubuntu/.ssh/id", "")

assert file_content == ssh_key
Loading