lnp-devopscli 1.0.3__tar.gz → 1.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.
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/PKG-INFO +4 -1
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/pyproject.toml +9 -3
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/__init__.py +1 -1
- lnp_devopscli-1.1.0/scripts/dc_cli/bw_secrets/__init__.py +22 -0
- lnp_devopscli-1.1.0/scripts/dc_cli/bw_secrets/client.py +134 -0
- lnp_devopscli-1.1.0/scripts/dc_cli/bw_secrets/mapping.py +74 -0
- lnp_devopscli-1.1.0/scripts/dc_cli/bw_secrets/sync.py +236 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/groups/bw.py +176 -0
- lnp_devopscli-1.1.0/scripts/dc_cli/groups/ui.py +537 -0
- lnp_devopscli-1.1.0/scripts/dc_cli/web/__init__.py +5 -0
- lnp_devopscli-1.1.0/scripts/dc_cli/web/server.py +196 -0
- lnp_devopscli-1.1.0/scripts/dc_cli/web/static/app.js +417 -0
- lnp_devopscli-1.1.0/scripts/dc_cli/web/static/index.html +109 -0
- lnp_devopscli-1.1.0/scripts/dc_cli/web/static/style.css +304 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/lnp_devopscli.egg-info/PKG-INFO +4 -1
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/lnp_devopscli.egg-info/SOURCES.txt +9 -0
- lnp_devopscli-1.1.0/scripts/lnp_devopscli.egg-info/requires.txt +7 -0
- lnp_devopscli-1.0.3/scripts/dc_cli/groups/ui.py +0 -171
- lnp_devopscli-1.0.3/scripts/lnp_devopscli.egg-info/requires.txt +0 -4
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/README.md +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/groups/__init__.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/groups/gl.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/groups/test.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/groups/ws.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/main.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/ui/__init__.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/ui/dashboard.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/ui/events.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/ui/themes.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/utils/__init__.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/utils/config.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/dc_cli/utils/gitlab.py +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/lnp_devopscli.egg-info/dependency_links.txt +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/lnp_devopscli.egg-info/entry_points.txt +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/scripts/lnp_devopscli.egg-info/top_level.txt +0 -0
- {lnp_devopscli-1.0.3 → lnp_devopscli-1.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lnp-devopscli
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: DevOps CLI — workspace sync (GDrive, git, GPG, systemd timers) + GitLab/Bitwarden tooling
|
|
5
5
|
Author-email: Lucas Neves Pires <npires.lucas@gmail.com>
|
|
6
6
|
License: MIT
|
|
@@ -25,6 +25,9 @@ Requires-Dist: click>=8.0
|
|
|
25
25
|
Requires-Dist: pyyaml>=6.0
|
|
26
26
|
Requires-Dist: requests>=2.28
|
|
27
27
|
Requires-Dist: rich>=13.0
|
|
28
|
+
Requires-Dist: fastapi>=0.115
|
|
29
|
+
Requires-Dist: uvicorn[standard]>=0.32
|
|
30
|
+
Requires-Dist: sse-starlette>=2.1
|
|
28
31
|
|
|
29
32
|
# lnp-devopscli
|
|
30
33
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "lnp-devopscli"
|
|
7
|
-
version = "1.0
|
|
7
|
+
version = "1.1.0"
|
|
8
8
|
description = "DevOps CLI — workspace sync (GDrive, git, GPG, systemd timers) + GitLab/Bitwarden tooling"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -31,8 +31,16 @@ dependencies = [
|
|
|
31
31
|
"pyyaml>=6.0",
|
|
32
32
|
"requests>=2.28",
|
|
33
33
|
"rich>=13.0",
|
|
34
|
+
"fastapi>=0.115",
|
|
35
|
+
"uvicorn[standard]>=0.32",
|
|
36
|
+
"sse-starlette>=2.1",
|
|
34
37
|
]
|
|
35
38
|
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
"dc_cli" = ["*.toml", "*.yaml", "*.yml"]
|
|
41
|
+
"dc_cli.installers.scripts" = ["*.sh"]
|
|
42
|
+
"dc_cli.web.static" = ["*.html", "*.css", "*.js"]
|
|
43
|
+
|
|
36
44
|
[project.urls]
|
|
37
45
|
Homepage = "https://gitlab.com/lnp-consulting-ti/devops/devops-cli"
|
|
38
46
|
Repository = "https://gitlab.com/lnp-consulting-ti/devops/devops-cli"
|
|
@@ -50,5 +58,3 @@ where = ["scripts"]
|
|
|
50
58
|
include = ["dc_cli*"]
|
|
51
59
|
exclude = ["tests*", "*.tests*"]
|
|
52
60
|
|
|
53
|
-
[tool.setuptools.package-data]
|
|
54
|
-
"dc_cli" = ["*.toml", "*.yaml", "*.yml"]
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""Bitwarden Secrets Manager integration for machine secrets.
|
|
2
|
+
|
|
3
|
+
Provides bidirectional sync between local files (~/.ssh, ~/.config/rclone, etc)
|
|
4
|
+
and BWS project `devopscli-machine-secrets`. Replaces leaking via GDrive sync.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .mapping import SECRETS, SecretSpec
|
|
8
|
+
from .client import BwsClient, BwsError, BwsSecret
|
|
9
|
+
from .sync import sync_all, migrate_all, sync_one, SyncAction, SyncResult
|
|
10
|
+
|
|
11
|
+
__all__ = [
|
|
12
|
+
"SECRETS",
|
|
13
|
+
"SecretSpec",
|
|
14
|
+
"BwsClient",
|
|
15
|
+
"BwsError",
|
|
16
|
+
"BwsSecret",
|
|
17
|
+
"sync_all",
|
|
18
|
+
"sync_one",
|
|
19
|
+
"migrate_all",
|
|
20
|
+
"SyncAction",
|
|
21
|
+
"SyncResult",
|
|
22
|
+
]
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
"""Thin wrapper around the `bws` CLI for project/secret CRUD."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import subprocess
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class BwsError(RuntimeError):
|
|
14
|
+
pass
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class BwsSecret:
|
|
19
|
+
id: str
|
|
20
|
+
key: str
|
|
21
|
+
value: str
|
|
22
|
+
note: str
|
|
23
|
+
project_id: str
|
|
24
|
+
revision_date: str
|
|
25
|
+
|
|
26
|
+
@classmethod
|
|
27
|
+
def from_api(cls, data: dict[str, Any]) -> "BwsSecret":
|
|
28
|
+
return cls(
|
|
29
|
+
id=data.get("id", ""),
|
|
30
|
+
key=data.get("key", ""),
|
|
31
|
+
value=data.get("value", ""),
|
|
32
|
+
note=data.get("note") or "",
|
|
33
|
+
project_id=data.get("projectId", ""),
|
|
34
|
+
revision_date=data.get("revisionDate", ""),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class BwsClient:
|
|
39
|
+
"""Wrap the bws binary. Auto-detects BWS_ACCESS_TOKEN from env."""
|
|
40
|
+
|
|
41
|
+
def __init__(self, access_token: str | None = None):
|
|
42
|
+
self.access_token = access_token or os.environ.get(
|
|
43
|
+
"BWS_ACCESS_TOKEN"
|
|
44
|
+
) or os.environ.get("BW_ACCESS_TOKEN")
|
|
45
|
+
if not self.access_token:
|
|
46
|
+
raise BwsError(
|
|
47
|
+
"BWS_ACCESS_TOKEN nao definido. Exporte em ~/.zshrc."
|
|
48
|
+
)
|
|
49
|
+
if not shutil.which("bws"):
|
|
50
|
+
raise BwsError(
|
|
51
|
+
"bws CLI nao instalado. Veja: "
|
|
52
|
+
"https://bitwarden.com/help/secrets-manager-cli/"
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
def _run(self, args: list[str], timeout: int = 30) -> dict:
|
|
56
|
+
cmd = ["bws", "--access-token", self.access_token, "--output", "json"] + args
|
|
57
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
|
|
58
|
+
if result.returncode != 0:
|
|
59
|
+
raise BwsError(
|
|
60
|
+
f"bws {' '.join(args)} falhou: {result.stderr.strip()[:200]}"
|
|
61
|
+
)
|
|
62
|
+
if not result.stdout.strip():
|
|
63
|
+
return {}
|
|
64
|
+
try:
|
|
65
|
+
return json.loads(result.stdout)
|
|
66
|
+
except json.JSONDecodeError as e:
|
|
67
|
+
raise BwsError(f"resposta inválida do bws: {e}") from None
|
|
68
|
+
|
|
69
|
+
# ─── Projects ──────────────────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
def list_projects(self) -> list[dict]:
|
|
72
|
+
return self._run(["project", "list"]) # type: ignore[return-value]
|
|
73
|
+
|
|
74
|
+
def get_project_by_name(self, name: str) -> dict | None:
|
|
75
|
+
for p in self.list_projects():
|
|
76
|
+
if p.get("name") == name:
|
|
77
|
+
return p
|
|
78
|
+
return None
|
|
79
|
+
|
|
80
|
+
def ensure_project(self, name: str) -> str:
|
|
81
|
+
"""Get or create project, return its ID."""
|
|
82
|
+
existing = self.get_project_by_name(name)
|
|
83
|
+
if existing:
|
|
84
|
+
return existing["id"]
|
|
85
|
+
created = self._run(["project", "create", name])
|
|
86
|
+
return created["id"]
|
|
87
|
+
|
|
88
|
+
# ─── Secrets ───────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
def list_secrets(self, project_id: str | None = None) -> list[BwsSecret]:
|
|
91
|
+
raw = self._run(["secret", "list"])
|
|
92
|
+
if not isinstance(raw, list):
|
|
93
|
+
return []
|
|
94
|
+
secrets = [BwsSecret.from_api(s) for s in raw]
|
|
95
|
+
if project_id:
|
|
96
|
+
secrets = [s for s in secrets if s.project_id == project_id]
|
|
97
|
+
return secrets
|
|
98
|
+
|
|
99
|
+
def find_secret(self, key: str, project_id: str) -> BwsSecret | None:
|
|
100
|
+
for s in self.list_secrets(project_id):
|
|
101
|
+
if s.key == key:
|
|
102
|
+
return s
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
def create_secret(
|
|
106
|
+
self, key: str, value: str, project_id: str, note: str = ""
|
|
107
|
+
) -> BwsSecret:
|
|
108
|
+
args = ["secret", "create", key, value, project_id]
|
|
109
|
+
if note:
|
|
110
|
+
args += ["--note", note]
|
|
111
|
+
return BwsSecret.from_api(self._run(args))
|
|
112
|
+
|
|
113
|
+
def update_secret(
|
|
114
|
+
self,
|
|
115
|
+
secret_id: str,
|
|
116
|
+
project_id: str,
|
|
117
|
+
key: str | None = None,
|
|
118
|
+
value: str | None = None,
|
|
119
|
+
note: str | None = None,
|
|
120
|
+
) -> BwsSecret:
|
|
121
|
+
args = ["secret", "edit", secret_id]
|
|
122
|
+
# bws edit aceita --key, --value, --note, --project-id
|
|
123
|
+
if key is not None:
|
|
124
|
+
args += ["--key", key]
|
|
125
|
+
if value is not None:
|
|
126
|
+
args += ["--value", value]
|
|
127
|
+
if note is not None:
|
|
128
|
+
args += ["--note", note]
|
|
129
|
+
if project_id:
|
|
130
|
+
args += ["--project-id", project_id]
|
|
131
|
+
return BwsSecret.from_api(self._run(args))
|
|
132
|
+
|
|
133
|
+
def delete_secret(self, secret_id: str) -> None:
|
|
134
|
+
self._run(["secret", "delete", secret_id])
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Specification of machine secrets stored in BWS."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
DEFAULT_PROJECT_NAME = "devopscli-machine-secrets"
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class SecretSpec:
|
|
15
|
+
"""One machine secret managed by devopscli bw."""
|
|
16
|
+
|
|
17
|
+
name: str
|
|
18
|
+
path: str
|
|
19
|
+
file_mode: int = 0o600
|
|
20
|
+
dir_mode: int = 0o700
|
|
21
|
+
optional: bool = True
|
|
22
|
+
category: str = "misc"
|
|
23
|
+
binary: bool = False
|
|
24
|
+
|
|
25
|
+
def local_path(self) -> Path:
|
|
26
|
+
return Path(os.path.expanduser(self.path))
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def parent(self) -> Path:
|
|
30
|
+
return self.local_path().parent
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SECRETS: tuple[SecretSpec, ...] = (
|
|
34
|
+
SecretSpec(name="rclone_conf", path="~/.config/rclone/rclone.conf",
|
|
35
|
+
file_mode=0o600, dir_mode=0o700, category="rclone"),
|
|
36
|
+
SecretSpec(name="ssh_config", path="~/.ssh/config",
|
|
37
|
+
file_mode=0o600, category="ssh"),
|
|
38
|
+
SecretSpec(name="ssh_known_hosts", path="~/.ssh/known_hosts",
|
|
39
|
+
file_mode=0o644, category="ssh"),
|
|
40
|
+
SecretSpec(name="ssh_id_rsa", path="~/.ssh/id_rsa",
|
|
41
|
+
file_mode=0o600, category="ssh"),
|
|
42
|
+
SecretSpec(name="ssh_id_rsa_pub", path="~/.ssh/id_rsa.pub",
|
|
43
|
+
file_mode=0o644, category="ssh"),
|
|
44
|
+
SecretSpec(name="ssh_id_rsa_builders", path="~/.ssh/id_rsa_builders",
|
|
45
|
+
file_mode=0o600, category="ssh"),
|
|
46
|
+
SecretSpec(name="ssh_id_rsa_builders_pub", path="~/.ssh/id_rsa_builders.pub",
|
|
47
|
+
file_mode=0o644, category="ssh"),
|
|
48
|
+
SecretSpec(name="ssh_id_rsa_git_hml", path="~/.ssh/id_rsa_git_hml",
|
|
49
|
+
file_mode=0o600, category="ssh"),
|
|
50
|
+
SecretSpec(name="ssh_id_rsa_git_hml_pub", path="~/.ssh/id_rsa_git_hml.pub",
|
|
51
|
+
file_mode=0o644, category="ssh"),
|
|
52
|
+
SecretSpec(name="ssh_id_ed25519", path="~/.ssh/id_ed25519",
|
|
53
|
+
file_mode=0o600, category="ssh"),
|
|
54
|
+
SecretSpec(name="ssh_id_ed25519_pub", path="~/.ssh/id_ed25519.pub",
|
|
55
|
+
file_mode=0o644, category="ssh"),
|
|
56
|
+
SecretSpec(name="ssh_desktop_key", path="~/.ssh/desktop_key",
|
|
57
|
+
file_mode=0o600, category="ssh"),
|
|
58
|
+
SecretSpec(name="ssh_desktop_key_pub", path="~/.ssh/desktop_key.pub",
|
|
59
|
+
file_mode=0o644, category="ssh"),
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def secrets_by_category() -> dict[str, list[SecretSpec]]:
|
|
64
|
+
result: dict[str, list[SecretSpec]] = {}
|
|
65
|
+
for s in SECRETS:
|
|
66
|
+
result.setdefault(s.category, []).append(s)
|
|
67
|
+
return result
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def find_secret(name: str) -> SecretSpec | None:
|
|
71
|
+
for s in SECRETS:
|
|
72
|
+
if s.name == name:
|
|
73
|
+
return s
|
|
74
|
+
return None
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Bidirectional sync logic with hash-based idempotency."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import json
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from enum import Enum
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from .client import BwsClient, BwsError, BwsSecret
|
|
13
|
+
from .mapping import SECRETS, SecretSpec
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class SyncAction(str, Enum):
|
|
17
|
+
UNCHANGED = "unchanged"
|
|
18
|
+
PULLED = "pulled"
|
|
19
|
+
PUSHED = "pushed"
|
|
20
|
+
SKIPPED_LOCAL_MISSING = "skipped_local_missing"
|
|
21
|
+
SKIPPED_REMOTE_MISSING = "skipped_remote_missing"
|
|
22
|
+
CREATED_REMOTE = "created_remote"
|
|
23
|
+
CONFLICT = "conflict"
|
|
24
|
+
ERROR = "error"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class SyncResult:
|
|
29
|
+
spec: SecretSpec
|
|
30
|
+
action: SyncAction
|
|
31
|
+
detail: str = ""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _sha256_text(s: str) -> str:
|
|
35
|
+
return hashlib.sha256(s.encode("utf-8")).hexdigest()
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _read_local(spec: SecretSpec) -> str | None:
|
|
39
|
+
path = spec.local_path()
|
|
40
|
+
if not path.exists():
|
|
41
|
+
return None
|
|
42
|
+
try:
|
|
43
|
+
if spec.binary:
|
|
44
|
+
return base64.b64encode(path.read_bytes()).decode("ascii")
|
|
45
|
+
return path.read_text()
|
|
46
|
+
except OSError:
|
|
47
|
+
return None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_note(note: str) -> dict:
|
|
51
|
+
if not note:
|
|
52
|
+
return {}
|
|
53
|
+
try:
|
|
54
|
+
return json.loads(note)
|
|
55
|
+
except json.JSONDecodeError:
|
|
56
|
+
return {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _make_note(local_path: str, content_hash: str) -> str:
|
|
60
|
+
return json.dumps(
|
|
61
|
+
{"path": local_path, "sha256": content_hash, "managed_by": "devopscli"},
|
|
62
|
+
separators=(",", ":"),
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _apply_permissions(spec: SecretSpec) -> None:
|
|
67
|
+
"""chmod the file and ensure parent dir has secure mode."""
|
|
68
|
+
parent = spec.parent
|
|
69
|
+
if parent.exists() and parent.is_dir():
|
|
70
|
+
try:
|
|
71
|
+
parent.chmod(spec.dir_mode)
|
|
72
|
+
except OSError:
|
|
73
|
+
pass
|
|
74
|
+
path = spec.local_path()
|
|
75
|
+
if path.exists():
|
|
76
|
+
try:
|
|
77
|
+
path.chmod(spec.file_mode)
|
|
78
|
+
except OSError:
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _write_local(spec: SecretSpec, content: str) -> None:
|
|
83
|
+
path = spec.local_path()
|
|
84
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
try:
|
|
86
|
+
path.parent.chmod(spec.dir_mode)
|
|
87
|
+
except OSError:
|
|
88
|
+
pass
|
|
89
|
+
if spec.binary:
|
|
90
|
+
path.write_bytes(base64.b64decode(content.encode("ascii")))
|
|
91
|
+
else:
|
|
92
|
+
path.write_text(content)
|
|
93
|
+
_apply_permissions(spec)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def sync_one(
|
|
97
|
+
client: BwsClient,
|
|
98
|
+
spec: SecretSpec,
|
|
99
|
+
project_id: str,
|
|
100
|
+
direction: str = "auto",
|
|
101
|
+
dry_run: bool = False,
|
|
102
|
+
) -> SyncResult:
|
|
103
|
+
"""Sync a single secret.
|
|
104
|
+
|
|
105
|
+
direction:
|
|
106
|
+
- "auto": if both exist, compare hashes; if remote is newer, pull;
|
|
107
|
+
if local is newer, push; if equal, unchanged
|
|
108
|
+
- "push": only upload local → BWS
|
|
109
|
+
- "pull": only download BWS → local
|
|
110
|
+
"""
|
|
111
|
+
local_content = _read_local(spec)
|
|
112
|
+
remote_secret = client.find_secret(spec.name, project_id)
|
|
113
|
+
|
|
114
|
+
# ── Caso 1: nada local, nada remoto ─────────────────────────
|
|
115
|
+
if local_content is None and remote_secret is None:
|
|
116
|
+
if spec.optional:
|
|
117
|
+
return SyncResult(spec, SyncAction.SKIPPED_LOCAL_MISSING,
|
|
118
|
+
"nem local nem remoto existem")
|
|
119
|
+
return SyncResult(spec, SyncAction.ERROR, "obrigatorio mas faltando dos dois lados")
|
|
120
|
+
|
|
121
|
+
# ── Caso 2: só remoto existe (pull) ─────────────────────────
|
|
122
|
+
if local_content is None and remote_secret is not None:
|
|
123
|
+
if direction == "push":
|
|
124
|
+
return SyncResult(spec, SyncAction.SKIPPED_LOCAL_MISSING,
|
|
125
|
+
"push solicitado mas arquivo local nao existe")
|
|
126
|
+
if dry_run:
|
|
127
|
+
return SyncResult(spec, SyncAction.PULLED,
|
|
128
|
+
f"[dry-run] restauraria {spec.local_path()}")
|
|
129
|
+
_write_local(spec, remote_secret.value)
|
|
130
|
+
return SyncResult(spec, SyncAction.PULLED,
|
|
131
|
+
f"restaurado em {spec.local_path()}")
|
|
132
|
+
|
|
133
|
+
# ── Caso 3: só local existe (push/create) ───────────────────
|
|
134
|
+
if local_content is not None and remote_secret is None:
|
|
135
|
+
if direction == "pull":
|
|
136
|
+
return SyncResult(spec, SyncAction.SKIPPED_REMOTE_MISSING,
|
|
137
|
+
"pull solicitado mas secret remoto nao existe")
|
|
138
|
+
if dry_run:
|
|
139
|
+
return SyncResult(spec, SyncAction.CREATED_REMOTE,
|
|
140
|
+
f"[dry-run] criaria secret {spec.name}")
|
|
141
|
+
h = _sha256_text(local_content)
|
|
142
|
+
note = _make_note(spec.path, h)
|
|
143
|
+
client.create_secret(spec.name, local_content, project_id, note=note)
|
|
144
|
+
return SyncResult(spec, SyncAction.CREATED_REMOTE,
|
|
145
|
+
f"criado no BWS (hash {h[:8]})")
|
|
146
|
+
|
|
147
|
+
# ── Caso 4: ambos existem ───────────────────────────────────
|
|
148
|
+
assert local_content is not None and remote_secret is not None
|
|
149
|
+
local_hash = _sha256_text(local_content)
|
|
150
|
+
remote_meta = _parse_note(remote_secret.note)
|
|
151
|
+
remote_hash = remote_meta.get("sha256", "")
|
|
152
|
+
|
|
153
|
+
# Se hashes batem, nada a fazer
|
|
154
|
+
if local_hash == remote_hash and local_content == remote_secret.value:
|
|
155
|
+
return SyncResult(spec, SyncAction.UNCHANGED, f"hash {local_hash[:8]}")
|
|
156
|
+
|
|
157
|
+
# Conteúdo difere — decide direção
|
|
158
|
+
if direction == "push":
|
|
159
|
+
target = "push"
|
|
160
|
+
elif direction == "pull":
|
|
161
|
+
target = "pull"
|
|
162
|
+
else:
|
|
163
|
+
# auto: prefere remoto se hash do BWS bate com conteúdo (significa
|
|
164
|
+
# foi alguem com revisao mais nova). Se hashes nao batem na nota,
|
|
165
|
+
# local ganha (fonte de verdade do dispositivo atual).
|
|
166
|
+
if remote_secret.value != local_content:
|
|
167
|
+
# Diferenca real. Local ganha por padrao (push)
|
|
168
|
+
target = "push"
|
|
169
|
+
else:
|
|
170
|
+
target = "pull"
|
|
171
|
+
|
|
172
|
+
if target == "push":
|
|
173
|
+
if dry_run:
|
|
174
|
+
return SyncResult(spec, SyncAction.PUSHED,
|
|
175
|
+
f"[dry-run] sobrescreveria remoto (local hash {local_hash[:8]})")
|
|
176
|
+
note = _make_note(spec.path, local_hash)
|
|
177
|
+
client.update_secret(
|
|
178
|
+
remote_secret.id,
|
|
179
|
+
project_id=project_id,
|
|
180
|
+
value=local_content,
|
|
181
|
+
note=note,
|
|
182
|
+
)
|
|
183
|
+
return SyncResult(spec, SyncAction.PUSHED,
|
|
184
|
+
f"local → BWS (hash {local_hash[:8]})")
|
|
185
|
+
else:
|
|
186
|
+
if dry_run:
|
|
187
|
+
return SyncResult(spec, SyncAction.PULLED,
|
|
188
|
+
f"[dry-run] sobrescreveria local")
|
|
189
|
+
_write_local(spec, remote_secret.value)
|
|
190
|
+
return SyncResult(spec, SyncAction.PULLED,
|
|
191
|
+
f"BWS → local (hash {remote_hash[:8]})")
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def sync_all(
|
|
195
|
+
client: BwsClient,
|
|
196
|
+
project_id: str,
|
|
197
|
+
direction: str = "auto",
|
|
198
|
+
dry_run: bool = False,
|
|
199
|
+
only: list[str] | None = None,
|
|
200
|
+
category: str | None = None,
|
|
201
|
+
on_result=None,
|
|
202
|
+
) -> list[SyncResult]:
|
|
203
|
+
"""Sync all secrets matching filters.
|
|
204
|
+
|
|
205
|
+
on_result: optional callback(result) for live progress.
|
|
206
|
+
"""
|
|
207
|
+
results = []
|
|
208
|
+
for spec in SECRETS:
|
|
209
|
+
if only and spec.name not in only:
|
|
210
|
+
continue
|
|
211
|
+
if category and spec.category != category:
|
|
212
|
+
continue
|
|
213
|
+
try:
|
|
214
|
+
r = sync_one(client, spec, project_id, direction=direction, dry_run=dry_run)
|
|
215
|
+
except (BwsError, OSError) as e:
|
|
216
|
+
r = SyncResult(spec, SyncAction.ERROR, str(e)[:150])
|
|
217
|
+
results.append(r)
|
|
218
|
+
if on_result:
|
|
219
|
+
on_result(r)
|
|
220
|
+
return results
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def migrate_all(
|
|
224
|
+
client: BwsClient,
|
|
225
|
+
project_id: str,
|
|
226
|
+
dry_run: bool = False,
|
|
227
|
+
on_result=None,
|
|
228
|
+
) -> list[SyncResult]:
|
|
229
|
+
"""Upload current state of all local files to BWS (first-time migration)."""
|
|
230
|
+
return sync_all(
|
|
231
|
+
client,
|
|
232
|
+
project_id,
|
|
233
|
+
direction="push",
|
|
234
|
+
dry_run=dry_run,
|
|
235
|
+
on_result=on_result,
|
|
236
|
+
)
|