feat(FN-009): implement config and secrets management with runtime injection
- Add RuntimeInjectionService for scope-based config/secret resolution - Mount configs as JSON files at /app/config/ with 0400 permissions - Inject secrets as environment variables with uppercase keys - Implement scope hierarchy: instance > project > user > global - Create ConfigListPage and SecretListPage frontend components - Mask secret values in API responses (never expose decrypted) - Validate secrets exist before spawning containers - Add comprehensive tests for runtime injection service - Update documentation with config/secrets workflow
This commit is contained in:
@@ -6,7 +6,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|||||||
|
|
||||||
from app.auth.dependencies import get_current_active_user
|
from app.auth.dependencies import get_current_active_user
|
||||||
from app.db import get_db_session
|
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.project import Project
|
||||||
from app.models.secret import Secret
|
from app.models.secret import Secret
|
||||||
from app.models.tool_instance import ToolInstance
|
from app.models.tool_instance import ToolInstance
|
||||||
@@ -55,13 +55,7 @@ async def create_secret(
|
|||||||
session.add(secret)
|
session.add(secret)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(secret)
|
await session.refresh(secret)
|
||||||
return SecretRead(
|
return SecretRead.from_secret(secret)
|
||||||
id=secret.id,
|
|
||||||
scope_type=secret.scope_type,
|
|
||||||
scope_id=secret.scope_id,
|
|
||||||
key=secret.key,
|
|
||||||
value=decrypt_value(secret.encrypted_value),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/secrets", response_model=list[SecretRead])
|
@router.get("/secrets", response_model=list[SecretRead])
|
||||||
@@ -82,13 +76,7 @@ async def list_secrets(
|
|||||||
for s in secrets:
|
for s in secrets:
|
||||||
try:
|
try:
|
||||||
await _verify_secret_ownership(s, current_user, session)
|
await _verify_secret_ownership(s, current_user, session)
|
||||||
allowed.append(SecretRead(
|
allowed.append(SecretRead.from_secret(s))
|
||||||
id=s.id,
|
|
||||||
scope_type=s.scope_type,
|
|
||||||
scope_id=s.scope_id,
|
|
||||||
key=s.key,
|
|
||||||
value=decrypt_value(s.encrypted_value),
|
|
||||||
))
|
|
||||||
except HTTPException:
|
except HTTPException:
|
||||||
pass
|
pass
|
||||||
return allowed
|
return allowed
|
||||||
@@ -104,13 +92,7 @@ async def get_secret(
|
|||||||
if not s:
|
if not s:
|
||||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
|
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Secret not found")
|
||||||
await _verify_secret_ownership(s, current_user, session)
|
await _verify_secret_ownership(s, current_user, session)
|
||||||
return SecretRead(
|
return SecretRead.from_secret(s)
|
||||||
id=s.id,
|
|
||||||
scope_type=s.scope_type,
|
|
||||||
scope_id=s.scope_id,
|
|
||||||
key=s.key,
|
|
||||||
value=decrypt_value(s.encrypted_value),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.put("/secrets/{secret_id}", response_model=SecretRead)
|
@router.put("/secrets/{secret_id}", response_model=SecretRead)
|
||||||
@@ -130,13 +112,7 @@ async def update_secret(
|
|||||||
s.encrypted_value = encrypt_value(secret_in.value)
|
s.encrypted_value = encrypt_value(secret_in.value)
|
||||||
await session.commit()
|
await session.commit()
|
||||||
await session.refresh(s)
|
await session.refresh(s)
|
||||||
return SecretRead(
|
return SecretRead.from_secret(s)
|
||||||
id=s.id,
|
|
||||||
scope_type=s.scope_type,
|
|
||||||
scope_id=s.scope_id,
|
|
||||||
key=s.key,
|
|
||||||
value=decrypt_value(s.encrypted_value),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/secrets/{secret_id}", status_code=status.HTTP_204_NO_CONTENT)
|
@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 uuid import UUID
|
||||||
|
|
||||||
from app.schemas.base import OrmBase
|
from app.schemas.base import OrmBase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.models.secret import Secret
|
||||||
|
|
||||||
|
|
||||||
class SecretBase(OrmBase):
|
class SecretBase(OrmBase):
|
||||||
scope_type: str
|
scope_type: str
|
||||||
@@ -15,7 +21,17 @@ class SecretCreate(SecretBase):
|
|||||||
|
|
||||||
class SecretRead(SecretBase):
|
class SecretRead(SecretBase):
|
||||||
id: UUID
|
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):
|
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,
|
workspace_path: Path | None = None,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
ssh_key_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]:
|
) -> dict[str, Any]:
|
||||||
service_name = f"tool-{instance_id[:8]}"
|
service_name = f"tool-{instance_id[:8]}"
|
||||||
|
|
||||||
@@ -96,9 +98,15 @@ class SpawnService:
|
|||||||
if ssh_key_path and ssh_key_path.exists():
|
if ssh_key_path and ssh_key_path.exists():
|
||||||
volumes.append(f"{ssh_key_path}:/home/coder/.ssh:ro")
|
volumes.append(f"{ssh_key_path}:/home/coder/.ssh:ro")
|
||||||
|
|
||||||
|
if config_mounts:
|
||||||
|
volumes.extend(config_mounts)
|
||||||
|
|
||||||
if volumes:
|
if volumes:
|
||||||
service["volumes"] = volumes
|
service["volumes"] = volumes
|
||||||
|
|
||||||
|
if secret_env_vars:
|
||||||
|
service["environment"].update(secret_env_vars)
|
||||||
|
|
||||||
if manifest.health_check:
|
if manifest.health_check:
|
||||||
hc = manifest.health_check
|
hc = manifest.health_check
|
||||||
healthcheck: dict[str, Any] = {
|
healthcheck: dict[str, Any] = {
|
||||||
@@ -170,6 +178,8 @@ class SpawnService:
|
|||||||
workspace_path: Path | None = None,
|
workspace_path: Path | None = None,
|
||||||
config_path: Path | None = None,
|
config_path: Path | None = None,
|
||||||
ssh_key_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]:
|
) -> dict[str, Any]:
|
||||||
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
|
label_gen = TraefikLabelGenerator(domain=settings.root_domain)
|
||||||
|
|
||||||
@@ -203,6 +213,8 @@ class SpawnService:
|
|||||||
workspace_path=workspace_path,
|
workspace_path=workspace_path,
|
||||||
config_path=config_path,
|
config_path=config_path,
|
||||||
ssh_key_path=ssh_key_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)
|
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'
|
const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000'
|
||||||
|
|
||||||
@@ -141,4 +141,58 @@ export const api = {
|
|||||||
const response = await fetchWithAuth('/tools')
|
const response = await fetchWithAuth('/tools')
|
||||||
return response.json()
|
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 ToolInstanceDetailPage from './pages/ToolInstanceDetailPage'
|
||||||
import SettingsPage from './pages/SettingsPage'
|
import SettingsPage from './pages/SettingsPage'
|
||||||
import RepositoriesPage from './pages/RepositoriesPage'
|
import RepositoriesPage from './pages/RepositoriesPage'
|
||||||
|
import ConfigListPage from './pages/ConfigListPage'
|
||||||
|
import SecretListPage from './pages/SecretListPage'
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -36,6 +38,8 @@ export const router = createBrowserRouter([
|
|||||||
{ path: '/projects/:projectId/instances/:instanceId', element: <ToolInstanceDetailPage /> },
|
{ path: '/projects/:projectId/instances/:instanceId', element: <ToolInstanceDetailPage /> },
|
||||||
{ path: '/settings', element: <SettingsPage /> },
|
{ path: '/settings', element: <SettingsPage /> },
|
||||||
{ path: '/repositories', element: <RepositoriesPage /> },
|
{ path: '/repositories', element: <RepositoriesPage /> },
|
||||||
|
{ path: '/projects/:id/configs', element: <ConfigListPage /> },
|
||||||
|
{ path: '/projects/:id/secrets', element: <SecretListPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: '/login', element: <LoginPage /> },
|
||||||
|
|||||||
+31
-55
@@ -52,60 +52,36 @@ export interface ToolInstance {
|
|||||||
updated_at: string
|
updated_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolManifest {
|
export interface Config {
|
||||||
id: string
|
id: string
|
||||||
name: string
|
key: string
|
||||||
description: string
|
value: unknown
|
||||||
version: string
|
scope_type: string
|
||||||
image: string
|
scope_id: string
|
||||||
runtime_command: string[] | null
|
created_at: string
|
||||||
runtime_entrypoint: string[] | null
|
updated_at: string
|
||||||
runtime_user: string | null
|
}
|
||||||
runtime_working_dir: string | null
|
|
||||||
ports: Array<{
|
export interface ConfigCreate {
|
||||||
container_port: number
|
key: string
|
||||||
protocol: string
|
value: unknown
|
||||||
name: string | null
|
scope_type: string
|
||||||
primary: boolean
|
scope_id: string
|
||||||
}>
|
}
|
||||||
workspace_mounts: Array<{
|
|
||||||
type: string
|
export interface Secret {
|
||||||
source_pattern: string
|
id: string
|
||||||
target: string
|
key: string
|
||||||
read_only: boolean
|
value: string
|
||||||
}>
|
scope_type: string
|
||||||
config_mounts: Array<{
|
scope_id: string
|
||||||
type: string
|
created_at: string
|
||||||
source_pattern: string
|
updated_at: string
|
||||||
target: string
|
}
|
||||||
read_only: boolean
|
|
||||||
}>
|
export interface SecretCreate {
|
||||||
env: Record<string, string>
|
key: string
|
||||||
secrets: Array<{
|
value: string
|
||||||
name: string
|
scope_type: string
|
||||||
env_var: string
|
scope_id: 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1171,3 +1171,44 @@ Any implementation task must:
|
|||||||
7. **High availability:** No replicas or load balancing in MVP.
|
7. **High availability:** No replicas or load balancing in MVP.
|
||||||
8. **Backup strategy:** Out of MVP scope; rely on host-level volume backups.
|
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.
|
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,67 @@ docker network create tools # One-time setup
|
|||||||
```
|
```
|
||||||
|
|
||||||
Spawned containers use the `tools` network for Traefik routing.
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user