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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user