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
59 changes: 59 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
lint-format:
name: 🧹 Lint and Format
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"

- name: Lint with Ruff
run: uv run ruff check .

- name: Format with Ruff
run: uv run ruff format --check .

test:
name: 🧪 Test and Coverage
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Install uv
uses: astral-sh/setup-uv@v5

- name: "Set up Python"
uses: actions/setup-python@v5
with:
python-version-file: "pyproject.toml"

- name: Run tests
run: |
uv run pytest --cov --cov-branch --cov-report=xml --junitxml=junit.xml -o junit_family=legacy

- name: Upload coverage reports to Codecov
uses: codecov/codecov-action@v5
with:
token: ${{ secrets.CODECOV_TOKEN }}

- name: Upload test results to Codecov
if: ${{ !cancelled() }}
uses: codecov/test-results-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
coverage.xml
.coverage
htmlcov/
junit.xml
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
![CI](https://github.com/mikaeld/secrets-manager/workflows/CI/badge.svg)
[![codecov](https://codecov.io/gh/mikaeld/secrets-manager/branch/main/graph/badge.svg)](https://codecov.io/gh/<username>/secrets-manager)

![tui.png](imgs/tui.png)

# Secrets Manager
Expand Down
19 changes: 11 additions & 8 deletions app.py
Original file line number Diff line number Diff line change
@@ -1,16 +1,15 @@
from google.cloud import secretmanager
from google.api_core.exceptions import GoogleAPICallError
from google.cloud import secretmanager
from textual import work
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical
from textual.reactive import reactive
from textual.widgets import Header, Footer, Tree, DataTable, Input
from textual.markup import escape
from textual.widgets import DataTable, Footer, Header, Input, Tree

from secrets_manager.utils.gcp import search_gcp_projects, list_secrets, get_secret_versions
from secrets_manager.utils.helpers import sanitize_project_id_search, format_error_message
from secrets_manager.models.gcp_projects import GCPProject
from secrets_manager.utils.gcp import get_secret_versions, list_secrets, search_gcp_projects
from secrets_manager.utils.helpers import format_error_message, sanitize_project_id_search


class SecretsManager(App):
Expand Down Expand Up @@ -88,7 +87,8 @@ def _do_search(self, search_term: str) -> None:
except Exception as e:
self.notify(
f"Failed to search projects: {format_error_message(str(e), 200)}",
severity="error", markup=False,
severity="error",
markup=False,
)
return None

Expand All @@ -114,7 +114,9 @@ def _load_secrets(self) -> None:
# First secret in list is always the latest secret
latest_version = secret_versions[0]
latest_version_number = latest_version.name.split("/")[-1]
table.add_row(secret_name, latest_version_number, latest_version.state.name, create_time)
table.add_row(
secret_name, latest_version_number, latest_version.state.name, create_time
)

except GoogleAPICallError as e:
self.notify(
Expand All @@ -124,7 +126,8 @@ def _load_secrets(self) -> None:
except Exception as e:
self.notify(
f"Failed to load secrets: {format_error_message(str(e), 200)}",
severity="error", markup=False,
severity="error",
markup=False,
)


Expand Down
Binary file modified imgs/tui.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
39 changes: 39 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ dependencies = [
[dependency-groups]
dev = [
"pytest>=8.3.5",
"pytest-cov>=6.1.1",
"ruff>=0.11.7",
]

Expand All @@ -24,3 +25,41 @@ pythonpath = ["."]
[tool.pytest.ini_options]
addopts = "-v --tb=short"
testpaths = ["tests"]

[tool.ruff]
target-version = "py313"
line-length = 100
fix = true

[tool.ruff.lint]
select = [
"E", # pycodestyle errors
"W", # pycodestyle warnings
"F", # pyflakes
"I", # isort
"C", # flake8-comprehensions
"B", # flake8-bugbear
"UP", # pyupgrade
]
ignore = [
"E501", # line too long, handled by formatter
]

[tool.ruff.format]
quote-style = "double"
indent-style = "space"
skip-magic-trailing-comma = false
line-ending = "auto"

[tool.coverage.run]
source = ["secrets_manager"]
omit = ["tests/*", "setup.py"]

[tool.coverage.report]
exclude_lines = [
"pragma: no cover",
"def __repr__",
"if __name__ == .__main__.:",
"raise NotImplementedError",
"pass",
]
11 changes: 4 additions & 7 deletions secrets_manager/models/gcp_projects.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
from google.cloud.resourcemanager_v3.types import Project
from pydantic import BaseModel, Field, field_validator
from typing import Dict, Optional


class GCPProject(BaseModel):
Expand All @@ -11,7 +10,7 @@ class GCPProject(BaseModel):
name: str = Field(
description="The unique resource name of the project (e.g., projects/415104041262)"
)
parent: Optional[str] = Field(
parent: str | None = Field(
default=None,
description="Reference to parent resource (e.g., organizations/123 or folders/876)",
)
Expand All @@ -25,7 +24,7 @@ class GCPProject(BaseModel):
max_length=30,
description="User-assigned display name of the project",
)
labels: Dict[str, str] = Field(
labels: dict[str, str] = Field(
default_factory=dict, description="Key-value pairs for project labels"
)

Expand Down Expand Up @@ -53,7 +52,7 @@ def __str__(self):
return f"GCPProject(display_name={self.display_name}, name={self.name}, project_id={self.project_id})"

@field_validator("labels")
def validate_labels(cls, v: Dict[str, str]) -> Dict[str, str]:
def validate_labels(cls, v: dict[str, str]) -> dict[str, str]:
"""Validate label keys and values according to GCP requirements."""
for key, value in v.items():
# Validate key format
Expand All @@ -63,9 +62,7 @@ def validate_labels(cls, v: Dict[str, str]) -> Dict[str, str]:
raise ValueError(f"Label key too long: {key}")

# Validate value format
if not value.isalnum() and not all(
c in "-_" for c in value if not c.isalnum()
):
if not value.isalnum() and not all(c in "-_" for c in value if not c.isalnum()):
raise ValueError(f"Invalid label value format: {value}")
if len(value) > 63:
raise ValueError(f"Label value too long: {value}")
Expand Down
13 changes: 7 additions & 6 deletions secrets_manager/utils/gcp.py
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import json

from google.cloud import resourcemanager_v3
from google.cloud import secretmanager
from google.cloud import resourcemanager_v3, secretmanager
from google.cloud.secretmanager_v1.services.secret_manager_service.pagers import (
ListSecretsPager,
)

from secrets_manager.models.gcp_projects import GCPProject


def list_secrets(gcp_project: GCPProject) -> ListSecretsPager:
client = secretmanager.SecretManagerServiceClient()
parent = f"projects/{gcp_project.project_id}"
return client.list_secrets(request={"parent": parent})


def get_secret_versions(secret: secretmanager.Secret, show_deleted: bool = False) -> list[
secretmanager.SecretVersion]:
def get_secret_versions(
secret: secretmanager.Secret, show_deleted: bool = False
) -> list[secretmanager.SecretVersion]:
"""
Get all versions of a secret from GCP Secret Manager.

Expand All @@ -31,8 +32,7 @@ def get_secret_versions(secret: secretmanager.Secret, show_deleted: bool = False

# List all versions of the secret
request = secretmanager.ListSecretVersionsRequest(
parent=parent,
filter="state!=DESTROYED" if not show_deleted else None
parent=parent, filter="state!=DESTROYED" if not show_deleted else None
)

versions = []
Expand All @@ -41,6 +41,7 @@ def get_secret_versions(secret: secretmanager.Secret, show_deleted: bool = False

return versions


def get_secret_version_value(secret_version: secretmanager.SecretVersion) -> dict:
"""
Get the value of a specific secret version.
Expand Down
11 changes: 4 additions & 7 deletions secrets_manager/utils/helpers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from textual.markup import escape

def format_error_message(error_message: str, max_length: int | None = None) -> str:
"""
Format the error message for display.
Expand Down Expand Up @@ -31,9 +29,7 @@ def sanitize_project_id_search(search_term: str) -> str:
# Replace spaces and invalid characters with hyphens
# Keep only allowed characters: lowercase letters, numbers, and hyphens
sanitized = "".join(
c if c.isalnum() or c == "-" else "-"
for c in sanitized
if c.isalnum() or c in "-_ "
c if c.isalnum() or c == "-" else "-" for c in sanitized if c.isalnum() or c in "-_ "
)

# Remove consecutive hyphens
Expand All @@ -42,8 +38,9 @@ def sanitize_project_id_search(search_term: str) -> str:

return sanitized

if __name__ == '__main__':

if __name__ == "__main__":
print(sanitize_project_id_search(""))
print(sanitize_project_id_search("-"))
print(sanitize_project_id_search("--"))
print(sanitize_project_id_search(" "))
print(sanitize_project_id_search(" "))
18 changes: 9 additions & 9 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import pytest
from google.cloud.resourcemanager_v3.types import Project
from google.cloud import secretmanager
from google.cloud.resourcemanager_v3.types import Project


@pytest.fixture
def mock_project_data():
Expand All @@ -9,27 +10,26 @@ def mock_project_data():
"project_id": "test-project-123",
"display_name": "Test Project",
"parent": "organizations/456",
"labels": {"environment": "test", "team": "platform"}
"labels": {"environment": "test", "team": "platform"},
}


@pytest.fixture
def mock_gcp_project(mock_project_data):
return Project(
name=mock_project_data["name"],
project_id=mock_project_data["project_id"],
display_name=mock_project_data["display_name"],
parent=mock_project_data["parent"],
labels=mock_project_data["labels"]
labels=mock_project_data["labels"],
)


@pytest.fixture
def mock_secret():
return secretmanager.Secret(
name="projects/123456789/secrets/test-secret"
)
return secretmanager.Secret(name="projects/123456789/secrets/test-secret")


@pytest.fixture
def mock_secret_version():
return secretmanager.SecretVersion(
name="projects/123456789/secrets/test-secret/versions/1"
)
return secretmanager.SecretVersion(name="projects/123456789/secrets/test-secret/versions/1")
Loading