Files
portal-api/docs/hosting-module-design.md
T
baodev0 79f06c2fa2 Add bulk update gateway endpoint to vCenter API routes
- Introduced a new POST route 'bulk-update-gateway' for bulk updating gateways.
- This route is protected by the 'module-vcenter.manager' permission middleware.
2026-08-06 15:32:51 +07:00

26 KiB

Hosting Management Module — Phase 1 (Provisioning) Design

Scope: create Plesk Customer → create Hosting Subscription → assign Plan → persist the local record. Nothing else. Database/SSL/Mail/Git deploy/Let's Encrypt/auto-upload are explicitly out of scope and are not stubbed — no placeholder methods for them are created in this pass.

This revises the earlier proposal in this file: type is now a varchar column on dns_manager_servers (not a capability pivot), per explicit instruction. Everything else below is re-verified against the actual source, not carried over by assumption.

1. Audit — what's actually in the codebase

1.1 Domain / DNS / vCenter modules

  • DNS: table dns_manager_servers (model App\Models\DnsServer) — the live Plesk credential store: id, name, ip_address, username, password, api_key, status, timestamps. No type, hostname, port, protocol columns exist today. dns_manager_domain (model DnsDomain) has dns_manager_server_id FK and plesk_id. dns_manager_ns (model DnsManagerNS) has plesk_id too. Repository: App\Repositories\DnsManager\Server\DnsServerRepositoryInterface → bound in RepositoryProvider — actually check: this specific interface/binding was not found in RepositoryProvider.php's bind list (only Module\DnsServer\DnsServerRepositoryInterface, a different, legacy class tied to table module_dns_server, was found bound). This means ServerService (below) currently resolves DnsManager\Server\DnsServerRepositoryInterface from the container without an explicit binding visible in the audited file — either it's bound elsewhere (a second provider) or Laravel's implicit binding/ interface-to-concrete auto-resolution isn't in play and this is already fragile. Do not copy this uncertainty into the new module — bind the new interface explicitly (§6).
  • Plesk HTTP calls today live in App\Services\ServerService, talking to :8443/api/v2/{server,domains,dns/records} with Http::withoutVerifying()->withBasicAuth($username, Crypt::decryptString($password)). It already implements createDomain(), updateDomain(), removeDomain(), getDomainInfo(), listRecords(), checkConnection(). createDomain() already POSTs hosting_settings.ftp_login/ftp_password + plan — i.e. it's already creating a hosting-capable Plesk domain object, not a bare DNS zone. This is the piece to extract and reuse (§7), not reimplement.
  • vCenter: App\Http\Services\vCenter\Client — same shape (Http facade, Basic Auth, returns raw arrays/bools), different domain. Confirms "thin service class over Http facade, no HTTP abstraction layer" is the house style, not a DNS-module one-off.
  • Legacy Hosting (module_hosting, module_hosting_server, App\Http\Services\Hosting, App\Jobs\Hosting\CreateHostingJobs): a pre-services era, WHMCS-shaped flow. Different crypto (SignRSA, not Crypt::), different credential table, different (non-Plesk-v2-REST) API surface (CheckUserClient, CreateNewClient, CreateNewDomain, CreateExpirationForDomain), stores the account password directly on module_hosting.password. Not reused — instruction says no legacy tables and no duplicated data; this codebase's own services/products layer already superseded it for VPS (module_vmware + services.vm_id) and Hosting should follow that same replacement, not the old one.
  • control_panels / product_control_panel: exist, seeded (Plesk, DirectAdmin, cPanel, ...), unused by any controller/service found. Out of scope to wire up in Phase 1 (no product-selection UI requested), but worth knowing they exist so Phase 1 doesn't hardcode "Plesk" somewhere that later blocks reading the panel off the product.

1.2 Repository Pattern

Confirmed convention, three files per resource:

XRepositoryInterface  extends BaseRepositoryInterface
XRepository            extends BaseRepository            (getModel(), query logic)
XCachedRepository      extends BaseCachedRepository, implements XRepositoryInterface
                        (wraps XRepository, Cache::remember on read methods)

