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:
2025-11-25 04:20:19 +00:00
parent 9aa6906ca5
commit 8ebd677478
28 changed files with 3462 additions and 490 deletions

View File

@@ -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
View File

@@ -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
- **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
- **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
- **Cloudflare Integration** - Automatic DNS record management
- **Modern Stack** - Deno runtime + SQLite database + Angular 18 UI
- **Cloudflare Integration** - Automatic DNS record management and zone synchronization
- **Modern Stack** - Deno runtime + SQLite database + Angular 19 UI
## 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
- 🌐 **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
- ☁️ **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
- 🔄 **Real-time WebSocket Updates** - Live service status, logs, and system events
### Monitoring & Management
- 📊 **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
- 👥 **Multi-user Support** - Role-based access control (admin/user)
- 💾 **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) │
└─────────────────┬───────────────────────────────┘
│ HTTP/WS
@@ -121,13 +121,15 @@ Onebox is built with modern technologies for performance and developer experienc
### Core Components
- **Deno Runtime** - Modern TypeScript with built-in security
- **Native Reverse Proxy** - Custom HTTP/HTTPS proxy with TLS SNI support
- **Docker Swarm** - Container orchestration (NOT standalone containers)
- **SQLite Database** - Configuration, metrics, and user data
- **WebSocket Server** - Real-time bidirectional communication
- **Let's Encrypt** - Automatic SSL certificate management
- **Cloudflare API** - DNS record automation
| Component | Description |
|-----------|-------------|
| **Deno Runtime** | Modern TypeScript with built-in security |
| **Native Reverse Proxy** | Custom HTTP/HTTPS proxy with TLS SNI support |
| **Docker Swarm** | Container orchestration (NOT standalone containers) |
| **SQLite Database** | Configuration, metrics, and user data |
| **WebSocket Server** | Real-time bidirectional communication |
| **Let's Encrypt** | Automatic SSL certificate management |
| **Cloudflare API** | DNS record automation |
## CLI Reference 📖
@@ -244,9 +246,11 @@ onebox status
### Data Locations
- **Database**: `./onebox.db` (or custom path)
- **SSL Certificates**: Managed by CertManager
- **Registry Data**: `./.nogit/registry-data`
| Data | Location |
|------|----------|
| **Database** | `./onebox.db` (or custom path) |
| **SSL Certificates** | Managed by CertManager |
| **Registry Data** | `./.nogit/registry-data` |
### Environment Variables
@@ -270,8 +274,8 @@ ONEBOX_DEBUG=true
git clone https://code.foss.global/serve.zone/onebox
cd onebox
# Install dependencies (Deno handles this automatically)
deno task dev
# Start development server (auto-restart on changes)
pnpm run watch
```
### Tasks
@@ -295,38 +299,93 @@ deno task compile
```
onebox/
├── ts/
│ ├── classes/ # Core implementations
│ │ ├── onebox.ts # Main coordinator
│ │ ├── reverseproxy.ts # Native HTTP/HTTPS proxy
│ │ ├── docker.ts # Docker Swarm API
│ │ ├── database.ts # SQLite storage
│ │ ├── httpserver.ts # REST API + WebSocket
│ │ ├── services.ts # Service orchestration
│ │ ├── certmanager.ts # SSL certificate management
│ │ ├── registry.ts # Built-in Docker registry
│ │ ── ...
│ ├── cli.ts # CLI router
│ ├── types.ts # TypeScript interfaces
└── plugins.ts # Dependency imports
├── ui/ # Angular web interface
├── test/ # Test files
├── mod.ts # Main entry point
└── deno.json # Deno configuration
│ ├── classes/ # Core implementations
│ │ ├── onebox.ts # Main coordinator
│ │ ├── reverseproxy.ts # Native HTTP/HTTPS proxy
│ │ ├── docker.ts # Docker Swarm API
│ │ ├── database.ts # SQLite storage
│ │ ├── httpserver.ts # REST API + WebSocket
│ │ ├── services.ts # Service orchestration
│ │ ├── certmanager.ts # SSL certificate management
│ │ ├── cert-requirement-manager.ts # Certificate requirements
│ │ ── ssl.ts # SSL utilities
│ ├── registry.ts # Built-in Docker registry
│ ├── registries.ts # External registry management
│ ├── dns.ts # DNS record management
├── cloudflare-sync.ts # Cloudflare zone sync
│ │ ├── daemon.ts # Systemd daemon management
│ │ └── apiclient.ts # API client utilities
│ ├── 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
The HTTP server exposes the following endpoints:
The HTTP server exposes a comprehensive REST API:
- `POST /api/auth/login` - User authentication (returns token)
- `GET /api/status` - System status (requires auth)
- `GET /api/services` - List all services (requires auth)
- `POST /api/services` - Create service (requires auth)
- `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
#### Authentication
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/api/auth/login` | User authentication (returns token) |
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
@@ -374,6 +433,19 @@ docker push localhost:4000/myapp:latest
# 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
```bash
@@ -388,16 +460,19 @@ onebox service add myapp \
--domain myapp.example.com
# DNS record is automatically created!
# Sync all domains from Cloudflare
onebox dns sync
```
### SSL Certificate Management
SSL certificates are automatically obtained and renewed:
- Certificates are requested when a service with a domain is deployed
- Renewal happens automatically 30 days before expiry
- Certificates are hot-reloaded without downtime
- Force renewal: `onebox ssl force-renew <domain>`
- Certificates are requested when a service with a domain is deployed
- Renewal happens automatically 30 days before expiry
- Certificates are hot-reloaded without downtime
- Force renewal: `onebox ssl force-renew <domain>`
### Monitoring and Metrics
@@ -449,9 +524,9 @@ onebox ssl force-renew yourdomain.com
### WebSocket Connection Issues
- Ensure firewall allows WebSocket connections
- Check browser console for connection errors
- Verify `/api/ws` endpoint is accessible
- Ensure firewall allows WebSocket connections
- Check browser console for connection errors
- Verify `/api/ws` endpoint is accessible
### Service Not Starting

