Compare commits

..

No commits in common. "5dd4ae0a3caab1f8cebf8dcfc4dfc64ae2a7d251" and "f67821c0b83977eef2936152dcc3dbd315c619ca" have entirely different histories.

36 changed files with 3745 additions and 6818 deletions

View File

@ -1,6 +0,0 @@
node_modules/
dist/
target/
src-tauri/target/
coverage/
tailwind.config.ts

View File

@ -134,12 +134,11 @@ jobs:
exit 1 exit 1
fi fi
# Generate changelog for current tag only (range: PREV_TAG..CURRENT_TAG) # Generate changelog for current tag only
PREV_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \ PREV_TAG=$(git tag --sort=-version:refname | grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| grep -v "^${CURRENT_TAG}$" | head -1 || echo "") | grep -v "^${CURRENT_TAG}$" | head -1 || echo "")
if [ -n "$PREV_TAG" ]; then if [ -n "$PREV_TAG" ]; then
# Generate changelog for current tag only using tag range git-cliff --config cliff.toml --tag "$CURRENT_TAG" --strip all > /tmp/release_body.md || true
git-cliff --config cliff.toml --tag "${PREV_TAG}..${CURRENT_TAG}" > /tmp/release_body.md || true
# Generate full CHANGELOG.md from all tags # Generate full CHANGELOG.md from all tags
git-cliff --config cliff.toml --output CHANGELOG.md git-cliff --config cliff.toml --output CHANGELOG.md
else else

File diff suppressed because it is too large Load Diff

View File

@ -17,7 +17,7 @@
| Frontend test (watch) | `npm run test` | | Frontend test (watch) | `npm run test` |
| Frontend coverage | `npm run test:coverage` | | Frontend coverage | `npm run test:coverage` |
| TypeScript type check | `npx tsc --noEmit` | | TypeScript type check | `npx tsc --noEmit` |
| Frontend lint | `npx eslint src/ tests/ --quiet` | | Frontend lint | `npx eslint . --quiet` |
**Lint Policy**: **ALWAYS run `cargo fmt` and `cargo clippy` after any Rust code change**. Fix all issues before proceeding. **Lint Policy**: **ALWAYS run `cargo fmt` and `cargo clippy` after any Rust code change**. Fix all issues before proceeding.

View File

@ -44,6 +44,7 @@ CI, chore, and build changes are excluded.
- Pin plugin-stronghold npm version to match Rust crate (2.3.1) - Pin plugin-stronghold npm version to match Rust crate (2.3.1)
### Features ### Features
- Full copy from apollo_nxt-trcaa with complete sanitization
- **kube**: Add Kubernetes management support - **kube**: Add Kubernetes management support
## [0.3.12] — 2026-06-05 ## [0.3.12] — 2026-06-05

View File

@ -1,89 +0,0 @@
# Kubectl Runtime Implementation Fix Plan
## Issues Identified
### CRITICAL BLOCKERS
1. **std::mem::drop(child.kill()) ignores async Kill future** (kube.rs:532-540)
- `child.kill()` returns a `Future<Output = ()>` that must be awaited
- Current code drops the future without awaiting, leaving process in undefined state
2. **Arc<Mutex<Child>> is not Send/Sync** (kube.rs:500, portforward.rs:14)
- `tokio::process::Child` is NOT `Send` or `Sync`
- `std::sync::Mutex` provides no `Send` guarantee for its contents
- Cannot safely share `Child` across async boundaries
3. **No error propagation from kubectl subprocess** (kube.rs:530-531, 548)
- stderr/stdout from kubectl subprocess are completely ignored
- No way to detect kubectl errors or capture error messages
- Session state never updated with error information
4. **std::sync::Mutex<Child> in PortForwardSession** (portforward.rs:23, 87, 103)
- Same issues as #2, plus `Drop` implementation can't await
### WARNING ISSUES
5. **validate_resource_name regex not cached** (kube.rs:303-304)
- `Regex::new()` called on every validation call
- Should use `lazy_static!` or `once_cell::sync::Lazy<Regex>`
6. **Temp kubeconfig not cleaned on all paths** (kube.rs:524-534)
- `TempFileCleanup` struct exists but only used in `discover_pods`
- `start_port_forward` and `test_cluster_connection` don't clean up
7. **Tests don't verify subprocess exists** (cluster_management.rs:278-290)
- No mock Command framework or subprocess verification
## Implementation Plan
### Phase 1: Core Architecture Fix
**Goal:** Replace unsafe `Arc<Mutex<Child>>` with proper async-safe storage
**Approach:**
1. Store `JoinHandle<()>` instead of `Child` directly
2. Spawn background task to wait on child and update session state
3. Use `tokio::sync::Mutex` for session state access
4. Implement proper async cleanup in `stop()` and `Drop`
### Phase 2: Error Handling
**Goal:** Capture and propagate kubectl subprocess errors
**Approach:**
1. Background task waits on child and captures exit status
2. Update session state with error messages on failure
3. Store stderr/stdout for debugging
4. Propagate errors to UI via session status
### Phase 3: Cleanup Improvements
**Goal:** Ensure temp files are always cleaned up
**Approach:**
1. Use RAII pattern consistently across all functions
2. Add cleanup hooks for panic/early-return paths
3. Store temp path in session struct for later cleanup
### Phase 4: Regex Caching
**Goal:** Cache compiled regex for performance
**Approach:**
1. Define `static ref NAME_PATTERN_REGEX: Lazy<Regex> = ...`
2. Replace `Regex::new()` call with static reference
## Files to Modify
1. `src-tauri/src/kube/portforward.rs` - Core architecture fix
2. `src-tauri/src/commands/kube.rs` - Integration and fixes
3. `src-tauri/tests/integration/kube/cluster_management.rs` - Add subprocess verification
4. `src-tauri/tests/integration/kube/port_forwarding.rs` - Add subprocess verification
## Test Strategy
After fixes:
1. Run `cargo test --lib` - expect 325 tests passing
2. Run `cargo clippy` - expect no warnings
3. Run type check: `npx tsc --noEmit` - expect no errors
4. Run frontend tests: `npm run test:run` - expect 98 tests passing

View File

@ -1,321 +0,0 @@
# Kubernetes Management Implementation Assessment
## v1.1.0 Plan Status Report
**Date**: 2026-06-06
**Project**: tftsr-devops_investigation
**Current Version**: 1.1.0
---
## Executive Summary
The Kubernetes management feature is **partially implemented** with a solid foundation but missing critical runtime functionality. The backend architecture and frontend UI components are in place, but the actual kubectl command execution integration remains incomplete. The feature is **not production-ready** for v1.1.0 release without addressing the critical path items.
---
## Current Implementation Status
### ✅ Implemented Components
#### Backend (Rust)
| Component | Status | Details |
|-----------|--------|---------|
| **ClusterClient struct** | ✅ Complete | Basic cluster metadata storage (id, name, context, server_url, kubeconfig_content) |
| **PortForwardSession struct** | ✅ Complete | Session tracking with status, pod info, ports, and child process management |
| **RefreshRegistry** | ✅ Complete | Domain-based data caching infrastructure (not yet utilized) |
| **6 IPC Commands** | ✅ Complete | `add_cluster`, `remove_cluster`, `list_clusters`, `start_port_forward`, `stop_port_forward`, `list_port_forwards`, `delete_port_forward` |
| **AppState Extension** | ✅ Complete | Added `clusters`, `port_forwards`, `refresh_registry` to state |
| **Kubeconfig Parsing** | ✅ Complete | Basic YAML parsing in `shell/kubeconfig.rs` |
| **kubectl Binary Detection** | ✅ Complete | Locates kubectl in PATH, bundled sidecar, or common paths |
#### Frontend (React)
| Component | Status | Details |
|-----------|--------|---------|
| **KubernetesPage** | ✅ Complete | Main navigation page with tabs for clusters and port forwards |
| **ClusterList** | ✅ Complete | Displays cluster list with add/remove functionality |
| **PortForwardList** | ✅ Complete | Shows active port forwards with stop/delete controls |
| **AddClusterModal** | ✅ Complete | Form for adding clusters via kubeconfig YAML |
| **PortForwardForm** | ✅ Complete | Form for starting port forwards with cluster/pod/port selection |
| **TypeScript Types** | ✅ Complete | `ClusterInfo`, `PortForwardRequest`, `PortForwardResponse` in `tauriCommands.ts` |
#### Tests
| Test Type | Status | Details |
|-----------|--------|---------|
| **Rust Tests** | ⚠️ Partial | 308 total tests; kube module has no unit tests |
| **Frontend Tests** | ⚠️ Partial | 98 total tests; `kubernetesCommands.test.ts` exists (141 lines) |
---
## Critical Missing Features for v1.1.0
### 🚨 Must-Have (Blocker)
#### 1. Port Forward Runtime Execution (CRITICAL)
**Priority**: BLOCKER
**Impact**: Feature is non-functional without this
**Current State**:
- `start_port_forward` IPC command creates session metadata but **does not execute kubectl port-forward**
- Local port is hardcoded to `0` and never assigned
- No actual kubectl subprocess is spawned
**Required Implementation**:
```rust
// In commands/kube.rs: start_port_forward()
// Current: Creates session but doesn't run kubectl
// Required:
let kubectl_path = locate_kubectl()?; // from shell/kubectl.rs
let kubeconfig_path = get_kubeconfig_path(cluster_id, state)?; // from shell/executor.rs
// Build kubectl command: kubectl port-forward pod -n namespace local_port:container_port
let args = vec![
"port-forward".to_string(),
format!("{}/{}", request.namespace, request.pod),
format!("{}:{}", local_port, container_port),
];
// Start subprocess and store child handle in PortForwardSession
let child = Command::new(kubectl_path)
.args(&args)
.env("KUBECONFIG", kubeconfig_path)
.spawn()?;
session.kubectl_child = Some(Arc::new(Mutex::new(child)));
```
**Estimate**: 3-4 days
---
#### 2. Kubeconfig Integration (CRITICAL)
**Priority**: BLOCKER
**Impact**: Cannot connect to clusters without this
**Current State**:
- Clusters are stored in memory with kubeconfig content
- No integration with database-backed kubeconfig management
- No way to reference stored kubeconfigs by ID
**Required Implementation**:
- Store clusters in database with encrypted kubeconfig content
- Add `kubeconfig_id` field to cluster metadata
- Link port forwards to stored kubeconfigs
- Implement kubeconfig rotation and validation
**Estimate**: 2-3 days
---
#### 3. Error Handling & Session Recovery (CRITICAL)
**Priority**: BLOCKER
**Impact**: Poor UX, potential resource leaks
**Current State**:
- No error reporting from kubectl subprocess
- Sessions not recovered on app restart
- No cleanup of orphaned kubectl processes
**Required Implementation**:
- Capture kubectl stderr/stdout and propagate errors
- Persist port forward sessions to database
- Implement session recovery on startup
- Add cleanup logic in `Drop` implementations
**Estimate**: 2 days
---
### ⚠️ Should-Have (High Priority)
#### 4. Pod Discovery UI (HIGH)
**Priority**: HIGH
**Impact**: Users cannot discover available pods
**Required Implementation**:
- Add "Discover Pods" button to PortForwardForm
- Call `kubectl get pods -n <namespace>` to populate pod dropdown
- Filter pods by status (Running, Pending, etc.)
**Estimate**: 1-2 days
---
#### 5. Multiple Port Support (HIGH)
**Priority**: HIGH
**Impact**: Limited functionality for multi-port pods
**Current State**:
- Only supports single port forward
- `local_ports` and `ports` vectors are unused
**Required Implementation**:
- Support multiple port mappings in UI
- Allow users to specify multiple container ports
- Execute multiple kubectl port-forward commands
**Estimate**: 1-2 days
---
#### 6. Cluster Health Monitoring (MEDIUM-HIGH)
**Priority**: MEDIUM-HIGH
**Impact**: No visibility into cluster connectivity
**Required Implementation**:
- Add "Test Connection" button to cluster list
- Call `kubectl cluster-info` to verify connectivity
- Display cluster status (Connected/Disconnected)
**Estimate**: 1 day
---
### 📋 Nice-to-Have (Deferred to v1.2.0+)
#### 7. Advanced Port Forward Features
- **Port Reuse**: Allow same local port for different clusters
- **Background Mode**: Keep port forwards running after app close
- **Port Range**: Support port ranges (e.g., 8080-8090)
- **Reverse Port Forward**: Support `--reverse` flag
#### 8. Cluster Management Enhancements
- **Cluster Groups**: Organize clusters by environment (prod/staging/dev)
- **Cluster Labels**: Add custom labels to clusters
- **Export/Import**: Export cluster configurations
#### 9. Logging & Diagnostics
- **kubectl Output Logging**: Show kubectl stdout/stderr in UI
- **Connection Diagnostics**: Diagnose common kubectl issues
- **Session History**: Track port forward history
#### 10. Integration with Existing Features
- **Triage Integration**: Link port forwards to issues
- **AI Context**: Inject port forward sessions into AI analysis
- **Audit Logging**: Track all port forward operations
---
## Architectural Concerns
### 1. State Management
**Issue**: Clusters and port forwards stored in memory only
**Risk**: Data loss on app crash/restart
**Recommendation**:
- Add database persistence layer
- Implement periodic snapshots
- Add migration for `clusters` and `port_forwards` tables
### 2. Error Propagation
**Issue**: kubectl errors not propagated to UI
**Risk**: Silent failures, debugging difficulty
**Recommendation**:
- Implement structured error types
- Add retry logic with exponential backoff
- Log kubectl output to file for debugging
### 3. Concurrency
**Issue**: No rate limiting for kubectl commands
**Risk**: Resource exhaustion with many port forwards
**Recommendation**:
- Implement concurrent port forward limit
- Add resource usage monitoring
- Queue system for command execution
### 4. Security
**Issue**: Kubeconfig content stored in memory
**Risk**: Potential credential exposure
**Recommendation**:
- Use secure memory allocation
- Clear secrets immediately after use
- Implement kubeconfig encryption at rest
---
## Implementation Roadmap
### Phase 1: Critical Fixes (5-7 days) - **BLOCKS v1.1.0**
1. ✅ Implement port forward runtime execution
2. ✅ Add database persistence for clusters
3. ✅ Implement error handling and session recovery
4. ✅ Add cluster health check
### Phase 2: High Priority Enhancements (3-4 days)
5. ✅ Pod discovery UI
6. ✅ Multiple port support
7. ✅ Connection testing
### Phase 3: Polish & Testing (3-4 days)
8. Unit test coverage for kube module
9. Integration tests for port forwarding
10. UI/UX improvements
11. Documentation
### Phase 4: Future Enhancements (v1.2.0+)
12. Advanced features (groups, labels, export/import)
13. Logging and diagnostics
14. Triage/AI integration
---
## Testing Requirements
### Unit Tests Needed
- [ ] `kube::client::tests` - ClusterClient serialization
- [ ] `kube::portforward::tests` - Session lifecycle
- [ ] `commands::kube::tests` - IPC command handlers
- [ ] `shell::kubeconfig::tests` - YAML parsing
### Integration Tests Needed
- [ ] End-to-end port forwarding flow
- [ ] Multi-cluster management
- [ ] Error recovery scenarios
- [ ] Concurrent port forwards
### Frontend Tests Needed
- [ ] ClusterList integration
- [ ] PortForwardForm validation
- [ ] Modal state management
---
## Risk Assessment
| Risk | Probability | Impact | Mitigation |
|------|-------------|--------|------------|
| **Port forwards don't work** | 100% | Critical | Implement Phase 1 immediately |
| **Data loss on restart** | 80% | High | Add database persistence |
| **kubectl errors silent** | 90% | High | Implement error propagation |
| **Resource leaks** | 60% | Medium | Add Drop cleanup + tests |
| **Poor UX** | 70% | Medium | Add pod discovery, health checks |
---
## Recommendation
**DO NOT RELEASE v1.1.0 with current state.**
The Kubernetes management feature is **functionally incomplete**. Users can add clusters and see UI elements, but port forwarding will not work without kubectl execution.
### Path to v1.1.0:
1. **Implement Phase 1 (Critical)** - 5-7 days
2. **Add integration tests** - 2 days
3. **User acceptance testing** - 2 days
**Total additional effort**: ~10 days
### Alternative: Release with Feature Flag
If timeline is tight:
- Release v1.1.0 with Kubernetes feature **disabled by default**
- Add feature flag in settings: `experimental.kubernetes.enabled`
- Document as "Preview: Requires manual kubectl setup"
- Enable by default after Phase 1 completion
---
## Conclusion
The Kubernetes management feature has a **solid architectural foundation** but requires critical runtime implementation to be functional. The frontend UI and data models are complete, but the backend execution layer (kubectl subprocess management) is missing.
**Priority Action**: Implement port forward runtime execution with proper error handling and session persistence.
**Estimated v1.1.0 Readiness**: 10-12 days from now with focused development.

File diff suppressed because it is too large Load Diff

View File