Bound in app/Providers/RepositoryProvider.php: $this->app->singleton(XRepositoryInterface::class, XCachedRepository::class);

This is the pattern used for Module\DnsServer, Module\HostingServer, Module\Hosting (legacy), Domain, etc. — followed as-is for the new module, no changes to the pattern itself.

1.3 Service Pattern

Plain App\Services\* or App\Http\Services\{Domain}\* classes, no interfaces, no DI container binding beyond constructor injection of repositories. Return shape for external-API-calling services observed consistently: ['error' => bool, 'data' => ..., 'message' => ...] (see ServerService — every public method returns this shape or array|false). Followed as-is.

1.4 Naming convention

  • Tables: snake_case, no prefix for "core" entities (customers, services, products), module_ prefix for provisioned-resource tables descended from the legacy admin panel (module_vmware, module_dns_server, module_hosting). No tbl_ prefix anywhere in the codebase.
  • Migrations: unsignedBigInteger('x_id')->index() + a separate $table->foreign('x_id')->references('id')->on('x')->... call is the dominant style (46 migrations) over the foreignId() shorthand (18 migrations, mostly older). Followed as the majority style for consistency with every table this module touches (dns_manager_servers, services, customers).
  • Migration filenames: YYYY_MM_DD_HHMMSS_verb_noun.php, one Schema::create/Schema::table block per concern; multi-step column changes get their own migration file rather than being folded into the original create (e.g. add_domain_fields_to_services.php, alter_services_vm_id_to_fk.php).

1.5 Permission convention

Seeded in database/seeders/RolesAndPermissions.php, pattern {module}.{view|create|edit|destroy|manager}, guard admin. Routes apply via ->middleware('perm:{module}.{action}').

A module-hosting.{view,create,edit,destroy,manager} permission set already exists in the seeder (tied to the legacy module_hosting admin-blade screens, no route match found in routes/api.php — i.e. currently orphaned or admin-blade-only). Reusing it for the new API would silently couple this module's authorization to whatever the legacy screens still do with it. New module gets its own permission group instead: hosting.{view,create,edit,destroy} — matches the flat domain.*/ customer.* style (no module- prefix) used for the modern, services-era modules, as opposed to the module-* prefix used for the older admin-panel-generation features (module-vcenter.*, module-dns.*). This keeps "which permission style = which generation of code" legible, which is already implicitly true in the seeder today.

2. dns_manager_servers.type

Decision: varchar, not a native SQL ENUM.

Reasons, specific to this codebase:

  • Zero existing migrations use MySQL ->enum(...). dns_manager_servers.status itself is a string column with values validated by convention/PHP, not a DB enum. Matching that means type should be string too — one column-kind convention for the whole table, not a mix.
  • App\Enums\Domain\DomainProvisioningStatus shows the actual "enum" convention in this codebase: a PHP backed enum class used for validation and label()/transition logic, with the DB column being a plain string. Adding both as a third case later, or a mail/cdn value in some future phase, is then a one-line enum case, not a migration to widen a native ENUM definition (which MySQL handles as a full table metadata rewrite).
  • Pivot/capability table and JSON were explicitly excluded by the request; they're also not needed here — domain | hosting | both is a small, stable, mutually-exclusive-enough set for a single column, unlike the earlier revision's "server can have an open-ended combination" framing.
enum ServerType: string
{
    case DOMAIN  = 'domain';
    case HOSTING = 'hosting';
    case BOTH    = 'both';
}

Query helper needed because of both: any lookup for "servers usable for hosting" must match type IN ('hosting', 'both'), not type = 'hosting'. This is the one real cost of varchar+both over a pivot — every query site needs to know to expand both. Put that in one place: DnsServerRepositoryInterface::getUsableFor(ServerType $type) on the repository, so PleskHostingService and any future admin filter never hand-roll the IN (...) themselves.

