loki-mode 6.53.0 → 6.55.0

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 (46) hide show
  1. package/SKILL.md +2 -2
  2. package/VERSION +1 -1
  3. package/bin/postinstall.js +29 -0
  4. package/dashboard/__init__.py +1 -1
  5. package/docs/INSTALLATION.md +1 -1
  6. package/mcp/__init__.py +1 -1
  7. package/package.json +11 -2
  8. package/web-app/Dockerfile +59 -0
  9. package/web-app/alembic.ini +43 -0
  10. package/web-app/auth.py +249 -0
  11. package/web-app/crypto.py +83 -0
  12. package/web-app/deploy/k8s/purple-lab/configmap.yaml +8 -0
  13. package/web-app/deploy/k8s/purple-lab/deployment.yaml +69 -0
  14. package/web-app/deploy/k8s/purple-lab/hpa.yaml +24 -0
  15. package/web-app/deploy/k8s/purple-lab/ingress.yaml +30 -0
  16. package/web-app/deploy/k8s/purple-lab/networkpolicy.yaml +82 -0
  17. package/web-app/deploy/k8s/purple-lab/pdb.yaml +11 -0
  18. package/web-app/deploy/k8s/purple-lab/postgres.yaml +84 -0
  19. package/web-app/deploy/k8s/purple-lab/pvc.yaml +10 -0
  20. package/web-app/deploy/k8s/purple-lab/secret.yaml +13 -0
  21. package/web-app/deploy/k8s/purple-lab/service.yaml +13 -0
  22. package/web-app/deploy/k8s/purple-lab/serviceaccount.yaml +7 -0
  23. package/web-app/dist/assets/{Badge-CnWBUi7C.js → Badge-BDr4DPCT.js} +1 -1
  24. package/web-app/dist/assets/{Button-5ThWFbkO.js → Button-WBFGRnUr.js} +1 -1
  25. package/web-app/dist/assets/{Card-CcTmaOCN.js → Card-DzOT34Rr.js} +1 -1
  26. package/web-app/dist/assets/{HomePage-Dx4Ae0hu.js → HomePage-B8kMCXMB.js} +1 -1
  27. package/web-app/dist/assets/{LoginPage-CRffqZNo.js → LoginPage-D9lCyiqM.js} +1 -1
  28. package/web-app/dist/assets/{NotFoundPage-B1QZ92yR.js → NotFoundPage-DzeZ0uQ6.js} +1 -1
  29. package/web-app/dist/assets/{ProjectPage-BVnDGxXk.js → ProjectPage-C-k0iy0i.js} +14 -14
  30. package/web-app/dist/assets/{ProjectsPage-2Fi6cKB-.js → ProjectsPage-jys_pHzp.js} +1 -1
  31. package/web-app/dist/assets/{SettingsPage-DOzGoyLv.js → SettingsPage-Cz_RXr82.js} +1 -1
  32. package/web-app/dist/assets/{TemplatesPage-B-f1Gfbg.js → TemplatesPage-COnhb_Wq.js} +1 -1
  33. package/web-app/dist/assets/{TerminalOutput-DrKIbiB8.js → TerminalOutput-CmdEXHHd.js} +1 -1
  34. package/web-app/dist/assets/{arrow-left-CFG0TEkb.js → arrow-left-DAZzI0L-.js} +1 -1
  35. package/web-app/dist/assets/{clock-C-GPrW5k.js → clock-BHGf6zSk.js} +1 -1
  36. package/web-app/dist/assets/{external-link-ujbkNBY4.js → external-link-DLYjfP9j.js} +1 -1
  37. package/web-app/dist/assets/{index-B8gGcUMo.js → index-B8Eg1YHL.js} +2 -2
  38. package/web-app/dist/index.html +1 -1
  39. package/web-app/docker-compose.purple-lab.yml +76 -0
  40. package/web-app/migrations/env.py +103 -0
  41. package/web-app/migrations/script.py.mako +25 -0
  42. package/web-app/migrations/versions/.gitkeep +0 -0
  43. package/web-app/migrations/versions/001_initial_schema.py +118 -0
  44. package/web-app/models.py +140 -0
  45. package/web-app/requirements.txt +27 -0
  46. package/web-app/server.py +158 -22
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: loki-mode
3
3
  description: Multi-agent autonomous startup system. Triggers on "Loki Mode". Takes PRD to deployed product with minimal human intervention. Requires --dangerously-skip-permissions flag.