@ -1,338 +0,0 @@
# Proxmox Integration - Implementation Summary
## Overview
This document summarizes the implementation plan for adding Proxmox integration to the TRCAA application (v1.2.0).
## What Was Planned
### Core Features
1. **Multi-Cluster Management** - Support for multiple Proxmox clusters (both VE and PBS)
2. **Cross-Datacenter Metrics** - Unified dashboard across all clusters
3. **Full VM Management** - Start/stop/reboot/migrate operations
4. **Backup Management** - PBS job and backup management
5. **Live Migration** - VM migration between clusters
6. **Triage Integration** - Link Proxmox resources to issues and collect logs
## Critical Corrections (Based on User Feedback)
### Port Configuration
**Correction:** Proxmox VE and PBS use **different default ports**:
| Service | Default Port | API Endpoint |
|---------|--------------|--------------|
| Proxmox VE | **8006** | `https://hostname:8006/api2/json` |
| Proxmox Backup Server | **8007** | `https://hostname:8007/api2/json` |
**Implementation:**
- Default port set by cluster type (8006 for VE, 8007 for PBS)
- User can override port if needed
- Port displayed in cluster configuration UI
### Ceph Storage Management
**Addition:** Full Ceph cluster management required:
| Component | Management Operations |
|-----------|----------------------|
| **Ceph Pools** | Create, delete, list, quota management |
| **Ceph OSDs** | List, status, weight management, out/in |
| **Ceph MDS** | List, status, failover management |
| **Ceph RBD** | Create, delete, clone, snap, resize |
| **Ceph Monitors** | List, status, quorum health |
| **Ceph Health** | Overall cluster health monitoring |
### Proxmox Datacenter Manager Features (v1.2.0)
**Addition:** Include these PDM features in v1.2.0:
1. **SDN (Software-Defined Networking)**
- List virtual networks
- View network status
- Bridge configuration
2. **Firewall Management**
- List firewall rules
- Enable/disable firewall
- Rule management (add, delete, update)
3. **HA (High Availability) Groups**
- List HA groups
- Manage HA resources
- Failover configuration
4. **Update Management**
- Check for package updates
- List available updates
- Update status across clusters
### Backup Management Scope
**Clarification:** Full backup job management including:
| Feature | Description |
|---------|-------------|
| **Backup Scheduling** | Cron-style scheduling for backup jobs |
| **Trigger Backups** | Manual backup job execution |
| **Backup Restoration** | Restore backups to target cluster |
| **Backup Replication** | Cross-cluster backup replication |
| **Deduplication** | Monitor deduplication status |
| **Backup Jobs** | Create, delete, list, edit backup jobs |
### Cluster Selection UI
**Requirement:** Dropdown with three selection modes:
| Mode | Description | Use Case |
|------|-------------|----------|
| **Single Cluster** | Select one specific cluster | Targeted operations on one cluster |
| **Multiple Clusters** | Select 2+ specific clusters | Cross-cluster operations |
| **ALL Clusters** | All configured clusters | Global operations, dashboard |
### Authentication
- Root username/password authentication to Proxmox nodes (port 8006)
- Automatic API token generation and management
- Encrypted credential storage using AES-256-GCM
- SSL fingerprint verification (configurable)
- Support for self-signed certificates
### Technical Approach
**Backend:**
- New module: `src-tauri/src/proxmox/`
- API client with proper authentication flow
- Cluster registry for multi-cluster support
- Metrics aggregation across clusters
- Database migrations for new schema
**Frontend:**
- New sidebar item: "Proxmox"
- Cluster selector and management UI
- VM manager interface
- Backup manager interface
- Cross-cluster dashboard
- State management with Zustand
## Files Created
### Documentation
1. **`docs/TICKET-proxmox-integration.md`** (27 KB)
- Complete implementation plan
- Architecture details
- Implementation phases (6 weeks)
- Testing strategy
- Security considerations
- Risk assessment
2. **`docs/PROXMOX-QUICK-REFERENCE.md`** (8 KB)
- Quick reference card
- API endpoints
- IPC commands
- Common tasks
- Troubleshooting guide
## Key Decisions
### 1. Authentication Method
**Decision:** Use root credentials + port 8006 (VE) / 8007 (PBS)
**Rationale:**
- Simpler than Proxmox Datacenter Manager setup
- No additional network configuration required
- Works in all environments
- Aligns with user's feedback
- Default ports set by cluster type, user can override
### 2. Credential Storage
**Decision:** Store root credentials encrypted, generate API tokens
**Rationale:**
- Consistent with existing integration patterns
- Uses `encrypt_token()` from `src-tauri/src/integrations/auth.rs`
- API tokens provide better security than storing passwords
- Token auto-refresh before expiry
### 3. Multi-Cluster Support
**Decision:** Full multi-cluster support (primary feature)
**Rationale:**
- Key selling point of Proxmox Datacenter Manager
- Enables cross-datacenter management
- Supports active/standby architectures
- Allows unified monitoring
### 4. UI Location
**Decision:** New sidebar item (not settings tab)
**Rationale:**
- Proxmox is a core feature, not just configuration
- Similar to Kubernetes integration
- Easy access for daily operations
- Dashboard potential
## Implementation Phases
| Phase | Duration | Focus | Deliverables |
|-------|----------|-------|--------------|
| 1 | Week 1 | Foundation | Auth flow, API client, DB schema |
| 2 | Week 2 | VE Management | VM operations, node status, **Ceph management** |
| 3 | Week 3 | PBS + Advanced | Backup jobs, **SDN, Firewall, HA groups** |
| 4 | Week 4 | Cross-Datacenter | Cluster registry, metrics, **cluster selector UI** |
| 5 | Week 5 | Triage Integration | Resource linking, log collection |
| 6 | Week 6 | Testing & Docs | Tests, documentation, release |
## TDD Compliance
### Rust Tests
- **Target Coverage:** 80%+
- **Test Files:**
- `src-tauri/src/proxmox/tests/auth_tests.rs`
- `src-tauri/src/proxmox/tests/client_tests.rs`
- `src-tauri/src/proxmox/tests/cluster_tests.rs`
- `src-tauri/src/proxmox/tests/metrics_tests.rs`
- **Approach:** TDD with mockito for HTTP mocking
### Frontend Tests
- **Unit Tests:** Vitest, 80%+ coverage
- **Component Tests:** React Testing Library
- **E2E Tests:** WebdriverIO for critical paths
## Security Considerations
### Encryption
- **Passwords:** AES-256-GCM encrypted
- **API Tokens:** AES-256-GCM encrypted
- **Key Source:** `TRCAA_ENCRYPTION_KEY` env var or auto-generated `.enckey`
### Audit Logging
- Cluster add/remove
- Authentication events
- VM lifecycle operations
- Migration operations
- Backup operations
### SSL/TLS
- Fingerprint verification (configurable)
- Support for self-signed certificates
- Certificate pinning option
## Database Changes
### New Tables
1. **proxmox_clusters** - Store cluster configuration
2. **proxmox_resources** - Cache resource status
3. **proxmox_credentials** - Store API tokens
### Migration
- File: `src-tauri/src/db/migrations.rs`
- Number: 012_proxmox_clusters
- Type: Additive (no breaking changes)
## Integration Points
### Existing Patterns
- **Authentication:** Use `src-tauri/src/integrations/auth.rs`
- **Encryption:** Use `encrypt_token()` / `decrypt_token()`
- **Audit:** Use `src-tauri/src/audit/log.rs`
- **IPC:** Follow `src-tauri/src/commands/integrations.rs` pattern
### New Patterns
- **Cluster Registry:** Manage multiple client connections
- **Metrics Aggregation:** Cross-cluster data collection
- **Live Migration:** Multi-cluster coordination
## Success Criteria
### Functional
**Cluster Management:**
- [ ] Add/remove multiple clusters (VE and PBS)
- [ ] Default ports configured correctly (8006 for VE, 8007 for PBS)
- [ ] User can override port per cluster
- [ ] Cluster selection dropdown (single/multi/all) works
**Authentication:**
- [ ] Authentication with root credentials
- [ ] API token generation and storage
- [ ] SSL fingerprint verification configurable
**Proxmox VE:**
- [ ] VM management operations
- [ ] Ceph management (pools, OSDs, MDS, RBD, health)
- [ ] SDN management (zones, DHCP, firewall)
- [ ] Firewall management (rules, enable/disable)
- [ ] HA group management
**Proxmox Backup Server:**
- [ ] PBS backup operations
- [ ] Backup scheduling (create/edit/delete jobs)
- [ ] Manual backup trigger
- [ ] Backup restoration
- [ ] Backup replication between clusters
**Cross-Datacenter:**
- [ ] Cross-cluster metrics
- [ ] Live migration between clusters
- [ ] Global dashboard
**Triage Integration:**
- [ ] Triage integration (link resources, collect logs)
### Non-Functional
- [ ] ≥80% code coverage
- [ ] <2s cluster status refresh
- [ ] <5s VM list (100 VMs)
- [ ] All credentials encrypted
- [ ] Documentation complete
## Next Steps
1. **Review Plan** - User reviews documentation
2. **Clarify Requirements** - Address any questions
3. **Begin Implementation** - Phase 1 (Week 1)
4. **TDD Approach** - Write tests first, then implementation
5. **Iterate** - Phases 2-6
6. **Release** - v1.2.0
## Questions for User
Before implementation begins, please confirm:
1. **Authentication Flow** - Root credentials → API token ✓ (Confirmed)
2. **Cluster Support** - Both VE and PBS ✓ (Confirmed)
3. **Multi-Cluster** - Full support with cross-datacenter ✓ (Confirmed)
4. **UI Location** - Sidebar item ✓ (Confirmed)
5. **Credential Storage** - Encrypted in database ✓ (Confirmed)
6. **Version** - v1.2.0 ✓ (Confirmed)
## References
- **Proxmox API:** https://pve.proxmox.com/pve-docs/api-viewer/
- **Proxmox Datacenter Manager:** https://github.com/proxmox/proxmox-datacenter-manager
- **TRCAA Integrations:** `docs/wiki/Integrations.md`
- **Architecture Docs:** `docs/architecture/`
---
**Document Version:** 1.0
**Date:** 2026-06-06
**Status:** Planning Complete - Ready for Implementation
**Next Action:** User approval to begin Phase 1

View File

@ -1,427 +0,0 @@
# Proxmox Integration - Quick Reference
**Version:** v1.2.0
**Status:** Planning ✓ | Implementation: Pending
---
## Core Concepts
### Port Configuration
| Service | Default Port | API Endpoint |
|---------|--------------|--------------|
| Proxmox VE | **8006** | `https://hostname:8006/api2/json` |
| Proxmox Backup Server | **8007** | `https://hostname:8007/api2/json` |
**Implementation:**
- Default port set by cluster type (8006 for VE, 8007 for PBS)
- User can override port if needed
- Port displayed in cluster configuration UI
### Authentication Flow
```
User Input → Root Credentials → Proxmox API → API Token → Encrypted Storage
SSL Fingerprint Verification (Optional)
```
### Data Flow
```
Proxmox Cluster (port 8006 for VE, 8007 for PBS)
↓ HTTPS API
ProxmoxClient (cached in memory)
↓ Encrypted Token
Database (SQLite + AES-256-GCM)
```
---
## Key Files
### Backend
| File | Purpose |
|------|---------|
| `src-tauri/src/proxmox/mod.rs` | Module exports |
| `src-tauri/src/proxmox/client.rs` | Proxmox API client |
| `src-tauri/src/proxmox/auth.rs` | Authentication logic |
| `src-tauri/src/proxmox/cluster.rs` | Cluster registry |
| `src-tauri/src/proxmox/models.rs` | Data models |
| `src-tauri/src/commands/proxmox.rs` | IPC commands |
| `src-tauri/src/db/migrations.rs` | DB schema (migration 012) |
### Frontend
| File | Purpose |
|------|---------|
| `src/pages/Proxmox/index.tsx` | Main page |
| `src/pages/Proxmox/ClusterList.tsx` | Cluster management |
| `src/pages/Proxmox/ClusterDashboard.tsx` | Metrics dashboard |
| `src/pages/Proxmox/VMManager.tsx` | VM operations |
| `src/pages/Proxmox/AddClusterModal.tsx` | Add cluster UI |
| `src/lib/tauriCommands.ts` | IPC wrappers |
| `src/stores/proxmoxStore.ts` | State management |
---
## Database Schema
### New Tables
**proxmox_clusters**
```sql
id TEXT PRIMARY KEY
name TEXT NOT NULL
node_address TEXT NOT NULL -- hostname:8006
node_fingerprint TEXT -- SSL cert hash
username TEXT NOT NULL -- root
encrypted_password TEXT NOT NULL
cluster_type TEXT CHECK('ve' OR 'pbs')
status TEXT DEFAULT 'unknown'
last_connected_at TEXT
created_at TEXT
updated_at TEXT
```
**proxmox_resources**
```sql
id TEXT PRIMARY KEY
cluster_id TEXT NOT NULL
resource_type TEXT -- 'node', 'vm', 'ct', 'storage', 'backup'
resource_id TEXT -- VM ID, storage ID
name TEXT
status TEXT
cpu_usage REAL
memory_usage REAL
storage_usage REAL
details TEXT -- JSON blob
last_updated_at TEXT
```
**proxmox_credentials**
```sql
id TEXT PRIMARY KEY
cluster_id TEXT NOT NULL
api_token TEXT NOT NULL -- Encrypted API token
token_hash TEXT NOT NULL -- SHA-256 for audit
expires_at TEXT
created_at TEXT
```
---
## API Endpoints
### Authentication
```
POST /api2/json/access/ticket
Request: { username: "root", password: "..." }
Response: { ticket: "PVE@pam!root!...", CSRFPreventionToken: "..." }
```
### Proxmox VE
```
GET /api2/json/nodes - List nodes
GET /api2/json/nodes/{node}/qemu - List VMs
GET /api2/json/nodes/{node}/qemu/{vmid}/status/current - Get VM status
POST /api2/json/nodes/{node}/qemu/{vmid}/status/start - Start VM
POST /api2/json/nodes/{node}/qemu/{vmid}/status/stop - Stop VM
POST /api2/json/nodes/{node}/qemu/{vmid}/status/reboot - Reboot VM
POST /api2/json/nodes/{node}/qemu/{vmid}/migrate - Migrate VM
GET /api2/json/nodes/{node}/storage - List storage
GET /api2/json/cluster/resources - Cluster resources
### Ceph Management
```
GET /api2/json/nodes/{node}/ceph/pool - List pools
POST /api2/json/nodes/{node}/ceph/pool - Create pool
DELETE /api2/json/nodes/{node}/ceph/pool/{pool} - Delete pool
GET /api2/json/nodes/{node}/ceph/osd - List OSDs
POST /api2/json/nodes/{node}/ceph/osd/{id}/set - Set OSD weight
POST /api2/json/nodes/{node}/ceph/osd/{id}/out - Set OSD out
POST /api2/json/nodes/{node}/ceph/osd/{id}/in - Set OSD in
GET /api2/json/nodes/{node}/ceph/mds - List MDS
POST /api2/json/nodes/{node}/ceph/mds/{id}/failover - MDS failover
GET /api2/json/nodes/{node}/ceph/rbd - List RBDs
POST /api2/json/nodes/{node}/ceph/rbd - Create RBD
DELETE /api2/json/nodes/{node}/ceph/rbd/{pool}/{name} - Delete RBD
PUT /api2/json/nodes/{node}/ceph/rbd/{pool}/{name} - Resize RBD
GET /api2/json/cluster/ceph/status - Ceph status
GET /api2/json/cluster/ceph/health - Ceph health
```
### SDN Management
```
GET /api2/json/nodes/{node}/sdn/zones - List SDN zones
GET /api2/json/nodes/{node}/sdn/dhcp - List SDN DHCP
GET /api2/json/nodes/{node}/sdn/firewall - List SDN firewall
```
### Firewall Management
```
GET /api2/json/nodes/{node}/firewall/rules - List firewall rules
POST /api2/json/nodes/{node}/firewall/rules - Add firewall rule
DELETE /api2/json/nodes/{node}/firewall/rules/{ruleid} - Delete firewall rule
POST /api2/json/nodes/{node}/firewall/status - Enable firewall
DELETE /api2/json/nodes/{node}/firewall/status - Disable firewall
```
### HA Group Management
```
GET /api2/json/cluster/ha/resources - List HA resources
GET /api2/json/cluster/ha/groups - List HA groups
POST /api2/json/cluster/ha/groups - Create HA group
DELETE /api2/json/cluster/ha/groups/{group} - Delete HA group
POST /api2/json/cluster/ha/resources/{rid} - Manage HA resource
```
### Proxmox Backup Server
```
GET /api2/json/nodes/{node}/backup - List backups
POST /api2/json/nodes/{node}/backup/{jobid}/run - Run backup job
GET /api2/json/nodes/{node}/storage - List datastores
GET /api2/json/nodes/{node}/backup/status - Backup status
### Backup Scheduling & Replication
```
POST /api2/json/nodes/{node}/backup/{jobid} - Create/edit backup job
DELETE /api2/json/nodes/{node}/backup/{jobid} - Delete backup job
POST /api2/json/nodes/{node}/backup/restore - Restore backup
GET /api2/json/nodes/{node}/backup/replication - List replication status
POST /api2/json/nodes/{node}/backup/replication - Trigger replication
```
---
## IPC Commands
### Cluster Management
```typescript
addProxmoxClusterCmd(config)
removeProxmoxClusterCmd(clusterId)
listProxmoxClustersCmd()
getProxmoxClusterCmd(clusterId)
testProxmoxConnectionCmd(config)
```
### VM Operations
```typescript
listProxmoxVMsCmd(clusterId)
startProxmoxVMCmd(clusterId, vmId)
stopProxmoxVMCmd(clusterId, vmId)
rebootProxmoxVMCmd(clusterId, vmId)
shutdownProxmoxVMCmd(clusterId, vmId)
suspendProxmoxVMCmd(clusterId, vmId)
cloneProxmoxVMCmd(clusterId, vmId, newId, name)
migrateProxmoxVMCmd(clusterId, vmId, targetClusterId, online)
```
### PBS Operations
```typescript
listProxmoxBackupsCmd(clusterId)
runProxmoxBackupJobCmd(clusterId, jobId)
listProxmoxDatastoresCmd(clusterId)
restoreProxmoxBackupCmd(clusterId, backupId, datastore)
```
### Metrics
```typescript
getProxmoxMetricsCmd(clusterId)
getCrossClusterMetricsCmd()
```
### Triage Integration
```typescript
linkProxmoxResourceCmd(issueId, clusterId, resourceType, resourceId)
collectProxmoxLogsCmd(issueId, clusterId, resourceType, resourceId, timeRange)
```
---
## Configuration
### Environment Variables
```bash
# Encryption key (auto-generated if not set)
TRCAA_ENCRYPTION_KEY=<32-byte-hex-key>
# Optional: Proxmox-specific config
PROXMOX_DEFAULT_PORT=8006
PROXMOX_DEFAULT_TIMEOUT=30
PROXMOX_ENABLE_SSL_VERIFY=true
```
### Cluster Configuration (JSON)
```json
{
"name": "pve-cluster-1",
"node_address": "pve1.example.com:8006",
"node_fingerprint": "SHA256:ABC123...",
"username": "root",
"encrypted_password": "base64(gcm-encrypted-password)",
"cluster_type": "ve"
}
```
---
## Security Checklist
- [ ] All passwords encrypted with AES-256-GCM
- [ ] API tokens stored encrypted
- [ ] SSL fingerprint verification configurable
- [ ] Audit logging for all operations
- [ ] No credentials in logs
- [ ] CSRF tokens handled properly
- [ ] Rate limiting implemented
- [ ] Error messages don't leak sensitive info
---
## Testing Strategy
### Rust Tests
```bash
# Run all Proxmox tests
cargo test --manifest-path src-tauri/Cargo.toml --lib proxmox
# Run specific test module
cargo test --manifest-path src-tauri/Cargo.toml -- lib proxmox::client
# Test coverage
cargo test --manifest-path src-tauri/Cargo.toml --lib proxmox -- --test-threads=1 --nocapture
```
### Frontend Tests
```bash
# Unit tests
npm run test -- proxmox
# Coverage
npm run test:coverage -- proxmox
```
### E2E Tests
```bash
# Full integration
npm run test:e2e
```
---
## Common Tasks
### Add New Cluster
1. Call `addProxmoxClusterCmd(config)`
2. Backend validates credentials
3. Generates API token
4. Stores encrypted credentials
5. Returns success/error
### List VMs
1. Call `listProxmoxVMsCmd(clusterId)`
2. Client authenticates (if needed)
3. Calls Proxmox API
4. Returns VM list
### Start VM
1. Call `startProxmoxVMCmd(clusterId, vmId)`
2. Client validates authentication
3. Calls Proxmox API
4. Returns task status
### Live Migration
1. Call `migrateProxmoxVMCmd(sourceClusterId, vmId, targetClusterId, online)`
2. Validates both clusters
3. Creates migration task
4. Returns task ID for polling
---
## Troubleshooting
### Common Issues
**"SSL fingerprint mismatch"**
- Verify cluster SSL certificate
- Disable fingerprint verification for self-signed certs
**"Authentication failed"**
- Verify root credentials
- Check Proxmox API is accessible on port 8006
- Ensure user has proper permissions
**"Rate limit exceeded"**
- Implement exponential backoff
- Reduce request frequency
- Use caching
**"Cluster unreachable"**
- Verify network connectivity
- Check firewall rules
- Ensure Proxmox service is running
---
## Performance Targets
| Operation | Target Latency | Max Data |
|-----------|---------------|----------|
| Cluster list | < 1s | 50 clusters |
| VM list | < 2s | 100 VMs |
| VM status | < 500ms | N/A |
| Metrics refresh | < 5s | 10 nodes |
| Migration | < 10s | N/A |
---
## Next Steps
1. ✅ **Planning complete** - This document
2. ⏳ **Phase 1** - Foundation (Week 1)
3. ⏳ **Phase 2** - VE Management (Week 2)
4. ⏳ **Phase 3** - PBS Support (Week 3)
5. ⏳ **Phase 4** - Cross-Datacenter (Week 4)
6. ⏳ **Phase 5** - Triage Integration (Week 5)
7. ⏳ **Phase 6** - Testing & Docs (Week 6)
---
## Resources
- **Proxmox API Docs:** https://pve.proxmox.com/pve-docs/api-viewer/
- **Proxmox Datacenter Manager:** https://github.com/proxmox/proxmox-datacenter-manager
- **TRCAA Architecture:** `docs/architecture/`
- **Integration Patterns:** `docs/wiki/Integrations.md`
---
**Document Version:** 1.0
**Last Updated:** 2026-06-06
**Author:** AI Assistant
**Review Status:** Pending

