feat: Implement platform service providers for MinIO and MongoDB
- Added base interface and abstract class for platform service providers. - Created MinIOProvider class for S3-compatible storage with deployment, provisioning, and deprovisioning functionalities. - Implemented MongoDBProvider class for MongoDB service with similar capabilities. - Introduced error handling utilities for better error management. - Developed TokensComponent for managing registry tokens in the UI, including creation, deletion, and display of tokens.
This commit is contained in:
253
readme.hints.md
253
readme.hints.md
@@ -1,253 +0,0 @@
|
|||||||
# Onebox - Project Hints
|
|
||||||
|
|
||||||
## Architecture Overview
|
|
||||||
|
|
||||||
Onebox is a Deno-based self-hosted container platform that compiles to standalone binaries. It follows the same architectural patterns as nupst and spark projects.
|
|
||||||
|
|
||||||
### Core Components
|
|
||||||
|
|
||||||
1. **OneboxDatabase** (`ts/onebox.classes.database.ts`)
|
|
||||||
- SQLite-based storage
|
|
||||||
- Tables: services, registries, nginx_configs, ssl_certificates, dns_records, metrics, logs, users, settings
|
|
||||||
- Migration system for schema updates
|
|
||||||
|
|
||||||
2. **OneboxDockerManager** (`ts/onebox.classes.docker.ts`)
|
|
||||||
- Docker API integration via @apiclient.xyz/docker
|
|
||||||
- Container lifecycle management
|
|
||||||
- Network management (onebox-network bridge)
|
|
||||||
- Stats collection and logging
|
|
||||||
|
|
||||||
3. **OneboxServicesManager** (`ts/onebox.classes.services.ts`)
|
|
||||||
- High-level service orchestration
|
|
||||||
- Coordinates Docker + Nginx + DNS + SSL
|
|
||||||
- Service deployment workflow
|
|
||||||
|
|
||||||
4. **OneboxRegistriesManager** (`ts/onebox.classes.registries.ts`)
|
|
||||||
- Docker registry authentication
|
|
||||||
- Credential storage (encrypted)
|
|
||||||
- Auto-login on daemon start
|
|
||||||
|
|
||||||
5. **OneboxNginxManager** (`ts/onebox.classes.nginx.ts`)
|
|
||||||
- Nginx reverse proxy configuration
|
|
||||||
- Config file generation
|
|
||||||
- SSL enablement
|
|
||||||
- Reload and testing
|
|
||||||
|
|
||||||
6. **OneboxDnsManager** (`ts/onebox.classes.dns.ts`)
|
|
||||||
- Cloudflare API integration
|
|
||||||
- Automatic A record creation
|
|
||||||
- DNS sync and verification
|
|
||||||
|
|
||||||
7. **OneboxSslManager** (`ts/onebox.classes.ssl.ts`)
|
|
||||||
- Let's Encrypt integration via certbot
|
|
||||||
- Certificate issuance and renewal
|
|
||||||
- Expiry monitoring
|
|
||||||
|
|
||||||
8. **OneboxDaemon** (`ts/onebox.classes.daemon.ts`)
|
|
||||||
- Background monitoring loop
|
|
||||||
- Metrics collection (every 60s by default)
|
|
||||||
- SSL certificate renewal checks
|
|
||||||
- Service health monitoring
|
|
||||||
- Systemd integration
|
|
||||||
|
|
||||||
9. **OneboxHttpServer** (`ts/onebox.classes.httpserver.ts`)
|
|
||||||
- REST API endpoints
|
|
||||||
- Static file serving (for Angular UI)
|
|
||||||
- Authentication middleware
|
|
||||||
|
|
||||||
10. **Onebox** (`ts/onebox.classes.onebox.ts`)
|
|
||||||
- Main coordinator class
|
|
||||||
- Initializes all components
|
|
||||||
- Provides unified API
|
|
||||||
|
|
||||||
### CLI Structure
|
|
||||||
|
|
||||||
- `onebox service` - Service management
|
|
||||||
- `onebox registry` - Registry credentials
|
|
||||||
- `onebox dns` - DNS records
|
|
||||||
- `onebox ssl` - SSL certificates
|
|
||||||
- `onebox nginx` - Nginx control
|
|
||||||
- `onebox daemon` - Systemd daemon
|
|
||||||
- `onebox config` - Settings management
|
|
||||||
- `onebox status` - System status
|
|
||||||
|
|
||||||
### Deployment Workflow
|
|
||||||
|
|
||||||
1. User runs: `onebox service add myapp --image nginx --domain app.example.com`
|
|
||||||
2. Service record created in database
|
|
||||||
3. Docker image pulled from registry
|
|
||||||
4. Container created and started
|
|
||||||
5. Nginx config generated and reloaded
|
|
||||||
6. DNS record created (if configured)
|
|
||||||
7. SSL certificate obtained (if configured)
|
|
||||||
8. Service is live!
|
|
||||||
|
|
||||||
### Configuration
|
|
||||||
|
|
||||||
Settings stored in database (settings table):
|
|
||||||
- `cloudflareAPIKey` - Cloudflare API key
|
|
||||||
- `cloudflareEmail` - Cloudflare email
|
|
||||||
- `cloudflareZoneID` - Cloudflare zone ID
|
|
||||||
- `acmeEmail` - Let's Encrypt email
|
|
||||||
- `serverIP` - Server public IP
|
|
||||||
- `nginxConfigDir` - Custom nginx config directory
|
|
||||||
- `httpPort` - HTTP server port (default: 3000)
|
|
||||||
- `metricsInterval` - Metrics collection interval (default: 60000ms)
|
|
||||||
- `logRetentionDays` - Log retention period
|
|
||||||
|
|
||||||
### Data Locations
|
|
||||||
|
|
||||||
- Database: `/var/lib/onebox/onebox.db`
|
|
||||||
- Nginx configs: `/etc/nginx/sites-available/onebox-*`
|
|
||||||
- SSL certificates: `/etc/letsencrypt/live/<domain>/`
|
|
||||||
- Certbot webroot: `/var/www/certbot`
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Running Locally
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Development mode
|
|
||||||
deno task dev
|
|
||||||
|
|
||||||
# Run tests
|
|
||||||
deno task test
|
|
||||||
|
|
||||||
# Compile all binaries
|
|
||||||
deno task compile
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding a New Feature
|
|
||||||
|
|
||||||
1. Create new class in `ts/onebox.classes.<name>.ts`
|
|
||||||
2. Add to main Onebox class in `ts/onebox.classes.onebox.ts`
|
|
||||||
3. Add CLI commands in `ts/onebox.cli.ts`
|
|
||||||
4. Add API endpoints in `ts/onebox.classes.httpserver.ts`
|
|
||||||
5. Update types in `ts/onebox.types.ts`
|
|
||||||
6. Add tests in `test/`
|
|
||||||
7. Update documentation
|
|
||||||
|
|
||||||
### Database Migrations
|
|
||||||
|
|
||||||
Add migration logic in `OneboxDatabase.runMigrations()`:
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
if (currentVersion === 1) {
|
|
||||||
this.db.query('ALTER TABLE services ADD COLUMN new_field TEXT');
|
|
||||||
this.setMigrationVersion(2);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## TODO
|
|
||||||
|
|
||||||
### Core Functionality (Complete ✓)
|
|
||||||
- [x] Database layer with SQLite
|
|
||||||
- [x] Docker integration
|
|
||||||
- [x] Service management
|
|
||||||
- [x] Registry authentication
|
|
||||||
- [x] Nginx reverse proxy
|
|
||||||
- [x] DNS management (Cloudflare)
|
|
||||||
- [x] SSL certificates (Let's Encrypt)
|
|
||||||
- [x] Background daemon
|
|
||||||
- [x] HTTP API server
|
|
||||||
- [x] CLI commands
|
|
||||||
- [x] Build system
|
|
||||||
|
|
||||||
### Next Steps
|
|
||||||
- [ ] Angular UI implementation
|
|
||||||
- Dashboard with service cards
|
|
||||||
- Service deployment form
|
|
||||||
- Logs viewer
|
|
||||||
- Metrics charts
|
|
||||||
- Settings page
|
|
||||||
- [ ] Authentication system (JWT)
|
|
||||||
- Login endpoint
|
|
||||||
- Token validation middleware
|
|
||||||
- Password hashing (bcrypt)
|
|
||||||
- [ ] WebSocket support for real-time logs/metrics
|
|
||||||
- [ ] Health checks for services
|
|
||||||
- [ ] Backup/restore functionality
|
|
||||||
- [ ] Multi-server support
|
|
||||||
- [ ] Load balancing
|
|
||||||
- [ ] Service templates/blueprints
|
|
||||||
|
|
||||||
### Testing
|
|
||||||
- [ ] Unit tests for all managers
|
|
||||||
- [ ] Integration tests for deployment workflow
|
|
||||||
- [ ] Mock Docker API for tests
|
|
||||||
- [ ] Database migration tests
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
- [ ] API documentation (OpenAPI/Swagger)
|
|
||||||
- [ ] Architecture diagram
|
|
||||||
- [ ] Deployment guide
|
|
||||||
- [ ] Troubleshooting guide
|
|
||||||
- [ ] Video tutorial
|
|
||||||
|
|
||||||
## Common Issues
|
|
||||||
|
|
||||||
### Docker Connection
|
|
||||||
If Docker commands fail, ensure:
|
|
||||||
- Docker daemon is running: `systemctl status docker`
|
|
||||||
- User has Docker permissions: `usermod -aG docker $USER`
|
|
||||||
- Socket exists: `ls -l /var/run/docker.sock`
|
|
||||||
|
|
||||||
### Nginx Issues
|
|
||||||
If nginx fails to reload:
|
|
||||||
- Check syntax: `onebox nginx test`
|
|
||||||
- Check logs: `journalctl -u nginx -n 50`
|
|
||||||
- Verify config files exist in `/etc/nginx/sites-available/`
|
|
||||||
|
|
||||||
### SSL Certificate Issues
|
|
||||||
If certbot fails:
|
|
||||||
- Verify domain DNS points to server
|
|
||||||
- Check port 80 is accessible
|
|
||||||
- Verify nginx is serving `.well-known/acme-challenge/`
|
|
||||||
- Check certbot logs: `journalctl -u certbot -n 50`
|
|
||||||
|
|
||||||
### Cloudflare DNS Issues
|
|
||||||
If DNS records aren't created:
|
|
||||||
- Verify API credentials: `onebox config show`
|
|
||||||
- Check zone ID matches your domain
|
|
||||||
- Verify API key has DNS edit permissions
|
|
||||||
|
|
||||||
## Dependencies
|
|
||||||
|
|
||||||
### Deno Packages
|
|
||||||
- `@std/path` - Path utilities
|
|
||||||
- `@std/fs` - Filesystem operations
|
|
||||||
- `@std/http` - HTTP server
|
|
||||||
- `@db/sqlite` - SQLite database
|
|
||||||
|
|
||||||
### NPM Packages (via Deno)
|
|
||||||
- `@push.rocks/smartdaemon` - Systemd integration
|
|
||||||
- `@apiclient.xyz/docker` - Docker API client
|
|
||||||
- `@apiclient.xyz/cloudflare` - Cloudflare API client
|
|
||||||
- `@push.rocks/smartacme` - ACME/Let's Encrypt
|
|
||||||
|
|
||||||
### System Dependencies
|
|
||||||
- `docker` - Container runtime
|
|
||||||
- `nginx` - Reverse proxy
|
|
||||||
- `certbot` - SSL certificates
|
|
||||||
- `systemd` - Service management
|
|
||||||
|
|
||||||
## Release Process
|
|
||||||
|
|
||||||
1. Update version in `deno.json`
|
|
||||||
2. Update `changelog.md`
|
|
||||||
3. Commit changes
|
|
||||||
4. Run `deno task compile` to build all binaries
|
|
||||||
5. Test binaries on each platform
|
|
||||||
6. Create git tag: `git tag v1.0.0`
|
|
||||||
7. Push tag: `git push origin v1.0.0`
|
|
||||||
8. Create Gitea release and upload binaries
|
|
||||||
9. Publish to npm: `pnpm publish`
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Onebox requires root privileges for nginx, Docker, and port binding
|
|
||||||
- Default admin password should be changed immediately after installation
|
|
||||||
- Use `--debug` flag for verbose logging
|
|
||||||
- All Docker containers are on the `onebox-network` bridge
|
|
||||||
- Metrics are collected every 60 seconds by default
|
|
||||||
- SSL certificates auto-renew 30 days before expiry
|
|
||||||
|
|||||||
177
readme.md
177
readme.md
@@ -14,10 +14,10 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- **Docker Swarm First** - All workloads run as Swarm services, not standalone containers, for built-in orchestration
|
- **Docker Swarm First** - All workloads run as Swarm services, not standalone containers, for built-in orchestration
|
||||||
- **Real-time Everything** - WebSocket-powered live updates for service status, logs, and metrics across all connected clients
|
- **Real-time Everything** - WebSocket-powered live updates for service status, logs, and metrics across all connected clients
|
||||||
- **Single Executable** - Compiles to a standalone binary - just run it, no dependencies
|
- **Single Executable** - Compiles to a standalone binary - just run it, no dependencies
|
||||||
- **Private Registry Included** - Built-in Docker registry with auto-deploy on push
|
- **Private Registry Included** - Built-in Docker registry with token-based auth and auto-deploy on push
|
||||||
- **Zero Config SSL** - Automatic Let's Encrypt certificates with hot-reload
|
- **Zero Config SSL** - Automatic Let's Encrypt certificates with hot-reload
|
||||||
- **Cloudflare Integration** - Automatic DNS record management
|
- **Cloudflare Integration** - Automatic DNS record management and zone synchronization
|
||||||
- **Modern Stack** - Deno runtime + SQLite database + Angular 18 UI
|
- **Modern Stack** - Deno runtime + SQLite database + Angular 19 UI
|
||||||
|
|
||||||
## Features ✨
|
## Features ✨
|
||||||
|
|
||||||
@@ -25,13 +25,13 @@ For reporting bugs, issues, or security vulnerabilities, please visit [community
|
|||||||
- 🐳 **Docker Swarm Management** - Deploy, scale, and orchestrate services with Swarm mode
|
- 🐳 **Docker Swarm Management** - Deploy, scale, and orchestrate services with Swarm mode
|
||||||
- 🌐 **Native Reverse Proxy** - Deno-based HTTP/HTTPS proxy with dynamic routing from database
|
- 🌐 **Native Reverse Proxy** - Deno-based HTTP/HTTPS proxy with dynamic routing from database
|
||||||
- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration with hot-reload and renewal monitoring
|
- 🔒 **Automatic SSL Certificates** - Let's Encrypt integration with hot-reload and renewal monitoring
|
||||||
- ☁️ **Cloudflare DNS Integration** - Automatic DNS record creation and synchronization
|
- ☁️ **Cloudflare DNS Integration** - Automatic DNS record creation and zone synchronization
|
||||||
- 📦 **Built-in Registry** - Private Docker registry with per-service tokens and auto-update
|
- 📦 **Built-in Registry** - Private Docker registry with per-service tokens and auto-update
|
||||||
- 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events
|
- 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events
|
||||||
|
|
||||||
### Monitoring & Management
|
### Monitoring & Management
|
||||||
- 📊 **Metrics Collection** - Historical CPU, memory, and network stats (every 60s)
|
- 📊 **Metrics Collection** - Historical CPU, memory, and network stats (every 60s)
|
||||||
- 📝 **Centralized Logging** - Container logs with retention policies
|
- 📝 **Centralized Logging** - Container logs with streaming and retention policies
|
||||||
- 🎨 **Angular Web UI** - Modern, responsive interface with real-time updates
|
- 🎨 **Angular Web UI** - Modern, responsive interface with real-time updates
|
||||||
- 👥 **Multi-user Support** - Role-based access control (admin/user)
|
- 👥 **Multi-user Support** - Role-based access control (admin/user)
|
||||||
- 💾 **SQLite Database** - Embedded, zero-configuration storage
|
- 💾 **SQLite Database** - Embedded, zero-configuration storage
|
||||||
@@ -98,7 +98,7 @@ Onebox is built with modern technologies for performance and developer experienc
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────┐
|
||||||
│ Angular 18 Web UI │
|
│ Angular 19 Web UI │
|
||||||
│ (Real-time WebSocket Updates) │
|
│ (Real-time WebSocket Updates) │
|
||||||
└─────────────────┬───────────────────────────────┘
|
└─────────────────┬───────────────────────────────┘
|
||||||
│ HTTP/WS
|
│ HTTP/WS
|
||||||
@@ -121,13 +121,15 @@ Onebox is built with modern technologies for performance and developer experienc
|
|||||||
|
|
||||||
### Core Components
|
### Core Components
|
||||||
|
|
||||||
- **Deno Runtime** - Modern TypeScript with built-in security
|
| Component | Description |
|
||||||
- **Native Reverse Proxy** - Custom HTTP/HTTPS proxy with TLS SNI support
|
|-----------|-------------|
|
||||||
- **Docker Swarm** - Container orchestration (NOT standalone containers)
|
| **Deno Runtime** | Modern TypeScript with built-in security |
|
||||||
- **SQLite Database** - Configuration, metrics, and user data
|
| **Native Reverse Proxy** | Custom HTTP/HTTPS proxy with TLS SNI support |
|
||||||
- **WebSocket Server** - Real-time bidirectional communication
|
| **Docker Swarm** | Container orchestration (NOT standalone containers) |
|
||||||
- **Let's Encrypt** - Automatic SSL certificate management
|
| **SQLite Database** | Configuration, metrics, and user data |
|
||||||
- **Cloudflare API** - DNS record automation
|
| **WebSocket Server** | Real-time bidirectional communication |
|
||||||
|
| **Let's Encrypt** | Automatic SSL certificate management |
|
||||||
|
| **Cloudflare API** | DNS record automation |
|
||||||
|
|
||||||
## CLI Reference 📖
|
## CLI Reference 📖
|
||||||
|
|
||||||
@@ -244,9 +246,11 @@ onebox status
|
|||||||
|
|
||||||
### Data Locations
|
### Data Locations
|
||||||
|
|
||||||
- **Database**: `./onebox.db` (or custom path)
|
| Data | Location |
|
||||||
- **SSL Certificates**: Managed by CertManager
|
|------|----------|
|
||||||
- **Registry Data**: `./.nogit/registry-data`
|
| **Database** | `./onebox.db` (or custom path) |
|
||||||
|
| **SSL Certificates** | Managed by CertManager |
|
||||||
|
| **Registry Data** | `./.nogit/registry-data` |
|
||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
@@ -270,8 +274,8 @@ ONEBOX_DEBUG=true
|
|||||||
git clone https://code.foss.global/serve.zone/onebox
|
git clone https://code.foss.global/serve.zone/onebox
|
||||||
cd onebox
|
cd onebox
|
||||||
|
|
||||||
# Install dependencies (Deno handles this automatically)
|
# Start development server (auto-restart on changes)
|
||||||
deno task dev
|
pnpm run watch
|
||||||
```
|
```
|
||||||
|
|
||||||
### Tasks
|
### Tasks
|
||||||
@@ -295,38 +299,93 @@ deno task compile
|
|||||||
```
|
```
|
||||||
onebox/
|
onebox/
|
||||||
├── ts/
|
├── ts/
|
||||||
│ ├── classes/ # Core implementations
|
│ ├── classes/ # Core implementations
|
||||||
│ │ ├── onebox.ts # Main coordinator
|
│ │ ├── onebox.ts # Main coordinator
|
||||||
│ │ ├── reverseproxy.ts # Native HTTP/HTTPS proxy
|
│ │ ├── reverseproxy.ts # Native HTTP/HTTPS proxy
|
||||||
│ │ ├── docker.ts # Docker Swarm API
|
│ │ ├── docker.ts # Docker Swarm API
|
||||||
│ │ ├── database.ts # SQLite storage
|
│ │ ├── database.ts # SQLite storage
|
||||||
│ │ ├── httpserver.ts # REST API + WebSocket
|
│ │ ├── httpserver.ts # REST API + WebSocket
|
||||||
│ │ ├── services.ts # Service orchestration
|
│ │ ├── services.ts # Service orchestration
|
||||||
│ │ ├── certmanager.ts # SSL certificate management
|
│ │ ├── certmanager.ts # SSL certificate management
|
||||||
│ │ ├── registry.ts # Built-in Docker registry
|
│ │ ├── cert-requirement-manager.ts # Certificate requirements
|
||||||
│ │ └── ...
|
│ │ ├── ssl.ts # SSL utilities
|
||||||
│ ├── cli.ts # CLI router
|
│ │ ├── registry.ts # Built-in Docker registry
|
||||||
│ ├── types.ts # TypeScript interfaces
|
│ │ ├── registries.ts # External registry management
|
||||||
│ └── plugins.ts # Dependency imports
|
│ │ ├── dns.ts # DNS record management
|
||||||
├── ui/ # Angular web interface
|
│ │ ├── cloudflare-sync.ts # Cloudflare zone sync
|
||||||
├── test/ # Test files
|
│ │ ├── daemon.ts # Systemd daemon management
|
||||||
├── mod.ts # Main entry point
|
│ │ └── apiclient.ts # API client utilities
|
||||||
└── deno.json # Deno configuration
|
│ ├── cli.ts # CLI router
|
||||||
|
│ ├── types.ts # TypeScript interfaces
|
||||||
|
│ ├── logging.ts # Logging utilities
|
||||||
|
│ └── plugins.ts # Dependency imports
|
||||||
|
├── ui/ # Angular web interface
|
||||||
|
├── test/ # Test files
|
||||||
|
├── mod.ts # Main entry point
|
||||||
|
└── deno.json # Deno configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
### API Endpoints
|
### API Endpoints
|
||||||
|
|
||||||
The HTTP server exposes the following endpoints:
|
The HTTP server exposes a comprehensive REST API:
|
||||||
|
|
||||||
- `POST /api/auth/login` - User authentication (returns token)
|
#### Authentication
|
||||||
- `GET /api/status` - System status (requires auth)
|
| Method | Endpoint | Description |
|
||||||
- `GET /api/services` - List all services (requires auth)
|
|--------|----------|-------------|
|
||||||
- `POST /api/services` - Create service (requires auth)
|
| `POST` | `/api/auth/login` | User authentication (returns token) |
|
||||||
- `PUT /api/services/:id` - Update service (requires auth)
|
|
||||||
- `DELETE /api/services/:id` - Delete service (requires auth)
|
|
||||||
- `GET /api/ws` - WebSocket connection for real-time updates
|
|
||||||
|
|
||||||
See `ts/classes/httpserver.ts` for complete API documentation.
|
#### Services
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/api/services` | List all services |
|
||||||
|
| `POST` | `/api/services` | Create/deploy service |
|
||||||
|
| `GET` | `/api/services/:name` | Get service details |
|
||||||
|
| `PUT` | `/api/services/:name` | Update service |
|
||||||
|
| `DELETE` | `/api/services/:name` | Delete service |
|
||||||
|
| `POST` | `/api/services/:name/start` | Start service |
|
||||||
|
| `POST` | `/api/services/:name/stop` | Stop service |
|
||||||
|
| `POST` | `/api/services/:name/restart` | Restart service |
|
||||||
|
| `GET` | `/api/services/:name/logs` | Get service logs |
|
||||||
|
| `WS` | `/api/services/:name/logs/stream` | Stream logs via WebSocket |
|
||||||
|
|
||||||
|
#### SSL Certificates
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/api/ssl/list` | List all certificates |
|
||||||
|
| `GET` | `/api/ssl/:domain` | Get certificate details |
|
||||||
|
| `POST` | `/api/ssl/obtain` | Request new certificate |
|
||||||
|
| `POST` | `/api/ssl/:domain/renew` | Force renew certificate |
|
||||||
|
|
||||||
|
#### Domains
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/api/domains` | List all domains |
|
||||||
|
| `GET` | `/api/domains/:domain` | Get domain details |
|
||||||
|
| `POST` | `/api/domains/sync` | Sync domains from Cloudflare |
|
||||||
|
|
||||||
|
#### DNS Records
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/api/dns` | List DNS records |
|
||||||
|
| `POST` | `/api/dns` | Create DNS record |
|
||||||
|
| `DELETE` | `/api/dns/:domain` | Delete DNS record |
|
||||||
|
| `POST` | `/api/dns/sync` | Sync DNS from Cloudflare |
|
||||||
|
|
||||||
|
#### Registry
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/api/registry/tags/:service` | Get registry tags for service |
|
||||||
|
| `GET` | `/api/registry/tokens` | List registry tokens |
|
||||||
|
| `POST` | `/api/registry/tokens` | Create registry token |
|
||||||
|
| `DELETE` | `/api/registry/tokens/:id` | Delete registry token |
|
||||||
|
|
||||||
|
#### System
|
||||||
|
| Method | Endpoint | Description |
|
||||||
|
|--------|----------|-------------|
|
||||||
|
| `GET` | `/api/status` | System status |
|
||||||
|
| `GET` | `/api/settings` | Get settings |
|
||||||
|
| `PUT` | `/api/settings` | Update settings |
|
||||||
|
| `WS` | `/api/ws` | WebSocket for real-time updates |
|
||||||
|
|
||||||
### WebSocket Messages
|
### WebSocket Messages
|
||||||
|
|
||||||
@@ -374,6 +433,19 @@ docker push localhost:4000/myapp:latest
|
|||||||
# Service automatically updates! 🎉
|
# Service automatically updates! 🎉
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Registry Token Management
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create a CI/CD token via API
|
||||||
|
curl -X POST http://localhost:3000/api/registry/tokens \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-d '{"name": "github-actions", "type": "ci", "scope": ["myapp"], "expiresIn": "90d"}'
|
||||||
|
|
||||||
|
# Use token for docker login
|
||||||
|
docker login localhost:4000 -u ci -p <token>
|
||||||
|
```
|
||||||
|
|
||||||
### Cloudflare DNS Integration
|
### Cloudflare DNS Integration
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -388,16 +460,19 @@ onebox service add myapp \
|
|||||||
--domain myapp.example.com
|
--domain myapp.example.com
|
||||||
|
|
||||||
# DNS record is automatically created!
|
# DNS record is automatically created!
|
||||||
|
|
||||||
|
# Sync all domains from Cloudflare
|
||||||
|
onebox dns sync
|
||||||
```
|
```
|
||||||
|
|
||||||
### SSL Certificate Management
|
### SSL Certificate Management
|
||||||
|
|
||||||
SSL certificates are automatically obtained and renewed:
|
SSL certificates are automatically obtained and renewed:
|
||||||
|
|
||||||
- Certificates are requested when a service with a domain is deployed
|
- ✅ Certificates are requested when a service with a domain is deployed
|
||||||
- Renewal happens automatically 30 days before expiry
|
- ✅ Renewal happens automatically 30 days before expiry
|
||||||
- Certificates are hot-reloaded without downtime
|
- ✅ Certificates are hot-reloaded without downtime
|
||||||
- Force renewal: `onebox ssl force-renew <domain>`
|
- ✅ Force renewal: `onebox ssl force-renew <domain>`
|
||||||
|
|
||||||
### Monitoring and Metrics
|
### Monitoring and Metrics
|
||||||
|
|
||||||
@@ -449,9 +524,9 @@ onebox ssl force-renew yourdomain.com
|
|||||||
|
|
||||||
### WebSocket Connection Issues
|
### WebSocket Connection Issues
|
||||||
|
|
||||||
- Ensure firewall allows WebSocket connections
|
- ✅ Ensure firewall allows WebSocket connections
|
||||||
- Check browser console for connection errors
|
- ✅ Check browser console for connection errors
|
||||||
- Verify `/api/ws` endpoint is accessible
|
- ✅ Verify `/api/ws` endpoint is accessible
|
||||||
|
|
||||||
### Service Not Starting
|
### Service Not Starting
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
|
import { getErrorMessage } from '../utils/error.ts';
|
||||||
import { OneboxDatabase } from './database.ts';
|
import { OneboxDatabase } from './database.ts';
|
||||||
|
|
||||||
export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
||||||
@@ -27,7 +28,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
|||||||
await Deno.mkdir(this.certBasePath, { recursive: true });
|
await Deno.mkdir(this.certBasePath, { recursive: true });
|
||||||
logger.info(`Certificate manager initialized (path: ${this.certBasePath})`);
|
logger.info(`Certificate manager initialized (path: ${this.certBasePath})`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to initialize certificate manager: ${error.message}`);
|
logger.error(`Failed to initialize certificate manager: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +57,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
|||||||
|
|
||||||
return cert;
|
return cert;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to retrieve certificate for ${domainName}: ${error.message}`);
|
logger.warn(`Failed to retrieve certificate for ${domainName}: ${getErrorMessage(error)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
|||||||
|
|
||||||
logger.success(`Certificate stored for ${domain}`);
|
logger.success(`Certificate stored for ${domain}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to store certificate for ${cert.domainName}: ${error.message}`);
|
logger.error(`Failed to store certificate for ${cert.domainName}: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
|||||||
try {
|
try {
|
||||||
await Deno.remove(domainPath, { recursive: true });
|
await Deno.remove(domainPath, { recursive: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to delete PEM files for ${domainName}: ${error.message}`);
|
logger.warn(`Failed to delete PEM files for ${domainName}: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete from database
|
// Delete from database
|
||||||
@@ -137,7 +138,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
|||||||
logger.info(`Certificate deleted for ${domainName}`);
|
logger.info(`Certificate deleted for ${domainName}`);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to delete certificate for ${domainName}: ${error.message}`);
|
logger.error(`Failed to delete certificate for ${domainName}: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -163,7 +164,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
|||||||
|
|
||||||
logger.warn('All certificates wiped');
|
logger.warn('All certificates wiped');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to wipe certificates: ${error.message}`);
|
logger.error(`Failed to wipe certificates: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -175,7 +176,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
|
|||||||
try {
|
try {
|
||||||
return await Deno.readTextFile(path);
|
return await Deno.readTextFile(path);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to read PEM file ${path}: ${error.message}`);
|
throw new Error(`Failed to read PEM file ${path}: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
import * as plugins from '../plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { projectInfo } from '../info.ts';
|
import { projectInfo } from '../info.ts';
|
||||||
|
import { getErrorMessage } from '../utils/error.ts';
|
||||||
import type { Onebox } from './onebox.ts';
|
import type { Onebox } from './onebox.ts';
|
||||||
|
|
||||||
// PID file constants
|
// PID file constants
|
||||||
@@ -72,7 +73,7 @@ export class OneboxDaemon {
|
|||||||
logger.success('Onebox daemon service installed');
|
logger.success('Onebox daemon service installed');
|
||||||
logger.info('Start with: sudo systemctl start smartdaemon_onebox');
|
logger.info('Start with: sudo systemctl start smartdaemon_onebox');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to install daemon service: ${error.message}`);
|
logger.error(`Failed to install daemon service: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -89,7 +90,8 @@ export class OneboxDaemon {
|
|||||||
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
|
this.smartdaemon = new plugins.smartdaemon.SmartDaemon();
|
||||||
}
|
}
|
||||||
|
|
||||||
const service = await this.smartdaemon.getService('onebox');
|
const services = await this.smartdaemon.systemdManager.getServices();
|
||||||
|
const service = services.find(s => s.name === 'onebox');
|
||||||
|
|
||||||
if (service) {
|
if (service) {
|
||||||
await service.stop();
|
await service.stop();
|
||||||
@@ -99,7 +101,7 @@ export class OneboxDaemon {
|
|||||||
|
|
||||||
logger.success('Onebox daemon service uninstalled');
|
logger.success('Onebox daemon service uninstalled');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to uninstall daemon service: ${error.message}`);
|
logger.error(`Failed to uninstall daemon service: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,7 +139,7 @@ export class OneboxDaemon {
|
|||||||
// Keep process alive
|
// Keep process alive
|
||||||
await this.keepAlive();
|
await this.keepAlive();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to start daemon: ${error.message}`);
|
logger.error(`Failed to start daemon: ${getErrorMessage(error)}`);
|
||||||
this.running = false;
|
this.running = false;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
@@ -167,7 +169,7 @@ export class OneboxDaemon {
|
|||||||
|
|
||||||
logger.success('Onebox daemon stopped');
|
logger.success('Onebox daemon stopped');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to stop daemon: ${error.message}`);
|
logger.error(`Failed to stop daemon: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +231,7 @@ export class OneboxDaemon {
|
|||||||
|
|
||||||
logger.debug('Monitoring tick complete');
|
logger.debug('Monitoring tick complete');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Monitoring tick failed: ${error.message}`);
|
logger.error(`Monitoring tick failed: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -257,12 +259,12 @@ export class OneboxDaemon {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.debug(`Failed to collect metrics for ${service.name}: ${error.message}`);
|
logger.debug(`Failed to collect metrics for ${service.name}: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to collect metrics: ${error.message}`);
|
logger.error(`Failed to collect metrics: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -277,7 +279,7 @@ export class OneboxDaemon {
|
|||||||
|
|
||||||
await this.oneboxRef.ssl.renewExpiring();
|
await this.oneboxRef.ssl.renewExpiring();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to check SSL expiration: ${error.message}`);
|
logger.error(`Failed to check SSL expiration: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -288,7 +290,7 @@ export class OneboxDaemon {
|
|||||||
try {
|
try {
|
||||||
await this.oneboxRef.certRequirementManager.processPendingRequirements();
|
await this.oneboxRef.certRequirementManager.processPendingRequirements();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to process cert requirements: ${error.message}`);
|
logger.error(`Failed to process cert requirements: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -299,7 +301,7 @@ export class OneboxDaemon {
|
|||||||
try {
|
try {
|
||||||
await this.oneboxRef.certRequirementManager.checkCertificateRenewal();
|
await this.oneboxRef.certRequirementManager.checkCertificateRenewal();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to check certificate renewal: ${error.message}`);
|
logger.error(`Failed to check certificate renewal: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -310,7 +312,7 @@ export class OneboxDaemon {
|
|||||||
try {
|
try {
|
||||||
await this.oneboxRef.certRequirementManager.cleanupOldCertificates();
|
await this.oneboxRef.certRequirementManager.cleanupOldCertificates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to cleanup old certificates: ${error.message}`);
|
logger.error(`Failed to cleanup old certificates: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +335,7 @@ export class OneboxDaemon {
|
|||||||
await this.oneboxRef.cloudflareDomainSync.syncZones();
|
await this.oneboxRef.cloudflareDomainSync.syncZones();
|
||||||
this.lastDomainSync = now;
|
this.lastDomainSync = now;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to sync Cloudflare domains: ${error.message}`);
|
logger.error(`Failed to sync Cloudflare domains: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,7 +390,7 @@ export class OneboxDaemon {
|
|||||||
this.pidFilePath = FALLBACK_PID_FILE;
|
this.pidFilePath = FALLBACK_PID_FILE;
|
||||||
logger.debug(`PID file written: ${FALLBACK_PID_FILE}`);
|
logger.debug(`PID file written: ${FALLBACK_PID_FILE}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Failed to write PID file: ${error.message}`);
|
logger.warn(`Failed to write PID file: ${getErrorMessage(error)}`);
|
||||||
// Non-fatal - daemon can still run
|
// Non-fatal - daemon can still run
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -402,7 +404,7 @@ export class OneboxDaemon {
|
|||||||
logger.debug(`PID file removed: ${this.pidFilePath}`);
|
logger.debug(`PID file removed: ${this.pidFilePath}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ignore errors - file might not exist
|
// Ignore errors - file might not exist
|
||||||
logger.debug(`Could not remove PID file: ${error.message}`);
|
logger.debug(`Could not remove PID file: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import * as plugins from '../plugins.ts';
|
|||||||
import type {
|
import type {
|
||||||
IService,
|
IService,
|
||||||
IRegistry,
|
IRegistry,
|
||||||
|
IRegistryToken,
|
||||||
INginxConfig,
|
INginxConfig,
|
||||||
ISslCertificate,
|
ISslCertificate,
|
||||||
IDnsRecord,
|
IDnsRecord,
|
||||||
@@ -13,11 +14,22 @@ import type {
|
|||||||
ILogEntry,
|
ILogEntry,
|
||||||
IUser,
|
IUser,
|
||||||
ISetting,
|
ISetting,
|
||||||
|
IPlatformService,
|
||||||
|
IPlatformResource,
|
||||||
|
IPlatformRequirements,
|
||||||
|
TPlatformServiceType,
|
||||||
|
IDomain,
|
||||||
|
ICertificate,
|
||||||
|
ICertRequirement,
|
||||||
} from '../types.ts';
|
} from '../types.ts';
|
||||||
|
|
||||||
|
// Type alias for sqlite bind parameters
|
||||||
|
type BindValue = string | number | bigint | boolean | null | undefined | Uint8Array;
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
|
import { getErrorMessage } from '../utils/error.ts';
|
||||||
|
|
||||||
export class OneboxDatabase {
|
export class OneboxDatabase {
|
||||||
private db: plugins.sqlite.DB | null = null;
|
private db: InstanceType<typeof plugins.sqlite.DB> | null = null;
|
||||||
private dbPath: string;
|
private dbPath: string;
|
||||||
|
|
||||||
constructor(dbPath = './.nogit/onebox.db') {
|
constructor(dbPath = './.nogit/onebox.db') {
|
||||||
@@ -43,7 +55,7 @@ export class OneboxDatabase {
|
|||||||
// Run migrations if needed
|
// Run migrations if needed
|
||||||
await this.runMigrations();
|
await this.runMigrations();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to initialize database: ${error.message}`);
|
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -447,28 +459,40 @@ export class OneboxDatabase {
|
|||||||
|
|
||||||
// 4. Migrate existing ssl_certificates data
|
// 4. Migrate existing ssl_certificates data
|
||||||
// Extract unique base domains from existing certificates
|
// Extract unique base domains from existing certificates
|
||||||
const existingCerts = this.query('SELECT * FROM ssl_certificates');
|
interface OldSslCert {
|
||||||
|
id?: number;
|
||||||
|
domain?: string;
|
||||||
|
cert_path?: string;
|
||||||
|
key_path?: string;
|
||||||
|
full_chain_path?: string;
|
||||||
|
expiry_date?: number;
|
||||||
|
issuer?: string;
|
||||||
|
created_at?: number;
|
||||||
|
updated_at?: number;
|
||||||
|
[key: number]: unknown; // Allow array-style access as fallback
|
||||||
|
}
|
||||||
|
const existingCerts = this.query<OldSslCert>('SELECT * FROM ssl_certificates');
|
||||||
|
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const domainMap = new Map<string, number>();
|
const domainMap = new Map<string, number>();
|
||||||
|
|
||||||
// Create domain entries for each unique base domain
|
// Create domain entries for each unique base domain
|
||||||
for (const cert of existingCerts) {
|
for (const cert of existingCerts) {
|
||||||
const domain = String(cert.domain ?? cert[1]);
|
const domain = String(cert.domain ?? (cert as Record<number, unknown>)[1]);
|
||||||
if (!domainMap.has(domain)) {
|
if (!domainMap.has(domain)) {
|
||||||
this.query(
|
this.query(
|
||||||
'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
[domain, null, 0, 1, now, now]
|
[domain, null, 0, 1, now, now]
|
||||||
);
|
);
|
||||||
const result = this.query('SELECT last_insert_rowid() as id');
|
const result = this.query<{ id?: number; [key: number]: unknown }>('SELECT last_insert_rowid() as id');
|
||||||
const domainId = result[0].id ?? result[0][0];
|
const domainId = result[0].id ?? (result[0] as Record<number, unknown>)[0];
|
||||||
domainMap.set(domain, Number(domainId));
|
domainMap.set(domain, Number(domainId));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migrate certificates to new table
|
// Migrate certificates to new table
|
||||||
for (const cert of existingCerts) {
|
for (const cert of existingCerts) {
|
||||||
const domain = String(cert.domain ?? cert[1]);
|
const domain = String(cert.domain ?? (cert as Record<number, unknown>)[1]);
|
||||||
const domainId = domainMap.get(domain);
|
const domainId = domainMap.get(domain);
|
||||||
|
|
||||||
this.query(
|
this.query(
|
||||||
@@ -480,14 +504,14 @@ export class OneboxDatabase {
|
|||||||
domainId,
|
domainId,
|
||||||
domain,
|
domain,
|
||||||
0, // We don't know if it's wildcard, default to false
|
0, // We don't know if it's wildcard, default to false
|
||||||
String(cert.cert_path ?? cert[2]),
|
String(cert.cert_path ?? (cert as Record<number, unknown>)[2]),
|
||||||
String(cert.key_path ?? cert[3]),
|
String(cert.key_path ?? (cert as Record<number, unknown>)[3]),
|
||||||
String(cert.full_chain_path ?? cert[4]),
|
String(cert.full_chain_path ?? (cert as Record<number, unknown>)[4]),
|
||||||
Number(cert.expiry_date ?? cert[5]),
|
Number(cert.expiry_date ?? (cert as Record<number, unknown>)[5]),
|
||||||
String(cert.issuer ?? cert[6]),
|
String(cert.issuer ?? (cert as Record<number, unknown>)[6]),
|
||||||
1, // Assume valid
|
1, // Assume valid
|
||||||
Number(cert.created_at ?? cert[7]),
|
Number(cert.created_at ?? (cert as Record<number, unknown>)[7]),
|
||||||
Number(cert.updated_at ?? cert[8])
|
Number(cert.updated_at ?? (cert as Record<number, unknown>)[8])
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -534,9 +558,143 @@ export class OneboxDatabase {
|
|||||||
this.setMigrationVersion(4);
|
this.setMigrationVersion(4);
|
||||||
logger.success('Migration 4 completed: Onebox Registry columns added to services table');
|
logger.success('Migration 4 completed: Onebox Registry columns added to services table');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Migration 5: Registry tokens table
|
||||||
|
const version5 = this.getMigrationVersion();
|
||||||
|
if (version5 < 5) {
|
||||||
|
logger.info('Running migration 5: Creating registry_tokens table...');
|
||||||
|
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE registry_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_type TEXT NOT NULL,
|
||||||
|
scope TEXT NOT NULL,
|
||||||
|
expires_at REAL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
last_used_at REAL,
|
||||||
|
created_by TEXT NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indices for performance
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_type ON registry_tokens(token_type)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_registry_tokens_hash ON registry_tokens(token_hash)');
|
||||||
|
|
||||||
|
this.setMigrationVersion(5);
|
||||||
|
logger.success('Migration 5 completed: Registry tokens table created');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 6: Drop registry_token column from services table (replaced by registry_tokens table)
|
||||||
|
const version6 = this.getMigrationVersion();
|
||||||
|
if (version6 < 6) {
|
||||||
|
logger.info('Running migration 6: Dropping registry_token column from services table...');
|
||||||
|
|
||||||
|
// SQLite doesn't support DROP COLUMN directly, so we need to recreate the table
|
||||||
|
// Create new table without registry_token
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE services_new (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
image TEXT NOT NULL,
|
||||||
|
registry TEXT,
|
||||||
|
env_vars TEXT,
|
||||||
|
port INTEGER NOT NULL,
|
||||||
|
domain TEXT,
|
||||||
|
container_id TEXT,
|
||||||
|
status TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL,
|
||||||
|
use_onebox_registry INTEGER DEFAULT 0,
|
||||||
|
registry_repository TEXT,
|
||||||
|
registry_image_tag TEXT DEFAULT 'latest',
|
||||||
|
auto_update_on_push INTEGER DEFAULT 0,
|
||||||
|
image_digest TEXT
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Copy data (excluding registry_token)
|
||||||
|
this.query(`
|
||||||
|
INSERT INTO services_new (
|
||||||
|
id, name, image, registry, env_vars, port, domain, container_id, status,
|
||||||
|
created_at, updated_at, use_onebox_registry, registry_repository,
|
||||||
|
registry_image_tag, auto_update_on_push, image_digest
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
id, name, image, registry, env_vars, port, domain, container_id, status,
|
||||||
|
created_at, updated_at, use_onebox_registry, registry_repository,
|
||||||
|
registry_image_tag, auto_update_on_push, image_digest
|
||||||
|
FROM services
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Drop old table
|
||||||
|
this.query('DROP TABLE services');
|
||||||
|
|
||||||
|
// Rename new table
|
||||||
|
this.query('ALTER TABLE services_new RENAME TO services');
|
||||||
|
|
||||||
|
// Recreate indices
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_services_name ON services(name)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_services_status ON services(status)');
|
||||||
|
|
||||||
|
this.setMigrationVersion(6);
|
||||||
|
logger.success('Migration 6 completed: registry_token column dropped from services table');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Migration 7: Platform services tables
|
||||||
|
const version7 = this.getMigrationVersion();
|
||||||
|
if (version7 < 7) {
|
||||||
|
logger.info('Running migration 7: Creating platform services tables...');
|
||||||
|
|
||||||
|
// Create platform_services table
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE platform_services (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name TEXT NOT NULL UNIQUE,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'stopped',
|
||||||
|
container_id TEXT,
|
||||||
|
config TEXT NOT NULL DEFAULT '{}',
|
||||||
|
admin_credentials_encrypted TEXT,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
updated_at REAL NOT NULL
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create platform_resources table
|
||||||
|
this.query(`
|
||||||
|
CREATE TABLE platform_resources (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
platform_service_id INTEGER NOT NULL,
|
||||||
|
service_id INTEGER NOT NULL,
|
||||||
|
resource_type TEXT NOT NULL,
|
||||||
|
resource_name TEXT NOT NULL,
|
||||||
|
credentials_encrypted TEXT NOT NULL,
|
||||||
|
created_at REAL NOT NULL,
|
||||||
|
FOREIGN KEY (platform_service_id) REFERENCES platform_services(id) ON DELETE CASCADE,
|
||||||
|
FOREIGN KEY (service_id) REFERENCES services(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Add platform_requirements column to services table
|
||||||
|
this.query(`
|
||||||
|
ALTER TABLE services ADD COLUMN platform_requirements TEXT DEFAULT '{}'
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Create indices
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_platform_services_type ON platform_services(type)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_service ON platform_resources(service_id)');
|
||||||
|
this.query('CREATE INDEX IF NOT EXISTS idx_platform_resources_platform ON platform_resources(platform_service_id)');
|
||||||
|
|
||||||
|
this.setMigrationVersion(7);
|
||||||
|
logger.success('Migration 7 completed: Platform services tables created');
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Migration failed: ${error.message}`);
|
logger.error(`Migration failed: ${getErrorMessage(error)}`);
|
||||||
logger.error(`Stack: ${error.stack}`);
|
if (error instanceof Error && error.stack) {
|
||||||
|
logger.error(`Stack: ${error.stack}`);
|
||||||
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -548,14 +706,14 @@ export class OneboxDatabase {
|
|||||||
if (!this.db) throw new Error('Database not initialized');
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = this.query('SELECT MAX(version) as version FROM migrations');
|
const result = this.query<{ version?: number | null; [key: number]: unknown }>('SELECT MAX(version) as version FROM migrations');
|
||||||
if (result.length === 0) return 0;
|
if (result.length === 0) return 0;
|
||||||
|
|
||||||
// Handle both array and object access patterns
|
// Handle both array and object access patterns
|
||||||
const versionValue = result[0].version ?? result[0][0];
|
const versionValue = result[0].version ?? (result[0] as Record<number, unknown>)[0];
|
||||||
return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0;
|
return versionValue !== null && versionValue !== undefined ? Number(versionValue) : 0;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.warn(`Error getting migration version: ${error.message}, defaulting to 0`);
|
logger.warn(`Error getting migration version: ${getErrorMessage(error)}, defaulting to 0`);
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -587,7 +745,7 @@ export class OneboxDatabase {
|
|||||||
/**
|
/**
|
||||||
* Execute a raw query
|
* Execute a raw query
|
||||||
*/
|
*/
|
||||||
query<T = unknown[]>(sql: string, params: unknown[] = []): T[] {
|
query<T = Record<string, unknown>>(sql: string, params: BindValue[] = []): T[] {
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
const error = new Error('Database not initialized');
|
const error = new Error('Database not initialized');
|
||||||
console.error('Database access before initialization!');
|
console.error('Database access before initialization!');
|
||||||
@@ -621,8 +779,8 @@ export class OneboxDatabase {
|
|||||||
`INSERT INTO services (
|
`INSERT INTO services (
|
||||||
name, image, registry, env_vars, port, domain, container_id, status,
|
name, image, registry, env_vars, port, domain, container_id, status,
|
||||||
created_at, updated_at,
|
created_at, updated_at,
|
||||||
use_onebox_registry, registry_repository, registry_token, registry_image_tag,
|
use_onebox_registry, registry_repository, registry_image_tag,
|
||||||
auto_update_on_push, image_digest
|
auto_update_on_push, image_digest, platform_requirements
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
[
|
[
|
||||||
service.name,
|
service.name,
|
||||||
@@ -637,10 +795,10 @@ export class OneboxDatabase {
|
|||||||
now,
|
now,
|
||||||
service.useOneboxRegistry ? 1 : 0,
|
service.useOneboxRegistry ? 1 : 0,
|
||||||
service.registryRepository || null,
|
service.registryRepository || null,
|
||||||
service.registryToken || null,
|
|
||||||
service.registryImageTag || 'latest',
|
service.registryImageTag || 'latest',
|
||||||
service.autoUpdateOnPush ? 1 : 0,
|
service.autoUpdateOnPush ? 1 : 0,
|
||||||
service.imageDigest || null,
|
service.imageDigest || null,
|
||||||
|
JSON.stringify(service.platformRequirements || {}),
|
||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -717,10 +875,6 @@ export class OneboxDatabase {
|
|||||||
fields.push('registry_repository = ?');
|
fields.push('registry_repository = ?');
|
||||||
values.push(updates.registryRepository);
|
values.push(updates.registryRepository);
|
||||||
}
|
}
|
||||||
if (updates.registryToken !== undefined) {
|
|
||||||
fields.push('registry_token = ?');
|
|
||||||
values.push(updates.registryToken);
|
|
||||||
}
|
|
||||||
if (updates.registryImageTag !== undefined) {
|
if (updates.registryImageTag !== undefined) {
|
||||||
fields.push('registry_image_tag = ?');
|
fields.push('registry_image_tag = ?');
|
||||||
values.push(updates.registryImageTag);
|
values.push(updates.registryImageTag);
|
||||||
@@ -733,6 +887,10 @@ export class OneboxDatabase {
|
|||||||
fields.push('image_digest = ?');
|
fields.push('image_digest = ?');
|
||||||
values.push(updates.imageDigest);
|
values.push(updates.imageDigest);
|
||||||
}
|
}
|
||||||
|
if (updates.platformRequirements !== undefined) {
|
||||||
|
fields.push('platform_requirements = ?');
|
||||||
|
values.push(JSON.stringify(updates.platformRequirements));
|
||||||
|
}
|
||||||
|
|
||||||
fields.push('updated_at = ?');
|
fields.push('updated_at = ?');
|
||||||
values.push(Date.now());
|
values.push(Date.now());
|
||||||
@@ -754,11 +912,23 @@ export class OneboxDatabase {
|
|||||||
try {
|
try {
|
||||||
envVars = JSON.parse(String(envVarsRaw));
|
envVars = JSON.parse(String(envVarsRaw));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logger.warn(`Failed to parse env_vars for service: ${e.message}`);
|
logger.warn(`Failed to parse env_vars for service: ${getErrorMessage(e)}`);
|
||||||
envVars = {};
|
envVars = {};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle platform_requirements JSON parsing safely
|
||||||
|
let platformRequirements: IPlatformRequirements | undefined;
|
||||||
|
const platformReqRaw = row.platform_requirements;
|
||||||
|
if (platformReqRaw && platformReqRaw !== 'undefined' && platformReqRaw !== 'null' && platformReqRaw !== '{}') {
|
||||||
|
try {
|
||||||
|
platformRequirements = JSON.parse(String(platformReqRaw));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Failed to parse platform_requirements for service: ${getErrorMessage(e)}`);
|
||||||
|
platformRequirements = undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: Number(row.id || row[0]),
|
id: Number(row.id || row[0]),
|
||||||
name: String(row.name || row[1]),
|
name: String(row.name || row[1]),
|
||||||
@@ -774,10 +944,11 @@ export class OneboxDatabase {
|
|||||||
// Onebox Registry fields
|
// Onebox Registry fields
|
||||||
useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined,
|
useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : undefined,
|
||||||
registryRepository: row.registry_repository ? String(row.registry_repository) : undefined,
|
registryRepository: row.registry_repository ? String(row.registry_repository) : undefined,
|
||||||
registryToken: row.registry_token ? String(row.registry_token) : undefined,
|
|
||||||
registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined,
|
registryImageTag: row.registry_image_tag ? String(row.registry_image_tag) : undefined,
|
||||||
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
|
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : undefined,
|
||||||
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
|
imageDigest: row.image_digest ? String(row.image_digest) : undefined,
|
||||||
|
// Platform service requirements
|
||||||
|
platformRequirements,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1392,4 +1563,279 @@ export class OneboxDatabase {
|
|||||||
updatedAt: Number(row.updated_at || row[7]),
|
updatedAt: Number(row.updated_at || row[7]),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Registry Tokens ============
|
||||||
|
|
||||||
|
createRegistryToken(token: Omit<IRegistryToken, 'id'>): IRegistryToken {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const scopeJson = Array.isArray(token.scope) ? JSON.stringify(token.scope) : token.scope;
|
||||||
|
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO registry_tokens (name, token_hash, token_type, scope, expires_at, created_at, last_used_at, created_by)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
token.name,
|
||||||
|
token.tokenHash,
|
||||||
|
token.type,
|
||||||
|
scopeJson,
|
||||||
|
token.expiresAt,
|
||||||
|
token.createdAt,
|
||||||
|
token.lastUsedAt,
|
||||||
|
token.createdBy,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM registry_tokens WHERE id = last_insert_rowid()');
|
||||||
|
return this.rowToRegistryToken(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getRegistryTokenById(id: number): IRegistryToken | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM registry_tokens WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getRegistryTokenByHash(tokenHash: string): IRegistryToken | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM registry_tokens WHERE token_hash = ?', [tokenHash]);
|
||||||
|
return rows.length > 0 ? this.rowToRegistryToken(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllRegistryTokens(): IRegistryToken[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM registry_tokens ORDER BY created_at DESC');
|
||||||
|
return rows.map((row) => this.rowToRegistryToken(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getRegistryTokensByType(type: 'global' | 'ci'): IRegistryToken[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM registry_tokens WHERE token_type = ? ORDER BY created_at DESC', [type]);
|
||||||
|
return rows.map((row) => this.rowToRegistryToken(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateRegistryTokenLastUsed(id: number): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
this.query('UPDATE registry_tokens SET last_used_at = ? WHERE id = ?', [Date.now(), id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteRegistryToken(id: number): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
this.query('DELETE FROM registry_tokens WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToRegistryToken(row: any): IRegistryToken {
|
||||||
|
// Parse scope - it's either 'all' or a JSON array
|
||||||
|
let scope: 'all' | string[];
|
||||||
|
const scopeRaw = row.scope || row[4];
|
||||||
|
if (scopeRaw === 'all') {
|
||||||
|
scope = 'all';
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
scope = JSON.parse(String(scopeRaw));
|
||||||
|
} catch {
|
||||||
|
scope = 'all';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id || row[0]),
|
||||||
|
name: String(row.name || row[1]),
|
||||||
|
tokenHash: String(row.token_hash || row[2]),
|
||||||
|
type: String(row.token_type || row[3]) as IRegistryToken['type'],
|
||||||
|
scope,
|
||||||
|
expiresAt: row.expires_at || row[5] ? Number(row.expires_at || row[5]) : null,
|
||||||
|
createdAt: Number(row.created_at || row[6]),
|
||||||
|
lastUsedAt: row.last_used_at || row[7] ? Number(row.last_used_at || row[7]) : null,
|
||||||
|
createdBy: String(row.created_by || row[8]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Platform Services CRUD ============
|
||||||
|
|
||||||
|
createPlatformService(service: Omit<IPlatformService, 'id'>): IPlatformService {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO platform_services (name, type, status, container_id, config, admin_credentials_encrypted, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
service.name,
|
||||||
|
service.type,
|
||||||
|
service.status,
|
||||||
|
service.containerId || null,
|
||||||
|
JSON.stringify(service.config),
|
||||||
|
service.adminCredentialsEncrypted || null,
|
||||||
|
now,
|
||||||
|
now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
return this.getPlatformServiceByName(service.name)!;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlatformServiceByName(name: string): IPlatformService | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM platform_services WHERE name = ?', [name]);
|
||||||
|
return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlatformServiceById(id: number): IPlatformService | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM platform_services WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlatformServiceByType(type: TPlatformServiceType): IPlatformService | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM platform_services WHERE type = ?', [type]);
|
||||||
|
return rows.length > 0 ? this.rowToPlatformService(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllPlatformServices(): IPlatformService[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM platform_services ORDER BY created_at DESC');
|
||||||
|
return rows.map((row) => this.rowToPlatformService(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
updatePlatformService(id: number, updates: Partial<IPlatformService>): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const fields: string[] = [];
|
||||||
|
const values: unknown[] = [];
|
||||||
|
|
||||||
|
if (updates.status !== undefined) {
|
||||||
|
fields.push('status = ?');
|
||||||
|
values.push(updates.status);
|
||||||
|
}
|
||||||
|
if (updates.containerId !== undefined) {
|
||||||
|
fields.push('container_id = ?');
|
||||||
|
values.push(updates.containerId);
|
||||||
|
}
|
||||||
|
if (updates.config !== undefined) {
|
||||||
|
fields.push('config = ?');
|
||||||
|
values.push(JSON.stringify(updates.config));
|
||||||
|
}
|
||||||
|
if (updates.adminCredentialsEncrypted !== undefined) {
|
||||||
|
fields.push('admin_credentials_encrypted = ?');
|
||||||
|
values.push(updates.adminCredentialsEncrypted);
|
||||||
|
}
|
||||||
|
|
||||||
|
fields.push('updated_at = ?');
|
||||||
|
values.push(Date.now());
|
||||||
|
values.push(id);
|
||||||
|
|
||||||
|
this.query(`UPDATE platform_services SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePlatformService(id: number): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
this.query('DELETE FROM platform_services WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToPlatformService(row: any): IPlatformService {
|
||||||
|
let config = { image: '', port: 0 };
|
||||||
|
const configRaw = row.config;
|
||||||
|
if (configRaw) {
|
||||||
|
try {
|
||||||
|
config = JSON.parse(String(configRaw));
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Failed to parse platform service config: ${getErrorMessage(e)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
name: String(row.name),
|
||||||
|
type: String(row.type) as TPlatformServiceType,
|
||||||
|
status: String(row.status) as IPlatformService['status'],
|
||||||
|
containerId: row.container_id ? String(row.container_id) : undefined,
|
||||||
|
config,
|
||||||
|
adminCredentialsEncrypted: row.admin_credentials_encrypted ? String(row.admin_credentials_encrypted) : undefined,
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
updatedAt: Number(row.updated_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Platform Resources CRUD ============
|
||||||
|
|
||||||
|
createPlatformResource(resource: Omit<IPlatformResource, 'id'>): IPlatformResource {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
this.query(
|
||||||
|
`INSERT INTO platform_resources (platform_service_id, service_id, resource_type, resource_name, credentials_encrypted, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||||
|
[
|
||||||
|
resource.platformServiceId,
|
||||||
|
resource.serviceId,
|
||||||
|
resource.resourceType,
|
||||||
|
resource.resourceName,
|
||||||
|
resource.credentialsEncrypted,
|
||||||
|
now,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM platform_resources WHERE id = last_insert_rowid()');
|
||||||
|
return this.rowToPlatformResource(rows[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlatformResourceById(id: number): IPlatformResource | null {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM platform_resources WHERE id = ?', [id]);
|
||||||
|
return rows.length > 0 ? this.rowToPlatformResource(rows[0]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlatformResourcesByService(serviceId: number): IPlatformResource[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM platform_resources WHERE service_id = ?', [serviceId]);
|
||||||
|
return rows.map((row) => this.rowToPlatformResource(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getPlatformResourcesByPlatformService(platformServiceId: number): IPlatformResource[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM platform_resources WHERE platform_service_id = ?', [platformServiceId]);
|
||||||
|
return rows.map((row) => this.rowToPlatformResource(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
getAllPlatformResources(): IPlatformResource[] {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
|
||||||
|
const rows = this.query('SELECT * FROM platform_resources ORDER BY created_at DESC');
|
||||||
|
return rows.map((row) => this.rowToPlatformResource(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePlatformResource(id: number): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
this.query('DELETE FROM platform_resources WHERE id = ?', [id]);
|
||||||
|
}
|
||||||
|
|
||||||
|
deletePlatformResourcesByService(serviceId: number): void {
|
||||||
|
if (!this.db) throw new Error('Database not initialized');
|
||||||
|
this.query('DELETE FROM platform_resources WHERE service_id = ?', [serviceId]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private rowToPlatformResource(row: any): IPlatformResource {
|
||||||
|
return {
|
||||||
|
id: Number(row.id),
|
||||||
|
platformServiceId: Number(row.platform_service_id),
|
||||||
|
serviceId: Number(row.service_id),
|
||||||
|
resourceType: String(row.resource_type) as IPlatformResource['resourceType'],
|
||||||
|
resourceName: String(row.resource_name),
|
||||||
|
credentialsEncrypted: String(row.credentials_encrypted),
|
||||||
|
createdAt: Number(row.created_at),
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -841,4 +841,89 @@ export class OneboxDockerManager {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a platform service container (MongoDB, MinIO, etc.)
|
||||||
|
* Platform containers are long-running infrastructure services
|
||||||
|
*/
|
||||||
|
async createPlatformContainer(options: {
|
||||||
|
name: string;
|
||||||
|
image: string;
|
||||||
|
port: number;
|
||||||
|
env: string[];
|
||||||
|
volumes?: string[];
|
||||||
|
network: string;
|
||||||
|
command?: string[];
|
||||||
|
exposePorts?: number[];
|
||||||
|
}): Promise<string> {
|
||||||
|
try {
|
||||||
|
logger.info(`Creating platform container: ${options.name}`);
|
||||||
|
|
||||||
|
// Check if container already exists
|
||||||
|
const existingContainers = await this.dockerClient!.listContainers();
|
||||||
|
const existing = existingContainers.find((c: any) =>
|
||||||
|
c.Names?.some((n: string) => n === `/${options.name}` || n === options.name)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
logger.info(`Platform container ${options.name} already exists, removing old container...`);
|
||||||
|
await this.removeContainer(existing.Id, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare exposed ports
|
||||||
|
const exposedPorts: Record<string, Record<string, never>> = {};
|
||||||
|
const portBindings: Record<string, Array<{ HostIp: string; HostPort: string }>> = {};
|
||||||
|
|
||||||
|
const portsToExpose = options.exposePorts || [options.port];
|
||||||
|
for (const port of portsToExpose) {
|
||||||
|
exposedPorts[`${port}/tcp`] = {};
|
||||||
|
// Don't bind to host ports by default - services communicate via Docker network
|
||||||
|
portBindings[`${port}/tcp`] = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prepare volume bindings
|
||||||
|
const binds: string[] = options.volumes || [];
|
||||||
|
|
||||||
|
// Create the container
|
||||||
|
const response = await this.dockerClient!.request('POST', `/containers/create?name=${options.name}`, {
|
||||||
|
Image: options.image,
|
||||||
|
Cmd: options.command,
|
||||||
|
Env: options.env,
|
||||||
|
Labels: {
|
||||||
|
'managed-by': 'onebox',
|
||||||
|
'onebox-platform-service': options.name,
|
||||||
|
},
|
||||||
|
ExposedPorts: exposedPorts,
|
||||||
|
HostConfig: {
|
||||||
|
NetworkMode: options.network,
|
||||||
|
RestartPolicy: {
|
||||||
|
Name: 'unless-stopped',
|
||||||
|
},
|
||||||
|
PortBindings: portBindings,
|
||||||
|
Binds: binds,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (response.statusCode >= 300) {
|
||||||
|
const errorMsg = response.body?.message || `HTTP ${response.statusCode}`;
|
||||||
|
throw new Error(`Failed to create platform container: ${errorMsg}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerID = response.body.Id;
|
||||||
|
logger.info(`Platform container created: ${containerID}`);
|
||||||
|
|
||||||
|
// Start the container
|
||||||
|
const startResponse = await this.dockerClient!.request('POST', `/containers/${containerID}/start`, {});
|
||||||
|
|
||||||
|
if (startResponse.statusCode >= 300 && startResponse.statusCode !== 304) {
|
||||||
|
throw new Error(`Failed to start platform container: HTTP ${startResponse.statusCode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`Platform container ${options.name} started successfully`);
|
||||||
|
return containerID;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to create platform container ${options.name}: ${error.message}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
203
ts/classes/encryption.ts
Normal file
203
ts/classes/encryption.ts
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
/**
|
||||||
|
* AES-256-GCM encryption for credential storage
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { logger } from '../logging.ts';
|
||||||
|
|
||||||
|
export class CredentialEncryption {
|
||||||
|
private key: CryptoKey | null = null;
|
||||||
|
private readonly algorithm = 'AES-GCM';
|
||||||
|
private readonly keyLength = 256;
|
||||||
|
private readonly ivLength = 12; // 96 bits for GCM
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize encryption with a key from environment or generate machine-specific key
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
const envKey = Deno.env.get('ONEBOX_ENCRYPTION_KEY');
|
||||||
|
|
||||||
|
if (envKey) {
|
||||||
|
// Use provided key (should be 32 bytes base64 encoded)
|
||||||
|
const keyBytes = this.base64ToBytes(envKey);
|
||||||
|
if (keyBytes.length !== 32) {
|
||||||
|
throw new Error('ONEBOX_ENCRYPTION_KEY must be 32 bytes (256 bits) base64 encoded');
|
||||||
|
}
|
||||||
|
this.key = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
keyBytes,
|
||||||
|
{ name: this.algorithm },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
logger.log('info', 'Encryption key loaded from environment', 'CredentialEncryption');
|
||||||
|
} else {
|
||||||
|
// Derive key from machine-specific data
|
||||||
|
this.key = await this.deriveKeyFromMachine();
|
||||||
|
logger.log('info', 'Encryption key derived from machine identity', 'CredentialEncryption');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive a key from machine-specific information
|
||||||
|
* This ensures the key is consistent across restarts on the same machine
|
||||||
|
*/
|
||||||
|
private async deriveKeyFromMachine(): Promise<CryptoKey> {
|
||||||
|
// Collect machine-specific data
|
||||||
|
const machineData: string[] = [];
|
||||||
|
|
||||||
|
// Hostname
|
||||||
|
try {
|
||||||
|
machineData.push(Deno.hostname());
|
||||||
|
} catch {
|
||||||
|
machineData.push('unknown-host');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Machine ID from /etc/machine-id (Linux) or generate consistent fallback
|
||||||
|
try {
|
||||||
|
const machineId = await Deno.readTextFile('/etc/machine-id');
|
||||||
|
machineData.push(machineId.trim());
|
||||||
|
} catch {
|
||||||
|
// Fallback: use a combination of other identifiers
|
||||||
|
machineData.push('onebox-default-machine-id');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a salt
|
||||||
|
machineData.push('onebox-credential-encryption-v1');
|
||||||
|
|
||||||
|
// Create seed from machine data
|
||||||
|
const seed = machineData.join(':');
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const seedBytes = encoder.encode(seed);
|
||||||
|
|
||||||
|
// Use PBKDF2 to derive key
|
||||||
|
const baseKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
seedBytes,
|
||||||
|
'PBKDF2',
|
||||||
|
false,
|
||||||
|
['deriveKey']
|
||||||
|
);
|
||||||
|
|
||||||
|
return await crypto.subtle.deriveKey(
|
||||||
|
{
|
||||||
|
name: 'PBKDF2',
|
||||||
|
salt: encoder.encode('onebox-salt-v1'),
|
||||||
|
iterations: 100000,
|
||||||
|
hash: 'SHA-256',
|
||||||
|
},
|
||||||
|
baseKey,
|
||||||
|
{ name: this.algorithm, length: this.keyLength },
|
||||||
|
false,
|
||||||
|
['encrypt', 'decrypt']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Encrypt a credentials object to a base64 string
|
||||||
|
*/
|
||||||
|
async encrypt(data: Record<string, string>): Promise<string> {
|
||||||
|
if (!this.key) {
|
||||||
|
throw new Error('Encryption not initialized. Call init() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const iv = crypto.getRandomValues(new Uint8Array(this.ivLength));
|
||||||
|
const encoded = new TextEncoder().encode(JSON.stringify(data));
|
||||||
|
|
||||||
|
const ciphertext = await crypto.subtle.encrypt(
|
||||||
|
{ name: this.algorithm, iv },
|
||||||
|
this.key,
|
||||||
|
encoded
|
||||||
|
);
|
||||||
|
|
||||||
|
// Combine IV + ciphertext and encode as base64
|
||||||
|
const combined = new Uint8Array(iv.length + ciphertext.byteLength);
|
||||||
|
combined.set(iv, 0);
|
||||||
|
combined.set(new Uint8Array(ciphertext), iv.length);
|
||||||
|
|
||||||
|
return this.bytesToBase64(combined);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypt a base64 string back to credentials object
|
||||||
|
*/
|
||||||
|
async decrypt(encrypted: string): Promise<Record<string, string>> {
|
||||||
|
if (!this.key) {
|
||||||
|
throw new Error('Encryption not initialized. Call init() first.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const combined = this.base64ToBytes(encrypted);
|
||||||
|
|
||||||
|
// Extract IV and ciphertext
|
||||||
|
const iv = combined.slice(0, this.ivLength);
|
||||||
|
const ciphertext = combined.slice(this.ivLength);
|
||||||
|
|
||||||
|
const decrypted = await crypto.subtle.decrypt(
|
||||||
|
{ name: this.algorithm, iv },
|
||||||
|
this.key,
|
||||||
|
ciphertext
|
||||||
|
);
|
||||||
|
|
||||||
|
const decoded = new TextDecoder().decode(decrypted);
|
||||||
|
return JSON.parse(decoded);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a secure random password
|
||||||
|
*/
|
||||||
|
generatePassword(length: number = 32): string {
|
||||||
|
// Exclude ambiguous characters (0, O, l, 1, I)
|
||||||
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghjkmnpqrstuvwxyz23456789';
|
||||||
|
const randomBytes = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
let result = '';
|
||||||
|
for (const byte of randomBytes) {
|
||||||
|
result += chars[byte % chars.length];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate an access key (alphanumeric, uppercase)
|
||||||
|
*/
|
||||||
|
generateAccessKey(length: number = 20): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
||||||
|
const randomBytes = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
let result = '';
|
||||||
|
for (const byte of randomBytes) {
|
||||||
|
result += chars[byte % chars.length];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a secret key (alphanumeric, mixed case)
|
||||||
|
*/
|
||||||
|
generateSecretKey(length: number = 40): string {
|
||||||
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
||||||
|
const randomBytes = crypto.getRandomValues(new Uint8Array(length));
|
||||||
|
let result = '';
|
||||||
|
for (const byte of randomBytes) {
|
||||||
|
result += chars[byte % chars.length];
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private bytesToBase64(bytes: Uint8Array): string {
|
||||||
|
let binary = '';
|
||||||
|
for (const byte of bytes) {
|
||||||
|
binary += String.fromCharCode(byte);
|
||||||
|
}
|
||||||
|
return btoa(binary);
|
||||||
|
}
|
||||||
|
|
||||||
|
private base64ToBytes(base64: string): Uint8Array {
|
||||||
|
const binary = atob(base64);
|
||||||
|
const bytes = new Uint8Array(binary.length);
|
||||||
|
for (let i = 0; i < binary.length; i++) {
|
||||||
|
bytes[i] = binary.charCodeAt(i);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Singleton instance
|
||||||
|
export const credentialEncryption = new CredentialEncryption();
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
import * as plugins from '../plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import type { Onebox } from './onebox.ts';
|
import type { Onebox } from './onebox.ts';
|
||||||
import type { IApiResponse } from '../types.ts';
|
import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView } from '../types.ts';
|
||||||
|
|
||||||
export class OneboxHttpServer {
|
export class OneboxHttpServer {
|
||||||
private oneboxRef: Onebox;
|
private oneboxRef: Onebox;
|
||||||
@@ -263,9 +263,28 @@ export class OneboxHttpServer {
|
|||||||
} else if (path.match(/^\/api\/registry\/tags\/[^/]+$/)) {
|
} else if (path.match(/^\/api\/registry\/tags\/[^/]+$/)) {
|
||||||
const serviceName = path.split('/').pop()!;
|
const serviceName = path.split('/').pop()!;
|
||||||
return await this.handleGetRegistryTagsRequest(serviceName);
|
return await this.handleGetRegistryTagsRequest(serviceName);
|
||||||
} else if (path.match(/^\/api\/registry\/token\/[^/]+$/)) {
|
} else if (path === '/api/registry/tokens' && method === 'GET') {
|
||||||
const serviceName = path.split('/').pop()!;
|
return await this.handleListRegistryTokensRequest(req);
|
||||||
return await this.handleGetRegistryTokenRequest(serviceName);
|
} else if (path === '/api/registry/tokens' && method === 'POST') {
|
||||||
|
return await this.handleCreateRegistryTokenRequest(req);
|
||||||
|
} else if (path.match(/^\/api\/registry\/tokens\/\d+$/) && method === 'DELETE') {
|
||||||
|
const tokenId = Number(path.split('/').pop());
|
||||||
|
return await this.handleDeleteRegistryTokenRequest(tokenId);
|
||||||
|
// Platform Services endpoints
|
||||||
|
} else if (path === '/api/platform-services' && method === 'GET') {
|
||||||
|
return await this.handleListPlatformServicesRequest();
|
||||||
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)$/) && method === 'GET') {
|
||||||
|
const type = path.split('/').pop()!;
|
||||||
|
return await this.handleGetPlatformServiceRequest(type);
|
||||||
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/start$/) && method === 'POST') {
|
||||||
|
const type = path.split('/')[3];
|
||||||
|
return await this.handleStartPlatformServiceRequest(type);
|
||||||
|
} else if (path.match(/^\/api\/platform-services\/(mongodb|minio|redis|postgresql|rabbitmq)\/stop$/) && method === 'POST') {
|
||||||
|
const type = path.split('/')[3];
|
||||||
|
return await this.handleStopPlatformServiceRequest(type);
|
||||||
|
} else if (path.match(/^\/api\/services\/[^/]+\/platform-resources$/) && method === 'GET') {
|
||||||
|
const serviceName = path.split('/')[3];
|
||||||
|
return await this.handleGetServicePlatformResourcesRequest(serviceName);
|
||||||
} else {
|
} else {
|
||||||
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
return this.jsonResponse({ success: false, error: 'Not found' }, 404);
|
||||||
}
|
}
|
||||||
@@ -1032,6 +1051,183 @@ export class OneboxHttpServer {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============ Platform Services Endpoints ============
|
||||||
|
|
||||||
|
private async handleListPlatformServicesRequest(): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const platformServices = this.oneboxRef.platformServices.getAllPlatformServices();
|
||||||
|
const providers = this.oneboxRef.platformServices.getAllProviders();
|
||||||
|
|
||||||
|
// Build response with provider info
|
||||||
|
const result = providers.map((provider) => {
|
||||||
|
const service = platformServices.find((s) => s.type === provider.type);
|
||||||
|
return {
|
||||||
|
type: provider.type,
|
||||||
|
displayName: provider.displayName,
|
||||||
|
resourceTypes: provider.resourceTypes,
|
||||||
|
status: service?.status || 'not-deployed',
|
||||||
|
containerId: service?.containerId,
|
||||||
|
createdAt: service?.createdAt,
|
||||||
|
updatedAt: service?.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.jsonResponse({ success: true, data: result });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to list platform services: ${error.message}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Failed to list platform services',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGetPlatformServiceRequest(type: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const provider = this.oneboxRef.platformServices.getProvider(type);
|
||||||
|
if (!provider) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: `Unknown platform service type: ${type}`,
|
||||||
|
}, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = this.oneboxRef.database.getPlatformServiceByType(type);
|
||||||
|
|
||||||
|
// Get resource count
|
||||||
|
const allResources = service?.id
|
||||||
|
? this.oneboxRef.database.getPlatformResourcesByPlatformService(service.id)
|
||||||
|
: [];
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: {
|
||||||
|
type: provider.type,
|
||||||
|
displayName: provider.displayName,
|
||||||
|
resourceTypes: provider.resourceTypes,
|
||||||
|
status: service?.status || 'not-deployed',
|
||||||
|
containerId: service?.containerId,
|
||||||
|
config: provider.getDefaultConfig(),
|
||||||
|
resourceCount: allResources.length,
|
||||||
|
createdAt: service?.createdAt,
|
||||||
|
updatedAt: service?.updatedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get platform service ${type}: ${error.message}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Failed to get platform service',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStartPlatformServiceRequest(type: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const provider = this.oneboxRef.platformServices.getProvider(type);
|
||||||
|
if (!provider) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: `Unknown platform service type: ${type}`,
|
||||||
|
}, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Starting platform service: ${type}`);
|
||||||
|
const service = await this.oneboxRef.platformServices.ensureRunning(type);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Platform service ${provider.displayName} started`,
|
||||||
|
data: {
|
||||||
|
type: service.type,
|
||||||
|
status: service.status,
|
||||||
|
containerId: service.containerId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to start platform service ${type}: ${error.message}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Failed to start platform service',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleStopPlatformServiceRequest(type: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const provider = this.oneboxRef.platformServices.getProvider(type);
|
||||||
|
if (!provider) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: `Unknown platform service type: ${type}`,
|
||||||
|
}, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Stopping platform service: ${type}`);
|
||||||
|
await this.oneboxRef.platformServices.stopPlatformService(type);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: `Platform service ${provider.displayName} stopped`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to stop platform service ${type}: ${error.message}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Failed to stop platform service',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleGetServicePlatformResourcesRequest(serviceName: string): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const service = this.oneboxRef.services.getService(serviceName);
|
||||||
|
if (!service) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Service not found',
|
||||||
|
}, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resources = await this.oneboxRef.services.getServicePlatformResources(serviceName);
|
||||||
|
|
||||||
|
// Format resources for API response (mask sensitive credentials)
|
||||||
|
const formattedResources = resources.map((r) => ({
|
||||||
|
id: r.resource.id,
|
||||||
|
resourceType: r.resource.resourceType,
|
||||||
|
resourceName: r.resource.resourceName,
|
||||||
|
platformService: {
|
||||||
|
type: r.platformService.type,
|
||||||
|
name: r.platformService.name,
|
||||||
|
status: r.platformService.status,
|
||||||
|
},
|
||||||
|
// Include env var mappings (show keys, not values)
|
||||||
|
envVars: Object.keys(r.credentials).reduce((acc, key) => {
|
||||||
|
// Mask sensitive values
|
||||||
|
const value = r.credentials[key];
|
||||||
|
if (key.toLowerCase().includes('password') || key.toLowerCase().includes('secret')) {
|
||||||
|
acc[key] = '********';
|
||||||
|
} else {
|
||||||
|
acc[key] = value;
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, string>),
|
||||||
|
createdAt: r.resource.createdAt,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
data: formattedResources,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get platform resources for service ${serviceName}: ${error.message}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Failed to get platform resources',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ============ Registry Endpoints ============
|
// ============ Registry Endpoints ============
|
||||||
|
|
||||||
private async handleGetRegistryTagsRequest(serviceName: string): Promise<Response> {
|
private async handleGetRegistryTagsRequest(serviceName: string): Promise<Response> {
|
||||||
@@ -1047,51 +1243,206 @@ export class OneboxHttpServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async handleGetRegistryTokenRequest(serviceName: string): Promise<Response> {
|
// ============ Registry Token Management Endpoints ============
|
||||||
|
|
||||||
|
private async handleListRegistryTokensRequest(req: Request): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
// Get the service to verify it exists
|
const tokens = this.oneboxRef.database.getAllRegistryTokens();
|
||||||
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
|
||||||
if (!service) {
|
// Convert to view format (mask token hash, add computed fields)
|
||||||
|
const tokenViews: IRegistryTokenView[] = tokens.map(token => {
|
||||||
|
const now = Date.now();
|
||||||
|
const isExpired = token.expiresAt !== null && token.expiresAt < now;
|
||||||
|
|
||||||
|
// Generate scope display string
|
||||||
|
let scopeDisplay: string;
|
||||||
|
if (token.scope === 'all') {
|
||||||
|
scopeDisplay = 'All services';
|
||||||
|
} else if (Array.isArray(token.scope)) {
|
||||||
|
scopeDisplay = token.scope.length === 1
|
||||||
|
? token.scope[0]
|
||||||
|
: `${token.scope.length} services`;
|
||||||
|
} else {
|
||||||
|
scopeDisplay = 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: token.id!,
|
||||||
|
name: token.name,
|
||||||
|
type: token.type,
|
||||||
|
scope: token.scope,
|
||||||
|
scopeDisplay,
|
||||||
|
expiresAt: token.expiresAt,
|
||||||
|
createdAt: token.createdAt,
|
||||||
|
lastUsedAt: token.lastUsedAt,
|
||||||
|
createdBy: token.createdBy,
|
||||||
|
isExpired,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.jsonResponse({ success: true, data: tokenViews });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to list registry tokens: ${error.message}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Failed to list registry tokens',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleCreateRegistryTokenRequest(req: Request): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const body = await req.json() as ICreateRegistryTokenRequest;
|
||||||
|
|
||||||
|
// Validate request
|
||||||
|
if (!body.name || !body.type || !body.scope || !body.expiresIn) {
|
||||||
return this.jsonResponse({
|
return this.jsonResponse({
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Service not found',
|
error: 'Missing required fields: name, type, scope, expiresIn',
|
||||||
}, 404);
|
}, 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
// If service already has a token, return it
|
if (body.type !== 'global' && body.type !== 'ci') {
|
||||||
if (service.registryToken) {
|
|
||||||
return this.jsonResponse({
|
return this.jsonResponse({
|
||||||
success: true,
|
success: false,
|
||||||
data: {
|
error: 'Invalid token type. Must be "global" or "ci"',
|
||||||
token: service.registryToken,
|
}, 400);
|
||||||
repository: serviceName,
|
|
||||||
baseUrl: this.oneboxRef.registry.getBaseUrl(),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generate new token
|
// Validate scope
|
||||||
const token = await this.oneboxRef.registry.createServiceToken(serviceName);
|
if (body.scope !== 'all' && !Array.isArray(body.scope)) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Scope must be "all" or an array of service names',
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
// Save token to database
|
// If scope is array of services, validate they exist
|
||||||
this.oneboxRef.database.updateService(service.id!, {
|
if (Array.isArray(body.scope)) {
|
||||||
registryToken: token,
|
for (const serviceName of body.scope) {
|
||||||
registryRepository: serviceName,
|
const service = this.oneboxRef.database.getServiceByName(serviceName);
|
||||||
|
if (!service) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: `Service not found: ${serviceName}`,
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate expiration timestamp
|
||||||
|
const now = Date.now();
|
||||||
|
let expiresAt: number | null = null;
|
||||||
|
if (body.expiresIn !== 'never') {
|
||||||
|
const daysMap: Record<string, number> = {
|
||||||
|
'30d': 30,
|
||||||
|
'90d': 90,
|
||||||
|
'365d': 365,
|
||||||
|
};
|
||||||
|
const days = daysMap[body.expiresIn];
|
||||||
|
if (days) {
|
||||||
|
expiresAt = now + (days * 24 * 60 * 60 * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate token (random 32 bytes as hex)
|
||||||
|
const plainToken = crypto.randomUUID() + crypto.randomUUID();
|
||||||
|
|
||||||
|
// Hash the token for storage (using simple hash for now)
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
const data = encoder.encode(plainToken);
|
||||||
|
const hashBuffer = await crypto.subtle.digest('SHA-256', data);
|
||||||
|
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||||
|
const tokenHash = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||||
|
|
||||||
|
// Get username from auth token
|
||||||
|
const authHeader = req.headers.get('Authorization');
|
||||||
|
let createdBy = 'system';
|
||||||
|
if (authHeader && authHeader.startsWith('Bearer ')) {
|
||||||
|
try {
|
||||||
|
const decoded = atob(authHeader.slice(7));
|
||||||
|
createdBy = decoded.split(':')[0];
|
||||||
|
} catch {
|
||||||
|
// Keep default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token in database
|
||||||
|
const token = this.oneboxRef.database.createRegistryToken({
|
||||||
|
name: body.name,
|
||||||
|
tokenHash,
|
||||||
|
type: body.type,
|
||||||
|
scope: body.scope,
|
||||||
|
expiresAt,
|
||||||
|
createdAt: now,
|
||||||
|
lastUsedAt: null,
|
||||||
|
createdBy,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Build view response
|
||||||
|
let scopeDisplay: string;
|
||||||
|
if (token.scope === 'all') {
|
||||||
|
scopeDisplay = 'All services';
|
||||||
|
} else if (Array.isArray(token.scope)) {
|
||||||
|
scopeDisplay = token.scope.length === 1
|
||||||
|
? token.scope[0]
|
||||||
|
: `${token.scope.length} services`;
|
||||||
|
} else {
|
||||||
|
scopeDisplay = 'Unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenView: IRegistryTokenView = {
|
||||||
|
id: token.id!,
|
||||||
|
name: token.name,
|
||||||
|
type: token.type,
|
||||||
|
scope: token.scope,
|
||||||
|
scopeDisplay,
|
||||||
|
expiresAt: token.expiresAt,
|
||||||
|
createdAt: token.createdAt,
|
||||||
|
lastUsedAt: token.lastUsedAt,
|
||||||
|
createdBy: token.createdBy,
|
||||||
|
isExpired: false,
|
||||||
|
};
|
||||||
|
|
||||||
return this.jsonResponse({
|
return this.jsonResponse({
|
||||||
success: true,
|
success: true,
|
||||||
data: {
|
data: {
|
||||||
token: token,
|
token: tokenView,
|
||||||
repository: serviceName,
|
plainToken, // Only returned once at creation
|
||||||
baseUrl: this.oneboxRef.registry.getBaseUrl(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to get registry token for ${serviceName}: ${error.message}`);
|
logger.error(`Failed to create registry token: ${error.message}`);
|
||||||
return this.jsonResponse({
|
return this.jsonResponse({
|
||||||
success: false,
|
success: false,
|
||||||
error: error.message || 'Failed to get registry token',
|
error: error.message || 'Failed to create registry token',
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async handleDeleteRegistryTokenRequest(tokenId: number): Promise<Response> {
|
||||||
|
try {
|
||||||
|
// Check if token exists
|
||||||
|
const token = this.oneboxRef.database.getRegistryTokenById(tokenId);
|
||||||
|
if (!token) {
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: 'Token not found',
|
||||||
|
}, 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the token
|
||||||
|
this.oneboxRef.database.deleteRegistryToken(tokenId);
|
||||||
|
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: true,
|
||||||
|
message: 'Token deleted successfully',
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to delete registry token ${tokenId}: ${error.message}`);
|
||||||
|
return this.jsonResponse({
|
||||||
|
success: false,
|
||||||
|
error: error.message || 'Failed to delete registry token',
|
||||||
}, 500);
|
}, 500);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { OneboxHttpServer } from './httpserver.ts';
|
|||||||
import { CloudflareDomainSync } from './cloudflare-sync.ts';
|
import { CloudflareDomainSync } from './cloudflare-sync.ts';
|
||||||
import { CertRequirementManager } from './cert-requirement-manager.ts';
|
import { CertRequirementManager } from './cert-requirement-manager.ts';
|
||||||
import { RegistryManager } from './registry.ts';
|
import { RegistryManager } from './registry.ts';
|
||||||
|
import { PlatformServicesManager } from './platform-services/index.ts';
|
||||||
|
|
||||||
export class Onebox {
|
export class Onebox {
|
||||||
public database: OneboxDatabase;
|
public database: OneboxDatabase;
|
||||||
@@ -31,6 +32,7 @@ export class Onebox {
|
|||||||
public cloudflareDomainSync: CloudflareDomainSync;
|
public cloudflareDomainSync: CloudflareDomainSync;
|
||||||
public certRequirementManager: CertRequirementManager;
|
public certRequirementManager: CertRequirementManager;
|
||||||
public registry: RegistryManager;
|
public registry: RegistryManager;
|
||||||
|
public platformServices: PlatformServicesManager;
|
||||||
|
|
||||||
private initialized = false;
|
private initialized = false;
|
||||||
|
|
||||||
@@ -56,6 +58,9 @@ export class Onebox {
|
|||||||
// Initialize domain management
|
// Initialize domain management
|
||||||
this.cloudflareDomainSync = new CloudflareDomainSync(this.database);
|
this.cloudflareDomainSync = new CloudflareDomainSync(this.database);
|
||||||
this.certRequirementManager = new CertRequirementManager(this.database, this.ssl);
|
this.certRequirementManager = new CertRequirementManager(this.database, this.ssl);
|
||||||
|
|
||||||
|
// Initialize platform services manager
|
||||||
|
this.platformServices = new PlatformServicesManager(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -106,6 +111,14 @@ export class Onebox {
|
|||||||
logger.warn(`Error: ${error.message}`);
|
logger.warn(`Error: ${error.message}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize Platform Services (non-critical)
|
||||||
|
try {
|
||||||
|
await this.platformServices.init();
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn('Platform services initialization failed - MongoDB/S3 features will be limited');
|
||||||
|
logger.warn(`Error: ${error.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
// Login to all registries
|
// Login to all registries
|
||||||
await this.registries.loginToAllRegistries();
|
await this.registries.loginToAllRegistries();
|
||||||
|
|
||||||
@@ -170,6 +183,13 @@ export class Onebox {
|
|||||||
const runningServices = services.filter((s) => s.status === 'running').length;
|
const runningServices = services.filter((s) => s.status === 'running').length;
|
||||||
const totalServices = services.length;
|
const totalServices = services.length;
|
||||||
|
|
||||||
|
// Get platform services status
|
||||||
|
const platformServices = this.platformServices.getAllPlatformServices();
|
||||||
|
const platformServicesStatus = platformServices.map((ps) => ({
|
||||||
|
type: ps.type,
|
||||||
|
status: ps.status,
|
||||||
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
docker: {
|
docker: {
|
||||||
running: dockerRunning,
|
running: dockerRunning,
|
||||||
@@ -188,6 +208,7 @@ export class Onebox {
|
|||||||
running: runningServices,
|
running: runningServices,
|
||||||
stopped: totalServices - runningServices,
|
stopped: totalServices - runningServices,
|
||||||
},
|
},
|
||||||
|
platformServices: platformServicesStatus,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to get system status: ${error.message}`);
|
logger.error(`Failed to get system status: ${error.message}`);
|
||||||
|
|||||||
10
ts/classes/platform-services/index.ts
Normal file
10
ts/classes/platform-services/index.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Platform Services Module
|
||||||
|
* Exports all platform service related classes and types
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { PlatformServicesManager } from './manager.ts';
|
||||||
|
export type { IPlatformServiceProvider } from './providers/base.ts';
|
||||||
|
export { BasePlatformServiceProvider } from './providers/base.ts';
|
||||||
|
export { MongoDBProvider } from './providers/mongodb.ts';
|
||||||
|
export { MinioProvider } from './providers/minio.ts';
|
||||||
361
ts/classes/platform-services/manager.ts
Normal file
361
ts/classes/platform-services/manager.ts
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
/**
|
||||||
|
* Platform Services Manager
|
||||||
|
* Orchestrates platform services (MongoDB, MinIO) and their resources
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IService,
|
||||||
|
IPlatformService,
|
||||||
|
IPlatformResource,
|
||||||
|
IPlatformRequirements,
|
||||||
|
IProvisionedResource,
|
||||||
|
TPlatformServiceType,
|
||||||
|
} from '../../types.ts';
|
||||||
|
import type { IPlatformServiceProvider } from './providers/base.ts';
|
||||||
|
import { MongoDBProvider } from './providers/mongodb.ts';
|
||||||
|
import { MinioProvider } from './providers/minio.ts';
|
||||||
|
import { logger } from '../../logging.ts';
|
||||||
|
import { credentialEncryption } from '../encryption.ts';
|
||||||
|
import type { Onebox } from '../onebox.ts';
|
||||||
|
|
||||||
|
export class PlatformServicesManager {
|
||||||
|
private oneboxRef: Onebox;
|
||||||
|
private providers = new Map<TPlatformServiceType, IPlatformServiceProvider>();
|
||||||
|
|
||||||
|
constructor(oneboxRef: Onebox) {
|
||||||
|
this.oneboxRef = oneboxRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the platform services manager
|
||||||
|
*/
|
||||||
|
async init(): Promise<void> {
|
||||||
|
// Initialize encryption
|
||||||
|
await credentialEncryption.init();
|
||||||
|
|
||||||
|
// Register providers
|
||||||
|
this.registerProvider(new MongoDBProvider(this.oneboxRef));
|
||||||
|
this.registerProvider(new MinioProvider(this.oneboxRef));
|
||||||
|
|
||||||
|
logger.info(`Platform services manager initialized with ${this.providers.size} providers`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a platform service provider
|
||||||
|
*/
|
||||||
|
registerProvider(provider: IPlatformServiceProvider): void {
|
||||||
|
this.providers.set(provider.type, provider);
|
||||||
|
logger.debug(`Registered platform service provider: ${provider.displayName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get a provider by type
|
||||||
|
*/
|
||||||
|
getProvider(type: TPlatformServiceType): IPlatformServiceProvider | undefined {
|
||||||
|
return this.providers.get(type);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all registered providers
|
||||||
|
*/
|
||||||
|
getAllProviders(): IPlatformServiceProvider[] {
|
||||||
|
return Array.from(this.providers.values());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ensure a platform service is running, deploying it if necessary
|
||||||
|
*/
|
||||||
|
async ensureRunning(type: TPlatformServiceType): Promise<IPlatformService> {
|
||||||
|
const provider = this.providers.get(type);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`Unknown platform service type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if platform service exists in database
|
||||||
|
let platformService = this.oneboxRef.database.getPlatformServiceByType(type);
|
||||||
|
|
||||||
|
if (!platformService) {
|
||||||
|
// Create platform service record
|
||||||
|
logger.info(`Creating new ${provider.displayName} platform service...`);
|
||||||
|
const config = provider.getDefaultConfig();
|
||||||
|
|
||||||
|
platformService = this.oneboxRef.database.createPlatformService({
|
||||||
|
name: `onebox-${type}`,
|
||||||
|
type,
|
||||||
|
status: 'stopped',
|
||||||
|
config,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if already running
|
||||||
|
if (platformService.status === 'running') {
|
||||||
|
// Verify it's actually healthy
|
||||||
|
const isHealthy = await provider.healthCheck();
|
||||||
|
if (isHealthy) {
|
||||||
|
logger.debug(`${provider.displayName} is already running and healthy`);
|
||||||
|
return platformService;
|
||||||
|
}
|
||||||
|
logger.warn(`${provider.displayName} reports running but health check failed, restarting...`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deploy if not running
|
||||||
|
if (platformService.status !== 'running') {
|
||||||
|
logger.info(`Starting ${provider.displayName} platform service...`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'starting' });
|
||||||
|
|
||||||
|
const containerId = await provider.deployContainer();
|
||||||
|
|
||||||
|
// Wait for health check to pass
|
||||||
|
const healthy = await this.waitForHealthy(type, 60000); // 60 second timeout
|
||||||
|
|
||||||
|
if (healthy) {
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||||
|
status: 'running',
|
||||||
|
containerId,
|
||||||
|
});
|
||||||
|
logger.success(`${provider.displayName} platform service is now running`);
|
||||||
|
} else {
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'failed' });
|
||||||
|
throw new Error(`${provider.displayName} failed to start within timeout`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh platform service from database
|
||||||
|
platformService = this.oneboxRef.database.getPlatformServiceByType(type)!;
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to start ${provider.displayName}: ${error.message}`);
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'failed' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return platformService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a platform service to become healthy
|
||||||
|
*/
|
||||||
|
private async waitForHealthy(type: TPlatformServiceType, timeoutMs: number): Promise<boolean> {
|
||||||
|
const provider = this.providers.get(type);
|
||||||
|
if (!provider) return false;
|
||||||
|
|
||||||
|
const startTime = Date.now();
|
||||||
|
const checkInterval = 2000; // Check every 2 seconds
|
||||||
|
|
||||||
|
while (Date.now() - startTime < timeoutMs) {
|
||||||
|
const isHealthy = await provider.healthCheck();
|
||||||
|
if (isHealthy) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, checkInterval));
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop a platform service
|
||||||
|
*/
|
||||||
|
async stopPlatformService(type: TPlatformServiceType): Promise<void> {
|
||||||
|
const provider = this.providers.get(type);
|
||||||
|
if (!provider) {
|
||||||
|
throw new Error(`Unknown platform service type: ${type}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(type);
|
||||||
|
if (!platformService) {
|
||||||
|
logger.warn(`Platform service ${type} not found`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!platformService.containerId) {
|
||||||
|
logger.warn(`Platform service ${type} has no container ID`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.info(`Stopping ${provider.displayName} platform service...`);
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'stopping' });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await provider.stopContainer(platformService.containerId);
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||||
|
status: 'stopped',
|
||||||
|
containerId: undefined,
|
||||||
|
});
|
||||||
|
logger.success(`${provider.displayName} platform service stopped`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to stop ${provider.displayName}: ${error.message}`);
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, { status: 'failed' });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision platform resources for a user service based on its requirements
|
||||||
|
*/
|
||||||
|
async provisionForService(service: IService): Promise<Record<string, string>> {
|
||||||
|
const requirements = service.platformRequirements;
|
||||||
|
if (!requirements) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const allEnvVars: Record<string, string> = {};
|
||||||
|
|
||||||
|
// Provision MongoDB if requested
|
||||||
|
if (requirements.mongodb) {
|
||||||
|
logger.info(`Provisioning MongoDB for service '${service.name}'...`);
|
||||||
|
|
||||||
|
// Ensure MongoDB is running
|
||||||
|
const mongoService = await this.ensureRunning('mongodb');
|
||||||
|
const provider = this.providers.get('mongodb')!;
|
||||||
|
|
||||||
|
// Provision database
|
||||||
|
const result = await provider.provisionResource(service);
|
||||||
|
|
||||||
|
// Store resource record
|
||||||
|
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
|
||||||
|
this.oneboxRef.database.createPlatformResource({
|
||||||
|
platformServiceId: mongoService.id!,
|
||||||
|
serviceId: service.id!,
|
||||||
|
resourceType: result.type,
|
||||||
|
resourceName: result.name,
|
||||||
|
credentialsEncrypted: encryptedCreds,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge env vars
|
||||||
|
Object.assign(allEnvVars, result.envVars);
|
||||||
|
logger.success(`MongoDB provisioned for service '${service.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Provision S3/MinIO if requested
|
||||||
|
if (requirements.s3) {
|
||||||
|
logger.info(`Provisioning S3 storage for service '${service.name}'...`);
|
||||||
|
|
||||||
|
// Ensure MinIO is running
|
||||||
|
const minioService = await this.ensureRunning('minio');
|
||||||
|
const provider = this.providers.get('minio')!;
|
||||||
|
|
||||||
|
// Provision bucket
|
||||||
|
const result = await provider.provisionResource(service);
|
||||||
|
|
||||||
|
// Store resource record
|
||||||
|
const encryptedCreds = await credentialEncryption.encrypt(result.credentials);
|
||||||
|
this.oneboxRef.database.createPlatformResource({
|
||||||
|
platformServiceId: minioService.id!,
|
||||||
|
serviceId: service.id!,
|
||||||
|
resourceType: result.type,
|
||||||
|
resourceName: result.name,
|
||||||
|
credentialsEncrypted: encryptedCreds,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Merge env vars
|
||||||
|
Object.assign(allEnvVars, result.envVars);
|
||||||
|
logger.success(`S3 storage provisioned for service '${service.name}'`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEnvVars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup platform resources when a user service is deleted
|
||||||
|
*/
|
||||||
|
async cleanupForService(serviceId: number): Promise<void> {
|
||||||
|
const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId);
|
||||||
|
|
||||||
|
for (const resource of resources) {
|
||||||
|
try {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId);
|
||||||
|
if (!platformService) {
|
||||||
|
logger.warn(`Platform service not found for resource ${resource.id}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const provider = this.providers.get(platformService.type);
|
||||||
|
if (!provider) {
|
||||||
|
logger.warn(`Provider not found for type ${platformService.type}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt credentials
|
||||||
|
const credentials = await credentialEncryption.decrypt(resource.credentialsEncrypted);
|
||||||
|
|
||||||
|
// Deprovision the resource
|
||||||
|
logger.info(`Cleaning up ${resource.resourceType} '${resource.resourceName}'...`);
|
||||||
|
await provider.deprovisionResource(resource, credentials);
|
||||||
|
|
||||||
|
// Delete resource record
|
||||||
|
this.oneboxRef.database.deletePlatformResource(resource.id!);
|
||||||
|
logger.success(`Cleaned up ${resource.resourceType} '${resource.resourceName}'`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to cleanup resource ${resource.id}: ${error.message}`);
|
||||||
|
// Continue with other resources even if one fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get injected environment variables for a service
|
||||||
|
*/
|
||||||
|
async getInjectedEnvVars(serviceId: number): Promise<Record<string, string>> {
|
||||||
|
const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId);
|
||||||
|
const allEnvVars: Record<string, string> = {};
|
||||||
|
|
||||||
|
for (const resource of resources) {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId);
|
||||||
|
if (!platformService) continue;
|
||||||
|
|
||||||
|
const provider = this.providers.get(platformService.type);
|
||||||
|
if (!provider) continue;
|
||||||
|
|
||||||
|
const credentials = await credentialEncryption.decrypt(resource.credentialsEncrypted);
|
||||||
|
const mappings = provider.getEnvVarMappings();
|
||||||
|
|
||||||
|
for (const mapping of mappings) {
|
||||||
|
if (credentials[mapping.credentialPath]) {
|
||||||
|
allEnvVars[mapping.envVar] = credentials[mapping.credentialPath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return allEnvVars;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all platform services with their status
|
||||||
|
*/
|
||||||
|
getAllPlatformServices(): IPlatformService[] {
|
||||||
|
return this.oneboxRef.database.getAllPlatformServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get resources for a specific user service
|
||||||
|
*/
|
||||||
|
async getResourcesForService(serviceId: number): Promise<Array<{
|
||||||
|
resource: IPlatformResource;
|
||||||
|
platformService: IPlatformService;
|
||||||
|
credentials: Record<string, string>;
|
||||||
|
}>> {
|
||||||
|
const resources = this.oneboxRef.database.getPlatformResourcesByService(serviceId);
|
||||||
|
const result = [];
|
||||||
|
|
||||||
|
for (const resource of resources) {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceById(resource.platformServiceId);
|
||||||
|
if (!platformService) continue;
|
||||||
|
|
||||||
|
const credentials = await credentialEncryption.decrypt(resource.credentialsEncrypted);
|
||||||
|
|
||||||
|
result.push({
|
||||||
|
resource,
|
||||||
|
platformService,
|
||||||
|
credentials,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
123
ts/classes/platform-services/providers/base.ts
Normal file
123
ts/classes/platform-services/providers/base.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
/**
|
||||||
|
* Base interface and types for platform service providers
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
IService,
|
||||||
|
IPlatformService,
|
||||||
|
IPlatformResource,
|
||||||
|
IPlatformServiceConfig,
|
||||||
|
IProvisionedResource,
|
||||||
|
IEnvVarMapping,
|
||||||
|
TPlatformServiceType,
|
||||||
|
TPlatformResourceType,
|
||||||
|
} from '../../../types.ts';
|
||||||
|
import type { Onebox } from '../../onebox.ts';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Interface that all platform service providers must implement
|
||||||
|
*/
|
||||||
|
export interface IPlatformServiceProvider {
|
||||||
|
/** Unique identifier for this provider type */
|
||||||
|
readonly type: TPlatformServiceType;
|
||||||
|
|
||||||
|
/** Human-readable display name */
|
||||||
|
readonly displayName: string;
|
||||||
|
|
||||||
|
/** Resource types this provider can provision */
|
||||||
|
readonly resourceTypes: TPlatformResourceType[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the default configuration for this platform service
|
||||||
|
*/
|
||||||
|
getDefaultConfig(): IPlatformServiceConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deploy the platform service container
|
||||||
|
* @returns The container ID
|
||||||
|
*/
|
||||||
|
deployContainer(): Promise<string>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop the platform service container
|
||||||
|
*/
|
||||||
|
stopContainer(containerId: string): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if the platform service is healthy and ready to accept connections
|
||||||
|
*/
|
||||||
|
healthCheck(): Promise<boolean>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provision a resource for a user service (e.g., create database, bucket)
|
||||||
|
* @param userService The user service requesting the resource
|
||||||
|
* @returns Provisioned resource with credentials and env var mappings
|
||||||
|
*/
|
||||||
|
provisionResource(userService: IService): Promise<IProvisionedResource>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deprovision a resource (e.g., drop database, delete bucket)
|
||||||
|
* @param resource The resource to deprovision
|
||||||
|
*/
|
||||||
|
deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the environment variable mappings for this provider
|
||||||
|
*/
|
||||||
|
getEnvVarMappings(): IEnvVarMapping[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base class for platform service providers with common functionality
|
||||||
|
*/
|
||||||
|
export abstract class BasePlatformServiceProvider implements IPlatformServiceProvider {
|
||||||
|
abstract readonly type: TPlatformServiceType;
|
||||||
|
abstract readonly displayName: string;
|
||||||
|
abstract readonly resourceTypes: TPlatformResourceType[];
|
||||||
|
|
||||||
|
protected oneboxRef: Onebox;
|
||||||
|
|
||||||
|
constructor(oneboxRef: Onebox) {
|
||||||
|
this.oneboxRef = oneboxRef;
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract getDefaultConfig(): IPlatformServiceConfig;
|
||||||
|
abstract deployContainer(): Promise<string>;
|
||||||
|
abstract stopContainer(containerId: string): Promise<void>;
|
||||||
|
abstract healthCheck(): Promise<boolean>;
|
||||||
|
abstract provisionResource(userService: IService): Promise<IProvisionedResource>;
|
||||||
|
abstract deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void>;
|
||||||
|
abstract getEnvVarMappings(): IEnvVarMapping[];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the internal Docker network name for platform services
|
||||||
|
*/
|
||||||
|
protected getNetworkName(): string {
|
||||||
|
return 'onebox-network';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the container name for this platform service
|
||||||
|
*/
|
||||||
|
protected getContainerName(): string {
|
||||||
|
return `onebox-${this.type}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a resource name from a user service name
|
||||||
|
*/
|
||||||
|
protected generateResourceName(serviceName: string, prefix: string = 'onebox'): string {
|
||||||
|
// Replace dashes with underscores for database compatibility
|
||||||
|
const sanitized = serviceName.replace(/-/g, '_');
|
||||||
|
return `${prefix}_${sanitized}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a bucket name from a user service name
|
||||||
|
*/
|
||||||
|
protected generateBucketName(serviceName: string, prefix: string = 'onebox'): string {
|
||||||
|
// Buckets use dashes, lowercase
|
||||||
|
const sanitized = serviceName.toLowerCase().replace(/_/g, '-');
|
||||||
|
return `${prefix}-${sanitized}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
299
ts/classes/platform-services/providers/minio.ts
Normal file
299
ts/classes/platform-services/providers/minio.ts
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
/**
|
||||||
|
* MinIO (S3-compatible) Platform Service Provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BasePlatformServiceProvider } from './base.ts';
|
||||||
|
import type {
|
||||||
|
IService,
|
||||||
|
IPlatformResource,
|
||||||
|
IPlatformServiceConfig,
|
||||||
|
IProvisionedResource,
|
||||||
|
IEnvVarMapping,
|
||||||
|
TPlatformServiceType,
|
||||||
|
TPlatformResourceType,
|
||||||
|
} from '../../../types.ts';
|
||||||
|
import { logger } from '../../../logging.ts';
|
||||||
|
import { credentialEncryption } from '../../encryption.ts';
|
||||||
|
import type { Onebox } from '../../onebox.ts';
|
||||||
|
|
||||||
|
export class MinioProvider extends BasePlatformServiceProvider {
|
||||||
|
readonly type: TPlatformServiceType = 'minio';
|
||||||
|
readonly displayName = 'S3 Storage (MinIO)';
|
||||||
|
readonly resourceTypes: TPlatformResourceType[] = ['bucket'];
|
||||||
|
|
||||||
|
constructor(oneboxRef: Onebox) {
|
||||||
|
super(oneboxRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultConfig(): IPlatformServiceConfig {
|
||||||
|
return {
|
||||||
|
image: 'minio/minio:latest',
|
||||||
|
port: 9000,
|
||||||
|
volumes: ['/var/lib/onebox/minio:/data'],
|
||||||
|
command: 'server /data --console-address :9001',
|
||||||
|
environment: {
|
||||||
|
MINIO_ROOT_USER: 'admin',
|
||||||
|
// Password will be generated and stored encrypted
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnvVarMappings(): IEnvVarMapping[] {
|
||||||
|
return [
|
||||||
|
{ envVar: 'S3_ENDPOINT', credentialPath: 'endpoint' },
|
||||||
|
{ envVar: 'S3_BUCKET', credentialPath: 'bucket' },
|
||||||
|
{ envVar: 'S3_ACCESS_KEY', credentialPath: 'accessKey' },
|
||||||
|
{ envVar: 'S3_SECRET_KEY', credentialPath: 'secretKey' },
|
||||||
|
{ envVar: 'S3_REGION', credentialPath: 'region' },
|
||||||
|
// AWS SDK compatible names
|
||||||
|
{ envVar: 'AWS_ACCESS_KEY_ID', credentialPath: 'accessKey' },
|
||||||
|
{ envVar: 'AWS_SECRET_ACCESS_KEY', credentialPath: 'secretKey' },
|
||||||
|
{ envVar: 'AWS_ENDPOINT_URL', credentialPath: 'endpoint' },
|
||||||
|
{ envVar: 'AWS_REGION', credentialPath: 'region' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployContainer(): Promise<string> {
|
||||||
|
const config = this.getDefaultConfig();
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
|
// Generate admin credentials
|
||||||
|
const adminUser = 'admin';
|
||||||
|
const adminPassword = credentialEncryption.generatePassword(32);
|
||||||
|
|
||||||
|
const adminCredentials = {
|
||||||
|
username: adminUser,
|
||||||
|
password: adminPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Deploying MinIO platform service as ${containerName}...`);
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
try {
|
||||||
|
await Deno.mkdir('/var/lib/onebox/minio', { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
||||||
|
logger.warn(`Could not create MinIO data directory: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create container using Docker API
|
||||||
|
const envVars = [
|
||||||
|
`MINIO_ROOT_USER=${adminCredentials.username}`,
|
||||||
|
`MINIO_ROOT_PASSWORD=${adminCredentials.password}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
const containerId = await this.oneboxRef.docker.createPlatformContainer({
|
||||||
|
name: containerName,
|
||||||
|
image: config.image,
|
||||||
|
port: config.port,
|
||||||
|
env: envVars,
|
||||||
|
volumes: config.volumes,
|
||||||
|
network: this.getNetworkName(),
|
||||||
|
command: config.command?.split(' '),
|
||||||
|
exposePorts: [9000, 9001], // API and Console ports
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store encrypted admin credentials
|
||||||
|
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (platformService) {
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||||
|
containerId,
|
||||||
|
adminCredentialsEncrypted: encryptedCreds,
|
||||||
|
status: 'starting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`MinIO container created: ${containerId}`);
|
||||||
|
return containerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopContainer(containerId: string): Promise<void> {
|
||||||
|
logger.info(`Stopping MinIO container ${containerId}...`);
|
||||||
|
await this.oneboxRef.docker.stopContainer(containerId);
|
||||||
|
logger.success('MinIO container stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
const endpoint = `http://${containerName}:9000/minio/health/live`;
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'GET',
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.ok;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`MinIO health check failed: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async provisionResource(userService: IService): Promise<IProvisionedResource> {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||||
|
throw new Error('MinIO platform service not found or not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
|
// Generate bucket name and credentials
|
||||||
|
const bucketName = this.generateBucketName(userService.name);
|
||||||
|
const accessKey = credentialEncryption.generateAccessKey(20);
|
||||||
|
const secretKey = credentialEncryption.generateSecretKey(40);
|
||||||
|
|
||||||
|
logger.info(`Provisioning MinIO bucket '${bucketName}' for service '${userService.name}'...`);
|
||||||
|
|
||||||
|
const endpoint = `http://${containerName}:9000`;
|
||||||
|
|
||||||
|
// Import AWS S3 client
|
||||||
|
const { S3Client, CreateBucketCommand, PutBucketPolicyCommand } = await import('npm:@aws-sdk/client-s3@3');
|
||||||
|
|
||||||
|
// Create S3 client with admin credentials
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
endpoint,
|
||||||
|
region: 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: adminCreds.username,
|
||||||
|
secretAccessKey: adminCreds.password,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create the bucket
|
||||||
|
try {
|
||||||
|
await s3Client.send(new CreateBucketCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
}));
|
||||||
|
logger.info(`Created MinIO bucket '${bucketName}'`);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e.name !== 'BucketAlreadyOwnedByYou' && e.name !== 'BucketAlreadyExists') {
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
logger.warn(`Bucket '${bucketName}' already exists`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create service account/access key using MinIO Admin API
|
||||||
|
// MinIO Admin API requires mc client or direct API calls
|
||||||
|
// For simplicity, we'll use root credentials and bucket policy isolation
|
||||||
|
// In production, you'd use MinIO's Admin API to create service accounts
|
||||||
|
|
||||||
|
// Set bucket policy to allow access only with this bucket's credentials
|
||||||
|
const bucketPolicy = {
|
||||||
|
Version: '2012-10-17',
|
||||||
|
Statement: [
|
||||||
|
{
|
||||||
|
Effect: 'Allow',
|
||||||
|
Principal: { AWS: ['*'] },
|
||||||
|
Action: ['s3:GetObject', 's3:PutObject', 's3:DeleteObject', 's3:ListBucket'],
|
||||||
|
Resource: [
|
||||||
|
`arn:aws:s3:::${bucketName}`,
|
||||||
|
`arn:aws:s3:::${bucketName}/*`,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await s3Client.send(new PutBucketPolicyCommand({
|
||||||
|
Bucket: bucketName,
|
||||||
|
Policy: JSON.stringify(bucketPolicy),
|
||||||
|
}));
|
||||||
|
logger.info(`Set bucket policy for '${bucketName}'`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Could not set bucket policy: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: For proper per-service credentials, MinIO Admin API should be used
|
||||||
|
// For now, we're providing the bucket with root access
|
||||||
|
// TODO: Implement MinIO service account creation
|
||||||
|
logger.warn('Using root credentials for MinIO access. Consider implementing service accounts for production.');
|
||||||
|
|
||||||
|
const credentials: Record<string, string> = {
|
||||||
|
endpoint,
|
||||||
|
bucket: bucketName,
|
||||||
|
accessKey: adminCreds.username, // Using root for now
|
||||||
|
secretKey: adminCreds.password,
|
||||||
|
region: 'us-east-1',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map credentials to env vars
|
||||||
|
const envVars: Record<string, string> = {};
|
||||||
|
for (const mapping of this.getEnvVarMappings()) {
|
||||||
|
if (credentials[mapping.credentialPath]) {
|
||||||
|
envVars[mapping.envVar] = credentials[mapping.credentialPath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`MinIO bucket '${bucketName}' provisioned`);
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'bucket',
|
||||||
|
name: bucketName,
|
||||||
|
credentials,
|
||||||
|
envVars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||||
|
throw new Error('MinIO platform service not found or not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
const endpoint = `http://${containerName}:9000`;
|
||||||
|
|
||||||
|
logger.info(`Deprovisioning MinIO bucket '${resource.resourceName}'...`);
|
||||||
|
|
||||||
|
const { S3Client, DeleteBucketCommand, ListObjectsV2Command, DeleteObjectsCommand } = await import('npm:@aws-sdk/client-s3@3');
|
||||||
|
|
||||||
|
const s3Client = new S3Client({
|
||||||
|
endpoint,
|
||||||
|
region: 'us-east-1',
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: adminCreds.username,
|
||||||
|
secretAccessKey: adminCreds.password,
|
||||||
|
},
|
||||||
|
forcePathStyle: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, delete all objects in the bucket
|
||||||
|
let continuationToken: string | undefined;
|
||||||
|
do {
|
||||||
|
const listResponse = await s3Client.send(new ListObjectsV2Command({
|
||||||
|
Bucket: resource.resourceName,
|
||||||
|
ContinuationToken: continuationToken,
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (listResponse.Contents && listResponse.Contents.length > 0) {
|
||||||
|
await s3Client.send(new DeleteObjectsCommand({
|
||||||
|
Bucket: resource.resourceName,
|
||||||
|
Delete: {
|
||||||
|
Objects: listResponse.Contents.map(obj => ({ Key: obj.Key! })),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
logger.info(`Deleted ${listResponse.Contents.length} objects from bucket`);
|
||||||
|
}
|
||||||
|
|
||||||
|
continuationToken = listResponse.IsTruncated ? listResponse.NextContinuationToken : undefined;
|
||||||
|
} while (continuationToken);
|
||||||
|
|
||||||
|
// Now delete the bucket
|
||||||
|
await s3Client.send(new DeleteBucketCommand({
|
||||||
|
Bucket: resource.resourceName,
|
||||||
|
}));
|
||||||
|
|
||||||
|
logger.success(`MinIO bucket '${resource.resourceName}' deleted`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.error(`Failed to delete MinIO bucket: ${e.message}`);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
246
ts/classes/platform-services/providers/mongodb.ts
Normal file
246
ts/classes/platform-services/providers/mongodb.ts
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
/**
|
||||||
|
* MongoDB Platform Service Provider
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { BasePlatformServiceProvider } from './base.ts';
|
||||||
|
import type {
|
||||||
|
IService,
|
||||||
|
IPlatformResource,
|
||||||
|
IPlatformServiceConfig,
|
||||||
|
IProvisionedResource,
|
||||||
|
IEnvVarMapping,
|
||||||
|
TPlatformServiceType,
|
||||||
|
TPlatformResourceType,
|
||||||
|
} from '../../../types.ts';
|
||||||
|
import { logger } from '../../../logging.ts';
|
||||||
|
import { credentialEncryption } from '../../encryption.ts';
|
||||||
|
import type { Onebox } from '../../onebox.ts';
|
||||||
|
|
||||||
|
export class MongoDBProvider extends BasePlatformServiceProvider {
|
||||||
|
readonly type: TPlatformServiceType = 'mongodb';
|
||||||
|
readonly displayName = 'MongoDB';
|
||||||
|
readonly resourceTypes: TPlatformResourceType[] = ['database'];
|
||||||
|
|
||||||
|
constructor(oneboxRef: Onebox) {
|
||||||
|
super(oneboxRef);
|
||||||
|
}
|
||||||
|
|
||||||
|
getDefaultConfig(): IPlatformServiceConfig {
|
||||||
|
return {
|
||||||
|
image: 'mongo:7',
|
||||||
|
port: 27017,
|
||||||
|
volumes: ['/var/lib/onebox/mongodb:/data/db'],
|
||||||
|
environment: {
|
||||||
|
MONGO_INITDB_ROOT_USERNAME: 'admin',
|
||||||
|
// Password will be generated and stored encrypted
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
getEnvVarMappings(): IEnvVarMapping[] {
|
||||||
|
return [
|
||||||
|
{ envVar: 'MONGODB_URI', credentialPath: 'connectionString' },
|
||||||
|
{ envVar: 'MONGODB_HOST', credentialPath: 'host' },
|
||||||
|
{ envVar: 'MONGODB_PORT', credentialPath: 'port' },
|
||||||
|
{ envVar: 'MONGODB_DATABASE', credentialPath: 'database' },
|
||||||
|
{ envVar: 'MONGODB_USERNAME', credentialPath: 'username' },
|
||||||
|
{ envVar: 'MONGODB_PASSWORD', credentialPath: 'password' },
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
async deployContainer(): Promise<string> {
|
||||||
|
const config = this.getDefaultConfig();
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
|
// Generate admin password
|
||||||
|
const adminPassword = credentialEncryption.generatePassword(32);
|
||||||
|
|
||||||
|
// Store admin credentials encrypted in the platform service record
|
||||||
|
const adminCredentials = {
|
||||||
|
username: 'admin',
|
||||||
|
password: adminPassword,
|
||||||
|
};
|
||||||
|
|
||||||
|
logger.info(`Deploying MongoDB platform service as ${containerName}...`);
|
||||||
|
|
||||||
|
// Ensure data directory exists
|
||||||
|
try {
|
||||||
|
await Deno.mkdir('/var/lib/onebox/mongodb', { recursive: true });
|
||||||
|
} catch (e) {
|
||||||
|
// Directory might already exist
|
||||||
|
if (!(e instanceof Deno.errors.AlreadyExists)) {
|
||||||
|
logger.warn(`Could not create MongoDB data directory: ${e.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create container using Docker API
|
||||||
|
const envVars = [
|
||||||
|
`MONGO_INITDB_ROOT_USERNAME=${adminCredentials.username}`,
|
||||||
|
`MONGO_INITDB_ROOT_PASSWORD=${adminCredentials.password}`,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Use Docker to create the container
|
||||||
|
const containerId = await this.oneboxRef.docker.createPlatformContainer({
|
||||||
|
name: containerName,
|
||||||
|
image: config.image,
|
||||||
|
port: config.port,
|
||||||
|
env: envVars,
|
||||||
|
volumes: config.volumes,
|
||||||
|
network: this.getNetworkName(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store encrypted admin credentials
|
||||||
|
const encryptedCreds = await credentialEncryption.encrypt(adminCredentials);
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (platformService) {
|
||||||
|
this.oneboxRef.database.updatePlatformService(platformService.id!, {
|
||||||
|
containerId,
|
||||||
|
adminCredentialsEncrypted: encryptedCreds,
|
||||||
|
status: 'starting',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
logger.success(`MongoDB container created: ${containerId}`);
|
||||||
|
return containerId;
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopContainer(containerId: string): Promise<void> {
|
||||||
|
logger.info(`Stopping MongoDB container ${containerId}...`);
|
||||||
|
await this.oneboxRef.docker.stopContainer(containerId);
|
||||||
|
logger.success('MongoDB container stopped');
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthCheck(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
|
// Try to connect to MongoDB using mongosh ping
|
||||||
|
const { MongoClient } = await import('npm:mongodb@6');
|
||||||
|
const uri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`;
|
||||||
|
|
||||||
|
const client = new MongoClient(uri, {
|
||||||
|
serverSelectionTimeoutMS: 5000,
|
||||||
|
connectTimeoutMS: 5000,
|
||||||
|
});
|
||||||
|
|
||||||
|
await client.connect();
|
||||||
|
await client.db('admin').command({ ping: 1 });
|
||||||
|
await client.close();
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.debug(`MongoDB health check failed: ${error.message}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async provisionResource(userService: IService): Promise<IProvisionedResource> {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||||
|
throw new Error('MongoDB platform service not found or not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
|
// Generate resource names and credentials
|
||||||
|
const dbName = this.generateResourceName(userService.name);
|
||||||
|
const username = this.generateResourceName(userService.name);
|
||||||
|
const password = credentialEncryption.generatePassword(32);
|
||||||
|
|
||||||
|
logger.info(`Provisioning MongoDB database '${dbName}' for service '${userService.name}'...`);
|
||||||
|
|
||||||
|
// Connect to MongoDB and create database/user
|
||||||
|
const { MongoClient } = await import('npm:mongodb@6');
|
||||||
|
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`;
|
||||||
|
|
||||||
|
const client = new MongoClient(adminUri);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Create the database by switching to it (MongoDB creates on first write)
|
||||||
|
const db = client.db(dbName);
|
||||||
|
|
||||||
|
// Create a collection to ensure the database exists
|
||||||
|
await db.createCollection('_onebox_init');
|
||||||
|
|
||||||
|
// Create user with readWrite access to this database
|
||||||
|
await db.command({
|
||||||
|
createUser: username,
|
||||||
|
pwd: password,
|
||||||
|
roles: [{ role: 'readWrite', db: dbName }],
|
||||||
|
});
|
||||||
|
|
||||||
|
logger.success(`MongoDB database '${dbName}' provisioned with user '${username}'`);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build the credentials and env vars
|
||||||
|
const credentials: Record<string, string> = {
|
||||||
|
host: containerName,
|
||||||
|
port: '27017',
|
||||||
|
database: dbName,
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
connectionString: `mongodb://${username}:${password}@${containerName}:27017/${dbName}?authSource=${dbName}`,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Map credentials to env vars
|
||||||
|
const envVars: Record<string, string> = {};
|
||||||
|
for (const mapping of this.getEnvVarMappings()) {
|
||||||
|
if (credentials[mapping.credentialPath]) {
|
||||||
|
envVars[mapping.envVar] = credentials[mapping.credentialPath];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'database',
|
||||||
|
name: dbName,
|
||||||
|
credentials,
|
||||||
|
envVars,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async deprovisionResource(resource: IPlatformResource, credentials: Record<string, string>): Promise<void> {
|
||||||
|
const platformService = this.oneboxRef.database.getPlatformServiceByType(this.type);
|
||||||
|
if (!platformService || !platformService.adminCredentialsEncrypted) {
|
||||||
|
throw new Error('MongoDB platform service not found or not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
const adminCreds = await credentialEncryption.decrypt(platformService.adminCredentialsEncrypted);
|
||||||
|
const containerName = this.getContainerName();
|
||||||
|
|
||||||
|
logger.info(`Deprovisioning MongoDB database '${resource.resourceName}'...`);
|
||||||
|
|
||||||
|
const { MongoClient } = await import('npm:mongodb@6');
|
||||||
|
const adminUri = `mongodb://${adminCreds.username}:${adminCreds.password}@${containerName}:27017/?authSource=admin`;
|
||||||
|
|
||||||
|
const client = new MongoClient(adminUri);
|
||||||
|
await client.connect();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const db = client.db(resource.resourceName);
|
||||||
|
|
||||||
|
// Drop the user
|
||||||
|
try {
|
||||||
|
await db.command({ dropUser: credentials.username });
|
||||||
|
logger.info(`Dropped MongoDB user '${credentials.username}'`);
|
||||||
|
} catch (e) {
|
||||||
|
logger.warn(`Could not drop MongoDB user: ${e.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop the database
|
||||||
|
await db.dropDatabase();
|
||||||
|
logger.success(`MongoDB database '${resource.resourceName}' dropped`);
|
||||||
|
} finally {
|
||||||
|
await client.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -107,30 +107,6 @@ export class RegistryManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a push/pull token for a service
|
|
||||||
*/
|
|
||||||
async createServiceToken(serviceName: string): Promise<string> {
|
|
||||||
if (!this.isInitialized) {
|
|
||||||
throw new Error('Registry not initialized');
|
|
||||||
}
|
|
||||||
|
|
||||||
const repository = serviceName;
|
|
||||||
const scopes = [
|
|
||||||
`oci:repository:${repository}:push`,
|
|
||||||
`oci:repository:${repository}:pull`,
|
|
||||||
];
|
|
||||||
|
|
||||||
// Create OCI JWT token (expires in 1 year = 365 * 24 * 60 * 60 seconds)
|
|
||||||
const token = await this.registry.authManager.createOciToken(
|
|
||||||
'onebox',
|
|
||||||
scopes,
|
|
||||||
31536000 // 365 days in seconds
|
|
||||||
);
|
|
||||||
|
|
||||||
return token;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all tags for a repository
|
* Get all tags for a repository
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -4,10 +4,11 @@
|
|||||||
* Orchestrates service deployment: Docker + Nginx + DNS + SSL
|
* Orchestrates service deployment: Docker + Nginx + DNS + SSL
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { IService, IServiceDeployOptions } from '../types.ts';
|
import type { IService, IServiceDeployOptions, IPlatformRequirements } from '../types.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
import { OneboxDatabase } from './database.ts';
|
import { OneboxDatabase } from './database.ts';
|
||||||
import { OneboxDockerManager } from './docker.ts';
|
import { OneboxDockerManager } from './docker.ts';
|
||||||
|
import type { PlatformServicesManager } from './platform-services/index.ts';
|
||||||
|
|
||||||
export class OneboxServicesManager {
|
export class OneboxServicesManager {
|
||||||
private oneboxRef: any; // Will be Onebox instance
|
private oneboxRef: any; // Will be Onebox instance
|
||||||
@@ -34,13 +35,9 @@ export class OneboxServicesManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle Onebox Registry setup
|
// Handle Onebox Registry setup
|
||||||
let registryToken: string | undefined;
|
|
||||||
let imageToPull: string;
|
let imageToPull: string;
|
||||||
|
|
||||||
if (options.useOneboxRegistry) {
|
if (options.useOneboxRegistry) {
|
||||||
// Generate registry token
|
|
||||||
registryToken = await this.oneboxRef.registry.createServiceToken(options.name);
|
|
||||||
|
|
||||||
// Use onebox registry image name
|
// Use onebox registry image name
|
||||||
const tag = options.registryImageTag || 'latest';
|
const tag = options.registryImageTag || 'latest';
|
||||||
imageToPull = this.oneboxRef.registry.getImageName(options.name, tag);
|
imageToPull = this.oneboxRef.registry.getImageName(options.name, tag);
|
||||||
@@ -49,6 +46,15 @@ export class OneboxServicesManager {
|
|||||||
imageToPull = options.image;
|
imageToPull = options.image;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build platform requirements
|
||||||
|
const platformRequirements: IPlatformRequirements | undefined =
|
||||||
|
(options.enableMongoDB || options.enableS3)
|
||||||
|
? {
|
||||||
|
mongodb: options.enableMongoDB,
|
||||||
|
s3: options.enableS3,
|
||||||
|
}
|
||||||
|
: undefined;
|
||||||
|
|
||||||
// Create service record in database
|
// Create service record in database
|
||||||
const service = await this.database.createService({
|
const service = await this.database.createService({
|
||||||
name: options.name,
|
name: options.name,
|
||||||
@@ -63,18 +69,46 @@ export class OneboxServicesManager {
|
|||||||
// Onebox Registry fields
|
// Onebox Registry fields
|
||||||
useOneboxRegistry: options.useOneboxRegistry,
|
useOneboxRegistry: options.useOneboxRegistry,
|
||||||
registryRepository: options.useOneboxRegistry ? options.name : undefined,
|
registryRepository: options.useOneboxRegistry ? options.name : undefined,
|
||||||
registryToken: registryToken,
|
|
||||||
registryImageTag: options.registryImageTag || 'latest',
|
registryImageTag: options.registryImageTag || 'latest',
|
||||||
autoUpdateOnPush: options.autoUpdateOnPush,
|
autoUpdateOnPush: options.autoUpdateOnPush,
|
||||||
|
// Platform requirements
|
||||||
|
platformRequirements,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Provision platform resources if needed
|
||||||
|
let platformEnvVars: Record<string, string> = {};
|
||||||
|
if (platformRequirements) {
|
||||||
|
try {
|
||||||
|
logger.info(`Provisioning platform resources for service '${options.name}'...`);
|
||||||
|
const platformServices = this.oneboxRef.platformServices as PlatformServicesManager;
|
||||||
|
platformEnvVars = await platformServices.provisionForService(service);
|
||||||
|
logger.success(`Platform resources provisioned for service '${options.name}'`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to provision platform resources: ${error.message}`);
|
||||||
|
// Clean up the service record on failure
|
||||||
|
this.database.deleteService(service.id!);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge platform env vars with user-specified env vars (user vars take precedence)
|
||||||
|
const mergedEnvVars = { ...platformEnvVars, ...(options.envVars || {}) };
|
||||||
|
|
||||||
|
// Update service with merged env vars
|
||||||
|
if (Object.keys(platformEnvVars).length > 0) {
|
||||||
|
this.database.updateService(service.id!, { envVars: mergedEnvVars });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get updated service with merged env vars
|
||||||
|
const serviceWithEnvVars = this.database.getServiceByName(options.name)!;
|
||||||
|
|
||||||
// Pull image (skip if using onebox registry - image might not exist yet)
|
// Pull image (skip if using onebox registry - image might not exist yet)
|
||||||
if (!options.useOneboxRegistry) {
|
if (!options.useOneboxRegistry) {
|
||||||
await this.docker.pullImage(imageToPull, options.registry);
|
await this.docker.pullImage(imageToPull, options.registry);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create container
|
// Create container (uses the updated service with merged env vars)
|
||||||
const containerID = await this.docker.createContainer(service);
|
const containerID = await this.docker.createContainer(serviceWithEnvVars);
|
||||||
|
|
||||||
// Update service with container ID
|
// Update service with container ID
|
||||||
this.database.updateService(service.id!, {
|
this.database.updateService(service.id!, {
|
||||||
@@ -293,6 +327,19 @@ export class OneboxServicesManager {
|
|||||||
// as they might be used by other services or need manual cleanup
|
// as they might be used by other services or need manual cleanup
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cleanup platform resources (MongoDB databases, S3 buckets, etc.)
|
||||||
|
if (service.platformRequirements) {
|
||||||
|
try {
|
||||||
|
logger.info(`Cleaning up platform resources for service '${name}'...`);
|
||||||
|
const platformServices = this.oneboxRef.platformServices as PlatformServicesManager;
|
||||||
|
await platformServices.cleanupForService(service.id!);
|
||||||
|
logger.success(`Platform resources cleaned up for service '${name}'`);
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Failed to cleanup platform resources: ${error.message}`);
|
||||||
|
// Continue with service deletion even if cleanup fails
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Remove from database
|
// Remove from database
|
||||||
this.database.deleteService(service.id!);
|
this.database.deleteService(service.id!);
|
||||||
|
|
||||||
@@ -392,6 +439,28 @@ export class OneboxServicesManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get platform resources for a service
|
||||||
|
*/
|
||||||
|
async getServicePlatformResources(name: string) {
|
||||||
|
try {
|
||||||
|
const service = this.database.getServiceByName(name);
|
||||||
|
if (!service) {
|
||||||
|
throw new Error(`Service not found: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!service.platformRequirements) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const platformServices = this.oneboxRef.platformServices as PlatformServicesManager;
|
||||||
|
return await platformServices.getResourcesForService(service.id!);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(`Failed to get platform resources for service ${name}: ${error.message}`);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get service status
|
* Get service status
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
|
|
||||||
import * as plugins from '../plugins.ts';
|
import * as plugins from '../plugins.ts';
|
||||||
import { logger } from '../logging.ts';
|
import { logger } from '../logging.ts';
|
||||||
|
import { getErrorMessage } from '../utils/error.ts';
|
||||||
import { OneboxDatabase } from './database.ts';
|
import { OneboxDatabase } from './database.ts';
|
||||||
import { SqliteCertManager } from './certmanager.ts';
|
import { SqliteCertManager } from './certmanager.ts';
|
||||||
|
|
||||||
@@ -77,7 +78,7 @@ export class OneboxSslManager {
|
|||||||
|
|
||||||
logger.success('SSL manager initialized with SmartACME DNS-01 challenge');
|
logger.success('SSL manager initialized with SmartACME DNS-01 challenge');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to initialize SSL manager: ${error.message}`);
|
logger.error(`Failed to initialize SSL manager: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -121,16 +122,23 @@ export class OneboxSslManager {
|
|||||||
// Reload certificates in reverse proxy
|
// Reload certificates in reverse proxy
|
||||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
|
|
||||||
// Return certificate data
|
// The certManager stores the cert to disk and database during getCertificateForDomain
|
||||||
|
// Look up the paths from the database
|
||||||
|
const dbCert = this.database.getSSLCertificate(domain);
|
||||||
|
if (!dbCert) {
|
||||||
|
throw new Error(`Certificate stored but not found in database for ${domain}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return certificate data from database
|
||||||
return {
|
return {
|
||||||
certPath: cert.certFilePath,
|
certPath: dbCert.certPath,
|
||||||
keyPath: cert.keyFilePath,
|
keyPath: dbCert.keyPath,
|
||||||
fullChainPath: cert.chainFilePath || cert.certFilePath,
|
fullChainPath: dbCert.fullChainPath,
|
||||||
expiryDate: cert.validUntil,
|
expiryDate: cert.validUntil,
|
||||||
issuer: cert.issuer || 'Let\'s Encrypt',
|
issuer: dbCert.issuer || 'Let\'s Encrypt',
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to acquire certificate for ${domain}: ${error.message}`);
|
logger.error(`Failed to acquire certificate for ${domain}: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -164,7 +172,7 @@ export class OneboxSslManager {
|
|||||||
// Reload certificates in reverse proxy
|
// Reload certificates in reverse proxy
|
||||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`);
|
logger.error(`Failed to obtain certificate for ${domain}: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -203,7 +211,7 @@ export class OneboxSslManager {
|
|||||||
|
|
||||||
logger.success(`Certbot obtained certificate for ${domain}`);
|
logger.success(`Certbot obtained certificate for ${domain}`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to run certbot: ${error.message}`);
|
throw new Error(`Failed to run certbot: ${getErrorMessage(error)}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +235,7 @@ export class OneboxSslManager {
|
|||||||
// Reload certificates in reverse proxy
|
// Reload certificates in reverse proxy
|
||||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
|
logger.error(`Failed to renew certificate for ${domain}: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -270,14 +278,14 @@ export class OneboxSslManager {
|
|||||||
await this.renewCertificate(dbCert.domain);
|
await this.renewCertificate(dbCert.domain);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to renew ${dbCert.domain}: ${error.message}`);
|
logger.error(`Failed to renew ${dbCert.domain}: ${getErrorMessage(error)}`);
|
||||||
// Continue with other certificates
|
// Continue with other certificates
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
logger.success('Certificate renewal check complete');
|
logger.success('Certificate renewal check complete');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to check expiring certificates: ${error.message}`);
|
logger.error(`Failed to check expiring certificates: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -307,7 +315,7 @@ export class OneboxSslManager {
|
|||||||
// Reload certificates in reverse proxy
|
// Reload certificates in reverse proxy
|
||||||
await this.oneboxRef.reverseProxy.reloadCertificates();
|
await this.oneboxRef.reverseProxy.reloadCertificates();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to renew all certificates: ${error.message}`);
|
logger.error(`Failed to renew all certificates: ${getErrorMessage(error)}`);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -358,7 +366,7 @@ export class OneboxSslManager {
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(`Failed to get certificate expiry for ${domain}: ${error.message}`);
|
logger.error(`Failed to get certificate expiry for ${domain}: ${getErrorMessage(error)}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
ts/cli.ts
29
ts/cli.ts
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
import { logger } from './logging.ts';
|
import { logger } from './logging.ts';
|
||||||
import { projectInfo } from './info.ts';
|
import { projectInfo } from './info.ts';
|
||||||
|
import { getErrorMessage } from './utils/error.ts';
|
||||||
import { Onebox } from './classes/onebox.ts';
|
import { Onebox } from './classes/onebox.ts';
|
||||||
import { OneboxDaemon } from './classes/daemon.ts';
|
import { OneboxDaemon } from './classes/daemon.ts';
|
||||||
|
|
||||||
@@ -80,7 +81,7 @@ export async function runCli(): Promise<void> {
|
|||||||
// Cleanup
|
// Cleanup
|
||||||
await onebox.shutdown();
|
await onebox.shutdown();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(error.message);
|
logger.error(getErrorMessage(error));
|
||||||
Deno.exit(1);
|
Deno.exit(1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -227,20 +228,36 @@ async function handleSslCommand(onebox: Onebox, subcommand: string, args: string
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nginx commands
|
// Reverse proxy commands (formerly nginx commands)
|
||||||
async function handleNginxCommand(onebox: Onebox, subcommand: string, _args: string[]) {
|
async function handleNginxCommand(onebox: Onebox, subcommand: string, _args: string[]) {
|
||||||
switch (subcommand) {
|
switch (subcommand) {
|
||||||
case 'reload':
|
case 'reload':
|
||||||
await onebox.nginx.reload();
|
// Reload routes and certificates
|
||||||
|
await onebox.reverseProxy.reloadRoutes();
|
||||||
|
await onebox.reverseProxy.reloadCertificates();
|
||||||
|
logger.success('Reverse proxy configuration reloaded');
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'test':
|
case 'test':
|
||||||
await onebox.nginx.test();
|
// Verify reverse proxy is running
|
||||||
|
const proxyStatus = onebox.reverseProxy.getStatus();
|
||||||
|
if (proxyStatus.http.running || proxyStatus.https.running) {
|
||||||
|
logger.success('Reverse proxy is running');
|
||||||
|
logger.info(`HTTP: ${proxyStatus.http.running ? 'active' : 'inactive'} (port ${proxyStatus.http.port})`);
|
||||||
|
logger.info(`HTTPS: ${proxyStatus.https.running ? 'active' : 'inactive'} (port ${proxyStatus.https.port})`);
|
||||||
|
logger.info(`Routes: ${proxyStatus.routes}, Certificates: ${proxyStatus.https.certificates}`);
|
||||||
|
} else {
|
||||||
|
logger.error('Reverse proxy is not running');
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'status': {
|
case 'status': {
|
||||||
const status = await onebox.nginx.getStatus();
|
const status = onebox.reverseProxy.getStatus();
|
||||||
logger.info(`Nginx status: ${status}`);
|
logger.info(`Reverse proxy status:`);
|
||||||
|
logger.info(` HTTP: ${status.http.running ? 'running' : 'stopped'} (port ${status.http.port})`);
|
||||||
|
logger.info(` HTTPS: ${status.https.running ? 'running' : 'stopped'} (port ${status.https.port})`);
|
||||||
|
logger.info(` Routes: ${status.routes}`);
|
||||||
|
logger.info(` Certificates: ${status.https.certificates}`);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
96
ts/types.ts
96
ts/types.ts
@@ -18,10 +18,11 @@ export interface IService {
|
|||||||
// Onebox Registry fields
|
// Onebox Registry fields
|
||||||
useOneboxRegistry?: boolean;
|
useOneboxRegistry?: boolean;
|
||||||
registryRepository?: string;
|
registryRepository?: string;
|
||||||
registryToken?: string;
|
|
||||||
registryImageTag?: string;
|
registryImageTag?: string;
|
||||||
autoUpdateOnPush?: boolean;
|
autoUpdateOnPush?: boolean;
|
||||||
imageDigest?: string;
|
imageDigest?: string;
|
||||||
|
// Platform service requirements
|
||||||
|
platformRequirements?: IPlatformRequirements;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Registry types
|
// Registry types
|
||||||
@@ -33,6 +34,96 @@ export interface IRegistry {
|
|||||||
createdAt: number;
|
createdAt: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registry token types
|
||||||
|
export interface IRegistryToken {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
tokenHash: string;
|
||||||
|
type: 'global' | 'ci';
|
||||||
|
scope: 'all' | string[]; // 'all' or array of service names
|
||||||
|
expiresAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsedAt: number | null;
|
||||||
|
createdBy: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateRegistryTokenRequest {
|
||||||
|
name: string;
|
||||||
|
type: 'global' | 'ci';
|
||||||
|
scope: 'all' | string[];
|
||||||
|
expiresIn: '30d' | '90d' | '365d' | 'never';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IRegistryTokenView {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'global' | 'ci';
|
||||||
|
scope: 'all' | string[];
|
||||||
|
scopeDisplay: string;
|
||||||
|
expiresAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsedAt: number | null;
|
||||||
|
createdBy: string;
|
||||||
|
isExpired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITokenCreatedResponse {
|
||||||
|
token: IRegistryTokenView;
|
||||||
|
plainToken: string; // Only shown once at creation
|
||||||
|
}
|
||||||
|
|
||||||
|
// Platform service types
|
||||||
|
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq';
|
||||||
|
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||||
|
export type TPlatformServiceStatus = 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||||
|
|
||||||
|
export interface IPlatformService {
|
||||||
|
id?: number;
|
||||||
|
name: string;
|
||||||
|
type: TPlatformServiceType;
|
||||||
|
status: TPlatformServiceStatus;
|
||||||
|
containerId?: string;
|
||||||
|
config: IPlatformServiceConfig;
|
||||||
|
adminCredentialsEncrypted?: string;
|
||||||
|
createdAt: number;
|
||||||
|
updatedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlatformServiceConfig {
|
||||||
|
image: string;
|
||||||
|
port: number;
|
||||||
|
volumes?: string[];
|
||||||
|
command?: string;
|
||||||
|
environment?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlatformResource {
|
||||||
|
id?: number;
|
||||||
|
platformServiceId: number;
|
||||||
|
serviceId: number;
|
||||||
|
resourceType: TPlatformResourceType;
|
||||||
|
resourceName: string;
|
||||||
|
credentialsEncrypted: string;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlatformRequirements {
|
||||||
|
mongodb?: boolean;
|
||||||
|
s3?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IProvisionedResource {
|
||||||
|
type: TPlatformResourceType;
|
||||||
|
name: string;
|
||||||
|
credentials: Record<string, string>;
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEnvVarMapping {
|
||||||
|
envVar: string;
|
||||||
|
credentialPath: string;
|
||||||
|
}
|
||||||
|
|
||||||
// Nginx configuration types
|
// Nginx configuration types
|
||||||
export interface INginxConfig {
|
export interface INginxConfig {
|
||||||
id?: number;
|
id?: number;
|
||||||
@@ -193,6 +284,9 @@ export interface IServiceDeployOptions {
|
|||||||
useOneboxRegistry?: boolean;
|
useOneboxRegistry?: boolean;
|
||||||
registryImageTag?: string;
|
registryImageTag?: string;
|
||||||
autoUpdateOnPush?: boolean;
|
autoUpdateOnPush?: boolean;
|
||||||
|
// Platform service requirements
|
||||||
|
enableMongoDB?: boolean;
|
||||||
|
enableS3?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP API request/response types
|
// HTTP API request/response types
|
||||||
|
|||||||
43
ts/utils/error.ts
Normal file
43
ts/utils/error.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
/**
|
||||||
|
* Error handling utilities for TypeScript strict mode compatibility
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extract error message from unknown error type
|
||||||
|
*/
|
||||||
|
export function getErrorMessage(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.message;
|
||||||
|
}
|
||||||
|
return String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extract error stack from unknown error type
|
||||||
|
*/
|
||||||
|
export function getErrorStack(error: unknown): string | undefined {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.stack;
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Safely extract error name from unknown error type
|
||||||
|
*/
|
||||||
|
export function getErrorName(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.name;
|
||||||
|
}
|
||||||
|
return 'Error';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if error is a specific error type by name
|
||||||
|
*/
|
||||||
|
export function isErrorType(error: unknown, name: string): boolean {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
return error.name === name;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
@@ -84,6 +84,13 @@ export const routes: Routes = [
|
|||||||
(m) => m.RegistriesComponent
|
(m) => m.RegistriesComponent
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'tokens',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./features/tokens/tokens.component').then(
|
||||||
|
(m) => m.TokensComponent
|
||||||
|
),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'settings',
|
path: 'settings',
|
||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
|
|||||||
@@ -12,8 +12,14 @@ import {
|
|||||||
IDnsRecord,
|
IDnsRecord,
|
||||||
IRegistry,
|
IRegistry,
|
||||||
IRegistryCreate,
|
IRegistryCreate,
|
||||||
|
IRegistryToken,
|
||||||
|
ICreateTokenRequest,
|
||||||
|
ITokenCreatedResponse,
|
||||||
ISetting,
|
ISetting,
|
||||||
ISettings,
|
ISettings,
|
||||||
|
IPlatformService,
|
||||||
|
IPlatformResource,
|
||||||
|
TPlatformServiceType,
|
||||||
} from '../types/api.types';
|
} from '../types/api.types';
|
||||||
|
|
||||||
@Injectable({ providedIn: 'root' })
|
@Injectable({ providedIn: 'root' })
|
||||||
@@ -75,6 +81,19 @@ export class ApiService {
|
|||||||
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registries/${id}`));
|
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registries/${id}`));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registry Tokens
|
||||||
|
async getRegistryTokens(): Promise<IApiResponse<IRegistryToken[]>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IRegistryToken[]>>('/api/registry/tokens'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async createRegistryToken(data: ICreateTokenRequest): Promise<IApiResponse<ITokenCreatedResponse>> {
|
||||||
|
return firstValueFrom(this.http.post<IApiResponse<ITokenCreatedResponse>>('/api/registry/tokens', data));
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteRegistryToken(id: number): Promise<IApiResponse<void>> {
|
||||||
|
return firstValueFrom(this.http.delete<IApiResponse<void>>(`/api/registry/tokens/${id}`));
|
||||||
|
}
|
||||||
|
|
||||||
// DNS Records
|
// DNS Records
|
||||||
async getDnsRecords(): Promise<IApiResponse<IDnsRecord[]>> {
|
async getDnsRecords(): Promise<IApiResponse<IDnsRecord[]>> {
|
||||||
return firstValueFrom(this.http.get<IApiResponse<IDnsRecord[]>>('/api/dns'));
|
return firstValueFrom(this.http.get<IApiResponse<IDnsRecord[]>>('/api/dns'));
|
||||||
@@ -138,4 +157,25 @@ export class ApiService {
|
|||||||
})
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platform Services
|
||||||
|
async getPlatformServices(): Promise<IApiResponse<IPlatformService[]>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IPlatformService[]>>('/api/platform-services'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getPlatformService(type: TPlatformServiceType): Promise<IApiResponse<IPlatformService>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IPlatformService>>(`/api/platform-services/${type}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
async startPlatformService(type: TPlatformServiceType): Promise<IApiResponse<void>> {
|
||||||
|
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/start`, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async stopPlatformService(type: TPlatformServiceType): Promise<IApiResponse<void>> {
|
||||||
|
return firstValueFrom(this.http.post<IApiResponse<void>>(`/api/platform-services/${type}/stop`, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
async getServicePlatformResources(serviceName: string): Promise<IApiResponse<IPlatformResource[]>> {
|
||||||
|
return firstValueFrom(this.http.get<IApiResponse<IPlatformResource[]>>(`/api/services/${serviceName}/platform-resources`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,16 @@ export interface ILoginResponse {
|
|||||||
user: IUser;
|
user: IUser;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platform Service Types (defined early for use in ISystemStatus)
|
||||||
|
export type TPlatformServiceType = 'mongodb' | 'minio' | 'redis' | 'postgresql' | 'rabbitmq';
|
||||||
|
export type TPlatformServiceStatus = 'not-deployed' | 'stopped' | 'starting' | 'running' | 'stopping' | 'failed';
|
||||||
|
export type TPlatformResourceType = 'database' | 'bucket' | 'cache' | 'queue';
|
||||||
|
|
||||||
|
export interface IPlatformRequirements {
|
||||||
|
mongodb?: boolean;
|
||||||
|
s3?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface IService {
|
export interface IService {
|
||||||
id?: number;
|
id?: number;
|
||||||
name: string;
|
name: string;
|
||||||
@@ -29,10 +39,10 @@ export interface IService {
|
|||||||
updatedAt: number;
|
updatedAt: number;
|
||||||
useOneboxRegistry?: boolean;
|
useOneboxRegistry?: boolean;
|
||||||
registryRepository?: string;
|
registryRepository?: string;
|
||||||
registryToken?: string;
|
|
||||||
registryImageTag?: string;
|
registryImageTag?: string;
|
||||||
autoUpdateOnPush?: boolean;
|
autoUpdateOnPush?: boolean;
|
||||||
imageDigest?: string;
|
imageDigest?: string;
|
||||||
|
platformRequirements?: IPlatformRequirements;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IServiceCreate {
|
export interface IServiceCreate {
|
||||||
@@ -44,6 +54,8 @@ export interface IServiceCreate {
|
|||||||
useOneboxRegistry?: boolean;
|
useOneboxRegistry?: boolean;
|
||||||
registryImageTag?: string;
|
registryImageTag?: string;
|
||||||
autoUpdateOnPush?: boolean;
|
autoUpdateOnPush?: boolean;
|
||||||
|
enableMongoDB?: boolean;
|
||||||
|
enableS3?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IServiceUpdate {
|
export interface IServiceUpdate {
|
||||||
@@ -67,6 +79,7 @@ export interface ISystemStatus {
|
|||||||
dns: { configured: boolean };
|
dns: { configured: boolean };
|
||||||
ssl: { configured: boolean; certbotInstalled: boolean };
|
ssl: { configured: boolean; certbotInstalled: boolean };
|
||||||
services: { total: number; running: number; stopped: number };
|
services: { total: number; running: number; stopped: number };
|
||||||
|
platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IDomain {
|
export interface IDomain {
|
||||||
@@ -138,6 +151,32 @@ export interface IRegistryCreate {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Registry Token Types
|
||||||
|
export interface IRegistryToken {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
type: 'global' | 'ci';
|
||||||
|
scope: 'all' | string[];
|
||||||
|
scopeDisplay: string;
|
||||||
|
expiresAt: number | null;
|
||||||
|
createdAt: number;
|
||||||
|
lastUsedAt: number | null;
|
||||||
|
createdBy: string;
|
||||||
|
isExpired: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ICreateTokenRequest {
|
||||||
|
name: string;
|
||||||
|
type: 'global' | 'ci';
|
||||||
|
scope: 'all' | string[];
|
||||||
|
expiresIn: '30d' | '90d' | '365d' | 'never';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ITokenCreatedResponse {
|
||||||
|
token: IRegistryToken;
|
||||||
|
plainToken: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ISetting {
|
export interface ISetting {
|
||||||
key: string;
|
key: string;
|
||||||
value: string;
|
value: string;
|
||||||
@@ -173,3 +212,27 @@ export interface IToast {
|
|||||||
message: string;
|
message: string;
|
||||||
duration?: number;
|
duration?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Platform Service Interfaces
|
||||||
|
export interface IPlatformService {
|
||||||
|
type: TPlatformServiceType;
|
||||||
|
displayName: string;
|
||||||
|
resourceTypes: TPlatformResourceType[];
|
||||||
|
status: TPlatformServiceStatus;
|
||||||
|
containerId?: string;
|
||||||
|
createdAt?: number;
|
||||||
|
updatedAt?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IPlatformResource {
|
||||||
|
id: number;
|
||||||
|
resourceType: TPlatformResourceType;
|
||||||
|
resourceName: string;
|
||||||
|
platformService: {
|
||||||
|
type: TPlatformServiceType;
|
||||||
|
name: string;
|
||||||
|
status: TPlatformServiceStatus;
|
||||||
|
};
|
||||||
|
envVars: Record<string, string>;
|
||||||
|
createdAt: number;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Component, inject, signal, OnInit } from '@angular/core';
|
import { Component, inject, signal, OnInit } from '@angular/core';
|
||||||
import { FormsModule } from '@angular/forms';
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
|
import { IRegistry, IRegistryCreate } from '../../core/types/api.types';
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import { ButtonComponent } from '../../ui/button/button.component';
|
import { ButtonComponent } from '../../ui/button/button.component';
|
||||||
import { InputComponent } from '../../ui/input/input.component';
|
import { InputComponent } from '../../ui/input/input.component';
|
||||||
import { LabelComponent } from '../../ui/label/label.component';
|
import { LabelComponent } from '../../ui/label/label.component';
|
||||||
|
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||||
import {
|
import {
|
||||||
TableComponent,
|
TableComponent,
|
||||||
TableHeaderComponent,
|
TableHeaderComponent,
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
standalone: true,
|
standalone: true,
|
||||||
imports: [
|
imports: [
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
CardComponent,
|
CardComponent,
|
||||||
CardHeaderComponent,
|
CardHeaderComponent,
|
||||||
CardTitleComponent,
|
CardTitleComponent,
|
||||||
@@ -43,6 +46,7 @@ import {
|
|||||||
ButtonComponent,
|
ButtonComponent,
|
||||||
InputComponent,
|
InputComponent,
|
||||||
LabelComponent,
|
LabelComponent,
|
||||||
|
BadgeComponent,
|
||||||
TableComponent,
|
TableComponent,
|
||||||
TableHeaderComponent,
|
TableHeaderComponent,
|
||||||
TableBodyComponent,
|
TableBodyComponent,
|
||||||
@@ -59,42 +63,75 @@ import {
|
|||||||
template: `
|
template: `
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-3xl font-bold tracking-tight">Docker Registries</h1>
|
<h1 class="text-3xl font-bold tracking-tight">Registries</h1>
|
||||||
<p class="text-muted-foreground">Manage Docker registry credentials</p>
|
<p class="text-muted-foreground">Manage container image registries</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Registry Form -->
|
<!-- Onebox Registry Card -->
|
||||||
<ui-card>
|
<ui-card class="border-primary/50">
|
||||||
<ui-card-header class="flex flex-col space-y-1.5">
|
<ui-card-header class="flex flex-row items-start justify-between space-y-0">
|
||||||
<ui-card-title>Add Registry</ui-card-title>
|
<div class="space-y-1">
|
||||||
<ui-card-description>Add credentials for a private Docker registry</ui-card-description>
|
<div class="flex items-center gap-2">
|
||||||
|
<svg class="h-5 w-5 text-primary" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"/>
|
||||||
|
</svg>
|
||||||
|
<ui-card-title>Onebox Registry (Built-in)</ui-card-title>
|
||||||
|
<ui-badge>Default</ui-badge>
|
||||||
|
</div>
|
||||||
|
<ui-card-description>Built-in container registry for your services</ui-card-description>
|
||||||
|
</div>
|
||||||
</ui-card-header>
|
</ui-card-header>
|
||||||
<ui-card-content>
|
<ui-card-content>
|
||||||
<form (ngSubmit)="addRegistry()" class="grid gap-4 md:grid-cols-4">
|
<div class="grid gap-6 md:grid-cols-3">
|
||||||
<div class="space-y-2">
|
<div>
|
||||||
<label uiLabel>Registry URL</label>
|
<div class="text-sm font-medium text-muted-foreground">Status</div>
|
||||||
<input uiInput [(ngModel)]="form.url" name="url" placeholder="registry.example.com" required />
|
<div class="flex items-center gap-2 mt-1">
|
||||||
|
<span class="h-2 w-2 rounded-full bg-success animate-pulse"></span>
|
||||||
|
<span class="font-medium text-success">Running</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div>
|
||||||
<label uiLabel>Username</label>
|
<div class="text-sm font-medium text-muted-foreground">Registry URL</div>
|
||||||
<input uiInput [(ngModel)]="form.username" name="username" required />
|
<div class="font-mono text-sm mt-1">localhost:3000/v2</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<div>
|
||||||
<label uiLabel>Password</label>
|
<div class="text-sm font-medium text-muted-foreground">Authentication</div>
|
||||||
<input uiInput type="password" [(ngModel)]="form.password" name="password" required />
|
<div class="mt-1">
|
||||||
|
<a routerLink="/tokens" class="text-primary hover:underline text-sm">
|
||||||
|
Manage Tokens
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-end">
|
</div>
|
||||||
<button uiButton type="submit" [disabled]="loading()">Add Registry</button>
|
|
||||||
|
<div class="mt-6 p-4 bg-muted rounded-lg">
|
||||||
|
<h4 class="font-medium mb-2">Quick Start</h4>
|
||||||
|
<p class="text-sm text-muted-foreground mb-3">
|
||||||
|
To push images to the Onebox registry, use a CI or Global token:
|
||||||
|
</p>
|
||||||
|
<div class="font-mono text-xs bg-background p-3 rounded border overflow-x-auto">
|
||||||
|
<div class="text-muted-foreground"># Login to the registry</div>
|
||||||
|
<div>docker login localhost:3000 -u onebox -p YOUR_TOKEN</div>
|
||||||
|
<div class="mt-2 text-muted-foreground"># Tag and push your image</div>
|
||||||
|
<div>docker tag myapp localhost:3000/myservice:latest</div>
|
||||||
|
<div>docker push localhost:3000/myservice:latest</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</div>
|
||||||
</ui-card-content>
|
</ui-card-content>
|
||||||
</ui-card>
|
</ui-card>
|
||||||
|
|
||||||
<!-- Registries List -->
|
<!-- External Registries Section -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-xl font-semibold">External Registries</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">Add credentials for private Docker registries</p>
|
||||||
|
</div>
|
||||||
|
<button uiButton variant="outline" (click)="addDialogOpen.set(true)">
|
||||||
|
Add Registry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
<ui-card>
|
<ui-card>
|
||||||
<ui-card-header class="flex flex-col space-y-1.5">
|
|
||||||
<ui-card-title>Registered Registries</ui-card-title>
|
|
||||||
</ui-card-header>
|
|
||||||
<ui-card-content class="p-0">
|
<ui-card-content class="p-0">
|
||||||
@if (loading() && registries().length === 0) {
|
@if (loading() && registries().length === 0) {
|
||||||
<div class="p-6 space-y-4">
|
<div class="p-6 space-y-4">
|
||||||
@@ -104,15 +141,24 @@ import {
|
|||||||
</div>
|
</div>
|
||||||
} @else if (registries().length === 0) {
|
} @else if (registries().length === 0) {
|
||||||
<div class="p-12 text-center">
|
<div class="p-12 text-center">
|
||||||
<p class="text-muted-foreground">No registries configured</p>
|
<svg class="h-12 w-12 mx-auto text-muted-foreground/50 mb-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||||
|
</svg>
|
||||||
|
<h3 class="text-lg font-medium">No external registries</h3>
|
||||||
|
<p class="text-muted-foreground mt-1">
|
||||||
|
Add credentials for Docker Hub, GitHub Container Registry, or other private registries.
|
||||||
|
</p>
|
||||||
|
<button uiButton variant="outline" class="mt-4" (click)="addDialogOpen.set(true)">
|
||||||
|
Add External Registry
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<ui-table>
|
<ui-table>
|
||||||
<ui-table-header>
|
<ui-table-header>
|
||||||
<ui-table-row>
|
<ui-table-row>
|
||||||
<ui-table-head>URL</ui-table-head>
|
<ui-table-head>Registry URL</ui-table-head>
|
||||||
<ui-table-head>Username</ui-table-head>
|
<ui-table-head>Username</ui-table-head>
|
||||||
<ui-table-head>Created</ui-table-head>
|
<ui-table-head>Added</ui-table-head>
|
||||||
<ui-table-head class="text-right">Actions</ui-table-head>
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
</ui-table-row>
|
</ui-table-row>
|
||||||
</ui-table-header>
|
</ui-table-header>
|
||||||
@@ -136,6 +182,35 @@ import {
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Add Registry Dialog -->
|
||||||
|
<ui-dialog [open]="addDialogOpen()" (openChange)="addDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Add External Registry</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Add credentials for a private Docker registry
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Registry URL</label>
|
||||||
|
<input uiInput [(ngModel)]="form.url" placeholder="registry.example.com" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Username</label>
|
||||||
|
<input uiInput [(ngModel)]="form.username" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Password</label>
|
||||||
|
<input uiInput type="password" [(ngModel)]="form.password" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="addDialogOpen.set(false)">Cancel</button>
|
||||||
|
<button uiButton (click)="addRegistry()" [disabled]="loading()">Add Registry</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||||
<ui-dialog-header>
|
<ui-dialog-header>
|
||||||
<ui-dialog-title>Delete Registry</ui-dialog-title>
|
<ui-dialog-title>Delete Registry</ui-dialog-title>
|
||||||
@@ -156,6 +231,7 @@ export class RegistriesComponent implements OnInit {
|
|||||||
|
|
||||||
registries = signal<IRegistry[]>([]);
|
registries = signal<IRegistry[]>([]);
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
|
addDialogOpen = signal(false);
|
||||||
deleteDialogOpen = signal(false);
|
deleteDialogOpen = signal(false);
|
||||||
registryToDelete = signal<IRegistry | null>(null);
|
registryToDelete = signal<IRegistry | null>(null);
|
||||||
|
|
||||||
@@ -191,6 +267,7 @@ export class RegistriesComponent implements OnInit {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.toast.success('Registry added');
|
this.toast.success('Registry added');
|
||||||
this.form = { url: '', username: '', password: '' };
|
this.form = { url: '', username: '', password: '' };
|
||||||
|
this.addDialogOpen.set(false);
|
||||||
this.loadRegistries();
|
this.loadRegistries();
|
||||||
} else {
|
} else {
|
||||||
this.toast.error(response.error || 'Failed to add registry');
|
this.toast.error(response.error || 'Failed to add registry');
|
||||||
|
|||||||
@@ -186,6 +186,48 @@ interface EnvVar {
|
|||||||
|
|
||||||
<ui-separator />
|
<ui-separator />
|
||||||
|
|
||||||
|
<!-- Platform Services -->
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium">Platform Services</h3>
|
||||||
|
<p class="text-xs text-muted-foreground">Enable managed infrastructure for your service</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ui-checkbox
|
||||||
|
[checked]="form.enableMongoDB ?? false"
|
||||||
|
(checkedChange)="form.enableMongoDB = $event"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label uiLabel class="cursor-pointer">MongoDB Database</label>
|
||||||
|
<p class="text-xs text-muted-foreground">A dedicated database will be created and credentials injected as MONGODB_URI</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<ui-checkbox
|
||||||
|
[checked]="form.enableS3 ?? false"
|
||||||
|
(checkedChange)="form.enableS3 = $event"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<label uiLabel class="cursor-pointer">S3 Storage (MinIO)</label>
|
||||||
|
<p class="text-xs text-muted-foreground">A dedicated bucket will be created and credentials injected as S3_* and AWS_* env vars</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (form.enableMongoDB || form.enableS3) {
|
||||||
|
<ui-alert variant="default">
|
||||||
|
<ui-alert-description>
|
||||||
|
Platform services will be auto-deployed if not already running. Credentials are automatically injected as environment variables.
|
||||||
|
</ui-alert-description>
|
||||||
|
</ui-alert>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ui-separator />
|
||||||
|
|
||||||
<!-- Onebox Registry -->
|
<!-- Onebox Registry -->
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -257,6 +299,8 @@ export class ServiceCreateComponent implements OnInit {
|
|||||||
useOneboxRegistry: false,
|
useOneboxRegistry: false,
|
||||||
registryImageTag: 'latest',
|
registryImageTag: 'latest',
|
||||||
autoUpdateOnPush: false,
|
autoUpdateOnPush: false,
|
||||||
|
enableMongoDB: false,
|
||||||
|
enableS3: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
envVars = signal<EnvVar[]>([]);
|
envVars = signal<EnvVar[]>([]);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
|
|||||||
import { ApiService } from '../../core/services/api.service';
|
import { ApiService } from '../../core/services/api.service';
|
||||||
import { ToastService } from '../../core/services/toast.service';
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
import { LogStreamService } from '../../core/services/log-stream.service';
|
import { LogStreamService } from '../../core/services/log-stream.service';
|
||||||
import { IService, IServiceUpdate } from '../../core/types/api.types';
|
import { IService, IServiceUpdate, IPlatformResource } from '../../core/types/api.types';
|
||||||
import {
|
import {
|
||||||
CardComponent,
|
CardComponent,
|
||||||
CardHeaderComponent,
|
CardHeaderComponent,
|
||||||
@@ -209,6 +209,61 @@ import {
|
|||||||
</ui-card>
|
</ui-card>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
<!-- Platform Resources -->
|
||||||
|
@if (service()!.platformRequirements || platformResources().length > 0) {
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
|
<ui-card-title>Platform Resources</ui-card-title>
|
||||||
|
<ui-card-description>Managed infrastructure provisioned for this service</ui-card-description>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content class="space-y-4">
|
||||||
|
@if (platformResources().length > 0) {
|
||||||
|
@for (resource of platformResources(); track resource.id) {
|
||||||
|
<div class="border rounded-lg p-4 space-y-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
@if (resource.resourceType === 'database') {
|
||||||
|
<svg class="h-5 w-5 text-green-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />
|
||||||
|
</svg>
|
||||||
|
} @else if (resource.resourceType === 'bucket') {
|
||||||
|
<svg class="h-5 w-5 text-blue-500" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" d="M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4" />
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
<span class="font-medium">{{ resource.resourceName }}</span>
|
||||||
|
</div>
|
||||||
|
<ui-badge [variant]="resource.platformService.status === 'running' ? 'success' : 'secondary'">
|
||||||
|
{{ resource.platformService.status }}
|
||||||
|
</ui-badge>
|
||||||
|
</div>
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
{{ resource.platformService.type === 'mongodb' ? 'MongoDB Database' : 'S3 Bucket (MinIO)' }}
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 pt-2 border-t">
|
||||||
|
<p class="text-xs font-medium text-muted-foreground mb-1">Injected Environment Variables</p>
|
||||||
|
<div class="flex flex-wrap gap-1">
|
||||||
|
@for (key of getEnvKeys(resource.envVars); track key) {
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono bg-muted">{{ key }}</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else if (service()!.platformRequirements) {
|
||||||
|
<div class="text-sm text-muted-foreground">
|
||||||
|
@if (service()!.platformRequirements!.mongodb) {
|
||||||
|
<p>MongoDB database pending provisioning...</p>
|
||||||
|
}
|
||||||
|
@if (service()!.platformRequirements!.s3) {
|
||||||
|
<p>S3 bucket pending provisioning...</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
}
|
||||||
|
|
||||||
<!-- Onebox Registry Info -->
|
<!-- Onebox Registry Info -->
|
||||||
@if (service()!.useOneboxRegistry) {
|
@if (service()!.useOneboxRegistry) {
|
||||||
<ui-card>
|
<ui-card>
|
||||||
@@ -225,21 +280,11 @@ import {
|
|||||||
<dt class="text-sm font-medium text-muted-foreground">Tag</dt>
|
<dt class="text-sm font-medium text-muted-foreground">Tag</dt>
|
||||||
<dd class="text-sm">{{ service()!.registryImageTag || 'latest' }}</dd>
|
<dd class="text-sm">{{ service()!.registryImageTag || 'latest' }}</dd>
|
||||||
</div>
|
</div>
|
||||||
@if (service()!.registryToken) {
|
<div class="pt-2 border-t">
|
||||||
<div>
|
<a routerLink="/tokens" class="text-sm text-primary hover:underline">
|
||||||
<dt class="text-sm font-medium text-muted-foreground">Push Token</dt>
|
Manage registry tokens for CI/CD pipelines →
|
||||||
<dd class="flex items-center gap-2">
|
</a>
|
||||||
<input
|
</div>
|
||||||
uiInput
|
|
||||||
type="password"
|
|
||||||
[value]="service()!.registryToken"
|
|
||||||
readonly
|
|
||||||
class="font-mono text-xs"
|
|
||||||
/>
|
|
||||||
<button uiButton variant="outline" size="sm" (click)="copyToken()">Copy</button>
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div>
|
<div>
|
||||||
<dt class="text-sm font-medium text-muted-foreground">Auto-update on push</dt>
|
<dt class="text-sm font-medium text-muted-foreground">Auto-update on push</dt>
|
||||||
<dd class="text-sm">{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}</dd>
|
<dd class="text-sm">{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}</dd>
|
||||||
@@ -346,6 +391,7 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
service = signal<IService | null>(null);
|
service = signal<IService | null>(null);
|
||||||
|
platformResources = signal<IPlatformResource[]>([]);
|
||||||
loading = signal(false);
|
loading = signal(false);
|
||||||
actionLoading = signal(false);
|
actionLoading = signal(false);
|
||||||
editMode = signal(false);
|
editMode = signal(false);
|
||||||
@@ -389,6 +435,11 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
port: response.data.port,
|
port: response.data.port,
|
||||||
domain: response.data.domain,
|
domain: response.data.domain,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load platform resources if service has platform requirements
|
||||||
|
if (response.data.platformRequirements) {
|
||||||
|
this.loadPlatformResources(name);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
this.toast.error(response.error || 'Service not found');
|
this.toast.error(response.error || 'Service not found');
|
||||||
this.router.navigate(['/services']);
|
this.router.navigate(['/services']);
|
||||||
@@ -400,6 +451,17 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async loadPlatformResources(name: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await this.api.getServicePlatformResources(name);
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.platformResources.set(response.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail - platform resources are optional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
startLogStream(): void {
|
startLogStream(): void {
|
||||||
const name = this.service()?.name;
|
const name = this.service()?.name;
|
||||||
if (name) {
|
if (name) {
|
||||||
@@ -546,12 +608,4 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
|
|||||||
this.deleteDialogOpen.set(false);
|
this.deleteDialogOpen.set(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
copyToken(): void {
|
|
||||||
const token = this.service()?.registryToken;
|
|
||||||
if (token) {
|
|
||||||
navigator.clipboard.writeText(token);
|
|
||||||
this.toast.success('Token copied to clipboard');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
509
ui/src/app/features/tokens/tokens.component.ts
Normal file
509
ui/src/app/features/tokens/tokens.component.ts
Normal file
@@ -0,0 +1,509 @@
|
|||||||
|
import { Component, inject, signal, OnInit, computed } from '@angular/core';
|
||||||
|
import { FormsModule } from '@angular/forms';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import { ApiService } from '../../core/services/api.service';
|
||||||
|
import { ToastService } from '../../core/services/toast.service';
|
||||||
|
import { IRegistryToken, ICreateTokenRequest, IService } from '../../core/types/api.types';
|
||||||
|
import {
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardDescriptionComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
} from '../../ui/card/card.component';
|
||||||
|
import { ButtonComponent } from '../../ui/button/button.component';
|
||||||
|
import { InputComponent } from '../../ui/input/input.component';
|
||||||
|
import { LabelComponent } from '../../ui/label/label.component';
|
||||||
|
import { BadgeComponent } from '../../ui/badge/badge.component';
|
||||||
|
import {
|
||||||
|
TableComponent,
|
||||||
|
TableHeaderComponent,
|
||||||
|
TableBodyComponent,
|
||||||
|
TableRowComponent,
|
||||||
|
TableHeadComponent,
|
||||||
|
TableCellComponent,
|
||||||
|
} from '../../ui/table/table.component';
|
||||||
|
import { SkeletonComponent } from '../../ui/skeleton/skeleton.component';
|
||||||
|
import {
|
||||||
|
DialogComponent,
|
||||||
|
DialogHeaderComponent,
|
||||||
|
DialogTitleComponent,
|
||||||
|
DialogDescriptionComponent,
|
||||||
|
DialogFooterComponent,
|
||||||
|
} from '../../ui/dialog/dialog.component';
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'app-tokens',
|
||||||
|
standalone: true,
|
||||||
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
RouterLink,
|
||||||
|
CardComponent,
|
||||||
|
CardHeaderComponent,
|
||||||
|
CardTitleComponent,
|
||||||
|
CardDescriptionComponent,
|
||||||
|
CardContentComponent,
|
||||||
|
ButtonComponent,
|
||||||
|
InputComponent,
|
||||||
|
LabelComponent,
|
||||||
|
BadgeComponent,
|
||||||
|
TableComponent,
|
||||||
|
TableHeaderComponent,
|
||||||
|
TableBodyComponent,
|
||||||
|
TableRowComponent,
|
||||||
|
TableHeadComponent,
|
||||||
|
TableCellComponent,
|
||||||
|
SkeletonComponent,
|
||||||
|
DialogComponent,
|
||||||
|
DialogHeaderComponent,
|
||||||
|
DialogTitleComponent,
|
||||||
|
DialogDescriptionComponent,
|
||||||
|
DialogFooterComponent,
|
||||||
|
],
|
||||||
|
template: `
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-3xl font-bold tracking-tight">Registry Tokens</h1>
|
||||||
|
<p class="text-muted-foreground">Manage authentication tokens for the Onebox registry</p>
|
||||||
|
</div>
|
||||||
|
<button uiButton (click)="openCreateDialog()">Create Token</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Global Tokens Section -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
|
<ui-card-title>Global Tokens</ui-card-title>
|
||||||
|
<ui-card-description>Tokens that can push images to multiple services</ui-card-description>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content class="p-0">
|
||||||
|
@if (loading() && globalTokens().length === 0) {
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
@for (_ of [1,2]; track $index) {
|
||||||
|
<ui-skeleton class="h-12 w-full" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (globalTokens().length === 0) {
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<p class="text-muted-foreground">No global tokens created</p>
|
||||||
|
<button uiButton variant="outline" class="mt-4" (click)="openCreateDialog('global')">
|
||||||
|
Create Global Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<ui-table>
|
||||||
|
<ui-table-header>
|
||||||
|
<ui-table-row>
|
||||||
|
<ui-table-head>Name</ui-table-head>
|
||||||
|
<ui-table-head>Scope</ui-table-head>
|
||||||
|
<ui-table-head>Expires</ui-table-head>
|
||||||
|
<ui-table-head>Last Used</ui-table-head>
|
||||||
|
<ui-table-head>Created By</ui-table-head>
|
||||||
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
|
</ui-table-row>
|
||||||
|
</ui-table-header>
|
||||||
|
<ui-table-body>
|
||||||
|
@for (token of globalTokens(); track token.id) {
|
||||||
|
<ui-table-row [class.opacity-50]="token.isExpired">
|
||||||
|
<ui-table-cell class="font-medium">{{ token.name }}</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
<ui-badge variant="secondary">{{ token.scopeDisplay }}</ui-badge>
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
@if (token.isExpired) {
|
||||||
|
<ui-badge variant="destructive">Expired</ui-badge>
|
||||||
|
} @else if (token.expiresAt) {
|
||||||
|
{{ formatExpiry(token.expiresAt) }}
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted-foreground">Never</span>
|
||||||
|
}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
@if (token.lastUsedAt) {
|
||||||
|
{{ formatRelativeTime(token.lastUsedAt) }}
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted-foreground">Never</span>
|
||||||
|
}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>{{ token.createdBy }}</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-right">
|
||||||
|
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(token)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</ui-table-cell>
|
||||||
|
</ui-table-row>
|
||||||
|
}
|
||||||
|
</ui-table-body>
|
||||||
|
</ui-table>
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
|
||||||
|
<!-- CI Tokens Section -->
|
||||||
|
<ui-card>
|
||||||
|
<ui-card-header class="flex flex-col space-y-1.5">
|
||||||
|
<ui-card-title>CI Tokens (Service-specific)</ui-card-title>
|
||||||
|
<ui-card-description>Tokens tied to individual services for CI/CD pipelines</ui-card-description>
|
||||||
|
</ui-card-header>
|
||||||
|
<ui-card-content class="p-0">
|
||||||
|
@if (loading() && ciTokens().length === 0) {
|
||||||
|
<div class="p-6 space-y-4">
|
||||||
|
@for (_ of [1,2]; track $index) {
|
||||||
|
<ui-skeleton class="h-12 w-full" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (ciTokens().length === 0) {
|
||||||
|
<div class="p-12 text-center">
|
||||||
|
<p class="text-muted-foreground">No CI tokens created</p>
|
||||||
|
<button uiButton variant="outline" class="mt-4" (click)="openCreateDialog('ci')">
|
||||||
|
Create CI Token
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<ui-table>
|
||||||
|
<ui-table-header>
|
||||||
|
<ui-table-row>
|
||||||
|
<ui-table-head>Name</ui-table-head>
|
||||||
|
<ui-table-head>Service</ui-table-head>
|
||||||
|
<ui-table-head>Expires</ui-table-head>
|
||||||
|
<ui-table-head>Last Used</ui-table-head>
|
||||||
|
<ui-table-head>Created By</ui-table-head>
|
||||||
|
<ui-table-head class="text-right">Actions</ui-table-head>
|
||||||
|
</ui-table-row>
|
||||||
|
</ui-table-header>
|
||||||
|
<ui-table-body>
|
||||||
|
@for (token of ciTokens(); track token.id) {
|
||||||
|
<ui-table-row [class.opacity-50]="token.isExpired">
|
||||||
|
<ui-table-cell class="font-medium">{{ token.name }}</ui-table-cell>
|
||||||
|
<ui-table-cell>{{ token.scopeDisplay }}</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
@if (token.isExpired) {
|
||||||
|
<ui-badge variant="destructive">Expired</ui-badge>
|
||||||
|
} @else if (token.expiresAt) {
|
||||||
|
{{ formatExpiry(token.expiresAt) }}
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted-foreground">Never</span>
|
||||||
|
}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>
|
||||||
|
@if (token.lastUsedAt) {
|
||||||
|
{{ formatRelativeTime(token.lastUsedAt) }}
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted-foreground">Never</span>
|
||||||
|
}
|
||||||
|
</ui-table-cell>
|
||||||
|
<ui-table-cell>{{ token.createdBy }}</ui-table-cell>
|
||||||
|
<ui-table-cell class="text-right">
|
||||||
|
<button uiButton variant="destructive" size="sm" (click)="confirmDelete(token)">
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</ui-table-cell>
|
||||||
|
</ui-table-row>
|
||||||
|
}
|
||||||
|
</ui-table-body>
|
||||||
|
</ui-table>
|
||||||
|
}
|
||||||
|
</ui-card-content>
|
||||||
|
</ui-card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Create Token Dialog -->
|
||||||
|
<ui-dialog [open]="createDialogOpen()" (openChange)="createDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Create Registry Token</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Create a new token for pushing images to the Onebox registry
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<div class="grid gap-4 py-4">
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Token Name</label>
|
||||||
|
<input uiInput [(ngModel)]="createForm.name" placeholder="e.g., deploy-token" />
|
||||||
|
</div>
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Token Type</label>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="type" value="global" [(ngModel)]="createForm.type" class="accent-primary" />
|
||||||
|
<span>Global (multiple services)</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="radio" name="type" value="ci" [(ngModel)]="createForm.type" class="accent-primary" />
|
||||||
|
<span>CI (single service)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (createForm.type === 'global') {
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Scope</label>
|
||||||
|
<div class="flex items-center gap-2 mb-2">
|
||||||
|
<input type="checkbox" id="all-services" [(ngModel)]="scopeAll" class="accent-primary" />
|
||||||
|
<label for="all-services" class="cursor-pointer">All services</label>
|
||||||
|
</div>
|
||||||
|
@if (!scopeAll) {
|
||||||
|
<div class="border rounded-md p-3 max-h-48 overflow-y-auto space-y-2">
|
||||||
|
@for (service of services(); track service.name) {
|
||||||
|
<label class="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="selectedServices().includes(service.name)"
|
||||||
|
(change)="toggleService(service.name)"
|
||||||
|
class="accent-primary"
|
||||||
|
/>
|
||||||
|
<span>{{ service.name }}</span>
|
||||||
|
</label>
|
||||||
|
}
|
||||||
|
@if (services().length === 0) {
|
||||||
|
<p class="text-sm text-muted-foreground">No services available</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Service</label>
|
||||||
|
<select [(ngModel)]="selectedSingleService" class="w-full p-2 border rounded-md bg-background">
|
||||||
|
<option value="">Select a service</option>
|
||||||
|
@for (service of services(); track service.name) {
|
||||||
|
<option [value]="service.name">{{ service.name }}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="space-y-2">
|
||||||
|
<label uiLabel>Expiration</label>
|
||||||
|
<select [(ngModel)]="createForm.expiresIn" class="w-full p-2 border rounded-md bg-background">
|
||||||
|
<option value="30d">30 days</option>
|
||||||
|
<option value="90d">90 days</option>
|
||||||
|
<option value="365d">365 days</option>
|
||||||
|
<option value="never">Never</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="createDialogOpen.set(false)">Cancel</button>
|
||||||
|
<button uiButton (click)="createToken()" [disabled]="creating()">
|
||||||
|
@if (creating()) {
|
||||||
|
Creating...
|
||||||
|
} @else {
|
||||||
|
Create Token
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
|
|
||||||
|
<!-- Token Created Dialog -->
|
||||||
|
<ui-dialog [open]="tokenCreatedDialogOpen()" (openChange)="tokenCreatedDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Token Created</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Copy this token now. You won't be able to see it again!
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<div class="py-4">
|
||||||
|
<div class="p-4 bg-muted rounded-md font-mono text-sm break-all">
|
||||||
|
{{ createdPlainToken() }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton (click)="copyToken()">Copy Token</button>
|
||||||
|
<button uiButton variant="outline" (click)="tokenCreatedDialogOpen.set(false)">Done</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Dialog -->
|
||||||
|
<ui-dialog [open]="deleteDialogOpen()" (openChange)="deleteDialogOpen.set($event)">
|
||||||
|
<ui-dialog-header>
|
||||||
|
<ui-dialog-title>Delete Token</ui-dialog-title>
|
||||||
|
<ui-dialog-description>
|
||||||
|
Are you sure you want to delete "{{ tokenToDelete()?.name }}"? This action cannot be undone.
|
||||||
|
</ui-dialog-description>
|
||||||
|
</ui-dialog-header>
|
||||||
|
<ui-dialog-footer>
|
||||||
|
<button uiButton variant="outline" (click)="deleteDialogOpen.set(false)">Cancel</button>
|
||||||
|
<button uiButton variant="destructive" (click)="deleteToken()">Delete</button>
|
||||||
|
</ui-dialog-footer>
|
||||||
|
</ui-dialog>
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class TokensComponent implements OnInit {
|
||||||
|
private api = inject(ApiService);
|
||||||
|
private toast = inject(ToastService);
|
||||||
|
|
||||||
|
tokens = signal<IRegistryToken[]>([]);
|
||||||
|
services = signal<IService[]>([]);
|
||||||
|
loading = signal(false);
|
||||||
|
creating = signal(false);
|
||||||
|
|
||||||
|
createDialogOpen = signal(false);
|
||||||
|
tokenCreatedDialogOpen = signal(false);
|
||||||
|
deleteDialogOpen = signal(false);
|
||||||
|
tokenToDelete = signal<IRegistryToken | null>(null);
|
||||||
|
createdPlainToken = signal('');
|
||||||
|
|
||||||
|
// Form state
|
||||||
|
createForm: ICreateTokenRequest = {
|
||||||
|
name: '',
|
||||||
|
type: 'global',
|
||||||
|
scope: 'all',
|
||||||
|
expiresIn: '90d',
|
||||||
|
};
|
||||||
|
scopeAll = true;
|
||||||
|
selectedServices = signal<string[]>([]);
|
||||||
|
selectedSingleService = '';
|
||||||
|
|
||||||
|
// Computed signals for filtered tokens
|
||||||
|
globalTokens = computed(() => this.tokens().filter(t => t.type === 'global'));
|
||||||
|
ciTokens = computed(() => this.tokens().filter(t => t.type === 'ci'));
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.loadTokens();
|
||||||
|
this.loadServices();
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadTokens(): Promise<void> {
|
||||||
|
this.loading.set(true);
|
||||||
|
try {
|
||||||
|
const response = await this.api.getRegistryTokens();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.tokens.set(response.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to load tokens');
|
||||||
|
} finally {
|
||||||
|
this.loading.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async loadServices(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const response = await this.api.getServices();
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.services.set(response.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silent fail - services list is optional
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
openCreateDialog(type?: 'global' | 'ci'): void {
|
||||||
|
this.createForm = {
|
||||||
|
name: '',
|
||||||
|
type: type || 'global',
|
||||||
|
scope: 'all',
|
||||||
|
expiresIn: '90d',
|
||||||
|
};
|
||||||
|
this.scopeAll = true;
|
||||||
|
this.selectedServices.set([]);
|
||||||
|
this.selectedSingleService = '';
|
||||||
|
this.createDialogOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleService(serviceName: string): void {
|
||||||
|
const current = this.selectedServices();
|
||||||
|
if (current.includes(serviceName)) {
|
||||||
|
this.selectedServices.set(current.filter(s => s !== serviceName));
|
||||||
|
} else {
|
||||||
|
this.selectedServices.set([...current, serviceName]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createToken(): Promise<void> {
|
||||||
|
if (!this.createForm.name) {
|
||||||
|
this.toast.error('Please enter a token name');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build scope based on type
|
||||||
|
let scope: 'all' | string[];
|
||||||
|
if (this.createForm.type === 'global') {
|
||||||
|
if (this.scopeAll) {
|
||||||
|
scope = 'all';
|
||||||
|
} else {
|
||||||
|
if (this.selectedServices().length === 0) {
|
||||||
|
this.toast.error('Please select at least one service');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scope = this.selectedServices();
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (!this.selectedSingleService) {
|
||||||
|
this.toast.error('Please select a service');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
scope = [this.selectedSingleService];
|
||||||
|
}
|
||||||
|
|
||||||
|
this.creating.set(true);
|
||||||
|
try {
|
||||||
|
const response = await this.api.createRegistryToken({
|
||||||
|
...this.createForm,
|
||||||
|
scope,
|
||||||
|
});
|
||||||
|
if (response.success && response.data) {
|
||||||
|
this.createdPlainToken.set(response.data.plainToken);
|
||||||
|
this.createDialogOpen.set(false);
|
||||||
|
this.tokenCreatedDialogOpen.set(true);
|
||||||
|
this.loadTokens();
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to create token');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to create token');
|
||||||
|
} finally {
|
||||||
|
this.creating.set(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
copyToken(): void {
|
||||||
|
navigator.clipboard.writeText(this.createdPlainToken());
|
||||||
|
this.toast.success('Token copied to clipboard');
|
||||||
|
}
|
||||||
|
|
||||||
|
confirmDelete(token: IRegistryToken): void {
|
||||||
|
this.tokenToDelete.set(token);
|
||||||
|
this.deleteDialogOpen.set(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteToken(): Promise<void> {
|
||||||
|
const token = this.tokenToDelete();
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await this.api.deleteRegistryToken(token.id);
|
||||||
|
if (response.success) {
|
||||||
|
this.toast.success('Token deleted');
|
||||||
|
this.loadTokens();
|
||||||
|
} else {
|
||||||
|
this.toast.error(response.error || 'Failed to delete token');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
this.toast.error('Failed to delete token');
|
||||||
|
} finally {
|
||||||
|
this.deleteDialogOpen.set(false);
|
||||||
|
this.tokenToDelete.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
formatExpiry(timestamp: number): string {
|
||||||
|
const days = Math.ceil((timestamp - Date.now()) / (1000 * 60 * 60 * 24));
|
||||||
|
if (days < 0) return 'Expired';
|
||||||
|
if (days === 0) return 'Today';
|
||||||
|
if (days === 1) return 'Tomorrow';
|
||||||
|
if (days < 30) return `${days} days`;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
formatRelativeTime(timestamp: number): string {
|
||||||
|
const diff = Date.now() - timestamp;
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60));
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60));
|
||||||
|
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||||
|
|
||||||
|
if (minutes < 1) return 'Just now';
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
return new Date(timestamp).toLocaleDateString();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,7 @@ export class LayoutComponent {
|
|||||||
{ label: 'Dashboard', path: '/dashboard', icon: 'home' },
|
{ label: 'Dashboard', path: '/dashboard', icon: 'home' },
|
||||||
{ label: 'Services', path: '/services', icon: 'server' },
|
{ label: 'Services', path: '/services', icon: 'server' },
|
||||||
{ label: 'Registries', path: '/registries', icon: 'database' },
|
{ label: 'Registries', path: '/registries', icon: 'database' },
|
||||||
|
{ label: 'Tokens', path: '/tokens', icon: 'key' },
|
||||||
{ label: 'DNS', path: '/dns', icon: 'globe' },
|
{ label: 'DNS', path: '/dns', icon: 'globe' },
|
||||||
{ label: 'Domains', path: '/domains', icon: 'link' },
|
{ label: 'Domains', path: '/domains', icon: 'link' },
|
||||||
{ label: 'Settings', path: '/settings', icon: 'settings' },
|
{ label: 'Settings', path: '/settings', icon: 'settings' },
|
||||||
|
|||||||
Reference in New Issue
Block a user