View File

@@ -7,6 +7,7 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts';
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 });
logger.info(`Certificate manager initialized (path: ${this.certBasePath})`);
} catch (error) {
logger.error(`Failed to initialize certificate manager: ${error.message}`);
logger.error(`Failed to initialize certificate manager: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -56,7 +57,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
return cert;
} catch (error) {
logger.warn(`Failed to retrieve certificate for ${domainName}: ${error.message}`);
logger.warn(`Failed to retrieve certificate for ${domainName}: ${getErrorMessage(error)}`);
return null;
}
}
@@ -110,7 +111,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
logger.success(`Certificate stored for ${domain}`);
} 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;
}
}
@@ -128,7 +129,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
try {
await Deno.remove(domainPath, { recursive: true });
} 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
@@ -137,7 +138,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
logger.info(`Certificate deleted for ${domainName}`);
}
} catch (error) {
logger.error(`Failed to delete certificate for ${domainName}: ${error.message}`);
logger.error(`Failed to delete certificate for ${domainName}: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -163,7 +164,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
logger.warn('All certificates wiped');
} catch (error) {
logger.error(`Failed to wipe certificates: ${error.message}`);
logger.error(`Failed to wipe certificates: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -175,7 +176,7 @@ export class SqliteCertManager implements plugins.smartacme.ICertManager {
try {
return await Deno.readTextFile(path);
} catch (error) {
throw new Error(`Failed to read PEM file ${path}: ${error.message}`);
throw new Error(`Failed to read PEM file ${path}: ${getErrorMessage(error)}`);
}
}

View File

@@ -7,6 +7,7 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { projectInfo } from '../info.ts';
import { getErrorMessage } from '../utils/error.ts';
import type { Onebox } from './onebox.ts';
// PID file constants
@@ -72,7 +73,7 @@ export class OneboxDaemon {
logger.success('Onebox daemon service installed');
logger.info('Start with: sudo systemctl start smartdaemon_onebox');
} catch (error) {
logger.error(`Failed to install daemon service: ${error.message}`);
logger.error(`Failed to install daemon service: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -89,7 +90,8 @@ export class OneboxDaemon {
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) {
await service.stop();
@@ -99,7 +101,7 @@ export class OneboxDaemon {
logger.success('Onebox daemon service uninstalled');
} catch (error) {
logger.error(`Failed to uninstall daemon service: ${error.message}`);
logger.error(`Failed to uninstall daemon service: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -137,7 +139,7 @@ export class OneboxDaemon {
// Keep process alive
await this.keepAlive();
} catch (error) {
logger.error(`Failed to start daemon: ${error.message}`);
logger.error(`Failed to start daemon: ${getErrorMessage(error)}`);
this.running = false;
throw error;
}
@@ -167,7 +169,7 @@ export class OneboxDaemon {
logger.success('Onebox daemon stopped');
} catch (error) {
logger.error(`Failed to stop daemon: ${error.message}`);
logger.error(`Failed to stop daemon: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -229,7 +231,7 @@ export class OneboxDaemon {
logger.debug('Monitoring tick complete');
} 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) {
logger.debug(`Failed to collect metrics for ${service.name}: ${error.message}`);
logger.debug(`Failed to collect metrics for ${service.name}: ${getErrorMessage(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();
} 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 {
await this.oneboxRef.certRequirementManager.processPendingRequirements();
} 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 {
await this.oneboxRef.certRequirementManager.checkCertificateRenewal();
} 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 {
await this.oneboxRef.certRequirementManager.cleanupOldCertificates();
} 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();
this.lastDomainSync = now;
} 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;
logger.debug(`PID file written: ${FALLBACK_PID_FILE}`);
} 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
}
}
@@ -402,7 +404,7 @@ export class OneboxDaemon {
logger.debug(`PID file removed: ${this.pidFilePath}`);
} catch (error) {
// Ignore errors - file might not exist
logger.debug(`Could not remove PID file: ${error.message}`);
logger.debug(`Could not remove PID file: ${getErrorMessage(error)}`);
}
}

View File

@@ -6,6 +6,7 @@ import * as plugins from '../plugins.ts';
import type {
IService,
IRegistry,
IRegistryToken,
INginxConfig,
ISslCertificate,
IDnsRecord,
@@ -13,11 +14,22 @@ import type {
ILogEntry,
IUser,
ISetting,
IPlatformService,
IPlatformResource,
IPlatformRequirements,
TPlatformServiceType,
IDomain,
ICertificate,
ICertRequirement,
} from '../types.ts';
// Type alias for sqlite bind parameters
type BindValue = string | number | bigint | boolean | null | undefined | Uint8Array;
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
export class OneboxDatabase {
private db: plugins.sqlite.DB | null = null;
private db: InstanceType<typeof plugins.sqlite.DB> | null = null;
private dbPath: string;
constructor(dbPath = './.nogit/onebox.db') {
@@ -43,7 +55,7 @@ export class OneboxDatabase {
// Run migrations if needed
await this.runMigrations();
} catch (error) {
logger.error(`Failed to initialize database: ${error.message}`);
logger.error(`Failed to initialize database: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -447,28 +459,40 @@ export class OneboxDatabase {
// 4. Migrate existing ssl_certificates data
// 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 domainMap = new Map<string, number>();
// Create domain entries for each unique base domain
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)) {
this.query(
'INSERT INTO domains (domain, dns_provider, is_obsolete, default_wildcard, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[domain, null, 0, 1, now, now]
);
const result = this.query('SELECT last_insert_rowid() as id');
const domainId = result[0].id ?? result[0][0];
const result = this.query<{ id?: number; [key: number]: unknown }>('SELECT last_insert_rowid() as id');
const domainId = result[0].id ?? (result[0] as Record<number, unknown>)[0];
domainMap.set(domain, Number(domainId));
}
}
// Migrate certificates to new table
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);
this.query(
@@ -480,14 +504,14 @@ export class OneboxDatabase {
domainId,
domain,
0, // We don't know if it's wildcard, default to false
String(cert.cert_path ?? cert[2]),
String(cert.key_path ?? cert[3]),
String(cert.full_chain_path ?? cert[4]),
Number(cert.expiry_date ?? cert[5]),
String(cert.issuer ?? cert[6]),
String(cert.cert_path ?? (cert as Record<number, unknown>)[2]),
String(cert.key_path ?? (cert as Record<number, unknown>)[3]),
String(cert.full_chain_path ?? (cert as Record<number, unknown>)[4]),
Number(cert.expiry_date ?? (cert as Record<number, unknown>)[5]),
String(cert.issuer ?? (cert as Record<number, unknown>)[6]),
1, // Assume valid
Number(cert.created_at ?? cert[7]),
Number(cert.updated_at ?? cert[8])
Number(cert.created_at ?? (cert as Record<number, unknown>)[7]),
Number(cert.updated_at ?? (cert as Record<number, unknown>)[8])
]
);
}
@@ -534,9 +558,143 @@ export class OneboxDatabase {
this.setMigrationVersion(4);
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) {
logger.error(`Migration failed: ${error.message}`);
logger.error(`Stack: ${error.stack}`);
logger.error(`Migration failed: ${getErrorMessage(error)}`);
if (error instanceof Error && error.stack) {
logger.error(`Stack: ${error.stack}`);
}
throw error;
}
}
@@ -548,14 +706,14 @@ export class OneboxDatabase {
if (!this.db) throw new Error('Database not initialized');
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;
// 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;
} 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;
}
}
@@ -587,7 +745,7 @@ export class OneboxDatabase {
/**
* 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) {
const error = new Error('Database not initialized');
console.error('Database access before initialization!');
@@ -621,8 +779,8 @@ export class OneboxDatabase {
`INSERT INTO services (
name, image, registry, env_vars, port, domain, container_id, status,
created_at, updated_at,
use_onebox_registry, registry_repository, registry_token, registry_image_tag,
auto_update_on_push, image_digest
use_onebox_registry, registry_repository, registry_image_tag,
auto_update_on_push, image_digest, platform_requirements
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[
service.name,
@@ -637,10 +795,10 @@ export class OneboxDatabase {
now,
service.useOneboxRegistry ? 1 : 0,
service.registryRepository || null,
service.registryToken || null,
service.registryImageTag || 'latest',
service.autoUpdateOnPush ? 1 : 0,
service.imageDigest || null,
JSON.stringify(service.platformRequirements || {}),
]
);
@@ -717,10 +875,6 @@ export class OneboxDatabase {
fields.push('registry_repository = ?');
values.push(updates.registryRepository);
}
if (updates.registryToken !== undefined) {
fields.push('registry_token = ?');
values.push(updates.registryToken);
}
if (updates.registryImageTag !== undefined) {
fields.push('registry_image_tag = ?');
values.push(updates.registryImageTag);
@@ -733,6 +887,10 @@ export class OneboxDatabase {
fields.push('image_digest = ?');
values.push(updates.imageDigest);
}
if (updates.platformRequirements !== undefined) {
fields.push('platform_requirements = ?');
values.push(JSON.stringify(updates.platformRequirements));
}
fields.push('updated_at = ?');
values.push(Date.now());
@@ -754,11 +912,23 @@ export class OneboxDatabase {
try {
envVars = JSON.parse(String(envVarsRaw));
} 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 = {};
}
}
// 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 {
id: Number(row.id || row[0]),
name: String(row.name || row[1]),
@@ -774,10 +944,11 @@ export class OneboxDatabase {
// Onebox Registry fields
useOneboxRegistry: row.use_onebox_registry ? Boolean(row.use_onebox_registry) : 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,
autoUpdateOnPush: row.auto_update_on_push ? Boolean(row.auto_update_on_push) : 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]),
};
}
// ============ 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),
};
}
}

View File

@@ -841,4 +841,89 @@ export class OneboxDockerManager {
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
View 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();

View File

@@ -7,7 +7,7 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import type { Onebox } from './onebox.ts';
import type { IApiResponse } from '../types.ts';
import type { IApiResponse, ICreateRegistryTokenRequest, IRegistryTokenView } from '../types.ts';
export class OneboxHttpServer {
private oneboxRef: Onebox;
@@ -263,9 +263,28 @@ export class OneboxHttpServer {
} else if (path.match(/^\/api\/registry\/tags\/[^/]+$/)) {
const serviceName = path.split('/').pop()!;
return await this.handleGetRegistryTagsRequest(serviceName);
} else if (path.match(/^\/api\/registry\/token\/[^/]+$/)) {
const serviceName = path.split('/').pop()!;
return await this.handleGetRegistryTokenRequest(serviceName);
} else if (path === '/api/registry/tokens' && method === 'GET') {
return await this.handleListRegistryTokensRequest(req);
} 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 {
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 ============
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 {
// Get the service to verify it exists
const service = this.oneboxRef.database.getServiceByName(serviceName);
if (!service) {
const tokens = this.oneboxRef.database.getAllRegistryTokens();
// 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({
success: false,
error: 'Service not found',
}, 404);
error: 'Missing required fields: name, type, scope, expiresIn',
}, 400);
}
// If service already has a token, return it
if (service.registryToken) {
if (body.type !== 'global' && body.type !== 'ci') {
return this.jsonResponse({
success: true,
data: {
token: service.registryToken,
repository: serviceName,
baseUrl: this.oneboxRef.registry.getBaseUrl(),
},
});
success: false,
error: 'Invalid token type. Must be "global" or "ci"',
}, 400);
}
// Generate new token
const token = await this.oneboxRef.registry.createServiceToken(serviceName);
// Validate scope
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
this.oneboxRef.database.updateService(service.id!, {
registryToken: token,
registryRepository: serviceName,
// If scope is array of services, validate they exist
if (Array.isArray(body.scope)) {
for (const serviceName of body.scope) {
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({
success: true,
data: {
token: token,
repository: serviceName,
baseUrl: this.oneboxRef.registry.getBaseUrl(),
token: tokenView,
plainToken, // Only returned once at creation
},
});
} 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({
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);
}
}

View File

@@ -17,6 +17,7 @@ import { OneboxHttpServer } from './httpserver.ts';
import { CloudflareDomainSync } from './cloudflare-sync.ts';
import { CertRequirementManager } from './cert-requirement-manager.ts';
import { RegistryManager } from './registry.ts';
import { PlatformServicesManager } from './platform-services/index.ts';
export class Onebox {
public database: OneboxDatabase;
@@ -31,6 +32,7 @@ export class Onebox {
public cloudflareDomainSync: CloudflareDomainSync;
public certRequirementManager: CertRequirementManager;
public registry: RegistryManager;
public platformServices: PlatformServicesManager;
private initialized = false;
@@ -56,6 +58,9 @@ export class Onebox {
// Initialize domain management
this.cloudflareDomainSync = new CloudflareDomainSync(this.database);
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}`);
}
// 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
await this.registries.loginToAllRegistries();
@@ -170,6 +183,13 @@ export class Onebox {
const runningServices = services.filter((s) => s.status === 'running').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 {
docker: {
running: dockerRunning,
@@ -188,6 +208,7 @@ export class Onebox {
running: runningServices,
stopped: totalServices - runningServices,
},
platformServices: platformServicesStatus,
};
} catch (error) {
logger.error(`Failed to get system status: ${error.message}`);

View 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';

View 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;
}
}

View 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}`;
}
}

View 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;
}
}
}

View 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();
}
}
}

View File

@@ -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
*/

View File

@@ -4,10 +4,11 @@
* 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 { OneboxDatabase } from './database.ts';
import { OneboxDockerManager } from './docker.ts';
import type { PlatformServicesManager } from './platform-services/index.ts';
export class OneboxServicesManager {
private oneboxRef: any; // Will be Onebox instance
@@ -34,13 +35,9 @@ export class OneboxServicesManager {
}
// Handle Onebox Registry setup
let registryToken: string | undefined;
let imageToPull: string;
if (options.useOneboxRegistry) {
// Generate registry token
registryToken = await this.oneboxRef.registry.createServiceToken(options.name);
// Use onebox registry image name
const tag = options.registryImageTag || 'latest';
imageToPull = this.oneboxRef.registry.getImageName(options.name, tag);
@@ -49,6 +46,15 @@ export class OneboxServicesManager {
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
const service = await this.database.createService({
name: options.name,
@@ -63,18 +69,46 @@ export class OneboxServicesManager {
// Onebox Registry fields
useOneboxRegistry: options.useOneboxRegistry,
registryRepository: options.useOneboxRegistry ? options.name : undefined,
registryToken: registryToken,
registryImageTag: options.registryImageTag || 'latest',
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)
if (!options.useOneboxRegistry) {
await this.docker.pullImage(imageToPull, options.registry);
}
// Create container
const containerID = await this.docker.createContainer(service);
// Create container (uses the updated service with merged env vars)
const containerID = await this.docker.createContainer(serviceWithEnvVars);
// Update service with container 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
}
// 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
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
*/

View File

@@ -6,6 +6,7 @@
import * as plugins from '../plugins.ts';
import { logger } from '../logging.ts';
import { getErrorMessage } from '../utils/error.ts';
import { OneboxDatabase } from './database.ts';
import { SqliteCertManager } from './certmanager.ts';
@@ -77,7 +78,7 @@ export class OneboxSslManager {
logger.success('SSL manager initialized with SmartACME DNS-01 challenge');
} catch (error) {
logger.error(`Failed to initialize SSL manager: ${error.message}`);
logger.error(`Failed to initialize SSL manager: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -121,16 +122,23 @@ export class OneboxSslManager {
// Reload certificates in reverse proxy
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 {
certPath: cert.certFilePath,
keyPath: cert.keyFilePath,
fullChainPath: cert.chainFilePath || cert.certFilePath,
certPath: dbCert.certPath,
keyPath: dbCert.keyPath,
fullChainPath: dbCert.fullChainPath,
expiryDate: cert.validUntil,
issuer: cert.issuer || 'Let\'s Encrypt',
issuer: dbCert.issuer || 'Let\'s Encrypt',
};
} catch (error) {
logger.error(`Failed to acquire certificate for ${domain}: ${error.message}`);
logger.error(`Failed to acquire certificate for ${domain}: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -164,7 +172,7 @@ export class OneboxSslManager {
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.error(`Failed to obtain certificate for ${domain}: ${error.message}`);
logger.error(`Failed to obtain certificate for ${domain}: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -203,7 +211,7 @@ export class OneboxSslManager {
logger.success(`Certbot obtained certificate for ${domain}`);
} 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
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.error(`Failed to renew certificate for ${domain}: ${error.message}`);
logger.error(`Failed to renew certificate for ${domain}: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -270,14 +278,14 @@ export class OneboxSslManager {
await this.renewCertificate(dbCert.domain);
}
} catch (error) {
logger.error(`Failed to renew ${dbCert.domain}: ${error.message}`);
logger.error(`Failed to renew ${dbCert.domain}: ${getErrorMessage(error)}`);
// Continue with other certificates
}
}
logger.success('Certificate renewal check complete');
} catch (error) {
logger.error(`Failed to check expiring certificates: ${error.message}`);
logger.error(`Failed to check expiring certificates: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -307,7 +315,7 @@ export class OneboxSslManager {
// Reload certificates in reverse proxy
await this.oneboxRef.reverseProxy.reloadCertificates();
} catch (error) {
logger.error(`Failed to renew all certificates: ${error.message}`);
logger.error(`Failed to renew all certificates: ${getErrorMessage(error)}`);
throw error;
}
}
@@ -358,7 +366,7 @@ export class OneboxSslManager {
return null;
} 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;
}
}

View File

@@ -4,6 +4,7 @@
import { logger } from './logging.ts';
import { projectInfo } from './info.ts';
import { getErrorMessage } from './utils/error.ts';
import { Onebox } from './classes/onebox.ts';
import { OneboxDaemon } from './classes/daemon.ts';
@@ -80,7 +81,7 @@ export async function runCli(): Promise<void> {
// Cleanup
await onebox.shutdown();
} catch (error) {
logger.error(error.message);
logger.error(getErrorMessage(error));
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[]) {
switch (subcommand) {
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;
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;
case 'status': {
const status = await onebox.nginx.getStatus();
logger.info(`Nginx status: ${status}`);
const status = onebox.reverseProxy.getStatus();
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;
}

View File

@@ -18,10 +18,11 @@ export interface IService {
// Onebox Registry fields
useOneboxRegistry?: boolean;
registryRepository?: string;
registryToken?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
imageDigest?: string;
// Platform service requirements
platformRequirements?: IPlatformRequirements;
}
// Registry types
@@ -33,6 +34,96 @@ export interface IRegistry {
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
export interface INginxConfig {
id?: number;
@@ -193,6 +284,9 @@ export interface IServiceDeployOptions {
useOneboxRegistry?: boolean;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
// Platform service requirements
enableMongoDB?: boolean;
enableS3?: boolean;
}
// HTTP API request/response types

43
ts/utils/error.ts Normal file
View 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;
}

View File

@@ -84,6 +84,13 @@ export const routes: Routes = [
(m) => m.RegistriesComponent
),
},
{
path: 'tokens',
loadComponent: () =>
import('./features/tokens/tokens.component').then(
(m) => m.TokensComponent
),
},
{
path: 'settings',
loadComponent: () =>

View File

@@ -12,8 +12,14 @@ import {
IDnsRecord,
IRegistry,
IRegistryCreate,
IRegistryToken,
ICreateTokenRequest,
ITokenCreatedResponse,
ISetting,
ISettings,
IPlatformService,
IPlatformResource,
TPlatformServiceType,
} from '../types/api.types';
@Injectable({ providedIn: 'root' })
@@ -75,6 +81,19 @@ export class ApiService {
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
async getDnsRecords(): Promise<IApiResponse<IDnsRecord[]>> {
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`));
}
}

View File

@@ -15,6 +15,16 @@ export interface ILoginResponse {
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 {
id?: number;
name: string;
@@ -29,10 +39,10 @@ export interface IService {
updatedAt: number;
useOneboxRegistry?: boolean;
registryRepository?: string;
registryToken?: string;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
imageDigest?: string;
platformRequirements?: IPlatformRequirements;
}
export interface IServiceCreate {
@@ -44,6 +54,8 @@ export interface IServiceCreate {
useOneboxRegistry?: boolean;
registryImageTag?: string;
autoUpdateOnPush?: boolean;
enableMongoDB?: boolean;
enableS3?: boolean;
}
export interface IServiceUpdate {
@@ -67,6 +79,7 @@ export interface ISystemStatus {
dns: { configured: boolean };
ssl: { configured: boolean; certbotInstalled: boolean };
services: { total: number; running: number; stopped: number };
platformServices: Array<{ type: TPlatformServiceType; status: TPlatformServiceStatus }>;
}
export interface IDomain {
@@ -138,6 +151,32 @@ export interface IRegistryCreate {
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 {
key: string;
value: string;
@@ -173,3 +212,27 @@ export interface IToast {
message: string;
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;
}

View File

@@ -1,5 +1,6 @@
import { Component, inject, signal, OnInit } 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 { IRegistry, IRegistryCreate } from '../../core/types/api.types';
@@ -13,6 +14,7 @@ import {
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,
@@ -35,6 +37,7 @@ import {
standalone: true,
imports: [
FormsModule,
RouterLink,
CardComponent,
CardHeaderComponent,
CardTitleComponent,
@@ -43,6 +46,7 @@ import {
ButtonComponent,
InputComponent,
LabelComponent,
BadgeComponent,
TableComponent,
TableHeaderComponent,
TableBodyComponent,
@@ -59,42 +63,75 @@ import {
template: `
<div class="space-y-6">
<div>
<h1 class="text-3xl font-bold tracking-tight">Docker Registries</h1>
<p class="text-muted-foreground">Manage Docker registry credentials</p>
<h1 class="text-3xl font-bold tracking-tight">Registries</h1>
<p class="text-muted-foreground">Manage container image registries</p>
</div>
<!-- Add Registry Form -->
<ui-card>
<ui-card-header class="flex flex-col space-y-1.5">
<ui-card-title>Add Registry</ui-card-title>
<ui-card-description>Add credentials for a private Docker registry</ui-card-description>
<!-- Onebox Registry Card -->
<ui-card class="border-primary/50">
<ui-card-header class="flex flex-row items-start justify-between space-y-0">
<div class="space-y-1">
<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-content>
<form (ngSubmit)="addRegistry()" class="grid gap-4 md:grid-cols-4">
<div class="space-y-2">
<label uiLabel>Registry URL</label>
<input uiInput [(ngModel)]="form.url" name="url" placeholder="registry.example.com" required />
<div class="grid gap-6 md:grid-cols-3">
<div>
<div class="text-sm font-medium text-muted-foreground">Status</div>
<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 class="space-y-2">
<label uiLabel>Username</label>
<input uiInput [(ngModel)]="form.username" name="username" required />
<div>
<div class="text-sm font-medium text-muted-foreground">Registry URL</div>
<div class="font-mono text-sm mt-1">localhost:3000/v2</div>
</div>
<div class="space-y-2">
<label uiLabel>Password</label>
<input uiInput type="password" [(ngModel)]="form.password" name="password" required />
<div>
<div class="text-sm font-medium text-muted-foreground">Authentication</div>
<div class="mt-1">
<a routerLink="/tokens" class="text-primary hover:underline text-sm">
Manage Tokens
</a>
</div>
</div>
<div class="flex items-end">
<button uiButton type="submit" [disabled]="loading()">Add Registry</button>
</div>
<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>
</form>
</div>
</ui-card-content>
</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-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">
@if (loading() && registries().length === 0) {
<div class="p-6 space-y-4">
@@ -104,15 +141,24 @@ import {
</div>
} @else if (registries().length === 0) {
<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>
} @else {
<ui-table>
<ui-table-header>
<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>Created</ui-table-head>
<ui-table-head>Added</ui-table-head>
<ui-table-head class="text-right">Actions</ui-table-head>
</ui-table-row>
</ui-table-header>
@@ -136,6 +182,35 @@ import {
</ui-card>
</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-header>
<ui-dialog-title>Delete Registry</ui-dialog-title>
@@ -156,6 +231,7 @@ export class RegistriesComponent implements OnInit {
registries = signal<IRegistry[]>([]);
loading = signal(false);
addDialogOpen = signal(false);
deleteDialogOpen = signal(false);
registryToDelete = signal<IRegistry | null>(null);
@@ -191,6 +267,7 @@ export class RegistriesComponent implements OnInit {
if (response.success) {
this.toast.success('Registry added');
this.form = { url: '', username: '', password: '' };
this.addDialogOpen.set(false);
this.loadRegistries();
} else {
this.toast.error(response.error || 'Failed to add registry');

View File

@@ -186,6 +186,48 @@ interface EnvVar {
<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 -->
<div class="space-y-4">
<div class="flex items-center gap-3">
@@ -257,6 +299,8 @@ export class ServiceCreateComponent implements OnInit {
useOneboxRegistry: false,
registryImageTag: 'latest',
autoUpdateOnPush: false,
enableMongoDB: false,
enableS3: false,
};
envVars = signal<EnvVar[]>([]);

View File

@@ -4,7 +4,7 @@ import { FormsModule } from '@angular/forms';
import { ApiService } from '../../core/services/api.service';
import { ToastService } from '../../core/services/toast.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 {
CardComponent,
CardHeaderComponent,
@@ -209,6 +209,61 @@ import {
</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 -->
@if (service()!.useOneboxRegistry) {
<ui-card>
@@ -225,21 +280,11 @@ import {
<dt class="text-sm font-medium text-muted-foreground">Tag</dt>
<dd class="text-sm">{{ service()!.registryImageTag || 'latest' }}</dd>
</div>
@if (service()!.registryToken) {
<div>
<dt class="text-sm font-medium text-muted-foreground">Push Token</dt>
<dd class="flex items-center gap-2">
<input
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 class="pt-2 border-t">
<a routerLink="/tokens" class="text-sm text-primary hover:underline">
Manage registry tokens for CI/CD pipelines →
</a>
</div>
<div>
<dt class="text-sm font-medium text-muted-foreground">Auto-update on push</dt>
<dd class="text-sm">{{ service()!.autoUpdateOnPush ? 'Enabled' : 'Disabled' }}</dd>
@@ -346,6 +391,7 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
@ViewChild('logContainer') logContainer!: ElementRef<HTMLDivElement>;
service = signal<IService | null>(null);
platformResources = signal<IPlatformResource[]>([]);
loading = signal(false);
actionLoading = signal(false);
editMode = signal(false);
@@ -389,6 +435,11 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
port: response.data.port,
domain: response.data.domain,
};
// Load platform resources if service has platform requirements
if (response.data.platformRequirements) {
this.loadPlatformResources(name);
}
} else {
this.toast.error(response.error || 'Service not found');
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 {
const name = this.service()?.name;
if (name) {
@@ -546,12 +608,4 @@ export class ServiceDetailComponent implements OnInit, OnDestroy {
this.deleteDialogOpen.set(false);
}
}
copyToken(): void {
const token = this.service()?.registryToken;
if (token) {
navigator.clipboard.writeText(token);
this.toast.success('Token copied to clipboard');
}
}
}

View 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();
}
}

View File

@@ -119,6 +119,7 @@ export class LayoutComponent {
{ label: 'Dashboard', path: '/dashboard', icon: 'home' },
{ label: 'Services', path: '/services', icon: 'server' },
{ label: 'Registries', path: '/registries', icon: 'database' },
{ label: 'Tokens', path: '/tokens', icon: 'key' },
{ label: 'DNS', path: '/dns', icon: 'globe' },
{ label: 'Domains', path: '/domains', icon: 'link' },
{ label: 'Settings', path: '/settings', icon: 'settings' },