File diff suppressed because it is too large Load Diff

View File

@ -1,108 +0,0 @@
# Proxmox Integration Documentation
This directory contains documentation for the Proxmox integration into TRCAA.
## Documentation Files
### Overview
- **`IMPLEMENTATION_SUMMARY.md`** - High-level summary of the implementation plan
- **`QUICK_REFERENCE.md`** - Quick reference card for developers
- **`TICKET-proxmox-integration.md`** - Complete implementation plan with technical details
### Implementation Phases
- **Phase 1** - Foundation (Week 1)
- **Phase 2** - Proxmox VE Management (Week 2)
- **Phase 3** - Proxmox Backup Server (Week 3)
- **Phase 4** - Multi-Cluster & Cross-Datacenter (Week 4)
- **Phase 5** - Triage Integration (Week 5)
- **Phase 6** - Testing & Documentation (Week 6)
## Quick Start
### For Developers
1. Review `QUICK_REFERENCE.md` for API endpoints and IPC commands
2. Read `TICKET-proxmox-integration.md` for complete technical details
3. Follow implementation phases in order
4. Write tests first (TDD approach)
5. Run `cargo test` and `npm run test` after each phase
### For Users
See the user-facing documentation in `docs/wiki/Proxmox-Integration.md` (to be created during Phase 6).
## Implementation Checklist
- [ ] Phase 1: Foundation
- [ ] Create `src-tauri/src/proxmox/` module
- [ ] Implement authentication flow
- [ ] Create Proxmox API client
- [ ] Database migrations
- [ ] Basic IPC commands
- [ ] Frontend: Cluster management UI
- [ ] Phase 2: Proxmox VE Management
- [ ] VM management commands
- [ ] Node status and metrics
- [ ] Storage management
- [ ] VM lifecycle operations
- [ ] Frontend: VM manager interface
- [ ] Phase 3: Proxmox Backup Server
- [ ] Backup job management
- [ ] Datastore management
- [ ] Backup listing and restoration
- [ ] Frontend: Backup manager interface
- [ ] Phase 4: Multi-Cluster & Cross-Datacenter
- [ ] Cluster registry
- [ ] Cross-cluster metrics aggregation
- [ ] Live migration between clusters
- [ ] Dashboard with multi-cluster view
- [ ] Phase 5: Triage Integration
- [ ] Link Proxmox resources to issues
- [ ] Log collection from Proxmox
- [ ] PII detection in Proxmox logs
- [ ] Integration with existing triage workflow
- [ ] Phase 6: Testing & Documentation
- [ ] End-to-end testing
- [ ] Performance optimization
- [ ] User documentation
- [ ] Developer documentation
- [ ] Release preparation
## Testing
### Rust Tests
```bash
# Run all Proxmox tests
cargo test --manifest-path src-tauri/Cargo.toml --lib proxmox
# Test coverage
cargo test --manifest-path src-tauri/Cargo.toml --lib proxmox -- --test-threads=1
```
### Frontend Tests
```bash
# Unit tests
npm run test -- proxmox
# Coverage
npm run test:coverage -- proxmox
```
## References
- **Proxmox API Docs:** https://pve.proxmox.com/pve-docs/api-viewer/
- **Proxmox Datacenter Manager:** https://github.com/proxmox/proxmox-datacenter-manager
- **TRCAA Integrations Pattern:** `docs/wiki/Integrations.md`
## Questions?
See `TICKET-proxmox-integration.md` for detailed technical information or contact the development team.

View File

@ -136,137 +136,7 @@ export default [
}, },
}, },
{ {
files: ["src/**/*.{ts,tsx}"], files: ["**/*.ts", "**/*.tsx"],
languageOptions: { ignores: ["dist/", "node_modules/", "src-tauri/", "target/", "coverage/", "tailwind.config.ts"],
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
},
parser: parserTs,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
project: "./tsconfig.json",
},
},
plugins: {
react: pluginReact,
"react-hooks": pluginReactHooks,
"@typescript-eslint": pluginTs,
},
settings: {
react: {
version: "detect",
},
},
rules: {
...pluginReact.configs.recommended.rules,
...pluginReactHooks.configs.recommended.rules,
...pluginTs.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": ["warn", { allow: ["warn", "error"] }],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/no-unescaped-entities": "off",
},
},
{
files: ["tests/unit/**/*.test.{ts,tsx}", "tests/unit/setup.ts"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.browser,
...globals.node,
...globals.vitest,
},
parser: parserTs,
parserOptions: {
ecmaFeatures: {
jsx: true,
},
project: "./tsconfig.json",
},
},
plugins: {
react: pluginReact,
"react-hooks": pluginReactHooks,
"@typescript-eslint": pluginTs,
},
settings: {
react: {
version: "detect",
},
},
rules: {
...pluginReact.configs.recommended.rules,
...pluginReactHooks.configs.recommended.rules,
...pluginTs.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": ["warn", { allow: ["warn", "error"] }],
"react/react-in-jsx-scope": "off",
"react/prop-types": "off",
"react/no-unescaped-entities": "off",
},
},
{
files: ["tests/e2e/**/*.ts", "tests/e2e/**/*.tsx"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node,
},
parser: parserTs,
parserOptions: {
ecmaFeatures: {
jsx: false,
},
},
},
plugins: {
"@typescript-eslint": pluginTs,
},
rules: {
...pluginTs.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": ["warn", { allow: ["warn", "error"] }],
},
},
{
files: ["cli/**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
globals: {
...globals.node,
},
parser: parserTs,
parserOptions: {
ecmaFeatures: {
jsx: false,
},
},
},
plugins: {
"@typescript-eslint": pluginTs,
},
rules: {
...pluginTs.configs.recommended.rules,
"no-unused-vars": "off",
"@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }],
"no-console": ["warn", { allow: ["warn", "error"] }],
"react/no-unescaped-entities": "off",
},
},
{
files: ["**/*.{js,jsx,mjs,cjs,ts,tsx}"],
ignores: ["dist/", "node_modules/", "src-tauri/target/**", "target/**", "coverage/", "tailwind.config.ts"],
}, },
]; ];

View File

@ -66,5 +66,3 @@ mockito = "1.2"
[profile.release] [profile.release]
opt-level = "s" opt-level = "s"
strip = true strip = true

View File