// migration: add_type_to_dns_manager_servers.php
Schema::table('dns_manager_servers', function (Blueprint $table) {
    $table->string('type', 20)->default('domain')->after('status');
});
// backfill: every existing row already serves DNS today
DB::table('dns_manager_servers')->update(['type' => 'domain']);

3. Hosting module — table name

module_hosting_accounts. Follows the module_ + resource-noun pattern already used for module_vmware (the closest analog: a provisioned resource hanging off services, one credential-holding parent per row). Does not collide with legacy module_hosting/module_hosting_server (different tables, different meaning, left untouched — not migrated into, not read from).

4. Migration

// database/migrations/2026_08_06_130000_add_type_to_dns_manager_servers_table.php
Schema::table('dns_manager_servers', function (Blueprint $table) {
    $table->string('type', 20)->default('domain')->after('status');
});
// database/migrations/2026_08_06_130001_create_module_hosting_accounts_table.php
Schema::create('module_hosting_accounts', function (Blueprint $table) {
    $table->id();

    $table->unsignedBigInteger('customer_id')->index();
    $table->unsignedBigInteger('service_id')->nullable()->index();
    $table->unsignedBigInteger('dns_manager_server_id')->index();
    $table->unsignedBigInteger('dns_manager_domain_id')->nullable()->index();

    $table->unsignedInteger('plesk_client_id')->nullable();
    $table->unsignedInteger('plesk_subscription_id')->nullable()->index();
    $table->string('plesk_guid', 64)->nullable();

    $table->string('hosting_plan', 100)->nullable();
    $table->string('ftp_username', 100)->nullable();
    $table->string('system_username', 100)->nullable();
    $table->string('document_root', 255)->nullable();

    // pending -> active -> suspended -> terminated (mirrors services.status vocabulary)
    $table->string('status', 20)->default('pending')->index();

    $table->timestamps();

    $table->foreign('customer_id')->references('id')->on('customers')->onDelete('cascade');
    $table->foreign('service_id')->references('id')->on('services')->onDelete('set null');
    $table->foreign('dns_manager_server_id')->references('id')->on('dns_manager_servers')->onDelete('restrict');
    $table->foreign('dns_manager_domain_id')->references('id')->on('dns_manager_domain')->onDelete('set null');
});

No password / FTP-password / Basic-Auth column — confirmed not needed: dns_manager_servers already holds what's required to manage the box: the FTP account Plesk creates is reachable/resettable through the same Basic-Auth credential, so nothing hosting-account-specific needs storing locally. (module_vmware.plain_password is a known wart elsewhere in this codebase — not repeated here.)

plesk_guid is string(64), not uuid(): Plesk's GUID format isn't guaranteed to be a canonical RFC-4122 UUID the DB driver's uuid column type would validate against; storing it as an opaque string avoids a runtime insert failure on a format mismatch.

service_id is nullable: a hosting account can be provisioned by an admin ahead of/independent from an order line in Phase 1 (no order/service flow is being built here), matching how services.vm_id is nullable during the VM's own pending window.

5. Model

// app/Models/ModuleHostingAccount.php
namespace App\Models;

use App\Traits\LogsActivity;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;

class ModuleHostingAccount extends Model
{
    use HasFactory, LogsActivity;

    protected $table = 'module_hosting_accounts';

    protected $fillable = [
        'customer_id',
        'service_id',
        'dns_manager_server_id',
        'dns_manager_domain_id',
        'plesk_client_id',
        'plesk_subscription_id',
        'plesk_guid',
        'hosting_plan',
        'ftp_username',
        'system_username',
        'document_root',
        'status',
    ];

    public function customer()
    {
        return $this->belongsTo(Customer::class);
    }

    public function service()
    {
        return $this->belongsTo(Service::class);
    }

    public function server()
    {
        return $this->belongsTo(DnsServer::class, 'dns_manager_server_id');
    }

    public function domain()
    {
        return $this->belongsTo(DnsDomain::class, 'dns_manager_domain_id');
    }
}

DnsServer gets one addition — a typed accessor for type, matching how DnsDomain/DnsServer already expose small helper methods (getStatus()):

