nasiko-cli 2.0.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.
Files changed (103) hide show
  1. nasiko_cli-2.0.0/PKG-INFO +33 -0
  2. nasiko_cli-2.0.0/auth/__init__.py +1 -0
  3. nasiko_cli-2.0.0/auth/auth_commands.py +218 -0
  4. nasiko_cli-2.0.0/auth/auth_manager.py +381 -0
  5. nasiko_cli-2.0.0/commands/__init__.py +1 -0
  6. nasiko_cli-2.0.0/commands/access.py +299 -0
  7. nasiko_cli-2.0.0/commands/chat_history.py +198 -0
  8. nasiko_cli-2.0.0/commands/chat_send.py +173 -0
  9. nasiko_cli-2.0.0/commands/github.py +383 -0
  10. nasiko_cli-2.0.0/commands/n8n.py +251 -0
  11. nasiko_cli-2.0.0/commands/observability.py +974 -0
  12. nasiko_cli-2.0.0/commands/registry.py +437 -0
  13. nasiko_cli-2.0.0/commands/search.py +127 -0
  14. nasiko_cli-2.0.0/commands/upload_agent.py +270 -0
  15. nasiko_cli-2.0.0/commands/user_management.py +327 -0
  16. nasiko_cli-2.0.0/core/__init__.py +1 -0
  17. nasiko_cli-2.0.0/core/api_client.py +374 -0
  18. nasiko_cli-2.0.0/core/settings.py +94 -0
  19. nasiko_cli-2.0.0/groups/__init__.py +1 -0
  20. nasiko_cli-2.0.0/groups/access_group.py +77 -0
  21. nasiko_cli-2.0.0/groups/agent_group.py +109 -0
  22. nasiko_cli-2.0.0/groups/chat_group.py +90 -0
  23. nasiko_cli-2.0.0/groups/github_group.py +57 -0
  24. nasiko_cli-2.0.0/groups/images_group.py +478 -0
  25. nasiko_cli-2.0.0/groups/local_group.py +610 -0
  26. nasiko_cli-2.0.0/groups/n8n_group.py +94 -0
  27. nasiko_cli-2.0.0/groups/observability_group.py +81 -0
  28. nasiko_cli-2.0.0/groups/search_group.py +34 -0
  29. nasiko_cli-2.0.0/groups/user_group.py +89 -0
  30. nasiko_cli-2.0.0/k8s/__init__.py +1 -0
  31. nasiko_cli-2.0.0/k8s/agent-rbac.yaml +57 -0
  32. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/Chart.yaml +0 -0
  33. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/environments/dev.yaml +0 -0
  34. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/environments/prod.yaml +0 -0
  35. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/environments/staging.yaml +0 -0
  36. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/configmaps/app-config.yaml +0 -0
  37. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/buildkit/deployment.yaml +79 -0
  38. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/buildkit/namespace.yaml +6 -0
  39. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/buildkit/pvc.yaml +13 -0
  40. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/buildkit/regcred-secret.yaml +15 -0
  41. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/buildkit/service.yaml +12 -0
  42. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/buildkit/serviceaccount.yaml +5 -0
  43. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/mongodb.yaml +31 -0
  44. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/ollama.yaml +56 -0
  45. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/phoenix.yaml +146 -0
  46. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/postgresql.yaml +32 -0
  47. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/infrastructure/redis.yaml +27 -0
  48. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/initialization/superuser-init.yaml +154 -0
  49. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/namespace.yaml +14 -0
  50. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/networking/ingress.yaml +0 -0
  51. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/networking/networkpolicies.yaml +0 -0
  52. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/rbac/clusterrole.yaml +33 -0
  53. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/rbac/clusterrolebinding.yaml +12 -0
  54. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/rbac/serviceaccount.yaml +5 -0
  55. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/secrets/registry-secret.yaml +0 -0
  56. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/agent-gateway/deployment.yaml +136 -0
  57. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/agent-gateway/kong-migrations.yaml +29 -0
  58. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/agent-gateway/kong-plugins-config.yaml +349 -0
  59. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/agent-gateway/service-registry-deployment.yaml +56 -0
  60. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/auth-service/deployment.yaml +47 -0
  61. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/n8n/deployment.yaml +84 -0
  62. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/n8n/pvc.yaml +14 -0
  63. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/n8n/service.yaml +16 -0
  64. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/nasiko-backend/deployment.yaml +67 -0
  65. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/nasiko-k8s-build-worker/deployment.yaml +101 -0
  66. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/nasiko-router/deployment.yaml +62 -0
  67. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/templates/services/nasiko-web/deployment.yaml +49 -0
  68. nasiko_cli-2.0.0/k8s/charts/nasiko-platform/values.yaml +0 -0
  69. nasiko_cli-2.0.0/k8s/dashboard-admin.yaml +18 -0
  70. nasiko_cli-2.0.0/k8s/kube-dashboard.yaml +307 -0
  71. nasiko_cli-2.0.0/k8s/utils.py +150 -0
  72. nasiko_cli-2.0.0/main.py +283 -0
  73. nasiko_cli-2.0.0/nasiko_cli.egg-info/PKG-INFO +33 -0
  74. nasiko_cli-2.0.0/nasiko_cli.egg-info/SOURCES.txt +101 -0
  75. nasiko_cli-2.0.0/nasiko_cli.egg-info/dependency_links.txt +1 -0
  76. nasiko_cli-2.0.0/nasiko_cli.egg-info/entry_points.txt +2 -0
  77. nasiko_cli-2.0.0/nasiko_cli.egg-info/requires.txt +16 -0
  78. nasiko_cli-2.0.0/nasiko_cli.egg-info/top_level.txt +8 -0
  79. nasiko_cli-2.0.0/pyproject.toml +78 -0
  80. nasiko_cli-2.0.0/setup/__init__.py +0 -0
  81. nasiko_cli-2.0.0/setup/app_setup.py +1194 -0
  82. nasiko_cli-2.0.0/setup/buildkit_setup.py +342 -0
  83. nasiko_cli-2.0.0/setup/config.py +611 -0
  84. nasiko_cli-2.0.0/setup/container_registry_setup.py +626 -0
  85. nasiko_cli-2.0.0/setup/harbor_setup.py +332 -0
  86. nasiko_cli-2.0.0/setup/k8s_setup.py +672 -0
  87. nasiko_cli-2.0.0/setup/setup.py +2149 -0
  88. nasiko_cli-2.0.0/setup/terraform/__init__.py +2 -0
  89. nasiko_cli-2.0.0/setup/terraform/aws/__init__.py +1 -0
  90. nasiko_cli-2.0.0/setup/terraform/aws/main.tf +225 -0
  91. nasiko_cli-2.0.0/setup/terraform/aws/outputs.tf +14 -0
  92. nasiko_cli-2.0.0/setup/terraform/aws/variables.tf +23 -0
  93. nasiko_cli-2.0.0/setup/terraform/aws/versions.tf +10 -0
  94. nasiko_cli-2.0.0/setup/terraform/digitalocean/__init__.py +1 -0
  95. nasiko_cli-2.0.0/setup/terraform/digitalocean/doks.tf +47 -0
  96. nasiko_cli-2.0.0/setup/terraform/digitalocean/outputs.tf +59 -0
  97. nasiko_cli-2.0.0/setup/terraform/digitalocean/provider.tf +13 -0
  98. nasiko_cli-2.0.0/setup/terraform/digitalocean/variables.tf +65 -0
  99. nasiko_cli-2.0.0/setup/terraform_state.py +325 -0
  100. nasiko_cli-2.0.0/setup/utils.py +535 -0
  101. nasiko_cli-2.0.0/setup.cfg +4 -0
  102. nasiko_cli-2.0.0/utils/__init__.py +1 -0
  103. nasiko_cli-2.0.0/utils/utils.py +3 -0
