fix: resolve deployment API errors and enable E2E deployment flow

Backend fixes:
- instance_dto: add Version field with Normalize() to support both 'version'
  and 'tag' field names from frontend
- instance_handler: add version empty validation before creating instance
- authz.go: fix unused variable compilation error
- registry_repository: fix GetByID/GetByName to use correct DB schema
  (add workspace_id, owner_id, is_shared fields); decrypt password
  gracefully when encryption key mismatches instead of returning error

Frontend:
- charts/page: add Template and Storage dropdown selectors to Deploy Modal

Testing:
- add e2e_test.py: 5-step Playwright E2E test (admin login → create
  workspace → create user → user login → deploy chart)
- add tasks/lesson.md: document 4 bug root causes and fixes
- add tasks/todo.md: track implementation progress
- add PLAN_E2E_DEPLOYMENT.md: comprehensive implementation plan

Verification: confirmed deployment creates instance with status=deployed,
chart downloads from Harbor OCI to /tmp/charts/, Helm release deploys to K8s
This commit is contained in:
Ivan087
2026-04-16 18:39:23 +08:00
parent ef961d4ade
commit 985369d40f
9 changed files with 813 additions and 17 deletions

View File

@ -1,7 +1,7 @@
'use client';
import { useEffect, useState } from 'react';
import { registryApi, instanceApi, clusterApi } from '@/lib/api';
import { registryApi, instanceApi, clusterApi, valuesTemplateApi, storageApi } from '@/lib/api';
import { Package, Database, ChevronRight, Search, Rocket, X, Loader2 } from 'lucide-react';
interface RegistryDTO {
@ -34,9 +34,28 @@ interface ClusterDTO {
description?: string;
}
interface ValuesTemplateDTO {
id: string;
name: string;
description?: string;
values_yaml: string;
version: number;
is_default: boolean;
chart_reference_id: string;
}
interface StorageDTO {
id: string;
name: string;
type: string;
description?: string;
}
export default function ChartsPage() {
const [registries, setRegistries] = useState<RegistryDTO[]>([]);
const [clusters, setClusters] = useState<ClusterDTO[]>([]);
const [templates, setTemplates] = useState<ValuesTemplateDTO[]>([]);
const [storages, setStorages] = useState<StorageDTO[]>([]);
const [selectedRegistry, setSelectedRegistry] = useState<RegistryDTO | null>(null);
const [repositories, setRepositories] = useState<string[]>([]);
const [selectedRepo, setSelectedRepo] = useState<string | null>(null);
@ -55,6 +74,8 @@ export default function ChartsPage() {
clusterId: '',
description: '',
valuesYaml: '',
selectedTemplateId: '',
selectedStorageId: '',
});
const fetchRegistries = async () => {
@ -83,6 +104,56 @@ export default function ChartsPage() {
}
};
const fetchTemplates = async () => {
try {
const response = await valuesTemplateApi.list();
const data = response.data;
const templateList = Array.isArray(data) ? data : (data?.templates || []);
setTemplates(templateList);
} catch (error) {
console.error('Failed to fetch templates:', error);
}
};
const fetchStorages = async () => {
try {
const response = await storageApi.list();
const data = response.data;
const storageList = Array.isArray(data) ? data : (data?.storages || []);
setStorages(storageList);
} catch (error) {
console.error('Failed to fetch storages:', error);
}
};
const handleTemplateChange = (templateId: string) => {
const template = templates.find(t => t.id === templateId);
if (template) {
setDeployForm(prev => ({ ...prev, selectedTemplateId: templateId, valuesYaml: template.values_yaml }));
}
};
const handleStorageChange = (storageId: string) => {
const storage = storages.find(s => s.id === storageId);
if (storage) {
setDeployForm(prev => ({ ...prev, selectedStorageId: storageId }));
// Merge storage config into values (simple merge for NFS)
try {
const storageConfig = `persistence:
enabled: true
storageClass: "${storage.type}"
`;
setDeployForm(prev => ({
...prev,
selectedStorageId: storageId,
valuesYaml: prev.valuesYaml + '\n' + storageConfig
}));
} catch (e) {
console.error('Failed to merge storage config:', e);
}
}
};
const fetchRepositories = async (registryId: string) => {
setIsLoadingRepos(true);
setRepositories([]);
@ -116,6 +187,8 @@ export default function ChartsPage() {
useEffect(() => {
fetchRegistries();
fetchClusters();
fetchTemplates();
fetchStorages();
}, []);
useEffect(() => {
@ -168,6 +241,8 @@ export default function ChartsPage() {
clusterId: clusters[0]?.id || '',
description: '',
valuesYaml: '',
selectedTemplateId: '',
selectedStorageId: '',
});
} catch (error: unknown) {
console.error('Failed to deploy:', error);
@ -184,10 +259,15 @@ export default function ChartsPage() {
const openDeployModal = (artifact: Artifact) => {
setSelectedArtifact(artifact);
setDeployForm(prev => ({
...prev,
setDeployForm({
name: artifact.tag.replace(/[^\w-]/g, '-').toLowerCase(),
}));
namespace: 'default',
clusterId: clusters[0]?.id || '',
description: '',
valuesYaml: '',
selectedTemplateId: '',
selectedStorageId: '',
});
setShowDeployModal(true);
};
@ -447,6 +527,46 @@ export default function ChartsPage() {
</select>
</div>
{templates.length > 0 && (
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Values Template
</label>
<select
value={deployForm.selectedTemplateId}
onChange={(e) => handleTemplateChange(e.target.value)}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
>
<option value="">-- Select a template --</option>
{templates.map((template) => (
<option key={template.id} value={template.id}>
{template.name} (v{template.version}){template.is_default ? ' [Default]' : ''}
</option>
))}
</select>
</div>
)}
{storages.length > 0 && (
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Storage Backend
</label>
<select
value={deployForm.selectedStorageId}
onChange={(e) => handleStorageChange(e.target.value)}
className="w-full px-3 py-2 bg-[var(--background)] border border-[var(--border)] rounded-lg text-[var(--foreground)]"
>
<option value="">-- Select storage (optional) --</option>
{storages.map((storage) => (
<option key={storage.id} value={storage.id}>
{storage.name} ({storage.type})
</option>
))}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-[var(--foreground)] mb-1">
Description