// App\Models\DnsServer — add
public function servesHosting(): bool
{
    return in_array($this->attributes['type'] ?? 'domain', ['hosting', 'both'], true);
}

6. Relationships

customers               1──* module_hosting_accounts
services                 1──0..1 module_hosting_accounts   (service_id, nullable — same shape as services.vm_id → module_vmware)
dns_manager_servers      1──* module_hosting_accounts       (type must include 'hosting' or 'both')
dns_manager_domain       1──0..1 module_hosting_accounts    (dns_manager_domain_id, nullable)
Customer
  └─ Hosting Account (module_hosting_accounts)
       ├─ DNS Manager Server (dns_manager_servers, type ∈ {hosting, both})
       └─ DNS Domain (dns_manager_domain, optional)

Domain Module link — yes, loosely, and only via dns_manager_domain, not App\Models\Domain. Reasoning:

  • App\Models\Domain (registrar-side TLD ownership, the "Domain Management" module in the request) and App\Models\DnsDomain (dns_manager_domain, a DNS zone actually hosted on one of our Plesk boxes) are different entities in the current schema — a customer can own a registrar domain that has never been pointed at our DNS/Plesk infrastructure, and conversely a DNS zone here doesn't strictly need to correspond to a domain we sold. Nothing in the audited code links them 1:1 today.
  • Hosting only needs "which DNS zone/vhost name will Plesk serve this subscription under," which is exactly what dns_manager_domain_id captures. Forcing a link to Domain (registrar) would either require inventing a domains.dns_manager_domain_id bridge that doesn't exist (out of scope, a Domain-module change, not a Hosting-module one) or silently assume every hosting customer's domain is registrar-managed by us, which isn't guaranteed.
  • Keeping dns_manager_domain_id nullable also covers the CLI/admin flow of provisioning hosting before any DNS zone exists for it — Plesk's subscription can be created against a domain name string even if no local dns_manager_domain row is made yet in Phase 1.

7. Repository

app/Repositories/Module/HostingAccount/
    HostingAccountRepositoryInterface.php
    HostingAccountRepository.php
    HostingAccountCachedRepository.php
interface HostingAccountRepositoryInterface extends BaseRepositoryInterface
{
    public function filter(
        array $data = [],
        array $relationships = [],
        array $withCount = [],
        array $select = ['*'],
        bool $returnQuery = false
    );
}

HostingAccountRepository::getModel() → ModuleHostingAccount::class, filter() implemented the same way as HostingRepository::filter() (status/relations/pagination handling already shown in that file) — copy that shape, don't invent a new query-building convention.

HostingAccountCachedRepository wraps it exactly like DnsServerCachedRepository does (Cache::remember on filter, pass- through when returnQuery/paginate is set).

Also add, on App\Repositories\DnsManager\Server\DnsServerRepositoryInterface (the interface ServerService already depends on):

public function getUsableFor(ServerType $type): Collection;
// WHERE type = 'both' OR type = :type

Bind both interfaces explicitly in RepositoryProvider:

$this->app->singleton(HostingAccountRepositoryInterface::class, HostingAccountCachedRepository::class);

(And, while touching this file: confirm/fix the DnsManager\Server binding gap noted in §1.1 — the new Hosting service depends on the same interface ServerService uses, so if that resolution is currently implicit it needs to keep working for both consumers, not just happen to work for the one that existed first.)

8. Service — Plesk integration (Phase 1 only)

app/Services/Plesk/
    PleskService.php          // shared low-level HTTP: resolves a DnsServer → Http::withBasicAuth(...), used by all the others
    PleskClientService.php    // find-or-create Plesk customer
    PleskDomainService.php    // create/update/remove Plesk domain object — EXTRACTED from ServerService, not rewritten
    PleskHostingService.php   // orchestrates: customer → subscription → plan → persist

PleskService is the one new piece of shared plumbing, and it's a direct copy of what ServerService already does per-call (Http::withoutVerifying() ->withBasicAuth($server->username, Crypt::decryptString($server->password)) ->timeout(...)), pulled out so ServerService (DNS) and the new PleskHostingService both call the same code instead of two Basic-Auth blocks drifting apart:

class PleskService
{
    public function client(DnsServer $server): PendingRequest
    {
        return Http::withoutVerifying()
            ->withBasicAuth($server->username, Crypt::decryptString($server->password))
            ->timeout(30);
    }
}

PleskDomainService::createDomain() is ServerService::createDomain() moved verbatim (same hosting_settings/plan payload shape, same ['error'=>bool,'data'=>...] return) — ServerService then delegates to it instead of duplicating the HTTP call, so DNS-module behavior doesn't change.

PleskClientService — new for Phase 1, Plesk has no "create domain without a client" path that fits multi-tenant billing, so this is required, not optional:

class PleskClientService
{
    public function __construct(private PleskService $plesk) {}

    public function findOrCreateCustomer(DnsServer $server, Customer $customer): array
    {
        // GET /clients?login=... ; if 404, POST /clients
        // returns ['error' => bool, 'data' => ['id' => ..., 'guid' => ...]]
    }
}

PleskHostingService — the Phase 1 entry point:

class PleskHostingService
{
    public function __construct(
        private PleskClientService $clients,
        private PleskDomainService $domains,
        private HostingAccountRepositoryInterface $accounts,
    ) {}

    public function provision(array $data): array
    {
        // $data: customer_id, dns_manager_server_id, dns_manager_domain_id?,
        //        domain_name, hosting_plan

        $server = ...; // DnsServer::findOrFail, must servesHosting()

        $client = $this->clients->findOrCreateCustomer($server, $customer);
        if ($client['error']) return $client;

        $subscription = $this->domains->createDomain($server, [
            'domain_name' => $data['domain_name'],
            'plesk_client_id' => $client['data']['id'],
            'plan' => $data['hosting_plan'],
        ]);
        if ($subscription['error']) return $subscription;

        $account = $this->accounts->create([
            'customer_id'            => $data['customer_id'],
            'service_id'             => $data['service_id'] ?? null,
            'dns_manager_server_id'  => $server->id,
            'dns_manager_domain_id'  => $data['dns_manager_domain_id'] ?? null,
            'plesk_client_id'        => $client['data']['id'],
            'plesk_subscription_id'  => $subscription['data']['id'],
            'plesk_guid'             => $subscription['data']['guid'],
            'hosting_plan'           => $data['hosting_plan'],
            'ftp_username'           => $subscription['data']['hosting_settings']['ftp_login'] ?? null,
            'system_username'        => $subscription['data']['hosting_settings']['ftp_login'] ?? null,
            'document_root'          => $subscription['data']['www_root'] ?? null,
            'status'                 => 'active',
        ]);

        return ['error' => false, 'data' => $account];
    }
}

No suspend()/unsuspend()/resetFtpPassword() bodies are written yet — the API routes for them exist (§9, per explicit request) but their controller actions return 501/"not implemented in this phase" until those methods are built. Writing stub method bodies now that call nothing would be scaffolding for a phase that hasn't been scoped — the route existing is enough to reserve the contract.

9. Controller & API

app/Http/Requests/ModuleHosting/CreateHostingAccountRequest.php
app/Http/Requests/ModuleHosting/UpdateHostingAccountRequest.php
app/Http/Resources/Api/Admin/HostingAccountResource.php
app/Http/Controllers/Api/Admin/Hosting/HostingAccountController.php
// routes/api.php, alongside the existing DNS Manager group
Route::prefix('hosting-accounts')
    ->controller(HostingAccountController::class)
    ->group(function () {
        Route::get('/', 'index')->middleware('perm:hosting.view');
        Route::get('{id}', 'show')->middleware('perm:hosting.view');
        Route::post('/', 'store')->middleware('perm:hosting.create');
        Route::put('{id}', 'update')->middleware('perm:hosting.edit');
        Route::delete('{id}', 'destroy')->middleware('perm:hosting.destroy');

        Route::post('{id}/suspend', 'suspend')->middleware('perm:hosting.edit');
        Route::post('{id}/unsuspend', 'unsuspend')->middleware('perm:hosting.edit');
        Route::post('{id}/reset-ftp-password', 'resetFtpPassword')->middleware('perm:hosting.edit');
    });