@ -1,8 +1,8 @@
use tauri::State; use tauri::State;
use crate::db::models::{ use crate::db::models::{
AiConversation, AiMessage, Cluster, ImageAttachment, Issue, IssueDetail, IssueFilter, AiConversation, AiMessage, ImageAttachment, Issue, IssueDetail, IssueFilter, IssueSummary,
IssueSummary, IssueUpdate, LogFile, PortForward, ResolutionStep, TimelineEvent, IssueUpdate, LogFile, ResolutionStep, TimelineEvent,
}; };
use crate::state::AppState; use crate::state::AppState;
@ -805,93 +805,3 @@ mod tests {
assert_eq!(results[0], "issue-1"); assert_eq!(results[0], "issue-1");
} }
} }
// ─── Kubernetes Cluster CRUD ────────────────────────────────────────────────
use rusqlite::ffi;
#[tauri::command]
pub async fn load_clusters(state: State<'_, AppState>) -> Result<Vec<Cluster>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = db
.prepare(
"SELECT id, name, context, server_url, kubeconfig_content, created_at, updated_at \
FROM clusters ORDER BY name ASC",
)
.map_err(|e| e.to_string())?;
let clusters: Vec<Cluster> = stmt
.query_map([], |row| {
Ok(Cluster {
id: row.get(0)?,
name: row.get(1)?,
context: row.get(2)?,
server_url: row.get(3)?,
kubeconfig_content: row.get(4)?,
created_at: row.get(5)?,
updated_at: row.get(6)?,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(clusters)
}
// ─── Port Forward CRUD ──────────────────────────────────────────────────────
#[tauri::command]
pub async fn load_port_forwards(state: State<'_, AppState>) -> Result<Vec<PortForward>, String> {
let db = state.db.lock().map_err(|e| e.to_string())?;
let mut stmt = db
.prepare(
"SELECT id, cluster_id, namespace, pod, container, ports, local_ports, status, error_message, created_at, updated_at \
FROM port_forwards ORDER BY created_at ASC",
)
.map_err(|e| e.to_string())?;
let port_forwards: Vec<PortForward> = stmt
.query_map([], |row| {
let ports_str: String = row.get(5)?;
let local_ports_str: String = row.get(6)?;
let ports: Vec<u16> = match serde_json::from_str(&ports_str) {
Ok(v) => v,
Err(e) => {
return Err(rusqlite::Error::SqliteFailure(
ffi::Error::new(ffi::SQLITE_ERROR),
Some(format!("Failed to parse ports: {e}")),
))
}
};
let local_ports: Vec<u16> = match serde_json::from_str(&local_ports_str) {
Ok(v) => v,
Err(e) => {
return Err(rusqlite::Error::SqliteFailure(
ffi::Error::new(ffi::SQLITE_ERROR),
Some(format!("Failed to parse local_ports: {e}")),
))
}
};
Ok(PortForward {
id: row.get(0)?,
cluster_id: row.get(1)?,
namespace: row.get(2)?,
pod: row.get(3)?,
container: row.get(4)?,
ports,
local_ports,
status: row.get(7)?,
error_message: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
})
})
.map_err(|e| e.to_string())?
.filter_map(|r| r.ok())
.collect();
Ok(port_forwards)
}

View File

@ -1,27 +1,10 @@
use crate::kube::portforward::{PortForwardSession, PortForwardSessionConfig}; use crate::kube::portforward::PortForwardSessionConfig;
use crate::kube::ClusterClient; use crate::kube::ClusterClient;
use crate::shell::kubectl::locate_kubectl;
use crate::state::AppState; use crate::state::AppState;
use lazy_static::lazy_static;
use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_yaml::Value; use serde_yaml::Value;
use std::sync::Arc; use std::sync::Arc;
use tauri::State; use tauri::State;
use tokio::process::Command;
use tracing::info;
// Regex pattern for Kubernetes resource names - cached for performance
lazy_static! {
static ref NAME_PATTERN_REGEX: Regex = Regex::new(r"^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$").unwrap();
}
struct TempFileCleanup(std::path::PathBuf);
impl Drop for TempFileCleanup {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClusterInfo { pub struct ClusterInfo {
@ -37,9 +20,6 @@ pub struct PortForwardRequest {
pub namespace: String, pub namespace: String,
pub pod: String, pub pod: String,
pub container_port: u16, pub container_port: u16,
/// Optional: Local port to bind to. If 0, kubectl will allocate dynamically.
#[serde(default)]
pub local_port: u16,
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -48,32 +28,11 @@ pub struct PortForwardResponse {
pub cluster_id: String, pub cluster_id: String,
pub namespace: String, pub namespace: String,
pub pod: String, pub pod: String,
pub container_ports: Vec<u16>, pub container_port: u16,
pub local_ports: Vec<u16>, pub local_port: u16,
pub status: String, pub status: String,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PodInfo {
pub name: String,
pub status: String,
pub ready: String,
pub age: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClusterConnectionStatus {
pub status: ClusterConnectionState,
pub context: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ClusterConnectionState {
Connected,
Disconnected { error: String },
}
#[tauri::command] #[tauri::command]
pub async fn add_cluster( pub async fn add_cluster(
id: String, id: String,
@ -155,37 +114,10 @@ fn extract_server_url(content: &str) -> Result<String, String> {
#[tauri::command] #[tauri::command]
pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> { pub async fn remove_cluster(id: String, state: State<'_, AppState>) -> Result<(), String> {
// Check existence in memory BEFORE touching the DB
let exists = {
let clusters = state.clusters.lock().await;
clusters.contains_key(&id)
};
if !exists {
return Err(format!("Cluster {id} not found"));
}
// Safe to delete from DB now
{
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute("DELETE FROM clusters WHERE id = ?1", [&id])
.map_err(|e| format!("Failed to delete cluster: {e}"))?;
}
let mut clusters = state.clusters.lock().await; let mut clusters = state.clusters.lock().await;
clusters.remove(&id);
// Cascade: close all port forwards for this cluster if clusters.remove(&id).is_none() {
let mut port_forwards = state.port_forwards.lock().await; return Err(format!("Cluster {id} not found"));
let session_ids_to_remove: Vec<String> = port_forwards
.iter()
.filter(|(_, session)| session.cluster_id == id)
.map(|(id, _)| id.clone())
.collect();
for session_id in session_ids_to_remove {
if let Some(mut session) = port_forwards.remove(&session_id) {
session.close().await;
}
} }
Ok(()) Ok(())
@ -208,238 +140,6 @@ pub async fn list_clusters(state: State<'_, AppState>) -> Result<Vec<ClusterInfo
Ok(cluster_list) Ok(cluster_list)
} }
#[tauri::command]
pub async fn test_cluster_connection(
cluster_id: String,
state: State<'_, AppState>,
) -> Result<ClusterConnectionStatus, String> {
let clusters = state.clusters.lock().await;
let cluster = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let kubeconfig_content = cluster.kubeconfig_content.as_ref();
let context = &cluster.context;
// Write kubeconfig to temp file and ensure cleanup even on panic
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("kubeconfig-{}.yaml", cluster_id));
let _cleanup = TempFileCleanup(temp_path.clone());
std::fs::write(&temp_path, kubeconfig_content)
.map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?;
// Run kubectl cluster-info
let kubectl_path = locate_kubectl()?;
let output = Command::new(kubectl_path)
.arg("cluster-info")
.env("KUBECONFIG", temp_path.to_string_lossy().to_string())
.env("KUBERNETES_CONTEXT", context)
.output()
.await
.map_err(|e| format!("Failed to execute kubectl: {e}"))?;
let status = if output.status.success() {
ClusterConnectionState::Connected
} else {
let stderr = String::from_utf8_lossy(&output.stderr);
ClusterConnectionState::Disconnected {
error: stderr.to_string(),
}
};
Ok(ClusterConnectionStatus {
status,
context: context.clone(),
})
}
#[tauri::command]
pub async fn discover_pods(
cluster_id: String,
namespace: String,
state: State<'_, AppState>,
) -> Result<Vec<PodInfo>, String> {
let clusters = state.clusters.lock().await;
let cluster = clusters
.get(&cluster_id)
.ok_or_else(|| format!("Cluster {} not found", cluster_id))?;
let kubeconfig_content = cluster.kubeconfig_content.as_ref();
let context = &cluster.context;
// Write kubeconfig to temp file and ensure cleanup even on panic
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("kubeconfig-{}-pods.yaml", cluster_id));
let _cleanup = TempFileCleanup(temp_path.clone());
std::fs::write(&temp_path, kubeconfig_content)
.map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?;
// Run kubectl get pods with full JSON output
let kubectl_path = locate_kubectl()?;
let output = Command::new(kubectl_path)
.arg("get")
.arg("pods")
.arg("-n")
.arg(&namespace)
.arg("-o")
.arg("json")
.env("KUBECONFIG", temp_path.to_string_lossy().to_string())
.env("KUBERNETES_CONTEXT", context)
.output()
.await
.map_err(|e| format!("Failed to execute kubectl: {e}"))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
return Err(format!("Failed to list pods: {}", stderr));
}
// Parse actual JSON output to get real pod information
let stdout = String::from_utf8_lossy(&output.stdout);
let pods = parse_pods_json(&stdout)?;
Ok(pods)
}
/// Parses the JSON output from `kubectl get pods -o json`
/// and extracts pod information including real status, ready state, and age.
fn parse_pods_json(json_str: &str) -> Result<Vec<PodInfo>, String> {
let value: serde_json::Value = serde_json::from_str(json_str)
.map_err(|e| format!("Failed to parse kubectl JSON output: {}", e))?;
let items = value
.get("items")
.and_then(|v| v.as_array())
.ok_or("Missing 'items' array in kubectl JSON output")?;
let mut pods = Vec::new();
for item in items {
let metadata = item
.get("metadata")
.ok_or("Missing 'metadata' in pod item")?;
let status = item.get("status").ok_or("Missing 'status' in pod item")?;
let name = metadata
.get("name")
.and_then(|v| v.as_str())
.unwrap_or("unknown")
.to_string();
let phase = status
.get("phase")
.and_then(|v| v.as_str())
.unwrap_or("Unknown")
.to_string();
let mut ready = "N/A".to_string();
let mut age = "N/A".to_string();
// Parse ready state from container statuses
if let Some(container_statuses) = status.get("containerStatuses").and_then(|v| v.as_array())
{
let total = container_statuses.len();
let ready_count = container_statuses
.iter()
.filter(|c| c.get("ready").and_then(|v| v.as_bool()).unwrap_or(false))
.count();
ready = format!("{}/{}", ready_count, total);
}
// Parse age from creation timestamp
if let Some(creation_timestamp) = metadata.get("creationTimestamp").and_then(|v| v.as_str())
{
age = parse_creation_timestamp(creation_timestamp);
}
pods.push(PodInfo {
name,
status: phase,
ready,
age,
});
}
Ok(pods)
}
/// Parses a Kubernetes creation timestamp and returns a human-readable age.
fn parse_creation_timestamp(timestamp: &str) -> String {
use chrono::{DateTime, Utc};
// Try parsing as RFC3339 format (e.g., "2024-01-15T10:30:00Z")
if let Ok(dt) = timestamp.parse::<DateTime<Utc>>() {
let elapsed = Utc::now() - dt;
let seconds = elapsed.num_seconds();
if seconds < 60 {
return format!("{}s", seconds);
} else if seconds < 3600 {
return format!("{}m", seconds / 60);
} else if seconds < 86400 {
return format!("{}h", seconds / 3600);
} else {
return format!("{}d", seconds / 86400);
}
}
"N/A".to_string()
}
// Regex patterns for Kubernetes resource names
// Must match: ^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$ (DNS subdomain name)
// Added max length check (253 chars) to prevent ReDoS attacks
const MAX_NAME_LENGTH: usize = 253;
/// Validates a Kubernetes resource name against DNS subdomain naming rules.
///
/// # Arguments
/// * `name` - The name to validate
/// * `field_name` - The field name for error messages
///
/// # Returns
/// * `Ok(())` if the name is valid
/// * `Err(String)` with an error message if the name is invalid
pub fn validate_resource_name(name: &str, field_name: &str) -> Result<(), String> {
// Check max length to prevent ReDoS attacks
if name.len() > MAX_NAME_LENGTH {
return Err(format!(
"{} '{}' exceeds maximum length of {} characters",
field_name, name, MAX_NAME_LENGTH
));
}
// Reject names starting with hyphens or dots
if name.starts_with('-') || name.starts_with('.') {
return Err(format!(
"{} '{}' cannot start with a hyphen or dot",
field_name, name
));
}
// Reject names ending with hyphens or dots
if name.ends_with('-') || name.ends_with('.') {
return Err(format!(
"{} '{}' cannot end with a hyphen or dot",
field_name, name
));
}
// Use cached regex pattern
if !NAME_PATTERN_REGEX.is_match(name) {
return Err(format!(
"{} '{}' does not match pattern {}",
field_name, name, r"^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$"
));
}
Ok(())
}
#[tauri::command] #[tauri::command]
pub async fn start_port_forward( pub async fn start_port_forward(
request: PortForwardRequest, request: PortForwardRequest,
@ -447,74 +147,15 @@ pub async fn start_port_forward(
) -> Result<PortForwardResponse, String> { ) -> Result<PortForwardResponse, String> {
let session_id = uuid::Uuid::now_v7().to_string(); let session_id = uuid::Uuid::now_v7().to_string();
// Validate namespace and pod names FIRST to prevent command injection
// Validation must happen before any operations to prevent partial state creation
validate_resource_name(&request.namespace, "namespace")?;
validate_resource_name(&request.pod, "pod")?;
let clusters = state.clusters.lock().await; let clusters = state.clusters.lock().await;
let cluster = clusters let cluster = clusters
.get(&request.cluster_id) .get(&request.cluster_id)
.ok_or_else(|| format!("Cluster {} not found", request.cluster_id))?; .ok_or_else(|| format!("Cluster {} not found", request.cluster_id))?;
let cluster_name = cluster.name.clone(); let cluster_name = cluster.name.clone();
let kubeconfig_content = cluster.kubeconfig_content.clone(); let _kubeconfig_content = cluster.kubeconfig_content.clone();
// Use kubectl's dynamic port binding by specifying 0 as local port let session = crate::kube::PortForwardSession::new(PortForwardSessionConfig {
// This avoids race condition with port allocation
// Note: Dynamic port allocation (when local_port=0) currently returns 0
// The actual allocated port could be captured from kubectl's stderr/stdout
// but this requires parsing kubectl output which is complex and error-prone
// For now, users must specify a local port or use the default behavior
let local_port = if request.local_port > 0 {
request.local_port
} else {
0 // Let kubectl allocate dynamically (currently not captured)
};
info!(
session_id = %session_id,
cluster_id = %request.cluster_id,
namespace = %request.namespace,
pod = %request.pod,
container_port = request.container_port,
local_port,
"Allocating local port for port-forward"
);
// Write kubeconfig to temp file
let temp_dir = std::env::temp_dir();
let temp_path = temp_dir.join(format!("kubeconfig-{}.yaml", request.cluster_id));
std::fs::write(&temp_path, kubeconfig_content.as_ref())
.map_err(|e| format!("Failed to write kubeconfig temp file: {e}"))?;
// Build kubectl command
let kubectl_path = locate_kubectl()?;
let args = vec![
"port-forward".to_string(),
format!("pod/{}", request.pod),
format!("{}:{}", local_port, request.container_port),
"-n".to_string(),
request.namespace.clone(),
];
info!(
session_id = %session_id,
command = ?args,
"Spawning kubectl port-forward subprocess"
);
// Spawn kubectl subprocess
let child = Command::new(kubectl_path)
.args(&args)
.env("KUBECONFIG", temp_path.to_string_lossy().to_string())
.env("KUBERNETES_CONTEXT", &cluster.context)
.spawn()
.map_err(|e| format!("Failed to spawn kubectl: {e}"))?;
// Create session with allocated port
let session = PortForwardSession::new(PortForwardSessionConfig {
id: session_id.clone(), id: session_id.clone(),
cluster_id: request.cluster_id.clone(), cluster_id: request.cluster_id.clone(),
cluster_name, cluster_name,
@ -522,31 +163,21 @@ pub async fn start_port_forward(
pod: request.pod.clone(), pod: request.pod.clone(),
container: None, container: None,
ports: vec![request.container_port], ports: vec![request.container_port],
local_ports: vec![local_port], local_ports: vec![0],
temp_kubeconfig_path: Some(temp_path),
}); });
// Store child handle in session - spawn background task to wait on child
{ {
let mut port_forwards = state.port_forwards.lock().await; let mut port_forwards = state.port_forwards.lock().await;
port_forwards.insert(session_id.clone(), session); port_forwards.insert(session_id.clone(), session);
let session_mut = port_forwards.get_mut(&session_id).unwrap();
session_mut.spawn_child_waiter(child);
} }
info!(
session_id = %session_id,
local_port,
"Port-forward session started"
);
Ok(PortForwardResponse { Ok(PortForwardResponse {
id: session_id, id: session_id,
cluster_id: request.cluster_id, cluster_id: request.cluster_id,
namespace: request.namespace, namespace: request.namespace,
pod: request.pod, pod: request.pod,
container_ports: vec![request.container_port], container_port: request.container_port,
local_ports: vec![local_port], local_port: 0,
status: "Active".to_string(), status: "Active".to_string(),
}) })
} }
@ -556,8 +187,7 @@ pub async fn stop_port_forward(id: String, state: State<'_, AppState>) -> Result
let mut port_forwards = state.port_forwards.lock().await; let mut port_forwards = state.port_forwards.lock().await;
if let Some(session) = port_forwards.get_mut(&id) { if let Some(session) = port_forwards.get_mut(&id) {
session.stop_async().await; session.stop();
info!(session_id = %id, "Port-forward session stopped");
Ok(()) Ok(())
} else { } else {
Err(format!("Port forward session {id} not found")) Err(format!("Port forward session {id} not found"))
@ -570,155 +200,33 @@ pub async fn list_port_forwards(
) -> Result<Vec<PortForwardResponse>, String> { ) -> Result<Vec<PortForwardResponse>, String> {
let port_forwards = state.port_forwards.lock().await; let port_forwards = state.port_forwards.lock().await;
let mut forwards = Vec::new(); let forwards: Vec<PortForwardResponse> = port_forwards
for s in port_forwards.values() { .values()
let status_str = { .map(|s| PortForwardResponse {
let status = s.shared_status.lock().await;
match &*status {
crate::kube::PortForwardStatus::Active => "Active".to_string(),
crate::kube::PortForwardStatus::Stopped => "Stopped".to_string(),
crate::kube::PortForwardStatus::Error(e) => e.clone(),
}
};
forwards.push(PortForwardResponse {
id: s.id.clone(), id: s.id.clone(),
cluster_id: s.cluster_id.clone(), cluster_id: s.cluster_id.clone(),
namespace: s.namespace.clone(), namespace: s.namespace.clone(),
pod: s.pod.clone(), pod: s.pod.clone(),
container_ports: s.ports.clone(), container_port: s.ports.first().copied().unwrap_or(0),
local_ports: s.local_ports.clone(), local_port: s.local_ports.first().copied().unwrap_or(0),
status: status_str, status: match s.status {
}); crate::kube::PortForwardStatus::Active => "Active".to_string(),
} crate::kube::PortForwardStatus::Stopped => "Stopped".to_string(),
crate::kube::PortForwardStatus::Error(ref e) => e.clone(),
},
})
.collect();
Ok(forwards) Ok(forwards)
} }
#[tauri::command] #[tauri::command]
pub async fn delete_port_forward(id: String, state: State<'_, AppState>) -> Result<(), String> { pub async fn delete_port_forward(id: String, state: State<'_, AppState>) -> Result<(), String> {
// Delete from database
{
let db = state.db.lock().map_err(|e| e.to_string())?;
db.execute("DELETE FROM port_forwards WHERE id = ?1", [&id])
.map_err(|e| format!("Failed to delete port forward: {e}"))?;
}
let mut port_forwards = state.port_forwards.lock().await; let mut port_forwards = state.port_forwards.lock().await;
if let Some(mut session) = port_forwards.remove(&id) { if port_forwards.remove(&id).is_none() {
// Close the session to kill the child and clean up temp files
session.close().await;
} else {
return Err(format!("Port forward session {id} not found")); return Err(format!("Port forward session {id} not found"));
} }
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_cluster_info_serialization() {
let info = ClusterInfo {
id: "cluster-1".to_string(),
name: "Production".to_string(),
context: "prod-context".to_string(),
cluster_url: "https://k8s.example.com".to_string(),
};
let json = serde_json::to_string(&info).unwrap();
let parsed: ClusterInfo = serde_json::from_str(&json).unwrap();
assert_eq!(info.id, parsed.id);
assert_eq!(info.name, parsed.name);
assert_eq!(info.context, parsed.context);
assert_eq!(info.cluster_url, parsed.cluster_url);
}
#[test]
fn test_cluster_connection_state_serialization() {
let connected = ClusterConnectionState::Connected;
let json = serde_json::to_string(&connected).unwrap();
let parsed: ClusterConnectionState = serde_json::from_str(&json).unwrap();
assert!(matches!(parsed, ClusterConnectionState::Connected));
let disconnected = ClusterConnectionState::Disconnected {
error: "connection refused".to_string(),
};
let json = serde_json::to_string(&disconnected).unwrap();
let parsed: ClusterConnectionState = serde_json::from_str(&json).unwrap();
assert!(matches!(
parsed,
ClusterConnectionState::Disconnected { .. }
));
}
#[test]
fn test_port_forward_request_serialization() {
let request = PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "my-pod-abc123".to_string(),
container_port: 8080,
local_port: 0,
};
let json = serde_json::to_string(&request).unwrap();
let parsed: PortForwardRequest = serde_json::from_str(&json).unwrap();
assert_eq!(request.cluster_id, parsed.cluster_id);
assert_eq!(request.namespace, parsed.namespace);
assert_eq!(request.pod, parsed.pod);
assert_eq!(request.container_port, parsed.container_port);
assert_eq!(request.local_port, parsed.local_port);
}
#[test]
fn test_validate_resource_name_valid() {
// Valid names
assert!(validate_resource_name("my-pod", "pod").is_ok());
assert!(validate_resource_name("my-pod-123", "pod").is_ok());
assert!(validate_resource_name("a", "pod").is_ok());
assert!(validate_resource_name("my.pod.name", "pod").is_ok());
assert!(validate_resource_name("123", "pod").is_ok());
}
#[test]
fn test_validate_resource_name_invalid() {
// Invalid names
assert!(validate_resource_name("-mypod", "pod").is_err());
assert!(validate_resource_name("mypod-", "pod").is_err());
assert!(validate_resource_name(".mypod", "pod").is_err());
assert!(validate_resource_name("mypod.", "pod").is_err());
assert!(validate_resource_name("MYPOD", "pod").is_err());
assert!(validate_resource_name("my_pod", "pod").is_err());
assert!(validate_resource_name("", "pod").is_err());
}
#[test]
fn test_validate_resource_name_length() {
// Too long names
let long_name = "a".repeat(254);
assert!(validate_resource_name(&long_name, "pod").is_err());
}
}
#[tauri::command]
pub async fn shutdown_port_forwards(state: State<'_, AppState>) -> Result<(), String> {
let mut port_forwards = state.port_forwards.lock().await;
// Close all active port forward sessions
let session_ids: Vec<String> = port_forwards.keys().cloned().collect();
for session_id in session_ids {
if let Some(mut session) = port_forwards.remove(&session_id) {
session.close().await;
}
}
Ok(())
}

View File

@ -360,40 +360,6 @@ pub fn run_migrations(conn: &Connection) -> anyhow::Result<()> {
"ALTER TABLE ai_providers ADD COLUMN supports_tool_calling INTEGER DEFAULT 1; "ALTER TABLE ai_providers ADD COLUMN supports_tool_calling INTEGER DEFAULT 1;
-- Default to true for existing providers to maintain backward compatibility", -- Default to true for existing providers to maintain backward compatibility",
), ),
(
"029_create_clusters",
"CREATE TABLE IF NOT EXISTS clusters (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
context TEXT NOT NULL,
server_url TEXT,
kubeconfig_content TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX IF NOT EXISTS idx_clusters_name ON clusters(name);
CREATE INDEX IF NOT EXISTS idx_clusters_context ON clusters(context);",
),
(
"030_create_port_forwards",
"CREATE TABLE IF NOT EXISTS port_forwards (
id TEXT PRIMARY KEY,
cluster_id TEXT NOT NULL,
namespace TEXT NOT NULL,
pod TEXT NOT NULL,
container TEXT,
ports TEXT NOT NULL,
local_ports TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active' CHECK(status IN ('active', 'stopped', 'error')),
error_message TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
FOREIGN KEY (cluster_id) REFERENCES clusters(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_port_forwards_cluster ON port_forwards(cluster_id);
CREATE INDEX IF NOT EXISTS idx_port_forwards_status ON port_forwards(status);
CREATE INDEX IF NOT EXISTS idx_port_forwards_namespace ON port_forwards(namespace);",
),
]; ];
for (name, sql) in migrations { for (name, sql) in migrations {
@ -1380,218 +1346,4 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(applied, 1, "023 should only be recorded once"); assert_eq!(applied, 1, "023 should only be recorded once");
} }
// ─── Migration 029-030: Kubernetes clusters and port_forwards ───────────────
#[test]
fn test_029_clusters_table_exists() {
let conn = setup_test_db();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='clusters'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn test_029_clusters_columns() {
let conn = setup_test_db();
let mut stmt = conn.prepare("PRAGMA table_info(clusters)").unwrap();
let columns: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(columns.contains(&"id".to_string()));
assert!(columns.contains(&"name".to_string()));
assert!(columns.contains(&"context".to_string()));
assert!(columns.contains(&"server_url".to_string()));
assert!(columns.contains(&"kubeconfig_content".to_string()));
assert!(columns.contains(&"created_at".to_string()));
assert!(columns.contains(&"updated_at".to_string()));
}
#[test]
fn test_029_clusters_foreign_key() {
let conn = setup_test_db();
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
// Create cluster with embedded kubeconfig
let kubeconfig = "apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com
name: cluster-1
contexts:
- context:
cluster: cluster-1
user: user-1
name: context-1
users:
- name: user-1
user:
token: test-token
";
conn.execute(
"INSERT INTO clusters (id, name, context, server_url, kubeconfig_content)
VALUES ('cluster-1', 'Production', 'context-1', 'https://k8s.example.com', ?1)",
[kubeconfig],
)
.unwrap();
// Verify insertion
let (name, context, server_url, kubeconfig_content): (String, String, String, String) = conn
.query_row(
"SELECT name, context, server_url, kubeconfig_content FROM clusters WHERE id = 'cluster-1'",
[],
|r| Ok((r.get(0)?, r.get(1)?, r.get(2)?, r.get(3)?)),
)
.unwrap();
assert_eq!(name, "Production");
assert_eq!(context, "context-1");
assert_eq!(server_url, "https://k8s.example.com");
assert!(kubeconfig_content.contains("k8s.example.com"));
}
#[test]
fn test_030_port_forwards_table_exists() {
let conn = setup_test_db();
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='port_forwards'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1);
}
#[test]
fn test_030_port_forwards_columns() {
let conn = setup_test_db();
let mut stmt = conn.prepare("PRAGMA table_info(port_forwards)").unwrap();
let columns: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))
.unwrap()
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert!(columns.contains(&"id".to_string()));
assert!(columns.contains(&"cluster_id".to_string()));
assert!(columns.contains(&"namespace".to_string()));
assert!(columns.contains(&"pod".to_string()));
assert!(columns.contains(&"container".to_string()));
assert!(columns.contains(&"ports".to_string()));
assert!(columns.contains(&"local_ports".to_string()));
assert!(columns.contains(&"status".to_string()));
assert!(columns.contains(&"error_message".to_string()));
assert!(columns.contains(&"created_at".to_string()));
assert!(columns.contains(&"updated_at".to_string()));
}
#[test]
fn test_030_port_forwards_status_constraint() {
let conn = setup_test_db();
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
// Create kubeconfig first
conn.execute(
"INSERT INTO kubeconfig_files (id, name, encrypted_content, context)
VALUES ('k8s-test', 'Test Cluster', 'encrypted', 'test-context')",
[],
)
.unwrap();
// Create cluster
conn.execute(
"INSERT INTO clusters (id, name, context, kubeconfig_content)
VALUES ('cluster-1', 'Test', 'test-context', 'k8s-test')",
[],
)
.unwrap();
// Valid status should succeed
conn.execute(
"INSERT INTO port_forwards (id, cluster_id, namespace, pod, ports, local_ports, status)
VALUES ('pf-1', 'cluster-1', 'default', 'pod-1', '[8080]', '[0]', 'active')",
[],
)
.unwrap();
// Invalid status must fail
let err = conn.execute(
"INSERT INTO port_forwards (id, cluster_id, namespace, pod, ports, local_ports, status)
VALUES ('pf-2', 'cluster-1', 'default', 'pod-2', '[8080]', '[0]', 'unknown')",
[],
);
assert!(err.is_err(), "invalid status should be rejected");
}
#[test]
fn test_030_port_forwards_cascade_delete() {
let conn = setup_test_db();
conn.execute("PRAGMA foreign_keys = ON", []).unwrap();
// Create kubeconfig first
conn.execute(
"INSERT INTO kubeconfig_files (id, name, encrypted_content, context)
VALUES ('k8s-3', 'Test Cluster', 'encrypted', 'ctx')",
[],
)
.unwrap();
// Create cluster
conn.execute(
"INSERT INTO clusters (id, name, context, kubeconfig_content)
VALUES ('cluster-3', 'Test', 'ctx', 'k8s-3')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO port_forwards (id, cluster_id, namespace, pod, ports, local_ports)
VALUES ('pf-3', 'cluster-3', 'default', 'pod-3', '[8080]', '[0]')",
[],
)
.unwrap();
// Verify port forward exists
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM port_forwards", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 1);
// Delete cluster — cascade should remove port forward
conn.execute("DELETE FROM clusters WHERE id = 'cluster-3'", [])
.unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM port_forwards", [], |r| r.get(0))
.unwrap();
assert_eq!(count, 0, "cascade delete should remove port_forwards");
}
#[test]
fn test_029_030_idempotent() {
let conn = Connection::open_in_memory().unwrap();
run_migrations(&conn).unwrap();
run_migrations(&conn).unwrap();
for migration in &["029_create_clusters", "030_create_port_forwards"] {
let count: i64 = conn
.query_row(
"SELECT COUNT(*) FROM _migrations WHERE name = ?1",
[migration],
|r| r.get(0),
)
.unwrap();
assert_eq!(count, 1, "{migration} should be recorded exactly once");
}
}
} }

View File

@ -64,6 +64,17 @@ pub struct IssueSummary {
pub step_count: i64, pub step_count: i64,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IssueListItem {
pub id: String,
pub title: String,
pub domain: String,
pub status: String,
pub severity: String,
pub created_at: i64,
pub updated_at: i64,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)] #[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct IssueFilter { pub struct IssueFilter {
pub status: Option<String>, pub status: Option<String>,
@ -457,169 +468,6 @@ pub struct ImageAttachmentSummary {
pub is_paste: bool, pub is_paste: bool,
} }
// ─── Kubernetes Cluster ─────────────────────────────────────────────────────
/// Represents a Kubernetes cluster configuration stored in the database.
/// The kubeconfig content is stored directly in the clusters table.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cluster {
pub id: String,
pub name: String,
pub context: String,
pub server_url: Option<String>,
pub kubeconfig_content: String,
pub created_at: String,
pub updated_at: String,
}
impl Cluster {
pub fn new(
name: String,
context: String,
server_url: Option<String>,
kubeconfig_content: String,
) -> Self {
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
Cluster {
id: Uuid::now_v7().to_string(),
name,
context,
server_url,
kubeconfig_content,
created_at: now.clone(),
updated_at: now,
}
}
}
/// Lightweight summary for cluster list views.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ClusterSummary {
pub id: String,
pub name: String,
pub context: String,
pub server_url: String,
pub created_at: String,
pub updated_at: String,
pub port_forward_count: i64,
}
// ─── Port Forward ───────────────────────────────────────────────────────────
/// Represents a port forwarding session for a Kubernetes cluster.
/// The ports and local_ports are stored as JSON arrays of u16.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortForward {
pub id: String,
pub cluster_id: String,
pub namespace: String,
pub pod: String,
pub container: Option<String>,
pub ports: Vec<u16>,
pub local_ports: Vec<u16>,
pub status: String,
pub error_message: Option<String>,
pub created_at: String,
pub updated_at: String,
}
impl PortForward {
pub fn new(
cluster_id: String,
namespace: String,
pod: String,
container: Option<String>,
ports: Vec<u16>,
local_ports: Vec<u16>,
) -> Self {
let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
PortForward {
id: Uuid::now_v7().to_string(),
cluster_id,
namespace,
pod,
container,
ports,
local_ports,
status: "Active".to_string(),
error_message: None,
created_at: now.clone(),
updated_at: now,
}
}
}
/// Lightweight summary for port forward list views.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PortForwardSummary {
pub id: String,
pub cluster_id: String,
pub cluster_name: String,
pub namespace: String,
pub pod: String,
pub container: Option<String>,
pub ports: Vec<u16>,
pub local_ports: Vec<u16>,
pub status: String,
pub created_at: String,
pub updated_at: String,
}
/// Filter for listing clusters.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClusterFilter {
pub name: Option<String>,
pub context: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// Filter for listing port forwards.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PortForwardFilter {
pub cluster_id: Option<String>,
pub status: Option<String>,
pub namespace: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
}
/// New cluster data for creation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewCluster {
pub name: String,
pub context: String,
pub server_url: String,
pub kubeconfig_content: String,
}
/// Update for existing cluster.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ClusterUpdate {
pub name: Option<String>,
pub context: Option<String>,
pub server_url: Option<String>,
pub kubeconfig_content: Option<String>,
}
/// New port forward data for creation.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NewPortForward {
pub cluster_id: String,
pub namespace: String,
pub pod: String,
pub container: Option<String>,
pub ports: Vec<u16>,
pub local_ports: Vec<u16>,
}
/// Update for existing port forward.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PortForwardUpdate {
pub status: Option<String>,
pub error_message: Option<String>,
}
impl ImageAttachment { impl ImageAttachment {
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
pub fn new( pub fn new(

View File

@ -5,26 +5,3 @@ pub mod refresh;
pub use client::ClusterClient; pub use client::ClusterClient;
pub use portforward::{PortForwardSession, PortForwardStatus}; pub use portforward::{PortForwardSession, PortForwardStatus};
pub use refresh::RefreshRegistry; pub use refresh::RefreshRegistry;
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Arc;
#[test]
fn test_cluster_client_new() {
let content = Arc::new("kubeconfig-content".to_string());
let client = ClusterClient::new(
"cluster-1".to_string(),
"Production".to_string(),
"prod-context".to_string(),
"https://k8s.example.com".to_string(),
content,
);
assert_eq!(client.id, "cluster-1");
assert_eq!(client.name, "Production");
assert_eq!(client.context, "prod-context");
assert_eq!(client.server_url, "https://k8s.example.com");
}
}

View File

@ -1,15 +1,6 @@
use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc; use std::sync::Arc;
use tokio::process::Child;
use tokio::sync::Mutex as TokioMutex;
/// Background task handle for waiting on kubectl child process
pub struct ChildWaitHandle {
pub join_handle: tokio::task::JoinHandle<()>,
pub child: Arc<TokioMutex<Option<Child>>>,
}
pub struct PortForwardSession { pub struct PortForwardSession {
pub id: String, pub id: String,
pub cluster_id: String, pub cluster_id: String,
@ -20,17 +11,10 @@ pub struct PortForwardSession {
pub ports: Vec<u16>, pub ports: Vec<u16>,
pub local_ports: Vec<u16>, pub local_ports: Vec<u16>,
pub status: PortForwardStatus, pub status: PortForwardStatus,
/// Join handle for the background task waiting on the kubectl child pub kubectl_child: Option<Arc<std::sync::Mutex<std::process::Child>>>,
pub child_wait_handle: Option<Arc<TokioMutex<ChildWaitHandle>>>,
pub is_stopped: Arc<AtomicBool>, pub is_stopped: Arc<AtomicBool>,
pub error_message: Option<String>,
pub shared_status: Arc<TokioMutex<PortForwardStatus>>,
pub shared_error: Arc<TokioMutex<Option<String>>>,
/// Path to temp kubeconfig file for cleanup
pub temp_kubeconfig_path: Option<std::path::PathBuf>,
} }
#[derive(Clone)]
pub enum PortForwardStatus { pub enum PortForwardStatus {
Active, Active,
Stopped, Stopped,
@ -47,8 +31,6 @@ pub struct PortForwardSessionConfig {
pub container: Option<String>, pub container: Option<String>,
pub ports: Vec<u16>, pub ports: Vec<u16>,
pub local_ports: Vec<u16>, pub local_ports: Vec<u16>,
/// Path to temp kubeconfig file for cleanup
pub temp_kubeconfig_path: Option<std::path::PathBuf>,
} }
impl PortForwardSession { impl PortForwardSession {
@ -63,126 +45,18 @@ impl PortForwardSession {
ports: config.ports, ports: config.ports,
local_ports: config.local_ports, local_ports: config.local_ports,
status: PortForwardStatus::Active, status: PortForwardStatus::Active,
child_wait_handle: None, kubectl_child: None,
is_stopped: Arc::new(AtomicBool::new(false)), is_stopped: Arc::new(AtomicBool::new(false)),
error_message: None,
shared_status: Arc::new(TokioMutex::new(PortForwardStatus::Active)),
shared_error: Arc::new(TokioMutex::new(None)),
temp_kubeconfig_path: config.temp_kubeconfig_path,
} }
} }
/// Spawn a background task to wait on the kubectl child process
/// and update session state on completion/error
pub fn spawn_child_waiter(&mut self, child: Child) {
let is_stopped = self.is_stopped.clone();
let status_clone = self.shared_status.clone();
let error_clone = self.shared_error.clone();
// Store the child in an Arc<Mutex<Option<Child>>> so it can be accessed from the async task
// and also from the stop() method
let child_arc = Arc::new(TokioMutex::new(Some(child)));
let child_for_task = child_arc.clone();
let temp_path_clone = self.temp_kubeconfig_path.clone();
let join_handle = tokio::spawn(async move {
// Take the child from the Arc. If None, stop_async/close already took it and will
// handle cleanup — nothing left to do here.
let child_opt = child_for_task.lock().await.take();
let mut child = match child_opt {
Some(c) => c,
None => return,
};
// Wait for the child process to complete
let result = child.wait().await;
// Clean up temp kubeconfig file after child completes
if let Some(path) = &temp_path_clone {
let _ = std::fs::remove_file(path);
}
// Only update if not already explicitly stopped
if !is_stopped.load(Ordering::SeqCst) {
match result {
Ok(status) if status.success() => {
*status_clone.lock().await = PortForwardStatus::Stopped;
}
Ok(status) => {
let error_msg = format!("kubectl process exited with status: {}", status);
*status_clone.lock().await = PortForwardStatus::Error(error_msg.clone());
*error_clone.lock().await = Some(error_msg);
}
Err(e) => {
let error_msg = format!("Failed to wait for kubectl process: {}", e);
*status_clone.lock().await = PortForwardStatus::Error(error_msg.clone());
*error_clone.lock().await = Some(error_msg);
}
}
}
});
self.child_wait_handle = Some(Arc::new(TokioMutex::new(ChildWaitHandle {
join_handle,
child: child_arc,
})));
}
pub fn stop(&mut self) { pub fn stop(&mut self) {
self.is_stopped.store(true, Ordering::SeqCst); self.is_stopped.store(true, Ordering::SeqCst);
self.status = PortForwardStatus::Stopped; self.status = PortForwardStatus::Stopped;
if let Ok(mut s) = self.shared_status.try_lock() {
*s = PortForwardStatus::Stopped;
}
self.child_wait_handle = None;
}
pub async fn stop_async(&mut self) { if let Some(child_mutex) = &self.kubectl_child {
self.is_stopped.store(true, Ordering::SeqCst); let mut child = child_mutex.lock().unwrap();
self.status = PortForwardStatus::Stopped; let _ = child.kill();
*self.shared_status.lock().await = PortForwardStatus::Stopped;
// Kill the child process if it exists
if let Some(ref child_wait_handle) = self.child_wait_handle {
let guard = child_wait_handle.lock().await;
let child_opt = guard.child.lock().await.take();
if let Some(mut child) = child_opt {
let _ = child.kill().await;
}
}
// Clean up the temp kubeconfig file. Taking the child above causes the background
// task to exit early without reaching its own cleanup branch.
if let Some(ref path) = self.temp_kubeconfig_path {
let _ = std::fs::remove_file(path);
}
}
pub async fn close(&mut self) {
// Kill the child process if it exists
if let Some(ref child_wait_handle) = self.child_wait_handle {
let guard = child_wait_handle.lock().await;
let child_opt = guard.child.lock().await.take();
if let Some(mut child) = child_opt {
let _ = child.kill().await;
}
}
// Clean up the temp kubeconfig file. Taking the child above causes the background
// task to exit early without reaching its own cleanup branch.
if let Some(ref path) = self.temp_kubeconfig_path {
let _ = std::fs::remove_file(path);
}
}
pub fn set_error(&mut self, error: String) {
self.status = PortForwardStatus::Error(error.clone());
self.error_message = Some(error.clone());
if let Ok(mut s) = self.shared_status.try_lock() {
*s = PortForwardStatus::Error(error.clone());
}
if let Ok(mut e) = self.shared_error.try_lock() {
*e = Some(error);
} }
} }
@ -197,151 +71,9 @@ impl Drop for PortForwardSession {
return; return;
} }
// Drop the handle — detaches the background task. Called from sync context so if let Some(child_mutex) = &self.kubectl_child {
// we cannot await kill(); the Child inside the task will be dropped by the OS. let mut child = child_mutex.lock().unwrap();
self.child_wait_handle = None; let _ = child.kill();
// Best-effort temp file cleanup on unexpected drop (e.g., panic paths).
if let Some(ref path) = self.temp_kubeconfig_path {
let _ = std::fs::remove_file(path);
} }
} }
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_port_forward_session_new() {
let config = PortForwardSessionConfig {
id: "pf-1".to_string(),
cluster_id: "cluster-1".to_string(),
cluster_name: "Production".to_string(),
namespace: "default".to_string(),
pod: "my-pod".to_string(),
container: None,
ports: vec![8080],
local_ports: vec![0],
temp_kubeconfig_path: None,
};
let session = PortForwardSession::new(config);
assert_eq!(session.id, "pf-1");
assert_eq!(session.cluster_id, "cluster-1");
assert_eq!(session.cluster_name, "Production");
assert_eq!(session.namespace, "default");
assert_eq!(session.pod, "my-pod");
assert_eq!(session.ports, vec![8080]);
assert_eq!(session.local_ports, vec![0]);
assert!(matches!(session.status, PortForwardStatus::Active));
}
#[test]
fn test_port_forward_session_stop() {
let config = PortForwardSessionConfig {
id: "pf-2".to_string(),
cluster_id: "cluster-1".to_string(),
cluster_name: "Test".to_string(),
namespace: "default".to_string(),
pod: "pod-1".to_string(),
container: None,
ports: vec![9000],
local_ports: vec![0],
temp_kubeconfig_path: None,
};
let mut session = PortForwardSession::new(config);
assert!(matches!(session.status, PortForwardStatus::Active));
session.stop();
assert!(matches!(session.status, PortForwardStatus::Stopped));
}
#[test]
fn test_port_forward_session_set_error() {
let config = PortForwardSessionConfig {
id: "pf-3".to_string(),
cluster_id: "cluster-1".to_string(),
cluster_name: "Test".to_string(),
namespace: "default".to_string(),
pod: "pod-1".to_string(),
container: None,
ports: vec![9000],
local_ports: vec![0],
temp_kubeconfig_path: None,
};
let mut session = PortForwardSession::new(config);
assert!(matches!(session.status, PortForwardStatus::Active));
session.set_error("connection refused".to_string());
assert!(matches!(session.status, PortForwardStatus::Error(_)));
assert_eq!(
session.error_message,
Some("connection refused".to_string())
);
}
#[test]
fn test_port_forward_session_is_active() {
// Test Active status
let config = PortForwardSessionConfig {
id: "pf-4".to_string(),
cluster_id: "cluster-1".to_string(),
cluster_name: "Test".to_string(),
namespace: "default".to_string(),
pod: "pod-1".to_string(),
container: None,
ports: vec![9000],
local_ports: vec![0],
temp_kubeconfig_path: None,
};
let session = PortForwardSession::new(config);
assert!(session.is_active());
// Test Stopped status
let stopped_session = PortForwardSession {
id: "pf-5".to_string(),
cluster_id: "cluster-1".to_string(),
cluster_name: "Test".to_string(),
namespace: "default".to_string(),
pod: "pod-1".to_string(),
container: None,
ports: vec![9000],
local_ports: vec![0],
status: PortForwardStatus::Stopped,
child_wait_handle: None,
is_stopped: Arc::new(AtomicBool::new(false)),
error_message: None,
shared_status: Arc::new(TokioMutex::new(PortForwardStatus::Stopped)),
shared_error: Arc::new(TokioMutex::new(None)),
temp_kubeconfig_path: None,
};
assert!(!stopped_session.is_active());
// Test Error status
let error_session = PortForwardSession {
id: "pf-6".to_string(),
cluster_id: "cluster-1".to_string(),
cluster_name: "Test".to_string(),
namespace: "default".to_string(),
pod: "pod-1".to_string(),
container: None,
ports: vec![9000],
local_ports: vec![0],
status: PortForwardStatus::Error("error".to_string()),
child_wait_handle: None,
is_stopped: Arc::new(AtomicBool::new(false)),
error_message: Some("error".to_string()),
shared_status: Arc::new(TokioMutex::new(PortForwardStatus::Error(
"error".to_string(),
))),
shared_error: Arc::new(TokioMutex::new(Some("error".to_string()))),
temp_kubeconfig_path: None,
};
assert!(!error_session.is_active());
}
}

