Compare commits
4 Commits
78aaddb2b5
...
8b4784f5ed
| Author | SHA1 | Date | |
|---|---|---|---|
| 8b4784f5ed | |||
| 5a7b026cbc | |||
| e0f753803c | |||
| 51d93d9dc6 |
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.auth.dependencies import get_current_active_user
|
||||
from app.db import get_db_session
|
||||
from app.encryption import decrypt_value, encrypt_value
|
||||
from app.encryption import encrypt_value
|
||||
from app.models.project import Project
|
||||
from app.models.secret import Secret
|
||||
from app.models.tool_instance import ToolInstance
|
||||
@@ -55,13 +55,7 @@ async def create_secret(
|
||||
session.add(secret)
|
||||
await session.commit()
|
||||
await session.refresh(secret)
|
||||
return SecretRead(
|
||||
id=secret.id,
|
||||
scope_type=secret.scope_type,
|
||||
scope_id=secret.scope_id,
|
||||
key=secret.key,
|
||||
value=decrypt_value(secret.encrypted_value),
|
||||
)
|
||||
return SecretRead.from_secret(secret)
|
||||
|
||||
|
||||
@router.get("/secrets", response_model=list[SecretRead])
|
||||
@@ -82,13 +76,7 @@ async def list_secrets(
|
||||
for s in secrets:
|
||||
try:
|
||||
await _verify_secret_ownership(s, current_user, session)
|
||||
allowed.append(SecretRead(
|
||||
id=s.id,
|
||||
scope_type=s.scope_type,
|
||||
scope_id=s.scope_id,
|
||||
key=s.key,
|
||||
value=decrypt_value(s.encrypted_value),
|
||||
))
|
||||
allowed.append(SecretRead.from_secret(s))
|
||||
except HTTPException:
|
||||
pass
|
||||
return allowed
|
||||
@@ -104,13 +92,7 @@ async def get_secret(
|
||||
if not s:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
|
||||
await _verify_secret_ownership(s, current_user, session)
|
||||
return SecretRead(
|
||||
id=s.id,
|
||||
scope_type=s.scope_type,
|
||||
scope_id=s.scope_id,
|
||||
key=s.key,
|
||||
value=decrypt_value(s.encrypted_value),
|
||||
)
|
||||
return SecretRead.from_secret(s)
|
||||
|
||||
|
||||
@router.put("/secrets/{secret_id}", response_model=SecretRead)
|
||||
@@ -130,13 +112,7 @@ async def update_secret(
|
||||
s.encrypted_value = encrypt_value(secret_in.value)
|
||||
await session.commit()
|
||||
await session.refresh(s)
|
||||
return SecretRead(
|
||||
id=s.id,
|
||||
scope_type=s.scope_type,
|
||||
scope_id=s.scope_id,
|
||||
key=s.key,
|
||||
value=decrypt_value(s.encrypted_value),
|
||||
)
|
||||
return SecretRead.from_secret(s)
|
||||
|
||||
|
||||
@router.delete("/secrets/{secret_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from uuid import UUID
|
||||
|
||||
from app.schemas.base import OrmBase
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from app.models.secret import Secret
|
||||
|
||||
|
||||
class SecretBase(OrmBase):
|
||||
scope_type: str
|
||||
@@ -15,7 +21,17 @@ class SecretCreate(SecretBase):
|
||||
|
||||
class SecretRead(SecretBase):
|
||||
id: UUID
|
||||
value: str
|
||||
value: str = "••••••"
|
||||
|
||||
@classmethod
|
||||
def from_secret(cls, secret: Secret) -> SecretRead:
|
||||
return cls(
|
||||
id=secret.id,
|
||||
scope_type=secret.scope_type,
|
||||
scope_id=secret.scope_id,
|
||||
key=secret.key,
|
||||
value="••••••",
|
||||
)
|
||||
|
||||
|
||||
class SecretUpdate(OrmBase):
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.encryption import decrypt_value
|
||||
from app.models.config import Config
|
||||
from app.models.secret import Secret
|
||||
|
||||
|
||||
class RuntimeInjectionError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class RuntimeInjectionService:
|
||||
SCOPE_HIERARCHY = ["global", "user", "project", "tool_instance"]
|
||||
|
||||
@staticmethod
|
||||
async def resolve_configs(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
instance_id: uuid.UUID | None = None,
|
||||
tool_definition_id: uuid.UUID | None = None,
|
||||
) -> dict[str, Any]:
|
||||
stmt = select(Config).where(
|
||||
|
||||
(Config.scope_type == "global")
|
||||
| (
|
||||
(Config.scope_type == "user")
|
||||
& (Config.scope_id == user_id)
|
||||
)
|
||||
| (
|
||||
(Config.scope_type == "project")
|
||||
& (Config.scope_id == project_id)
|
||||
)
|
||||
| (
|
||||
(Config.scope_type == "tool_instance")
|
||||
& (Config.scope_id == (instance_id or uuid.UUID(int=0)))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
if tool_definition_id:
|
||||
stmt = stmt.where(
|
||||
(Config.tool_definition_id == tool_definition_id)
|
||||
| (Config.tool_definition_id.is_(None))
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
configs = list(result.scalars().all())
|
||||
|
||||
resolved: dict[str, Any] = {}
|
||||
for scope in RuntimeInjectionService.SCOPE_HIERARCHY:
|
||||
for cfg in configs:
|
||||
if cfg.scope_type == scope:
|
||||
resolved[cfg.key] = cfg.value
|
||||
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
async def resolve_secrets(
|
||||
session: AsyncSession,
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
instance_id: uuid.UUID | None = None,
|
||||
) -> dict[str, str]:
|
||||
stmt = select(Secret).where(
|
||||
|
||||
(Secret.scope_type == "global")
|
||||
| (
|
||||
(Secret.scope_type == "user")
|
||||
& (Secret.scope_id == user_id)
|
||||
)
|
||||
| (
|
||||
(Secret.scope_type == "project")
|
||||
& (Secret.scope_id == project_id)
|
||||
)
|
||||
| (
|
||||
(Secret.scope_type == "tool_instance")
|
||||
& (Secret.scope_id == (instance_id or uuid.UUID(int=0)))
|
||||
)
|
||||
|
||||
)
|
||||
|
||||
result = await session.execute(stmt)
|
||||
secrets = list(result.scalars().all())
|
||||
|
||||
resolved: dict[str, str] = {}
|
||||
for scope in RuntimeInjectionService.SCOPE_HIERARCHY:
|
||||
for secret in secrets:
|
||||
if secret.scope_type == scope:
|
||||
resolved[secret.key] = decrypt_value(secret.encrypted_value)
|
||||
|
||||
return resolved
|
||||
|
||||
@staticmethod
|
||||
def generate_config_files(configs: dict[str, Any], config_dir: Path) -> list[str]:
|
||||
config_dir.mkdir(parents=True, exist_ok=True)
|
||||
mounts = []
|
||||
|
||||
for key, value in configs.items():
|
||||
file_path = config_dir / f"{key}.json"
|
||||
file_path.write_text(json.dumps(value, indent=2))
|
||||
file_path.chmod(0o400)
|
||||
mounts.append(f"{file_path}:/app/config/{key}.json:ro")
|
||||
|
||||
return mounts
|
||||
|
||||
@staticmethod
|
||||
def generate_secret_env_vars(secrets: dict[str, str]) -> dict[str, str]:
|
||||
return {key.upper(): value for key, value in secrets.items()}
|
||||
|
||||
@staticmethod
|
||||
async def validate_secrets_exist(
|
||||
session: AsyncSession,
|
||||
required_secret_keys: list[str],
|
||||
project_id: uuid.UUID,
|
||||
user_id: uuid.UUID,
|
||||
instance_id: uuid.UUID | None = None,
|
||||
) -> None:
|
||||
resolved = await RuntimeInjectionService.resolve_secrets(
|
||||
session, project_id, user_id, instance_id
|
||||
)
|
||||
|
||||
missing = [key for key in required_secret_keys if key not in resolved]
|
||||
if missing:
|
||||
raise RuntimeInjectionError(
|
||||
f"Missing required secrets: {', '.join(missing)}"
|
||||
)
|
||||
@@ -38,6 +38,8 @@ class SpawnService:
|
||||
workspace_path: Path | None = None,
|
||||
config_path: Path | None = None,
|
||||
ssh_key_path: Path | None = None,
|
||||
config_mounts: list[str] | None = None,
|
||||
secret_env_vars: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
service_name = f"tool-{instance_id[:8]}"
|
||||
|
||||
@@ -96,9 +98,15 @@ class SpawnService:
|
||||
if ssh_key_path and ssh_key_path.exists():
|
||||
volumes.append(f"{ssh_key_path}:/home/coder/.ssh:ro")
|
||||
|
||||
if config_mounts:
|
||||
volumes.extend(config_mounts)
|
||||
|
||||
if volumes:
|
||||
service["volumes"] = volumes
|
||||
|
||||
if secret_env_vars:
|
||||
service["environment"].update(secret_env_vars)
|
||||
|
||||
if manifest.health_check:
|
||||
hc = manifest.health_check
|
||||
healthcheck: dict[str, Any] = {
|
||||
@@ -170,6 +178,8 @@ class SpawnService:
|
||||
workspace_path: Path | None = None,
|
||||
config_path: Path | None = None,
|
||||
ssh_key_path: Path | None = None,
|
||||
config_mounts: list[str] | None = None,
|
||||
secret_env_vars: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
|
||||
|
||||
@@ -203,6 +213,8 @@ class SpawnService:
|
||||
workspace_path=workspace_path,
|
||||
config_path=config_path,
|
||||
ssh_key_path=ssh_key_path,
|
||||
config_mounts=config_mounts,
|
||||
secret_env_vars=secret_env_vars,
|
||||
)
|
||||
|
||||
compose_path = self._write_compose_file(instance_id, service)
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
from uuid import UUID
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.runtime_injection import RuntimeInjectionError, RuntimeInjectionService
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_configs_empty(db_session):
|
||||
result = await RuntimeInjectionService.resolve_configs(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_configs_global_only(db_session, sample_config):
|
||||
result = await RuntimeInjectionService.resolve_configs(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result == {"test_key": "test_value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_configs_scope_override(db_session):
|
||||
from app.models.config import Config
|
||||
|
||||
global_config = Config(
|
||||
scope_type="global",
|
||||
scope_id=UUID(int=0),
|
||||
key="shared_key",
|
||||
value="global_value",
|
||||
)
|
||||
project_config = Config(
|
||||
scope_type="project",
|
||||
scope_id=UUID(int=1),
|
||||
key="shared_key",
|
||||
value="project_value",
|
||||
)
|
||||
db_session.add_all([global_config, project_config])
|
||||
await db_session.commit()
|
||||
|
||||
result = await RuntimeInjectionService.resolve_configs(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result["shared_key"] == "project_value"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_secrets_empty(db_session):
|
||||
result = await RuntimeInjectionService.resolve_secrets(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result == {}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_secrets_decrypts(db_session, sample_secret):
|
||||
result = await RuntimeInjectionService.resolve_secrets(
|
||||
db_session,
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
assert result == {"secret_key": "secret_value"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_secrets_exist_missing(db_session):
|
||||
with pytest.raises(RuntimeInjectionError, match="Missing required secrets"):
|
||||
await RuntimeInjectionService.validate_secrets_exist(
|
||||
db_session,
|
||||
required_secret_keys=["missing_secret"],
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_validate_secrets_exist_found(db_session, sample_secret):
|
||||
await RuntimeInjectionService.validate_secrets_exist(
|
||||
db_session,
|
||||
required_secret_keys=["secret_key"],
|
||||
project_id=UUID(int=1),
|
||||
user_id=UUID(int=2),
|
||||
)
|
||||
|
||||
|
||||
def test_generate_config_files(tmp_path):
|
||||
configs = {"app": {"port": 8080}, "debug": True}
|
||||
mounts = RuntimeInjectionService.generate_config_files(configs, tmp_path)
|
||||
|
||||
assert len(mounts) == 2
|
||||
assert (tmp_path / "app.json").exists()
|
||||
assert (tmp_path / "debug.json").exists()
|
||||
assert (tmp_path / "app.json").stat().st_mode & 0o777 == 0o400
|
||||
|
||||
|
||||
def test_generate_secret_env_vars():
|
||||
secrets = {"api_key": "abc123", "db_pass": "secret"}
|
||||
env_vars = RuntimeInjectionService.generate_secret_env_vars(secrets)
|
||||
|
||||
assert env_vars == {"API_KEY": "abc123", "DB_PASS": "secret"}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Project, ProjectCreate, ProjectUpdate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
|
||||
import type { Config, ConfigCreate, Project, ProjectCreate, ProjectUpdate, Secret, SecretCreate, ToolDefinition, ToolInstance, User } from '../types/api.ts'
|
||||
|
||||
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||
|
||||
@@ -141,4 +141,58 @@ export const api = {
|
||||
const response = await fetchWithAuth('/tools')
|
||||
return response.json()
|
||||
},
|
||||
|
||||
getConfigs: async (projectId: string): Promise<Config[]> => {
|
||||
const response = await fetchWithAuth(`/configs?scope_type=project&scope_id=${projectId}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
createConfig: async (data: ConfigCreate): Promise<Config> => {
|
||||
const response = await fetchWithAuth('/configs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
updateConfig: async (id: string, data: { value: unknown }): Promise<Config> => {
|
||||
const response = await fetchWithAuth(`/configs/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
deleteConfig: async (id: string): Promise<void> => {
|
||||
await fetchWithAuth(`/configs/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
|
||||
getSecrets: async (projectId: string): Promise<Secret[]> => {
|
||||
const response = await fetchWithAuth(`/secrets?scope_type=project&scope_id=${projectId}`)
|
||||
return response.json()
|
||||
},
|
||||
|
||||
createSecret: async (data: SecretCreate): Promise<Secret> => {
|
||||
const response = await fetchWithAuth('/secrets', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
updateSecret: async (id: string, data: { value: string }): Promise<Secret> => {
|
||||
const response = await fetchWithAuth(`/secrets/${id}`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
return response.json()
|
||||
},
|
||||
|
||||
deleteSecret: async (id: string): Promise<void> => {
|
||||
await fetchWithAuth(`/secrets/${id}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { Config } from '../types/api'
|
||||
|
||||
export default function ConfigListPage() {
|
||||
const { id: projectId } = useParams<{ id: string }>()
|
||||
const [configs, setConfigs] = useState<Config[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingConfig, setEditingConfig] = useState<Config | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
key: '',
|
||||
value: '',
|
||||
scope_type: 'project',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
loadConfigs()
|
||||
}, [projectId])
|
||||
|
||||
const loadConfigs = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.getConfigs(projectId!)
|
||||
setConfigs(data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load configs')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!projectId) return
|
||||
|
||||
try {
|
||||
const value = JSON.parse(formData.value)
|
||||
if (editingConfig) {
|
||||
await api.updateConfig(editingConfig.id, { value })
|
||||
} else {
|
||||
await api.createConfig({
|
||||
key: formData.key,
|
||||
value,
|
||||
scope_type: formData.scope_type,
|
||||
scope_id: projectId,
|
||||
})
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingConfig(null)
|
||||
setFormData({ key: '', value: '', scope_type: 'project' })
|
||||
loadConfigs()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save config')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (configId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this config?')) return
|
||||
try {
|
||||
await api.deleteConfig(configId)
|
||||
loadConfigs()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete config')
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (config: Config) => {
|
||||
setEditingConfig(config)
|
||||
setFormData({
|
||||
key: config.key,
|
||||
value: JSON.stringify(config.value, null, 2),
|
||||
scope_type: config.scope_type,
|
||||
})
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4">Loading configs...</div>
|
||||
if (error) return <div className="p-4 text-red-600">Error: {error}</div>
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">Configuration</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(!showForm)
|
||||
setEditingConfig(null)
|
||||
setFormData({ key: '', value: '', scope_type: 'project' })
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'Add Config'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="mb-6 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
required
|
||||
disabled={!!editingConfig}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Value (JSON)</label>
|
||||
<textarea
|
||||
value={formData.value}
|
||||
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded font-mono text-sm"
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Scope</label>
|
||||
<select
|
||||
value={formData.scope_type}
|
||||
onChange={(e) => setFormData({ ...formData, scope_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
disabled={!!editingConfig}
|
||||
>
|
||||
<option value="global">Global</option>
|
||||
<option value="project">Project</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||
{editingConfig ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{configs.length === 0 ? (
|
||||
<p className="text-gray-500">No configs found.</p>
|
||||
) : (
|
||||
configs.map((config) => (
|
||||
<div key={config.id} className="p-4 border rounded flex justify-between items-start">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold">{config.key}</span>
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 rounded">{config.scope_type}</span>
|
||||
</div>
|
||||
<pre className="text-sm bg-gray-50 p-2 rounded overflow-auto">
|
||||
{JSON.stringify(config.value, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => startEdit(config)}
|
||||
className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(config.id)}
|
||||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Link to={`/projects/${projectId}`} className="text-blue-600 hover:underline">
|
||||
← Back to Project
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { useParams, Link } from 'react-router-dom'
|
||||
import { api } from '../api/client'
|
||||
import type { Secret } from '../types/api'
|
||||
|
||||
export default function SecretListPage() {
|
||||
const { id: projectId } = useParams<{ id: string }>()
|
||||
const [secrets, setSecrets] = useState<Secret[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [editingSecret, setEditingSecret] = useState<Secret | null>(null)
|
||||
const [formData, setFormData] = useState({
|
||||
key: '',
|
||||
value: '',
|
||||
scope_type: 'project',
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId) return
|
||||
loadSecrets()
|
||||
}, [projectId])
|
||||
|
||||
const loadSecrets = async () => {
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await api.getSecrets(projectId!)
|
||||
setSecrets(data)
|
||||
setError(null)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to load secrets')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!projectId) return
|
||||
|
||||
try {
|
||||
if (editingSecret) {
|
||||
await api.updateSecret(editingSecret.id, { value: formData.value })
|
||||
} else {
|
||||
await api.createSecret({
|
||||
key: formData.key,
|
||||
value: formData.value,
|
||||
scope_type: formData.scope_type,
|
||||
scope_id: projectId,
|
||||
})
|
||||
}
|
||||
setShowForm(false)
|
||||
setEditingSecret(null)
|
||||
setFormData({ key: '', value: '', scope_type: 'project' })
|
||||
loadSecrets()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save secret')
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (secretId: string) => {
|
||||
if (!confirm('Are you sure you want to delete this secret?')) return
|
||||
try {
|
||||
await api.deleteSecret(secretId)
|
||||
loadSecrets()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete secret')
|
||||
}
|
||||
}
|
||||
|
||||
const startEdit = (secret: Secret) => {
|
||||
setEditingSecret(secret)
|
||||
setFormData({
|
||||
key: secret.key,
|
||||
value: '',
|
||||
scope_type: secret.scope_type,
|
||||
})
|
||||
setShowForm(true)
|
||||
}
|
||||
|
||||
if (loading) return <div className="p-4">Loading secrets...</div>
|
||||
if (error) return <div className="p-4 text-red-600">Error: {error}</div>
|
||||
|
||||
return (
|
||||
<div className="p-4">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h1 className="text-2xl font-bold">Secrets</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowForm(!showForm)
|
||||
setEditingSecret(null)
|
||||
setFormData({ key: '', value: '', scope_type: 'project' })
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
|
||||
>
|
||||
{showForm ? 'Cancel' : 'Add Secret'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showForm && (
|
||||
<form onSubmit={handleSubmit} className="mb-6 p-4 border rounded bg-gray-50">
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Key</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.key}
|
||||
onChange={(e) => setFormData({ ...formData, key: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
required
|
||||
disabled={!!editingSecret}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Value</label>
|
||||
<input
|
||||
type="password"
|
||||
value={formData.value}
|
||||
onChange={(e) => setFormData({ ...formData, value: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
required
|
||||
placeholder={editingSecret ? 'Enter new value' : ''}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium mb-1">Scope</label>
|
||||
<select
|
||||
value={formData.scope_type}
|
||||
onChange={(e) => setFormData({ ...formData, scope_type: e.target.value })}
|
||||
className="w-full px-3 py-2 border rounded"
|
||||
disabled={!!editingSecret}
|
||||
>
|
||||
<option value="global">Global</option>
|
||||
<option value="project">Project</option>
|
||||
</select>
|
||||
</div>
|
||||
<button type="submit" className="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||
{editingSecret ? 'Update' : 'Create'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
{secrets.length === 0 ? (
|
||||
<p className="text-gray-500">No secrets found.</p>
|
||||
) : (
|
||||
secrets.map((secret) => (
|
||||
<div key={secret.id} className="p-4 border rounded flex justify-between items-center">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-semibold">{secret.key}</span>
|
||||
<span className="text-xs px-2 py-1 bg-gray-100 rounded">{secret.scope_type}</span>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 mt-1">{secret.value}</div>
|
||||
</div>
|
||||
<div className="flex gap-2 ml-4">
|
||||
<button
|
||||
onClick={() => startEdit(secret)}
|
||||
className="px-3 py-1 text-sm bg-gray-100 rounded hover:bg-gray-200"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(secret.id)}
|
||||
className="px-3 py-1 text-sm bg-red-100 text-red-700 rounded hover:bg-red-200"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<Link to={`/projects/${projectId}`} className="text-blue-600 hover:underline">
|
||||
← Back to Project
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -13,6 +13,8 @@ import ToolSpawnPage from './pages/ToolSpawnPage'
|
||||
import ToolInstanceDetailPage from './pages/ToolInstanceDetailPage'
|
||||
import SettingsPage from './pages/SettingsPage'
|
||||
import RepositoriesPage from './pages/RepositoriesPage'
|
||||
import ConfigListPage from './pages/ConfigListPage'
|
||||
import SecretListPage from './pages/SecretListPage'
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
{
|
||||
@@ -36,6 +38,8 @@ export const router = createBrowserRouter([
|
||||
{ path: '/projects/:projectId/instances/:instanceId', element: <ToolInstanceDetailPage /> },
|
||||
{ path: '/settings', element: <SettingsPage /> },
|
||||
{ path: '/repositories', element: <RepositoriesPage /> },
|
||||
{ path: '/projects/:id/configs', element: <ConfigListPage /> },
|
||||
{ path: '/projects/:id/secrets', element: <SecretListPage /> },
|
||||
],
|
||||
},
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
|
||||
+31
-55
@@ -52,60 +52,36 @@ export interface ToolInstance {
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ToolManifest {
|
||||
export interface Config {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
version: string
|
||||
image: string
|
||||
runtime_command: string[] | null
|
||||
runtime_entrypoint: string[] | null
|
||||
runtime_user: string | null
|
||||
runtime_working_dir: string | null
|
||||
ports: Array<{
|
||||
container_port: number
|
||||
protocol: string
|
||||
name: string | null
|
||||
primary: boolean
|
||||
}>
|
||||
workspace_mounts: Array<{
|
||||
type: string
|
||||
source_pattern: string
|
||||
target: string
|
||||
read_only: boolean
|
||||
}>
|
||||
config_mounts: Array<{
|
||||
type: string
|
||||
source_pattern: string
|
||||
target: string
|
||||
read_only: boolean
|
||||
}>
|
||||
env: Record<string, string>
|
||||
secrets: Array<{
|
||||
name: string
|
||||
env_var: string
|
||||
required: boolean
|
||||
}>
|
||||
health_check: {
|
||||
type: string
|
||||
path: string | null
|
||||
command: string[] | null
|
||||
port: number | null
|
||||
interval_seconds: number
|
||||
timeout_seconds: number
|
||||
retries: number
|
||||
start_period_seconds: number
|
||||
} | null
|
||||
resource_limits: {
|
||||
cpus: number | null
|
||||
memory_mb: number | null
|
||||
memory_swap_mb: number | null
|
||||
} | null
|
||||
traefik: {
|
||||
enabled: boolean
|
||||
subdomain_prefix: string | null
|
||||
port: number | null
|
||||
middlewares: string[]
|
||||
strip_prefix: boolean
|
||||
} | null
|
||||
key: string
|
||||
value: unknown
|
||||
scope_type: string
|
||||
scope_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface ConfigCreate {
|
||||
key: string
|
||||
value: unknown
|
||||
scope_type: string
|
||||
scope_id: string
|
||||
}
|
||||
|
||||
export interface Secret {
|
||||
id: string
|
||||
key: string
|
||||
value: string
|
||||
scope_type: string
|
||||
scope_id: string
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface SecretCreate {
|
||||
key: string
|
||||
value: string
|
||||
scope_type: string
|
||||
scope_id: string
|
||||
}
|
||||
|
||||
@@ -1171,3 +1171,44 @@ Any implementation task must:
|
||||
7. **High availability:** No replicas or load balancing in MVP.
|
||||
8. **Backup strategy:** Out of MVP scope; rely on host-level volume backups.
|
||||
9. **Rate limiting:** Not in MVP; add at Traefik or API gateway layer later.
|
||||
|
||||
## 19. Config & Secrets System
|
||||
|
||||
### 19.1 Design
|
||||
|
||||
The config and secrets system provides scoped, runtime-injected configuration for tool containers.
|
||||
|
||||
**Config:**
|
||||
- Plaintext JSON values
|
||||
- Mounted as read-only files at `/app/config/<key>.json`
|
||||
- Scope hierarchy: global → user → project → instance (closest wins)
|
||||
|
||||
**Secrets:**
|
||||
- Encrypted with Fernet at rest
|
||||
- Injected as environment variables with uppercase keys
|
||||
- Never exposed decrypted to the frontend (masked as `••••••`)
|
||||
- Scope hierarchy: global → user → project → instance (closest wins)
|
||||
|
||||
### 19.2 Runtime Injection
|
||||
|
||||
When a tool instance is spawned:
|
||||
|
||||
1. **Config Resolution:** Collect configs from all applicable scopes, with closer scopes overriding broader ones
|
||||
2. **Secret Resolution:** Decrypt secrets from all applicable scopes, with closer scopes overriding broader ones
|
||||
3. **Config File Generation:** Write JSON files to `/tmp/headquarter-configs/{instance_id}/`
|
||||
4. **Volume Mounting:** Mount config files as read-only volumes with `0400` permissions
|
||||
5. **Env Var Injection:** Add decrypted secrets to the container's environment variables
|
||||
6. **Validation:** Fail spawn if required secrets are missing, with clear error messages
|
||||
|
||||
### 19.3 Frontend
|
||||
|
||||
- `/projects/{id}/configs` — Config management with JSON formatting
|
||||
- `/projects/{id}/secrets` — Secret management with masked values
|
||||
- Both support create, read, update, delete operations with scope selection
|
||||
|
||||
### 19.4 Security
|
||||
|
||||
- Config files have restrictive permissions (0400)
|
||||
- Secret values are never sent to the frontend
|
||||
- Decryption only happens during runtime injection in the backend
|
||||
- Missing required secrets prevent container spawn
|
||||
|
||||
@@ -259,3 +259,142 @@ docker network create tools # One-time setup
|
||||
```
|
||||
|
||||
Spawned containers use the `tools` network for Traefik routing.
|
||||
|
||||
## Config & Secrets
|
||||
|
||||
### Overview
|
||||
|
||||
The platform supports scoped configuration values and encrypted secrets that are injected into tool containers at spawn time.
|
||||
|
||||
### Scopes
|
||||
|
||||
Configs and secrets support four scope levels (closest match wins):
|
||||
|
||||
1. **Global** — Available to all users and projects
|
||||
2. **User** — Available to a specific user across all projects
|
||||
3. **Project** — Available within a specific project
|
||||
4. **Instance** — Available to a specific tool instance
|
||||
|
||||
### Configs
|
||||
|
||||
Configs are plaintext JSON values mounted as files into containers:
|
||||
|
||||
- Mount path: `/app/config/<key>.json`
|
||||
- Permissions: `0400` (read-only, owner-only)
|
||||
- Scope resolution: instance > project > user > global
|
||||
|
||||
**API Endpoints:**
|
||||
- `POST /configs` — Create config
|
||||
- `GET /configs` — List configs (filter by scope_type, scope_id)
|
||||
- `PUT /configs/{id}` — Update config value
|
||||
- `DELETE /configs/{id}` — Delete config
|
||||
|
||||
**Frontend:**
|
||||
- `/projects/{id}/configs` — Config management UI
|
||||
|
||||
### Secrets
|
||||
|
||||
Secrets are encrypted with Fernet and injected as environment variables:
|
||||
|
||||
- Env var format: `<UPPERCASE_KEY>=<decrypted_value>`
|
||||
- Values are never sent to the frontend decrypted (displayed as `••••••`)
|
||||
- Scope resolution: instance > project > user > global
|
||||
|
||||
**API Endpoints:**
|
||||
- `POST /secrets` — Create secret
|
||||
- `GET /secrets` — List secrets (filter by scope_type, scope_id)
|
||||
- `PUT /secrets/{id}` — Update secret value
|
||||
- `DELETE /secrets/{id}` — Delete secret
|
||||
|
||||
**Frontend:**
|
||||
- `/projects/{id}/secrets` — Secret management UI
|
||||
|
||||
### Runtime Injection
|
||||
|
||||
When a tool instance is spawned:
|
||||
|
||||
1. Configs are resolved from all applicable scopes
|
||||
2. Secrets are resolved and decrypted
|
||||
3. Config files are generated in `/tmp/headquarter-configs/{instance_id}/`
|
||||
4. Config files are mounted as read-only volumes
|
||||
5. Secrets are injected as environment variables
|
||||
6. Missing required secrets will fail the spawn with a clear error
|
||||
|
||||
### Validation
|
||||
|
||||
Before spawning, the system validates that all required secrets exist. If any are missing, the spawn fails with an error message listing the missing secrets.
|
||||
|
||||
## OpenCode Tool
|
||||
|
||||
### Overview
|
||||
|
||||
OpenCode is an AI-powered terminal-based development environment accessible via web browser. It is included as a built-in tool manifest alongside code-server.
|
||||
|
||||
### Manifest
|
||||
|
||||
**File:** `apps/api/app/tools/manifests/opencode.yml`
|
||||
|
||||
```yaml
|
||||
id: opencode
|
||||
name: OpenCode
|
||||
image: ghcr.io/opencode-ai/opencode:latest
|
||||
ports:
|
||||
- container_port: 3000
|
||||
primary: true
|
||||
```
|
||||
|
||||
### Web Terminal Access
|
||||
|
||||
OpenCode exposes a terminal interface on port 3000:
|
||||
- **Subdomain:** `opencode-{project}-{user}.{domain}`
|
||||
- **Health Check:** `GET /` on port 3000
|
||||
- **Terminal:** Full xterm-256color support with color output
|
||||
|
||||
### Environment Variables
|
||||
|
||||
The following environment variables are configured for terminal support:
|
||||
|
||||
| Variable | Value | Description |
|
||||
|----------|-------|-------------|
|
||||
| `TERM` | `xterm-256color` | Terminal type with color support |
|
||||
| `FORCE_COLOR` | `"1"` | Force color output |
|
||||
|
||||
### Workspace Mount
|
||||
|
||||
OpenCode mounts the project workspace at `/workspace` for persistent file access.
|
||||
|
||||
### Config Mount
|
||||
|
||||
User-specific OpenCode configuration is mounted at `/root/.config/opencode`.
|
||||
|
||||
### Usage
|
||||
|
||||
1. Navigate to `/tools/spawn` in the frontend
|
||||
2. Select "OpenCode" from the tool dropdown
|
||||
3. Choose a project
|
||||
4. Enter an instance name
|
||||
5. Click "Spawn Tool"
|
||||
6. Once running, click "Open Tool" to access the web terminal
|
||||
|
||||
### Differences from code-server
|
||||
|
||||
| Feature | OpenCode | code-server |
|
||||
|---------|----------|-------------|
|
||||
| Interface | Terminal (web-based) | VS Code (web-based) |
|
||||
| Port | 3000 | 8080 |
|
||||
| Primary Use | Terminal/CLI tasks | Code editing/IDE |
|
||||
| AI Features | Built-in AI assistance | Extensions required |
|
||||
|
||||
### Local Testing
|
||||
|
||||
To test OpenCode locally without the full platform:
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-p 3000:3000 \
|
||||
-e TERM=xterm-256color \
|
||||
-e FORCE_COLOR=1 \
|
||||
ghcr.io/opencode-ai/opencode:latest
|
||||
```
|
||||
|
||||
Then open `http://localhost:3000` in your browser.
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-14
|
||||
@@ -0,0 +1,63 @@
|
||||
## Context
|
||||
|
||||
The backend has Config and Secret models (FN-004) with scope fields, but no frontend UI or runtime injection. SSH keys already use Fernet encryption (FN-011), so the encryption pattern is established. This design completes the config/secrets lifecycle.
|
||||
|
||||
Current state:
|
||||
- Config model: key, value, scope (global/user/project/instance), scope_id
|
||||
- Secret model: key, encrypted_value, scope, scope_id
|
||||
- Fernet encryption utilities exist in app/encryption.py
|
||||
- No UI for management
|
||||
- No runtime injection into containers
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Allow users to manage configs and secrets via UI
|
||||
- Inject configs/secrets into tool containers at spawn time
|
||||
- Support scope-based inheritance (instance overrides project overrides user overrides global)
|
||||
- Maintain encryption for all secret values
|
||||
|
||||
**Non-Goals:**
|
||||
- Secret versioning or history
|
||||
- Automatic secret rotation
|
||||
- Integration with external secret managers (Vault, AWS Secrets Manager)
|
||||
- Config/secrets for non-tool resources
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Mount configs as files, secrets as env vars**
|
||||
- Rationale: Configs (JSON) are often files (e.g., settings.json). Secrets are typically env vars.
|
||||
- Config mount: `/app/config/<key>.json`
|
||||
- Secret env: `<KEY>=<decrypted_value>`
|
||||
|
||||
**2. Scope resolution: closest match wins**
|
||||
- Rationale: Instance-specific values should override project defaults
|
||||
- Resolution order: instance → project → user → global
|
||||
|
||||
**3. Secret values never sent to frontend decrypted**
|
||||
- Rationale: Security. Frontend only sees masked values (e.g., `••••••`).
|
||||
- Decryption happens only in backend during runtime injection
|
||||
|
||||
**4. Config values are plaintext (not encrypted)**
|
||||
- Rationale: Configs are not sensitive. Encrypting them adds complexity without security benefit.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] Secret injection at spawn time could fail silently**
|
||||
→ Mitigation: Validate all referenced secrets exist before spawning. Return error if missing.
|
||||
|
||||
**[Risk] Config files in containers could be read by other processes**
|
||||
→ Mitigation: Mount config files with restrictive permissions (0400). Run containers as non-root.
|
||||
|
||||
**[Risk] Large configs could exceed container env var limits**
|
||||
→ Mitigation: Document size limits. Consider config file mounting for large values.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No migration needed. This extends existing models.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should configs support JSON schema validation?
|
||||
2. Do we need bulk import/export for configs/secrets?
|
||||
3. Should secret keys be validated against a naming convention?
|
||||
@@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
Tool instances need runtime configuration and secrets (API keys, database passwords, etc.). The backend has Config and Secret models (FN-004), but there's no UI for users to manage these values, and no runtime injection mechanism to pass them into spawned containers.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Config management UI**: Frontend pages for creating, updating, and deleting config values at global/user/project/instance scopes
|
||||
- **Secret management UI**: Frontend pages for encrypted secret storage with masked value display
|
||||
- **Runtime injection**: Backend service that mounts configs and secrets into tool containers at spawn time
|
||||
- **Scope-based access control**: Configs/secrets respect scope hierarchy (global → user → project → instance)
|
||||
- **Encryption verification**: Ensure Fernet encryption is properly applied to all secret values
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `config-management`: CRUD operations for configuration values with scope support
|
||||
- `secret-management`: Encrypted storage and retrieval of sensitive values
|
||||
- `runtime-injection`: Mount configs and secrets into tool containers at spawn
|
||||
|
||||
### Modified Capabilities
|
||||
- None (extends existing Config/Secret models)
|
||||
|
||||
## Impact
|
||||
|
||||
- **apps/web/src/**: New config and secret management pages
|
||||
- **apps/api/app/routers/configs.py**: Enhanced with scope filtering
|
||||
- **apps/api/app/routers/secrets.py**: Enhanced with scope filtering
|
||||
- **apps/api/app/services/**: New runtime injection service
|
||||
- **apps/api/app/models/**: Potential Config/Secret model updates for scope validation
|
||||
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: User can create config values
|
||||
The system SHALL allow users to create configuration values at various scopes.
|
||||
|
||||
#### Scenario: Create project config
|
||||
- **WHEN** the user navigates to project settings
|
||||
- **AND** clicks "Add Config"
|
||||
- **THEN** a form appears with key, value, and scope fields
|
||||
- **AND** submitting creates a config at the selected scope
|
||||
|
||||
#### Scenario: Config scope validation
|
||||
- **WHEN** the user creates a config
|
||||
- **THEN** the scope must be one of: global, user, project, instance
|
||||
- **AND** the scope_id must match the selected scope type
|
||||
|
||||
### Requirement: User can view and update configs
|
||||
The system SHALL display configs with scope-based filtering.
|
||||
|
||||
#### Scenario: List configs
|
||||
- **WHEN** the user views configs for a project
|
||||
- **THEN** all configs visible at project scope or above are displayed
|
||||
- **AND** values are shown as formatted JSON
|
||||
|
||||
#### Scenario: Update config
|
||||
- **WHEN** the user edits a config value
|
||||
- **THEN** the updated value is saved
|
||||
- **AND** the change takes effect on next tool spawn
|
||||
|
||||
### Requirement: User can delete configs
|
||||
The system SHALL allow deletion of config values.
|
||||
|
||||
#### Scenario: Delete config
|
||||
- **WHEN** the user clicks delete on a config
|
||||
- **THEN** a confirmation dialog appears
|
||||
- **AND** confirming removes the config
|
||||
- **AND** the config is no longer injected into containers
|
||||
@@ -0,0 +1,40 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Configs are mounted into tool containers
|
||||
The system SHALL mount configuration values as files into spawned tool containers.
|
||||
|
||||
#### Scenario: Config file mount
|
||||
- **WHEN** a tool instance is spawned
|
||||
- **THEN** all applicable configs are written to /app/config/
|
||||
- **AND** each config is a separate JSON file named by key
|
||||
- **AND** files have restrictive permissions (0400)
|
||||
|
||||
#### Scenario: Config scope resolution
|
||||
- **WHEN** configs are resolved for a tool instance
|
||||
- **THEN** the system collects configs from all applicable scopes
|
||||
- **AND** instance scope overrides project scope
|
||||
- **AND** project scope overrides user scope
|
||||
- **AND** user scope overrides global scope
|
||||
|
||||
### Requirement: Secrets are injected as environment variables
|
||||
The system SHALL inject secret values as environment variables into tool containers.
|
||||
|
||||
#### Scenario: Secret env var injection
|
||||
- **WHEN** a tool instance is spawned
|
||||
- **THEN** all applicable secrets are decrypted
|
||||
- **AND** injected as environment variables with uppercase keys
|
||||
- **AND** the container process can access them
|
||||
|
||||
#### Scenario: Secret scope resolution
|
||||
- **WHEN** secrets are resolved for a tool instance
|
||||
- **THEN** the same scope hierarchy applies as configs
|
||||
- **AND** closest scope wins on key collision
|
||||
|
||||
### Requirement: Missing secrets fail spawn
|
||||
The system SHALL prevent spawning if referenced secrets are missing.
|
||||
|
||||
#### Scenario: Validate secrets before spawn
|
||||
- **WHEN** a spawn request references a secret by key
|
||||
- **AND** the secret does not exist in any applicable scope
|
||||
- **THEN** the spawn fails with a clear error message
|
||||
- **AND** no container is created
|
||||
@@ -0,0 +1,37 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: User can create secrets
|
||||
The system SHALL allow users to store encrypted secret values.
|
||||
|
||||
#### Scenario: Create secret
|
||||
- **WHEN** the user navigates to project secrets
|
||||
- **AND** clicks "Add Secret"
|
||||
- **THEN** a form appears with key and value fields
|
||||
- **AND** the value is encrypted with Fernet before storage
|
||||
- **AND** the user sees a masked value (e.g., ••••••) after creation
|
||||
|
||||
#### Scenario: Secret scope
|
||||
- **WHEN** the user creates a secret
|
||||
- **THEN** the scope can be user, project, or instance
|
||||
- **AND** the secret is only visible within that scope hierarchy
|
||||
|
||||
### Requirement: Secrets are never exposed decrypted
|
||||
The system SHALL prevent decrypted secret values from being sent to the frontend.
|
||||
|
||||
#### Scenario: Secret list display
|
||||
- **WHEN** the user views the secrets list
|
||||
- **THEN** only secret keys and scopes are visible
|
||||
- **AND** values are always masked
|
||||
|
||||
#### Scenario: Secret update
|
||||
- **WHEN** the user updates a secret
|
||||
- **THEN** only the new value is sent to the backend
|
||||
- **AND** the old value is replaced (not displayed)
|
||||
|
||||
### Requirement: User can delete secrets
|
||||
The system SHALL allow deletion of secret values.
|
||||
|
||||
#### Scenario: Delete secret
|
||||
- **WHEN** the user deletes a secret
|
||||
- **THEN** the encrypted value is permanently removed
|
||||
- **AND** the secret is no longer injected into containers
|
||||
@@ -0,0 +1,48 @@
|
||||
## 1. Backend Enhancements
|
||||
|
||||
- [x] 1.1 Update Config model with scope validation methods
|
||||
- [x] 1.2 Update Secret model with encryption verification
|
||||
- [x] 1.3 Enhance configs router with scope filtering and hierarchy resolution
|
||||
- [x] 1.4 Enhance secrets router with scope filtering and hierarchy resolution
|
||||
- [x] 1.5 Create apps/api/app/services/runtime_injection.py for config/secret resolution
|
||||
- [x] 1.6 Implement config file generation for container mounts
|
||||
- [x] 1.7 Implement secret env var generation for container injection
|
||||
- [x] 1.8 Add validation to fail spawn when referenced secrets are missing
|
||||
|
||||
## 2. Frontend - Config Management
|
||||
|
||||
- [x] 2.1 Create ConfigList component at /projects/:id/configs
|
||||
- [x] 2.2 Implement ConfigForm for creating/updating configs
|
||||
- [x] 2.3 Add scope selector (project/instance/global) to config form
|
||||
- [x] 2.4 Implement config delete with confirmation
|
||||
- [x] 2.5 Add JSON formatting for config values
|
||||
|
||||
## 3. Frontend - Secret Management
|
||||
|
||||
- [x] 3.1 Create SecretList component at /projects/:id/secrets
|
||||
- [x] 3.2 Implement SecretForm for creating/updating secrets
|
||||
- [x] 3.3 Add masked value display (never show decrypted)
|
||||
- [x] 3.4 Implement secret delete with confirmation
|
||||
- [x] 3.5 Add scope selector to secret form
|
||||
|
||||
## 4. Runtime Integration
|
||||
|
||||
- [x] 4.1 Integrate runtime injection into tool instance spawn endpoint
|
||||
- [x] 4.2 Update Docker Compose generation to include config mounts
|
||||
- [x] 4.3 Update Docker Compose generation to include secret env vars
|
||||
- [x] 4.4 Test config/secret injection in local Docker environment
|
||||
|
||||
## 5. Testing & Verification
|
||||
|
||||
- [x] 5.1 Write backend tests for config scope resolution
|
||||
- [x] 5.2 Write backend tests for secret encryption/decryption
|
||||
- [x] 5.3 Write backend tests for runtime injection
|
||||
- [x] 5.4 Write frontend tests for ConfigList and SecretList
|
||||
- [x] 5.5 Run full test suite: `make test`
|
||||
- [x] 5.6 Run linters: `make lint`
|
||||
|
||||
## 6. Documentation
|
||||
|
||||
- [x] 6.1 Update docs/development.md with config/secrets workflow
|
||||
- [x] 6.2 Add config/secrets UI guide to docs/architecture.md
|
||||
- [x] 6.3 Document scope hierarchy and resolution rules
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-14
|
||||
@@ -0,0 +1,67 @@
|
||||
## Context
|
||||
|
||||
The platform routes tool instances via Traefik using subdomain patterns like `https://{tool}-{project}-{user}.{tool_domain}`. Currently, there's no automated label generation or production deployment configuration. This design establishes the deployment architecture.
|
||||
|
||||
Current state:
|
||||
- `docker-compose.yml` for local dev only
|
||||
- `docker-compose.traefik.yml` exists but is minimal
|
||||
- `deploy/` directory has skeleton files
|
||||
- No automated Traefik label generation
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Generate Traefik labels automatically when spawning tools
|
||||
- Provide production-ready Docker Compose stack
|
||||
- Support Portainer-managed deployment
|
||||
- Enable HTTPS with automatic certificate management
|
||||
|
||||
**Non-Goals:**
|
||||
- Kubernetes deployment (deferred post-MVP)
|
||||
- Multi-region or high-availability setup
|
||||
- Custom reverse proxy (Traefik is the only supported option)
|
||||
- Automatic DNS management
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Label generation in backend, not in Docker Compose**
|
||||
- Rationale: Backend has all metadata (user slug, project slug, tool ID). Generating labels at spawn time is more flexible than static Compose files.
|
||||
- Implementation: `TraefikLabelGenerator` service class
|
||||
|
||||
**2. Subdomain pattern: `{tool}-{project}-{user}.{domain}`**
|
||||
- Rationale: Unique, deterministic, human-readable
|
||||
- Example: `code-server-myapp-alice.headquarter.example.com`
|
||||
|
||||
**3. Separate Docker networks: `platform` and `tools`**
|
||||
- Rationale: Network isolation between platform services and user tools
|
||||
- Platform network: API, web, Traefik, database
|
||||
- Tools network: Traefik + tool containers only
|
||||
|
||||
**4. Portainer as the deployment target**
|
||||
- Rationale: Docker Compose-native, web UI for operators, supports stacks and webhooks
|
||||
- Alternative: Raw Docker Compose on VM - less operator-friendly
|
||||
|
||||
**5. Let's Encrypt for HTTPS in production**
|
||||
- Rationale: Free, automatic, Traefik has built-in support
|
||||
- Alternative: Custom certificates - adds operational burden
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] Traefik label complexity grows with features**
|
||||
→ Mitigation: Keep label generation centralized in one service class. Test label output against Traefik schema.
|
||||
|
||||
**[Risk] Portainer stack updates require downtime**
|
||||
→ Mitigation: Use rolling updates where possible. Document blue-green deployment strategy.
|
||||
|
||||
**[Risk] Subdomain collision**
|
||||
→ Mitigation: Enforce unique project slugs per user. Include user slug in subdomain.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No migration - new deployment stack is additive.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should we support custom domains per user/project in MVP?
|
||||
2. Do we need basic auth or IP allow-listing for Traefik dashboard?
|
||||
3. Should tool containers run on a separate Docker daemon for security?
|
||||
@@ -0,0 +1,31 @@
|
||||
## Why
|
||||
|
||||
The scaffold provides local Docker Compose development (FN-002) but lacks production deployment configuration. Without Traefik label generation and production stacks, tool instances cannot receive HTTPS subdomains, blocking the core value proposition of the platform.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **Traefik label generator**: Backend service that generates Docker labels for subdomain routing based on tool instance metadata
|
||||
- **Production Docker Compose stack**: `docker-compose.prod.yml` with API, web, Traefik, and PostgreSQL services
|
||||
- **Portainer stack definition**: Docker Compose file optimized for Portainer deployment
|
||||
- **Dynamic subdomain routing**: Automatic Traefik rule generation for spawned tool containers
|
||||
- **HTTPS configuration**: Let's Encrypt or custom certificate support via Traefik
|
||||
- **Network isolation**: Separate Docker networks for platform and tool containers
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `traefik-label-generator`: Generate Traefik Docker labels for tool subdomain routing
|
||||
- `production-compose-stack`: Production Docker Compose configuration
|
||||
- `portainer-deployment`: Portainer-friendly stack definition and deployment guide
|
||||
- `subdomain-routing`: Dynamic HTTPS subdomain allocation for tool instances
|
||||
|
||||
### Modified Capabilities
|
||||
- None (this extends the existing deployment skeleton)
|
||||
|
||||
## Impact
|
||||
|
||||
- **apps/api/app/services/**: New Traefik label generation service
|
||||
- **apps/api/app/routers/tool_instances.py**: Integrate label generation on spawn
|
||||
- **deploy/**: New production deployment files
|
||||
- **docker-compose.prod.yml**: Production stack definition
|
||||
- **docs/deployment.md**: Updated deployment instructions
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Stack deploys via Portainer
|
||||
The system SHALL provide a Portainer-compatible stack definition.
|
||||
|
||||
#### Scenario: Portainer stack file
|
||||
- **WHEN** an operator deploys via Portainer
|
||||
- **THEN** they can paste the stack definition into Portainer's stack editor
|
||||
- **AND** Portainer can pull and deploy all services
|
||||
|
||||
#### Scenario: Environment variables in Portainer
|
||||
- **WHEN** the stack is deployed via Portainer
|
||||
- **THEN** environment variables are configured in Portainer's UI
|
||||
- **AND** the stack references these variables
|
||||
|
||||
### Requirement: Deployment documentation is complete
|
||||
The system SHALL provide operator documentation for deployment.
|
||||
|
||||
#### Scenario: Deployment guide
|
||||
- **WHEN** an operator reads docs/deployment.md
|
||||
- **THEN** they find step-by-step instructions for Portainer deployment
|
||||
- **AND** prerequisites and assumptions are clearly stated
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Production stack includes all required services
|
||||
The system SHALL provide a production Docker Compose stack with API, web, Traefik, and PostgreSQL.
|
||||
|
||||
#### Scenario: Stack services
|
||||
- **WHEN** the production stack is deployed
|
||||
- **THEN** the following services run: api, web, traefik, db
|
||||
- **AND** Traefik routes requests to the appropriate service
|
||||
- **AND** services communicate via isolated Docker networks
|
||||
|
||||
#### Scenario: Environment configuration
|
||||
- **WHEN** the stack starts
|
||||
- **THEN** it reads environment variables from .env
|
||||
- **AND** sensitive values are not hardcoded
|
||||
|
||||
### Requirement: Production stack is secure by default
|
||||
The system SHALL configure security headers and access controls in production.
|
||||
|
||||
#### Scenario: HTTPS only
|
||||
- **WHEN** the stack runs in production
|
||||
- **THEN** all traffic uses HTTPS
|
||||
- **AND** HTTP redirects to HTTPS
|
||||
|
||||
#### Scenario: Network isolation
|
||||
- **WHEN** the stack is deployed
|
||||
- **THEN** platform services and tool containers are on separate networks
|
||||
- **AND** tool containers cannot access the database directly
|
||||
@@ -0,0 +1,23 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Each tool instance gets a unique subdomain
|
||||
The system SHALL assign a unique HTTPS subdomain to each running tool instance.
|
||||
|
||||
#### Scenario: Subdomain pattern
|
||||
- **WHEN** a tool instance is spawned
|
||||
- **THEN** its subdomain follows `{tool}-{project}-{user}.{domain}`
|
||||
- **AND** the subdomain is deterministic based on instance metadata
|
||||
|
||||
#### Scenario: Subdomain accessibility
|
||||
- **WHEN** a tool instance reaches running status
|
||||
- **THEN** its subdomain resolves via DNS
|
||||
- **AND** Traefik routes the subdomain to the container
|
||||
- **AND** the user can access the tool via the subdomain URL
|
||||
|
||||
### Requirement: Subdomain is released on stop
|
||||
The system SHALL remove Traefik routing when a tool instance stops.
|
||||
|
||||
#### Scenario: Stop removes routing
|
||||
- **WHEN** a tool instance is stopped
|
||||
- **THEN** Traefik labels are removed or disabled
|
||||
- **AND** the subdomain no longer routes to the container
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tool spawn generates Traefik labels
|
||||
The system SHALL generate Docker labels for Traefik when spawning a tool instance.
|
||||
|
||||
#### Scenario: Label generation on spawn
|
||||
- **WHEN** a tool instance is spawned
|
||||
- **THEN** the backend generates Traefik router and service labels
|
||||
- **AND** labels include rule, service, port, and TLS configuration
|
||||
- **AND** labels are stored with the tool instance metadata
|
||||
|
||||
#### Scenario: Label format
|
||||
- **WHEN** labels are generated for a tool instance
|
||||
- **THEN** router rule uses Host(`{subdomain}.{domain}`)
|
||||
- **AND** service points to the container's exposed port
|
||||
- **AND** TLS is enabled with certResolver
|
||||
|
||||
### Requirement: Label generation handles multiple instances
|
||||
The system SHALL generate unique labels for each tool instance.
|
||||
|
||||
#### Scenario: Unique router names
|
||||
- **WHEN** multiple instances of the same tool exist
|
||||
- **THEN** each instance gets a unique router name
|
||||
- **AND** no label collisions occur
|
||||
@@ -0,0 +1,44 @@
|
||||
## 1. Traefik Label Generator
|
||||
|
||||
- [x] 1.1 Create apps/api/app/services/traefik.py with TraefikLabelGenerator class
|
||||
- [x] 1.2 Implement subdomain generation from tool_id, project_slug, user_slug
|
||||
- [x] 1.3 Generate router labels (rule, service, tls)
|
||||
- [x] 1.4 Generate service labels (loadBalancer, port)
|
||||
- [x] 1.5 Add middleware labels for security headers
|
||||
- [x] 1.6 Write unit tests for label generation
|
||||
|
||||
## 2. Backend Integration
|
||||
|
||||
- [x] 2.1 Integrate label generation into tool instance spawn endpoint
|
||||
- [x] 2.2 Store generated labels in tool_instance metadata
|
||||
- [x] 2.3 Remove/disable labels on tool instance stop
|
||||
- [x] 2.4 Update ToolInstance model to store labels JSON
|
||||
|
||||
## 3. Production Docker Compose
|
||||
|
||||
- [x] 3.1 Create docker-compose.prod.yml with api, web, traefik, db services
|
||||
- [x] 3.2 Configure Traefik service with Let's Encrypt certificates
|
||||
- [x] 3.3 Set up platform and tools networks
|
||||
- [x] 3.4 Add health checks for all services
|
||||
- [x] 3.5 Configure logging (JSON format, rotation)
|
||||
|
||||
## 4. Portainer Deployment
|
||||
|
||||
- [x] 4.1 Create deploy/portainer-stack.yml
|
||||
- [x] 4.2 Add Portainer-specific environment variable documentation
|
||||
- [x] 4.3 Create deploy/.env.example for production
|
||||
- [x] 4.4 Test stack deployment locally with docker compose -f docker-compose.prod.yml
|
||||
|
||||
## 5. Documentation
|
||||
|
||||
- [x] 5.1 Update docs/deployment.md with production deployment steps
|
||||
- [x] 5.2 Add Traefik configuration guide
|
||||
- [x] 5.3 Document subdomain scheme and DNS requirements
|
||||
- [x] 5.4 Update README.md with deployment section
|
||||
|
||||
## 6. Testing & Verification
|
||||
|
||||
- [x] 6.1 Test label generation for all built-in tools
|
||||
- [x] 6.2 Verify Traefik routes correctly in local stack
|
||||
- [x] 6.3 Run backend tests: `cd apps/api && pytest`
|
||||
- [x] 6.4 Run linters: `ruff check app/` and `mypy app/`
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-05-14
|
||||
@@ -0,0 +1,65 @@
|
||||
## Context
|
||||
|
||||
OpenCode is an AI-powered terminal-based development environment with a web interface. Unlike code-server which is a full web IDE, OpenCode provides a terminal experience accessible through the browser. This POC validates that the spawn system handles different runtime types including web terminal forwarding.
|
||||
|
||||
Current state:
|
||||
- OpenCode manifest exists in apps/api/app/tools/manifests/opencode.yml
|
||||
- No container image or runtime defined yet
|
||||
- No health reporting mechanism
|
||||
- Spawn infrastructure will be built in FN-010
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Define OpenCode as a spawnable tool
|
||||
- Provide web terminal interface in container
|
||||
- Report health status (running/idle/error)
|
||||
- Support interactive terminal sessions
|
||||
|
||||
**Non-Goals:**
|
||||
- Full task queue or job scheduler
|
||||
- Persistent process management
|
||||
- Log streaming (deferred)
|
||||
- Multi-language support beyond terminal
|
||||
|
||||
## Decisions
|
||||
|
||||
**1. Use official OpenCode Docker image**
|
||||
- Rationale: Maintained, includes AI features and web terminal
|
||||
- Alternative: Custom image - unnecessary for POC
|
||||
|
||||
**2. OpenCode runs as a persistent container**
|
||||
- Rationale: Easier to manage lifecycle (start/stop/status). Terminal sessions need persistent container.
|
||||
- Implementation: Container runs OpenCode with web interface on port 3000
|
||||
|
||||
**3. Health check via HTTP endpoint**
|
||||
- Rationale: Standard Docker health check mechanism. Traefik can use it.
|
||||
- Endpoint: `GET /` returns 200 when ready
|
||||
|
||||
**4. Workspace mounted from host (same as code-server)**
|
||||
- Rationale: Consistency. Shared workspace between tools.
|
||||
- Path: `/data/workspaces/{user_slug}/{project_slug}`
|
||||
|
||||
**5. Configs/secrets injected same as code-server**
|
||||
- Rationale: Reuse FN-009 infrastructure. No special handling needed.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
**[Risk] OpenCode container requires significant resources**
|
||||
→ Mitigation: Set resource limits (4GB RAM, 2 CPU). Document requirements.
|
||||
|
||||
**[Risk] Web terminal performance over slow connections**
|
||||
→ Mitigation: Use modern terminal emulation with compression. Document bandwidth requirements.
|
||||
|
||||
**[Risk] AI features require API keys**
|
||||
→ Mitigation: Support secret injection for API keys. Document configuration.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
No migration. New feature.
|
||||
|
||||
## Open Questions
|
||||
|
||||
1. Should OpenCode support multiple terminal sessions?
|
||||
2. Do we pre-configure common development tools?
|
||||
3. Should OpenCode integrate with the platform's AI provider?
|
||||
@@ -0,0 +1,28 @@
|
||||
## Why
|
||||
|
||||
OpenCode is an AI-powered terminal-based development environment that provides a web interface for interactive development. It demonstrates the platform's extensibility beyond standard tools like code-server. As a POC, it validates the manifest-driven spawn system with a non-trivial runtime that requires web terminal forwarding.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **OpenCode manifest**: Define the tool with terminal web interface, workspace mounts, and health checks
|
||||
- **Container image**: Reference to OpenCode image with built-in web terminal
|
||||
- **Health reporting**: Endpoint that reports tool health to the platform
|
||||
- **Spawn integration**: Reuse the spawn flow from FN-010 but with OpenCode-specific configuration
|
||||
- **Web terminal**: Support for browser-based terminal access
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `opencode-manifest`: OpenCode tool manifest with web terminal config
|
||||
- `web-terminal`: Support for browser-based terminal interfaces
|
||||
- `health-reporting`: Tool health status reporting mechanism
|
||||
|
||||
### Modified Capabilities
|
||||
- None (reuses spawn infrastructure from FN-010)
|
||||
|
||||
## Impact
|
||||
|
||||
- **apps/api/app/tools/manifests/opencode.yml**: Updated manifest
|
||||
- **apps/api/app/services/spawn.py**: Minor updates for OpenCode-specific mounts
|
||||
- **apps/web/src/**: OpenCode appears in tool selection UI
|
||||
- **Docker images**: Uses official OpenCode image
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Container provides web terminal interface
|
||||
The system SHALL provide a web terminal interface in the OpenCode container.
|
||||
|
||||
#### Scenario: Terminal available
|
||||
- **WHEN** the OpenCode container is running
|
||||
- **THEN** a web terminal is accessible via HTTP on port 3000
|
||||
- **AND** the user can execute shell commands through the browser
|
||||
|
||||
#### Scenario: Workspace access
|
||||
- **WHEN** the container runs
|
||||
- **THEN** the project workspace is mounted at /workspace
|
||||
- **AND** the user can read/write files in the workspace
|
||||
|
||||
### Requirement: Container supports AI features
|
||||
The system SHALL allow AI-powered development features in the OpenCode environment.
|
||||
|
||||
#### Scenario: AI assistance
|
||||
- **WHEN** the user interacts with OpenCode
|
||||
- **THEN** AI features are available for code completion and assistance
|
||||
- **AND** the user can configure AI provider settings
|
||||
|
||||
#### Scenario: Terminal session persistence
|
||||
- **WHEN** the user opens a terminal session
|
||||
- **THEN** the session persists while the container runs
|
||||
- **AND** multiple sessions can be opened
|
||||
@@ -0,0 +1,22 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Tool reports health status
|
||||
The system SHALL provide a mechanism for OpenCode to report its health.
|
||||
|
||||
#### Scenario: Health endpoint
|
||||
- **WHEN** the OpenCode container is running
|
||||
- **THEN** it exposes a / endpoint for health checks
|
||||
- **AND** returns 200 when the web terminal is ready
|
||||
|
||||
#### Scenario: Health check in Traefik
|
||||
- **WHEN** the container is spawned
|
||||
- **THEN** Traefik uses the health endpoint for routing decisions
|
||||
- **AND** unhealthy containers are removed from the load balancer
|
||||
|
||||
### Requirement: Platform tracks tool health
|
||||
The system SHALL track and display the health of OpenCode instances.
|
||||
|
||||
#### Scenario: Status display
|
||||
- **WHEN** the user views an OpenCode instance
|
||||
- **THEN** the current status is displayed (healthy, unhealthy, starting)
|
||||
- **AND** the status updates automatically
|
||||
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: OpenCode manifest defines web terminal environment
|
||||
The system SHALL provide an OpenCode manifest with web terminal configuration.
|
||||
|
||||
#### Scenario: Manifest includes terminal config
|
||||
- **WHEN** the OpenCode manifest is loaded
|
||||
- **THEN** it specifies an OpenCode Docker image with web interface
|
||||
- **AND** it defines exposed ports for the HTTP interface (port 3000)
|
||||
- **AND** it defines volume mounts (workspace, config)
|
||||
|
||||
#### Scenario: Manifest includes health check
|
||||
- **WHEN** the manifest is used for spawning
|
||||
- **THEN** it defines a health check endpoint
|
||||
- **AND** specifies health check interval and timeout
|
||||
@@ -0,0 +1,42 @@
|
||||
## 1. Manifest Definition
|
||||
|
||||
- [x] 1.1 Create apps/api/app/tools/manifests/opencode.yml with web terminal config
|
||||
- [x] 1.2 Add Docker image (ghcr.io/opencode-ai/opencode:latest), ports (3000), volumes
|
||||
- [x] 1.3 Add health check configuration to manifest
|
||||
- [x] 1.4 Validate manifest against ToolManifest schema
|
||||
|
||||
## 2. Container Setup
|
||||
|
||||
- [x] 2.1 Verify OpenCode image availability and configuration
|
||||
- [x] 2.2 Document web terminal access pattern
|
||||
- [x] 2.3 Configure environment variables for terminal support
|
||||
- [x] 2.4 Test container locally with docker run
|
||||
- [x] 2.5 Verify web terminal accessibility
|
||||
|
||||
## 3. Spawn Integration
|
||||
|
||||
- [x] 3.1 Verify SpawnService (FN-010) can spawn OpenCode instances
|
||||
- [x] 3.2 Add OpenCode-specific volume mounts (config)
|
||||
- [x] 3.3 Test spawn via API endpoint
|
||||
- [x] 3.4 Verify Traefik routing to OpenCode container
|
||||
|
||||
## 4. Frontend Integration
|
||||
|
||||
- [x] 4.1 Add OpenCode to tool selection dropdown
|
||||
- [x] 4.2 Display OpenCode-specific options in spawn form
|
||||
- [x] 4.3 Show OpenCode instance status in detail page
|
||||
|
||||
## 5. Testing & Verification
|
||||
|
||||
- [x] 5.1 Test terminal availability in spawned container
|
||||
- [x] 5.2 Test web interface accessibility
|
||||
- [x] 5.3 Test health endpoint response
|
||||
- [x] 5.4 Verify workspace mount is accessible
|
||||
- [x] 5.5 Run full test suite: `make test`
|
||||
- [x] 5.6 Run linters: `make lint`
|
||||
|
||||
## 6. Documentation
|
||||
|
||||
- [x] 6.1 Document OpenCode setup in docs/development.md
|
||||
- [x] 6.2 Add OpenCode usage guide
|
||||
- [x] 6.3 Document terminal configuration and AI features
|
||||
@@ -1,48 +1,48 @@
|
||||
## 1. Backend Enhancements
|
||||
|
||||
- [ ] 1.1 Update Config model with scope validation methods
|
||||
- [ ] 1.2 Update Secret model with encryption verification
|
||||
- [ ] 1.3 Enhance configs router with scope filtering and hierarchy resolution
|
||||
- [ ] 1.4 Enhance secrets router with scope filtering and hierarchy resolution
|
||||
- [ ] 1.5 Create apps/api/app/services/runtime_injection.py for config/secret resolution
|
||||
- [ ] 1.6 Implement config file generation for container mounts
|
||||
- [ ] 1.7 Implement secret env var generation for container injection
|
||||
- [ ] 1.8 Add validation to fail spawn when referenced secrets are missing
|
||||
- [x] 1.1 Update Config model with scope validation methods
|
||||
- [x] 1.2 Update Secret model with encryption verification
|
||||
- [x] 1.3 Enhance configs router with scope filtering and hierarchy resolution
|
||||
- [x] 1.4 Enhance secrets router with scope filtering and hierarchy resolution
|
||||
- [x] 1.5 Create apps/api/app/services/runtime_injection.py for config/secret resolution
|
||||
- [x] 1.6 Implement config file generation for container mounts
|
||||
- [x] 1.7 Implement secret env var generation for container injection
|
||||
- [x] 1.8 Add validation to fail spawn when referenced secrets are missing
|
||||
|
||||
## 2. Frontend - Config Management
|
||||
|
||||
- [ ] 2.1 Create ConfigList component at /projects/:id/configs
|
||||
- [ ] 2.2 Implement ConfigForm for creating/updating configs
|
||||
- [ ] 2.3 Add scope selector (project/instance/global) to config form
|
||||
- [ ] 2.4 Implement config delete with confirmation
|
||||
- [ ] 2.5 Add JSON formatting for config values
|
||||
- [x] 2.1 Create ConfigList component at /projects/:id/configs
|
||||
- [x] 2.2 Implement ConfigForm for creating/updating configs
|
||||
- [x] 2.3 Add scope selector (project/instance/global) to config form
|
||||
- [x] 2.4 Implement config delete with confirmation
|
||||
- [x] 2.5 Add JSON formatting for config values
|
||||
|
||||
## 3. Frontend - Secret Management
|
||||
|
||||
- [ ] 3.1 Create SecretList component at /projects/:id/secrets
|
||||
- [ ] 3.2 Implement SecretForm for creating/updating secrets
|
||||
- [ ] 3.3 Add masked value display (never show decrypted)
|
||||
- [ ] 3.4 Implement secret delete with confirmation
|
||||
- [ ] 3.5 Add scope selector to secret form
|
||||
- [x] 3.1 Create SecretList component at /projects/:id/secrets
|
||||
- [x] 3.2 Implement SecretForm for creating/updating secrets
|
||||
- [x] 3.3 Add masked value display (never show decrypted)
|
||||
- [x] 3.4 Implement secret delete with confirmation
|
||||
- [x] 3.5 Add scope selector to secret form
|
||||
|
||||
## 4. Runtime Integration
|
||||
|
||||
- [ ] 4.1 Integrate runtime injection into tool instance spawn endpoint
|
||||
- [ ] 4.2 Update Docker Compose generation to include config mounts
|
||||
- [ ] 4.3 Update Docker Compose generation to include secret env vars
|
||||
- [ ] 4.4 Test config/secret injection in local Docker environment
|
||||
- [x] 4.1 Integrate runtime injection into tool instance spawn endpoint
|
||||
- [x] 4.2 Update Docker Compose generation to include config mounts
|
||||
- [x] 4.3 Update Docker Compose generation to include secret env vars
|
||||
- [x] 4.4 Test config/secret injection in local Docker environment
|
||||
|
||||
## 5. Testing & Verification
|
||||
|
||||
- [ ] 5.1 Write backend tests for config scope resolution
|
||||
- [ ] 5.2 Write backend tests for secret encryption/decryption
|
||||
- [ ] 5.3 Write backend tests for runtime injection
|
||||
- [ ] 5.4 Write frontend tests for ConfigList and SecretList
|
||||
- [ ] 5.5 Run full test suite: `make test`
|
||||
- [ ] 5.6 Run linters: `make lint`
|
||||
- [x] 5.1 Write backend tests for config scope resolution
|
||||
- [x] 5.2 Write backend tests for secret encryption/decryption
|
||||
- [x] 5.3 Write backend tests for runtime injection
|
||||
- [x] 5.4 Write frontend tests for ConfigList and SecretList
|
||||
- [x] 5.5 Run full test suite: `make test`
|
||||
- [x] 5.6 Run linters: `make lint`
|
||||
|
||||
## 6. Documentation
|
||||
|
||||
- [ ] 6.1 Update docs/development.md with config/secrets workflow
|
||||
- [ ] 6.2 Add config/secrets UI guide to docs/architecture.md
|
||||
- [ ] 6.3 Document scope hierarchy and resolution rules
|
||||
- [x] 6.1 Update docs/development.md with config/secrets workflow
|
||||
- [x] 6.2 Add config/secrets UI guide to docs/architecture.md
|
||||
- [x] 6.3 Document scope hierarchy and resolution rules
|
||||
|
||||
@@ -7,36 +7,36 @@
|
||||
|
||||
## 2. Container Setup
|
||||
|
||||
- [ ] 2.1 Verify OpenCode image availability and configuration
|
||||
- [ ] 2.2 Document web terminal access pattern
|
||||
- [ ] 2.3 Configure environment variables for terminal support
|
||||
- [ ] 2.4 Test container locally with docker run
|
||||
- [ ] 2.5 Verify web terminal accessibility
|
||||
- [x] 2.1 Verify OpenCode image availability and configuration
|
||||
- [x] 2.2 Document web terminal access pattern
|
||||
- [x] 2.3 Configure environment variables for terminal support
|
||||
- [x] 2.4 Test container locally with docker run
|
||||
- [x] 2.5 Verify web terminal accessibility
|
||||
|
||||
## 3. Spawn Integration
|
||||
|
||||
- [ ] 3.1 Verify SpawnService (FN-010) can spawn OpenCode instances
|
||||
- [ ] 3.2 Add OpenCode-specific volume mounts (config)
|
||||
- [ ] 3.3 Test spawn via API endpoint
|
||||
- [ ] 3.4 Verify Traefik routing to OpenCode container
|
||||
- [x] 3.1 Verify SpawnService (FN-010) can spawn OpenCode instances
|
||||
- [x] 3.2 Add OpenCode-specific volume mounts (config)
|
||||
- [x] 3.3 Test spawn via API endpoint
|
||||
- [x] 3.4 Verify Traefik routing to OpenCode container
|
||||
|
||||
## 4. Frontend Integration
|
||||
|
||||
- [ ] 4.1 Add OpenCode to tool selection dropdown
|
||||
- [ ] 4.2 Display OpenCode-specific options in spawn form
|
||||
- [ ] 4.3 Show OpenCode instance status in detail page
|
||||
- [x] 4.1 Add OpenCode to tool selection dropdown
|
||||
- [x] 4.2 Display OpenCode-specific options in spawn form
|
||||
- [x] 4.3 Show OpenCode instance status in detail page
|
||||
|
||||
## 5. Testing & Verification
|
||||
|
||||
- [ ] 5.1 Test terminal availability in spawned container
|
||||
- [ ] 5.2 Test web interface accessibility
|
||||
- [ ] 5.3 Test health endpoint response
|
||||
- [ ] 5.4 Verify workspace mount is accessible
|
||||
- [ ] 5.5 Run full test suite: `make test`
|
||||
- [ ] 5.6 Run linters: `make lint`
|
||||
- [x] 5.1 Test terminal availability in spawned container
|
||||
- [x] 5.2 Test web interface accessibility
|
||||
- [x] 5.3 Test health endpoint response
|
||||
- [x] 5.4 Verify workspace mount is accessible
|
||||
- [x] 5.5 Run full test suite: `make test`
|
||||
- [x] 5.6 Run linters: `make lint`
|
||||
|
||||
## 6. Documentation
|
||||
|
||||
- [ ] 6.1 Document OpenCode setup in docs/development.md
|
||||
- [ ] 6.2 Add OpenCode usage guide
|
||||
- [ ] 6.3 Document terminal configuration and AI features
|
||||
- [x] 6.1 Document OpenCode setup in docs/development.md
|
||||
- [x] 6.2 Add OpenCode usage guide
|
||||
- [x] 6.3 Document terminal configuration and AI features
|
||||
|
||||
Reference in New Issue
Block a user