@@ -0,0 +1,33 @@
1
+ Metadata-Version: 2.4
2
+ Name: nasiko-cli
3
+ Version: 2.0.0
4
+ Summary: Command-line interface for Nasiko agent management platform
5
+ Author: Nasiko Team
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/arithmic/nasiko
8
+ Project-URL: Repository, https://github.com/arithmic/nasiko
9
+ Project-URL: Issues, https://github.com/arithmic/nasiko/issues
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: Operating System :: OS Independent
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Requires-Python: >=3.9
19
+ Requires-Dist: typer>=0.9.0
20
+ Requires-Dist: requests>=2.28.0
21
+ Requires-Dist: rich>=13.0.0
22
+ Requires-Dist: kubernetes>=34.1.0
23
+ Requires-Dist: docker>=7.1.0
24
+ Requires-Dist: keyring>=24.0.0
25
+ Requires-Dist: cryptography>=41.0.0
26
+ Requires-Dist: python-dotenv>=1.0.0
27
+ Provides-Extra: dev
28
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
29
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
30
+ Requires-Dist: black>=23.0.0; extra == "dev"
31
+ Requires-Dist: isort>=5.0.0; extra == "dev"
32
+ Requires-Dist: flake8>=6.0.0; extra == "dev"
33
+ Requires-Dist: mypy>=1.0.0; extra == "dev"
@@ -0,0 +1 @@
1
+ # Auth module for Nasiko CLI
@@ -0,0 +1,218 @@
1
+ """
2
+ Authentication commands for Nasiko CLI.
3
+ """
4
+
5
+ import typer
6
+ from typing import Optional
7
+ import sys
8
+ import os
9
+
10
+ sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
11
+
12
+ from auth.auth_manager import get_auth_manager
13
+ from core.api_client import get_api_client
14
+
15
+ # Create auth command group
16
+ auth_app = typer.Typer(help="Authentication commands")
17
+
18
+
19
+ @auth_app.command("login")
20
+ def login_command(
21
+ access_key: Optional[str] = typer.Option(
22
+ None, "--access-key", "-k", help="Your access key"
23
+ ),
24
+ access_secret: Optional[str] = typer.Option(
25
+ None, "--access-secret", "-s", help="Your access secret"
26
+ ),
27
+ save_credentials: bool = typer.Option(
28
+ True, "--save-credentials/--no-save", help="Save credentials for auto-renewal"
29
+ ),
30
+ api_url: Optional[str] = typer.Option(
31
+ None, "--api-url", help="API base URL (optional)"
32
+ ),
33
+ ):
34
+ """Login to Nasiko with access key and secret."""
35
+
36
+ # Get credentials interactively if not provided
37
+ if not access_key:
38
+ access_key = typer.prompt("Access Key")
39
+
40
+ if not access_secret:
41
+ access_secret = typer.prompt("Access Secret", hide_input=True)
42
+
43
+ # Validate inputs
44
+ if not access_key or not access_secret:
45
+ typer.echo("❌ Access key and secret are required")
46
+ raise typer.Exit(1)
47
+
48
+ if not access_key.startswith("NASK_"):
49
+ typer.echo("❌ Invalid access key format (should start with NASK_)")
50
+ raise typer.Exit(1)
51
+
52
+ # Login with auth manager
53
+ auth_manager = get_auth_manager()
54
+ if api_url:
55
+ auth_manager.base_url = api_url
56
+
57
+ typer.echo("🔐 Authenticating...")
58
+
59
+ if auth_manager.login(access_key, access_secret, save_credentials):
60
+ # Get user info if available
61
+ user_info = auth_manager.get_user_info()
62
+ if user_info:
63
+ username = user_info.get("username", "Unknown")
64
+ is_super = user_info.get("is_super_user", False)
65
+ role = "Super User" if is_super else "User"
66
+ typer.echo(f"👋 Welcome back, {username} ({role})")
67
+
68
+ typer.echo("\n🚀 You can now use authenticated commands:")
69
+ typer.echo(" • nasiko registry-list")
70
+ typer.echo(" • nasiko upload-zip <file.zip>")
71
+ typer.echo(" • nasiko status")
72
+ typer.echo(" • nasiko traces <agent-name>")
73
+ else:
74
+ raise typer.Exit(1)
75
+
76
+
77
+ @auth_app.command("logout")
78
+ def logout_command(
79
+ clear_all: bool = typer.Option(
80
+ False, "--clear-all", help="Clear all stored credentials"
81
+ )
82
+ ):
83
+ """Logout from Nasiko."""
84
+
85
+ auth_manager = get_auth_manager()
86
+
87
+ if not auth_manager.is_logged_in():
88
+ typer.echo("ℹ️ You are not logged in")
89
+ return
90
+
91
+ if auth_manager.logout(clear_credentials=clear_all):
92
+ if clear_all:
93
+ typer.echo("🗑️ All authentication data cleared")
94
+ typer.echo("👋 See you next time!")
95
+ else:
96
+ typer.echo("⚠️ Logout may not have completed successfully")
97
+
98
+
99
+ @auth_app.command("status")
100
+ def status_command():
101
+ """Check authentication status."""
102
+
103
+ auth_manager = get_auth_manager()
104
+
105
+ if auth_manager.is_logged_in():
106
+ typer.echo("✅ Logged in")
107
+
108
+ # Try to get user info
109
+ user_info = auth_manager.get_user_info()
110
+ if user_info:
111
+ typer.echo(f" User: {user_info.get('username', 'Unknown')}")
112
+ typer.echo(
113
+ f" Role: {'Super User' if user_info.get('is_super_user') else 'User'}"
114
+ )
115
+ typer.echo(f" Email: {user_info.get('email', 'Not available')}")
116
+
117
+ if user_info.get("last_login"):
118
+ typer.echo(f" Last login: {user_info['last_login']}")
119
+
120
+ # Test API connectivity
121
+ try:
122
+ client = get_api_client()
123
+ response = client.get("healthcheck", require_auth=False)
124
+ if response.status_code == 200:
125
+ typer.echo(" API: ✅ Connected")
126
+ else:
127
+ typer.echo(" API: ⚠️ Connection issues")
128
+ except:
129
+ typer.echo(" API: ❌ Cannot connect")
130
+
131
+ else:
132
+ typer.echo("❌ Not logged in")
133
+ typer.echo("\n💡 To login:")
134
+ typer.echo(" nasiko login")
135
+
136
+
137
+ @auth_app.command("whoami")
138
+ def whoami_command():
139
+ """Show current user information."""
140
+
141
+ auth_manager = get_auth_manager()
142
+
143
+ if not auth_manager.is_logged_in():
144
+ typer.echo("❌ Not logged in")
145
+ typer.echo("💡 Use: nasiko login")
146
+ raise typer.Exit(1)
147
+
148
+ user_info = auth_manager.get_user_info()
149
+
150
+ if not user_info:
151
+ typer.echo("❌ Could not retrieve user information")
152
+ typer.echo("💡 Try: nasiko login")
153
+ raise typer.Exit(1)
154
+
155
+ # Display user information
156
+ typer.echo("👤 User Information:")
157
+ typer.echo(f" Username: {user_info.get('username', 'Unknown')}")
158
+ typer.echo(f" Email: {user_info.get('email', 'Not available')}")
159
+ typer.echo(f" Role: {'Super User' if user_info.get('is_super_user') else 'User'}")
160
+ typer.echo(f" Active: {'Yes' if user_info.get('is_active') else 'No'}")
161
+
162
+ if user_info.get("created_at"):
163
+ typer.echo(f" Created: {user_info['created_at']}")
164
+
165
+ if user_info.get("last_login"):
166
+ typer.echo(f" Last login: {user_info['last_login']}")
167
+
168
+
169
+ # Standalone commands for backward compatibility
170
+ def login_standalone(
171
+ access_key: str = None,
172
+ access_secret: str = None,
173
+ save_credentials: bool = True,
174
+ api_url: str = None,
175
+ ):
176
+ """Standalone login function for backward compatibility"""
177
+ # Call the actual login_command with proper parameters
178
+ return _do_login(access_key, access_secret, save_credentials, api_url)
179
+
180
+
181
+ def _do_login(
182
+ access_key: Optional[str],
183
+ access_secret: Optional[str],
184
+ save_credentials: bool = True,
185
+ api_url: Optional[str] = None,
186
+ ):
187
+ """Internal login function that does the actual work"""
188
+ # Validate inputs
189
+ if not access_key or not access_secret:
190
+ typer.echo("❌ Access key and secret are required")
191
+ raise typer.Exit(1)
192
+
193
+ if not access_key.startswith("NASK_"):
194
+ typer.echo("❌ Invalid access key format (should start with NASK_)")
195
+ raise typer.Exit(1)
196
+
197
+ # Login with auth manager
198
+ auth_manager = get_auth_manager()
199
+ if api_url:
200
+ auth_manager.auth_url = api_url
201
+
202
+ typer.echo("🔐 Authenticating...")
203
+
204
+ if auth_manager.login(access_key, access_secret, save_credentials):
205
+ # Get user info if available
206
+ user_info = auth_manager.get_user_info()
207
+ if user_info:
208
+ username = user_info.get("username", "Unknown")
209
+ is_super = user_info.get("is_super_user", False)
210
+ role = "Super User" if is_super else "User"
211
+ typer.echo(f"👋 Welcome back, {username} ({role})")
212
+ else:
213
+ raise typer.Exit(1)
214
+
215
+
216
+ # Export for use in main CLI
217
+ if __name__ == "__main__":
218
+ auth_app()
@@ -0,0 +1,381 @@
1
+ """
2
+ Secure Authentication Manager for Nasiko CLI.
3
+ Provides secure token storage using OS keyring with fallback to encrypted files.
4
+ """
5
+
6
+ import json
7
+ from typing import Optional, Dict, Any
8
+ import requests
9
+ import typer
10
+
11
+ # Try to import keyring for secure storage
12
+ try:
13
+ import keyring
14
+
15
+ KEYRING_AVAILABLE = True
16
+ except ImportError:
17
+ KEYRING_AVAILABLE = False
18
+ typer.echo(
19
+ "⚠️ Warning: keyring not available. Falling back to file-based storage."
20
+ )
21
+
22
+ # Fallback imports for encrypted storage
23
+ try:
24
+ from cryptography.fernet import Fernet
25
+ import base64
26
+ import hashlib
27
+
28
+ CRYPTO_AVAILABLE = True
29
+ except ImportError:
30
+ CRYPTO_AVAILABLE = False
31
+
32
+ import sys
33
+ import os
34
+
35
+ # Add CLI directory to path for imports
36
+ cli_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
37
+ if cli_dir not in sys.path:
38
+ sys.path.insert(0, cli_dir)
39
+
40
+ from core.settings import CONFIG_DIR
41
+ from setup.config import get_cluster_api_url
42
+
43
+
44
+ class AuthManager:
45
+ """Secure authentication manager for Nasiko CLI"""
46
+
47
+ SERVICE_NAME = "nasiko-cli"
48
+ TOKEN_KEY = "jwt_token"
49
+ CREDS_KEY = "user_creds"
50
+
51
+ def __init__(self, base_url: str = None, cluster_name: str = None):
52
+ self.config_dir = CONFIG_DIR
53
+ self.config_dir.mkdir(parents=True, exist_ok=True)
54
+
55
+ # Determine Base URL
56
+ if base_url:
57
+ self.base_url = base_url.rstrip("/")
58
+ elif cluster_name:
59
+ # Look up URL for specific cluster
60
+ url = get_cluster_api_url(cluster_name)
61
+ if url:
62
+ self.base_url = url.rstrip("/")
63
+ else:
64
+ # Fallback to localhost if cluster unknown (shouldn't happen if validated before)
65
+ self.base_url = "http://localhost:8000"
66
+ else:
67
+ # Fallback 1: NASIKO_API_URL env var
68
+ env_url = os.getenv("NASIKO_API_URL")
69
+ if env_url:
70
+ self.base_url = env_url.rstrip("/")
71
+ else:
72
+ # Check for NASIKO_CLUSTER_NAME env var
73
+ default_cluster = os.environ.get("NASIKO_CLUSTER_NAME")
74
+ if default_cluster:
75
+ url = get_cluster_api_url(default_cluster)
76
+ if url:
77
+ self.base_url = url.rstrip("/")
78
+ else:
79
+ self.base_url = "http://localhost:8000"
80
+ else:
81
+ # Fallback 2: Default to localhost
82
+ self.base_url = "http://localhost:8000"
83
+
84
+ self.auth_url = self.base_url # Auth service is at base URL
85
+
86
+ # File-based fallback paths
87
+ self.token_file = self.config_dir / "token.enc"
88
+ self.creds_file = self.config_dir / "credentials.enc"
89
+
90
+ def _get_encryption_key(self) -> bytes:
91
+ """Generate encryption key from system/user information"""
92
+ if not CRYPTO_AVAILABLE:
93
+ raise RuntimeError("Cryptography not available for secure storage")
94
+
95
+ # Use a combination of username and machine info for key derivation
96
+ import getpass
97
+ import platform
98
+
99
+ user_info = f"{getpass.getuser()}:{platform.node()}:{platform.system()}"
100
+ key = hashlib.pbkdf2_hmac(
101
+ "sha256", user_info.encode(), b"nasiko-cli-salt", 100000
102
+ )
103
+ return base64.urlsafe_b64encode(key)
104
+
105
+ def _encrypt_data(self, data: str) -> bytes:
106
+ """Encrypt data using system-derived key"""
107
+ if not CRYPTO_AVAILABLE:
108
+ return data.encode()
109
+
110
+ key = self._get_encryption_key()
111
+ f = Fernet(key)
112
+ return f.encrypt(data.encode())
113
+
114
+ def _decrypt_data(self, encrypted_data: bytes) -> str:
115
+ """Decrypt data using system-derived key"""
116
+ if not CRYPTO_AVAILABLE:
117
+ return encrypted_data.decode()
118
+
119
+ try:
120
+ key = self._get_encryption_key()
121
+ f = Fernet(key)
122
+ return f.decrypt(encrypted_data).decode()
123
+ except Exception:
124
+ raise ValueError("Failed to decrypt data - key may have changed")
125
+
126
+ def _store_secure(self, key: str, value: str) -> bool:
127
+ """Store value securely using best available method"""
128
+ if KEYRING_AVAILABLE:
129
+ try:
130
+ keyring.set_password(self.SERVICE_NAME, key, value)
131
+ return True
132
+ except Exception as e:
133
+ typer.echo(f"⚠️ Keyring storage failed: {e}")
134
+
135
+ # Fallback to encrypted file storage
136
+ try:
137
+ if key == self.TOKEN_KEY:
138
+ file_path = self.token_file
139
+ else:
140
+ file_path = self.creds_file
141
+
142
+ encrypted_data = self._encrypt_data(value)
143
+ file_path.write_bytes(encrypted_data)
144
+ file_path.chmod(0o600) # Read-write for owner only
145
+ return True
146
+ except Exception as e:
147
+ typer.echo(f"❌ Failed to store {key} securely: {e}")
148
+ return False
149
+
150
+ def _retrieve_secure(self, key: str) -> Optional[str]:
151
+ """Retrieve value securely using best available method"""
152
+ if KEYRING_AVAILABLE:
153
+ try:
154
+ value = keyring.get_password(self.SERVICE_NAME, key)
155
+ if value:
156
+ return value
157
+ except Exception as e:
158
+ typer.echo(f"⚠️ Keyring retrieval failed: {e}")
159
+
160
+ # Fallback to encrypted file storage
161
+ try:
162
+ if key == self.TOKEN_KEY:
163
+ file_path = self.token_file
164
+ else:
165
+ file_path = self.creds_file
166
+
167
+ if file_path.exists():
168
+ encrypted_data = file_path.read_bytes()
169
+ return self._decrypt_data(encrypted_data)
170
+ except Exception as e:
171
+ typer.echo(f"⚠️ Failed to retrieve {key}: {e}")
172
+
173
+ return None
174
+
175
+ def _delete_secure(self, key: str) -> bool:
176
+ """Delete stored value securely"""
177
+ success = True
178
+
179
+ if KEYRING_AVAILABLE:
180
+ try:
181
+ keyring.delete_password(self.SERVICE_NAME, key)
182
+ except Exception:
183
+ pass # May not exist
184
+
185
+ # Also remove file-based storage
186
+ try:
187
+ if key == self.TOKEN_KEY:
188
+ file_path = self.token_file
189
+ else:
190
+ file_path = self.creds_file
191
+
192
+ if file_path.exists():
193
+ file_path.unlink()
194
+ except Exception:
195
+ success = False
196
+
197
+ return success
198
+
199
+ def login(
200
+ self, access_key: str, access_secret: str, save_credentials: bool = True
201
+ ) -> bool:
202
+ """Login and store JWT token securely"""
203
+ try:
204
+ # Make login request
205
+ base_url_str = str(self.base_url) # Ensure it's a string
206
+ login_url = f"{self.auth_url}/auth/users/login"
207
+ response = requests.post(
208
+ login_url,
209
+ json={"access_key": access_key, "access_secret": access_secret},
210
+ timeout=30,
211
+ )
212
+
213
+ if response.status_code == 200:
214
+ token_data = response.json()
215
+ jwt_token = token_data["token"]
216
+
217
+ # Store JWT token securely
218
+ if self._store_secure(self.TOKEN_KEY, jwt_token):
219
+ typer.echo("✅ Login successful! Token stored securely.")
220
+
221
+ # Optionally store credentials for auto-renewal
222
+ if save_credentials:
223
+ creds = json.dumps(
224
+ {"access_key": access_key, "access_secret": access_secret}
225
+ )
226
+ self._store_secure(self.CREDS_KEY, creds)
227
+
228
+ return True
229
+ else:
230
+ typer.echo("❌ Login succeeded but failed to store token securely")
231
+ return False
232
+ else:
233
+ error_detail = (
234
+ response.json().get("detail", "Unknown error")
235
+ if response.content
236
+ else "Connection failed"
237
+ )
238
+ typer.echo(f"❌ Login failed: {error_detail}")
239
+ return False
240
+
241
+ except requests.exceptions.RequestException as e:
242
+ typer.echo(f"❌ Connection error: {e}")
243
+ return False
244
+ except Exception as e:
245
+ typer.echo(f"❌ Login error: {e}")
246
+ return False
247
+
248
+ def get_auth_headers(self) -> Optional[Dict[str, str]]:
249
+ """Get authentication headers for API calls"""
250
+ token = self._retrieve_secure(self.TOKEN_KEY)
251
+ if token:
252
+ return {"Authorization": f"Bearer {token}"}
253
+ return None
254
+
255
+ def is_logged_in(self) -> bool:
256
+ """Check if user is logged in"""
257
+ return self.get_auth_headers() is not None
258
+
259
+ def logout(self, clear_credentials: bool = False) -> bool:
260
+ """Logout and remove stored token"""
261
+ try:
262
+ success = self._delete_secure(self.TOKEN_KEY)
263
+
264
+ if clear_credentials:
265
+ self._delete_secure(self.CREDS_KEY)
266
+
267
+ if success:
268
+ typer.echo("✅ Logged out successfully!")
269
+ else:
270
+ typer.echo("⚠️ Logout completed (some data may not have been cleared)")
271
+ return True
272
+ except Exception as e:
273
+ typer.echo(f"❌ Logout error: {e}")
274
+ return False
275
+
276
+ def refresh_token_if_needed(self) -> bool:
277
+ """Check token validity and refresh if needed"""
278
+ headers = self.get_auth_headers()
279
+ if not headers:
280
+ return False
281
+
282
+ try:
283
+ # Test token with a lightweight API call
284
+ base_url_str = str(self.base_url) # Ensure it's a string
285
+ health_url = f"{base_url_str}/api/v1/healthcheck"
286
+ response = requests.get(health_url, headers=headers, timeout=10)
287
+
288
+ if response.status_code == 401:
289
+ # Try to auto-renew with stored credentials
290
+ return self._auto_renew_token()
291
+
292
+ return response.status_code == 200
293
+
294
+ except Exception:
295
+ return self._auto_renew_token()
296
+
297
+ def _auto_renew_token(self) -> bool:
298
+ """Attempt to automatically renew token using stored credentials"""
299
+ try:
300
+ creds_data = self._retrieve_secure(self.CREDS_KEY)
301
+ if not creds_data:
302
+ return False
303
+
304
+ creds = json.loads(creds_data)
305
+ typer.echo("🔄 Token expired, attempting auto-renewal...")
306
+
307
+ # Clear old token and re-login
308
+ self._delete_secure(self.TOKEN_KEY)
309
+ return self.login(
310
+ creds["access_key"], creds["access_secret"], save_credentials=False
311
+ )
312
+
313
+ except Exception:
314
+ typer.echo("❌ Auto-renewal failed. Please login again.")
315
+ return False
316
+
317
+ def get_user_info(self) -> Optional[Dict[str, Any]]:
318
+ """Get current user information"""
319
+ headers = self.get_auth_headers()
320
+ if not headers:
321
+ return None
322
+
323
+ try:
324
+ user_url = f"{self.auth_url}/auth/user/"
325
+ response = requests.get(user_url, headers=headers, timeout=10)
326
+
327
+ if response.status_code == 200:
328
+ return response.json()
329
+ return None
330
+
331
+ except Exception:
332
+ return None
333
+
334
+ def clear_all_data(self) -> bool:
335
+ """Clear all stored authentication data"""
336
+ try:
337
+ self._delete_secure(self.TOKEN_KEY)
338
+ self._delete_secure(self.CREDS_KEY)
339
+
340
+ # Also remove any legacy token files
341
+ legacy_files = [
342
+ self.config_dir / "token",
343
+ self.config_dir / "token.json",
344
+ ]
345
+
346
+ for file_path in legacy_files:
347
+ if file_path.exists():
348
+ file_path.unlink()
349
+
350
+ typer.echo("✅ All authentication data cleared!")
351
+ return True
352
+
353
+ except Exception as e:
354
+ typer.echo(f"❌ Error clearing data: {e}")
355
+ return False
356
+
357
+
358
+ # Global auth manager instances cache
359
+ _auth_managers: Dict[str, AuthManager] = {}
360
+
361
+
362
+ def get_auth_manager(base_url: str = None, cluster_name: str = None) -> AuthManager:
363
+ """
364
+ Get an auth manager instance, creating one if needed.
365
+ Instances are cached by cluster_name to ensure singleton behavior per cluster.
366
+ """
367
+ global _auth_managers
368
+
369
+ # Determine cache key
370
+ if cluster_name:
371
+ key = cluster_name
372
+ elif base_url:
373
+ key = base_url
374
+ else:
375
+ # Check env var for default cluster
376
+ key = os.environ.get("NASIKO_CLUSTER_NAME", "default")
377
+
378
+ if key not in _auth_managers:
379
+ _auth_managers[key] = AuthManager(base_url=base_url, cluster_name=cluster_name)
380
+
381
+ return _auth_managers[key]
@@ -0,0 +1 @@
1
+ # Commands module for Nasiko CLI