lnp-devopscli 1.0.0__py3-none-any.whl

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.
dc_cli/main.py ADDED
@@ -0,0 +1,54 @@
1
+ """Entry point do CLI dc - DevOps CLI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import importlib
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from . import __version__
11
+
12
+
13
+ @click.group()
14
+ @click.version_option(version=__version__, prog_name="devopscli")
15
+ def cli():
16
+ """DevOps CLI — ferramentas DevOps para LNP Consulting TI."""
17
+
18
+
19
+ def _auto_register_groups():
20
+ """Auto-discover e registra command groups de groups/.
21
+
22
+ Convencao: cada arquivo groups/<nome>.py deve exportar <nome>_group.
23
+ Exemplo: groups/gl.py exporta gl_group.
24
+ """
25
+ groups_dir = Path(__file__).parent / "groups"
26
+
27
+ for py_file in sorted(groups_dir.glob("*.py")):
28
+ if py_file.name.startswith("_"):
29
+ continue
30
+
31
+ module_name = py_file.stem
32
+ expected_name = f"{module_name}_group"
33
+
34
+ try:
35
+ module = importlib.import_module(
36
+ f".groups.{module_name}", package=__package__
37
+ )
38
+ group = getattr(module, expected_name, None)
39
+
40
+ if group and isinstance(group, (click.Group, click.Command)):
41
+ cli.add_command(group)
42
+ except Exception as e:
43
+ click.echo(f"[ERRO] Falha ao carregar grupo '{module_name}': {e}", err=True)
44
+
45
+
46
+ _auto_register_groups()
47
+
48
+
49
+ def main():
50
+ cli(prog_name="devopscli")
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
File without changes
dc_cli/utils/config.py ADDED
@@ -0,0 +1,83 @@
1
+ """Utilitarios de configuracao: .env, config.yaml, workspace root."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import yaml
10
+
11
+
12
+ def get_workspace_root() -> Path:
13
+ """Retorna raiz do projeto (3 niveis acima de utils/)."""
14
+ return Path(__file__).resolve().parent.parent.parent.parent
15
+
16
+
17
+ def get_global_config_path() -> Path:
18
+ """Retorna path do config global (~/bin/config.yaml)."""
19
+ return Path.home() / "bin" / "config.yaml"
20
+
21
+
22
+ def _strip_quotes(value: str) -> str:
23
+ """Remove aspas simples ou duplas que envolvem o valor."""
24
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in ('"', "'"):
25
+ return value[1:-1]
26
+ return value
27
+
28
+
29
+ def parse_env_line(line: str) -> tuple[str, str] | None:
30
+ """Parseia uma linha de .env e retorna (key, value) ou None."""
31
+ line = line.strip()
32
+ if not line or line.startswith("#") or "=" not in line:
33
+ return None
34
+ key, _, value = line.partition("=")
35
+ key = key.strip()
36
+ value = _strip_quotes(value.strip())
37
+ if not key:
38
+ return None
39
+ return key, value
40
+
41
+
42
+ def load_dotenv() -> None:
43
+ """Carrega variaveis do .env do workspace-personal (se existir)."""
44
+ env_file = Path.home() / "workspace-personal" / ".env"
45
+ if not env_file.exists():
46
+ env_file = get_workspace_root() / ".env"
47
+ if not env_file.exists():
48
+ return
49
+ with open(env_file) as f:
50
+ for line in f:
51
+ parsed = parse_env_line(line)
52
+ if not parsed:
53
+ continue
54
+ key, value = parsed
55
+ if key not in os.environ:
56
+ os.environ[key] = value
57
+
58
+
59
+ def load_config() -> dict:
60
+ """Carrega config.yaml completo, retorna dict."""
61
+ import click
62
+
63
+ config_path = get_global_config_path()
64
+ if not config_path.exists():
65
+ config_path = get_workspace_root() / "config.yaml"
66
+ if not config_path.exists():
67
+ click.echo("[ERRO] config.yaml nao encontrado.", err=True)
68
+ sys.exit(1)
69
+
70
+ with open(config_path) as f:
71
+ return yaml.safe_load(f) or {}
72
+
73
+
74
+ def get_section(name: str) -> dict:
75
+ """Retorna secao do config (ex: 'gl' -> config['gl'])."""
76
+ import click
77
+
78
+ config = load_config()
79
+ section = config.get(name)
80
+ if not section:
81
+ click.echo(f"[ERRO] Secao '{name}' nao encontrada no config.yaml.", err=True)
82
+ sys.exit(1)
83
+ return section
dc_cli/utils/gitlab.py ADDED
@@ -0,0 +1,165 @@
1
+ """GitLab API helper — wrapper fino para requests."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, cast
6
+
7
+ import click
8
+ import requests
9
+
10
+
11
+ class GitLabAPI:
12
+ """Cliente para a API do GitLab."""
13
+
14
+ def __init__(self, host: str, token: str):
15
+ self.host = host.rstrip("/")
16
+ self.token = token
17
+ self.session = requests.Session()
18
+ self.session.headers.update({"PRIVATE-TOKEN": token})
19
+
20
+ def _url(self, path: str) -> str:
21
+ return f"{self.host}/api/v4{path}"
22
+
23
+ def get(self, path: str, **params) -> Any:
24
+ """GET request para a API do GitLab."""
25
+ resp = self.session.get(self._url(path), params=params)
26
+ resp.raise_for_status()
27
+ return resp.json()
28
+
29
+ def post(self, path: str, **data) -> dict[str, Any]:
30
+ """POST request para a API do GitLab."""
31
+ resp = self.session.post(self._url(path), json=data)
32
+ resp.raise_for_status()
33
+ return resp.json()
34
+
35
+ def delete(self, path: str) -> None:
36
+ """DELETE request para a API do GitLab."""
37
+ resp = self.session.delete(self._url(path))
38
+ resp.raise_for_status()
39
+
40
+ def search_groups(self, search: str) -> list[dict[str, Any]]:
41
+ """Busca grupos pelo nome/path."""
42
+ return cast(
43
+ list[dict[str, Any]], self.get("/groups", search=search, per_page=20)
44
+ )
45
+
46
+ def search_projects(self, search: str) -> list[dict[str, Any]]:
47
+ """Busca projetos pelo nome/path."""
48
+ return cast(
49
+ list[dict[str, Any]],
50
+ self.get("/projects", search=search, simple=True, per_page=20),
51
+ )
52
+
53
+ def resolve_group_id(self, group: str) -> int:
54
+ """Resolve group_id a partir de ID numerico ou path.
55
+
56
+ Aceita:
57
+ - "12345" -> retorna 12345
58
+ - "lnp-consulting-ti" -> busca e retorna o ID
59
+ - "lnp-consulting-ti/saas" -> busca e retorna o ID
60
+ """
61
+ if group.isdigit():
62
+ return int(group)
63
+
64
+ # Tentar buscar pelo path exato via URL-encoded path
65
+ try:
66
+ encoded = group.replace("/", "%2F")
67
+ result = cast(dict[str, Any], self.get(f"/groups/{encoded}"))
68
+ return result["id"]
69
+ except requests.HTTPError:
70
+ pass
71
+
72
+ # Fallback: buscar pelo nome
73
+ groups = self.search_groups(group)
74
+ for g in groups:
75
+ if g["full_path"] == group or g["path"] == group:
76
+ return g["id"]
77
+
78
+ if groups:
79
+ click.echo(
80
+ f"[!] Grupo exato '{group}' nao encontrado. Resultados parciais:"
81
+ )
82
+ for g in groups[:5]:
83
+ click.echo(f" {g['full_path']} (id: {g['id']})")
84
+
85
+ raise click.ClickException(f"Grupo '{group}' nao encontrado.")
86
+
87
+ def create_deploy_token(
88
+ self, group_id: int, name: str, scopes: list[str]
89
+ ) -> dict[str, Any]:
90
+ """Cria deploy token no grupo."""
91
+ return self.post(
92
+ f"/groups/{group_id}/deploy_tokens",
93
+ name=name,
94
+ scopes=scopes,
95
+ )
96
+
97
+ def list_deploy_tokens(self, group_id: int) -> list[dict[str, Any]]:
98
+ """Lista deploy tokens do grupo."""
99
+ return cast(
100
+ list[dict[str, Any]],
101
+ self.get(f"/groups/{group_id}/deploy_tokens", per_page=100),
102
+ )
103
+
104
+ def revoke_deploy_token(self, group_id: int, token_id: int) -> None:
105
+ """Revoga deploy token do grupo."""
106
+ self.delete(f"/groups/{group_id}/deploy_tokens/{token_id}")
107
+
108
+ def resolve_project_id(self, project: str) -> int:
109
+ """Resolve project_id a partir de ID numerico ou path."""
110
+ if project.isdigit():
111
+ return int(project)
112
+
113
+ try:
114
+ encoded = project.replace("/", "%2F")
115
+ result = cast(dict[str, Any], self.get(f"/projects/{encoded}"))
116
+ return result["id"]
117
+ except requests.HTTPError:
118
+ pass
119
+
120
+ projects = self.search_projects(project)
121
+ for p in projects:
122
+ if p.get("path_with_namespace") == project or p.get("path") == project:
123
+ return p["id"]
124
+
125
+ if projects:
126
+ click.echo(
127
+ f"[!] Projeto exato '{project}' nao encontrado. Resultados parciais:"
128
+ )
129
+ for p in projects[:5]:
130
+ click.echo(
131
+ f" {p.get('path_with_namespace', '?')} (id: {p.get('id', '?')})"
132
+ )
133
+
134
+ raise click.ClickException(f"Projeto '{project}' nao encontrado.")
135
+
136
+ def create_project_access_token(
137
+ self,
138
+ project_id: int,
139
+ name: str,
140
+ scopes: list[str],
141
+ access_level: int | None = None,
142
+ expires_at: str | None = None,
143
+ ) -> dict[str, Any]:
144
+ """Cria project access token."""
145
+ payload: dict[str, Any] = {
146
+ "name": name,
147
+ "scopes": scopes,
148
+ }
149
+ if access_level is not None:
150
+ payload["access_level"] = access_level
151
+ if expires_at:
152
+ payload["expires_at"] = expires_at
153
+
154
+ return self.post(f"/projects/{project_id}/access_tokens", **payload)
155
+
156
+ def list_project_access_tokens(self, project_id: int) -> list[dict[str, Any]]:
157
+ """Lista project access tokens do projeto."""
158
+ return cast(
159
+ list[dict[str, Any]],
160
+ self.get(f"/projects/{project_id}/access_tokens", per_page=100),
161
+ )
162
+
163
+ def revoke_project_access_token(self, project_id: int, token_id: int) -> None:
164
+ """Revoga project access token do projeto."""
165
+ self.delete(f"/projects/{project_id}/access_tokens/{token_id}")
@@ -0,0 +1,136 @@
1
+ Metadata-Version: 2.4
2
+ Name: lnp-devopscli
3
+ Version: 1.0.0
4
+ Summary: DevOps CLI — workspace sync (GDrive, git, GPG, systemd timers) + GitLab/Bitwarden tooling
5
+ Author-email: Lucas Neves Pires <npires.lucas@gmail.com>
6
+ License: MIT
7
+ Project-URL: Homepage, https://gitlab.com/lnp-consulting-ti/devops/devops-cli
8
+ Project-URL: Repository, https://gitlab.com/lnp-consulting-ti/devops/devops-cli
9
+ Project-URL: Issues, https://gitlab.com/lnp-consulting-ti/devops/devops-cli/-/issues
10
+ Keywords: devops,workspace-sync,rclone,gdrive,gitlab,bitwarden,systemd
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: System Administrators
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: POSIX :: Linux
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: System :: Systems Administration
21
+ Classifier: Topic :: Utilities
22
+ Requires-Python: >=3.10
23
+ Description-Content-Type: text/markdown
24
+ Requires-Dist: click>=8.0
25
+ Requires-Dist: pyyaml>=6.0
26
+ Requires-Dist: requests>=2.28
27
+
28
+ # lnp-devopscli
29
+
30
+ DevOps CLI for workspace sync (Google Drive + git + GPG + systemd timers) and
31
+ GitLab/Bitwarden tooling.
32
+
33
+ ## Install
34
+
35
+ ### Via PyPI (recommended)
36
+
37
+ ```bash
38
+ pipx install lnp-devopscli
39
+ # or
40
+ pip install --user lnp-devopscli
41
+ ```
42
+
43
+ Provides two equivalent commands: `devopscli` and `dc`.
44
+
45
+ ### From source
46
+
47
+ ```bash
48
+ git clone https://gitlab.com/lnp-consulting-ti/devops/devops-cli.git ~/bin/devops-cli
49
+ cd ~/bin/devops-cli && make setup
50
+ ```
51
+
52
+ ### Bootstrap on a new PC
53
+
54
+ For a full workspace bootstrap (rclone + GPG + git-crypt + 10 repos + systemd
55
+ timers), use the public bootstrap launcher:
56
+
57
+ ```bash
58
+ bash <(curl -fsSL https://gitlab.com/-/snippets/6003334/raw/main/bootstrap.sh)
59
+ ```
60
+
61
+ See [MIGRATION-RUNBOOK.md](https://gitlab.com/lnpires/ai/workspace-personal/-/blob/main/MIGRATION-RUNBOOK.md)
62
+ for the complete migration procedure.
63
+
64
+ ## Commands
65
+
66
+ ```bash
67
+ devopscli ws sync # full bidirectional sync (pull → git → push)
68
+ devopscli ws push # sync local → GDrive (13 categories)
69
+ devopscli ws pull # sync GDrive → local
70
+ devopscli ws doctor # health check (deps, configs, timers, repos)
71
+ devopscli ws ai-sync # git pull/commit/push for ai_workspaces
72
+ devopscli ws repos-sync # git pull/commit/push for repos_sync
73
+ devopscli ws gpg-backup # export GPG key + encrypt + send to GDrive
74
+ devopscli ws gpg-restore # download GPG key from GDrive + decrypt + import
75
+ devopscli ws install-timers # install systemd user timers (replaces cron)
76
+ devopscli ws uninstall-timers # remove systemd timers
77
+ ```
78
+
79
+ See `devopscli ws --help` for the full list.
80
+
81
+ ## Security
82
+
83
+ The CLI is open source but never accesses your data without your credentials:
84
+
85
+ - **Google OAuth** protects access to your GDrive (rclone config)
86
+ - **GPG passphrase** protects the GPG key backup
87
+ - **git-crypt** protects `.env` files inside synced repos
88
+ - **GitLab/SSH keys** protect access to private repositories
89
+
90
+ Anyone can `pip install` the CLI — but without your credentials, they cannot
91
+ access your data.
92
+
93
+ ## Configuration
94
+
95
+ Edit `~/bin/config.yaml` (synced via `ws push` step 7):
96
+
97
+ ```yaml
98
+ ai_workspaces:
99
+ - name: "workspace-personal"
100
+ path: "~/workspace-personal"
101
+ clone_url: "git@gitlab.com:user/repo.git"
102
+ remote: "origin"
103
+ branch: "main"
104
+ auto_commit: true
105
+ commit_message: "chore(auto-sync): snapshot via ws ai-sync"
106
+
107
+ repos_sync:
108
+ - name: "devops-cli"
109
+ path: "~/bin/devops-cli"
110
+ clone_url: "git@gitlab.com:lnp-consulting-ti/devops/devops-cli.git"
111
+ remote: "origin"
112
+ branch: "main"
113
+ auto_commit: true
114
+ commit_message: "chore(auto-sync): snapshot via ws repos-sync"
115
+ ```
116
+
117
+ ## Release
118
+
119
+ To publish a new version to PyPI:
120
+
121
+ ```bash
122
+ # 1. Bump version in pyproject.toml
123
+ git commit -am "chore(release): v1.0.1"
124
+
125
+ # 2. Tag and push
126
+ git tag v1.0.1
127
+ git push origin main v1.0.1
128
+ ```
129
+
130
+ The GitLab CI pipeline auto-publishes when a tag matches `v*.*.*`.
131
+
132
+ Setup once: add `PYPI_TOKEN` to GitLab CI/CD Variables (Settings > CI/CD).
133
+
134
+ ## License
135
+
136
+ MIT
@@ -0,0 +1,14 @@
1
+ dc_cli/__init__.py,sha256=V8NQxHA6heIkRMo_WVrkPrlazyK21jUDcGWwRuNgQzs,61
2
+ dc_cli/main.py,sha256=h30wx8jGTZ_qOpYtMzfTUB2nW3VKePcXRSoLYUOMWZ0,1326
3
+ dc_cli/groups/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ dc_cli/groups/bw.py,sha256=AACX6MSslPjlU2tHTX5FL8tDlHf4IzZtRTlxcnfvd5Q,12997
5
+ dc_cli/groups/gl.py,sha256=d_OVtQOGRcPTQ2yJOwUiVbrcuBqiEOahrZ1i1_Jr4X0,9632
6
+ dc_cli/groups/ws.py,sha256=bRhOz6ar0DgZX8pd-hgwfML_r3S101j_RnUAvpYlaWM,47147
7
+ dc_cli/utils/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
8
+ dc_cli/utils/config.py,sha256=rnZmJqfd3vq0MoD5UFwzVVtJjQLw28aWS22RqxaxAag,2383
9
+ dc_cli/utils/gitlab.py,sha256=yK0UuvYfwxgV1T6nJxo0iOm-pYQWZJ9sWkjl01Ko1sw,5577
10
+ lnp_devopscli-1.0.0.dist-info/METADATA,sha256=Neg9EcLmTYgNbjRRP0w7vMNGn4AZpyiSD9-JSsvdoZI,4208
11
+ lnp_devopscli-1.0.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
12
+ lnp_devopscli-1.0.0.dist-info/entry_points.txt,sha256=yvFR7YX57Mr_KtRiCA2muf61eVGV2ThFmXOu6O2jy0U,69
13
+ lnp_devopscli-1.0.0.dist-info/top_level.txt,sha256=lddxuZKQNgGZHWu5I8IQX_PYOOYHfVQZpiBIeU2DYfc,7
14
+ lnp_devopscli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ dc = dc_cli.main:main
3
+ devopscli = dc_cli.main:main
@@ -0,0 +1 @@
1
+ dc_cli