28 KiB
Kasm Workspaces & Multi-Tenancy Implementation
Overview
This document details the implementation plan for:
- Kasm Workspaces - Cloud desktop/workspace service with hourly billing
- Multi-Tenancy - White-label reseller platform using Tenancy for Laravel
Part 1: Kasm Workspaces Integration
What is Kasm Workspaces?
Kasm Workspaces provides streaming containerized apps and desktops to end-users. It's perfect for:
- Developer Workspaces: Pre-configured dev environments (VS Code, IDEs, terminals)
- Business Workspaces: Office apps, browsers, secure remote work environments
Official Docs: https://docs.kasm.com/ Developer API: https://docs.kasm.com/docs/latest/developers/developer_api/
Service Offerings
| Workspace Type | Use Case | Target Customers |
|---|---|---|
| Developer Basic | 2 vCPU, 4GB RAM, Linux with VS Code | Freelance developers, students |
| Developer Pro | 4 vCPU, 8GB RAM, multiple IDEs, Docker | Professional developers, small teams |
| Developer Enterprise | 8 vCPU, 16GB RAM, full dev stack | Development teams, agencies |
| Business Basic | 2 vCPU, 4GB RAM, Browser + Office apps | Remote workers, contractors |
| Business Pro | 4 vCPU, 8GB RAM, Full office suite | Business users, managers |
| Business Enterprise | 8 vCPU, 16GB RAM, Custom apps | Executives, power users |
Provisioning Strategy
Fully Automated Provisioning
- Customer selects workspace type and template
- Payment processed (either subscription or credits added)
- Kasm API called to create workspace
- Workspace URL and credentials sent via email
- Customer can access workspace immediately
Kasm API Integration
Key API Endpoints
POST /api/public/create_workspace
Parameters:
- workspace_type (developer_basic, business_pro, etc.)
- user_id
- session_duration (optional)
Response:
{
"workspace_id": "abc-123",
"access_url": "https://kasm.ezscale.cloud/workspace/abc-123",
"username": "user@email.com",
"password": "generated_password"
}
POST /api/public/destroy_workspace
Parameters:
- workspace_id
GET /api/public/workspace_status
Parameters:
- workspace_id
Response:
{
"status": "running",
"uptime_seconds": 3600,
"cpu_usage_percent": 45,
"ram_usage_mb": 2048
}
POST /api/public/stop_workspace
POST /api/public/start_workspace
Authentication
- API Key authentication via headers
X-API-Key: <key>andX-API-Secret: <secret>- Keys generated in Kasm admin panel
Database Schema
kasm_workspaces table:
├── id
├── user_id
├── service_id (links to main services table)
├── workspace_type (developer_basic, business_pro, etc.)
├── kasm_workspace_id (Kasm's internal ID)
├── access_url
├── username (workspace login)
├── password_encrypted
├── status (provisioning, running, stopped, terminated)
├── vcpu (2, 4, 8)
├── ram_mb (4096, 8192, 16384)
├── template_name (Ubuntu with VS Code, Windows 11 Business, etc.)
├── created_at
├── provisioned_at
├── last_started_at
├── last_stopped_at
├── terminated_at
kasm_usage_sessions table:
├── id
├── kasm_workspace_id
├── started_at
├── stopped_at (nullable - ongoing session)
├── duration_seconds (calculated)
├── duration_billable_seconds (rounded to 15-min increments)
├── cost_per_hour (rate at time of use)
├── total_cost (duration_billable_seconds / 3600 * cost_per_hour)
├── invoice_id (nullable - which invoice this was billed on)
├── created_at
kasm_workspace_templates table:
├── id
├── name (Ubuntu 22.04 + VS Code)
├── description
├── workspace_type (developer, business)
├── kasm_template_id (Kasm's image ID)
├── icon_url
├── vcpu_default
├── ram_mb_default
├── preinstalled_apps (JSON array)
├── status (active, deprecated)
├── sort_order
Hourly Billing Model
Billing Calculation
- 15-minute increments (round up)
- Examples:
- 8 minutes = 15 minutes = $0.05 (if $0.20/hour)
- 22 minutes = 30 minutes = $0.10
- 1 hour 5 minutes = 1 hour 15 minutes = $0.25
Pricing Structure
| Workspace Type | vCPU | RAM | Price per Hour |
|---|---|---|---|
| Developer Basic | 2 | 4GB | $0.15 |
| Developer Pro | 4 | 8GB | $0.30 |
| Developer Enterprise | 8 | 16GB | $0.60 |
| Business Basic | 2 | 4GB | $0.20 |
| Business Pro | 4 | 8GB | $0.40 |
| Business Enterprise | 8 | 16GB | $0.80 |
Note: Pricing subject to adjustment based on Kasm licensing costs and infrastructure overhead.
Hybrid Billing Approach
- Real-time tracking: Dashboard shows current running cost
- Monthly invoicing: All usage invoiced at end of billing cycle
- Running total: Customer sees "Current month usage: $47.35" in real-time
- Low balance alerts: Warn when usage approaching credit limit
Implementation Flow
1. Customer Orders Workspace
// routes/web.php
Route::post('/kasm/order', [KasmController::class, 'createOrder'])
->middleware(['auth', 'verified']);
// app/Http/Controllers/KasmController.php
public function createOrder(Request $request)
{
$plan = Plan::findOrFail($request->plan_id);
// Validate customer has credits or payment method
if ($user->account_credits < 5.00 && !$user->hasPaymentMethod()) {
return back()->with('error', 'Add credits or payment method first');
}
// Create service record
$service = Service::create([
'user_id' => $user->id,
'plan_id' => $plan->id,
'service_type' => 'kasm_workspace',
'status' => 'provisioning',
]);
// Queue provisioning job
ProvisionKasmWorkspace::dispatch($service);
return redirect()->route('services.show', $service)
->with('success', 'Workspace is being provisioned...');
}
2. Provisioning Job
// app/Jobs/ProvisionKasmWorkspace.php
class ProvisionKasmWorkspace implements ShouldQueue
{
public function handle()
{
$kasmService = app(KasmProvisioningService::class);
try {
// Call Kasm API to create workspace
$result = $kasmService->createWorkspace([
'workspace_type' => $this->service->plan->kasm_workspace_type,
'vcpu' => $this->service->plan->vcpu,
'ram_mb' => $this->service->plan->ram_mb,
'template' => $this->service->plan->kasm_template_id,
]);
// Store workspace details
KasmWorkspace::create([
'service_id' => $this->service->id,
'user_id' => $this->service->user_id,
'workspace_type' => $this->service->plan->kasm_workspace_type,
'kasm_workspace_id' => $result['workspace_id'],
'access_url' => $result['access_url'],
'username' => $result['username'],
'password_encrypted' => encrypt($result['password']),
'status' => 'running',
'vcpu' => $this->service->plan->vcpu,
'ram_mb' => $this->service->plan->ram_mb,
'provisioned_at' => now(),
'last_started_at' => now(),
]);
// Start usage session
KasmUsageSession::create([
'kasm_workspace_id' => $workspace->id,
'started_at' => now(),
'cost_per_hour' => $this->service->plan->price_per_hour,
]);
// Update service status
$this->service->update([
'status' => 'active',
'provisioned_at' => now(),
]);
// Send email with credentials
Mail::to($this->service->user)->send(
new KasmWorkspaceProvisioned($workspace)
);
} catch (\Exception $e) {
// Handle provisioning failure
Log::error('Kasm provisioning failed', [
'service_id' => $this->service->id,
'error' => $e->getMessage(),
]);
$this->service->update(['status' => 'failed']);
// Alert admin via Discord
app(DiscordNotificationService::class)->sendAlert([
'title' => 'Kasm Provisioning Failed',
'message' => "Service #{$this->service->id} failed to provision",
'error' => $e->getMessage(),
]);
}
}
}
3. Usage Tracking
// app/Console/Commands/TrackKasmUsage.php
// Run every 15 minutes via cron
class TrackKasmUsage extends Command
{
public function handle()
{
$activeWorkspaces = KasmWorkspace::where('status', 'running')->get();
foreach ($activeWorkspaces as $workspace) {
// Check if workspace is still running via API
$status = app(KasmProvisioningService::class)
->getWorkspaceStatus($workspace->kasm_workspace_id);
if ($status['status'] === 'stopped') {
// Workspace was stopped, close usage session
$session = KasmUsageSession::where('kasm_workspace_id', $workspace->id)
->whereNull('stopped_at')
->first();
if ($session) {
$session->update([
'stopped_at' => now(),
'duration_seconds' => now()->diffInSeconds($session->started_at),
]);
// Calculate billable duration (round to 15-min increments)
$minutes = ceil($session->duration_seconds / 60);
$billableMinutes = ceil($minutes / 15) * 15;
$billableSeconds = $billableMinutes * 60;
$session->update([
'duration_billable_seconds' => $billableSeconds,
'total_cost' => ($billableSeconds / 3600) * $session->cost_per_hour,
]);
}
$workspace->update(['status' => 'stopped']);
}
}
}
}
4. Monthly Billing
// app/Console/Commands/BillKasmUsage.php
// Run monthly on billing cycle date
class BillKasmUsage extends Command
{
public function handle()
{
$users = User::has('kasmWorkspaces')->get();
foreach ($users as $user) {
// Get unbilled usage sessions
$sessions = KasmUsageSession::whereHas('workspace', function ($q) use ($user) {
$q->where('user_id', $user->id);
})
->whereNull('invoice_id')
->where('stopped_at', '!=', null)
->get();
if ($sessions->isEmpty()) continue;
$totalCost = $sessions->sum('total_cost');
// Create invoice
$invoice = Invoice::create([
'user_id' => $user->id,
'total' => $totalCost,
'currency' => 'USD',
'due_date' => now()->addDays(7),
'description' => 'Kasm Workspace Usage - ' . now()->format('F Y'),
]);
// Add line items
foreach ($sessions as $session) {
InvoiceItem::create([
'invoice_id' => $invoice->id,
'description' => sprintf(
'%s - %s (%d minutes @ $%s/hour)',
$session->workspace->workspace_type,
$session->started_at->format('M d, Y'),
$session->duration_billable_seconds / 60,
number_format($session->cost_per_hour, 2)
),
'amount' => $session->total_cost,
'quantity' => 1,
]);
// Mark session as billed
$session->update(['invoice_id' => $invoice->id]);
}
// Send invoice email
Mail::to($user)->send(new InvoiceGenerated($invoice));
// Charge payment method
if ($user->hasDefaultPaymentMethod()) {
try {
$payment = $user->charge($totalCost * 100, $user->defaultPaymentMethod());
$invoice->update(['paid_at' => now()]);
} catch (\Exception $e) {
// Payment failed - send notification
Mail::to($user)->send(new PaymentFailed($invoice));
}
}
}
}
}
Customer Dashboard Features
Workspace Management
- Start/Stop Controls: Customer can start/stop workspace from dashboard
- Current Session Timer: "Running for 2 hours 37 minutes - $0.50"
- Monthly Usage Summary: "This month: $47.35 (23.5 hours)"
- Session History: List of all sessions with durations and costs
- Workspace Access: One-click button to launch workspace in new tab
Running Cost Indicator
<!-- resources/js/Pages/Services/KasmWorkspace.vue -->
<template>
<div class="workspace-status">
<div v-if="workspace.status === 'running'" class="running">
<Icon name="circle-dot" class="text-green-500 animate-pulse" />
<span>Running for {{ runningDuration }}</span>
<span class="text-lg font-bold">${{ currentCost }}</span>
<button @click="stopWorkspace">Stop Workspace</button>
</div>
<div v-else class="stopped">
<Icon name="circle" class="text-gray-400" />
<span>Stopped</span>
<button @click="startWorkspace">Start Workspace</button>
</div>
<div class="usage-summary">
<h3>This Month's Usage</h3>
<p class="text-2xl">${{ monthlyTotal }}</p>
<p class="text-sm text-gray-600">{{ totalHours }} hours</p>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from 'vue'
import { router } from '@inertiajs/vue3'
const props = defineProps(['workspace'])
const runningDuration = ref('0m')
const currentCost = ref(0)
// Update every minute if running
let interval
onMounted(() => {
if (props.workspace.status === 'running') {
updateRunningCost()
interval = setInterval(updateRunningCost, 60000) // Every minute
}
})
function updateRunningCost() {
const started = new Date(props.workspace.last_started_at)
const now = new Date()
const minutes = Math.floor((now - started) / 60000)
const hours = Math.floor(minutes / 60)
const mins = minutes % 60
runningDuration.value = hours > 0 ? `${hours}h ${mins}m` : `${mins}m`
// Calculate cost (round to 15-min increments)
const billableMinutes = Math.ceil(minutes / 15) * 15
currentCost.value = ((billableMinutes / 60) * props.workspace.cost_per_hour).toFixed(2)
}
function startWorkspace() {
router.post(`/kasm/${props.workspace.id}/start`)
}
function stopWorkspace() {
router.post(`/kasm/${props.workspace.id}/stop`)
}
</script>
Part 2: Multi-Tenancy (White-Label Resellers)
What is Multi-Tenancy?
Allowing resellers to run their own branded billing platform using your infrastructure. Each reseller (tenant) has:
- Their own customers
- Their own branding (logo, colors, domain)
- Their own pricing
- Isolated database
Tenancy for Laravel Package
Package: https://tenancyforlaravel.com/ Documentation: https://tenancyforlaravel.com/docs/v3/
Key Features:
- Automatic tenant identification (by domain, subdomain, or path)
- Database per tenant (complete isolation)
- Tenant-aware caching, filesystems, queues
- Easy migrations across all tenants
- Central app + tenant apps architecture
Architecture
Central Application
- Domain: ezscale.cloud (your main application)
- Purpose: Manage resellers (tenants), global settings, wholesale billing
- Database:
ezscale_central(stores tenant list, domains, configs)
Tenant Applications
- Domains:
reseller1.com,reseller2.hosting,custom-domain.net - Purpose: Each reseller's branded billing platform
- Databases:
tenant_reseller1,tenant_reseller2,tenant_custom
Database Structure
Central Database (ezscale_central)
tenants table:
├── id
├── name (Reseller Company Name)
├── slug (reseller1, reseller2)
├── database_name (tenant_reseller1)
├── domain (reseller1.com, custom-domain.net)
├── status (active, suspended, trial)
├── owner_email
├── owner_name
├── created_at
├── trial_ends_at (nullable)
├── suspended_at (nullable)
tenant_domains table:
├── id
├── tenant_id
├── domain (reseller1.com, reseller1.ezscale.cloud, custom-domain.net)
├── type (primary, alias)
├── ssl_status (pending, active, failed)
├── verified_at
├── created_at
tenant_billing table:
├── id
├── tenant_id
├── plan_id (reseller tier: basic, pro, enterprise)
├── wholesale_discount_percent (e.g., 30% off retail prices)
├── monthly_fee (platform fee - e.g., $99/month)
├── commission_percent (if commission-based instead of wholesale)
├── billing_cycle_day (1-28)
├── next_billing_date
├── status (active, past_due, cancelled)
tenant_branding table:
├── id
├── tenant_id
├── logo_url
├── favicon_url
├── primary_color (#3B82F6)
├── secondary_color
├── company_name
├── support_email
├── support_phone
├── from_email (noreply@reseller.com)
├── from_name (Reseller Hosting)
├── custom_css (nullable - advanced branding)
Tenant Databases (e.g., tenant_reseller1)
Each tenant gets a complete copy of the main application schema:
users(reseller's customers)plans(reseller's custom pricing)subscriptionsinvoicesserviceskasm_workspaces- ... all other tables from main schema
Key difference: Tenant plans reference wholesale prices in central database, but show custom prices to customers.
Installation & Setup
# Install Tenancy for Laravel
composer require stancl/tenancy
# Publish config and migrations
php artisan vendor:publish --provider='Stancl\Tenancy\TenancyServiceProvider'
# Run central migrations
php artisan migrate
# Configure tenancy
# config/tenancy.php
return [
'tenant_model' => \App\Models\Tenant::class,
'id_generator' => \Stancl\Tenancy\UUIDGenerator::class,
'database' => [
'prefix' => 'tenant_',
'template_tenant_connection' => 'mysql',
],
'bootstrappers' => [
\Stancl\Tenancy\Bootstrappers\DatabaseTenancyBootstrapper::class,
\Stancl\Tenancy\Bootstrappers\CacheTenancyBootstrapper::class,
\Stancl\Tenancy\Bootstrappers\FilesystemTenancyBootstrapper::class,
\Stancl\Tenancy\Bootstrappers\QueueTenancyBootstrapper::class,
],
];
Creating a Reseller (Tenant)
// app/Http/Controllers/Admin/ResellerController.php
public function store(Request $request)
{
$validated = $request->validate([
'name' => 'required|string',
'email' => 'required|email|unique:tenants,owner_email',
'domain' => 'required|string|unique:tenant_domains,domain',
]);
// Create tenant
$tenant = Tenant::create([
'name' => $validated['name'],
'slug' => Str::slug($validated['name']),
'owner_email' => $validated['email'],
]);
// Create domain
$tenant->domains()->create([
'domain' => $validated['domain'],
'type' => 'primary',
]);
// Run migrations for tenant database
$tenant->run(function () {
Artisan::call('migrate', ['--database' => 'tenant', '--force' => true]);
// Seed initial data (plans, roles, etc.)
Artisan::call('db:seed', ['--class' => 'TenantSeeder']);
});
// Set up branding defaults
TenantBranding::create([
'tenant_id' => $tenant->id,
'company_name' => $validated['name'],
'primary_color' => '#3B82F6',
'support_email' => $validated['email'],
]);
// Send welcome email to reseller
Mail::to($validated['email'])->send(new ResellerWelcome($tenant));
return redirect()->route('admin.resellers.index')
->with('success', 'Reseller created successfully!');
}
Tenant Identification (Automatic)
// routes/web.php (Central app routes)
Route::get('/', [HomeController::class, 'index']);
Route::get('/admin/resellers', [Admin\ResellerController::class, 'index'])
->middleware(['auth', 'admin']);
// routes/tenant.php (Tenant-specific routes)
Route::middleware('tenant')->group(function () {
// These routes run within tenant context
Route::get('/', [Tenant\DashboardController::class, 'index']);
Route::get('/services', [Tenant\ServiceController::class, 'index']);
Route::post('/kasm/order', [Tenant\KasmController::class, 'createOrder']);
// ... all customer-facing routes
});
// Middleware automatically identifies tenant by domain
// If request is to reseller1.com → loads tenant_reseller1 database
// If request is to ezscale.cloud → uses central database
Reseller Pricing Control
// Tenant database: plans table
// Reseller can set any price they want
CREATE TABLE plans (
id INT PRIMARY KEY,
name VARCHAR(255),
description TEXT,
price DECIMAL(10, 2), -- Reseller's customer price
wholesale_price DECIMAL(10, 2), -- What reseller pays EZSCALE
service_type VARCHAR(50),
-- ... other fields
);
// Example:
// VPS Basic
// - Wholesale price (you charge reseller): $8/month
// - Reseller's price (they charge customer): $15/month
// - Reseller's profit: $7/month per customer
// When customer subscribes:
// 1. Customer pays reseller $15
// 2. Reseller pays you $8 (via wholesale invoice)
// 3. Reseller keeps $7 profit
Wholesale Billing (Billing Resellers)
// app/Console/Commands/BillResellers.php
// Run monthly to bill resellers for their customer usage
class BillResellers extends Command
{
public function handle()
{
$tenants = Tenant::where('status', 'active')->get();
foreach ($tenants as $tenant) {
// Switch to tenant database
$tenant->run(function () use ($tenant) {
// Count active subscriptions
$subscriptions = Subscription::where('status', 'active')->get();
$wholesaleTotal = 0;
foreach ($subscriptions as $subscription) {
$plan = Plan::find($subscription->plan_id);
$wholesaleTotal += $plan->wholesale_price;
}
// Add platform fee
$platformFee = TenantBilling::where('tenant_id', $tenant->id)
->value('monthly_fee');
$totalDue = $wholesaleTotal + $platformFee;
// Create wholesale invoice in CENTRAL database
tenancy()->end(); // Switch back to central
WholesaleInvoice::create([
'tenant_id' => $tenant->id,
'platform_fee' => $platformFee,
'usage_charges' => $wholesaleTotal,
'total' => $totalDue,
'due_date' => now()->addDays(7),
]);
// Email reseller
Mail::to($tenant->owner_email)->send(
new ResellerInvoice($tenant, $totalDue)
);
});
}
}
}
Reseller Dashboard (Central App)
Resellers log into ezscale.cloud/reseller to:
- View their wholesale invoices
- See customer count and revenue
- Manage branding (logo, colors)
- View usage statistics
- Configure pricing for their plans
- Add/manage their custom domains
Branding Customization
// app/Http/Controllers/Reseller/BrandingController.php
public function update(Request $request)
{
$tenant = Auth::user()->tenant;
$validated = $request->validate([
'logo' => 'nullable|image|max:2048',
'primary_color' => 'required|string',
'company_name' => 'required|string',
'support_email' => 'required|email',
]);
if ($request->hasFile('logo')) {
$logoPath = $request->file('logo')->store('tenant_logos', 's3');
$validated['logo_url'] = Storage::disk('s3')->url($logoPath);
}
$tenant->branding()->update($validated);
return back()->with('success', 'Branding updated!');
}
Loading Tenant Branding
// app/Http/Middleware/InjectTenantBranding.php
public function handle($request, $next)
{
if (tenancy()->initialized) {
$branding = TenantBranding::where('tenant_id', tenant('id'))->first();
// Share branding with all views
View::share('branding', $branding);
// Inject CSS variables
if ($branding) {
$customCss = "
:root {
--primary-color: {$branding->primary_color};
--secondary-color: {$branding->secondary_color};
}
";
View::share('customCss', $customCss);
}
}
return $next($request);
}
<!-- resources/views/layouts/tenant.blade.php -->
<!DOCTYPE html>
<html>
<head>
<title>{{ $branding->company_name ?? 'EZSCALE Hosting' }}</title>
<link rel="icon" href="{{ $branding->favicon_url ?? asset('favicon.ico') }}">
@if(isset($customCss))
<style>{!! $customCss !!}</style>
@endif
@vite(['resources/css/app.css', 'resources/js/app.js'])
</head>
<body>
<nav>
<img src="{{ $branding->logo_url ?? asset('logo.png') }}" alt="Logo">
<span>{{ $branding->company_name }}</span>
</nav>
@yield('content')
<footer>
<p>Support: {{ $branding->support_email }}</p>
</footer>
</body>
</html>
Integration Points
Kasm + Multi-Tenancy
- Each tenant (reseller) can offer Kasm workspaces
- Resellers set their own Kasm pricing
- Usage tracked per tenant database
- Wholesale billing aggregates all tenant usage
Database Structure
Central DB (ezscale_central)
├── tenants
├── tenant_domains
├── tenant_billing
└── wholesale_invoices
Tenant DB (tenant_reseller1)
├── users (reseller's customers)
├── subscriptions
├── kasm_workspaces
├── kasm_usage_sessions
└── invoices (customer invoices)
Implementation Phases
Phase 12: Kasm Workspaces (New Phase)
- Research Kasm licensing (per named user vs concurrent session)
- Set up Kasm Workspaces instance
- Create Kasm API integration service
- Build workspace provisioning automation
- Implement usage tracking (15-min increments)
- Build workspace management UI
- Implement start/stop controls
- Create workspace templates (dev + business)
- Build monthly usage billing
- Test end-to-end workflow
Phase 13: Multi-Tenancy (New Phase)
- Install Tenancy for Laravel package
- Configure tenant identification (domain-based)
- Create central reseller management UI
- Build tenant creation workflow
- Implement automatic tenant migrations
- Build branding customization system
- Create wholesale billing system
- Build reseller dashboard
- Test multi-domain SSL (Let's Encrypt)
- Test tenant isolation thoroughly
- Create reseller onboarding documentation
Open Questions
- Kasm Licensing: Which Kasm license tier (per named user vs concurrent)? Cost per user?
- Kasm Infrastructure: Self-hosted Kasm server or Kasm cloud? If self-hosted, hardware requirements?
- Reseller Trials: Should resellers get trial period? How long?
- Reseller Pricing: Fixed platform fee ($99/month) or percentage of revenue?
- Minimum Customers: Require minimum customer count before reseller can launch?
- Support Responsibility: Who handles tenant customer support - you or reseller?
Summary
This document provides comprehensive implementation plans for:
-
Kasm Workspaces Integration
- Fully automated provisioning
- Hourly billing with 15-minute increments
- Real-time usage tracking
- Developer and Business workspace types
- Complete API integration
-
Multi-Tenancy for Resellers
- Database-per-tenant isolation
- Full white-label branding
- Custom domain support
- Reseller pricing control
- Wholesale billing system
Both features significantly expand EZSCALE's service offerings and create new revenue streams through workspace hosting and reseller partnerships.
References: