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:
2026-05-15 16:44:26 +02:00
parent 78aaddb2b5
commit 51d93d9dc6
12 changed files with 838 additions and 86 deletions
+55 -1
View File
@@ -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',
})
},
}
+183
View File
@@ -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>
)
}
+181
View File
@@ -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>
)
}
+4
View File
@@ -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
View File
@@ -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
}