4
4
  ---
5
5
 
6
- # Loki Mode v6.53.0
6
+ # Loki Mode v6.55.0
7
7
 
8
8
  **You are an autonomous agent. You make decisions. You do not ask questions. You do not stop.**
9
9
 
@@ -267,4 +267,4 @@ The following features are documented in skill modules but not yet fully automat
267
267
  | Quality gates 3-reviewer system | Implemented (v5.35.0) | 5 specialist reviewers in `skills/quality-gates.md`; execution in run.sh |
268
268
  | Benchmarks (HumanEval, SWE-bench) | Infrastructure only | Runner scripts and datasets exist in `benchmarks/`; no published results |
269
269
 
270
- **v6.53.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
270
+ **v6.55.0 | [Autonomi](https://www.autonomi.dev/) flagship product | ~260 lines core**
package/VERSION CHANGED
@@ -1 +1 @@
1
- 6.53.0
1
+ 6.55.0
@@ -131,6 +131,35 @@ try {
131
131
  // If npm bin check fails, skip PATH warning silently
132
132
  }
133
133
 
134
+ // Install Python dependencies for Purple Lab (pexpect, watchdog, httpx)
135
+ try {
136
+ const { execSync } = require('child_process');
137
+ const pyDeps = ['pexpect', 'watchdog', 'httpx'];
138
+ // Check if deps are already installed
139
+ const missing = pyDeps.filter(dep => {
140
+ try {
141
+ execSync(`python3 -c "import ${dep}"`, { stdio: 'pipe' });
142
+ return false;
143
+ } catch { return true; }
144
+ });
145
+ if (missing.length > 0) {
146
+ console.log(`Installing Python dependencies: ${missing.join(', ')}...`);
147
+ try {
148
+ execSync(`python3 -m pip install --break-system-packages ${missing.join(' ')}`, { stdio: 'pipe', timeout: 60000 });
149
+ console.log(' [OK] Python dependencies installed');
150
+ } catch {
151
+ try {
152
+ execSync(`python3 -m pip install ${missing.join(' ')}`, { stdio: 'pipe', timeout: 60000 });
153
+ console.log(' [OK] Python dependencies installed');
154
+ } catch {
155
+ console.log(` [WARN] Could not install Python deps. Run: pip install ${missing.join(' ')}`);
156
+ }
157
+ }
158
+ }
159
+ } catch {
160
+ // Python not available, skip silently
161
+ }
162
+
134
163
  console.log('');
135
164
  console.log('CLI commands:');
136
165
  console.log(' loki start ./prd.md Start with Claude (default)');
@@ -7,7 +7,7 @@ Modules:
7
7
  control: Session control API (start/stop/pause/resume)
8
8
  """
9
9
 
10
- __version__ = "6.53.0"
10
+ __version__ = "6.55.0"
11
11
 
12
12
  # Expose the control app for easy import
13
13
  try:
@@ -2,7 +2,7 @@
2
2
 
3
3
  The flagship product of [Autonomi](https://www.autonomi.dev/). Complete installation instructions for all platforms and use cases.
4
4
 
5
- **Version:** v6.53.0
5
+ **Version:** v6.55.0
6
6
 
7
7
  ---
8
8
 
package/mcp/__init__.py CHANGED
@@ -57,4 +57,4 @@ try:
57
57
  except ImportError:
58
58
  __all__ = ['mcp']
59
59
 
60
- __version__ = '6.53.0'
60
+ __version__ = '6.55.0'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "loki-mode",
3
- "version": "6.53.0",
3
+ "version": "6.55.0",
4
4
  "description": "Loki Mode by Autonomi - Multi-agent autonomous startup system for Claude Code, Codex CLI, and Gemini CLI",
5
5
  "keywords": [
6
6
  "agent",
@@ -83,7 +83,16 @@
83
83
  "completions/",
84
84
  "src/",
85
85
  "web-app/dist/",
86
- "web-app/server.py"
86
+ "web-app/server.py",
87
+ "web-app/auth.py",
88
+ "web-app/models.py",
89
+ "web-app/crypto.py",
90
+ "web-app/requirements.txt",
91
+ "web-app/alembic.ini",
92
+ "web-app/migrations/",
93
+ "web-app/Dockerfile",
94
+ "web-app/docker-compose.purple-lab.yml",
95
+ "web-app/deploy/"
87
96
  ],
88
97
  "scripts": {
89
98
  "postinstall": "node bin/postinstall.js",
@@ -0,0 +1,59 @@
1
+ # Purple Lab Dockerfile
2
+ # Build: docker compose -f docker-compose.purple-lab.yml build
3
+ # Run: docker compose -f docker-compose.purple-lab.yml up
4
+
5
+ # --- Stage 1: Build frontend ---
6
+ FROM node:20-alpine AS frontend-build
7
+
8
+ WORKDIR /build
9
+ COPY package.json package-lock.json* ./
10
+ RUN npm ci --ignore-scripts
11
+ COPY tsconfig*.json vite.config.ts tailwind.config.js postcss.config.js index.html ./
12
+ COPY src/ ./src/
13
+ RUN npm run build
14
+
15
+ # --- Stage 2: Production image ---
16
+ FROM python:3.12-slim
17
+
18
+ LABEL maintainer="Lokesh Mure"
19
+ LABEL description="Purple Lab - Replit-like web UI for Loki Mode"
20
+
21
+ # Install system dependencies
22
+ RUN apt-get update && apt-get install -y --no-install-recommends \
23
+ bash \
24
+ curl \
25
+ git \
26
+ && rm -rf /var/lib/apt/lists/*
27
+
28
+ # Create non-root user
29
+ RUN useradd -m -s /bin/bash -u 1000 purplelab
30
+
31
+ WORKDIR /opt/purple-lab
32
+
33
+ # Install Python dependencies
34
+ COPY requirements.txt ./
35
+ RUN pip install --no-cache-dir -r requirements.txt
36
+
37
+ # Copy Python backend
38
+ COPY server.py auth.py models.py ./
39
+
40
+ # Copy built frontend from stage 1
41
+ COPY --from=frontend-build /build/dist/ ./dist/
42
+
43
+ # Copy loki CLI and supporting files from parent context (if needed at runtime)
44
+ # For standalone mode, the server works without loki CLI
45
+
46
+ # Set ownership
47
+ RUN chown -R purplelab:purplelab /opt/purple-lab
48
+
49
+ # Create project data directory
50
+ RUN mkdir -p /projects && chown purplelab:purplelab /projects
51
+
52
+ EXPOSE 57375
53
+
54
+ USER purplelab
55
+
56
+ CMD ["python3", "server.py"]
57
+
58
+ HEALTHCHECK --interval=10s --timeout=5s --start-period=15s --retries=3 \
59
+ CMD curl -f http://localhost:57375/health || exit 1
@@ -0,0 +1,43 @@
1
+ [alembic]
2
+ script_location = migrations
3
+ prepend_sys_path = .
4
+
5
+ # The URL is overridden at runtime from DATABASE_URL env var.
6
+ # This placeholder is required by alembic but never used directly.
7
+ sqlalchemy.url = driver://user:pass@localhost/dbname
8
+
9
+ [post_write_hooks]
10
+
11
+ [loggers]
12
+ keys = root,sqlalchemy,alembic
13
+
14
+ [handlers]
15
+ keys = console
16
+
17
+ [formatters]
18
+ keys = generic
19
+
20
+ [logger_root]
21
+ level = WARN
22
+ handlers = console
23
+ qualname =
24
+
25
+ [logger_sqlalchemy]
26
+ level = WARN
27
+ handlers =
28
+ qualname = sqlalchemy.engine
29
+
30
+ [logger_alembic]
31
+ level = INFO
32
+ handlers =
33
+ qualname = alembic
34
+
35
+ [handler_console]
36
+ class = StreamHandler
37
+ args = (sys.stderr,)
38
+ level = NOTSET
39
+ formatter = generic
40
+
41
+ [formatter_generic]
42
+ format = %(levelname)-5.5s [%(name)s] %(message)s
43
+ datefmt = %H:%M:%S
@@ -0,0 +1,249 @@
1
+ """Purple Lab authentication -- JWT + OAuth.
2
+
3
+ When no DATABASE_URL is configured, authentication is completely disabled
4
+ and all endpoints are accessible without tokens (local development mode).
5
+ """
6
+ import logging
7
+ import os
8
+ import secrets
9
+ import time
10
+ from datetime import datetime, timedelta
11
+ from typing import Optional
12
+
13
+ from fastapi import Depends, HTTPException, Request
14
+ from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
15
+
16
+ try:
17
+ from jose import JWTError, jwt
18
+ except ImportError:
19
+ jwt = None # type: ignore[assignment]
20
+ JWTError = Exception # type: ignore[assignment,misc]
21
+
22
+ try:
23
+ from passlib.context import CryptContext
24
+ pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
25
+ except ImportError:
26
+ pwd_context = None # type: ignore[assignment]
27
+
28
+ logger = logging.getLogger("purple-lab.auth")
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Config from environment variables
32
+ # ---------------------------------------------------------------------------
33
+
34
+ SECRET_KEY = os.environ.get("PURPLE_LAB_SECRET_KEY", "")
35
+ if not SECRET_KEY:
36
+ SECRET_KEY = secrets.token_hex(32)
37
+ logger.warning(
38
+ "PURPLE_LAB_SECRET_KEY not set -- generated ephemeral key. "
39
+ "Tokens will not survive server restarts. "
40
+ "Set PURPLE_LAB_SECRET_KEY env var for production use."
41
+ )
42
+ ALGORITHM = "HS256"
43
+ ACCESS_TOKEN_EXPIRE_HOURS = 24
44
+
45
+ # OAuth config
46
+ GITHUB_CLIENT_ID = os.environ.get("GITHUB_CLIENT_ID", "")
47
+ GITHUB_CLIENT_SECRET = os.environ.get("GITHUB_CLIENT_SECRET", "")
48
+ GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", "")
49
+ GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET", "")
50
+
51
+ security = HTTPBearer(auto_error=False)
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Token helpers
56
+ # ---------------------------------------------------------------------------
57
+
58
+ def create_access_token(data: dict, expires_delta: timedelta | None = None) -> str:
59
+ """Create a signed JWT access token."""
60
+ if jwt is None:
61
+ raise RuntimeError("python-jose is not installed. Install with: pip install python-jose[cryptography]")
62
+ to_encode = data.copy()
63
+ expire = datetime.utcnow() + (expires_delta or timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS))
64
+ to_encode.update({"exp": expire})
65
+ return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
66
+
67
+
68
+ def verify_token(token: str) -> Optional[dict]:
69
+ """Verify and decode a JWT token. Returns None if invalid."""
70
+ if jwt is None:
71
+ return None
72
+ try:
73
+ payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
74
+ return payload
75
+ except JWTError:
76
+ return None
77
+
78
+
79
+ def hash_password(password: str) -> str:
80
+ """Hash a password using bcrypt."""
81
+ if pwd_context is None:
82
+ raise RuntimeError("passlib is not installed. Install with: pip install passlib[bcrypt]")
83
+ return pwd_context.hash(password)
84
+
85
+
86
+ def verify_password(plain_password: str, hashed_password: str) -> bool:
87
+ """Verify a password against a bcrypt hash."""
88
+ if pwd_context is None:
89
+ return False
90
+ return pwd_context.verify(plain_password, hashed_password)
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # FastAPI dependencies
95
+ # ---------------------------------------------------------------------------
96
+
97
+ async def get_current_user(
98
+ credentials: HTTPAuthorizationCredentials = Depends(security),
99
+ ) -> Optional[dict]:
100
+ """Get current user from JWT token. Returns None if no auth configured."""
101
+ # If no database is configured, auth is disabled (local development mode)
102
+ from models import async_session_factory
103
+
104
+ if async_session_factory is None:
105
+ return None # Auth disabled, allow all requests
106
+
107
+ if not credentials:
108
+ raise HTTPException(status_code=401, detail="Not authenticated")
109
+
110
+ payload = verify_token(credentials.credentials)
111
+ if not payload:
112
+ raise HTTPException(status_code=401, detail="Invalid or expired token")
113
+
114
+ return payload
115
+
116
+
117
+ # ---------------------------------------------------------------------------
118
+ # OAuth state (CSRF protection)
119
+ # ---------------------------------------------------------------------------
120
+
121
+ # In-memory store of valid OAuth state tokens. Each entry maps
122
+ # state -> expiry timestamp. Tokens expire after 10 minutes.
123
+ _oauth_states: dict[str, float] = {}
124
+ _OAUTH_STATE_TTL = 600 # 10 minutes
125
+
126
+
127
+ def generate_oauth_state() -> str:
128
+ """Generate a cryptographically random state token for OAuth CSRF protection."""
129
+ _purge_expired_states()
130
+ state = secrets.token_urlsafe(32)
131
+ _oauth_states[state] = time.time() + _OAUTH_STATE_TTL
132
+ return state
133
+
134
+
135
+ def validate_oauth_state(state: str | None) -> bool:
136
+ """Validate and consume an OAuth state token. Returns False if invalid or expired."""
137
+ if not state:
138
+ return False
139
+ _purge_expired_states()
140
+ expiry = _oauth_states.pop(state, None)
141
+ if expiry is None:
142
+ return False
143
+ return time.time() < expiry
144
+
145
+
146
+ def _purge_expired_states() -> None:
147
+ """Remove expired state tokens to prevent memory growth."""
148
+ now = time.time()
149
+ expired = [s for s, exp in _oauth_states.items() if now >= exp]
150
+ for s in expired:
151
+ _oauth_states.pop(s, None)
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # OAuth handlers
156
+ # ---------------------------------------------------------------------------
157
+
158
+ async def github_oauth_callback(code: str) -> dict:
159
+ """Exchange GitHub OAuth code for user info."""
160
+ import httpx
161
+
162
+ async with httpx.AsyncClient(timeout=10.0) as client:
163
+ # Exchange code for token
164
+ token_resp = await client.post(
165
+ "https://github.com/login/oauth/access_token",
166
+ json={
167
+ "client_id": GITHUB_CLIENT_ID,
168
+ "client_secret": GITHUB_CLIENT_SECRET,
169
+ "code": code,
170
+ },
171
+ headers={"Accept": "application/json"},
172
+ )
173
+ token_data = token_resp.json()
174
+ access_token = token_data.get("access_token")
175
+
176
+ if not access_token:
177
+ error_desc = token_data.get("error_description", "Unknown error")
178
+ logger.warning("GitHub OAuth token exchange failed: %s", error_desc)
179
+ raise HTTPException(status_code=400, detail=f"Failed to get GitHub access token: {error_desc}")
180
+
181
+ # Get user info
182
+ user_resp = await client.get(
183
+ "https://api.github.com/user",
184
+ headers={"Authorization": f"Bearer {access_token}"},
185
+ )
186
+ if user_resp.status_code != 200:
187
+ raise HTTPException(status_code=502, detail="Failed to fetch GitHub user info")
188
+ user_data = user_resp.json()
189
+
190
+ # Get primary email
191
+ email_resp = await client.get(
192
+ "https://api.github.com/user/emails",
193
+ headers={"Authorization": f"Bearer {access_token}"},
194
+ )
195
+ emails = email_resp.json() if email_resp.status_code == 200 else []
196
+ primary_email = next(
197
+ (e["email"] for e in emails if isinstance(e, dict) and e.get("primary")), None
198
+ )
199
+
200
+ return {
201
+ "email": primary_email or user_data.get("email"),
202
+ "name": user_data.get("name") or user_data.get("login"),
203
+ "avatar_url": user_data.get("avatar_url"),
204
+ "provider": "github",
205
+ "provider_id": str(user_data["id"]),
206
+ }
207
+
208
+
209
+ async def google_oauth_callback(code: str, redirect_uri: str) -> dict:
210
+ """Exchange Google OAuth code for user info."""
211
+ import httpx
212
+
213
+ async with httpx.AsyncClient(timeout=10.0) as client:
214
+ token_resp = await client.post(
215
+ "https://oauth2.googleapis.com/token",
216
+ data={
217
+ "code": code,
218
+ "client_id": GOOGLE_CLIENT_ID,
219
+ "client_secret": GOOGLE_CLIENT_SECRET,
220
+ "redirect_uri": redirect_uri,
221
+ "grant_type": "authorization_code",
222
+ },
223
+ )
224
+ token_data = token_resp.json()
225
+ access_token = token_data.get("access_token")
226
+
227
+ if not access_token:
228
+ error_desc = token_data.get("error_description", "Unknown error")
229
+ logger.warning("Google OAuth token exchange failed: %s", error_desc)
230
+ raise HTTPException(status_code=400, detail=f"Failed to get Google access token: {error_desc}")
231
+
232
+ user_resp = await client.get(
233
+ "https://www.googleapis.com/oauth2/v2/userinfo",
234
+ headers={"Authorization": f"Bearer {access_token}"},
235
+ )
236
+ if user_resp.status_code != 200:
237
+ raise HTTPException(status_code=502, detail="Failed to fetch Google user info")
238
+ user_data = user_resp.json()
239
+
240
+ if "email" not in user_data:
241
+ raise HTTPException(status_code=400, detail="Google account has no email")
242
+
243
+ return {
244
+ "email": user_data["email"],
245
+ "name": user_data.get("name"),
246
+ "avatar_url": user_data.get("picture"),
247
+ "provider": "google",
248
+ "provider_id": str(user_data["id"]),
249
+ }
@@ -0,0 +1,83 @@
1
+ """Purple Lab secrets encryption -- Fernet symmetric encryption.
2
+
3
+ When PURPLE_LAB_SECRET_KEY is set, secret values are encrypted at rest
4
+ using Fernet (AES-128-CBC with HMAC-SHA256). In local dev mode without
5
+ the env var, secrets are stored in plaintext.
6
+ """
7
+ import base64
8
+ import hashlib
9
+ import logging
10
+ import os
11
+
12
+ logger = logging.getLogger("purple-lab.crypto")
13
+
14
+ try:
15
+ from cryptography.fernet import Fernet, InvalidToken
16
+ _HAS_FERNET = True
17
+ except ImportError:
18
+ _HAS_FERNET = False
19
+ InvalidToken = Exception # type: ignore[assignment,misc]
20
+ logger.warning(
21
+ "cryptography package not installed -- secret encryption disabled. "
22
+ "Install with: pip install cryptography"
23
+ )
24
+
25
+
26
+ def _get_secret_key() -> str | None:
27
+ """Return PURPLE_LAB_SECRET_KEY if explicitly set, else None."""
28
+ return os.environ.get("PURPLE_LAB_SECRET_KEY") or None
29
+
30
+
31
+ def _derive_fernet_key(secret: str) -> bytes:
32
+ """Derive a Fernet-compatible key from an arbitrary secret string.
33
+
34
+ Fernet requires a 32-byte URL-safe base64-encoded key. We derive it
35
+ deterministically from the secret using SHA-256.
36
+ """
37
+ raw = hashlib.sha256(secret.encode()).digest()
38
+ return base64.urlsafe_b64encode(raw)
39
+
40
+
41
+ def encryption_available() -> bool:
42
+ """Return True if encryption is both available and configured."""
43
+ return _HAS_FERNET and _get_secret_key() is not None
44
+
45
+
46
+ def encrypt_value(plaintext: str) -> str:
47
+ """Encrypt a string value using Fernet.
48
+
49
+ Returns the ciphertext as a UTF-8 string. If encryption is not
50
+ available or not configured, returns the plaintext unchanged.
51
+ """
52
+ secret = _get_secret_key()
53
+ if not _HAS_FERNET or secret is None:
54
+ return plaintext
55
+ try:
56
+ f = Fernet(_derive_fernet_key(secret))
57
+ return f.encrypt(plaintext.encode()).decode()
58
+ except Exception:
59
+ logger.exception("Failed to encrypt value -- storing plaintext")
60
+ return plaintext
61
+
62
+
63
+ def decrypt_value(ciphertext: str) -> str:
64
+ """Decrypt a Fernet-encrypted string.
65
+
66
+ Returns the plaintext. If the value was never encrypted (legacy
67
+ plaintext) or decryption fails, returns the original string so that
68
+ existing unencrypted secrets continue to work.
69
+ """
70
+ secret = _get_secret_key()
71
+ if not _HAS_FERNET or secret is None:
72
+ return ciphertext
73
+ try:
74
+ f = Fernet(_derive_fernet_key(secret))
75
+ return f.decrypt(ciphertext.encode()).decode()
76
+ except InvalidToken:
77
+ # Value is likely plaintext from before encryption was enabled.
78
+ # Return as-is so existing secrets keep working.
79
+ logger.debug("Could not decrypt value -- returning as plaintext (likely legacy)")
80
+ return ciphertext
81
+ except Exception:
82
+ logger.exception("Unexpected decryption error -- returning raw value")
83
+ return ciphertext
@@ -0,0 +1,8 @@
1
+ apiVersion: v1
2
+ kind: ConfigMap
3
+ metadata:
4
+ name: purple-lab-config
5
+ data:
6
+ PURPLE_LAB_HOST: "0.0.0.0"
7
+ PURPLE_LAB_PORT: "57375"
8
+ LOG_LEVEL: "info"
@@ -0,0 +1,69 @@
1
+ apiVersion: apps/v1
2
+ kind: Deployment
3
+ metadata:
4
+ name: purple-lab
5
+ labels:
6
+ app: purple-lab
7
+ spec:
8
+ replicas: 2
9
+ selector:
10
+ matchLabels:
11
+ app: purple-lab
12
+ strategy:
13
+ type: RollingUpdate
14
+ rollingUpdate:
15
+ maxSurge: 1
16
+ maxUnavailable: 0
17
+ template:
18
+ metadata:
19
+ labels:
20
+ app: purple-lab
21
+ spec:
22
+ serviceAccountName: purple-lab
23
+ securityContext:
24
+ runAsNonRoot: true
25
+ runAsUser: 1000
26
+ fsGroup: 1000
27
+ containers:
28
+ - name: purple-lab
29
+ image: asklokesh/purple-lab:latest
30
+ imagePullPolicy: Always
31
+ ports:
32
+ - name: http
33
+ containerPort: 57375
34
+ protocol: TCP
35
+ envFrom:
36
+ - configMapRef:
37
+ name: purple-lab-config
38
+ - secretRef:
39
+ name: purple-lab-secrets
40
+ resources:
41
+ requests:
42
+ memory: "256Mi"
43
+ cpu: "250m"
44
+ limits:
45
+ memory: "1Gi"
46
+ cpu: "1000m"
47
+ livenessProbe:
48
+ httpGet:
49
+ path: /health
50
+ port: http
51
+ initialDelaySeconds: 15
52
+ periodSeconds: 10
53
+ timeoutSeconds: 5
54
+ failureThreshold: 3
55
+ readinessProbe:
56
+ httpGet:
57
+ path: /health
58
+ port: http
59
+ initialDelaySeconds: 5
60
+ periodSeconds: 5
61
+ timeoutSeconds: 3
62
+ failureThreshold: 3
63
+ volumeMounts:
64
+ - name: project-storage
65
+ mountPath: /projects
66
+ volumes:
67
+ - name: project-storage
68
+ persistentVolumeClaim:
69
+ claimName: purple-lab-projects
@@ -0,0 +1,24 @@
1
+ apiVersion: autoscaling/v2
2
+ kind: HorizontalPodAutoscaler
3
+ metadata:
4
+ name: purple-lab
5
+ spec:
6
+ scaleTargetRef:
7
+ apiVersion: apps/v1
8
+ kind: Deployment
9
+ name: purple-lab
10
+ minReplicas: 2
11
+ maxReplicas: 10
12
+ metrics:
13
+ - type: Resource
14
+ resource:
15
+ name: cpu
16
+ target:
17
+ type: Utilization
18
+ averageUtilization: 70
19
+ - type: Resource
20
+ resource:
21
+ name: memory
22
+ target:
23
+ type: Utilization
24
+ averageUtilization: 80
@@ -0,0 +1,30 @@
1
+ apiVersion: networking.k8s.io/v1
2
+ kind: Ingress
3
+ metadata:
4
+ name: purple-lab
5
+ annotations:
6
+ nginx.ingress.kubernetes.io/websocket-services: "purple-lab"
7
+ nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
8
+ nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
9
+ nginx.ingress.kubernetes.io/proxy-body-size: "100m"
10
+ nginx.ingress.kubernetes.io/configuration-snippet: |
11
+ proxy_set_header Upgrade $http_upgrade;
12
+ proxy_set_header Connection "upgrade";
13
+ cert-manager.io/cluster-issuer: "letsencrypt-prod"
14
+ spec:
15
+ ingressClassName: nginx
16
+ tls:
17
+ - hosts:
18
+ - lab.autonomi.dev
19
+ secretName: purple-lab-tls
20
+ rules:
21
+ - host: lab.autonomi.dev
22
+ http:
23
+ paths:
24
+ - path: /
25
+ pathType: Prefix
26
+ backend:
27
+ service:
28
+ name: purple-lab
29
+ port:
30
+ number: 80