store() calls PleskHostingService::provision() and returns the HostingAccountResource. suspend/unsuspend/reset-ftp-password controller methods exist per the request's API list but return response()->json(['message' => 'Not implemented in this phase'], 501) — routed and permission-gated now, implemented when their phase lands.

Add to RolesAndPermissions.php seeder:

'hosting.view', 'hosting.create', 'hosting.edit', 'hosting.destroy',

10. Reusable existing code

  • App\Services\ServerService::createDomain/updateDomain/removeDomain → extracted into PleskDomainService, reused by both modules (§8).
  • Basic-Auth-over-Http-facade pattern → copied into PleskService exactly as done in ServerService/vCenter\Client, no new HTTP layer.
  • Crypt::decryptString($server->password) → same decryption call reused as-is; no new encryption scheme introduced (explicitly not SignRSA, which is the legacy module's scheme).
  • BaseRepository/BaseCachedRepository/BaseRepositoryInterface → used unchanged.
  • App\Traits\LogsActivity → reused on ModuleHostingAccount, same as DnsServer/DnsDomain/ModuleHosting.
  • Migration style, permission-seeding call site, route-group style — all copied from the DNS Manager section of routes/api.php and RolesAndPermissions.php, not reinvented.
  • HostingRepository::filter() (legacy, app/Repositories/Module/Hosting/HostingRepository.php) → its filter/relation/status query shape is copied as the template for the new repository's filter(), even though the legacy table itself is not reused.

11. Trade-offs

Decision Trade-off accepted
type as varchar + both value One extra IN-expansion needed at every "find a server for X" query site, centralized in getUsableFor(). In exchange: zero new tables, matches the table's existing status column style, and a future 4th type is a one-line enum case instead of an ALTER TABLE ... MODIFY ENUM.
No pivot/capability table Can't express fine-grained per-capability metadata (e.g. "default server for hosting" flag) — not needed by anything requested; if it's needed later it's an additive migration, not a rewrite of this one.
service_id nullable, not required Lets Phase 1 provision hosting without a full order/service pipeline wired up yet; costs a nullable check everywhere the relation is read.
dns_manager_domain_id nullable, no FK to App\Models\Domain Keeps Hosting decoupled from Domain-module assumptions that don't hold today (registrar domain ≠ DNS-managed zone); costs one more nullable relation to explain in onboarding docs.
No password/Basic-Auth column on module_hosting_accounts Every Plesk call for this account has to resolve back through dns_manager_server_id first — no local shortcut. In exchange: one credential source of truth, matches the explicit "don't store it twice" instruction, and avoids repeating module_vmware.plain_password.
Extracting PleskDomainService out of ServerService rather than writing a second Plesk client Touches an existing, working file (small risk) instead of a net-new, isolated one (zero risk to DNS module). In exchange: avoids the exact duplication (module_hosting_server vs dns_manager_servers, two unrelated Plesk-ish clients) that already happened once in this codebase.
New hosting.* permission group instead of reusing seeded module-hosting.* One more permission group to seed. In exchange: doesn't couple a new, modern API to whatever the legacy admin-blade screens currently do with module-hosting.*, and keeps the "flat name = modern services-era module" convention legible.
No stub bodies for suspend/unsuspend/reset-ftp-password Routes exist per the request's API list but 501 until built — avoids writing speculative Plesk-call code for a phase not yet scoped, per explicit instruction to design Phase 1 only.

12. Explicitly not designed here

Per instruction: Database, SSL, Mailbox, dedicated FTP account creation beyond Plesk's default system user, Git deploy, source auto-upload, Let's Encrypt. No schema columns, service methods, or routes for these are included above, including no nullable "reserved for later" columns on module_hosting_accounts — adding those now would be exactly the speculative scaffolding this phase is meant to avoid.