View File

@ -95,8 +95,6 @@ pub fn run() {
commands::db::update_five_why, commands::db::update_five_why,
commands::db::add_timeline_event, commands::db::add_timeline_event,
commands::db::get_timeline_events, commands::db::get_timeline_events,
commands::db::load_clusters,
commands::db::load_port_forwards,
// Analysis / PII // Analysis / PII
commands::analysis::upload_log_file, commands::analysis::upload_log_file,
commands::analysis::upload_log_file_by_content, commands::analysis::upload_log_file_by_content,
@ -184,9 +182,6 @@ pub fn run() {
commands::kube::stop_port_forward, commands::kube::stop_port_forward,
commands::kube::list_port_forwards, commands::kube::list_port_forwards,
commands::kube::delete_port_forward, commands::kube::delete_port_forward,
commands::kube::shutdown_port_forwards,
commands::kube::test_cluster_connection,
commands::kube::discover_pods,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("Error running Troubleshooting and RCA Assistant application"); .expect("Error running Troubleshooting and RCA Assistant application");

View File

@ -113,119 +113,9 @@ impl CommandClassifier {
} }
fn classify_single_command(&self, command: &str, subcommand: Option<&str>) -> CommandTier { fn classify_single_command(&self, command: &str, subcommand: Option<&str>) -> CommandTier {
// Tier 3: Always deny - destructive operations (Linux + Windows) // Tier 3: Always deny - destructive operations
let tier3_commands = [ let tier3_commands = [
// Linux destructive commands "rm", "mkfs", "dd", "fdisk", "parted", "shutdown", "reboot", "halt", "poweroff",
"rm",
"mkfs",
"mkfs.ext4",
"mkfs.xfs",
"mkfs.btrfs",
"dd",
"fdisk",
"parted",
"cfdisk",
"sfdisk",
"gdisk",
"shutdown",
"reboot",
"halt",
"poweroff",
"init 0",
"init 6",
"service stop",
"systemctl stop",
"kill -9",
"pkill -9",
"killall -9",
"wipefs",
"blkdiscard",
"dmsetup wipe",
"cryptsetup luksFormat",
"cryptsetup erase",
"dd if=/dev/zero",
"dd if=/dev/urandom",
"mkswap",
"zpool destroy",
"zpool export",
"vgremove",
"lvremove",
"pvremove",
"dmsetup remove",
"mdadm --stop",
"mdadm --remove",
"mdadm --zero-superblock",
"dd if=/dev/zero of=",
"dd if=/dev/urandom of=",
// Windows destructive commands (cmd)
"format",
"diskpart",
"del",
"erase",
"rd",
"rmdir",
"remove-item",
"clear-item",
"wimlib-imaging",
"dism",
"bcdedit",
"bootrec",
"net user",
"net localgroup",
"sdelete",
"cipher",
// Windows PowerShell destructive commands
"remove-item -recurse",
"remove-item -force",
"remove-item -path * -recurse",
"clear-recyclebin",
"stop-process -force",
"stop-computer",
"restart-computer -force",
"uninstall-module",
"uninstall-package",
"unregister-scheduledtask",
"remove-wmiobject",
"remove-itemproperty",
"remove-item -path * -force",
"remove-item -path * -recurse -force",
"remove-item * -force",
// Destructive Windows commands with wildcards
"del *",
"del *.*",
"erase *",
"erase *.*",
"rd /s",
"rmdir /s",
// PowerShell destructive commands
"remove-item -recurse -force",
"clear-host",
"stop-process",
"stop-service",
"stop-computer",
"restart-computer",
"suspend-process",
"suspend-service",
"resume-process",
"resume-service",
"wait-process",
"wait-service",
"wait-computer",
"start-process",
"start-service",
"start-computer",
"invoke-item",
"unregister-scheduledtask",
"remove-scheduledtask",
"remove-job",
"remove-runspace",
"remove-appdomain",
"remove-pssession",
"remove-module",
"uninstall-package",
"uninstall-module",
"remove-wmiobject",
"remove-itemproperty",
]; ];
if tier3_commands.contains(&command) { if tier3_commands.contains(&command) {
@ -234,33 +124,6 @@ impl CommandClassifier {
// Check if this will be caught by args parsing // Check if this will be caught by args parsing
return CommandTier::Tier3; // Conservative: all rm is Tier 3 return CommandTier::Tier3; // Conservative: all rm is Tier 3
} }
// Special case: bootrec with destructive subcommands
if command == "bootrec" {
if let Some(sub) = subcommand {
if sub == "/fixmbr" || sub == "/fixboot" || sub == "/rebuildbcd" {
return CommandTier::Tier3;
}
}
}
// Special case: net user with /delete
// (not tested, so commented out for now)
/*
if command == "net" && subcommand == Some("user") {
if let Some(args) = subcommand {
if args.contains("/delete") {
return CommandTier::Tier3;
}
}
}
*/
// Special case: cipher with /w: is destructive (overwrites free space)
if command == "cipher" {
if let Some(args) = subcommand {
if args.contains("/w:") {
return CommandTier::Tier3;
}
}
}
return CommandTier::Tier3; return CommandTier::Tier3;
} }
@ -333,9 +196,8 @@ impl CommandClassifier {
} }
} }
// Tier 1: General safe read-only commands (Linux + Windows) // Tier 1: General safe read-only commands
let tier1_general = [ let tier1_general = [
// Linux read-only
"cat", "cat",
"grep", "grep",
"ls", "ls",
@ -346,6 +208,7 @@ impl CommandClassifier {
"ss", "ss",
"netstat", "netstat",
"journalctl", "journalctl",
"systemctl",
"echo", "echo",
"pwd", "pwd",
"whoami", "whoami",
@ -361,348 +224,26 @@ impl CommandClassifier {
"cut", "cut",
"tr", "tr",
"test", "test",
"stat",
"file",
"readlink",
"which",
"whereis",
"type",
"help",
"man",
"info",
"cat /proc/*",
"cat /sys/*",
"dmidecode",
"lscpu",
"lsblk",
"lshw",
"lspci",
"lsusb",
"hwinfo",
"smartctl -a",
"smartctl -H",
"mdadm --detail",
"vgdisplay",
"lvdisplay",
"pvdisplay",
"zpool status",
"zpool list",
"ceph -s",
"ceph health",
"pvecm status",
"pvesh get",
// Windows read-only (cmd)
"dir",
"type",
"more",
"find",
"findstr",
"fc",
"comp",
"diskpart /s",
"mountvol",
"driverquery",
"systeminfo",
"ver",
"ipconfig",
"ping",
"tracert",
"net view",
"net share",
"net session",
"net user",
"net localgroup",
"net group",
"net start",
"net stop",
"net use",
"net config",
"netstat",
"nbtstat",
"pathping",
"nslookup",
"arp -a",
"route print",
"hostname",
"whoami",
"date /t",
"time /t",
"chcp",
"prompt",
"cls",
"echo",
"cd",
"md",
"mkdir",
"fsutil volume info",
"fsutil file queryfileinfo",
"sfc /scannow",
"chkdsk",
"certutil -urlcache",
"certutil -verify",
"quser",
"qwinsta",
"rwinsta",
"wevtutil qe",
"wevtutil gl",
"get-wmiobject",
"get-process",
"get-service",
"get-eventlog",
"get-childitem",
"get-content",
"get-date",
"get-location",
"get-physicalmemory",
"get-processor",
"get-volume",
"get-partition",
"get-disk",
"get-computerinfo",
"get-windowsfeature",
"get-module",
"get-command",
// Windows read-only (PowerShell)
"get-process",
"get-service",
"get-eventlog",
"get-childitem",
"get-content",
"get-date",
"get-location",
"get-physicalmemory",
"get-processor",
"get-volume",
"get-partition",
"get-disk",
"get-computerinfo",
"get-windowsfeature",
"get-module",
"get-command",
"get-wmiobject",
"get-ciminstance",
"get-counter",
"get-process",
"get-service",
"get-netadapter",
"get-netipaddress",
"get-netroute",
"get-nettcpconnection",
"get-NetFirewallRule",
"get-itemproperty",
"get-childitem -recurse",
"get-alias",
"get-variable",
"get-psdrive",
"get-location",
"get-clipboard",
"get-credential",
"get-credential -list",
"get-scheduledtask",
"get-job",
"get-runspace",
// Network potentially mutating (read-only commands moved to Tier2)
"nc -zv",
"telnet",
"nmap -sV",
"nmap -sP",
"dig",
"host",
"ldapsearch",
"ldapbind",
"ldapmodify",
"ldapdelete",
]; ];
if tier1_general.contains(&command) { if tier1_general.contains(&command) {
// systemctl needs subcommand check // systemctl needs subcommand check
if command == "systemctl" { if command == "systemctl" {
if let Some(sub) = subcommand { if let Some(sub) = subcommand {
if sub == "status" if sub == "status" || sub == "is-active" || sub == "is-enabled" {
|| sub == "is-active"
|| sub == "is-enabled"
|| sub == "list-units"
|| sub == "list-unit-files"
{
return CommandTier::Tier1; return CommandTier::Tier1;
} }
// restart, reload, enable, disable, etc. are Tier 2 // restart, reload, etc. are Tier 2
return CommandTier::Tier2; return CommandTier::Tier2;
} }
} }
// Windows PowerShell commands starting with get-
if command.starts_with("get-") && (command.contains("-") || command.contains("_")) {
return CommandTier::Tier1;
}
// Windows cmd commands starting with get-
if command == "get-process" || command == "get-service" || command == "get-eventlog" {
return CommandTier::Tier1;
}
// Windows cmd commands starting with get-
if command.starts_with("get-") {
return CommandTier::Tier1;
}
return CommandTier::Tier1; return CommandTier::Tier1;
} }
// Tier 2: Network and potentially mutating commands (Linux + Windows) // Tier 2: Network and potentially mutating commands
let tier2_general = [ let tier2_general = [
// Linux potentially mutating "ssh", "scp", "rsync", "curl", "wget", "chmod", "chown", "mv", "cp", "awk",
"ssh", "sed", // Can be safe, but can also modify
"scp",
"rsync",
"chmod",
"chown",
"mv",
"cp",
"awk",
"sed",
"sudo",
"ln",
"ln -s",
"touch",
"truncate",
"mktemp",
"mkdir",
"rmdir",
"mount",
"umount",
"mount -o",
"umount -l",
"mount -t",
"umount -f",
"ln -sf",
"ln -sfn",
"ln -sf --backup",
"ln -sfn --backup",
// Windows potentially mutating (cmd)
"move",
"ren",
"rename",
"copy",
"xcopy",
"robocopy",
"mklink",
"mklink /d",
"attrib",
"cacls",
"icacls",
"takeown",
"setx",
"reg add",
"reg delete",
"reg import",
"schtasks",
"schtasks /create",
"schtasks /delete",
"schtasks /change",
"wevtutil im",
"wevtutil sl",
"wevtutil cl",
"wevtutil epl",
"diskpart",
"format",
"mountvol",
"subst",
"pushd",
"popd",
// Network potentially mutating
"curl",
"wget",
"ftp",
"sftp",
"tftp",
"ftps",
// Windows potentially mutating (PowerShell) - non-destructive only
"set-item",
"set-itemproperty",
"set-location",
"set-variable",
"set-alias",
"set-executionpolicy",
"set-service",
"set-process",
"set-date",
"set-time",
"new-item",
"new-itemproperty",
"new-item -itemtype",
"new-item -path",
"register-scheduledtask",
"enable-scheduledtask",
"disable-scheduledtask",
"new-scheduledtask",
"new-module",
"import-module",
"import-pssession",
"new-pssession",
"enter-pssession",
"exit-pssession",
"new-runspace",
"enter-runspace",
"exit-runspace",
"new-job",
"wait-job",
"receive-job",
"new-appdomain",
// Dangerous Windows commands with wildcards
"del *",
"del *.*",
"erase *",
"erase *.*",
"rd /s",
"rmdir /s",
"move *",
"move *.*",
"copy *",
"copy *.*",
"xcopy *",
"xcopy *.*",
"set *",
"setx *",
"attrib *",
"cacls *",
"icacls *",
"takeown /f *",
"takeown /r",
"takeown /f * /r",
"schtasks /delete /tn *",
"schtasks /delete /s *",
"wevtutil cl *",
"wevtutil el | wevtutil cl",
// Network potentially mutating (methods with side effects)
"curl -X POST",
"curl -X PUT",
"curl -X DELETE",
"curl -X PATCH",
"wget --post-data",
"wget --post-file",
"ssh user@host",
"ssh -o",
"ssh -f",
"ssh -L",
"ssh -R",
"ssh -D",
"scp *",
"scp -r",
"rsync *",
"rsync -a",
"rsync -avz",
"nmap -sS",
"nmap -sT",
"nmap -sU",
"nmap -sA",
"nmap -sW",
"nmap -sP",
"nmap -O",
"nmap -sV",
"nmap -A",
"nmap --script",
"ldapmodify",
"ldapdelete",
"ldapadd",
"ldifde",
"csvde",
]; ];
if tier2_general.contains(&command) { if tier2_general.contains(&command) {
@ -973,210 +514,4 @@ mod tests {
); );
} }
} }
#[test]
fn test_windows_tier1_readonly_commands() {
let classifier = CommandClassifier::new();
let tier1_commands = vec![
"dir",
"type file.txt",
"more < file.txt",
"findstr pattern file.txt",
"ipconfig",
"ping 127.0.0.1",
"tracert 127.0.0.1",
"netstat",
"whoami",
"date /t",
"systeminfo",
"ver",
"hostname",
"get-process",
"get-service",
"get-eventlog -logname System",
"get-childitem",
"get-content file.txt",
"get-date",
"get-location",
"get-physicalmemory",
"get-processor",
"get-volume",
"get-partition",
"get-disk",
"get-computerinfo",
];
for cmd in tier1_commands {
let result = classifier.classify(cmd);
assert_eq!(
result.tier,
CommandTier::Tier1,
"Command '{}' should be Tier 1",
cmd
);
}
}
#[test]
fn test_windows_tier2_mutating_commands() {
let classifier = CommandClassifier::new();
let tier2_commands = vec![
"move file.txt newfile.txt",
"ren file.txt newfile.txt",
"copy file.txt dest.txt",
"xcopy file.txt dest.txt",
"robocopy source dest",
"attrib +r file.txt",
"icacls file.txt /grant user:F",
"schtasks /create /tn test /tr test.exe",
"reg add HKLM\\Software\\Test",
"setx VAR value",
"move *",
"copy *.*",
"set *",
"setx *",
"attrib *",
"new-item -path C:\\test",
"set-itemproperty -path HKLM:\\Software\\Test -name Test -value 1",
"sudo",
"new-scheduledtask -action (new-scheduledtaskaction -execute notepad)",
"register-scheduledtask -taskname test -action (new-scheduledtaskaction -execute notepad)",
"curl -X POST http://example.com",
"wget --post-data test http://example.com",
"time /t",
];
for cmd in tier2_commands {
let result = classifier.classify(cmd);
assert_eq!(
result.tier,
CommandTier::Tier2,
"Command '{}' should be Tier 2",
cmd
);
}
}
#[test]
fn test_windows_tier3_destructive_commands() {
let classifier = CommandClassifier::new();
let tier3_commands = vec![
"format C: /q",
"del *",
"del *.*",
"erase *",
"erase *.*",
"rd /s C:\\test",
"rmdir /s C:\\test",
"sdelete C:\\test",
"bootrec /fixmbr",
"bootrec /fixboot",
"diskpart",
"remove-item -recurse -force C:\\test",
"clear-recyclebin",
"stop-computer",
"restart-computer -force",
"remove-wmiobject -query \"select * from win32_process where name='notepad.exe'\"",
"remove-itemproperty -path HKLM:\\Software\\Test -name Test",
"uninstall-module -name PowerShellGet",
"uninstall-package -name Package",
"unregister-scheduledtask -taskname test",
"dd if=/dev/zero of=/dev/sda",
"mkfs.ext4 /dev/sda1",
"remove-item -recurse C:\\test",
"remove-item -force C:\\test",
"clear-host",
"stop-process",
"stop-service",
"restart-computer",
"suspend-process",
"suspend-service",
"resume-process",
"resume-service",
"wait-process",
"wait-service",
"wait-computer",
"start-process",
"start-service",
"start-computer",
"invoke-item",
"unregister-scheduledtask",
"remove-scheduledtask",
"remove-job",
"remove-runspace",
"remove-appdomain",
"remove-pssession",
"remove-module",
"uninstall-package",
"uninstall-module",
"remove-wmiobject",
"remove-itemproperty",
"cipher /w:C:\\test",
];
for cmd in tier3_commands {
let result = classifier.classify(cmd);
assert_eq!(
result.tier,
CommandTier::Tier3,
"Command '{}' should be Tier 3",
cmd
);
}
}
#[test]
fn test_linux_windows_mixed_commands() {
let classifier = CommandClassifier::new();
// Linux commands
let linux_commands = vec![
"cat /etc/passwd",
"ls -la /home",
"grep error /var/log/syslog",
"df -h",
"ps aux",
"systemctl status nginx",
"ssh user@host",
"scp file.txt user@host:",
"rm -rf /tmp/test",
"shutdown -h now",
];
for cmd in linux_commands {
let result = classifier.classify(cmd);
assert!(
result.tier == CommandTier::Tier1
|| result.tier == CommandTier::Tier2
|| result.tier == CommandTier::Tier3,
"Linux command '{}' should have a tier",
cmd
);
}
// Windows commands
let windows_commands = vec![
"dir C:\\",
"type C:\\test.txt",
"ipconfig /all",
"get-process",
"get-service",
"remove-item C:\\test",
"stop-process -name notepad",
];
for cmd in windows_commands {
let result = classifier.classify(cmd);
assert!(
result.tier == CommandTier::Tier1
|| result.tier == CommandTier::Tier2
|| result.tier == CommandTier::Tier3,
"Windows command '{}' should have a tier",
cmd
);
}
}
} }

View File

@ -1,6 +1,6 @@
{ {
"productName": "Troubleshooting and RCA Assistant", "productName": "Troubleshooting and RCA Assistant",
"version": "1.1.0", "version": "1.0.8",
"identifier": "com.trcaa.app", "identifier": "com.trcaa.app",
"build": { "build": {
"frontendDist": "../dist", "frontendDist": "../dist",

View File

@ -1,380 +0,0 @@
// Cluster management integration tests
// Tests: add cluster, list clusters, remove cluster
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex as TokioMutex;
fn setup_test_state() -> trcaa_lib::state::AppState {
let conn = rusqlite::Connection::open_in_memory().expect("Failed to create in-memory DB");
trcaa_lib::state::AppState {
db: Arc::new(StdMutex::new(conn)),
settings: Arc::new(StdMutex::new(trcaa_lib::state::AppSettings::default())),
app_data_dir: std::path::PathBuf::from("./test-data"),
integration_webviews: Arc::new(StdMutex::new(HashMap::new())),
mcp_connections: Arc::new(TokioMutex::new(HashMap::new())),
pending_approvals: Arc::new(TokioMutex::new(HashMap::new())),
clusters: Arc::new(TokioMutex::new(HashMap::new())),
port_forwards: Arc::new(TokioMutex::new(HashMap::new())),
refresh_registry: Arc::new(TokioMutex::new(trcaa_lib::kube::RefreshRegistry::new())),
}
}
#[tokio::test]
async fn test_add_cluster_success() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
namespace: default
name: production-context
current-context: production-context
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production Cluster".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_ok());
let cluster_info = result.unwrap();
assert_eq!(cluster_info.id, "cluster-1");
assert_eq!(cluster_info.name, "Production Cluster");
assert_eq!(cluster_info.context, "production-context");
assert_eq!(cluster_info.cluster_url, "https://k8s.example.com:6443");
}
#[tokio::test]
async fn test_add_cluster_empty_content() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Empty Cluster".to_string(),
"".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Kubeconfig content cannot be empty"));
}
#[tokio::test]
async fn test_add_cluster_missing_contexts() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"No Contexts".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Missing 'contexts' field"));
}
#[tokio::test]
async fn test_add_cluster_no_contexts() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts: []
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Empty Contexts".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("No contexts found"));
}
#[tokio::test]
async fn test_add_cluster_missing_clusters() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
contexts:
- context:
cluster: production
user: admin
name: production-context
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"No Clusters".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Missing 'clusters' field"));
}
#[tokio::test]
async fn test_add_cluster_invalid_yaml() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
invalid yaml here: [
missing closing bracket
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Invalid YAML".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Invalid kubeconfig YAML"));
}
#[tokio::test]
async fn test_list_clusters_empty() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::list_clusters(trcaa_lib::State::new(&state)).await;
assert!(result.is_ok());
let clusters = result.unwrap();
assert!(clusters.is_empty());
}
#[tokio::test]
async fn test_list_clusters_multiple() {
let state = setup_test_state();
// Add first cluster
let kubeconfig1 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s1.example.com:6443
name: cluster1
contexts:
- context:
cluster: cluster1
user: user1
name: context1
users:
- name: user1
user:
token: token1
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Cluster 1".to_string(),
kubeconfig1.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Add second cluster
let kubeconfig2 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s2.example.com:6443
name: cluster2
contexts:
- context:
cluster: cluster2
user: user2
name: context2
users:
- name: user2
user:
token: token2
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-2".to_string(),
"Cluster 2".to_string(),
kubeconfig2.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// List clusters
let result = trcaa_lib::commands::kube::list_clusters(trcaa_lib::State::new(&state)).await;
assert!(result.is_ok());
let clusters = result.unwrap();
assert_eq!(clusters.len(), 2);
let cluster_names: Vec<&str> = clusters.iter().map(|c| c.name.as_str()).collect();
assert!(cluster_names.contains(&"Cluster 1"));
assert!(cluster_names.contains(&"Cluster 2"));
}
#[tokio::test]
async fn test_remove_cluster_success() {
let state = setup_test_state();
// Add a cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Verify cluster exists
let clusters = trcaa_lib::commands::kube::list_clusters(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(clusters.len(), 1);
// Remove cluster
let result = trcaa_lib::commands::kube::remove_cluster(
"cluster-1".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_ok());
// Verify cluster is gone
let clusters = trcaa_lib::commands::kube::list_clusters(trcaa_lib::State::new(&state))
.await
.unwrap();
assert!(clusters.is_empty());
}
#[tokio::test]
async fn test_remove_cluster_not_found() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::remove_cluster(
"non-existent".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Cluster non-existent not found"));
}
#[tokio::test]
async fn test_add_cluster_with_no_server_url() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
# No server URL
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"No Server".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Server URL not found"));
}

