pulice 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pulice-0.1.0/PKG-INFO +128 -0
- pulice-0.1.0/README.md +101 -0
- pulice-0.1.0/pyproject.toml +112 -0
- pulice-0.1.0/src/pulice/__init__.py +19 -0
- pulice-0.1.0/src/pulice/admin/__init__.py +13 -0
- pulice-0.1.0/src/pulice/admin/__main__.py +8 -0
- pulice-0.1.0/src/pulice/admin/app.py +90 -0
- pulice-0.1.0/src/pulice/admin/data.py +112 -0
- pulice-0.1.0/src/pulice/admin/screens/__init__.py +15 -0
- pulice-0.1.0/src/pulice/admin/screens/dashboard.py +61 -0
- pulice-0.1.0/src/pulice/admin/screens/stacks.py +86 -0
- pulice-0.1.0/src/pulice/admin/screens/system.py +67 -0
- pulice-0.1.0/src/pulice/admin/screens/tasks.py +92 -0
- pulice-0.1.0/src/pulice/admin/screens/tenants.py +78 -0
- pulice-0.1.0/src/pulice/admin/serve.py +45 -0
- pulice-0.1.0/src/pulice/admin/styles/app.tcss +39 -0
- pulice-0.1.0/src/pulice/admin/widgets/__init__.py +6 -0
- pulice-0.1.0/src/pulice/admin/widgets/confirm.py +48 -0
- pulice-0.1.0/src/pulice/admin/widgets/stat_card.py +49 -0
- pulice-0.1.0/src/pulice/api/__init__.py +25 -0
- pulice-0.1.0/src/pulice/api/models.py +82 -0
- pulice-0.1.0/src/pulice/api/routes_stacks.py +95 -0
- pulice-0.1.0/src/pulice/api/routes_tasks.py +48 -0
- pulice-0.1.0/src/pulice/api/routes_tenants.py +65 -0
- pulice-0.1.0/src/pulice/cli/__init__.py +15 -0
- pulice-0.1.0/src/pulice/cli/app.py +82 -0
- pulice-0.1.0/src/pulice/cli/registry.py +558 -0
- pulice-0.1.0/src/pulice/core/__init__.py +50 -0
- pulice-0.1.0/src/pulice/core/base.py +49 -0
- pulice-0.1.0/src/pulice/core/celery_backend.py +89 -0
- pulice-0.1.0/src/pulice/core/controllers.py +130 -0
- pulice-0.1.0/src/pulice/core/managed.py +172 -0
- pulice-0.1.0/src/pulice/core/protocol.py +49 -0
- pulice-0.1.0/src/pulice/core/stack.py +743 -0
- pulice-0.1.0/src/pulice/core/task_definitions.py +211 -0
- pulice-0.1.0/src/pulice/core/tasks.py +222 -0
- pulice-0.1.0/src/pulice/py.typed +0 -0
- pulice-0.1.0/src/pulice/worker.py +12 -0
pulice-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pulice
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A framework for managing infrastructure-as-code components via Pulumi with tenant isolation, async execution, and pluggable backends.
|
|
5
|
+
Author: mk.fshr
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Classifier: Intended Audience :: Developers
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
11
|
+
Requires-Dist: pulumi>=3.215.0
|
|
12
|
+
Requires-Dist: pydantic>=2.12.5
|
|
13
|
+
Requires-Dist: typer>=0.21.1
|
|
14
|
+
Requires-Dist: textual>=3.0.0 ; extra == 'admin'
|
|
15
|
+
Requires-Dist: textual-serve>=1.1.0 ; extra == 'admin'
|
|
16
|
+
Requires-Dist: fastapi>=0.115.0 ; extra == 'api'
|
|
17
|
+
Requires-Dist: uvicorn[standard]>=0.30.0 ; extra == 'api'
|
|
18
|
+
Requires-Dist: huey>=2.5.0 ; extra == 'api'
|
|
19
|
+
Requires-Dist: pulumi-aws>=7.15.0 ; extra == 'aws'
|
|
20
|
+
Requires-Dist: celery[redis]>=5.4.0 ; extra == 'celery'
|
|
21
|
+
Requires-Python: >=3.13
|
|
22
|
+
Provides-Extra: admin
|
|
23
|
+
Provides-Extra: api
|
|
24
|
+
Provides-Extra: aws
|
|
25
|
+
Provides-Extra: celery
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# Pulice
|
|
29
|
+
|
|
30
|
+
A Python framework for managing infrastructure-as-code components via [Pulumi](https://www.pulumi.com/) with tenant isolation, async execution, and pluggable backends.
|
|
31
|
+
|
|
32
|
+
## Features
|
|
33
|
+
|
|
34
|
+
- **Component model** — Define cloud resources as `ManagedComponent` subclasses with Pydantic-validated inputs
|
|
35
|
+
- **Tenant isolation** — Named boundaries ensure stacks belonging to different environments never collide
|
|
36
|
+
- **9 lifecycle operations** — create, read, update, delete, refresh, list, status, export, import
|
|
37
|
+
- **CLI + HTTP API** — Synchronous CLI for interactive use; async FastAPI server for automation
|
|
38
|
+
- **Pluggable task backends** — Huey (SQLite, zero-dep) or Celery (Redis, distributed)
|
|
39
|
+
- **Passphrase-protected stacks** — scrypt-hashed passphrases validated before any Pulumi call
|
|
40
|
+
- **Advisory locking** — Prevents concurrent mutating operations on the same stack
|
|
41
|
+
- **Provider-agnostic** — Works with any Pulumi provider (AWS, GCP, Azure, Kubernetes, etc.)
|
|
42
|
+
|
|
43
|
+
## Installation
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
pip install pulice
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
With optional extras:
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
pip install pulice[api] # FastAPI server + Huey worker
|
|
53
|
+
pip install pulice[celery] # Celery backend for distributed deployments
|
|
54
|
+
pip install pulice[aws] # AWS provider
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Quick Start
|
|
58
|
+
|
|
59
|
+
Define a component:
|
|
60
|
+
|
|
61
|
+
```python
|
|
62
|
+
from pulice import ComponentArgs, ManagedComponent
|
|
63
|
+
from pydantic import Field
|
|
64
|
+
import pulumi
|
|
65
|
+
import pulumi_aws as aws
|
|
66
|
+
|
|
67
|
+
class BucketArgs(ComponentArgs):
|
|
68
|
+
region: str = Field(description="AWS region")
|
|
69
|
+
|
|
70
|
+
class Bucket(ManagedComponent):
|
|
71
|
+
args_model = BucketArgs
|
|
72
|
+
|
|
73
|
+
def __init__(self, name: str, args: BucketArgs, opts=None, **kwargs):
|
|
74
|
+
super().__init__("pulice:aws:Bucket", name, {}, opts)
|
|
75
|
+
aws.s3.BucketV2(f"{name}-bucket", opts=pulumi.ResourceOptions(parent=self))
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Register and run:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from typer import Typer
|
|
82
|
+
from pulice import PuliceCLI
|
|
83
|
+
|
|
84
|
+
app = Typer()
|
|
85
|
+
cli = PuliceCLI(app)
|
|
86
|
+
cli.register_component(Bucket, name="bucket")
|
|
87
|
+
|
|
88
|
+
if __name__ == "__main__":
|
|
89
|
+
cli()
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Use it:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pulice tenant create --name dev
|
|
96
|
+
pulice bucket create --name my-data --region eu-west-1 --tenant dev --passphrase secret
|
|
97
|
+
pulice bucket list --tenant dev
|
|
98
|
+
pulice bucket delete --stack-reference <ref> --tenant dev --passphrase secret
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## HTTP API
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
pip install pulice[api]
|
|
105
|
+
uvicorn pulice.api:create_api --factory --host 0.0.0.0 --port 8000
|
|
106
|
+
huey_consumer pulice.worker.huey -w 2 -k process
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Submit operations asynchronously:
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
curl -X POST http://localhost:8000/stacks/operations \
|
|
113
|
+
-H "Content-Type: application/json" \
|
|
114
|
+
-d '{"component_class": "myapp.Bucket", "operation": "create", "tenant": "dev", "passphrase": "secret", "args": {"name": "my-data", "region": "eu-west-1"}}'
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Documentation
|
|
118
|
+
|
|
119
|
+
Full documentation is available at the [project site](https://your-org.github.io/pulice/) or can be built locally:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
pip install pulice[docs]
|
|
123
|
+
zensical serve
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
## License
|
|
127
|
+
|
|
128
|
+
[MIT](LICENSE) — Mike Fischer and Dani Vela Calderón
|
pulice-0.1.0/README.md
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
# Pulice
|
|
2
|
+
|
|
3
|
+
A Python framework for managing infrastructure-as-code components via [Pulumi](https://www.pulumi.com/) with tenant isolation, async execution, and pluggable backends.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **Component model** — Define cloud resources as `ManagedComponent` subclasses with Pydantic-validated inputs
|
|
8
|
+
- **Tenant isolation** — Named boundaries ensure stacks belonging to different environments never collide
|
|
9
|
+
- **9 lifecycle operations** — create, read, update, delete, refresh, list, status, export, import
|
|
10
|
+
- **CLI + HTTP API** — Synchronous CLI for interactive use; async FastAPI server for automation
|
|
11
|
+
- **Pluggable task backends** — Huey (SQLite, zero-dep) or Celery (Redis, distributed)
|
|
12
|
+
- **Passphrase-protected stacks** — scrypt-hashed passphrases validated before any Pulumi call
|
|
13
|
+
- **Advisory locking** — Prevents concurrent mutating operations on the same stack
|
|
14
|
+
- **Provider-agnostic** — Works with any Pulumi provider (AWS, GCP, Azure, Kubernetes, etc.)
|
|
15
|
+
|
|
16
|
+
## Installation
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
pip install pulice
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
With optional extras:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
pip install pulice[api] # FastAPI server + Huey worker
|
|
26
|
+
pip install pulice[celery] # Celery backend for distributed deployments
|
|
27
|
+
pip install pulice[aws] # AWS provider
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Quick Start
|
|
31
|
+
|
|
32
|
+
Define a component:
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from pulice import ComponentArgs, ManagedComponent
|
|
36
|
+
from pydantic import Field
|
|
37
|
+
import pulumi
|
|
38
|
+
import pulumi_aws as aws
|
|
39
|
+
|
|
40
|
+
class BucketArgs(ComponentArgs):
|
|
41
|
+
region: str = Field(description="AWS region")
|
|
42
|
+
|
|
43
|
+
class Bucket(ManagedComponent):
|
|
44
|
+
args_model = BucketArgs
|
|
45
|
+
|
|
46
|
+
def __init__(self, name: str, args: BucketArgs, opts=None, **kwargs):
|
|
47
|
+
super().__init__("pulice:aws:Bucket", name, {}, opts)
|
|
48
|
+
aws.s3.BucketV2(f"{name}-bucket", opts=pulumi.ResourceOptions(parent=self))
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Register and run:
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from typer import Typer
|
|
55
|
+
from pulice import PuliceCLI
|
|
56
|
+
|
|
57
|
+
app = Typer()
|
|
58
|
+
cli = PuliceCLI(app)
|
|
59
|
+
cli.register_component(Bucket, name="bucket")
|
|
60
|
+
|
|
61
|
+
if __name__ == "__main__":
|
|
62
|
+
cli()
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Use it:
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
pulice tenant create --name dev
|
|
69
|
+
pulice bucket create --name my-data --region eu-west-1 --tenant dev --passphrase secret
|
|
70
|
+
pulice bucket list --tenant dev
|
|
71
|
+
pulice bucket delete --stack-reference <ref> --tenant dev --passphrase secret
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
## HTTP API
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
pip install pulice[api]
|
|
78
|
+
uvicorn pulice.api:create_api --factory --host 0.0.0.0 --port 8000
|
|
79
|
+
huey_consumer pulice.worker.huey -w 2 -k process
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Submit operations asynchronously:
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
curl -X POST http://localhost:8000/stacks/operations \
|
|
86
|
+
-H "Content-Type: application/json" \
|
|
87
|
+
-d '{"component_class": "myapp.Bucket", "operation": "create", "tenant": "dev", "passphrase": "secret", "args": {"name": "my-data", "region": "eu-west-1"}}'
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
## Documentation
|
|
91
|
+
|
|
92
|
+
Full documentation is available at the [project site](https://your-org.github.io/pulice/) or can be built locally:
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
pip install pulice[docs]
|
|
96
|
+
zensical serve
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## License
|
|
100
|
+
|
|
101
|
+
[MIT](LICENSE) — Mike Fischer and Dani Vela Calderón
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pulice"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "A framework for managing infrastructure-as-code components via Pulumi with tenant isolation, async execution, and pluggable backends."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{name = "mk.fshr"},
|
|
8
|
+
]
|
|
9
|
+
license = "MIT"
|
|
10
|
+
requires-python = ">=3.13"
|
|
11
|
+
dependencies = [
|
|
12
|
+
"pulumi>=3.215.0",
|
|
13
|
+
"pydantic>=2.12.5",
|
|
14
|
+
"typer>=0.21.1",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
20
|
+
"License :: OSI Approved :: MIT License",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
[tool.pyrefly]
|
|
24
|
+
project-includes = ["src"]
|
|
25
|
+
project-excludes = ["**/*venv/**/*", "examples/", "tests/"]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
aws = ["pulumi-aws>=7.15.0"]
|
|
29
|
+
api = [
|
|
30
|
+
"fastapi>=0.115.0",
|
|
31
|
+
"uvicorn[standard]>=0.30.0",
|
|
32
|
+
"huey>=2.5.0",
|
|
33
|
+
]
|
|
34
|
+
celery = [
|
|
35
|
+
"celery[redis]>=5.4.0",
|
|
36
|
+
]
|
|
37
|
+
admin = [
|
|
38
|
+
"textual>=3.0.0",
|
|
39
|
+
"textual-serve>=1.1.0",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
[dependency-groups]
|
|
43
|
+
dev = [
|
|
44
|
+
"pytest>=8.3.4",
|
|
45
|
+
"pytest-cov>=6.0.0",
|
|
46
|
+
"fastapi>=0.115.0",
|
|
47
|
+
"huey>=2.5.0",
|
|
48
|
+
"httpx>=0.28.0",
|
|
49
|
+
]
|
|
50
|
+
docs = [
|
|
51
|
+
"zensical>=0.0.37",
|
|
52
|
+
"mkdocstrings[python]>=0.29.0",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
[build-system]
|
|
56
|
+
requires = ["uv_build>=0.9.21,<0.10.0"]
|
|
57
|
+
build-backend = "uv_build"
|
|
58
|
+
|
|
59
|
+
[tool.pytest.ini_options]
|
|
60
|
+
minversion = "8.0"
|
|
61
|
+
addopts = ["-ra", "-q"]
|
|
62
|
+
testpaths = ["tests"]
|
|
63
|
+
|
|
64
|
+
[tool.uv.workspace]
|
|
65
|
+
members = [
|
|
66
|
+
"examples/bootstrap-usecase",
|
|
67
|
+
"examples/static-website",
|
|
68
|
+
"examples/api-postgres",
|
|
69
|
+
"examples/kubernetes-deploy",
|
|
70
|
+
]
|
|
71
|
+
|
|
72
|
+
[tool.ruff]
|
|
73
|
+
line-length = 100
|
|
74
|
+
indent-width = 4
|
|
75
|
+
|
|
76
|
+
[tool.ruff.format]
|
|
77
|
+
quote-style = "single"
|
|
78
|
+
indent-style = "space"
|
|
79
|
+
docstring-code-format = true
|
|
80
|
+
exclude = [
|
|
81
|
+
"build",
|
|
82
|
+
"dist",
|
|
83
|
+
".eggs",
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
[tool.ruff.lint]
|
|
87
|
+
ignore = [
|
|
88
|
+
"N801", # class names should use CapWords convention (class names)
|
|
89
|
+
"N812", # lowercase imported as non-lowercase (pyspark.sql functions as F)
|
|
90
|
+
"N818" # Exception name should have Error suffix
|
|
91
|
+
]
|
|
92
|
+
|
|
93
|
+
select = [
|
|
94
|
+
"E",
|
|
95
|
+
"F",
|
|
96
|
+
"N",
|
|
97
|
+
"W",
|
|
98
|
+
"I001", # isort configuration for ruff "--profile", "black"
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
[tool.ruff.lint.isort.sections]
|
|
103
|
+
typing = ["typing"]
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
[tool.ruff.lint.isort]
|
|
107
|
+
no-lines-before = ["future", "standard-library", "third-party", "first-party", "local-folder", "typing"]
|
|
108
|
+
section-order = ["future", "standard-library", "third-party", "first-party", "local-folder", "typing"]
|
|
109
|
+
|
|
110
|
+
[tool.bandit]
|
|
111
|
+
skips = ["B601", "B608", "B101", "B110"] # B101 asserts used
|
|
112
|
+
exclude_dirs = ["tests/", "examples/"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
__version__ = '0.1.0'
|
|
2
|
+
|
|
3
|
+
from pulice.cli.app import PuliceCLI
|
|
4
|
+
from pulice.core.base import ComponentArgs, ManagedComponent
|
|
5
|
+
from pulice.core.controllers import ComponentController, WorkspaceController
|
|
6
|
+
from pulice.core.protocol import PuliceApp
|
|
7
|
+
from pulice.core.tasks import TaskBackend, TaskResult, TaskStatus
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'ComponentArgs',
|
|
11
|
+
'ComponentController',
|
|
12
|
+
'ManagedComponent',
|
|
13
|
+
'PuliceApp',
|
|
14
|
+
'PuliceCLI',
|
|
15
|
+
'TaskBackend',
|
|
16
|
+
'TaskResult',
|
|
17
|
+
'TaskStatus',
|
|
18
|
+
'WorkspaceController',
|
|
19
|
+
]
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Pulice Admin TUI — terminal and browser dashboard."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def create_admin_app(
|
|
7
|
+
state_dir: str | None = None,
|
|
8
|
+
refresh_interval: int = 5,
|
|
9
|
+
) -> 'PuliceAdmin': # noqa: F821 # pyrefly: ignore
|
|
10
|
+
"""Create and return the admin TUI application."""
|
|
11
|
+
from pulice.admin.app import PuliceAdmin
|
|
12
|
+
|
|
13
|
+
return PuliceAdmin(state_dir=state_dir, refresh_interval=refresh_interval)
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""PuliceAdmin — main Textual application for the admin TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from textual.app import App, ComposeResult
|
|
6
|
+
from textual.binding import Binding
|
|
7
|
+
from textual.containers import Vertical
|
|
8
|
+
from textual.widgets import Footer, Header, TabbedContent, TabPane
|
|
9
|
+
from pulice.admin.data import AdminDataSource
|
|
10
|
+
from pulice.admin.screens.dashboard import DashboardScreen
|
|
11
|
+
from pulice.admin.screens.stacks import StacksScreen
|
|
12
|
+
from pulice.admin.screens.system import SystemScreen
|
|
13
|
+
from pulice.admin.screens.tasks import TasksScreen
|
|
14
|
+
from pulice.admin.screens.tenants import TenantsScreen
|
|
15
|
+
|
|
16
|
+
CSS_PATH = Path(__file__).parent / 'styles' / 'app.tcss'
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PuliceAdmin(App):
|
|
20
|
+
"""Pulice administrative dashboard TUI."""
|
|
21
|
+
|
|
22
|
+
TITLE = 'Pulice Admin'
|
|
23
|
+
CSS_PATH = CSS_PATH
|
|
24
|
+
BINDINGS = [
|
|
25
|
+
Binding('1', "switch_tab('dashboard')", 'Dashboard', show=False),
|
|
26
|
+
Binding('2', "switch_tab('tenants')", 'Tenants', show=False),
|
|
27
|
+
Binding('3', "switch_tab('stacks')", 'Stacks', show=False),
|
|
28
|
+
Binding('4', "switch_tab('tasks')", 'Tasks', show=False),
|
|
29
|
+
Binding('5', "switch_tab('system')", 'System', show=False),
|
|
30
|
+
Binding('r', 'force_refresh', 'Refresh'),
|
|
31
|
+
Binding('q', 'quit', 'Quit'),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
state_dir: str | None = None,
|
|
37
|
+
refresh_interval: int = 5,
|
|
38
|
+
**kwargs,
|
|
39
|
+
) -> None:
|
|
40
|
+
super().__init__(**kwargs)
|
|
41
|
+
self._state_dir = state_dir
|
|
42
|
+
self._refresh_interval = refresh_interval
|
|
43
|
+
self._data = AdminDataSource(state_dir=state_dir)
|
|
44
|
+
|
|
45
|
+
def compose(self) -> ComposeResult:
|
|
46
|
+
yield Header()
|
|
47
|
+
with Vertical(id='main'):
|
|
48
|
+
with TabbedContent(id='tabs'):
|
|
49
|
+
with TabPane('Dashboard', id='dashboard'):
|
|
50
|
+
yield DashboardScreen(self._data)
|
|
51
|
+
with TabPane('Tenants', id='tenants'):
|
|
52
|
+
yield TenantsScreen(self._data)
|
|
53
|
+
with TabPane('Stacks', id='stacks'):
|
|
54
|
+
yield StacksScreen(self._data)
|
|
55
|
+
with TabPane('Tasks', id='tasks'):
|
|
56
|
+
yield TasksScreen(self._data)
|
|
57
|
+
with TabPane('System', id='system'):
|
|
58
|
+
yield SystemScreen(self._data)
|
|
59
|
+
yield Footer()
|
|
60
|
+
|
|
61
|
+
def on_mount(self) -> None:
|
|
62
|
+
self._do_refresh()
|
|
63
|
+
if self._refresh_interval > 0:
|
|
64
|
+
self.set_interval(self._refresh_interval, self._do_refresh)
|
|
65
|
+
|
|
66
|
+
def _do_refresh(self) -> None:
|
|
67
|
+
self.run_worker(self._refresh_worker, exclusive=True)
|
|
68
|
+
|
|
69
|
+
async def _refresh_worker(self) -> None:
|
|
70
|
+
for screen_cls in (
|
|
71
|
+
DashboardScreen,
|
|
72
|
+
TenantsScreen,
|
|
73
|
+
StacksScreen,
|
|
74
|
+
TasksScreen,
|
|
75
|
+
SystemScreen,
|
|
76
|
+
):
|
|
77
|
+
try:
|
|
78
|
+
widget = self.query_one(screen_cls) # pyrefly: ignore
|
|
79
|
+
widget.refresh_data()
|
|
80
|
+
except Exception: # noseq
|
|
81
|
+
pass
|
|
82
|
+
|
|
83
|
+
def action_switch_tab(self, tab_id: str) -> None:
|
|
84
|
+
tabs = self.query_one('#tabs', TabbedContent)
|
|
85
|
+
tabs.active = tab_id # pyrefly: ignore
|
|
86
|
+
self._do_refresh()
|
|
87
|
+
|
|
88
|
+
def action_force_refresh(self) -> None:
|
|
89
|
+
self._do_refresh()
|
|
90
|
+
self.notify('Refreshed')
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Read-only data provider for the admin TUI."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from datetime import datetime, timezone
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import pulice
|
|
9
|
+
from pulice.core.stack import (
|
|
10
|
+
LocalStackReferenceStore,
|
|
11
|
+
SqliteBackendStorage,
|
|
12
|
+
StackReference,
|
|
13
|
+
Tenant,
|
|
14
|
+
)
|
|
15
|
+
from pulice.core.tasks import TaskBackend, TaskResult
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class AdminDataSource:
|
|
20
|
+
"""Read-only data provider for the admin TUI.
|
|
21
|
+
|
|
22
|
+
Wraps existing storage APIs to provide data for all admin screens.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, state_dir: str | None = None) -> None:
|
|
26
|
+
self._state_dir = state_dir or os.getenv('PULICE_STATE_DIR')
|
|
27
|
+
self._storage = SqliteBackendStorage(root_dir=self._state_dir)
|
|
28
|
+
self._references = LocalStackReferenceStore(root_dir=self._state_dir)
|
|
29
|
+
self._task_backend: TaskBackend | None = None
|
|
30
|
+
|
|
31
|
+
def _get_task_backend(self) -> TaskBackend:
|
|
32
|
+
if self._task_backend is None:
|
|
33
|
+
from pulice.core.tasks import get_task_backend
|
|
34
|
+
|
|
35
|
+
self._task_backend = get_task_backend(state_dir=self._state_dir)
|
|
36
|
+
return self._task_backend
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def state_dir(self) -> str:
|
|
40
|
+
return str(self._storage._root)
|
|
41
|
+
|
|
42
|
+
def get_tenants(self) -> list[Tenant]:
|
|
43
|
+
return self._storage.list_tenants()
|
|
44
|
+
|
|
45
|
+
def get_stacks(self, tenant_id: str | None = None) -> list[dict[str, Any]]:
|
|
46
|
+
return self._storage.list_stacks(tenant_id=tenant_id)
|
|
47
|
+
|
|
48
|
+
def get_stack_references(self, tenant_id: str | None = None) -> list[StackReference]:
|
|
49
|
+
return self._references.list(tenant_id=tenant_id)
|
|
50
|
+
|
|
51
|
+
def get_locks(self) -> list[dict[str, Any]]:
|
|
52
|
+
with self._storage._connect() as conn:
|
|
53
|
+
rows = conn.execute(
|
|
54
|
+
'SELECT stack_name, holder, operation, locked_at FROM stack_locks'
|
|
55
|
+
).fetchall()
|
|
56
|
+
now = datetime.now(timezone.utc)
|
|
57
|
+
results = []
|
|
58
|
+
for r in rows:
|
|
59
|
+
locked_at = datetime.fromisoformat(r[3])
|
|
60
|
+
age_seconds = (now - locked_at).total_seconds()
|
|
61
|
+
results.append(
|
|
62
|
+
{
|
|
63
|
+
'stack_name': r[0],
|
|
64
|
+
'holder': r[1],
|
|
65
|
+
'operation': r[2],
|
|
66
|
+
'locked_at': r[3],
|
|
67
|
+
'age_seconds': age_seconds,
|
|
68
|
+
}
|
|
69
|
+
)
|
|
70
|
+
return results
|
|
71
|
+
|
|
72
|
+
def get_task_status(self, task_id: str) -> TaskResult:
|
|
73
|
+
return self._get_task_backend().get_status(task_id)
|
|
74
|
+
|
|
75
|
+
def get_system_info(self) -> dict[str, Any]:
|
|
76
|
+
state_path = Path(self.state_dir)
|
|
77
|
+
db_path = state_path / 'pulice_stacks.sqlite3'
|
|
78
|
+
|
|
79
|
+
total_size = sum(f.stat().st_size for f in state_path.rglob('*') if f.is_file())
|
|
80
|
+
db_size = db_path.stat().st_size if db_path.exists() else 0
|
|
81
|
+
|
|
82
|
+
tenants = self.get_tenants()
|
|
83
|
+
stacks = self.get_stacks()
|
|
84
|
+
locks = self.get_locks()
|
|
85
|
+
|
|
86
|
+
backend_type = os.getenv('PULICE_TASK_BACKEND', 'huey')
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
'version': pulice.__version__,
|
|
90
|
+
'python': sys.version,
|
|
91
|
+
'state_dir': self.state_dir,
|
|
92
|
+
'state_dir_size': total_size,
|
|
93
|
+
'db_size': db_size,
|
|
94
|
+
'task_backend': backend_type,
|
|
95
|
+
'tenant_count': len(tenants),
|
|
96
|
+
'stack_count': len(stacks),
|
|
97
|
+
'lock_count': len(locks),
|
|
98
|
+
'locks': locks,
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
def delete_tenant(self, name: str) -> None:
|
|
102
|
+
self._storage.delete_tenant(name)
|
|
103
|
+
|
|
104
|
+
def release_lock(self, stack_name: str) -> None:
|
|
105
|
+
with self._storage._connect() as conn:
|
|
106
|
+
conn.execute('DELETE FROM stack_locks WHERE stack_name = ?', (stack_name,))
|
|
107
|
+
|
|
108
|
+
def cancel_task(self, task_id: str) -> bool:
|
|
109
|
+
return self._get_task_backend().cancel(task_id)
|
|
110
|
+
|
|
111
|
+
def retry_task(self, task_id: str) -> str:
|
|
112
|
+
return self._get_task_backend().retry(task_id)
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Admin TUI screens."""
|
|
2
|
+
|
|
3
|
+
from pulice.admin.screens.dashboard import DashboardScreen
|
|
4
|
+
from pulice.admin.screens.stacks import StacksScreen
|
|
5
|
+
from pulice.admin.screens.system import SystemScreen
|
|
6
|
+
from pulice.admin.screens.tasks import TasksScreen
|
|
7
|
+
from pulice.admin.screens.tenants import TenantsScreen
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
'DashboardScreen',
|
|
11
|
+
'TenantsScreen',
|
|
12
|
+
'StacksScreen',
|
|
13
|
+
'TasksScreen',
|
|
14
|
+
'SystemScreen',
|
|
15
|
+
]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Dashboard screen — summary view with key metrics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
from textual.app import ComposeResult
|
|
5
|
+
from textual.containers import Horizontal, Vertical
|
|
6
|
+
from textual.widgets import Static
|
|
7
|
+
from pulice.admin.widgets.stat_card import StatCard
|
|
8
|
+
|
|
9
|
+
if __name__ != '__main__':
|
|
10
|
+
from typing import TYPE_CHECKING
|
|
11
|
+
|
|
12
|
+
if TYPE_CHECKING:
|
|
13
|
+
from pulice.admin.data import AdminDataSource
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class DashboardScreen(Static):
|
|
17
|
+
"""Summary dashboard showing key metrics at a glance."""
|
|
18
|
+
|
|
19
|
+
DEFAULT_CSS = """
|
|
20
|
+
DashboardScreen {
|
|
21
|
+
height: 1fr;
|
|
22
|
+
padding: 1 2;
|
|
23
|
+
}
|
|
24
|
+
DashboardScreen .metrics-row {
|
|
25
|
+
height: 7;
|
|
26
|
+
margin-bottom: 1;
|
|
27
|
+
}
|
|
28
|
+
DashboardScreen .info-section {
|
|
29
|
+
height: auto;
|
|
30
|
+
padding: 1;
|
|
31
|
+
}
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(self, data_source: 'AdminDataSource') -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self._data = data_source
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
with Horizontal(classes='metrics-row'):
|
|
40
|
+
yield StatCard('Tenants', '0', id='stat-tenants')
|
|
41
|
+
yield StatCard('Stacks', '0', id='stat-stacks')
|
|
42
|
+
yield StatCard('Active Locks', '0', id='stat-locks')
|
|
43
|
+
with Horizontal(classes='metrics-row'):
|
|
44
|
+
yield StatCard('Pending Tasks', '—', id='stat-pending')
|
|
45
|
+
yield StatCard('Running Tasks', '—', id='stat-running')
|
|
46
|
+
yield StatCard('Failed Tasks', '—', id='stat-failed')
|
|
47
|
+
with Vertical(classes='info-section'):
|
|
48
|
+
yield Static(id='system-info')
|
|
49
|
+
|
|
50
|
+
def refresh_data(self) -> None:
|
|
51
|
+
info = self._data.get_system_info()
|
|
52
|
+
self.query_one('#stat-tenants', StatCard).update_value(str(info['tenant_count']))
|
|
53
|
+
self.query_one('#stat-stacks', StatCard).update_value(str(info['stack_count']))
|
|
54
|
+
self.query_one('#stat-locks', StatCard).update_value(str(info['lock_count']))
|
|
55
|
+
|
|
56
|
+
info_text = (
|
|
57
|
+
f'[bold]Pulice[/bold] v{info["version"]} | '
|
|
58
|
+
f'Backend: {info["task_backend"]} | '
|
|
59
|
+
f'State: {info["state_dir"]}'
|
|
60
|
+
)
|
|
61
|
+
self.query_one('#system-info', Static).update(info_text)
|