View File

@ -1,485 +0,0 @@
// Error scenarios integration tests
// Tests: invalid kubeconfig, cluster not found, port conflicts, edge cases
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex as TokioMutex;
fn setup_test_state() -> trcaa_lib::state::AppState {
let conn = rusqlite::Connection::open_in_memory().expect("Failed to create in-memory DB");
trcaa_lib::state::AppState {
db: Arc::new(StdMutex::new(conn)),
settings: Arc::new(StdMutex::new(trcaa_lib::state::AppSettings::default())),
app_data_dir: std::path::PathBuf::from("./test-data"),
integration_webviews: Arc::new(StdMutex::new(HashMap::new())),
mcp_connections: Arc::new(TokioMutex::new(HashMap::new())),
pending_approvals: Arc::new(TokioMutex::new(HashMap::new())),
clusters: Arc::new(TokioMutex::new(HashMap::new())),
port_forwards: Arc::new(TokioMutex::new(HashMap::new())),
refresh_registry: Arc::new(TokioMutex::new(trcaa_lib::kube::RefreshRegistry::new())),
}
}
#[tokio::test]
async fn test_invalid_yaml_syntax() {
let state = setup_test_state();
let invalid_yaml = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com
invalid: [unclosed array
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Invalid YAML".to_string(),
invalid_yaml.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.contains("Invalid kubeconfig YAML") || err.contains("YAML"));
}
#[tokio::test]
async fn test_empty_kubeconfig() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Empty".to_string(),
"".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot be empty"));
}
#[tokio::test]
async fn test_whitespace_only_kubeconfig() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Whitespace".to_string(),
" \n\t \n ".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("cannot be empty"));
}
#[tokio::test]
async fn test_kubeconfig_with_null_values() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: null
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Null Server".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("Server URL not found"));
}
#[tokio::test]
async fn test_port_forward_to_nonexistent_cluster() {
let state = setup_test_state();
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "non-existent-cluster".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
let result =
trcaa_lib::commands::kube::start_port_forward(request, trcaa_lib::State::new(&state)).await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[tokio::test]
async fn test_stop_nonexistent_port_forward() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::stop_port_forward(
"non-existent-session".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[tokio::test]
async fn test_delete_nonexistent_port_forward() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::delete_port_forward(
"non-existent-session".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[tokio::test]
async fn test_remove_nonexistent_cluster() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::remove_cluster(
"non-existent-cluster".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("not found"));
}
#[tokio::test]
async fn test_kubeconfig_with_empty_clusters_array() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters: []
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Empty Clusters".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("No clusters found"));
}
#[tokio::test]
async fn test_kubeconfig_with_empty_contexts_array() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts: []
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Empty Contexts".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result.unwrap_err().contains("No contexts found"));
}
#[tokio::test]
async fn test_kubeconfig_missing_api_version() {
let state = setup_test_state();
let kubeconfig = r#"
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"No API Version".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
// Should still work - we only check for required fields
assert!(result.is_ok());
}
#[tokio::test]
async fn test_kubeconfig_with_extra_fields() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
metadata:
name: my-config
annotations:
created-by: test
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"With Metadata".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_kubeconfig_with_multiple_clusters() {
let state = setup_test_state();
// Use first cluster's server URL
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s1.example.com:6443
name: cluster1
- cluster:
server: https://k8s2.example.com:6443
name: cluster2
contexts:
- context:
cluster: cluster1
user: admin
name: context1
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Multiple Clusters".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_ok());
let cluster_info = result.unwrap();
assert_eq!(cluster_info.cluster_url, "https://k8s1.example.com:6443");
}
#[tokio::test]
async fn test_kubeconfig_with_multiple_contexts() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
namespace: default
name: default-context
- context:
cluster: production
user: admin
namespace: kube-system
name: kube-system-context
users:
- name: admin
user:
token: test-token
"#;
let result = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Multiple Contexts".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_ok());
let cluster_info = result.unwrap();
// Should use first context
assert_eq!(cluster_info.context, "default-context");
}
#[tokio::test]
async fn test_port_forward_with_empty_namespace() {
let state = setup_test_state();
// Add a cluster first
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Try port forward with empty namespace
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
// Note: Current implementation doesn't validate namespace/pod
// This may need validation added
let result =
trcaa_lib::commands::kube::start_port_forward(request, trcaa_lib::State::new(&state)).await;
assert!(result.is_ok()); // Current behavior allows empty namespace
}
#[tokio::test]
async fn test_port_forward_with_empty_pod() {
let state = setup_test_state();
// Add a cluster first
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Try port forward with empty pod
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "".to_string(),
container_port: 80,
local_port: 0,
};
// Note: Current implementation doesn't validate pod name
let result =
trcaa_lib::commands::kube::start_port_forward(request, trcaa_lib::State::new(&state)).await;
assert!(result.is_ok()); // Current behavior allows empty pod
}

View File

@ -1,8 +0,0 @@
// Integration tests for Kubernetes management feature
// Tests end-to-end cluster management, port forwarding, and error scenarios
mod cluster_management;
mod port_forwarding;
mod multi_cluster;
mod error_scenarios;
mod session_recovery;

View File

@ -1,413 +0,0 @@
// Multi-cluster management integration tests
// Tests: multiple cluster operations, cluster isolation, cross-cluster port forwarding
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex as TokioMutex;
fn setup_test_state() -> trcaa_lib::state::AppState {
let conn = rusqlite::Connection::open_in_memory().expect("Failed to create in-memory DB");
trcaa_lib::state::AppState {
db: Arc::new(StdMutex::new(conn)),
settings: Arc::new(StdMutex::new(trcaa_lib::state::AppSettings::default())),
app_data_dir: std::path::PathBuf::from("./test-data"),
integration_webviews: Arc::new(StdMutex::new(HashMap::new())),
mcp_connections: Arc::new(TokioMutex::new(HashMap::new())),
pending_approvals: Arc::new(TokioMutex::new(HashMap::new())),
clusters: Arc::new(TokioMutex::new(HashMap::new())),
port_forwards: Arc::new(TokioMutex::new(HashMap::new())),
refresh_registry: Arc::new(TokioMutex::new(trcaa_lib::kube::RefreshRegistry::new())),
}
}
#[tokio::test]
async fn test_add_multiple_clusters_with_same_name() {
let state = setup_test_state();
let kubeconfig1 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s1.example.com:6443
name: cluster1
contexts:
- context:
cluster: cluster1
user: admin
name: context1
users:
- name: admin
user:
token: token1
"#;
let kubeconfig2 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s2.example.com:6443
name: cluster2
contexts:
- context:
cluster: cluster2
user: admin
name: context2
users:
- name: admin
user:
token: token2
"#;
// Add first cluster
let result1 = trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Same Name".to_string(),
kubeconfig1.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result1.is_ok());
// Add second cluster with same display name but different ID
let result2 = trcaa_lib::commands::kube::add_cluster(
"cluster-2".to_string(),
"Same Name".to_string(),
kubeconfig2.to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result2.is_ok());
// Verify both clusters exist
let clusters = trcaa_lib::commands::kube::list_clusters(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(clusters.len(), 2);
}
#[tokio::test]
async fn test_cluster_isolation() {
let state = setup_test_state();
// Add first cluster
let kubeconfig1 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s1.example.com:6443
name: cluster1
contexts:
- context:
cluster: cluster1
user: admin
name: context1
users:
- name: admin
user:
token: token1
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Cluster 1".to_string(),
kubeconfig1.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Add second cluster
let kubeconfig2 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s2.example.com:6443
name: cluster2
contexts:
- context:
cluster: cluster2
user: admin
name: context2
users:
- name: admin
user:
token: token2
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-2".to_string(),
"Cluster 2".to_string(),
kubeconfig2.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// List clusters - verify they're isolated
let clusters = trcaa_lib::commands::kube::list_clusters(trcaa_lib::State::new(&state))
.await
.unwrap();
let cluster_ids: Vec<&str> = clusters.iter().map(|c| c.id.as_str()).collect();
assert!(cluster_ids.contains(&"cluster-1"));
assert!(cluster_ids.contains(&"cluster-2"));
let cluster_names: Vec<&str> = clusters.iter().map(|c| c.name.as_str()).collect();
assert!(cluster_names.contains(&"Cluster 1"));
assert!(cluster_names.contains(&"Cluster 2"));
let cluster_urls: Vec<&str> = clusters.iter().map(|c| c.cluster_url.as_str()).collect();
assert!(cluster_urls.contains(&"https://k8s1.example.com:6443"));
assert!(cluster_urls.contains(&"https://k8s2.example.com:6443"));
}
#[tokio::test]
async fn test_port_forward_to_specific_cluster() {
let state = setup_test_state();
// Add first cluster
let kubeconfig1 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s1.example.com:6443
name: cluster1
contexts:
- context:
cluster: cluster1
user: admin
name: context1
users:
- name: admin
user:
token: token1
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Cluster 1".to_string(),
kubeconfig1.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Add second cluster
let kubeconfig2 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s2.example.com:6443
name: cluster2
contexts:
- context:
cluster: cluster2
user: admin
name: context2
users:
- name: admin
user:
token: token2
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-2".to_string(),
"Cluster 2".to_string(),
kubeconfig2.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Start port forward to first cluster
let request1 = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "pod-1".to_string(),
container_port: 80,
local_port: 0,
};
let result1 =
trcaa_lib::commands::kube::start_port_forward(request1, trcaa_lib::State::new(&state))
.await
.unwrap();
// Start port forward to second cluster
let request2 = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-2".to_string(),
namespace: "kube-system".to_string(),
pod: "pod-2".to_string(),
container_port: 443,
local_port: 0,
};
let result2 =
trcaa_lib::commands::kube::start_port_forward(request2, trcaa_lib::State::new(&state))
.await
.unwrap();
// List port forwards - verify both are present
let forwards = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(forwards.len(), 2);
// Verify cluster isolation in port forwards
let cluster_ids: Vec<&str> = forwards.iter().map(|f| f.cluster_id.as_str()).collect();
assert!(cluster_ids.contains(&"cluster-1"));
assert!(cluster_ids.contains(&"cluster-2"));
// Verify container_ports and local_ports are arrays
for f in &forwards {
assert!(!f.container_ports.is_empty());
assert!(!f.local_ports.is_empty());
}
}
#[tokio::test]
async fn test_remove_cluster_cascades_to_port_forwards() {
let state = setup_test_state();
// Add cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Start port forward
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
trcaa_lib::commands::kube::start_port_forward(request, trcaa_lib::State::new(&state))
.await
.unwrap();
// Verify port forward exists
let forwards = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(forwards.len(), 1);
// Remove cluster
trcaa_lib::commands::kube::remove_cluster(
"cluster-1".to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Note: Current implementation doesn't cascade delete port forwards
// This test documents the current behavior - port forwards persist after cluster removal
// This may be intentional for debugging or may need to be fixed
let forwards_after =
trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(forwards_after.len(), 1); // Port forward still exists
}
#[tokio::test]
async fn test_list_clusters_with_different_contexts() {
let state = setup_test_state();
let kubeconfig1 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s1.example.com:6443
name: cluster1
contexts:
- context:
cluster: cluster1
user: admin
namespace: production
name: prod-context
users:
- name: admin
user:
token: token1
"#;
let kubeconfig2 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s2.example.com:6443
name: cluster2
contexts:
- context:
cluster: cluster2
user: admin
namespace: staging
name: staging-context
users:
- name: admin
user:
token: token2
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig1.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
trcaa_lib::commands::kube::add_cluster(
"cluster-2".to_string(),
"Staging".to_string(),
kubeconfig2.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
let clusters = trcaa_lib::commands::kube::list_clusters(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(clusters.len(), 2);
assert_eq!(clusters[0].context, "prod-context");
assert_eq!(clusters[1].context, "staging-context");
}

View File

@ -1,426 +0,0 @@
// Port forwarding integration tests
// Tests: start port forward, list port forwards, stop port forward, delete port forward
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex as TokioMutex;
fn setup_test_state() -> trcaa_lib::state::AppState {
let conn = rusqlite::Connection::open_in_memory().expect("Failed to create in-memory DB");
trcaa_lib::state::AppState {
db: Arc::new(StdMutex::new(conn)),
settings: Arc::new(StdMutex::new(trcaa_lib::state::AppSettings::default())),
app_data_dir: std::path::PathBuf::from("./test-data"),
integration_webviews: Arc::new(StdMutex::new(HashMap::new())),
mcp_connections: Arc::new(TokioMutex::new(HashMap::new())),
pending_approvals: Arc::new(TokioMutex::new(HashMap::new())),
clusters: Arc::new(TokioMutex::new(HashMap::new())),
port_forwards: Arc::new(TokioMutex::new(HashMap::new())),
refresh_registry: Arc::new(TokioMutex::new(trcaa_lib::kube::RefreshRegistry::new())),
}
}
#[tokio::test]
async fn test_start_port_forward_success() {
let state = setup_test_state();
// Add a cluster first
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Start port forward
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod-abc123".to_string(),
container_port: 80,
local_port: 0,
};
let result =
trcaa_lib::commands::kube::start_port_forward(request, trcaa_lib::State::new(&state)).await;
assert!(result.is_ok());
let response = result.unwrap();
assert!(response.id.len() > 0);
assert_eq!(response.cluster_id, "cluster-1");
assert_eq!(response.namespace, "default");
assert_eq!(response.pod, "nginx-pod-abc123");
assert_eq!(response.container_ports, vec![80]);
assert_eq!(response.status, "Active");
}
#[tokio::test]
async fn test_start_port_forward_cluster_not_found() {
let state = setup_test_state();
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "non-existent".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
let result =
trcaa_lib::commands::kube::start_port_forward(request, trcaa_lib::State::new(&state)).await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Cluster non-existent not found"));
}
#[tokio::test]
async fn test_list_port_forwards_empty() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state)).await;
assert!(result.is_ok());
let forwards = result.unwrap();
assert!(forwards.is_empty());
}
#[tokio::test]
async fn test_list_port_forwards_multiple() {
let state = setup_test_state();
// Add a cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Start first port forward
let request1 = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "pod-1".to_string(),
container_port: 80,
local_port: 0,
};
trcaa_lib::commands::kube::start_port_forward(request1, trcaa_lib::State::new(&state))
.await
.unwrap();
// Start second port forward
let request2 = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "kube-system".to_string(),
pod: "pod-2".to_string(),
container_port: 443,
};
trcaa_lib::commands::kube::start_port_forward(request2, trcaa_lib::State::new(&state))
.await
.unwrap();
// List port forwards
let result = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state)).await;
assert!(result.is_ok());
let forwards = result.unwrap();
assert_eq!(forwards.len(), 2);
let pods: Vec<&str> = forwards.iter().map(|f| f.pod.as_str()).collect();
assert!(pods.contains(&"pod-1"));
assert!(pods.contains(&"pod-2"));
}
#[tokio::test]
async fn test_stop_port_forward_success() {
let state = setup_test_state();
// Add a cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Start port forward
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
let start_result =
trcaa_lib::commands::kube::start_port_forward(request, trcaa_lib::State::new(&state))
.await
.unwrap();
// Verify it's active
let list_result = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(list_result[0].status, "Active");
// Stop port forward
let result = trcaa_lib::commands::kube::stop_port_forward(
start_result.id.clone(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_ok());
// Verify it's stopped
let list_result = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(list_result[0].status, "Stopped");
}
#[tokio::test]
async fn test_stop_port_forward_not_found() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::stop_port_forward(
"non-existent".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Port forward session non-existent not found"));
}
#[tokio::test]
async fn test_delete_port_forward_success() {
let state = setup_test_state();
// Add a cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Start port forward
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
let start_result =
trcaa_lib::commands::kube::start_port_forward(request, trcaa_lib::State::new(&state))
.await
.unwrap();
// Verify port forward exists
let list_result = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(list_result.len(), 1);
// Delete port forward
let result = trcaa_lib::commands::kube::delete_port_forward(
start_result.id.clone(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_ok());
// Verify port forward is gone
let list_result = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert!(list_result.is_empty());
}
#[tokio::test]
async fn test_delete_port_forward_not_found() {
let state = setup_test_state();
let result = trcaa_lib::commands::kube::delete_port_forward(
"non-existent".to_string(),
trcaa_lib::State::new(&state),
)
.await;
assert!(result.is_err());
assert!(result
.unwrap_err()
.contains("Port forward session non-existent not found"));
}
#[tokio::test]
async fn test_port_forward_session_lifecycle() {
let state = setup_test_state();
// Add a cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Start port forward
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
let start_result =
trcaa_lib::commands::kube::start_port_forward(request, trcaa_lib::State::new(&state))
.await
.unwrap();
// Verify session is active
let session_id = start_result.id.clone();
let list_result = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(list_result[0].id, session_id);
assert_eq!(list_result[0].status, "Active");
// Stop port forward
trcaa_lib::commands::kube::stop_port_forward(session_id.clone(), trcaa_lib::State::new(&state))
.await
.unwrap();
// Verify session is stopped
let list_result = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert_eq!(list_result[0].status, "Stopped");
// Delete port forward
trcaa_lib::commands::kube::delete_port_forward(
session_id.clone(),
trcaa_lib::State::new(&state),
)
.await
.unwrap();
// Verify session is deleted
let list_result = trcaa_lib::commands::kube::list_port_forwards(trcaa_lib::State::new(&state))
.await
.unwrap();
assert!(list_result.is_empty());
}

View File

@ -1,384 +0,0 @@
// Session recovery integration tests
// Tests: cluster and port forward persistence across restarts
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex as StdMutex;
use tauri::State;
use tokio::sync::Mutex as TokioMutex;
fn setup_test_state() -> trcaa_lib::state::AppState {
let conn = rusqlite::Connection::open_in_memory().expect("Failed to create in-memory DB");
trcaa_lib::state::AppState {
db: Arc::new(StdMutex::new(conn)),
settings: Arc::new(StdMutex::new(trcaa_lib::state::AppSettings::default())),
app_data_dir: std::path::PathBuf::from("./test-data"),
integration_webviews: Arc::new(StdMutex::new(HashMap::new())),
mcp_connections: Arc::new(TokioMutex::new(HashMap::new())),
pending_approvals: Arc::new(TokioMutex::new(HashMap::new())),
clusters: Arc::new(TokioMutex::new(HashMap::new())),
port_forwards: Arc::new(TokioMutex::new(HashMap::new())),
refresh_registry: Arc::new(TokioMutex::new(trcaa_lib::kube::RefreshRegistry::new())),
}
}
#[tokio::test]
async fn test_clusters_persist_in_memory() {
let state = setup_test_state();
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
// Add cluster
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
State::new(&state),
)
.await
.unwrap();
// List clusters - should find it
let clusters = trcaa_lib::commands::kube::list_clusters(State::new(&state))
.await
.unwrap();
assert_eq!(clusters.len(), 1);
// Note: In-memory state doesn't persist across restarts
// This test documents the current in-memory behavior
// For true persistence, database storage would be required
}
#[tokio::test]
async fn test_port_forwards_persist_in_memory() {
let state = setup_test_state();
// Add cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
State::new(&state),
)
.await
.unwrap();
// Start port forward
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
trcaa_lib::commands::kube::start_port_forward(request, State::new(&state))
.await
.unwrap();
// List port forwards - should find it
let forwards = trcaa_lib::commands::kube::list_port_forwards(State::new(&state))
.await
.unwrap();
assert_eq!(forwards.len(), 1);
// Note: In-memory state doesn't persist across restarts
// For true persistence, database storage would be required
}
#[tokio::test]
async fn test_multiple_clusters_and_port_forwards() {
let state = setup_test_state();
// Add multiple clusters
let kubeconfig1 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s1.example.com:6443
name: cluster1
contexts:
- context:
cluster: cluster1
user: admin
name: context1
users:
- name: admin
user:
token: token1
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Cluster 1".to_string(),
kubeconfig1.to_string(),
State::new(&state),
)
.await
.unwrap();
let kubeconfig2 = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s2.example.com:6443
name: cluster2
contexts:
- context:
cluster: cluster2
user: admin
name: context2
users:
- name: admin
user:
token: token2
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-2".to_string(),
"Cluster 2".to_string(),
kubeconfig2.to_string(),
State::new(&state),
)
.await
.unwrap();
// Start multiple port forwards
let request1 = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "pod-1".to_string(),
container_port: 80,
local_port: 0,
};
trcaa_lib::commands::kube::start_port_forward(request1, State::new(&state))
.await
.unwrap();
let request2 = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-2".to_string(),
namespace: "kube-system".to_string(),
pod: "pod-2".to_string(),
container_port: 443,
local_port: 0,
};
trcaa_lib::commands::kube::start_port_forward(request2, State::new(&state))
.await
.unwrap();
// Verify all clusters exist
let clusters = trcaa_lib::commands::kube::list_clusters(State::new(&state))
.await
.unwrap();
assert_eq!(clusters.len(), 2);
// Verify all port forwards exist
let forwards = trcaa_lib::commands::kube::list_port_forwards(State::new(&state))
.await
.unwrap();
assert_eq!(forwards.len(), 2);
}
#[tokio::test]
async fn test_cluster_removal_clears_cluster_data() {
let state = setup_test_state();
// Add cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
State::new(&state),
)
.await
.unwrap();
// Verify cluster exists
let clusters = trcaa_lib::commands::kube::list_clusters(State::new(&state))
.await
.unwrap();
assert_eq!(clusters.len(), 1);
// Remove cluster
trcaa_lib::commands::kube::remove_cluster("cluster-1".to_string(), State::new(&state))
.await
.unwrap();
// Verify cluster is gone
let clusters = trcaa_lib::commands::kube::list_clusters(State::new(&state))
.await
.unwrap();
assert!(clusters.is_empty());
}
#[tokio::test]
async fn test_port_forward_stop_clears_session() {
let state = setup_test_state();
// Add cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
State::new(&state),
)
.await
.unwrap();
// Start port forward
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
let start_result = trcaa_lib::commands::kube::start_port_forward(request, State::new(&state))
.await
.unwrap();
// Stop port forward
trcaa_lib::commands::kube::stop_port_forward(start_result.id.clone(), State::new(&state))
.await
.unwrap();
// Verify session is stopped (not deleted)
let forwards = trcaa_lib::commands::kube::list_port_forwards(State::new(&state))
.await
.unwrap();
assert_eq!(forwards.len(), 1);
assert_eq!(forwards[0].status, "Stopped");
}
#[tokio::test]
async fn test_port_forward_delete_removes_session() {
let state = setup_test_state();
// Add cluster
let kubeconfig = r#"
apiVersion: v1
kind: Config
clusters:
- cluster:
server: https://k8s.example.com:6443
name: production
contexts:
- context:
cluster: production
user: admin
name: prod-context
users:
- name: admin
user:
token: test-token
"#;
trcaa_lib::commands::kube::add_cluster(
"cluster-1".to_string(),
"Production".to_string(),
kubeconfig.to_string(),
State::new(&state),
)
.await
.unwrap();
// Start port forward
let request = trcaa_lib::commands::kube::PortForwardRequest {
cluster_id: "cluster-1".to_string(),
namespace: "default".to_string(),
pod: "nginx-pod".to_string(),
container_port: 80,
local_port: 0,
};
let start_result = trcaa_lib::commands::kube::start_port_forward(request, State::new(&state))
.await
.unwrap();
// Delete port forward
trcaa_lib::commands::kube::delete_port_forward(start_result.id.clone(), State::new(&state))
.await
.unwrap();
// Verify session is deleted
let forwards = trcaa_lib::commands::kube::list_port_forwards(State::new(&state))
.await
.unwrap();
assert!(forwards.is_empty());
}

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect, useRef } from "react"; import React, { useState, useEffect } from "react";
import { Routes, Route, NavLink, useLocation } from "react-router-dom"; import { Routes, Route, NavLink, useLocation } from "react-router-dom";
import { import {
Home, Home,
@ -17,7 +17,7 @@ import {
FileCode, FileCode,
} from "lucide-react"; } from "lucide-react";
import { useSettingsStore } from "@/stores/settingsStore"; import { useSettingsStore } from "@/stores/settingsStore";
import { getAppVersionCmd, loadAiProvidersCmd, testProviderConnectionCmd, shutdownPortForwardsCmd } from "@/lib/tauriCommands"; import { getAppVersionCmd, loadAiProvidersCmd, testProviderConnectionCmd } from "@/lib/tauriCommands";
import Dashboard from "@/pages/Dashboard"; import Dashboard from "@/pages/Dashboard";
import NewIssue from "@/pages/NewIssue"; import NewIssue from "@/pages/NewIssue";
@ -56,25 +56,12 @@ export default function App() {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const [appVersion, setAppVersion] = useState(""); const [appVersion, setAppVersion] = useState("");
const { theme, setTheme, setProviders, getActiveProvider } = useSettingsStore(); const { theme, setTheme, setProviders, getActiveProvider } = useSettingsStore();
const cleanupDone = useRef(false);
void useLocation(); void useLocation();
useEffect(() => { useEffect(() => {
getAppVersionCmd().then(setAppVersion).catch(() => {}); getAppVersionCmd().then(setAppVersion).catch(() => {});
}, []); }, []);
// Cleanup port forwards on app unmount
useEffect(() => {
return () => {
if (!cleanupDone.current) {
cleanupDone.current = true;
void shutdownPortForwardsCmd().catch((err) => {
console.error("Failed to shutdown port forwards:", err);
});
}
};
}, []);
// Load providers and auto-test active provider on startup // Load providers and auto-test active provider on startup
useEffect(() => { useEffect(() => {
const initializeProviders = async () => { const initializeProviders = async () => {

View File

@ -1,7 +1,8 @@
import React from "react"; import React from "react";
import { Trash2, Plus, Server } from "lucide-react"; import { Trash2, Plus, Server, Activity } from "lucide-react";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import type { ClusterInfo } from "@/lib/tauriCommands"; import type { ClusterInfo } from "@/lib/tauriCommands";
import { removeClusterCmd } from "@/lib/tauriCommands";
interface ClusterListProps { interface ClusterListProps {
clusters: ClusterInfo[]; clusters: ClusterInfo[];

View File

@ -1,4 +1,4 @@
import React, { useState, useEffect } from "react"; import React, { useState } from "react";
import { X, Loader2 } from "lucide-react"; import { X, Loader2 } from "lucide-react";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import type { PortForwardResponse } from "@/lib/tauriCommands"; import type { PortForwardResponse } from "@/lib/tauriCommands";
@ -20,14 +20,14 @@ export function PortForwardForm({ isOpen, onClose, onStart }: PortForwardFormPro
const [error, setError] = useState(""); const [error, setError] = useState("");
const [clusters, setClusters] = useState<{ id: string; name: string }[]>([]); const [clusters, setClusters] = useState<{ id: string; name: string }[]>([]);
useEffect(() => { if (!isOpen) return null;
React.useEffect(() => {
if (isOpen) { if (isOpen) {
loadClusters(); loadClusters();
} }
}, [isOpen]); }, [isOpen]);
if (!isOpen) return null;
const loadClusters = async () => { const loadClusters = async () => {
try { try {
const clusters = await listClustersCmd(); const clusters = await listClustersCmd();

View File

@ -2,6 +2,7 @@ import React from "react";
import { Trash2, Plus, Activity } from "lucide-react"; import { Trash2, Plus, Activity } from "lucide-react";
import { Button } from "@/components/ui"; import { Button } from "@/components/ui";
import type { PortForwardResponse } from "@/lib/tauriCommands"; import type { PortForwardResponse } from "@/lib/tauriCommands";
import { stopPortForwardCmd } from "@/lib/tauriCommands";
interface PortForwardListProps { interface PortForwardListProps {
portForwards: PortForwardResponse[]; portForwards: PortForwardResponse[];
@ -94,9 +95,9 @@ export function PortForwardList({ portForwards, onStart, onStop, onDelete }: Por
Pod: {pf.pod} Pod: {pf.pod}
</p> </p>
<div className="flex items-center gap-2 text-sm text-muted-foreground"> <div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>Container Ports: {pf.container_ports.join(", ")}</span> <span>Container Port: {pf.container_port}</span>
<span className="text-gray-300 dark:text-gray-600">|</span> <span className="text-gray-300 dark:text-gray-600">|</span>
<span>Local Ports: {pf.local_ports.some(p => p > 0) ? pf.local_ports.join(", ") : "pending"}</span> <span>Local Port: {pf.local_port > 0 ? pf.local_port : "pending"}</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@ -753,7 +753,6 @@ export interface PortForwardRequest {
namespace: string; namespace: string;
pod: string; pod: string;
container_port: number; container_port: number;
local_port?: number;
} }
export interface PortForwardResponse { export interface PortForwardResponse {
@ -761,28 +760,11 @@ export interface PortForwardResponse {
cluster_id: string; cluster_id: string;
namespace: string; namespace: string;
pod: string; pod: string;
container_ports: number[]; container_port: number;
local_ports: number[]; local_port: number;
status: string; status: string;
} }
export interface PodInfo {
name: string;
status: string;
ready: string;
age: string;
}
export interface ClusterConnectionState {
type: "Connected" | "Disconnected";
error?: string;
}
export interface ClusterConnectionStatus {
status: ClusterConnectionState;
context: string;
}
// ─── Kubernetes Management Commands ─────────────────────────────────────────── // ─── Kubernetes Management Commands ───────────────────────────────────────────
export const addClusterCmd = (id: string, name: string, kubeconfigContent: string) => export const addClusterCmd = (id: string, name: string, kubeconfigContent: string) =>
@ -805,12 +787,3 @@ export const deletePortForwardCmd = (id: string) =>
export const listPortForwardsCmd = () => export const listPortForwardsCmd = () =>
invoke<PortForwardResponse[]>("list_port_forwards"); invoke<PortForwardResponse[]>("list_port_forwards");
export const shutdownPortForwardsCmd = () =>
invoke<void>("shutdown_port_forwards");
export const testClusterConnectionCmd = (clusterId: string) =>
invoke<ClusterConnectionStatus>("test_cluster_connection", { clusterId });
export const discoverPodsCmd = (clusterId: string, namespace: string) =>
invoke<PodInfo[]>("discover_pods", { clusterId, namespace });

View File

@ -1,4 +1,5 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import { Server, Activity } from "lucide-react";
import { ClusterList } from "@/components/Kubernetes/ClusterList"; import { ClusterList } from "@/components/Kubernetes/ClusterList";
import { PortForwardList } from "@/components/Kubernetes/PortForwardList"; import { PortForwardList } from "@/components/Kubernetes/PortForwardList";
import { AddClusterModal } from "@/components/Kubernetes/AddClusterModal"; import { AddClusterModal } from "@/components/Kubernetes/AddClusterModal";

View File

@ -5,8 +5,8 @@ import * as tauriCommands from "@/lib/tauriCommands";
// Mock Tauri invoke // Mock Tauri invoke
vi.mock("@tauri-apps/api/core"); vi.mock("@tauri-apps/api/core");
type MockedFunction<T = (...args: unknown[]) => unknown> = T & { type MockedFunction<T = (...args: any[]) => any> = T & {
mockResolvedValue: (value: unknown) => void; mockResolvedValue: (value: any) => void;
mockRejectedValue: (error: Error) => void; mockRejectedValue: (error: Error) => void;
}; };