Compare commits

..

237 Commits

Author SHA1 Message Date
jkunz 9cac2e616f v6.4.3
Docker (tags) / release (push) Failing after 1s
2026-05-28 16:44:18 +00:00
jkunz fd0653788e fix(appstore): label App Store platform requirement badges with canonical names 2026-05-28 16:43:53 +00:00
jkunz c6b8cbbe51 v6.4.2
Docker (tags) / release (push) Failing after 1s
2026-05-28 16:18:03 +00:00
jkunz 966c626d36 fix(services): clean up dependent resources during service deletion 2026-05-28 16:13:06 +00:00
jkunz 7f0c968b5c v6.4.1
Docker (tags) / release (push) Failing after 1s
2026-05-27 21:33:02 +00:00
jkunz 78d7479b4a fix(appstore): handle App Store backend failures and empty RPC responses 2026-05-27 21:32:32 +00:00
jkunz 9bac0a5f71 v6.4.0
Docker (tags) / release (push) Failing after 1s
2026-05-26 21:50:43 +00:00
jkunz 26256c92bd feat(hostedapp): add hosted Cloudly parent upgrade controls 2026-05-26 21:50:17 +00:00
jkunz c7a307c9d3 v6.3.1
Docker (tags) / release (push) Failing after 1s
2026-05-26 19:39:18 +00:00
jkunz 06d54db747 fix(ui): remove redundant wrappers around Cloudly tables 2026-05-26 19:38:52 +00:00
jkunz 756c35aa05 v6.3.0
Docker (tags) / release (push) Failing after 1s
2026-05-26 15:29:07 +00:00
jkunz 2adb86c5ea feat(hostedapp): add hosted app lifecycle protocol support 2026-05-26 15:27:00 +00:00
jkunz f6ab7460e1 v6.2.0
Docker (tags) / release (push) Failing after 1s
2026-05-26 11:06:45 +00:00
jkunz 2b65ddc193 feat(appstore): add App Store install and upgrade workflows 2026-05-26 11:06:21 +00:00
jkunz bfda4b4ca1 v6.1.0
Docker (tags) / release (push) Failing after 1s
2026-05-26 09:58:01 +00:00
jkunz a9d9ea585c chore(changelog): remove duplicate pending entry 2026-05-26 09:54:59 +00:00
jkunz 56a62e7008 feat(images): improve image operations UI and record archive metadata 2026-05-26 09:54:26 +00:00
jkunz 05560c9db9 v6.0.0
Docker (tags) / release (push) Failing after 1s
2026-05-25 03:03:25 +00:00
jkunz 50e69b095c feat(appstore): use shared resolver 2026-05-25 03:03:03 +00:00
jkunz d5445609a0 v5.9.0
Docker (tags) / release (push) Failing after 1s
2026-05-24 13:15:16 +00:00
jkunz fd7c7b4313 fix(cloudly): invalidate expired dashboard sessions 2026-05-24 13:14:49 +00:00
jkunz 057af996aa chore(cloudly): consume released Spark interfaces 2026-05-24 12:54:09 +00:00
jkunz 6565c44c29 feat(cloudly): accept Spark node heartbeats 2026-05-24 12:47:15 +00:00
jkunz ebb4f36c67 v5.8.2
Docker (tags) / release (push) Failing after 13m53s
2026-05-24 01:30:46 +00:00
jkunz e7d3140f7a chore(cloudly): update api dependency and stabilize tests 2026-05-24 01:30:32 +00:00
jkunz 304767a75c v5.8.1
Docker (tags) / release (push) Failing after 0s
2026-05-23 10:59:24 +00:00
jkunz 0fa95d6c99 fix(cloudly): allow Docker install of fresh interfaces 2026-05-23 10:59:06 +00:00
jkunz 5d6d43b564 v5.8.0
Docker (tags) / release (push) Failing after 0s
2026-05-23 10:47:12 +00:00
jkunz bef236cd86 feat(cloudly): add service runtime and onboarding 2026-05-23 10:46:52 +00:00
jkunz 59043b7281 v5.7.1
Docker (tags) / release (push) Failing after 0s
2026-05-21 22:35:19 +00:00
jkunz befd0efdc0 fix(web): clean dashboard console errors 2026-05-21 22:30:38 +00:00
jkunz b1a0ce684a v5.7.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 16:17:21 +00:00
jkunz d0b15ab51b feat(web): add dashboard SPA routing 2026-05-21 16:16:00 +00:00
jkunz 50bcbe0f45 v5.6.0
Docker (tags) / release (push) Failing after 0s
2026-05-21 11:35:49 +00:00
jkunz bb86f8882c docs(changelog): add dashboard navigation entry 2026-05-21 11:34:58 +00:00
jkunz 1874d791b2 feat(web): group dashboard navigation 2026-05-21 11:18:06 +00:00
jkunz f40ef6b7c0 chore: update cloudly dependency stack
Align Cloudly with the current typedserver, smartconfig, smartstate, and Docker tooling releases so builds and Docker output stay compatible with the upgraded stack.
2026-05-08 13:56:20 +00:00
jkunz 80226c8a1c fix: correct cloudly package exports 2026-05-07 20:49:33 +00:00
jkunz 5ba2bb2168 chore: use baseos interfaces release 2026-05-07 20:47:31 +00:00
jkunz 60c51fbf5d feat: add baseos source presets 2026-05-07 20:33:14 +00:00
jkunz 0fcf35c019 docs: refresh readme and legal info 2026-05-07 20:22:12 +00:00
jkunz d9dcc5b048 feat: add corebuild worker selection 2026-05-07 19:49:56 +00:00
jkunz c55eb5b832 feat: harden baseos image builds 2026-05-07 19:04:12 +00:00
jkunz 1792ea89e1 feat: add backup replication targets 2026-05-07 17:44:31 +00:00
jkunz b0f0963143 feat: add baseos image builds 2026-05-07 17:44:31 +00:00
jkunz be7735a9c3 feat: add baseos node enrollment 2026-05-07 15:53:16 +00:00
jkunz 3624c78f9d feat: orchestrate service backups 2026-05-02 21:59:42 +00:00
jkunz d1ce149487 feat: expose external gateway settings 2026-04-29 15:57:02 +00:00
jkunz 4eba247472 feat: discover external gateway domains 2026-04-29 15:34:48 +00:00
jkunz 452aaa3862 feat: pass external gateway config to coreflow 2026-04-29 15:29:17 +00:00
jkunz 7d380d04fc fix: use relative runtime imports 2026-04-29 08:25:58 +00:00
jkunz aab6e9044d fix: modernize docker publishing 2026-04-29 08:13:29 +00:00
jkunz 195236b693 fix: trim registry repository slugs 2026-04-29 01:39:40 +00:00
jkunz 1925f66efc fix: generate docker-compatible registry repositories 2026-04-28 19:46:44 +00:00
jkunz 865c8f2546 fix: allow coreflow deployment input reads 2026-04-28 16:57:54 +00:00
jkunz 1bed907f53 feat: push config updates to coreflow 2026-04-28 16:02:05 +00:00
jkunz ee6d4c3d04 feat: wire service registry targets 2026-04-28 15:50:59 +00:00
jkunz 94f1199858 feat: add built-in OCI registry 2026-04-28 15:23:51 +00:00
jkunz 333cbeb221 fix: make startup bootstrap production-safe 2026-04-28 15:07:08 +00:00
jkunz 13a7d5969d fix: send registry update payload 2026-04-28 14:35:19 +00:00
jkunz 675a95f857 chore: clean local tooling metadata 2026-04-28 14:35:19 +00:00
jkunz 3080075811 feat: add platform desired state manager 2026-04-28 12:18:12 +00:00
jkunz 84d3e8f52f fix(test): use dynamic Cloudly test port 2026-04-28 08:54:29 +00:00
jkunz 500cec008a refactor(cloudly): consume external api package 2026-04-25 15:03:12 +00:00
jkunz 37512cfaa6 refactor(cloudly): consume external interfaces package 2026-04-25 13:57:59 +00:00
jkunz 94e0c38191 feat(domain): improve domain update logic and ensure default activation state 2025-09-14 17:44:59 +00:00
jkunz 6cc3700d29 feat(domains): enhance domain management with activation states and sync options 2025-09-14 17:38:16 +00:00
jkunz bb313fd9dc feat: Add settings view for cloud provider configurations
- Implemented CloudlyViewSettings component for managing cloud provider settings including Hetzner, Cloudflare, AWS, DigitalOcean, Azure, and Google Cloud.
- Added functionality to load, save, and test connections for each provider.
- Enhanced UI with loading states and success/error notifications.

feat: Create tasks view with execution history

- Developed CloudlyViewTasks component to display and manage tasks and their executions.
- Integrated auto-refresh functionality for task executions.
- Added filtering and searching capabilities for tasks.

feat: Implement execution details and task panel components

- Created CloudlyExecutionDetails component to show detailed information about task executions including logs and metrics.
- Developed CloudlyTaskPanel component to display individual tasks with execution status and actions to run or cancel tasks.

feat: Utility functions for formatting and categorization

- Added utility functions for formatting dates, durations, and cron expressions.
- Implemented functions to retrieve category icons and hues for task categorization.
2025-09-14 17:28:21 +00:00
jkunz 5ef8621db7 feat(task-execution): implement task cancellation handling and improve UI feedback for canceling tasks 2025-09-12 23:53:10 +00:00
jkunz 6cd348ca28 feat(cloudly-view-tasks): add search and category filters, implement auto-refresh for task executions 2025-09-12 23:38:18 +00:00
jkunz 3183f9e909 chore(deps): update dependencies for @push.rocks/smartjson, @push.rocks/smartstate, @push.rocks/smartstring, and @git.zone/tstest 2025-09-12 22:20:30 +00:00
jkunz ff7004412b feat(appstate): Remove helper function for stripping class instances from data fetching 2025-09-12 10:58:16 +00:00
jkunz f07bcc4660 feat(appstate): Refactor data fetching to use helper for stripping class instances 2025-09-12 07:56:06 +00:00
jkunz d773e13aab feat(api-client): Add update and delete methods for external registries and secret bundles/groups 2025-09-10 20:33:32 +00:00
jkunz dc0d128892 feat(api-client): Add advanced cluster creation method and refactor login actions to use API client 2025-09-10 20:23:12 +00:00
jkunz 124c4ca46f feat: Enhance API client integration across web and CLI
- Added typedRequestInterfaces import to plugins.ts for better type handling.
- Updated CLI client to utilize environment variables for Cloudly API credentials and improved authentication flow.
- Refactored appstate.ts to use a shared API client instance, reducing redundancy in API calls for various actions.
- Simplified external registry actions in appstate.ts by leveraging the shared API client.
- Updated CloudlyDashboard and CloudlyViewSettings components to utilize the shared API client for fetching settings and managing connections.
- Removed redundant TypedRequest instances in favor of direct API client calls for improved performance and maintainability.
- Exposed the API client in plugins.ts for easier access in UI components.
2025-09-10 19:06:16 +00:00
jkunz 5d281d9b6c feat(tasks): Enhance task management with identity handling and initial data loading 2025-09-10 17:04:18 +00:00
jkunz 5b37bb5b11 feat: Implement Cloudly Task Manager with predefined tasks and execution tracking
- Added CloudlyTaskManager class for managing tasks, including registration, execution, scheduling, and cancellation.
- Created predefined tasks: DNS Sync, Certificate Renewal, Cleanup, Health Check, Resource Report, Database Maintenance, Security Scan, and Docker Cleanup.
- Introduced ITaskExecution interface for tracking task execution details and outcomes.
- Developed API request interfaces for task management operations (getTasks, getTaskExecutions, triggerTask, cancelTask).
- Implemented CloudlyViewTasks web component for displaying tasks and their execution history, including filtering and detailed views.
2025-09-10 16:37:03 +00:00
jkunz fd1da01a3f feat(dns): Enhance DNS management with auto-generated entries and service activation 2025-09-10 15:38:42 +00:00
jkunz 6a447369f8 feat(external-registry): Enhance authentication handling and update UI for external registries 2025-09-10 08:50:32 +00:00
jkunz 01d877f7ed feat(external-registry): Implement CRUD operations and connection verification for external registries 2025-09-10 08:24:55 +00:00
jkunz 73505d1ed8 feat(dns): Add domain validation and dropdown for DNS entry creation and updates 2025-09-09 15:13:44 +00:00
jkunz 766191899c feat(dns): Implement DNS management functionality
- Added DnsManager and DnsEntry classes to handle DNS entries.
- Introduced new interfaces for DNS entry requests and data structures.
- Updated Cloudly class to include DnsManager instance.
- Enhanced app state to manage DNS entries and actions for creating, updating, and deleting DNS records.
- Created UI components for DNS management, including forms for adding and editing DNS entries.
- Updated overview and services views to reflect DNS entries.
- Added validation and formatting methods for DNS entries.
2025-09-09 15:08:28 +00:00
jkunz 38e8b4086d feat(requests): Add deploymentRequests to index for improved request handling 2025-09-08 13:03:33 +00:00
jkunz ce047d1bb0 feat(deployment): Implement Deployment and DeploymentManager classes with CRUD operations and service integration 2025-09-08 12:46:23 +00:00
jkunz 4e38d2ff43 5.3.0
Docker (tags) / security (push) Successful in 52s
Docker (tags) / test (push) Failing after 2m4s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-09-08 06:46:14 +00:00
jkunz e19639c9be feat(web): Add deployments API typings and web UI improvements: services & deployments management with CRUD and actions 2025-09-08 06:46:14 +00:00
jkunz c142519004 5.2.0
Docker (tags) / security (push) Successful in 56s
Docker (tags) / test (push) Failing after 1m55s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-09-07 17:21:30 +00:00
jkunz 54ef62e7af feat(settings): Add runtime settings management, node & baremetal managers, and settings UI 2025-09-07 17:21:30 +00:00
jkunz 83abe37d8c 5.1.0
Docker (tags) / security (push) Successful in 53s
Docker (tags) / test (push) Failing after 1m59s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-09-05 16:07:46 +00:00
jkunz eefaa55e13 feat(cluster): Add cluster setupMode (manual|hetzner|aws|digitalocean) with conditional Hetzner auto-provisioning; UI and dashboard improvements; dependency upgrades 2025-09-05 16:07:46 +00:00
jkunz 330797ab1a 5.0.6
Docker (tags) / security (push) Successful in 41s
Docker (tags) / test (push) Failing after 1m57s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-08-18 21:24:47 +00:00
jkunz 4b3b91312b fix(connector.letsencrypt): Improve Lets Encrypt integration and certificate handling; fix coreflow certificate response; add local assistant permissions config 2025-08-18 21:24:46 +00:00
jkunz 1580bb1585 5.0.5
Docker (tags) / security (push) Successful in 1m13s
Docker (tags) / test (push) Failing after 7m12s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2025-08-18 21:11:28 +00:00
jkunz af7fcf6c2e fix(coreflow): Fix Coreflow identity lookup and response shape; improve API client tests and bump dependencies 2025-08-18 21:11:28 +00:00
jkunz 23c9e3f678 Enhance README for @serve.zone/interfaces: improve structure, add features, and clarify installation instructions 2025-08-18 03:16:28 +00:00
jkunz 7d4e766e9e Enhance documentation for @serve.zone/api and @serve.zone/cli
- Updated the README for @serve.zone/api to improve clarity and organization, adding sections for features, quick start, authentication, core operations, and advanced usage.
- Improved the installation instructions and added examples for various operations including image management, cluster operations, and real-time updates.
- Enhanced the @serve.zone/cli README with a focus on features, installation, quick start, core commands, and advanced usage.
- Added detailed command examples for cluster management, service deployment, secret management, and DNS management.
- Included sections for CI/CD integration and troubleshooting in both README files.
- Improved formatting and added emojis for better readability and engagement.
2025-08-18 03:14:49 +00:00
jkunz 907f3e8320 Enhance Cloudly Configuration and Testing Setup
- Updated README to include architecture overview and details on components.
- Changed import paths in test helpers and test files to use the new Git zone packages.
- Modified S3 bucket name in test setup for consistency.
- Updated CloudlyConfig class to use more descriptive environment variable names for MongoDB and S3 configuration.
- Adjusted ImageManager to retrieve the S3 bucket name from the configuration instead of hardcoding it.
2025-08-18 03:07:12 +00:00
philkunz bc7a2ca5f1 5.0.4
Docker (tags) / security (push) Successful in 48s
Docker (tags) / test (push) Successful in 1m56s
Docker (tags) / metadata (push) Successful in 3s
Docker (tags) / release (push) Failing after 14s
2025-04-25 18:20:18 +00:00
philkunz 77d911e47a fix(platformservice/mta): Update getEmailStatus response schema: make details property optional 2025-04-25 18:20:18 +00:00
philkunz b9c9c2d0a9 5.0.3
Docker (tags) / security (push) Successful in 49s
Docker (tags) / test (push) Successful in 1m55s
Docker (tags) / metadata (push) Successful in 3s
Docker (tags) / release (push) Failing after 14s
2025-04-25 17:02:48 +00:00
philkunz d5b91789d1 fix(mta): update email status response type in MTA platform service 2025-04-25 17:02:48 +00:00
philkunz eb8350f453 5.0.2
Docker (tags) / security (push) Successful in 38s
Docker (tags) / test (push) Successful in 1m56s
Docker (tags) / metadata (push) Successful in 3s
Docker (tags) / release (push) Failing after 13s
2025-04-25 16:34:01 +00:00
philkunz b987ce27b8 fix(platformservice/mta): Refactor email status response in MTA service 2025-04-25 16:34:00 +00:00
philkunz 630e363e53 5.0.1
Docker (tags) / security (push) Successful in 48s
Docker (tags) / test (push) Successful in 1m54s
Docker (tags) / metadata (push) Successful in 3s
Docker (tags) / release (push) Failing after 14s
2025-04-25 16:27:48 +00:00
philkunz a602021952 fix(mta): Update email stats response interface in mta platform service to include totalEmailsSent, totalEmailsDelivered, totalEmailsBounced, averageDeliveryTimeMs, and lastUpdated timestamp. 2025-04-25 16:27:48 +00:00
philkunz 80585437a0 5.0.0
Docker (tags) / security (push) Successful in 47s
Docker (tags) / test (push) Successful in 1m54s
Docker (tags) / metadata (push) Successful in 3s
Docker (tags) / release (push) Failing after 41s
2025-04-25 15:57:35 +00:00
philkunz 4674a20a2c BREAKING CHANGE(ts_interfaces/platformservice/mta): Rename mta interfaces and upgrade dependency versions 2025-04-25 15:57:35 +00:00
philkunz 820cdfcd48 4.13.0
Docker (tags) / security (push) Successful in 52s
Docker (tags) / test (push) Successful in 3m5s
Docker (tags) / metadata (push) Successful in 8s
Docker (tags) / release (push) Failing after 22s
2025-01-20 02:18:58 +01:00
philkunz 6e5dd9b05a feat(service): Add support for service creation, update, and deletion. 2025-01-20 02:18:58 +01:00
philkunz f3d5c21fab 4.12.2
Docker (tags) / security (push) Successful in 1m6s
Docker (tags) / test (push) Successful in 3m5s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 24s
2025-01-20 02:11:12 +01:00
philkunz 04b278ee28 fix(service): Fix secret bundle and service management bugs 2025-01-20 02:11:12 +01:00
philkunz 7084d76c43 4.12.1
Docker (tags) / security (push) Successful in 51s
Docker (tags) / test (push) Successful in 2m53s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 20s
2025-01-02 04:07:43 +01:00
philkunz 41d7550e89 fix(deps): Updated @git.zone/tspublish to version ^1.9.1 2025-01-02 04:07:43 +01:00
philkunz 4bf361d3a6 4.12.0
Docker (tags) / security (push) Successful in 1m1s
Docker (tags) / test (push) Successful in 2m57s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 31s
2025-01-02 03:58:09 +01:00
philkunz d70617a90c feat(cli): Add CLI support and external registries view 2025-01-02 03:58:09 +01:00
philkunz 62ad1655d5 4.11.0
Docker (tags) / security (push) Successful in 1m7s
Docker (tags) / test (push) Successful in 3m0s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 23s
2024-12-30 00:01:27 +01:00
philkunz caf3a095f2 feat(external-registry): Introduce external registry management 2024-12-30 00:01:26 +01:00
philkunz 89e44b2e5f 4.10.0
Docker (tags) / security (push) Successful in 1m3s
Docker (tags) / test (push) Successful in 2m59s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 22s
2024-12-29 13:40:51 +01:00
philkunz a617f51b19 feat(apiclient): Added support for managing external registries in the API client. 2024-12-29 13:40:51 +01:00
philkunz 355e04fd1d 4.9.0
Docker (tags) / security (push) Successful in 1m6s
Docker (tags) / test (push) Successful in 3m2s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 30s
2024-12-29 13:19:46 +01:00
philkunz 89bd767bea feat(apiclient): Add external registry management capabilities to Cloudly API client. 2024-12-29 13:19:46 +01:00
philkunz e567ebbf21 4.8.1
Docker (tags) / security (push) Successful in 53s
Docker (tags) / test (push) Successful in 2m58s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 22s
2024-12-28 21:48:57 +01:00
philkunz 33311348e2 fix(interfaces): Fix image location schema in IImage interface 2024-12-28 21:48:57 +01:00
philkunz d6e914edab 4.8.0
Docker (tags) / security (push) Successful in 1m5s
Docker (tags) / test (push) Successful in 2m58s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 22s
2024-12-28 21:39:44 +01:00
philkunz da7b866f23 feat(manager.registry): Add external registry management 2024-12-28 21:39:44 +01:00
philkunz 7654d780b1 4.7.1
Docker (tags) / security (push) Successful in 1m10s
Docker (tags) / test (push) Successful in 3m1s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 23s
2024-12-28 19:50:30 +01:00
philkunz dbd9b661c6 fix(secretmanagement): Refactor secret bundle actions and improve authorization handling 2024-12-28 19:50:29 +01:00
philkunz e19d0b4deb 4.7.0
Docker (tags) / security (push) Successful in 53s
Docker (tags) / test (push) Successful in 3m3s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 22s
2024-12-22 20:09:23 +01:00
philkunz f0ebb719f7 feat(apiclient): Add method to flatten secret bundles into key-value objects. 2024-12-22 20:09:23 +01:00
philkunz c8e0666bc6 4.6.0
Docker (tags) / security (push) Successful in 54s
Docker (tags) / test (push) Successful in 3m0s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 23s
2024-12-22 20:03:11 +01:00
philkunz 0d0b106f90 feat(cloudlyapiclient): Extend CloudlyApiClient with cluster, secretbundle, and secretgroup methods 2024-12-22 20:03:11 +01:00
philkunz c9073df7cd 4.5.5
Docker (tags) / security (push) Successful in 1m4s
Docker (tags) / test (push) Successful in 3m0s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 23s
2024-12-22 19:55:56 +01:00
philkunz f65200703d fix(apiclient): Fixed image creation method in cloudlyApiClient 2024-12-22 19:55:56 +01:00
philkunz 57970b3d10 4.5.4
Docker (tags) / security (push) Successful in 1m6s
Docker (tags) / test (push) Successful in 2m58s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 23s
2024-12-21 22:14:45 +01:00
philkunz b4d9f40c41 fix(ts_web): Fix action type and data fields in appstate for CRUD operations 2024-12-21 22:14:45 +01:00
philkunz a219725ff6 4.5.3
Docker (tags) / security (push) Successful in 1m6s
Docker (tags) / test (push) Successful in 2m58s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 23m54s
2024-12-21 20:21:54 +01:00
philkunz 4b993fc6b3 fix(secret-management): Refactor secret management to use distinct secret bundle and group APIs. Introduce API client classes for secret bundles and groups. 2024-12-21 20:21:54 +01:00
philkunz d453da709f 4.5.2
Docker (tags) / security (push) Successful in 1m4s
Docker (tags) / test (push) Successful in 2m58s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 23m21s
2024-12-20 02:13:50 +01:00
philkunz 50fac41c95 fix(apiclient): Implemented IService interface in Service class and improved secret bundle documentation. 2024-12-20 02:13:50 +01:00
philkunz affce1fcd1 4.5.1
Docker (tags) / security (push) Successful in 1m4s
Docker (tags) / test (push) Successful in 3m4s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 16s
2024-12-17 19:51:11 +01:00
philkunz df67ebd27a fix(core): Updated dependencies in package.json to latest versions. 2024-12-17 19:51:10 +01:00
philkunz ef5bfd435a 4.5.0
Docker (tags) / security (push) Successful in 1m8s
Docker (tags) / test (push) Successful in 3m5s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 23m36s
2024-12-14 20:32:17 +01:00
philkunz db07db930c feat(services): Add service management functionalities 2024-12-14 20:32:17 +01:00
philkunz f6309f600c 4.4.0
Docker (tags) / security (push) Successful in 48s
Docker (tags) / test (push) Successful in 2m42s
Docker (tags) / metadata (push) Successful in 6s
Docker (tags) / release (push) Successful in 4m55s
2024-11-18 19:52:15 +01:00
philkunz 7477704905 feat(api-client): Add static method getImageById for Image class in api-client 2024-11-18 19:52:15 +01:00
philkunz db89d86242 4.3.21
Docker (tags) / security (push) Successful in 58s
Docker (tags) / test (push) Successful in 2m43s
Docker (tags) / metadata (push) Successful in 6s
Docker (tags) / release (push) Successful in 5m0s
2024-11-18 19:43:52 +01:00
philkunz b74ce05845 fix(interfaces): Remove deprecated deployment directive and update related interfaces 2024-11-18 19:43:52 +01:00
philkunz 79db68a4a2 4.3.20
Docker (tags) / security (push) Successful in 58s
Docker (tags) / test (push) Successful in 2m42s
Docker (tags) / metadata (push) Successful in 6s
Docker (tags) / release (push) Successful in 6m19s
2024-11-18 17:48:26 +01:00
philkunz 5a3ddcf39b fix(apiclient): Ensure mandatory parameter in CloudlyApiClient constructor 2024-11-18 17:48:26 +01:00
philkunz fe6bfc0a83 4.3.19
Docker (tags) / security (push) Successful in 1m1s
Docker (tags) / test (push) Successful in 2m52s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Successful in 5m3s
2024-11-18 13:41:29 +01:00
philkunz 36a481ecd1 fix(docker): Fix improper Docker push command preventing push to the correct registry. 2024-11-18 13:41:29 +01:00
philkunz f7b2e203ed 4.3.18
Docker (tags) / security (push) Successful in 1m2s
Docker (tags) / test (push) Successful in 2m49s
Docker (tags) / metadata (push) Successful in 6s
Docker (tags) / release (push) Successful in 4m25s
2024-11-17 07:05:41 +01:00
philkunz 27c98c4e32 fix(docker_tags): Updated Docker configuration to include NPM tokens. 2024-11-17 07:05:41 +01:00
philkunz 79257908d0 4.3.17
Docker (tags) / security (push) Successful in 1m2s
Docker (tags) / test (push) Successful in 2m46s
Docker (tags) / metadata (push) Successful in 9s
Docker (tags) / release (push) Failing after 28s
2024-11-17 06:31:22 +01:00
philkunz b5ca898eac fix(Dockerfile): Corrected docker base image tag in Dockerfile for alpine compatibility. 2024-11-17 06:31:22 +01:00
philkunz 53ade28931 4.3.16
Docker (tags) / security (push) Successful in 50s
Docker (tags) / test (push) Successful in 2m42s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 32s
2024-11-17 01:04:26 +01:00
philkunz fff4c7642d fix(infrastructure): Correct Docker image path in Dockerfile for improved build consistency. 2024-11-17 01:04:26 +01:00
philkunz dafe6574cc 4.3.15
Docker (tags) / security (push) Successful in 1m1s
Docker (tags) / test (push) Successful in 2m47s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 28s
2024-11-17 00:00:56 +01:00
philkunz b70dad4996 fix(project setup): fixed incorrect configuration in npmextra.json 2024-11-17 00:00:55 +01:00
philkunz 17b0b50fbd 4.3.14
Docker (tags) / security (push) Successful in 50s
Docker (tags) / test (push) Successful in 2m50s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 33s
2024-11-16 22:31:17 +01:00
philkunz 91a0272ab3 fix(docker tags): Comment out unused secret variables in docker_tags.yaml 2024-11-16 22:31:16 +01:00
philkunz efd22d4087 4.3.13
Docker (tags) / security (push) Successful in 51s
Docker (tags) / test (push) Successful in 2m48s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 35s
2024-11-16 16:08:51 +01:00
philkunz c9e32540bf fix(package): Updated package dependencies 2024-11-16 16:08:50 +01:00
philkunz 8344f96983 4.3.12
Docker (tags) / security (push) Successful in 43s
Docker (tags) / test (push) Successful in 3m11s
Docker (tags) / metadata (push) Successful in 7s
Docker (tags) / release (push) Failing after 34s
2024-11-06 21:26:23 +01:00
philkunz 3b77089d79 fix(workflow): Fix Docker image path in GitHub action workflow 2024-11-06 21:26:23 +01:00
philkunz b6bce76043 4.3.11
Docker (tags) / security (push) Successful in 46s
Docker (tags) / test (push) Successful in 3m7s
Docker (tags) / release (push) Failing after 0s
Docker (tags) / metadata (push) Successful in 7s
2024-11-06 21:21:15 +01:00
philkunz cab57ab303 fix(overall): Refactor and improve code consistency across all modules 2024-11-06 21:21:14 +01:00
philkunz 804f1f3b12 4.3.10
Docker (tags) / security (push) Successful in 1m1s
Docker (tags) / test (push) Failing after 11s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-06 21:16:12 +01:00
philkunz f0144fdd5b fix(dependencies): Updated dependencies and fixed Docker Alpine image retrieval issue in tests 2024-11-06 21:16:12 +01:00
philkunz 81f286cb2f 4.3.9
Docker (tags) / security (push) Successful in 58s
Docker (tags) / test (push) Failing after 3m6s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-06 17:19:44 +01:00
philkunz 1f12cb9f94 fix(test and dependencies): Corrected cloudlyUrl in test.apiclient and updated tapbundle dependency. 2024-11-06 17:19:43 +01:00
philkunz 26490e8ddd 4.3.8
Docker (tags) / security (push) Successful in 53s
Docker (tags) / test (push) Failing after 2m10s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-06 16:59:18 +01:00
philkunz 38d2120c35 fix(api client): Fixed localhost URL issue in test.client.ts 2024-11-06 16:59:17 +01:00
philkunz f80b8decbc 4.3.7
Docker (tags) / security (push) Successful in 59s
Docker (tags) / test (push) Failing after 2m4s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-06 16:53:22 +01:00
philkunz 28cd6d1b49 fix(tests): Refactored test setup for consistency and isolated config initialization. 2024-11-06 16:53:21 +01:00
philkunz 899e5b0a7d 4.3.6
Docker (tags) / security (push) Successful in 55s
Docker (tags) / test (push) Failing after 2m8s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-06 16:22:38 +01:00
philkunz 0eff7c7510 fix(test): Enhance test helpers with dynamic Hetzner token retrieval. 2024-11-06 16:22:38 +01:00
philkunz 7789348f4e 4.3.5
Docker (tags) / security (push) Successful in 1m2s
Docker (tags) / test (push) Failing after 2m8s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-06 16:13:55 +01:00
philkunz 66a23a515b fix(helpers): Add missing sslMode configuration to Cloudly config. 2024-11-06 16:13:54 +01:00
philkunz 7c1082f5a9 4.3.4
Docker (tags) / security (push) Successful in 1m0s
Docker (tags) / test (push) Failing after 2m5s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-06 03:56:46 +01:00
philkunz 15ea5adec6 fix(testing): Fixed Cloudly testing setup and dependencies 2024-11-06 03:56:46 +01:00
philkunz da0dddcceb 4.3.3
Docker (tags) / security (push) Successful in 1m0s
Docker (tags) / test (push) Failing after 1m37s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-05 21:31:16 +01:00
philkunz b5433e412f fix(core): Fix configuration initialization by accepting a config argument 2024-11-05 21:31:15 +01:00
philkunz 7eb6bf794c 4.3.2
Docker (tags) / security (push) Successful in 42s
Docker (tags) / test (push) Failing after 1m46s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-05 02:45:04 +01:00
philkunz b244518fcb fix(npmextra): Updated npm registry URL in npmextra.json 2024-11-05 02:45:04 +01:00
philkunz 95d0396abb 4.3.1
Docker (tags) / security (push) Successful in 58s
Docker (tags) / test (push) Failing after 3m59s
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-05 02:21:06 +01:00
philkunz a830299cc9 fix(package): Update dependency version for @git.zone/tspublish 2024-11-05 02:21:06 +01:00
philkunz 10fc1d7fba 4.3.0
Docker (tags) / security (push) Failing after 23s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-05 02:06:44 +01:00
philkunz 614ed78928 feat(dependencies): Upgrade dependencies and include publish orders 2024-11-05 02:06:44 +01:00
philkunz ea7f6a6477 4.2.1
Docker (tags) / security (push) Failing after 27s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-04 19:24:47 +01:00
philkunz 2d746a9d1c fix(config): Fix Docker image URL in Gitea workflow. 2024-11-04 19:24:47 +01:00
philkunz a9fab24961 4.2.0
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-11-04 19:23:45 +01:00
philkunz 5548d5a72d feat(cloudron): Add Dockerfile for Cloudron deployment 2024-11-04 19:23:45 +01:00
philkunz 6b52e05a86 4.1.3
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-28 21:54:55 +01:00
philkunz 87e273c30e fix(dependency): Updated dependency @git.zone/tspublish to version ^1.6.0 2024-10-28 21:54:55 +01:00
philkunz 0f3f4b8e3f 4.1.2
Docker (tags) / security (push) Failing after 1s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-28 21:49:43 +01:00
philkunz 5f16d8e494 fix(core): Corrected description and devDependencies 2024-10-28 21:49:43 +01:00
philkunz 5148bd1fff 4.1.1
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-28 16:55:25 +01:00
philkunz 41c54a070c fix(core): Fixed syntax issues in commitinfo data and package.json file. 2024-10-28 16:55:25 +01:00
philkunz 6956524c6e 4.1.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-28 16:19:00 +01:00
philkunz 7a1d933559 feat(core): Enhance core functionality for cloud management and orchestration 2024-10-28 16:19:00 +01:00
philkunz 343acd4997 4.0.1
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-28 15:58:26 +01:00
philkunz 337d111cf6 fix(package_manager): Update @git.zone/tspublish dependency version 2024-10-28 15:58:26 +01:00
philkunz f49dce92cd 4.0.0
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-28 01:39:49 +01:00
philkunz c6abfe69b8 BREAKING CHANGE(core): Significant overhaul with potential breaking changes, update to version 3.0.0 2024-10-28 01:39:49 +01:00
philkunz 9d52c62335 1.2.5
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-28 01:37:55 +01:00
philkunz 971abd19c9 fix(build): Updated devDependencies for tspublish and removed buildDocs script 2024-10-28 01:37:55 +01:00
philkunz 084a11d7ed 1.2.4
Docker (tags) / security (push) Failing after 0s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-27 19:50:39 +01:00
philkunz 8f49f0cb4f fix(ci): Fix Docker images and npm registry URL in CI workflows 2024-10-27 19:50:39 +01:00
philkunz 320b3ed9eb 1.2.3
Docker (tags) / security (push) Failing after 12s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-23 20:09:24 +02:00
philkunz 7cd6bc0e33 fix(cli): Set up CLI client definition and registry configuration 2024-10-23 20:09:24 +02:00
philkunz a218805f27 1.2.2
Docker (tags) / security (push) Failing after 16s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-23 20:06:16 +02:00
philkunz 6f56304e07 fix(docs): Updated documentation with clearer usage instructions and examples. 2024-10-23 20:06:16 +02:00
philkunz 4eb62200e8 1.2.1
Docker (tags) / security (push) Failing after 17s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-23 15:59:49 +02:00
philkunz c105596455 fix(core): Fixed startup issue for the Cloudly instance 2024-10-23 15:59:49 +02:00
philkunz 5aa136b8d9 1.2.0
Docker (tags) / security (push) Failing after 16s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-21 16:37:46 +02:00
philkunz 240516520a feat(cli): Add tspublish.json for CLI client and interfaces 2024-10-21 16:37:46 +02:00
philkunz 4e1f9464fe 1.1.9
Docker (tags) / security (push) Failing after 18s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-21 14:23:43 +02:00
philkunz 41f9f93d1c fix(build): Update Node types and other dependencies, add tspublish.json for api client 2024-10-21 14:23:43 +02:00
philkunz c502d410b1 1.1.8
Docker (tags) / security (push) Failing after 19s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-10-16 14:35:38 +02:00
philkunz 53f96095c7 fix(big fix upgrade): upgrade multiple areas of the core functionalities 2024-10-16 14:35:38 +02:00
philkunz d212dfb9f9 1.1.7
Docker (tags) / security (push) Failing after 18s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-08-25 14:29:26 +02:00
philkunz 0ec665516d fix(deps): Update dependencies to latest versions 2024-08-25 14:29:26 +02:00
philkunz acc642adf9 1.1.6
Docker (tags) / security (push) Failing after 16s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-06-20 19:20:17 +02:00
philkunz a6521708f7 fix(core): update 2024-06-20 19:20:16 +02:00
philkunz 206fe445bc 1.1.5
Docker (tags) / security (push) Failing after 18s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-06-20 19:00:58 +02:00
philkunz a7ee92cde9 fix(core): update 2024-06-20 19:00:58 +02:00
philkunz cdbab26008 1.1.4
Docker (tags) / security (push) Failing after 19s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-06-13 10:07:54 +02:00
philkunz 1983c64b77 fix(core): update 2024-06-13 10:07:53 +02:00
philkunz a6e3a7f5fe prepare service management 2024-06-13 09:36:02 +02:00
philkunz 6dd687012f 1.1.3
Docker (tags) / security (push) Failing after 15s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-06-05 14:13:04 +02:00
philkunz 55b2872ffc fix(structure): improve structure, prepare better CI integration 2024-06-05 14:13:03 +02:00
philkunz 2e6e7f6ca8 1.1.2
Docker (tags) / security (push) Failing after 14s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-06-02 21:39:32 +02:00
philkunz f453ce3126 fix(imagemanager): prepare proper storage and retrieval of container images 2024-06-02 21:39:31 +02:00
philkunz b8dd84b8a6 1.1.1
Docker (tags) / security (push) Failing after 14s
Docker (tags) / test (push) Has been skipped
Docker (tags) / release (push) Has been skipped
Docker (tags) / metadata (push) Has been skipped
2024-06-01 05:48:57 +02:00
philkunz 338ed5ed75 fix(image registry): start work on image registry 2024-06-01 05:48:57 +02:00
162 changed files with 26179 additions and 11277 deletions
+4
View File
@@ -1 +1,5 @@
node_modules/
.nogit/
.git/
.pnpm-store/
.vagrant/
+11 -47
View File
@@ -1,4 +1,4 @@
name: Docker (tags)
name: Docker (non-tag pushes)
on:
push:
@@ -6,44 +6,12 @@ on:
- '**'
env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
security:
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Install pnpm and npmci
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
@@ -54,18 +22,14 @@ jobs:
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
pnpm install -g @git.zone/tsdocker@latest
pnpm install
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test
run: pnpm test
- name: Test build
run: |
npmci npm prepare
npmci node install stable
npmci npm install
npmci command npm run build
- name: Build image
run: tsdocker build
- name: Test image
run: tsdocker test
+15 -80
View File
@@ -6,75 +6,15 @@ on:
- '*'
env:
IMAGE: registry.gitlab.com/hosttoday/ht-docker-node:npmci
IMAGE: code.foss.global/host.today/ht-docker-node:szci
NPMCI_COMPUTED_REPOURL: https://${{gitea.repository_owner}}:${{secrets.GITEA_TOKEN}}@gitea.lossless.digital/${{gitea.repository}}.git
NPMCI_TOKEN_NPM: ${{secrets.NPMCI_TOKEN_NPM}}
NPMCI_TOKEN_NPM2: ${{secrets.NPMCI_TOKEN_NPM2}}
NPMCI_GIT_GITHUBTOKEN: ${{secrets.NPMCI_GIT_GITHUBTOKEN}}
NPMCI_LOGIN_DOCKER_GITEA: ${{ github.server_url }}|${{ gitea.repository_owner }}|${{ secrets.GITEA_TOKEN }}
NPMCI_LOGIN_DOCKER_DOCKERREGISTRY: ${{ secrets.NPMCI_LOGIN_DOCKER_DOCKERREGISTRY }}
jobs:
security:
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
continue-on-error: true
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Audit production dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --prod
continue-on-error: true
- name: Audit development dependencies
run: |
npmci command npm config set registry https://registry.npmjs.org
npmci command pnpm audit --audit-level=high --dev
continue-on-error: true
test:
needs: security
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
steps:
- uses: actions/checkout@v3
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
npmci npm prepare
- name: Test stable
run: |
npmci node install stable
npmci npm install
npmci npm test
- name: Test build
run: |
npmci node install stable
npmci npm install
npmci command npm run build
release:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: registry.gitlab.com/hosttoday/ht-docker-dbase:npmci
image: code.foss.global/host.today/ht-docker-dbase:szci
steps:
- uses: actions/checkout@v3
@@ -82,25 +22,20 @@ jobs:
- name: Prepare
run: |
pnpm install -g pnpm
pnpm install -g @shipzone/npmci
pnpm install -g @git.zone/tsdocker@latest
pnpm install
- name: Release
run: |
npmci docker login
npmci docker build
npmci docker test
# npmci docker push gitea.lossless.digital
npmci docker push dockerregistry.lossless.digital
- name: Login to registries
run: tsdocker login
metadata:
needs: test
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
container:
image: ${{ env.IMAGE }}
- name: List images
run: tsdocker list
steps:
- uses: actions/checkout@v3
- name: Build images
run: tsdocker build
- name: Trigger
run: npmci trigger
- name: Test images
run: tsdocker test
- name: Push to code.foss.global
run: tsdocker push code.foss.global
+132
View File
@@ -0,0 +1,132 @@
{
"@git.zone/tsdocker": {
"registries": [
"code.foss.global"
],
"registryRepoMap": {
"code.foss.global": "serve.zone/cloudly"
},
"platforms": [
"linux/amd64",
"linux/arm64"
]
},
"@git.zone/tsbundle": {
"bundles": [
{
"from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js",
"outputMode": "bundle",
"bundler": "esbuild",
"production": true,
"includeFiles": [
"./html/**/*"
]
}
]
},
"@git.zone/tswatch": {
"preset": "website",
"server": {
"enabled": true,
"port": 3000,
"serveDir": "./dist_serve/",
"liveReload": true
},
"watchers": [
{
"name": "backend",
"watch": [
"./ts/**/*",
"./ts_cliclient/**/*"
],
"command": "pnpm run startTs",
"restart": true,
"debounce": 300,
"runOnStart": true
}
],
"bundles": [
{
"name": "website",
"from": "./ts_web/index.ts",
"to": "./dist_serve/bundle.js",
"watchPatterns": [
"./ts_web/**/*",
"./html/**/*"
],
"triggerReload": true,
"bundler": "esbuild",
"production": false,
"includeFiles": [
"./html/**/*"
]
}
]
},
"@ship.zone/szci": {
"npmGlobalTools": [],
"npmAccessLevel": "public",
"npmRegistryUrl": "verdaccio.lossless.digital",
"dockerRegistryRepoMap": {
"code.foss.global": "serve.zone/cloudly"
},
"dockerBuildargEnvMap": {}
},
"@git.zone/cli": {
"schemaVersion": 2,
"projectType": "service",
"module": {
"githost": "code.foss.global",
"gitscope": "serve.zone",
"gitrepo": "cloudly",
"description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.",
"npmPackagename": "@serve.zone/cloudly",
"license": "MIT",
"keywords": [
"multi-cloud management",
"Docker Swarmkit",
"container orchestration",
"cloud services",
"API integration",
"web interface",
"CLI",
"CI/CD integration",
"cloud providers",
"DigitalOcean",
"Hetzner Cloud",
"Cloudflare",
"TypeScript",
"Node.js",
"infrastructure automation",
"devOps",
"secret management",
"configuration management",
"task scheduling",
"logging",
"SSL management",
"system logging",
"cloud API client",
"frontend",
"backend",
"security"
]
},
"release": {
"targets": {
"git": {
"enabled": true,
"remote": "origin"
},
"docker": {
"enabled": true,
"engine": "tsdocker",
"patterns": []
}
}
}
},
"@git.zone/tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"json.schemas": [
{
"fileMatch": ["/npmextra.json"],
"fileMatch": ["/.smartconfig.json"],
"schema": {
"type": "object",
"properties": {
+23 -34
View File
@@ -1,46 +1,35 @@
# gitzone dockerfile_service
## STAGE 1 // BUILD
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node1
COPY ./ /app
FROM code.foss.global/host.today/ht-docker-node:lts AS build
WORKDIR /app
ARG NPMCI_TOKEN_NPM2
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
RUN npmci npm prepare
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules && pnpm install
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . ./
RUN pnpm run build
RUN pnpm prune --prod
## STAGE 2 // PRODUCTION
FROM code.foss.global/host.today/ht-docker-node:lts AS production
# gitzone dockerfile_service
## STAGE 2 // install production
FROM registry.gitlab.com/hosttoday/ht-docker-node:npmci as node2
WORKDIR /app
COPY --from=node1 /app /app
RUN rm -rf .pnpm-store
ARG NPMCI_TOKEN_NPM2
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
RUN npmci npm prepare
RUN pnpm config set store-dir .pnpm-store
RUN rm -rf node_modules/ && pnpm install --prod
ENV NODE_ENV=production
ENV SERVEZONE_PORT=80
## STAGE 3 // rebuild dependencies for alpine
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpinenpmci as node3
WORKDIR /app
COPY --from=node2 /app /app
ARG NPMCI_TOKEN_NPM2
ENV NPMCI_TOKEN_NPM2 $NPMCI_TOKEN_NPM2
RUN npmci npm prepare
RUN pnpm config set store-dir .pnpm-store
RUN pnpm rebuild -r
COPY --from=build /app/package.json ./package.json
COPY --from=build /app/node_modules ./node_modules
COPY --from=build /app/cli.js ./cli.js
COPY --from=build /app/dist_ts ./dist_ts
COPY --from=build /app/dist_serve ./dist_serve
## STAGE 4 // the final production image with all dependencies in place
FROM registry.gitlab.com/hosttoday/ht-docker-node:alpine as node4
WORKDIR /app
COPY --from=node3 /app /app
LABEL org.opencontainers.image.title="cloudly" \
org.opencontainers.image.description="serve.zone control plane" \
org.opencontainers.image.source="https://code.foss.global/serve.zone/cloudly"
### Healthchecks
RUN pnpm install -g @servezone/healthy
HEALTHCHECK --interval=30s --timeout=30s --start-period=30s --retries=3 CMD [ "healthy" ]
HEALTHCHECK --interval=30s --timeout=10s --start-period=120s --retries=3 CMD node -e "const http=require('node:http');const port=process.env.SERVEZONE_PORT||80;const req=http.get({host:'127.0.0.1',port,path:'/'},res=>process.exit(res.statusCode<500?0:1));req.on('error',()=>process.exit(1));req.setTimeout(5000,()=>{req.destroy();process.exit(1);});"
EXPOSE 80
CMD ["npm", "start"]
CMD ["node", "cli.js"]
+1
View File
@@ -0,0 +1 @@
FROM serve.zone/cloudly:latest
+689
View File
@@ -0,0 +1,689 @@
# Changelog
## Pending
## 2026-05-28 - 6.4.3
- label Valkey App Store platform requirements in the dashboard
- Displays Valkey using the canonical platform name when templates declare that requirement.
### Fixes
- label App Store platform requirement badges with canonical names (appstore)
- Displays Valkey with its canonical platform name when templates declare that requirement.
- Adds display labels for MongoDB, S3, ClickHouse, Valkey, and MariaDB platform requirement badges.
- Bumps appstore, interfaces, and tsbuild patch dependencies.
## 2026-05-28 - 6.4.2
### Fixes
- make service deletion clean up dependent control-plane state (services)
- Delete appstore operations, deployments, DNS entries, platform bindings, backups, registry repositories, owned secrets, and unreferenced App Store images before removing the service row.
- Preserve shared OCI blobs while removing service-owned registry repositories.
- Keep the service row when required cleanup fails so deletion remains retryable.
- clean up dependent resources during service deletion (services)
- Remove dependent deployments, DNS entries, platform bindings, backups, registry repositories, app store operations, owned secret bundles, and unreferenced App Store images before deleting the service row.
- Delete service-owned backup prefixes and OCI registry repositories while preserving shared OCI blobs.
- Keep the service row when required cleanup fails so deletion remains retryable.
## 2026-05-27 - 6.4.1
### Fixes
- return safe App Store backend errors for template, config, and install failures
- guard App Store client actions against empty typed RPC responses
- bump `@api.global/typedrequest` to `3.3.2` and `@serve.zone/api` to `^5.3.9`
- handle App Store backend failures and empty RPC responses (appstore)
- return sanitized App Store backend errors for template, config, and install failures
- validate App Store typed RPC responses before updating client state or returning results
- bump typedrequest and serve.zone API dependencies
## 2026-05-26 - 6.4.0
### Features
- add hosted Cloudly parent upgrade controls (hostedapp)
- Proxy admin upgrade status and start requests to the parent hosted-app runtime with the service-scoped runtime identity.
- Add a Settings hosted runtime panel for status refresh, parent upgrade start, and running-upgrade polling.
- Update `@serve.zone/interfaces` to `^6.2.0` for the parent upgrade contracts.
- add hosted Cloudly parent upgrade controls (hostedapp)
- Proxy admin upgrade status and start requests to the parent hosted-app runtime using the service runtime identity.
- Add Settings hosted runtime status, refresh, upgrade start, and running-upgrade polling UI.
- Update @serve.zone/interfaces to ^6.2.0 for parent upgrade request contracts.
## 2026-05-26 - 6.3.1
- remove redundant card wrappers around Cloudly tables (ui)
- Lets `dees-table` provide its own card shell in service, image, and task history views.
- Moves the live deployment refresh action into the table header actions.
### Fixes
- remove redundant wrappers around Cloudly tables (ui)
- Let dees-table provide its own card shell in service, image, and task history views.
- Move the live deployments refresh action into the deployments table header actions.
- Bump @types/node to ^25.9.1.
## 2026-05-26 - 6.3.0
- add hosted app lifecycle protocol support (hostedapp)
- Implements generic Hosted App TypedRequest handlers for Cloudly-hosted App Store services.
- Injects service-scoped runtime identity environment variables into Cloudly App Store installs.
- Lets Cloudly report initial admin bootstrap credentials to its parent host when `SERVEZONE_ADMINACCOUNT` is not configured.
### Features
- add hosted app lifecycle protocol support (hostedapp)
- Adds a hosted app manager with lifecycle, bootstrap, and managed upgrade TypedRequest handlers.
- Injects hosted app runtime identity environment variables into App Store installs.
- Allows initial admin bootstrap credentials to be requested from the parent hosted app runtime when SERVEZONE_ADMINACCOUNT is not configured.
- Updates hosted app platform requirements and @serve.zone/interfaces for the lifecycle protocol.
## 2026-05-26 - 6.2.0
### Features
- add App Store install and upgrade workflows (appstore)
- Add an App Store dashboard for browsing templates, viewing version configs, editing install inputs, and installing services
- Add App Store state actions, routing, and live upgrade operation progress handling in the web app
- Implement upgrade previews, asynchronous service upgrade operations, platform binding reconciliation, and preservation of service volume and published port overrides
- Enable service detail upgrades with preview confirmation, progress display, and refreshed service data
- Bump @serve.zone/interfaces to ^6.0.1 and add App Store upgrade merge tests
## 2026-05-26 - 6.1.0
### Features
- improve image operations UI and record archive metadata (images)
- Add image list metadata, detail drilldown, version tables, and service usage context.
- Record uploaded image archive size and SHA-256 digest after storage completes.
- Add deployment detail modals and safe double-click Details actions in deployment and service views.
- Initialize default location metadata when creating images.
## 2026-05-25 - 6.0.0
### Breaking Changes
- switch App Store APIs to the shared appstore client
- Replaces Cloudly App Store manager naming and request methods with App Store naming
- Uses `@serve.zone/appstore` for app metadata parsing and source resolution
- Adds `servezone.appstore.json` as Cloudly's source-owned App Store manifest
## 2026-05-24 - 5.9.0
### Features
- accept Spark node heartbeats
- Adds a Spark heartbeat HTTP endpoint for cluster nodes
- Stores Spark metrics and runtime info on cluster node records
- Extends jump onboarding with per-node Spark telemetry credentials
### Fixes
- invalidate expired dashboard sessions and return admins to login
### Maintenance
- refresh release tooling dependencies
- update `@serve.zone/interfaces` to the Spark telemetry contract release
## 2026-05-24 - 5.8.2
- update Cloudly to consume the released Jump Code API client
- bumps `@serve.zone/api` to `^5.3.8`
- keeps Docker release installs working with fresh serve.zone package releases
- refreshes mature build, bundle, test, docs, and watch tooling
- stabilizes API client integration test setup and cleanup
## 2026-05-23 - 5.8.1
### Fixes
- allow Docker builds to install freshly released serve.zone interfaces
- Copies pnpm-workspace.yaml into the Docker dependency install layer
- Excludes @serve.zone/interfaces from pnpm 11 minimum release age checks during release builds
## 2026-05-23 - 5.8.0
### Features
- add service detail runtime actions and app catalog onboarding
- Adds service detail pages with live deployments, restart, kill, and deployment IDE access
- Adds app catalog install/update detection contracts and Cloudly handlers
- Adds node jump codes for connecting systems to clusters
- Updates Cloudly to pnpm 11 and @serve.zone/interfaces 5.9.0
## 2026-05-21 - 5.7.1
### Fixes
- clean up Cloudly dashboard console and asset errors
- replace invalid Lucide icon references in table actions and context menu items
- add PWA manifest and local SVG favicon assets to avoid 404s
- align local Cloudly custom element tags with canonical kebab-case names
- remove noisy frontend debug logging from login and revenue checks
## 2026-05-21 - 5.7.0
### Features
- add SPA dashboard path navigation (web)
- support direct links to dashboard views and subviews via URL paths
- sync appdash selection with browser history and enable server SPA fallback
## 2026-05-21 - 5.6.0
### Features
- group dashboard navigation (web)
- reorganize Cloudly sidebar into Platform, Runtime, Registry & Build, Secrets, Domains & Messaging, Storage, and Logs sections
- keep Images next to Services in the Runtime group because services reference imageId and imageVersion
## 2025-09-08 - 5.3.0 - feat(web)
Add deployments API typings and web UI improvements: services & deployments management with CRUD and actions
- Add deployment request interfaces (ts_interfaces/requests/deployment.ts) to define typed API for create/read/update/delete/scale/restart operations.
- Extend web app state (ts_web/appstate.ts) to include typed services and deployments, and add actions for create/update/delete of services and deployments.
- Enhance web views (ts_web/elements/*): CloudlyViewServices and CloudlyViewDeployments now include richer display, styling, and UI actions (create, edit, deploy, restart, stop, delete).
- Fix subscription variable naming in several web components (subecription -> subscription) and improve table display functions to handle missing data safely.
- Add .claude/settings.local.json (tooling/permissions) used for local development/test tooling.
## 2025-09-07 - 5.2.0 - feat(settings)
Add runtime settings management, node & baremetal managers, and settings UI
- Introduce CloudlySettingsManager to store runtime settings in an EasyStore (MongoDB) with API handlers for get/update/clear/test.
- Add settings data/interface and typedrequest definitions (ts_interfaces/data/settings.ts, ts_interfaces/requests/settings.ts) and expose via interfaces index.
- Add web UI for managing provider credentials and connections (ts_web/elements/cloudly-view-settings.ts) and integrate the Settings view into the dashboard.
- Replace the previous ServerManager concept with NodeManager and BaremetalManager: new ClusterNode and BareMetal models and managers (auto-provisioning / Hetzner integration), plus curlfresh moved to node manager.
- Update Cluster data shape (servers -> nodes) and adjust related code paths (overview stats, cluster creation and provisioning flows).
- Use settingsManager for provider tokens (cloudflareToken, hetznerToken) instead of reading tokens directly from config/env; connector and manager init code updated accordingly.
- Add numerous implementations and API handlers to support baremetal/node lifecycle and control (getBaremetalServers, controlBaremetal, getNodeConfig, node provisioning helpers).
- Reorder Cloudly startup to initialize MongoDB and settings manager before managers that depend on settings; wire settingsManager into Cloudly class.
- Bump package dependency versions for @git.zone/tsdoc, @design.estate/dees-catalog and @push.rocks/taskbuffer in package.json.
## 2025-09-05 - 5.1.0 - feat(cluster)
Add cluster setupMode (manual|hetzner|aws|digitalocean) with conditional Hetzner auto-provisioning; UI and dashboard improvements; dependency upgrades
- Introduce optional setupMode on cluster configs and requests (ICluster.data.setupMode, createCluster request) to allow 'manual' | 'hetzner' | 'aws' | 'digitalocean'.
- ClusterManager: default setupMode to 'manual' when creating clusters and only trigger serverManager.ensureServerInfrastructure() for 'hetzner' clusters.
- ServerManager: skip provisioning for clusters not configured with setupMode 'hetzner' and log skipped clusters.
- Web UI: add a 'Setup Mode' dropdown when creating a cluster so users can choose auto-provisioning provider; ensure the add-cluster action passes setupMode.
- Web UI: dashboard enhancements — add icons to view tabs and replace cluster overview with a stats grid (including total clusters, total servers, images, services, deployments, secret groups/bundles, DNS, DBs, backups, mails, s3). The overview now computes total servers across clusters.
- Package dependency bumps (devDependencies and dependencies) to keep libs up-to-date (examples: @git.zone/tsbuild, @git.zone/tstest, @api.global/typedserver, @apiclient.xyz/docker, @design.estate/dees-catalog, @push.rocks/smartlog, @push.rocks/smartrequest, @push.rocks/taskbuffer, etc.).
- Add .claude/settings.local.json with local Claude permissions (editor/automation config).
## 2025-08-18 - 5.0.6 - fix(connector.letsencrypt)
Improve Let's Encrypt integration and certificate handling; fix coreflow certificate response; add local assistant permissions config
- Replace ad-hoc setChallenge/removeChallenge hooks with a DNS-01 handler (smartacme.handlers.Dns01Handler) using Cloudflare to manage ACME DNS challenges.
- Add MongoDB-backed certificate manager (smartacme.certmanagers.MongoCertManager) and pass it to SmartAcme as certManager.
- Initialize SmartAcme with certManager and challengeHandlers instead of setChallenge/removeChallenge/mongoDescriptor options.
- Return certificate object directly from coreflow certificate request handler (avoid createSavableObject) to fix the getCertificateForDomain response payload.
- Add .claude/settings.local.json with local assistant/permissions entries to allow specific debugging/automation commands.
- Bump commitinfo versions to 5.0.6 and update changelog.
## 2025-08-18 - 5.0.6 - fix(connector.letsencrypt)
Improve Let's Encrypt integration and certificate handling; add local assistant permissions config
- Replace ad-hoc setChallenge/removeChallenge hooks with a DNS-01 handler using Cloudflare (smartacme.handlers.Dns01Handler) to manage ACME DNS challenges.
- Add MongoDB-backed certificate manager (smartacme.certmanagers.MongoCertManager) and pass it to SmartAcme as certManager.
- Update SmartAcme initialization to use certManager and challengeHandlers instead of setChallenge/removeChallenge/mongoDescriptor options.
- Return certificate object directly from coreflow certificate request handler (avoid createSavableObject), fixing the response payload for getCertificateForDomain.
- Add .claude/settings.local.json with local assistant/permissions entries to allow specific debugging/automation commands.
## 2025-08-18 - 5.0.5 - fix(coreflow)
Fix Coreflow identity lookup and response shape; improve API client tests and bump dependencies
- ts/manager.coreflow/coreflowmanager.ts: Use $elemMatch to correctly query nested user.tokens when resolving identities and validate machine user types.
- ts/manager.coreflow/coreflowmanager.ts: Normalize getClusterConfig response to include services (was deploymentDirectives) and tidy handler signatures.
- test/test.apiclient.ts: Add detailed logging and improved error handling across preTask, client startup, identity retrieval, image creation and image upload to aid debugging and test observability.
- package.json: Update dependency versions (notable bumps): @types/node -> ^22.0.0, @push.rocks/smartacme -> ^8.0.0, @push.rocks/smartdata -> ^5.16.4, @push.rocks/smartexpect -> ^2.5.0, @push.rocks/smartpath -> ^6.0.0, @push.rocks/smartrequest -> ^4.2.2, plus other maintenance bumps.
- Add .claude/settings.local.json to provide local Claude permissions for developer tooling.
## 2025-04-25 - 5.0.4 - fix(platformservice/mta)
Update getEmailStatus response schema: make details property optional
- Changed details property from required with fixed message to optional with a flexible message structure in IReq_GetEMailStats response
## 2025-04-25 - 5.0.3 - fix(mta)
update email status response type in MTA platform service
- Changed the response 'status' field in IRequest_CheckEmailStatus from a literal 'unknown' to a generic string for improved flexibility
## 2025-04-25 - 5.0.2 - fix(platformservice/mta)
Refactor email status response in MTA service
- Updated IReq_CheckEmailStatus response: replaced union type ('ok' | 'not ok') with fixed status 'unknown' and added a details object with message 'Email not found'.
## 2025-04-25 - 5.0.1 - fix(mta)
Update email stats response interface in mta platform service to include totalEmailsSent, totalEmailsDelivered, totalEmailsBounced, averageDeliveryTimeMs, and lastUpdated timestamp.
- Modified IReq_GetEMailStats response in ts_interfaces/platformservice/mta.ts from an empty status object to a detailed email statistics structure.
## 2025-04-25 - 5.0.0 - BREAKING CHANGE(ts_interfaces/platformservice/mta)
Rename mta interfaces and upgrade dependency versions
- Upgraded devDependencies: @git.zone/tsbuild, tsbundle, tsdoc, tstest, tswatch, and @push.rocks/tapbundle to newer versions.
- Upgraded dependencies: @design.estate/dees-catalog, dees-domtools, dees-element, @push.rocks/smartdata, smartexpect, smartfile, smartpromise, smartrequest, smartrx, and tsclass (v4.2.0 to v9.0.0).
- Added new packageManager field in package.json and introduced pnpm-workspace.yaml for additional workspace configuration.
- Refactored mta API interfaces: renamed IRequest_SendEmail to IReq_SendEmail and IRequestRegisterRecipient to IReq_RegisterRecipient; added IReq_CheckEmailStatus and IReq_GetEMailStats.
## 2025-01-20 - 4.13.0 - feat(service)
Add support for service creation, update, and deletion.
- Implemented TypedHandlers for creating a new service.
- Added features to update existing service details.
- Enabled deletion of services by their unique ID.
## 2025-01-20 - 4.12.2 - fix(service)
Fix secret bundle and service management bugs
- Corrected the field name from 'includedImages' to 'imageClaims' in secret bundles.
- Implemented 'getFlatKeyValueObject' for secret bundles and modified related API interactions.
- Enhanced the Service class with methods for handling secret bundle data by resolving related groups and environments.
## 2025-01-02 - 4.12.1 - fix(deps)
Updated @git.zone/tspublish to version ^1.9.1
## 2025-01-02 - 4.12.0 - feat(cli)
Add CLI support and external registries view
- Adds CLI client functionality
- Introduces a new view for External Registries in the dashboard
## 2024-12-30 - 4.11.0 - feat(external-registry)
Introduce external registry management
- Added ExternalRegistryManager to handle external registry operations.
- Implemented ability to create, retrieve, and delete external registries.
- Enhanced Cloudly class to include ExternalRegistryManager.
## 2024-12-29 - 4.10.0 - feat(apiclient)
Added support for managing external registries in the API client.
- Introduced methods to get a registry by ID, get all registries, and create a new registry in the externalRegistry object.
- Updated external registry request interfaces to match new API client capabilities.
## 2024-12-29 - 4.9.0 - feat(apiclient)
Add external registry management capabilities to Cloudly API client.
- Introduce ExternalRegistry class with methods for getting, creating, and updating external registries.
- Expand requests module to handle external registry management, including creation and deletion.
## 2024-12-28 - 4.8.1 - fix(interfaces)
Fix image location schema in IImage interface
- Refactored the 'external' object within IImage data to a 'location' object.
- Added 'internal' boolean to 'location' to specify internal/external status.
## 2024-12-28 - 4.8.0 - feat(manager.registry)
Add external registry management
- Introduced ExternalRegistry class for handling external registry configurations.
- Updated IExternalRegistry interface to include registry details.
- Enhanced IImage interface to support linking with external registries.
## 2024-12-28 - 4.7.1 - fix(secretmanagement)
Refactor secret bundle actions and improve authorization handling
- Refactored secret bundle handling by renaming methods and reorganizing static and instance methods in SecretBundle class.
- Added getSecretBundleByAuthorization method to SecretBundle.
- Improved getFlatKeyValueObjectForEnvironment to accurately retrieve key-value pairs for specified environments.
- Removed deprecated IEnvBundle interface and related request handler for better clarity and code usage.
- Updated request interfaces related to secret bundles for consistent method naming and arguments.
## 2024-12-22 - 4.7.0 - feat(apiclient)
Add method to flatten secret bundles into key-value objects.
- SecretBundle: Implemented toFlatKeyValueObject method to flatten secret groups into key-value pairs.
- Removed stale SecretManager class from apiclient.
## 2024-12-22 - 4.6.0 - feat(cloudlyapiclient)
Extend CloudlyApiClient with cluster, secretbundle, and secretgroup methods
- Added methods to CloudlyApiClient for managing clusters: getClusterById, getClusters, createCluster.
- Added methods to CloudlyApiClient for managing secret bundles: getSecretBundleById, getSecretBundles, createSecretBundle.
- Added methods to CloudlyApiClient for managing secret groups: getSecretGroupById, getSecretGroups, createSecretGroup.
## 2024-12-22 - 4.5.5 - fix(apiclient)
Fixed image creation method in cloudlyApiClient
- Corrected method call from 'images.createImage' to 'image.createImage' to ensure proper image creation.
- Updated cluster retrieval methods and ensured proper API routes are being called.
## 2024-12-21 - 4.5.4 - fix(ts_web)
Fix action type and data fields in appstate for CRUD operations
- Correct request method in createSecretGroupAction to accurately reflect the purpose.
- Align the deleteSecretGroupAction and deleteSecretBundleAction request types with proper interfaces.
- Ensure data payload matches backend requirements for secret group and secret bundle operations.
## 2024-12-21 - 4.5.3 - fix(secret-management)
Refactor secret management to use distinct secret bundle and group APIs. Introduce API client classes for secret bundles and groups.
- Updated secret management logic to separate secret bundle and group APIs.
- Implemented new API client classes for managing secret bundles and groups.
- Fixed incorrect method usages for secret-related actions.
## 2024-12-20 - 4.5.2 - fix(apiclient)
Implemented IService interface in Service class and improved secret bundle documentation.
- Implemented plugins.servezoneInterfaces.data.IService interface in the Service class within ts_apiclient.
- Updated documentation comments for the type property in the ISecretBundle interface.
## 2024-12-17 - 4.5.1 - fix(core)
Updated dependencies in package.json to latest versions.
- Bumped @git.zone/tswatch to version ^2.0.37
- Bumped @types/node to version ^22.10.2
- Bumped @design.estate/dees-catalog to version ^1.3.2
- Bumped @push.rocks/smartfile to version ^11.0.23
- Bumped @tsclass/tsclass to version ^4.2.0
## 2024-12-14 - 4.5.0 - feat(services)
Add service management functionalities
- Integrated service-related API client methods including getServices, getServiceById, and createService.
- Updated the deployment data structure in the service manager.
- Enhanced service interface to incorporate additional fields for comprehensive data handling.
- Ensured secure token generation for Cloudly authentication processes.
## 2024-11-18 - 4.4.0 - feat(api-client)
Add static method getImageById for Image class in api-client
- Introduced a static method getImageById in the Image class.
- Updated CloudlyApiClient to include the getImageById method in the images interface.
## 2024-11-18 - 4.3.21 - fix(interfaces)
Remove deprecated deployment directive and update related interfaces
- Removed IDeploymentDirective from data and requests.
- Updated IDeployment to remove references to directives.
- Changed IRequest_Any_Cloudly_GetClusterConfig to return services instead of deployment directives.
- Removed deploymentDirectiveIds from IService data structure.
## 2024-11-18 - 4.3.20 - fix(apiclient)
Ensure mandatory parameter in CloudlyApiClient constructor
- Made the 'optionsArg' parameter mandatory in the constructor of CloudlyApiClient class.
## 2024-11-18 - 4.3.19 - fix(docker)
Fix improper Docker push command preventing push to the correct registry.
- Corrected the docker push command in the '.gitea/workflows/docker_tags.yaml' file to push images to the 'code.foss.global' registry.
## 2024-11-17 - 4.3.18 - fix(docker_tags)
Updated Docker configuration to include NPM tokens.
- Restored NPMCI_TOKEN_NPM and NPMCI_TOKEN_NPM2 environment variables in docker_tags.yaml for authentication purposes.
## 2024-11-17 - 4.3.17 - fix(Dockerfile)
Corrected docker base image tag in Dockerfile for alpine compatibility.
- Updated Dockerfile to use the correct base image tag for Alpine.
- Resolved any potential build issues related to incorrect image tag usage.
## 2024-11-17 - 4.3.16 - fix(infrastructure)
Correct Docker image path in Dockerfile for improved build consistency.
- Dockerfile: Updated base image paths from 'code.foss.global/hosttoday/ht-docker-node' to 'code.foss.global/host.today/ht-docker-node'.
## 2024-11-17 - 4.3.15 - fix(project setup)
fixed incorrect configuration in npmextra.json
- Removed unnecessary 'dockerBuildargEnvMap' entry for NPMCI_TOKEN_NPM2.
- Corrected the githost and gitscope in gitzone module configuration.
- Updated the license field to reflect the correct license.
## 2024-11-16 - 4.3.14 - fix(docker tags)
Comment out unused secret variables in docker_tags.yaml
- Modified docker_tags.yaml to comment out unused secret variables related to NPM and GitHub tokens.
## 2024-11-16 - 4.3.13 - fix(package)
Updated package dependencies
- Updated @design.estate/dees-catalog version to 1.3.1
## 2024-11-06 - 4.3.12 - fix(workflow)
Fix Docker image path in GitHub action workflow
- Corrected the path of the Docker image used in the GitHub action workflow from 'code.foss.global/hosttoday/ht-docker-dbase:npmci' to 'code.foss.global/host.today/ht-docker-dbase:npmci'.
## 2024-11-06 - 4.3.11 - fix(overall)
Refactor and improve code consistency across all modules
- Updated cloud configuration management for better reliability.
- Enhanced security measures in the authentication and authorization processes.
- Streamlined deployment logic in cluster management.
- Refactored code to improve maintainability and readability.
## 2024-11-06 - 4.3.10 - fix(dependencies)
Updated dependencies and fixed Docker Alpine image retrieval issue in tests
- Updated @push.rocks/tapbundle to version ^5.5.0 in devDependencies.
- Updated @push.rocks/smartrequest to version ^2.0.23 in dependencies.
- Ensured the Docker Alpine image is retrieved as a local tarball in cloudlyfactory.ts test helper.
## 2024-11-06 - 4.3.9 - fix(test and dependencies)
Corrected cloudlyUrl in test.apiclient and updated tapbundle dependency.
- Fixed cloudlyUrl assignment in the test.apiclient to use the correct helper method.
- Updated tapbundle dependency version from ^5.4.3 to ^5.4.4.
## 2024-11-06 - 4.3.8 - fix(api client)
Fixed localhost URL issue in test.client.ts
- Changed the cloudlyUrl in test.client.ts from 'localhost' to '127.0.0.1' to ensure consistency in network requests.
## 2024-11-06 - 4.3.7 - fix(tests)
Refactored test setup for consistency and isolated config initialization.
- test/helpers/cloudlyfactory.ts: Test configuration setup was refactored to ensure consistent initialization of cloudly configuration across tests.
- test/test.apiclient.ts: Updated cloudlyApiClient test setup to use testCloudlyConfig for dynamic port allocation.
## 2024-11-06 - 4.3.6 - fix(test)
Enhance test helpers with dynamic Hetzner token retrieval.
- Updated test/helpers/cloudlyfactory.ts to retrieve Hetzner token from environment variables.
- Expanded docker_tags workflow to securely handle and pass new environment secrets.
## 2024-11-06 - 4.3.5 - fix(helpers)
Add missing sslMode configuration to Cloudly config.
- Added the sslMode key with a value of 'none' to the Cloudly configuration object in test/helpers/cloudlyfactory.ts.
## 2024-11-06 - 4.3.4 - fix(testing)
Fixed Cloudly testing setup and dependencies
- Updated devDependency @push.rocks/tapbundle version from ^5.3.0 to ^5.4.3 in package.json.
- Updated devDependency @push.rocks/npmextra version from ^5.1.1 to ^5.1.2 in package.json.
- Improved the Cloudly test suite setup to ensure proper initialization of MongoDB and S3 services.
- Ensured asynchronous stopping and cleanup of MongoDB and S3 services post-test execution.
## 2024-11-05 - 4.3.3 - fix(core)
Fix configuration initialization by accepting a config argument
- Configuration initialization now accepts an optional config argument
- Updated test cloudly factory to use default public URL and port
- Updated dependencies versions
## 2024-11-05 - 4.3.2 - fix(npmextra)
Updated npm registry URL in npmextra.json
## 2024-11-05 - 4.3.1 - fix(package)
Update dependency version for @git.zone/tspublish
- Bump @git.zone/tspublish from version ^1.7.6 to ^1.7.7 in package.json
## 2024-11-05 - 4.3.0 - feat(dependencies)
Upgrade dependencies and include publish orders
- Upgraded @git.zone/tsbuild to version ^2.2.0
- Upgraded @git.zone/tspublish to version ^1.7.6
- Upgraded @types/node to version ^22.8.7
- Added publish order to ts_apiclient/tspublish.json and ts_interfaces/tspublish.json
## 2024-11-04 - 4.2.1 - fix(config)
Fix Docker image URL in Gitea workflow.
- Corrected the IMAGE URL from 'hosttoday' to 'host.today'.
## 2024-11-04 - 4.2.0 - feat(cloudron)
Add Dockerfile for Cloudron deployment
- Introduced a new Dockerfile for Cloudron deployment.
- The Dockerfile uses the latest version of cloudly as a base image.
## 2024-10-28 - 4.1.3 - fix(dependency)
Updated dependency @git.zone/tspublish to version ^1.6.0
- Bumped @git.zone/tspublish version from ^1.5.5 to ^1.6.0 in package.json
## 2024-10-28 - 4.1.2 - fix(core)
Corrected description and devDependencies
- Updated package.json description to accurately reflect features.
- Added `@git.zone/tsdoc` to devDependencies.
- Corrected version discrepancy in `@types/node` devDependency.
- Standardized description across multiple files including npmextra.json.
## 2024-10-28 - 4.1.1 - fix(core)
Fixed syntax issues in commitinfo data and package.json file.
- Added a missing newline at the end of the package.json file.
- Corrected a trailing comma and added proper syntax in the commitinfo data.
## 2024-10-28 - 4.1.0 - feat(core)
Enhance core functionality for cloud management and orchestration
- Improved initialization and management of cloud environments with Docker Swarmkit.
- Added capability to manage DNS records via Cloudflare.
- Introduced integration support for DigitalOcean resources.
## 2024-10-28 - 4.0.1 - fix(package_manager)
Update @git.zone/tspublish dependency version
- Bump @git.zone/tspublish version from 1.5.4 to 1.5.5.
## 2024-10-28 - 4.0.0 - BREAKING CHANGE(core)
Significant overhaul with potential breaking changes, update to version 3.0.0
- Updated project version from 1.2.5 to 3.0.0 in package.json
## 2024-10-28 - 1.2.5 - fix(build)
Updated devDependencies for tspublish and removed buildDocs script
- devDependencies updated: @git.zone/tspublish to version ^1.5.4
- Removed: buildDocs script from the scripts section
## 2024-10-27 - 1.2.4 - fix(ci)
Fix Docker images and npm registry URL in CI workflows
- Updated Docker image registry URL from 'registry.gitlab.com' to 'code.foss.global'.
- Fixed npmci package installation path from '@shipzone/npmci' to '@ship.zone/npmci'.
## 2024-10-23 - 1.2.3 - fix(cli)
Set up CLI client definition and registry configuration
- Added a console log message for CLI identification.
- Defined package name for CLI client.
- Configured npm registries for package distribution.
## 2024-10-23 - 1.2.2 - fix(docs)
Updated documentation with clearer usage instructions and examples.
- Refined setup guides in README.
- Added more detailed examples in code snippets.
- Clarified cloud service integration instructions.
## 2024-10-23 - 1.2.1 - fix(core)
Fixed startup issue for the Cloudly instance
## 2024-10-21 - 1.2.0 - feat(cli)
Add tspublish.json for CLI client and interfaces
- Added registration details for publishing CLI client (@serve.zone/cli)
- Specified npm and custom registries in the tspublish.json for better publish control
- Updated dependency version for @git.zone/tspublish in package.json
## 2024-10-21 - 1.1.9 - fix(build)
Update Node types and other dependencies, add tspublish.json for api client
- Updated Node types devDependency to version ^22.7.7 from ^22.7.5.
- Updated smartbucket dependency to version ^3.0.23 from ^3.0.22.
- Added tspublish configuration file to the api client.
- Fixed the npm publish script in package.json.
## 2024-10-16 - 1.1.8 - fix(big fix upgrade)
fix: update dependency versions and address type errors
- Updated all listed dependencies in the package.json to their specified ranges.
- Fixed type mismatches and added missing imports in various TypeScript files.
- Refined existing tests and added a new helper to manage Docker image streams.
## 2024-08-25 - 1.1.7 - fix(deps)
Update dependencies to latest versions
- Updated @git.zone/tsbuild from ^2.1.80 to ^2.1.84
- Updated @push.rocks/tapbundle from ^5.0.23 to ^5.0.24
- Updated @types/node from ^20.14.6 to ^22.5.0
- Updated @apiclient.xyz/docker from ^1.2.2 to ^1.2.3
- Updated @design.estate/dees-catalog from ^1.0.289 to ^1.1.6
- Updated @design.estate/dees-element from ^2.0.34 to ^2.0.36
- Updated @git.zone/tsrun from ^1.2.37 to ^1.2.49
- Updated @push.rocks/smartbucket from ^3.0.20 to ^3.0.22
- Updated @push.rocks/smartpromise from ^4.0.3 to ^4.0.4
- Updated @serve.zone/interfaces from ^1.0.74 to ^1.0.78
- Updated @tsclass/tsclass from ^4.0.60 to ^4.1.2
## 2024-06-20 - 1.1.6 - Updates
Routine updates and fixes.
- (fix) core: update
## 2024-06-13 - 1.1.4 - Service Management Preparation
Incorporated updates and service management preparations.
- (fix) core: update
- (feat) prepare service management
## 2024-06-05 - 1.1.3 - CI Integration Improvement
Structural improvements and better CI integration preparation.
- (fix) structure: improve structure, prepare better CI integration
## 2024-06-02 - 1.1.2 - Image Manager Update
Prepared proper storage and retrieval of container images.
- (fix) imagemanager: prepare proper storage and retrieval of container images
## 2024-06-01 - 1.1.0 - Image Registry Work
Initiated work on image registry.
- (fix) image registry: start work on image registry
## 2024-05-30 - 1.0.216 - Enhanced Smartguards
Enhanced smartguards to verify action authorization.
- (feat) guards: use better smartguards to verify action authorization
## 2024-05-28 - 1.0.215 - Unified Package Update
Updated package unification for cloudly + API + CLI.
- (fix) switch to unified package for cloudly + API + CLI: update
## 2024-05-05 - 1.0.214 - Core Updates
Routine core updates.
- (fix) core: update
## 2024-04-20 - 1.0.213 - Core Update
Routine core updates.
- (fix) core: update
+5
View File
@@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Cloudly">
<rect width="64" height="64" rx="14" fill="#050505"/>
<path d="M19 40h27a9 9 0 0 0 1.5-17.9A14 14 0 0 0 20.2 17 11.5 11.5 0 0 0 19 40Z" fill="none" stroke="#ffffff" stroke-width="4" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M23 32h18" stroke="#7dd3fc" stroke-width="4" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 416 B

+1 -2
View File
@@ -14,7 +14,7 @@
<!--Lets make sure we recognize this as an PWA-->
<link rel="manifest" href="/manifest.json" />
<link rel="icon" type="image/png" href="/assetbroker/manifest/favicon.png" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<!--Lets load standard fonts-->
<link rel="preconnect" href="https://assetbroker.lossless.one/" crossorigin>
@@ -114,7 +114,6 @@
} else {
window.revenueEnabled = false;
}
console.log(`revenue enabled: ${window.revenueEnabled}`);
};
runRevenueCheck();
+18
View File
@@ -0,0 +1,18 @@
{
"name": "Cloudly",
"short_name": "Cloudly",
"description": "Cloudly infrastructure management dashboard",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#000000",
"icons": [
{
"src": "/favicon.svg",
"sizes": "any",
"type": "image/svg+xml",
"purpose": "any maskable"
}
]
}
+19
View File
@@ -0,0 +1,19 @@
Copyright (c) 2014 Task Venture Capital GmbH (hello@task.vc)
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-45
View File
@@ -1,45 +0,0 @@
{
"npmci": {
"npmGlobalTools": [],
"npmAccessLevel": "public",
"npmRegistryUrl": "verdaccio.lossless.one",
"dockerRegistryRepoMap": {
"registry.gitlab.com": "losslessone/services/servezone/cloudly"
},
"dockerBuildargEnvMap": {
"NPMCI_TOKEN_NPM2": "NPMCI_TOKEN_NPM2"
}
},
"gitzone": {
"projectType": "service",
"module": {
"githost": "gitlab.com",
"gitscope": "servezone/private",
"gitrepo": "cloudly",
"description": "A cloud manager leveraging Docker Swarmkit for multi-cloud operations including DigitalOcean, Hetzner Cloud, and Cloudflare, with integration support and robust configuration management system.",
"npmPackagename": "@serve.zone/cloudly",
"license": "UNLICENSED",
"keywords": [
"cloud management",
"Docker Swarmkit",
"multi-cloud",
"DigitalOcean",
"Hetzner Cloud",
"Cloudflare",
"container orchestration",
"TypeScript",
"node.js",
"infrastructure automation",
"Cloudron",
"configuration management",
"SSL management",
"APIs",
"devOps",
"cloud integration"
]
}
},
"tsdoc": {
"legal": "\n## License and Legal Information\n\nThis repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository. \n\n**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.\n\n### Trademarks\n\nThis project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.\n\n### Company Information\n\nTask Venture Capital GmbH \nRegistered at District court Bremen HRB 35230 HB, Germany\n\nFor any legal inquiries or if you require further information, please contact us via email at hello@task.vc.\n\nBy using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.\n"
}
}
+93 -66
View File
@@ -1,83 +1,99 @@
{
"name": "@serve.zone/cloudly",
"version": "1.1.0",
"private": false,
"description": "A cloud manager leveraging Docker Swarmkit for multi-cloud operations including DigitalOcean, Hetzner Cloud, and Cloudflare, with integration support and robust configuration management system.",
"version": "6.4.3",
"private": true,
"description": "A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.",
"type": "module",
"exports": {
".": "./dist/index.js",
"./apiclient": "./dist_apiclient/index.js",
"./cliclient": "./dist_cliclient/index.js",
"./web": "./dist_web/index.js"
".": "./dist_ts/index.js",
"./cliclient": "./dist_ts_cliclient/index.js",
"./web": "./dist_ts_web/index.js"
},
"author": "Task Venture Capital GmbH",
"license": "MIT",
"scripts": {
"test": "(tstest test/)",
"build": "tsbuild tsfolders --web --allowimplicitany && tsbundle website --production",
"test": "(tstest test/ --verbose --logfile --timeout 120)",
"build": "tsbuild tsfolders && tsbundle",
"build:docker": "tsdocker build --verbose",
"start": "node cli.js",
"startTs": "node cli.ts.js",
"watch": "tswatch website",
"localPublish": "gitzone commit"
"watch": "tswatch",
"release:docker": "tsdocker push --verbose",
"publish": "tspublish",
"docs": "tsdoc aidoc"
},
"devDependencies": {
"@git.zone/tsbuild": "^2.1.80",
"@git.zone/tsbundle": "^2.0.15",
"@git.zone/tstest": "^1.0.90",
"@git.zone/tswatch": "^2.0.23",
"@push.rocks/tapbundle": "^5.0.23",
"@types/node": "^20.12.13"
"@git.zone/tsbuild": "^4.4.2",
"@git.zone/tsbundle": "^2.10.4",
"@git.zone/tsdoc": "^2.0.5",
"@git.zone/tsdocker": "^2.3.0",
"@git.zone/tspublish": "^1.11.7",
"@git.zone/tstest": "^3.6.6",
"@git.zone/tswatch": "^3.3.5",
"@push.rocks/smartnetwork": "^4.7.1",
"@types/node": "^25.9.1"
},
"dependencies": {
"@api.global/typedrequest": "3.0.28",
"@api.global/typedserver": "^3.0.50",
"@api.global/typedsocket": "^3.0.1",
"@apiclient.xyz/cloudflare": "^6.0.1",
"@apiclient.xyz/digitalocean": "^1.0.5",
"@apiclient.xyz/hetznercloud": "^1.0.18",
"@api.global/typedrequest": "3.3.2",
"@api.global/typedrequest-interfaces": "^3.0.19",
"@api.global/typedserver": "^8.4.6",
"@api.global/typedsocket": "^4.1.3",
"@apiclient.xyz/cloudflare": "^7.1.0",
"@apiclient.xyz/docker": "^5.1.4",
"@apiclient.xyz/hetznercloud": "^1.2.1",
"@apiclient.xyz/slack": "^3.0.9",
"@design.estate/dees-catalog": "^1.0.289",
"@design.estate/dees-domtools": "^2.0.57",
"@design.estate/dees-element": "^2.0.34",
"@git.zone/tsrun": "^1.2.37",
"@push.rocks/early": "^4.0.3",
"@push.rocks/npmextra": "^5.0.13",
"@push.rocks/projectinfo": "^5.0.1",
"@push.rocks/qenv": "^6.0.5",
"@push.rocks/smartacme": "^4.0.8",
"@push.rocks/smartbucket": "^3.0.9",
"@push.rocks/smartcli": "^4.0.11",
"@push.rocks/smartdata": "^5.2.1",
"@push.rocks/smartdelay": "^3.0.5",
"@push.rocks/smartexit": "^1.0.23",
"@push.rocks/smartfile": "^11.0.15",
"@push.rocks/smartguard": "^3.0.2",
"@push.rocks/smartjson": "^5.0.19",
"@push.rocks/smartjwt": "^2.0.4",
"@push.rocks/smartlog": "^3.0.6",
"@push.rocks/smartlog-destination-clickhouse": "^1.0.11",
"@push.rocks/smartpath": "^5.0.18",
"@push.rocks/smartpromise": "^4.0.3",
"@push.rocks/smartrequest": "^2.0.22",
"@push.rocks/smartrx": "^3.0.7",
"@push.rocks/smartssh": "^2.0.1",
"@push.rocks/smartstring": "^4.0.15",
"@design.estate/dees-catalog": "^3.81.0",
"@design.estate/dees-domtools": "^2.5.6",
"@design.estate/dees-element": "^2.2.4",
"@git.zone/tsrun": "^2.0.4",
"@push.rocks/early": "^4.0.4",
"@push.rocks/projectinfo": "^5.1.0",
"@push.rocks/qenv": "^6.1.4",
"@push.rocks/smartacme": "^9.5.0",
"@push.rocks/smartbucket": "^4.6.1",
"@push.rocks/smartcli": "^4.0.21",
"@push.rocks/smartclickhouse": "^2.2.1",
"@push.rocks/smartconfig": "^6.1.1",
"@push.rocks/smartdata": "^7.1.7",
"@push.rocks/smartdelay": "^3.1.0",
"@push.rocks/smartexit": "^2.0.3",
"@push.rocks/smartexpect": "^2.5.0",
"@push.rocks/smartfile": "^13.1.3",
"@push.rocks/smartguard": "^3.1.0",
"@push.rocks/smartjson": "^6.0.1",
"@push.rocks/smartjwt": "^2.2.2",
"@push.rocks/smartlog": "^3.2.2",
"@push.rocks/smartlog-destination-clickhouse": "^1.0.13",
"@push.rocks/smartlog-interfaces": "^3.0.2",
"@push.rocks/smartpath": "^6.0.0",
"@push.rocks/smartpromise": "^4.2.4",
"@push.rocks/smartregistry": "^2.9.2",
"@push.rocks/smartrequest": "^5.0.3",
"@push.rocks/smartrx": "^3.0.10",
"@push.rocks/smartsamba": "^0.2.0",
"@push.rocks/smartssh": "^2.1.0",
"@push.rocks/smartstate": "^2.3.1",
"@push.rocks/smartstream": "^3.4.2",
"@push.rocks/smartstring": "^4.1.1",
"@push.rocks/smartunique": "^3.0.9",
"@push.rocks/taskbuffer": "^3.0.2",
"@push.rocks/webjwt": "^1.0.9",
"@serve.zone/interfaces": "^1.0.56",
"@tsclass/tsclass": "^4.0.54"
"@push.rocks/taskbuffer": "^8.0.2",
"@push.rocks/webjwt": "^1.0.10",
"@serve.zone/api": "^5.3.9",
"@serve.zone/appstore": "^0.2.3",
"@serve.zone/interfaces": "^6.2.1",
"@tsclass/tsclass": "^9.5.1"
},
"files": [
"ts/**/*",
"ts_cliclient/**/*",
"ts_web/**/*",
"dist/**/*",
"dist_*/**/*",
"dist_serve/**/*",
"dist_ts/**/*",
"dist_ts_cliclient/**/*",
"dist_ts_web/**/*",
"assets/**/*",
"cli.js",
"npmextra.json",
".smartconfig.json",
"readme.md"
],
"browserslist": [
@@ -92,21 +108,32 @@
},
"homepage": "https://gitlab.com/servezone/private/cloudly#readme",
"keywords": [
"cloud management",
"multi-cloud management",
"Docker Swarmkit",
"multi-cloud",
"container orchestration",
"cloud services",
"API integration",
"web interface",
"CLI",
"CI/CD integration",
"cloud providers",
"DigitalOcean",
"Hetzner Cloud",
"Cloudflare",
"container orchestration",
"TypeScript",
"node.js",
"Node.js",
"infrastructure automation",
"Cloudron",
"configuration management",
"SSL management",
"APIs",
"devOps",
"cloud integration"
]
"secret management",
"configuration management",
"task scheduling",
"logging",
"SSL management",
"system logging",
"cloud API client",
"frontend",
"backend",
"security"
],
"packageManager": "pnpm@11.2.2"
}
+7162 -7377
View File
File diff suppressed because it is too large Load Diff
+14
View File
@@ -0,0 +1,14 @@
minimumReleaseAgeExclude:
- '@api.global/typedrequest'
- '@serve.zone/api'
- '@serve.zone/appstore'
- '@serve.zone/interfaces'
allowBuilds:
'@design.estate/dees-catalog': false
cpu-features: true
esbuild: true
mongodb-memory-server: false
puppeteer: false
sharp: false
ssh2: true
+19
View File
@@ -0,0 +1,19 @@
- This repository contains 4 projects around serve.zone
- the cloudly backend under ts/*
- the cloudly frontend under ts_web/*
- the api client under ts_apiclient
- the cli client under ts_cliclient
- the easiest method to spawn up a cloudly instance is to use the docker image:
`code.foss.global/serve.zone/cloudly:latest`
- Note: the exports are defined in the package.json.
- For now, cloud wise only the setup with cloudron and hetzner cloud is supported.
## Architecture Overview
- serve.zone is a monorepo containing multiple packages that work together to provide a complete container orchestration platform
- Uses Docker Swarm as the underlying container orchestration technology
- cloudly acts as the control plane providing API, web UI, and CLI interfaces
- coreflow runs inside Docker Swarm clusters to manage containers
- coretraffic runs on each node to handle traffic routing and SSL
- spark manages individual servers at the OS level
+265 -348
View File
@@ -1,406 +1,323 @@
# @serve.zone/cloudly
A cloud manager utilizing Docker Swarmkit, designed for operations on Cloudron, and supports various cloud platforms like DigitalOcean, Hetzner Cloud, and Cloudflare.
## Install
To install `@serve.zone/cloudly`, run the following command in your terminal:
```bash
npm install @serve.zone/cloudly --save
Cloudly is the serve.zone control plane: a TypeScript service and browser dashboard that stores desired infrastructure state, authenticates humans and machines, coordinates clusters, serves an OCI registry, manages workload metadata, and pushes runtime configuration to connected node components.
## Issue Reporting and Security
For reporting bugs, issues, or security vulnerabilities, please visit [community.foss.global/](https://community.foss.global/). This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a [code.foss.global/](https://code.foss.global/) account to submit Pull Requests directly.
## Why It Exists
Cloudly is the place where serve.zone operators describe what should run. It does not directly run every workload itself. Instead, it keeps the authoritative desired state in MongoDB and exposes TypedRequest/TypedSocket APIs so runtime components can reconcile that state where the containers actually live.
The current runtime pattern is reverse-connect:
```text
browser / CLI / SDK
-> Cloudly HTTP + TypedSocket API
-> MongoDB-backed managers
-> S3-backed image and artifact storage
<- Coreflow cluster agents connect outward
-> Docker Swarm reconciliation
-> Coretraffic routing updates
-> Corestore platform resources and backups
```
This will install the package and add it to your project's `package.json` dependencies.
## Usage
`@serve.zone/cloudly` is designed to help you manage and configure cloud environments. This package provides a comprehensive TypeScript and ESM-based interface for interacting with various cloud services, including Docker Swarmkit cluster management, and integration with cloud providers such as DigitalOcean, Hetzner Cloud, and Cloudflare.
## What Cloudly Manages
### Getting Started
Before diving into the specifics, ensure your environment is properly set up. This includes having Node.js installed (preferably the latest LTS version), and if you are working in a TypeScript project, ensure TypeScript is configured.
Cloudly currently coordinates these areas:
#### Initializing Cloudly
First, import `Cloudly` class from the package and initialize it as shown below:
- **Authentication and identity**: human admin login, JWT identities, machine tokens, and cluster identities.
- **Clusters**: desired cluster records and machine users used by Coreflow to authenticate back to Cloudly.
- **Services**: workload definitions, image references, domains, ports, scale factors, secret bundles, volumes, and deployment metadata.
- **Deployments**: deployment records, node placement metadata, health/resource fields, restart/scale API stubs, and DNS activation/deactivation hooks.
- **Images and registries**: image metadata, S3-backed image storage, external registry records, and an embedded OCI registry mounted at `/v2`.
- **Secrets**: secret groups and bundles that Coreflow flattens into Docker secrets for workloads.
- **Domains and DNS**: domain records, DNS entries, and optional domain sync from a dcrouter external gateway.
- **Platform bindings**: capabilities such as `database`, `objectstorage`, `logging`, `backup`, and RPC-style platform services that Coreflow/Corestore can reconcile.
- **Backups**: backup records, service backup/restore requests, scheduled backup tasks, and archive replication handshakes with Coreflow/Corestore.
- **BaseOS**: managed BaseOS node registration, heartbeat handling, desired-state response, image build tracking, and image download URLs.
- **CoreBuild workers**: selection of external build workers for BaseOS ISO and balena raw-image artifact generation.
- **Tasks**: TaskBuffer-backed operational tasks with execution history, metrics, logs, manual triggers, cancellation, and cron schedules.
- **Node and bare-metal inventory**: Hetzner-backed node creation paths and bare-metal metadata records where configured.
- **Dashboard**: a web component UI rendered from `ts_web` with views for overview, settings, secrets, clusters, external registries, images, services, deployments, tasks, domains, DNS, mail/log/storage/database shells, backups, and BaseOS.
```typescript
## Runtime Components
| Component | Role |
| --- | --- |
| `Cloudly` | Main service coordinator. Creates connectors and managers, then starts the API server. |
| `CloudlyServer` | TypedServer/TypedSocket HTTP server, dashboard static server, OCI registry HTTP bridge, and BaseOS HTTP endpoints. |
| `MongodbConnector` | SmartData persistence layer for Cloudly records. |
| `CloudflareConnector` | Optional Cloudflare account used by ACME DNS-01 when `cloudflareToken` is configured in settings. |
| `LetsencryptConnector` | SmartACME certificate issuance and certificate lookup. |
| `CloudlyCoreflowManager` | Authenticates Coreflow, returns cluster config payloads, and pushes config updates to connected Coreflow clients. |
| `CloudlyJumpManager` | Creates short-lived Jump Codes for onboarding existing systems into clusters. |
| `CloudlyRegistryManager` | Embedded OCI registry backed by configured S3 storage, including deploy-on-push metadata updates. |
| `CloudlyBaseOsManager` | BaseOS registration, heartbeat, image build orchestration, worker selection, and artifact downloads. |
| `CloudlyBackupManager` | Service backup/restore orchestration and remote archive object replication. |
| `CloudlyTaskManager` | Registers predefined and runtime tasks, tracks task executions, schedules cron jobs, and exposes task APIs. |
| `CloudlySettingsManager` | Stores runtime settings in MongoDB, masks sensitive values for API responses, and refreshes gateway/Coreflow state after relevant changes. |
## Configuration
Cloudly uses `@push.rocks/smartconfig` `AppData` with environment mappings. The runtime entry point loads `.nogit`/environment values through `@push.rocks/qenv`, and embedded callers can override values by constructing `new Cloudly(config)` programmatically.
Required runtime configuration:
| Variable | Purpose |
| --- | --- |
| `SERVEZONE_ENVIRONMENT` | ACME/runtime environment, currently `production` or `integration`. |
| `SERVEZONE_URL` | Public Cloudly hostname without protocol. |
| `SERVEZONE_PORT` | Public API/dashboard port as a string. |
| `SERVEZONE_SSLMODE` | `none`, `external`, or `letsencrypt`. |
| `SERVEZONE_ADMINACCOUNT` | First-run admin bootstrap in `username:password` format. |
| `MONGODB_URL` | MongoDB connection URL used by SmartData. |
| `MONGODB_NAME` | MongoDB database name. |
| `MONGODB_USER` | MongoDB username. |
| `MONGODB_PASS` | MongoDB password. |
| `S3_ENDPOINT` | S3-compatible endpoint for registry, images, and artifacts. |
| `S3_ACCESSKEY` | S3 access key. |
| `S3_SECRETKEY` | S3 secret key. |
| `S3_BUCKET` | S3 bucket name. |
| `S3_PORT` | S3 endpoint port. |
| `S3_USESSL` | Boolean SSL flag for the S3 endpoint. |
Common optional settings are stored through the Cloudly settings manager rather than direct environment variables:
| Setting | Purpose |
| --- | --- |
| `cloudflareToken` | Enables Cloudflare-backed ACME DNS-01 challenges. |
| `hetznerToken` | Enables Hetzner node and bare-metal provisioning paths. |
| `baseosJoinToken` | Allows BaseOS devices to enroll without a one-time image provisioning token. |
| `corebuildWorkersJson` | JSON array of CoreBuild worker URLs or `{ "url", "token", "id" }` objects. |
| `corebuildWorkerUrl` / `corebuildWorkerToken` | Legacy single-worker CoreBuild settings. |
| `dcrouterGatewayUrl` / `dcrouterGatewayApiToken` | Optional external gateway integration for domain and route sync. |
| `dcrouterWorkHosterId` | Optional stable external gateway work hoster ID; defaults to the cluster ID. |
| `dcrouterTargetHost` / `dcrouterTargetPort` | Optional target address that dcrouter should forward workload traffic to. |
Optional runtime environment variables:
| Variable | Purpose |
| --- | --- |
| `SERVEZONE_INSTALL_DEMO_DATA` | Runs the destructive demo data installer when set to `true`. |
| `CLOUDLY_BACKUP_CRON` | Enables the scheduled `backup-all-services` task with the supplied cron expression. |
| `CLOUDLY_BACKUP_KEEP_LAST` | Number of completed/failed backups to retain per service; defaults to `24`. |
| `CLOUDLY_BACKUP_TARGET_TYPE` | Remote archive replication target, currently `s3` or `smb`. |
| `CLOUDLY_BACKUP_TARGET_PREFIX` | Remote backup path prefix; defaults to `serve.zone-backups`. |
| `CLOUDLY_BASEOS_IMAGE_CLEANUP_INTERVAL_MS` | BaseOS image artifact cleanup interval; defaults to 12 hours. |
For backup replication with `CLOUDLY_BACKUP_TARGET_TYPE=s3`, set `CLOUDLY_BACKUP_S3_ENDPOINT`, `CLOUDLY_BACKUP_S3_ACCESS_KEY`, `CLOUDLY_BACKUP_S3_SECRET_KEY`, and `CLOUDLY_BACKUP_S3_BUCKET`. Optional S3 variables are `CLOUDLY_BACKUP_S3_REGION`, `CLOUDLY_BACKUP_S3_PORT`, and `CLOUDLY_BACKUP_S3_USE_SSL`.
For backup replication with `CLOUDLY_BACKUP_TARGET_TYPE=smb`, set `CLOUDLY_BACKUP_SMB_HOST` and `CLOUDLY_BACKUP_SMB_SHARE`. Optional SMB variables are `CLOUDLY_BACKUP_SMB_PORT`, `CLOUDLY_BACKUP_SMB_USERNAME`, `CLOUDLY_BACKUP_SMB_PASSWORD`, and `CLOUDLY_BACKUP_SMB_DOMAIN`.
## Starting Cloudly
Install and build with pnpm:
```sh
pnpm install
pnpm build
pnpm start
```
Run the TypeScript entry point during development:
```sh
pnpm run startTs
```
Start from code when embedding the control plane in another Node.js process:
```ts
import { Cloudly } from '@serve.zone/cloudly';
const myCloudlyInstance = new Cloudly();
const cloudly = new Cloudly({
environment: 'production',
publicUrl: 'cloudly.example.com',
publicPort: '443',
sslMode: 'external',
servezoneAdminaccount: 'admin:change-me',
mongoDescriptor: {
mongoDbUrl: process.env.MONGODB_URL,
mongoDbName: 'cloudly',
mongoDbUser: process.env.MONGODB_USER,
mongoDbPass: process.env.MONGODB_PASS,
},
s3Descriptor: {
endpoint: process.env.S3_ENDPOINT,
accessKey: process.env.S3_ACCESSKEY,
accessSecret: process.env.S3_SECRETKEY,
bucketName: process.env.S3_BUCKET,
port: process.env.S3_PORT,
useSsl: true,
},
});
await cloudly.start();
```
The `Cloudly` class is the entry point to using the library features. It prepares the environment for configuring the cloud services.
Set `SERVEZONE_INSTALL_DEMO_DATA=true` only when you intentionally want the demo data installer to run. The code labels that path destructive.
#### Configuration
Configuration plays a pivotal role in how `@serve.zone/cloudly` operates. The library expects certain configurations to be provided, which can include credentials for cloud services, database connections, etc.
## API Model
For example, to configure a connection to MongoDB, specify your MongoDB details as shown:
Cloudly exposes a single composed TypedRouter. Managers add their own typed handlers to the main router, and `CloudlyServer` exposes that router through the HTTP/WebSocket server.
```typescript
const myCloudlyConfig = {
mongoDescriptor: {
mongoDbUrl: 'mongodb+srv://<username>:<password>@<cluster>.mongodb.net/myFirstDatabase',
mongoDbName: 'myDatabase',
mongoDbUser: 'myUser',
mongoDbPass: 'myPassword',
},
// Additional configuration values...
};
On first startup, Cloudly bootstraps the first human admin from `SERVEZONE_ADMINACCOUNT`. Human clients authenticate through `adminLoginWithUsernameAndPassword`; machine clients authenticate through `getIdentityByToken`. Cluster creation creates a machine user and token for Coreflow.
const myCloudlyInstance = new Cloudly(myCloudlyConfig);
Typical consumers use `@serve.zone/api`:
```ts
import { CloudlyApiClient } from '@serve.zone/api';
const client = new CloudlyApiClient({
registerAs: 'admin-tool',
cloudlyUrl: 'https://cloudly.example.com',
});
await client.start();
const identity = await client.loginWithUsernameAndPassword('admin', 'change-me');
const clusters = await client.cluster.getClusters();
```
#### Managing Docker Swarmkit Cluster
Cloudly allows managing Docker Swarmkit clusters through an abstracted interface, simplifying operations such as deployment and scaling. Below are examples to demonstrate these capabilities.
Machine clients such as Coreflow authenticate with `getIdentityByToken`, request a stateful identity, and tag their WebSocket connection. That lets Cloudly push configuration to already-connected Coreflow instances instead of opening inbound connections to cluster nodes.
### Example: Start a Cloudly Instance and Add a Cluster
## Cluster Flow
```typescript
import { Cloudly, ClusterManager } from '@serve.zone/cloudly';
The implemented cluster flow is intentionally simple:
async function main() {
const myCloudlyConfig = {
mongoDescriptor: {
mongoDbUrl: 'mongodb+srv://<username>:<password>@<cluster>.mongodb.net/myFirstDatabase',
mongoDbName: 'myDatabase',
mongoDbUser: 'myUser',
mongoDbPass: 'myPassword',
},
cfToken: 'your_cloudflare_api_token',
environment: 'development',
letsEncryptEmail: 'lets_encrypt_email@example.com',
publicUrl: 'example.com',
publicPort: 8443,
hetznerToken: 'your_hetzner_api_token'
};
1. An admin creates a Cloudly cluster record.
2. Cloudly creates a machine user with a long-lived cluster token.
3. Coreflow starts on a Docker Swarm manager node with `CLOUDLY_URL` and `JUMPCODE`.
4. Coreflow authenticates to Cloudly and requests the cluster configuration payload.
5. Cloudly returns cluster data, workload services, platform bindings, provider configs, and optional external gateway configuration.
6. Coreflow reconciles Docker networks, base services, workload services, secrets, volumes, platform bindings, backups, and routing.
const myCloudlyInstance = new Cloudly(myCloudlyConfig);
await myCloudlyInstance.start();
When service, platform, or gateway settings change, Cloudly pushes updated config to connected Coreflow clients where supported.
const clusterManager = myCloudlyInstance.clusterManager;
const newCluster = await clusterManager.storeCluster({
id: 'example_cluster_id',
data: {
name: 'example_cluster',
jumpCode: 'random_jump_code',
jumpCodeUsedAt: null,
secretKey: 'example_secret_key',
acmeInfo: null,
cloudlyUrl: 'https://example.com:8443',
servers: [],
sshKeys: [],
},
});
### Jump Codes for Existing Systems
console.log('Cluster added:', newCluster);
}
main();
Admins can generate a short-lived, single-use Jump Code for a cluster. The dashboard displays a command in this form:
```sh
curl -fsSL 'https://cloudly.example.com/jump/<code>' | sudo bash
```
### Example: Manage Cloudflare DNS Records
The public `/jump/<code>` URL renders a browser landing page for humans and a shell bootstrap script for `curl`/CLI clients. The script installs the required host tooling, claims the code through `POST /jump/v1/claim`, receives the cluster runtime token, and starts Spark in `coreflow-node` mode. The long-lived cluster token is never displayed in the dashboard command.
```typescript
import { Cloudly, CloudflareConnector } from '@serve.zone/cloudly';
Jump Codes expire by default after 30 minutes and are consumed on first successful claim.
async function manageDNSRecords() {
const myCloudlyConfig = {
mongoDescriptor: {
mongoDbUrl: 'mongodb+srv://<username>:<password>@<cluster>.mongodb.net/myFirstDatabase',
mongoDbName: 'myDatabase',
mongoDbUser: 'myUser',
mongoDbPass: 'myPassword',
},
cfToken: 'your_cloudflare_api_token',
environment: 'development',
letsEncryptEmail: 'lets_encrypt_email@example.com',
publicUrl: 'example.com',
publicPort: 8443,
hetznerToken: 'your_hetzner_api_token'
};
## Registry and Deploy-On-Push
const myCloudlyInstance = new Cloudly(myCloudlyConfig);
await myCloudlyInstance.start();
Cloudly serves an OCI registry under `/v2` through `CloudlyRegistryManager`. The registry uses configured S3 storage and issues OCI tokens from Cloudly authentication state.
const dnsInfo = {
zoneName: 'example.com',
recordName: 'sub.example.com',
recordType: 'A',
recordContent: '127.0.0.1',
};
For Cloudly-managed services, `getServiceRegistryTarget()` creates stable registry targets like:
const cfConnector = myCloudlyInstance.cloudflareConnector.cloudflare;
const newRecord = await cfConnector.createDNSRecord(
dnsInfo.zoneName,
dnsInfo.recordName,
dnsInfo.recordType,
dnsInfo.recordContent
);
console.log('DNS Record created:', newRecord);
}
manageDNSRecords();
```text
<cloudly-host>/workloads/<service-name>-<service-id-prefix>:<tag>
```
### Example: Integrate with DigitalOcean
Registry push hooks record tag/digest metadata on the linked image and service. Unless `deployOnPush` is explicitly `false`, a successful push updates the service image version and asks connected Coreflow clients to reconcile.
```typescript
import { Cloudly, DigitalOceanConnector } from '@serve.zone/cloudly';
Registry token requests use HTTP Basic credentials against Cloudly users. User passwords and unexpired user tokens are accepted; push/delete scopes require an admin user or a token with the `admin` assigned role.
async function manageDroplet() {
const myCloudlyConfig = {
mongoDescriptor: {
mongoDbUrl: 'mongodb+srv://<username>:<password>@<cluster>.mongodb.net/myFirstDatabase',
mongoDbName: 'myDatabase',
mongoDbUser: 'myUser',
mongoDbPass: 'myPassword',
},
cfToken: 'your_cloudflare_api_token',
environment: 'development',
letsEncryptEmail: 'lets_encrypt_email@example.com',
publicUrl: 'example.com',
publicPort: 8443,
hetznerToken: 'your_hetzner_api_token'
};
## BaseOS and CoreBuild
const myCloudlyInstance = new Cloudly(myCloudlyConfig);
await myCloudlyInstance.start();
Cloudly can manage BaseOS nodes and image builds:
const doConnector = myCloudlyInstance.digitaloceanConnector;
const dropletInfo = {
name: 'example-droplet',
region: 'nyc3',
size: 's-1vcpu-1gb',
image: 'ubuntu-20-04-x64',
};
- BaseOS devices register through `POST /baseos/v1/nodes/register` and heartbeat through `POST /baseos/v1/nodes/heartbeat`.
- A configured `baseosJoinToken` accepts generic device enrollment.
- BaseOS image builds create one-time provisioning tokens that are embedded in generated images.
- Cloudly selects a CoreBuild worker based on `/corebuild/v1/capabilities` and sends the build to `/corebuild/v1/jobs/baseos-image`.
- Supported build kinds are `ubuntu-iso` and `balena-raw`; Raspberry Pi builds use `balena-raw`.
- Supported architecture values are `amd64`, `arm64`, and `rpi`.
- Completed artifacts are stored in the configured S3 bucket and served through short-lived `/baseos/v1/images/:buildId/download` URLs.
const newDroplet = await doConnector.createDroplet(
dropletInfo.name,
dropletInfo.region,
dropletInfo.size,
dropletInfo.image
);
CoreBuild worker configuration can use `corebuildWorkersJson` for multiple workers or the legacy `corebuildWorkerUrl` and `corebuildWorkerToken` settings for one worker.
console.log('Droplet created:', newDroplet);
}
manageDroplet();
## Backups and Corestore
Cloudly owns backup records and user-facing backup/restore requests. Coreflow executes the cluster-local work, and Corestore snapshots volumes, database resources, object storage resources, and archive objects.
The backup path includes:
- `createServiceBackup` and `restoreServiceBackup` typed requests for admins.
- `executeServiceBackup` and `executeServiceRestore` requests from Cloudly to Coreflow.
- Corestore volume/resource snapshot and restore endpoints behind Coreflow.
- Optional archive replication through `prepareBackupReplication`, `uploadBackupArchiveObject`, `completeBackupReplication`, `getBackupArchiveManifest`, and `downloadBackupArchiveObject`.
- Optional scheduled `backup-all-services` task when `CLOUDLY_BACKUP_CRON` is set.
Manual `createServiceBackup` requests expect Coreflow to complete remote archive replication. Cloudly validates archive object size and SHA-256 checksums, writes a manifest, records target metadata, and marks completed backups as `replicated`. Restores read the manifest and objects back through the configured target writer.
## Task Automation
Cloudly registers a TaskBuffer-backed task manager. The API and dashboard can list tasks, trigger tasks manually, inspect execution logs/metrics, and request cancellation for running tasks.
Predefined tasks currently include:
| Task | Purpose |
| --- | --- |
| `cloudflare-domain-sync` | Imports and updates domains from configured Cloudflare zones. |
| `dns-sync` | Iterates DNS entries marked as external; provider sync is currently a placeholder. |
| `cert-renewal` | Checks activated domains for certificate renewal; renewal logic is currently a placeholder. |
| `cleanup` | Removes old task executions and contains placeholders for log/image cleanup. |
| `health-check` | Iterates deployments and records health metrics; runtime health checks are currently placeholders. |
| `resource-report` | Generates node resource metrics; values are currently placeholders until runtime metrics are wired in. |
| `db-maintenance` | Maintenance shell for database optimization tasks. |
| `security-scan` | Security scan shell for exposed ports, image freshness, and weak configuration checks. |
| `docker-cleanup` | Docker cleanup shell for containers, images, volumes, and networks. |
| `backup-all-services` | Registered by the backup manager and enabled only when `CLOUDLY_BACKUP_CRON` is set. |
## External Gateway Integration
Cloudly can integrate with a dcrouter gateway when the gateway URL and API token are present in settings. The current integration syncs externally available domains into Cloudly and passes an external gateway route configuration to Coreflow. Coreflow can then ask dcrouter for certificates and synchronize public routes while still routing to cluster-local Coretraffic.
## Development
Common commands:
```sh
pnpm install
pnpm build
pnpm test
pnpm run build:docker
pnpm run release:docker
pnpm run docs
```
### Using Cloudly Web Interface
If your project includes a web interface to manage various sections like DNS, deployments, clusters, etc., you can use the provided elements and state management. Below is an example of setting up a dashboard using the components defined:
Important paths:
```typescript
import { commitinfo } from '../00_commitinfo_data.js';
import * as plugins from '../plugins.js';
| Path | Purpose |
| --- | --- |
| `ts/index.ts` | CLI/runtime entry point exporting `runCli`, `Cloudly`, and `ICloudlyConfig`. |
| `ts/classes.cloudly.ts` | Main service coordinator and startup order. |
| `ts/classes.server.ts` | API/dashboard server, registry bridge, and BaseOS HTTP routes. |
| `ts/manager.*` | Domain managers for auth, clusters, services, images, registry, platform, backups, BaseOS, and more. |
| `ts/connector.*` | External system connectors for MongoDB, Cloudflare, and Let's Encrypt. |
| `ts_web/` | Browser dashboard web components. |
| `ts_cliclient/` | Published `@serve.zone/cli` submodule. |
import * as appstate from '../appstate.js';
## Accuracy Notes
import {
DeesElement,
css,
cssManager,
customElement,
html,
state
} from '@design.estate/dees-element';
import { CloudlyViewBackups } from './cloudly-view-backups.js';
import { CloudlyViewClusters } from './cloudly-view-clusters.js';
import { CloudlyViewDbs } from './cloudly-view-dbs.js';
import { CloudlyViewDeployments } from './cloudly-view-deployments.js';
import { CloudlyViewDns } from './cloudly-view-dns.js';
import { CloudlyViewImages } from './cloudly-view-images.js';
import { CloudlyViewLogs } from './cloudly-view-logs.js';
import { CloudlyViewMails } from './cloudly-view-mails.js';
import { CloudlyViewOverview } from './cloudly-view-overview.js';
import { CloudlyViewS3 } from './cloudly-view-s3.js';
import { CloudlyViewSecretBundles } from './cloudly-view-secretbundles.js';
import { CloudlyViewSecretGroups } from './cloudly-view-secretgroups.js';
import { CloudlyViewServices } from './cloudly-view-services.js';
declare global {
interface HTMLElementTagNameMap {
'cvault-dashboard': CloudlyDashboard;
}
}
@customElement('cloudly-dashboard')
export class CloudlyDashboard extends DeesElement {
@state() private jwt: string;
@state() private data: appstate.IDataState = {
secretGroups: [],
secretBundles: [],
clusters: [],
};
constructor() {
super();
const subcription = appstate.dataState
.select((stateArg) => stateArg)
.subscribe((dataArg) => {
this.data = dataArg;
});
this.rxSubscriptions.push(subcription);
}
public static styles = [
cssManager.defaultStyles,
css`
.maincontainer {
position: relative;
width: 100vw;
height: 100vh;
}
h1 {
font-weight: 400;
font-size: 24px;
font-family: 'Cal Sans';
}
`,
];
public render() {
return html`
<div class="maincontainer">
<dees-simple-login name="cloudly v${commitinfo.version}">
<dees-simple-appdash name="cloudly v${commitinfo.version}"
.viewTabs=${[
{
name: 'Overview',
element: CloudlyViewOverview,
},
{
name: 'SecretGroups',
element: CloudlyViewSecretGroups,
},
{
name: 'SecretBundles',
element: CloudlyViewSecretBundles,
},
{
name: 'Clusters',
element: CloudlyViewClusters,
},
{
name: 'Images',
element: CloudlyViewImages,
},
{
name: 'Services',
element: CloudlyViewServices,
},
{
name: 'Deployments',
element: CloudlyViewDeployments,
},
{
name: 'DNS',
element: CloudlyViewDns,
},
{
name: 'Mails',
element: CloudlyViewMails,
},
{
name: 'Logs',
element: CloudlyViewLogs,
},
{
name: 's3',
element: CloudlyViewS3,
},
{
name: 'DBs',
element: CloudlyViewDbs,
},
{
name: 'Backups',
element: CloudlyViewBackups,
},
] as plugins.deesCatalog.IView[]}
></dees-simple-appdash>
</dees-simple-login>
</div>
`;
}
public async firstUpdated() {
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login');
simpleLogin.addEventListener('login', (e: CustomEvent) => {
console.log(e.detail);
this.login(e.detail.data.username, e.detail.data.password);
});
this.addEventListener('contextmenu', (eventArg) => {
plugins.deesCatalog.DeesContextmenu.openContextMenuWithOptions(eventArg, [
{
name: 'About',
iconName: 'mugHot',
action: async () => {
await plugins.deesCatalog.DeesModal.createAndShow({
heading: 'About',
content: html`configvault ${commitinfo.version}`,
menuOptions: [
{
name: 'close',
iconName: null,
action: async (modalArg) => {
await modalArg.destroy();
},
},
],
});
},
},
]);
});
// lets deal with initial state
const domtools = await this.domtoolsPromise;
const loginState = appstate.loginStatePart.getState();
console.log(loginState);
if (loginState.jwt) {
this.jwt = loginState.jwt;
await simpleLogin.switchToSlottedContent();
await appstate.dataState.dispatchAction(appstate.getDataAction, null);
}
}
private async login(username: string, password: string) {
console.log(`attempting to login...`);
const simpleLogin = this.shadowRoot.querySelector('dees-simple-login');
const form = simpleLogin.shadowRoot.querySelector('dees-form');
form.setStatus('pending', 'Logging in...');
const state = await appstate.loginStatePart.dispatchAction(appstate.loginAction, {
username,
password,
});
if (state.jwt) {
console.log('got jwt');
this.jwt = state.jwt;
form.setStatus('success', 'Logged in!');
await simpleLogin.switchToSlottedContent();
await appstate.dataState.dispatchAction(appstate.getDataAction, null);
} else {
form.setStatus('error', 'Login failed!');
await domtools.convenience.smartdelay.delayFor(2000);
form.reset();
}
}
private async logout() {}
}
```
This script sets up a cloud management dashboard for interacting with various cloud services seamlessly. It covers creating clusters, managing DNS records, handling cloud-provider-specific resources, and much more.
With the examples provided above, you should now have a good understanding of how to use `@serve.zone/cloudly` to manage your cloud infrastructure programmatically. For deeper insights and additional features, refer to the documentation relevant to specific modules and methods used in your application.
The package metadata and settings schema include fields for several cloud providers. The code paths currently exercised in this repository are Cloudflare for ACME DNS-01 and domain sync, Hetzner for selected node/bare-metal provisioning paths, S3-compatible storage, SMB/S3 backup archive targets, MongoDB/SmartData, CoreBuild, Coreflow, Corestore, and optional dcrouter integration. Several provider connection tests and predefined tasks are configuration checks or implementation shells; verify provider-specific behavior in the relevant manager before relying on it operationally.
## License and Legal Information
This repository contains open-source code that is licensed under the MIT License. A copy of the MIT License can be found in the [license](license) file within this repository.
This repository contains open-source code licensed under the MIT License. A copy of the license can be found in the [license](./license) file.
**Please note:** The MIT License does not grant permission to use the trade names, trademarks, service marks, or product names of the project, except as required for reasonable and customary use in describing the origin of the work and reproducing the content of the NOTICE file.
### Trademarks
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH and are not included within the scope of the MIT license granted herein. Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines, and any usage must be approved in writing by Task Venture Capital GmbH.
This project is owned and maintained by Task Venture Capital GmbH. The names and logos associated with Task Venture Capital GmbH and any related products or services are trademarks of Task Venture Capital GmbH or third parties, and are not included within the scope of the MIT license granted herein.
Use of these trademarks must comply with Task Venture Capital GmbH's Trademark Guidelines or the guidelines of the respective third-party owners, and any usage must be approved in writing. Third-party trademarks used herein are the property of their respective owners and used only in a descriptive manner, e.g. for an implementation of an API or similar.
### Company Information
Task Venture Capital GmbH
Registered at District court Bremen HRB 35230 HB, Germany
Registered at District Court Bremen HRB 35230 HB, Germany
For any legal inquiries or if you require further information, please contact us via email at hello@task.vc.
For any legal inquiries or further information, please contact us via email at hello@task.vc.
By using this repository, you acknowledge that you have read this section, agree to comply with its terms, and understand that the licensing of the code does not imply endorsement by Task Venture Capital GmbH of any derivative works.
+8 -10
View File
@@ -1,4 +1,4 @@
import { expect, tap } from '@push.rocks/tapbundle';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { Qenv } from '@push.rocks/qenv';
const testQenv = new Qenv('./', './.nogit/');
@@ -12,17 +12,15 @@ let testCloudly: cloudly.Cloudly;
tap.test('first test', async () => {
const cloudlyConfig: cloudly.ICloudlyConfig = {
cfToken: await testQenv.getEnvVarOnDemand('CF_TOKEN'),
environment: 'integration',
letsEncryptEmail: await testQenv.getEnvVarOnDemand('LETSENCRYPT_EMAIL'),
publicUrl: await testQenv.getEnvVarOnDemand('SERVEZONE_URL'),
publicPort: await testQenv.getEnvVarOnDemand('SERVEZONE_PORT'),
letsEncryptEmail: await testQenv.getEnvVarOnDemandStrict('LETSENCRYPT_EMAIL'),
publicUrl: await testQenv.getEnvVarOnDemandStrict('SERVEZONE_URL'),
publicPort: await testQenv.getEnvVarOnDemandStrict('SERVEZONE_PORT'),
mongoDescriptor: {
mongoDbName: await testQenv.getEnvVarOnDemand('MONGODB_DATABASE'),
mongoDbPass: await testQenv.getEnvVarOnDemand('MONGODB_PASSWORD'),
mongoDbUrl: await testQenv.getEnvVarOnDemand('MONGODB_URL')
mongoDbName: await testQenv.getEnvVarOnDemandStrict('MONGODB_DATABASE'),
mongoDbPass: await testQenv.getEnvVarOnDemandStrict('MONGODB_PASSWORD'),
mongoDbUrl: await testQenv.getEnvVarOnDemandStrict('MONGODB_URL')
},
digitalOceanToken: await testQenv.getEnvVarOnDemand('DIGITALOCEAN_TOKEN')
};
testCloudly = new cloudly.Cloudly(cloudlyConfig);
expect(testCloudly).toBeInstanceOf(cloudly.Cloudly);
@@ -36,4 +34,4 @@ tap.test('should end the service', async () => {
await testCloudly.stop();
});
tap.start();
export default tap.start();
+10 -12
View File
@@ -1,10 +1,10 @@
import { expect, tap } from '@push.rocks/tapbundle';
import { expect, tap } from '@git.zone/tstest/tapbundle';
import { Qenv } from '@push.rocks/qenv';
const testQenv = new Qenv('./', './.nogit/');
delete process.env.CLI_CALL;
import * as cloudly from '../ts/index';
import * as cloudly from '../ts/index.js';
process.env.TESTING_CLOUDLY = 'true';
@@ -12,20 +12,18 @@ let testCloudly: cloudly.Cloudly;
tap.test('first test', async () => {
const cloudlyConfig: cloudly.ICloudlyConfig = {
cfToken: testQenv.getEnvVarOnDemand('CF_TOKEN'),
environment: 'integration',
letsEncryptEmail: testQenv.getEnvVarOnDemand('LETSENCRYPT_EMAIL'),
publicUrl: testQenv.getEnvVarOnDemand('SERVEZONE_URL'),
publicPort: testQenv.getEnvVarOnDemand('SERVEZONE_PORT'),
letsEncryptEmail: await testQenv.getEnvVarOnDemandStrict('LETSENCRYPT_EMAIL'),
publicUrl: await testQenv.getEnvVarOnDemandStrict('SERVEZONE_URL'),
publicPort: await testQenv.getEnvVarOnDemandStrict('SERVEZONE_PORT'),
mongoDescriptor: {
mongoDbName: testQenv.getEnvVarOnDemand('MONGODB_DATABASE'),
mongoDbPass: testQenv.getEnvVarOnDemand('MONGODB_PASSWORD'),
mongoDbUrl: testQenv.getEnvVarOnDemand('MONGODB_URL')
mongoDbName: await testQenv.getEnvVarOnDemandStrict('MONGODB_DATABASE'),
mongoDbPass: await testQenv.getEnvVarOnDemandStrict('MONGODB_PASSWORD'),
mongoDbUrl: await testQenv.getEnvVarOnDemandStrict('MONGODB_URL')
},
digitalOceanToken: testQenv.getEnvVarOnDemand('DIGITALOCEAN_TOKEN')
};
testCloudly = new cloudly.Cloudly(cloudlyConfig);
expect(testCloudly).to.be.instanceof(cloudly.Cloudly);
expect(testCloudly).toBeInstanceOf(cloudly.Cloudly);
});
tap.test('should init servezone', async () => {
@@ -36,4 +34,4 @@ tap.test('should end the service', async () => {
await testCloudly.stop();
});
tap.start();
export default tap.start();
+123
View File
@@ -0,0 +1,123 @@
{
"schemaVersion": 1,
"app": {
"id": "cloudly",
"name": "Cloudly",
"description": "Multi-node serve.zone control plane for clusters, workload services, domains, and deployments.",
"category": "Dev Tools",
"iconName": "server",
"tags": ["serve.zone", "control-plane", "clusters", "deployments"],
"maintainer": "serve.zone",
"links": {
"Source": "https://code.foss.global/serve.zone/cloudly",
"Docs": "https://serve.zone"
}
},
"latestVersion": "latest",
"source": {
"type": "dockerImage",
"image": "code.foss.global/serve.zone/cloudly:latest",
"tracking": "digest"
},
"runtime": {
"image": "code.foss.global/serve.zone/cloudly:latest",
"port": 80,
"envVars": [
{
"key": "SERVEZONE_ENVIRONMENT",
"value": "production",
"description": "Cloudly runtime environment.",
"required": true
},
{
"key": "SERVEZONE_URL",
"value": "${SERVICE_DOMAIN}",
"description": "Public Cloudly hostname without protocol.",
"required": true
},
{
"key": "SERVEZONE_PORT",
"value": "80",
"description": "Internal Cloudly HTTP port inside the container.",
"required": true
},
{
"key": "SERVEZONE_SSLMODE",
"value": "external",
"description": "Use external TLS termination through Onebox or dcrouter.",
"required": true
},
{
"key": "MONGODB_URL",
"value": "${MONGODB_URI}",
"description": "Authenticated MongoDB connection URL provisioned by Onebox.",
"required": true
},
{
"key": "MONGODB_NAME",
"value": "${MONGODB_DATABASE}",
"description": "MongoDB database name provisioned by Onebox.",
"required": true
},
{
"key": "MONGODB_USER",
"value": "${MONGODB_USERNAME}",
"description": "MongoDB username provisioned by Onebox.",
"required": true
},
{
"key": "MONGODB_PASS",
"value": "${MONGODB_PASSWORD}",
"description": "MongoDB password provisioned by Onebox.",
"required": true
},
{
"key": "S3_ENDPOINT",
"value": "onebox-minio",
"description": "S3 endpoint host for the MinIO service provisioned by Onebox.",
"required": true
},
{
"key": "S3_PORT",
"value": "9000",
"description": "S3 endpoint port for the MinIO service provisioned by Onebox.",
"required": true
},
{
"key": "S3_USESSL",
"value": "false",
"description": "Use plain HTTP for internal MinIO traffic on the Onebox network.",
"required": true
},
{
"key": "S3_BUCKET",
"value": "${S3_BUCKET}",
"description": "S3 bucket provisioned by Onebox for Cloudly's registry.",
"required": true
},
{
"key": "S3_ACCESSKEY",
"value": "${S3_ACCESS_KEY}",
"description": "S3 access key provisioned by Onebox.",
"required": true
},
{
"key": "S3_SECRETKEY",
"value": "${S3_SECRET_KEY}",
"description": "S3 secret key provisioned by Onebox.",
"required": true
}
],
"platformRequirements": {
"mongodb": true,
"s3": true
},
"minOneboxVersion": "2.2.0",
"backupBeforeUpgrade": true,
"healthCheck": {
"path": "/status",
"port": 80,
"expectedStatus": 200
}
}
}
+81
View File
@@ -0,0 +1,81 @@
import * as fs from 'node:fs/promises';
import * as path from 'node:path';
import { Qenv } from '@push.rocks/qenv';
import { SmartNetwork } from '@push.rocks/smartnetwork';
import { tap } from '@git.zone/tstest/tapbundle';
import { TapNodeTools } from '@git.zone/tstest/tapbundle_serverside';
const tapNodeTools = new TapNodeTools(tap);
const testQenv = new Qenv('./', './.nogit/');
import * as cloudly from '../../ts/index.js';
const stopFunctions: Array<() => Promise<void>> = [];
const getPublicPort = async () => {
if (process.env.SERVEZONE_TEST_PORT) {
return process.env.SERVEZONE_TEST_PORT;
}
const smartnetwork = new SmartNetwork();
const publicPort = await smartnetwork.findFreePort(30000, 40000, { randomize: true });
if (!publicPort) {
throw new Error('Could not find a free Cloudly test port in range 30000-40000');
}
return String(publicPort);
};
const smartmongo = await tapNodeTools.createSmartmongo();
stopFunctions.push(async () => {
await smartmongo.stopAndDumpToDir('./.nogit/mongodump');
});
const smartstorage = await tapNodeTools.createSmartStorage();
await smartstorage.createBucket('cloudly_test_bucket');
stopFunctions.push(async () => {
await smartstorage.stop();
});
export const testCloudlyAdminAccount = {
username: 'testadmin',
password: 'testpassword',
};
export const testCloudlyConfig: cloudly.ICloudlyConfig = {
environment: 'integration',
letsEncryptEmail: 'test@serve.zone',
publicUrl: '127.0.0.1',
publicPort: await getPublicPort(),
mongoDescriptor: await smartmongo.getMongoDescriptor(),
s3Descriptor: await smartstorage.getStorageDescriptor({
bucketName: 'cloudly_test_bucket'
}),
sslMode: 'none',
servezoneAdminaccount: `${testCloudlyAdminAccount.username}:${testCloudlyAdminAccount.password}`,
...(() => {
if (process.env.NPMCI_SECRET01) {
return {
hetznerToken: process.env.NPMCI_SECRET01,
};
}
})(),
};
const alpineImageTarballPath = path.join(process.cwd(), '.nogit/testfiles/alpine.tar');
const existingAlpineImageTarball = await fs.stat(alpineImageTarballPath).catch(() => undefined);
if (!existingAlpineImageTarball?.isFile() || existingAlpineImageTarball.size === 0) {
await tapNodeTools.testFileProvider.getDockerAlpineImageAsLocalTarball();
}
export const createCloudly = async () => {
const cloudlyInstance = new cloudly.Cloudly(testCloudlyConfig);
return cloudlyInstance;
};
export const stopCloudly = async () => {
await Promise.all(stopFunctions.map((stopFunction) => stopFunction()));
};
export const getEnvVarOnDemand = async (envVarName: string) => {
return testQenv.getEnvVarOnDemand(envVarName);
};
+9
View File
@@ -0,0 +1,9 @@
import * as smartstream from '@push.rocks/smartstream';
import * as smartpath from '@push.rocks/smartpath';
export const getAlpineImageReadableStream = async () => {
const currentDir = smartpath.get.dirnameFromImportMetaUrl(import.meta.url);
const imagePath = smartpath.join(currentDir, '../../.nogit/testfiles/alpine.tar');
const readableStream = smartstream.nodewebhelpers.createWebReadableStreamFromFile(imagePath);
return readableStream;
}
+2
View File
@@ -0,0 +1,2 @@
export * from './cloudlyfactory.js';
export * from './docker.js';
+512
View File
@@ -0,0 +1,512 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import * as helpers from './helpers/index.js';
import * as cloudly from '../ts/index.js';
import * as cloudlyApiClient from '@serve.zone/api';
let testCloudly: cloudly.Cloudly;
let testClient: cloudlyApiClient.CloudlyApiClient;
const logErrorDetails = (errorArg: unknown) => {
if (errorArg instanceof Error) {
console.error(` - Error message: ${errorArg.message}`);
console.error(` - Error stack:`, errorArg.stack);
return;
}
console.error(` - Error:`, errorArg);
};
const withParentRuntimeEnvCleared = async <T>(callbackArg: () => Promise<T>): Promise<T> => {
const previousEnv = {
SERVEZONE_RUNTIME_URL: process.env.SERVEZONE_RUNTIME_URL,
SERVEZONE_APP_INSTANCE_ID: process.env.SERVEZONE_APP_INSTANCE_ID,
SERVEZONE_APP_CONTROL_TOKEN: process.env.SERVEZONE_APP_CONTROL_TOKEN,
};
delete process.env.SERVEZONE_RUNTIME_URL;
delete process.env.SERVEZONE_APP_INSTANCE_ID;
delete process.env.SERVEZONE_APP_CONTROL_TOKEN;
try {
return await callbackArg();
} finally {
for (const [key, value] of Object.entries(previousEnv)) {
if (value === undefined) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
}
};
tap.preTask('should start cloudly', async () => {
testCloudly = await helpers.createCloudly();
await testCloudly.start();
});
tap.preTask('should create a new machine user for testing', async () => {
console.log('🔵 PreTask: Creating first machine user...');
const machineUser = new testCloudly.authManager.CUser();
machineUser.id = await testCloudly.authManager.CUser.getNewId();
console.log(` - User ID: ${machineUser.id}`);
machineUser.data = {
type: 'machine',
username: 'test',
password: 'test',
tokens: [{
token: 'test',
expiresAt: Date.now() + 3600 * 1000 * 24 * 365,
assignedRoles: ['admin'],
}],
role: 'admin',
};
console.log(` - Username: ${machineUser.data.username}`);
console.log(` - Role: ${machineUser.data.role}`);
console.log(` - Token: 'test'`);
console.log(` - Token roles: ${machineUser.data.tokens?.[0]?.assignedRoles?.join(', ') ?? ''}`);
await machineUser.save();
console.log('✅ PreTask: First machine user saved successfully');
});
tap.test('should create a new cloudlyApiClient', async () => {
console.log('🔵 Test: Creating CloudlyApiClient...');
testClient = new cloudlyApiClient.CloudlyApiClient({
registerAs: 'api',
cloudlyUrl: `http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}`,
});
console.log(` - URL: http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}`);
await testClient.start();
console.log('✅ CloudlyApiClient started successfully');
expect(testClient).toBeTruthy();
});
tap.test('DEBUG: Check existing users', async () => {
console.log('🔍 DEBUG: Checking existing users in database...');
const allUsers = await testCloudly.authManager.CUser.getInstances({});
console.log(` - Total users found: ${allUsers.length}`);
for (const user of allUsers) {
console.log(` - User: ${user.data.username} (ID: ${user.id})`);
console.log(` - Type: ${user.data.type}`);
console.log(` - Role: ${user.data.role}`);
console.log(` - Tokens: ${user.data.tokens?.length ?? 0}`);
for (const token of user.data.tokens ?? []) {
console.log(` - Token: '${token.token}' | Roles: ${token.assignedRoles?.join(', ')}`);
}
}
});
tap.test('should get an identity', async () => {
console.log('🔵 Test: Getting identity by token...');
console.log(` - Using token: 'test'`);
console.log(` - API URL: http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}`);
try {
const identity = await testClient.getIdentityByToken('test');
console.log('✅ Identity retrieved successfully:');
console.log(` - Identity exists: ${!!identity}`);
if (identity) {
console.log(` - Identity data:`, JSON.stringify(identity, null, 2));
}
expect(identity).toBeTruthy();
} catch (error) {
console.error('❌ Failed to get identity:');
logErrorDetails(error);
throw error;
}
});
tap.test('should report parent hosted upgrade unavailable when not hosted', async () => {
await withParentRuntimeEnvCleared(async () => {
const statusRequest = testClient.typedsocketClient.createTypedRequest<any>('getHostedAppParentUpgradeStatus');
const statusResponse = await statusRequest.fire({ identity: testClient.identity });
expect(statusResponse.isHosted).toBeFalse();
expect(statusResponse.unavailableReason).toEqual('SERVEZONE_RUNTIME_URL is not configured.');
expect(statusResponse.upgradeState.status).toEqual('unknown');
const startRequest = testClient.typedsocketClient.createTypedRequest<any>('startHostedAppParentUpgrade');
const startResponse = await startRequest.fire({
identity: testClient.identity,
targetVersion: '0.0.0-test',
});
expect(startResponse.isHosted).toBeFalse();
expect(startResponse.upgradeState.status).toEqual('unknown');
});
});
tap.test('should create and consume node jump codes', async () => {
const cluster = await testClient.cluster.createCluster('Jump Code Test Cluster');
const createJumpCommandTR = testClient.typedsocketClient.createTypedRequest<any>('createNodeJumpCommand');
const jumpCommand = await createJumpCommandTR.fire({
identity: testClient.identity,
clusterId: cluster.id,
});
expect(jumpCommand.jumpUrl.includes('/jump/')).toBeTrue();
expect(jumpCommand.command.includes(jumpCommand.jumpUrl)).toBeTrue();
const setupResponse = await fetch(jumpCommand.jumpUrl, {
headers: {
accept: '*/*',
'user-agent': 'curl/8.0',
},
});
const setupScript = await setupResponse.text();
expect(setupResponse.status).toEqual(200);
expect(setupScript.includes('spark installdaemon --mode=coreflow-node')).toBeTrue();
const claimResponse = await fetch(
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/jump/v1/claim`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ jumpCode: jumpCommand.jumpCode, hostname: 'jump-code-test-node' }),
},
);
const claimBody = await claimResponse.json();
expect(claimResponse.status).toEqual(200);
expect(claimBody.accepted).toBeTrue();
expect(claimBody.nodeId).toBeTruthy();
expect(claimBody.coreflowJumpCode).toBeTruthy();
const secondClaimResponse = await fetch(
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/jump/v1/claim`,
{
method: 'POST',
headers: {
'content-type': 'application/json',
},
body: JSON.stringify({ jumpCode: jumpCommand.jumpCode }),
},
);
const secondClaimBody = await secondClaimResponse.json();
expect(secondClaimResponse.status).toEqual(400);
expect(secondClaimBody.accepted).toBeFalse();
});
tap.test('should expose the OCI registry endpoint', async () => {
const response = await fetch(
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/v2/`,
);
expect(response.status).toEqual(200);
expect(response.headers.get('docker-distribution-api-version')).toEqual('registry/2.0');
});
tap.test('should require authentication for OCI registry tokens', async () => {
const response = await fetch(
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/v2/token?service=cloudly&scope=repository:test/app:pull`,
);
expect(response.status).toEqual(401);
});
tap.test('should issue OCI registry tokens for the initial admin', async () => {
const credentials = Buffer.from(
`${helpers.testCloudlyAdminAccount.username}:${helpers.testCloudlyAdminAccount.password}`,
).toString('base64');
const response = await fetch(
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/v2/token?service=cloudly&scope=repository:test/app:pull,push`,
{
headers: {
Authorization: `Basic ${credentials}`,
},
},
);
const body = await response.json();
expect(response.status).toEqual(200);
expect(body.token).toBeTruthy();
expect(body.access_token).toEqual(body.token);
});
tap.test('should deny OCI registry push tokens for non-admin users', async () => {
const readonlyUsername = 'registry-readonly';
const readonlyToken = 'registry-readonly-token';
const readonlyUser = new testCloudly.authManager.CUser();
readonlyUser.id = await testCloudly.authManager.CUser.getNewId();
readonlyUser.data = {
type: 'machine',
username: readonlyUsername,
password: readonlyToken,
tokens: [{
token: readonlyToken,
expiresAt: Date.now() + 3600 * 1000,
assignedRoles: [],
}],
role: 'user',
};
await readonlyUser.save();
const credentials = Buffer.from(`${readonlyUsername}:${readonlyToken}`).toString('base64');
const pullResponse = await fetch(
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/v2/token?service=cloudly&scope=repository:test/readonly:pull`,
{
headers: {
Authorization: `Basic ${credentials}`,
},
},
);
expect(pullResponse.status).toEqual(200);
const pushResponse = await fetch(
`http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}/v2/token?service=cloudly&scope=repository:test/readonly:pull,push`,
{
headers: {
Authorization: `Basic ${credentials}`,
},
},
);
expect(pushResponse.status).toEqual(403);
});
tap.test('should expose generated service registry targets', async () => {
const image = await testClient.image.createImage({
name: 'Registry Target Test Image',
description: 'Image used by the registry target test',
});
const service = await testClient.services.createService({
name: 'Registry Target Test Service',
description: 'Service used by the registry target test',
imageId: image.id,
imageVersion: 'latest',
environment: {},
secretBundleId: '',
serviceCategory: 'workload',
deploymentStrategy: 'custom',
scaleFactor: 1,
balancingStrategy: 'round-robin',
ports: {
web: 3000,
},
domains: [],
deploymentIds: [],
});
const registryTarget = await testClient.services.getRegistryTarget(service.id, 'latest');
expect(registryTarget.protocol).toEqual('oci');
expect(registryTarget.registryHost).toEqual(`${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}`);
expect(registryTarget.repository.startsWith('workloads/registry-target-test-service-')).toBeTrue();
expect(registryTarget.repository.split('/').every((partArg) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(partArg))).toBeTrue();
expect(registryTarget.imageUrl).toEqual(`${registryTarget.registryHost}/${registryTarget.repository}:latest`);
const refreshedService = await testClient.services.getServiceById(service.id);
expect(refreshedService.data.registryTarget?.imageUrl).toEqual(registryTarget.imageUrl);
});
tap.test('should trim truncated registry repository suffixes', async () => {
const registryTarget = testCloudly.registryManager.getServiceRegistryTarget({
id: 'service-5gv-123456',
data: {
name: 'Registry Target Test Service',
},
} as any);
expect(registryTarget.repository).toEqual('workloads/registry-target-test-service-service-5gv');
expect(registryTarget.repository.split('/').every((partArg) => /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(partArg))).toBeTrue();
});
tap.test('should push service config updates to connected coreflows', async (toolsArg) => {
const cluster = await testClient.cluster.createCluster('Registry Config Push Test Cluster');
const persistedCluster = await testCloudly.clusterManager.getConfigBy_ConfigID(cluster.id);
const clusterUser = await testCloudly.authManager.CUser.getInstance({
id: persistedCluster.data.userId,
});
const clusterToken = clusterUser.data.tokens?.[0]?.token;
expect(clusterToken).toBeTruthy();
const coreflowClient = new cloudlyApiClient.CloudlyApiClient({
registerAs: 'coreflow',
cloudlyUrl: `http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}`,
});
const configUpdates: any[] = [];
let subscription: { unsubscribe: () => void } | undefined;
try {
await coreflowClient.start();
await coreflowClient.getIdentityByToken(clusterToken!, {
statefullIdentity: true,
tagConnection: true,
});
subscription = coreflowClient.configUpdateSubject.subscribe((updateArg) => {
configUpdates.push(updateArg);
});
const image = await testClient.image.createImage({
name: 'Registry Config Push Test Image',
description: 'Image used by the config push test',
});
const service = await testClient.services.createService({
name: 'Registry Config Push Test Service',
description: 'Service used by the config push test',
imageId: image.id,
imageVersion: 'latest',
environment: {},
secretBundleId: '',
serviceCategory: 'workload',
deploymentStrategy: 'custom',
scaleFactor: 1,
balancingStrategy: 'round-robin',
ports: {
web: 3000,
},
domains: [],
deploymentIds: [],
});
await toolsArg.delayFor(100);
expect(configUpdates[0]?.configData.id).toEqual(cluster.id);
expect(configUpdates[0]?.services.find((serviceArg: any) => serviceArg.id === service.id)).toBeTruthy();
} finally {
subscription?.unsubscribe();
await coreflowClient.stop();
}
});
tap.test('should allow cluster coreflows to read deployment inputs', async () => {
const cluster = await testClient.cluster.createCluster('Registry Coreflow Read Test Cluster');
const persistedCluster = await testCloudly.clusterManager.getConfigBy_ConfigID(cluster.id);
const clusterUser = await testCloudly.authManager.CUser.getInstance({
id: persistedCluster.data.userId,
});
const clusterToken = clusterUser.data.tokens?.[0]?.token;
expect(clusterToken).toBeTruthy();
const image = await testClient.image.createImage({
name: 'Registry Coreflow Read Test Image',
description: 'Image used by the coreflow read test',
});
const secretBundle = await testClient.secretbundle.createSecretBundle({
name: 'Registry Coreflow Read Test Secret Bundle',
description: 'Secret bundle used by the coreflow read test',
type: 'service',
includedSecretGroupIds: [],
includedTags: [],
imageClaims: [],
authorizations: [
{
environment: 'production',
secretAccessKey: 'registry-coreflow-read-test',
},
],
});
const coreflowClient = new cloudlyApiClient.CloudlyApiClient({
registerAs: 'coreflow',
cloudlyUrl: `http://${helpers.testCloudlyConfig.publicUrl}:${helpers.testCloudlyConfig.publicPort}`,
});
try {
await coreflowClient.start();
await coreflowClient.getIdentityByToken(clusterToken!, {
statefullIdentity: true,
tagConnection: true,
});
const clusterImage = await coreflowClient.image.getImageById(image.id);
const clusterSecretBundle = await coreflowClient.secretbundle.getSecretBundleById(secretBundle.id);
expect(clusterImage.id).toEqual(image.id);
expect(clusterSecretBundle.id).toEqual(secretBundle.id);
} finally {
await coreflowClient.stop();
}
});
tap.test('should expose platform desired state', async () => {
const capabilitiesResponse = await testClient.platform.getPlatformCapabilities();
expect(capabilitiesResponse.capabilities.find((capability) => capability.id === 'database')).toBeTruthy();
const desiredState = await testClient.platform.getPlatformDesiredState();
expect(desiredState.capabilities).toBeTruthy();
expect(desiredState.providerConfigs).toBeTruthy();
expect(desiredState.bindings).toBeTruthy();
});
let platformProviderConfigId: string;
let platformBindingId: string;
tap.test('should upsert platform provider config and binding', async () => {
const providerConfigResponse = await testClient.platform.upsertPlatformProviderConfig({
id: '',
capability: 'database',
providerType: 'docker',
name: 'Local Docker Database',
enabled: true,
});
platformProviderConfigId = providerConfigResponse.providerConfig.id;
expect(platformProviderConfigId).toBeTruthy();
const bindingResponse = await testClient.platform.upsertPlatformBinding({
id: '',
serviceId: 'test-service',
capability: 'database',
desiredState: 'enabled',
status: 'requested',
providerConfigId: platformProviderConfigId,
});
platformBindingId = bindingResponse.binding.id;
expect(platformBindingId).toBeTruthy();
const statusResponse = await testClient.platform.updatePlatformBindingStatus({
bindingId: platformBindingId,
status: 'ready',
endpoints: [
{
name: 'primary',
capability: 'database',
protocol: 'mongodb',
internalUrl: 'mongodb://platform-database:27017/test-service',
},
],
});
expect(statusResponse.binding.status).toEqual('ready');
const bindingsResponse = await testClient.platform.getPlatformBindings({
serviceId: 'test-service',
});
expect(bindingsResponse.bindings.find((binding) => binding.id === platformBindingId)).toBeTruthy();
});
let image: any;
tap.test('should create and upload an image', async () => {
console.log('🔵 Test: Creating and uploading image...');
console.log(` - Image name: 'test'`);
console.log(` - Image description: 'test'`);
try {
image = await testClient.image.createImage({
name: 'test',
description: 'test'
});
console.log('✅ Image created successfully:');
console.log(` - Image ID: ${image?.id}`);
console.log(` - Image data:`, image);
expect(image).toBeTruthy();
} catch (error) {
console.error('❌ Failed to create image:');
logErrorDetails(error);
throw error;
}
})
tap.test('should upload an image version', async () => {
console.log('🔵 Test: Uploading image version...');
console.log(` - Version: 'v1.0.0'`);
console.log(` - Image exists: ${!!image}`);
console.log(` - Image ID: ${image?.id}`);
try {
const imageStream = await helpers.getAlpineImageReadableStream();
console.log(' - Image stream obtained successfully');
await image.pushImageVersion('v1.0.0', imageStream);
console.log('✅ Image version uploaded successfully');
} catch (error) {
console.error('❌ Failed to upload image version:');
logErrorDetails(error);
throw error;
}
});
tap.test('should stop the apiclient', async (toolsArg) => {
await testClient.stop();
await testCloudly.stop();
await helpers.stopCloudly();
toolsArg.delayFor(1000).then(() => process.exit());
})
export default tap.start();
+61
View File
@@ -0,0 +1,61 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { CloudlyAppStoreManager } from '../ts/manager.appstore/classes.appstoremanager.js';
const createManager = () => Object.create(CloudlyAppStoreManager.prototype) as any;
tap.test('should preserve service volume overrides during App Store upgrades', async () => {
const manager = createManager();
const volumes = manager.mergeUpgradeVolumes(
[
{ mountPath: '/data', name: 'custom-data', driver: 'local' },
{ mountPath: '/cache', name: 'custom-cache' },
],
[
'/data',
{ mountPath: '/config', readOnly: true },
],
);
expect(volumes).toEqual([
{ mountPath: '/data', name: 'custom-data', driver: 'local' },
{ mountPath: '/config', readOnly: true },
{ mountPath: '/cache', name: 'custom-cache' },
]);
});
tap.test('should preserve service published port overrides during App Store upgrades', async () => {
const manager = createManager();
const publishedPorts = manager.mergeUpgradePublishedPorts(
[
{ targetPort: 5432, publishedPort: 15432, protocol: 'tcp' },
{ targetPort: 9999, publishedPort: 19999 },
],
[
{ targetPort: 5432, publishedPort: 5432 },
{ targetPort: 6379 },
],
);
expect(publishedPorts).toEqual([
{ targetPort: 5432, publishedPort: 15432, protocol: 'tcp' },
{ targetPort: 6379, protocol: 'tcp' },
{ targetPort: 9999, publishedPort: 19999, protocol: 'tcp' },
]);
});
tap.test('should report unsupported App Store published port configs', async () => {
const manager = createManager();
const unsupported = manager.getUnsupportedPublishedPorts([
{ targetPort: 80, publishedPort: 80 },
{ targetPort: 81, publishedPort: 80 },
{ targetPort: 82, publishedPort: 82, hostIp: '127.0.0.1' },
{ targetPort: 9000, targetPortEnd: 9001, publishedPort: 19000, publishedPortEnd: 19002 },
]);
expect(unsupported.some((messageArg: string) => messageArg.includes('duplicates published port 80/tcp'))).toBeTrue();
expect(unsupported.some((messageArg: string) => messageArg.includes('unsupported hostIp'))).toBeTrue();
expect(unsupported.some((messageArg: string) => messageArg.includes('mismatched target and published port ranges'))).toBeTrue();
});
export default tap.start();
+86
View File
@@ -0,0 +1,86 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { CloudlyRegistryManager } from '../ts/manager.registry/classes.registrymanager.js';
const digest = (fillArg: string): string => `sha256:${fillArg.repeat(64)}`;
class FakeRegistryStorage {
public objects = new Map<string, Buffer>();
public async getObject(pathArg: string): Promise<Buffer | null> {
return this.objects.get(pathArg) || null;
}
public async putObject(pathArg: string, dataArg: Buffer): Promise<void> {
this.objects.set(pathArg, dataArg);
}
public async deleteObject(pathArg: string): Promise<void> {
this.objects.delete(pathArg);
}
public async objectExists(pathArg: string): Promise<boolean> {
return this.objects.has(pathArg);
}
public async listObjects(prefixArg: string): Promise<string[]> {
return Array.from(this.objects.keys()).filter((pathArg) => pathArg.startsWith(prefixArg));
}
public async getOciManifest(repositoryArg: string, digestArg: string): Promise<Buffer | null> {
return this.getObject(`oci/manifests/${repositoryArg}/${digestArg.slice('sha256:'.length)}`);
}
}
const putJson = (storageArg: FakeRegistryStorage, pathArg: string, dataArg: unknown): void => {
storageArg.objects.set(pathArg, Buffer.from(JSON.stringify(dataArg)));
};
tap.test('should delete Cloudly service-owned OCI repository without deleting shared blobs', async () => {
const storage = new FakeRegistryStorage();
const manager = Object.create(CloudlyRegistryManager.prototype) as any;
const service = {
id: 'service-1',
data: {
registryTarget: { repository: 'workloads/ghost-service-1' },
},
};
manager.started = true;
manager.smartRegistry = { getStorage: () => storage };
manager.recordedTagDigests = new Map([
['workloads/ghost-service-1:latest', digest('a')],
]);
manager.cloudlyRef = {
serviceManager: {
CService: { getInstances: async () => [service] },
},
};
const targetDigest = digest('a');
const otherDigest = digest('b');
const sharedLayerDigest = digest('c');
const targetOnlyLayerDigest = digest('d');
const otherOnlyLayerDigest = digest('e');
putJson(storage, 'oci/tags/workloads/ghost-service-1/tags.json', { latest: targetDigest });
putJson(storage, 'oci/tags/workloads/other-service/tags.json', { latest: otherDigest });
putJson(storage, `oci/manifests/workloads/ghost-service-1/${targetDigest.slice('sha256:'.length)}`, {
layers: [{ digest: sharedLayerDigest }, { digest: targetOnlyLayerDigest }],
});
putJson(storage, `oci/manifests/workloads/other-service/${otherDigest.slice('sha256:'.length)}`, {
layers: [{ digest: sharedLayerDigest }, { digest: otherOnlyLayerDigest }],
});
for (const blobDigest of [sharedLayerDigest, targetOnlyLayerDigest, otherOnlyLayerDigest]) {
storage.objects.set(`oci/blobs/sha256/${blobDigest.slice('sha256:'.length)}`, Buffer.from(blobDigest));
}
await manager.deleteServiceRepository(service);
expect(storage.objects.has('oci/tags/workloads/ghost-service-1/tags.json')).toBeFalse();
expect(storage.objects.has(`oci/manifests/workloads/ghost-service-1/${targetDigest.slice('sha256:'.length)}`)).toBeFalse();
expect(storage.objects.has(`oci/blobs/sha256/${targetOnlyLayerDigest.slice('sha256:'.length)}`)).toBeFalse();
expect(storage.objects.has(`oci/blobs/sha256/${sharedLayerDigest.slice('sha256:'.length)}`)).toBeTrue();
expect(storage.objects.has(`oci/blobs/sha256/${otherOnlyLayerDigest.slice('sha256:'.length)}`)).toBeTrue();
expect(manager.recordedTagDigests.has('workloads/ghost-service-1:latest')).toBeFalse();
});
export default tap.start();
+137
View File
@@ -0,0 +1,137 @@
import { tap, expect } from '@git.zone/tstest/tapbundle';
import { ServiceManager } from '../ts/manager.service/classes.servicemanager.js';
const createDeleteable = (labelArg: string, callsArg: string[]) => ({
id: labelArg,
delete: async () => callsArg.push(`delete:${labelArg}`),
});
const createManager = (optionsArg: {
calls: string[];
failBackups?: boolean;
}) => {
const calls = optionsArg.calls;
const service = {
id: 'service-1',
data: {
name: 'ghost',
imageId: 'image-1',
appTemplateId: 'ghost',
secretBundleId: 'bundle-1',
registryTarget: { repository: 'workloads/ghost-service-1' },
domains: [{ name: 'ghost.example.com' }],
},
removeDnsEntries: async () => calls.push('delete:dns'),
delete: async () => calls.push('delete:service'),
};
const manager = Object.create(ServiceManager.prototype) as any;
manager.CService = {
getInstance: async () => service,
};
manager.cloudlyRef = {
appStoreManager: {
clearOperationsForService: (serviceIdArg: string) => calls.push(`clear-upgrades:${serviceIdArg}`),
},
deploymentManager: {
CDeployment: {
getInstances: async () => [createDeleteable('deployment-1', calls)],
},
},
platformManager: {
CPlatformBinding: {
getInstances: async (queryArg: { serviceId: string }) => queryArg.serviceId === 'service-1'
? [createDeleteable('platform-binding-1', calls)]
: [],
},
},
backupManager: {
deleteBackupsForService: async (serviceIdArg: string) => {
calls.push(`delete-backups:${serviceIdArg}`);
if (optionsArg.failBackups) {
throw new Error('backup cleanup failed');
}
},
},
registryManager: {
deleteServiceRepository: async () => calls.push('delete:registry-repository'),
},
secretManager: {
CSecretBundle: {
getInstance: async () => ({
id: 'bundle-1',
data: {
serviceId: 'service-1',
includedSecretGroupIds: ['group-1'],
},
delete: async () => calls.push('delete:secret-bundle'),
}),
getInstances: async () => [],
},
CSecretGroup: {
getInstance: async () => createDeleteable('secret-group-1', calls),
},
},
imageManager: {
deleteImageIfUnreferenced: async (imageIdArg: string, serviceIdArg: string) => {
calls.push(`delete-image:${imageIdArg}:${serviceIdArg}`);
return true;
},
},
settingsManager: {
getSettings: async () => ({
dcrouterGatewayUrl: 'https://dcrouter.example.com',
dcrouterGatewayApiToken: 'token',
dcrouterWorkHosterId: 'cloudly-main',
}),
},
clusterManager: {
getAllClusters: async () => [],
},
coreflowManager: {
pushClusterConfigToConnectedCoreflows: async () => calls.push('push:coreflow'),
},
};
manager.fireDcRouterRequest = async (_url: string, methodArg: string, payloadArg: any) => {
calls.push(`${methodArg}:${payloadArg.ownership.hostname}:${payloadArg.ownership.workHosterId}`);
return { success: true, action: 'deleted' };
};
return { manager, service };
};
tap.test('should delete Cloudly service-owned resources before deleting the service row', async () => {
const calls: string[] = [];
const { manager } = createManager({ calls });
await manager.deleteServiceById('service-1');
expect(calls).toContain('syncWorkAppRoute:ghost.example.com:cloudly-main');
expect(calls).toContain('clear-upgrades:service-1');
expect(calls).toContain('delete:deployment-1');
expect(calls).toContain('delete:dns');
expect(calls).toContain('delete:platform-binding-1');
expect(calls).toContain('delete-backups:service-1');
expect(calls).toContain('delete:registry-repository');
expect(calls).toContain('delete:secret-bundle');
expect(calls).toContain('delete:secret-group-1');
expect(calls).toContain('delete-image:image-1:service-1');
expect(calls.at(-2)).toEqual('delete:service');
expect(calls.at(-1)).toEqual('push:coreflow');
});
tap.test('should keep Cloudly service row when required cleanup fails', async () => {
const calls: string[] = [];
const { manager } = createManager({ calls, failBackups: true });
let errorMessage = '';
try {
await manager.deleteServiceById('service-1');
} catch (error) {
errorMessage = (error as Error).message;
}
expect(errorMessage).toEqual('backup cleanup failed');
expect(calls).not.toContain('delete:service');
expect(calls).not.toContain('push:coreflow');
});
export default tap.start();
+7 -23
View File
@@ -1,29 +1,11 @@
import { expect, tap } from '@push.rocks/tapbundle';
import { Qenv } from '@push.rocks/qenv';
const testQenv = new Qenv('./', './.nogit/');
process.env.TESTING_CLOUDLY = 'true';
delete process.env.CLI_CALL;
import { expect, tap } from '@git.zone/tstest/tapbundle';
import * as helpers from './helpers/index.js';
import * as cloudly from '../ts/index.js';
let testCloudly: cloudly.Cloudly;
tap.test('first test', async () => {
const cloudlyConfig: cloudly.ICloudlyConfig = {
cfToken: testQenv.getEnvVarOnDemand('CF_TOKEN'),
environment: 'integration',
letsEncryptEmail: testQenv.getEnvVarOnDemand('LETSENCRYPT_EMAIL'),
publicUrl: testQenv.getEnvVarOnDemand('SERVEZONE_URL'),
publicPort: testQenv.getEnvVarOnDemand('SERVEZONE_PORT'),
mongoDescriptor: {
mongoDbName: testQenv.getEnvVarOnDemand('MONGODB_DATABASE'),
mongoDbUser: testQenv.getEnvVarOnDemand('MONGODB_USER'),
mongoDbPass: testQenv.getEnvVarOnDemand('MONGODB_PASSWORD'),
mongoDbUrl: testQenv.getEnvVarOnDemand('MONGODB_URL'),
},
digitalOceanToken: testQenv.getEnvVarOnDemand('DIGITALOCEAN_TOKEN'),
};
testCloudly = new cloudly.Cloudly(cloudlyConfig);
testCloudly = await helpers.createCloudly();
expect(testCloudly).toBeInstanceOf(cloudly.Cloudly);
});
@@ -32,8 +14,10 @@ tap.test('should init cloudly', async () => {
});
tap.test('should end the service', async (tools) => {
await tools.delayFor(5000);
await helpers.stopCloudly();
await testCloudly.stop();
tools.delayFor(1000).then(() => process.exit())
tools.delayFor(1000).then(() => process.exit());
});
tap.start();
export default tap.start();
+32 -1
View File
@@ -1 +1,32 @@
echo 'hi'
#!/usr/bin/env bash
set -euo pipefail
node --input-type=module <<'NODE'
import fs from 'node:fs';
const readJson = (path) => JSON.parse(fs.readFileSync(path, 'utf8'));
const checks = {
packageVersion: readJson('/app/package.json').version,
interfacesVersion: readJson('/app/node_modules/@serve.zone/interfaces/package.json').version,
apiVersion: readJson('/app/node_modules/@serve.zone/api/package.json').version,
hasDistServe: fs.existsSync('/app/dist_serve/index.html'),
};
await import('/app/dist_ts/index.js');
if (checks.packageVersion !== '5.3.0') {
throw new Error(`Unexpected Cloudly package version ${checks.packageVersion}`);
}
if (checks.interfacesVersion !== '5.4.6') {
throw new Error(`Unexpected interfaces version ${checks.interfacesVersion}`);
}
if (checks.apiVersion !== '5.3.4') {
throw new Error(`Unexpected api version ${checks.apiVersion}`);
}
if (!checks.hasDistServe) {
throw new Error('Missing dist_serve/index.html');
}
console.log(JSON.stringify(checks));
NODE
+3 -3
View File
@@ -1,8 +1,8 @@
/**
* autocreated commitinfo by @pushrocks/commitinfo
* autocreated commitinfo by @push.rocks/commitinfo
*/
export const commitinfo = {
name: '@serve.zone/cloudly',
version: '1.1.0',
description: 'A cloud manager leveraging Docker Swarmkit for multi-cloud operations including DigitalOcean, Hetzner Cloud, and Cloudflare, with integration support and robust configuration management system.'
version: '6.4.3',
description: 'A comprehensive tool for managing containerized applications across multiple cloud providers using Docker Swarmkit, featuring web, CLI, and API interfaces.'
}
+17
View File
@@ -0,0 +1,17 @@
import * as plugins from '../plugins.js';
export const demoImages: plugins.servezoneInterfaces.data.IImage[] = [
{
id: 'DemoImage1',
data: {
name: 'DemoImage1',
description: 'DemoImage1',
location: {
internal: true,
externalRegistryId: '',
externalImageTag: '',
},
versions: [],
}
}
];
@@ -63,6 +63,8 @@ for (let i = 0; i < demoSecretGroups.length; i++) {
id: `configBundleId${i + 1}`,
data: {
name: `Demo Config Bundle ${i + 1}`,
imageClaims: [],
type: 'external',
description: 'Demo Purpose',
includedSecretGroupIds: [secretGroup.id],
includedTags: secretGroup.data.tags,
+20
View File
@@ -0,0 +1,20 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import type { Cloudly } from '../classes.cloudly.js';
export const getUsers = async (cloudlyRef: Cloudly) => {
const users: plugins.servezoneInterfaces.data.IUser[] = [];
const envAdminUser = await cloudlyRef.config.appData.waitForAndGetKey('servezoneAdminaccount');
if (envAdminUser) {
users.push({
id: 'envadmin',
data: {
type: 'human',
username: envAdminUser.split(':')[0],
password: envAdminUser.split(':')[1],
role: 'admin',
},
});
}
return users;
};
+15 -1
View File
@@ -43,9 +43,23 @@ export const installDemoData = async (cloudlyRef: Cloudly) => {
}
const demoDataUsers = await import('./demo.data.users.js');
for (const user of demoDataUsers.users) {
for (const user of await demoDataUsers.getUsers(cloudlyRef)) {
const userInstance = new cloudlyRef.authManager.CUser();
Object.assign(userInstance, user);
await userInstance.save();
}
// ================================================================================
// IMAGES
const images = await cloudlyRef.imageManager.CImage.getInstances({});
for (const image of images) {
await image.delete();
}
const demoDataImages = await import('./demo.data.images.js');
for (const image of demoDataImages.demoImages) {
const imageInstance = new cloudlyRef.imageManager.CImage();
Object.assign(imageInstance, image);
await imageInstance.save();
}
}
+92 -16
View File
@@ -15,14 +15,28 @@ import { MongodbConnector } from './connector.mongodb/connector.js';
// processes
import { CloudlyCoreflowManager } from './manager.coreflow/coreflowmanager.js';
import { ClusterManager } from './manager.cluster/clustermanager.js';
import { CloudlyTaskmanager } from './manager.task/taskmanager.js';
import { CloudlySecretManager } from './manager.secret/classes.secretmanager.js'
import { CloudlyServerManager } from './manager.server/servermanager.js';
import { ClusterManager } from './manager.cluster/classes.clustermanager.js';
import { CloudlyTaskManager } from './manager.task/classes.taskmanager.js';
import { CloudlySecretManager } from './manager.secret/classes.secretmanager.js';
import { CloudlyNodeManager } from './manager.node/classes.nodemanager.js';
import { CloudlyBaremetalManager } from './manager.baremetal/classes.baremetalmanager.js';
import { ExternalApiManager } from './manager.status/statusmanager.js';
import { ExternalRegistryManager } from './manager.externalregistry/index.js';
import { CloudlyRegistryManager } from './manager.registry/index.js';
import { ImageManager } from './manager.image/classes.imagemanager.js';
import { ServiceManager } from './manager.service/classes.servicemanager.js';
import { DeploymentManager } from './manager.deployment/classes.deploymentmanager.js';
import { DnsManager } from './manager.dns/classes.dnsmanager.js';
import { DomainManager } from './manager.domain/classes.domainmanager.js';
import { logger } from './logger.js';
import { CloudlyAuthManager } from './manager.auth/classes.authmanager.js';
import { CloudlySettingsManager } from './manager.settings/classes.settingsmanager.js';
import { CloudlyPlatformManager } from './manager.platform/classes.platformmanager.js';
import { CloudlyBackupManager } from './manager.backup/classes.backupmanager.js';
import { CloudlyBaseOsManager } from './manager.baseos/classes.baseosmanager.js';
import { CloudlyAppStoreManager } from './manager.appstore/classes.appstoremanager.js';
import { CloudlyHostedAppManager } from './manager.hostedapp/classes.hostedappmanager.js';
import { CloudlyJumpManager } from './manager.jump/classes.jumpmanager.js';
/**
* Cloudly class can be used to instantiate a cloudly server.
@@ -51,16 +65,32 @@ export class Cloudly {
// managers
public authManager: CloudlyAuthManager;
public secretManager: CloudlySecretManager;
public settingsManager: CloudlySettingsManager;
public platformManager: CloudlyPlatformManager;
public clusterManager: ClusterManager;
public coreflowManager: CloudlyCoreflowManager;
public externalApiManager: ExternalApiManager;
public externalRegistryManager: ExternalRegistryManager;
public registryManager: CloudlyRegistryManager;
public imageManager: ImageManager;
public taskManager: CloudlyTaskmanager;
public serverManager: CloudlyServerManager;
public serviceManager: ServiceManager;
public deploymentManager: DeploymentManager;
public dnsManager: DnsManager;
public domainManager: DomainManager;
public taskManager: CloudlyTaskManager;
public backupManager: CloudlyBackupManager;
public nodeManager: CloudlyNodeManager;
public baremetalManager: CloudlyBaremetalManager;
public baseOsManager: CloudlyBaseOsManager;
public appStoreManager: CloudlyAppStoreManager;
public hostedAppManager: CloudlyHostedAppManager;
public jumpManager: CloudlyJumpManager;
private readyDeferred = new plugins.smartpromise.Deferred();
constructor() {
private configOptions?: plugins.servezoneInterfaces.data.ICloudlyConfig;
constructor(configArg?: plugins.servezoneInterfaces.data.ICloudlyConfig) {
this.configOptions = configArg;
this.cloudlyInfo = new CloudlyInfo(this);
this.config = new CloudlyConfig(this);
@@ -75,13 +105,27 @@ export class Cloudly {
// managers
this.authManager = new CloudlyAuthManager(this);
this.settingsManager = new CloudlySettingsManager(this);
this.platformManager = new CloudlyPlatformManager(this);
this.clusterManager = new ClusterManager(this);
this.coreflowManager = new CloudlyCoreflowManager(this);
this.externalApiManager = new ExternalApiManager(this);
this.externalRegistryManager = new ExternalRegistryManager(this);
this.registryManager = new CloudlyRegistryManager(this);
this.imageManager = new ImageManager(this);
this.taskManager = new CloudlyTaskmanager(this);
this.serviceManager = new ServiceManager(this);
this.deploymentManager = new DeploymentManager(this);
this.dnsManager = new DnsManager(this);
this.domainManager = new DomainManager(this);
this.taskManager = new CloudlyTaskManager(this);
this.backupManager = new CloudlyBackupManager(this);
this.baseOsManager = new CloudlyBaseOsManager(this);
this.secretManager = new CloudlySecretManager(this);
this.serverManager = new CloudlyServerManager(this);
this.hostedAppManager = new CloudlyHostedAppManager(this);
this.appStoreManager = new CloudlyAppStoreManager(this);
this.nodeManager = new CloudlyNodeManager(this);
this.baremetalManager = new CloudlyBaremetalManager(this);
this.jumpManager = new CloudlyJumpManager(this);
}
/**
@@ -90,23 +134,42 @@ export class Cloudly {
*/
public async start() {
// config
await this.config.init();
await this.config.init(this.configOptions);
// database (data comes from config)
await this.mongodbConnector.init();
// settings (are stored in db)
await this.settingsManager.init();
// manageers
await this.authManager.start();
await this.secretManager.start();
await this.serverManager.start();
// connectors
await this.mongodbConnector.init();
await this.nodeManager.start();
await this.baremetalManager.start();
await this.jumpManager.start();
await this.serviceManager.start();
await this.platformManager.start();
await this.deploymentManager.start();
await this.taskManager.init();
await this.backupManager.start();
await this.baseOsManager.start();
await this.hostedAppManager.start();
await this.appStoreManager.start();
await this.registryManager.start();
await this.domainManager.init();
await this.cloudflareConnector.init();
await this.letsencryptConnector.init();
if (this.config.data.sslMode === 'letsencrypt') {
await this.letsencryptConnector.init();
}
await this.clusterManager.init();
await this.server.start();
this.readyDeferred.resolve();
// start the managers
this.imageManager.start();
this.externalRegistryManager.start();
}
/**
@@ -114,8 +177,21 @@ export class Cloudly {
*/
public async stop() {
await this.server.stop();
await this.letsencryptConnector.stop();
if (this.config.data.sslMode === 'letsencrypt') {
await this.letsencryptConnector.stop();
}
await this.mongodbConnector.stop();
await this.secretManager.stop();
await this.serviceManager.stop();
await this.platformManager.stop();
await this.deploymentManager.stop();
await this.jumpManager.stop();
await this.taskManager.stop();
await this.backupManager.stop();
await this.baseOsManager.stop();
await this.registryManager.stop();
await this.hostedAppManager.stop();
await this.appStoreManager.stop();
await this.externalRegistryManager.stop();
}
}
+1 -1
View File
@@ -9,5 +9,5 @@ export class CloudlyInfo {
this.cloudlyRef = cloudlyRefArg;
}
public projectInfo = new plugins.projectinfo.ProjectInfo(paths.packageDir);
public projectInfo = plugins.projectinfo.ProjectInfo.create(paths.packageDir);
}
+41 -41
View File
@@ -8,53 +8,53 @@ import type { Cloudly } from './classes.cloudly.js';
*/
export class CloudlyConfig {
public cloudlyRef: Cloudly;
public appData: plugins.npmextra.AppData<plugins.servezoneInterfaces.data.ICloudlyConfig>;
public data: plugins.servezoneInterfaces.data.ICloudlyConfig
// authentication and settings
public smartjwtInstance: plugins.smartjwt.SmartJwt;
public appData!: plugins.smartconfig.AppData<plugins.servezoneInterfaces.data.ICloudlyConfig>;
public data!: plugins.servezoneInterfaces.data.ICloudlyConfig;
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
}
public async init() {
this.appData = await plugins.npmextra.AppData.createAndInit<plugins.servezoneInterfaces.data.ICloudlyConfig>({
envMapping: {
cfToken: 'CF_TOKEN',
environment: 'SERVEZONE_ENVIRONMENT' as 'production' | 'integration',
letsEncryptEmail: 'hard:domains@lossless.org',
hetznerToken: 'HETZNER_API_TOKEN',
letsEncryptPrivateKey: null,
publicUrl: 'SERVEZONE_URL',
publicPort: 'SERVEZONE_PORT',
mongoDescriptor: {
mongoDbUrl: 'MONGODB_URL',
mongoDbName: 'MONGODB_DATABASE',
mongoDbUser: 'MONGODB_USER',
mongoDbPass: 'MONGODB_PASSWORD',
public async init(configArg?: plugins.servezoneInterfaces.data.ICloudlyConfig) {
this.appData =
await plugins.smartconfig.AppData.createAndInit<plugins.servezoneInterfaces.data.ICloudlyConfig>(
{
envMapping: {
environment: 'SERVEZONE_ENVIRONMENT' as 'production' | 'integration',
letsEncryptEmail: 'hard:domains@lossless.org',
letsEncryptPrivateKey: undefined,
publicUrl: 'SERVEZONE_URL',
publicPort: 'SERVEZONE_PORT',
mongoDescriptor: {
mongoDbUrl: 'MONGODB_URL',
mongoDbName: 'MONGODB_NAME',
mongoDbUser: 'MONGODB_USER',
mongoDbPass: 'MONGODB_PASS',
},
s3Descriptor: {
endpoint: 'S3_ENDPOINT',
accessKey: 'S3_ACCESSKEY',
accessSecret: 'S3_SECRETKEY',
port: 'S3_PORT', // Note: This will remain as a string. Ensure to parse it to an integer where it's used.
useSsl: 'boolean:S3_USESSL' as any as boolean,
bucketName: 'S3_BUCKET'
},
sslMode:
'SERVEZONE_SSLMODE' as plugins.servezoneInterfaces.data.ICloudlyConfig['sslMode'],
servezoneAdminaccount: 'SERVEZONE_ADMINACCOUNT',
},
requiredKeys: [
'letsEncryptEmail',
'publicUrl',
'publicPort',
'sslMode',
'environment',
'mongoDescriptor',
's3Descriptor',
],
overwriteObject: configArg,
},
s3Descriptor: {
endpoint: 'S3_ENDPOINT',
accessKey: 'S3_ACCESSKEY',
accessSecret: 'S3_SECRETKEY',
port: 'S3_PORT', // Note: This will remain as a string. Ensure to parse it to an integer where it's used.
useSsl: true,
},
sslMode: 'SERVEZONE_SSLMODE' as plugins.servezoneInterfaces.data.ICloudlyConfig['sslMode'],
},
requiredKeys: [
'cfToken',
'hetznerToken',
'letsEncryptEmail',
'publicUrl',
'publicPort',
'sslMode',
'environment',
'mongoDescriptor',
],
});
);
const kvStore = await this.appData.getKvStore();
+81 -35
View File
@@ -10,13 +10,14 @@ export class CloudlyServer {
/**
* a reference to the cloudly instance
*/
private cloudlyRef: Cloudly;
public cloudlyRef: Cloudly;
public additionalHandlers: plugins.typedserver.IRouteHandler[] = [];
/**
* the smartexpress server handling the actual requests
*/
public typedServer: plugins.typedserver.TypedServer;
public typedsocketServer: plugins.typedsocket.TypedSocket;
public typedServer!: plugins.typedserver.TypedServer;
public typedsocketServer!: plugins.typedsocket.TypedSocket;
/**
* typedrouter
@@ -37,53 +38,98 @@ export class CloudlyServer {
* init the reception instance
*/
public async start() {
logger.log('info', `cloudly domain is ${this.cloudlyRef.config.data.publicUrl}`)
let sslCert: plugins.smartacme.Cert;
logger.log('info', `cloudly domain is ${this.cloudlyRef.config.data.publicUrl}`);
let sslCert: plugins.smartacme.Cert | undefined;
if (this.cloudlyRef.config.data.sslMode === 'letsencrypt') {
logger.log('info', `Using letsencrypt for ssl mode. Trying to obtain a certificate...`)
logger.log('info', `This might take 10 minutes...`)
logger.log('info', `Using letsencrypt for ssl mode. Trying to obtain a certificate...`);
logger.log('info', `This might take 10 minutes...`);
sslCert = await this.cloudlyRef.letsencryptConnector.getCertificateForDomain(
this.cloudlyRef.config.data.publicUrl
this.cloudlyRef.config.data.publicUrl!,
);
logger.log(
'success',
`Successfully obtained certificate for cloudly domain ${this.cloudlyRef.config.data.publicUrl}`,
);
logger.log('success', `Successfully obtained certificate for cloudly domain ${this.cloudlyRef.config.data.publicUrl}`)
} else if (this.cloudlyRef.config.data.sslMode === 'external') {
logger.log('info', `Using external certificate for ssl mode, meaning cloudly is not in charge of ssl termination.`)
logger.log(
'info',
`Using external certificate for ssl mode, meaning cloudly is not in charge of ssl termination.`,
);
}
interface IRequestGuardData {
req: plugins.typedserver.Request;
res: plugins.typedserver.Response;
}
// guards
const guardIp = new plugins.smartguard.Guard<IRequestGuardData>(async (dataArg) => {
if (true) {
return true;
} else {
dataArg.res.status(500);
dataArg.res.send(`Not allowed to perform this operation!`);
dataArg.res.end();
return false;
}
});
// server
this.typedServer = new plugins.typedserver.TypedServer({
cors: true,
forceSsl: false,
port: this.cloudlyRef.config.data.publicPort,
...(sslCert ? {
privateKey: sslCert.privateKey,
publicKey: sslCert.publicKey,
} : {}),
port: this.cloudlyRef.config.data.publicPort,
...(sslCert
? {
privateKey: sslCert.privateKey,
publicKey: sslCert.publicKey,
}
: {}),
injectReload: true,
serveDir: paths.distServeDir,
spaFallback: true,
watch: true,
enableCompression: true,
preferredCompressionMethod: 'gzip',
compression: {
enabled: true,
algorithms: ['gzip'],
},
});
this.typedsocketServer = this.typedServer.typedsocket;
this.typedServer.typedrouter.addTypedRouter(this.typedrouter);
this.typedServer.addRoute(
'/v2',
'ALL',
async (ctx) => this.cloudlyRef.registryManager.handleHttpRequest(ctx),
);
this.typedServer.addRoute(
'/v2/*',
'ALL',
async (ctx) => this.cloudlyRef.registryManager.handleHttpRequest(ctx),
);
this.typedServer.addRoute(
'/curlfresh/:scriptname',
'ALL',
async (ctx) => this.cloudlyRef.nodeManager.curlfreshInstance.handleRequest(ctx),
);
this.typedServer.addRoute(
'/jump/v1/claim',
'POST',
async (ctx) => this.cloudlyRef.jumpManager.handleClaimHttpRequest(ctx),
);
this.typedServer.addRoute(
'/jump/:code/setup.sh',
'GET',
async (ctx) => this.cloudlyRef.jumpManager.handleSetupScriptHttpRequest(ctx),
);
this.typedServer.addRoute(
'/jump/:code',
'GET',
async (ctx) => this.cloudlyRef.jumpManager.handleJumpHttpRequest(ctx),
);
this.typedServer.addRoute(
'/baseos/v1/nodes/register',
'POST',
async (ctx) => this.cloudlyRef.baseOsManager.handleRegisterHttpRequest(ctx),
);
this.typedServer.addRoute(
'/baseos/v1/nodes/heartbeat',
'POST',
async (ctx) => this.cloudlyRef.baseOsManager.handleHeartbeatHttpRequest(ctx),
);
this.typedServer.addRoute(
'/spark/v1/nodes/heartbeat',
'POST',
async (ctx) => this.cloudlyRef.nodeManager.handleSparkHeartbeatHttpRequest(ctx),
);
this.typedServer.addRoute(
'/baseos/v1/images/:buildId/download',
'GET',
async (ctx) => this.cloudlyRef.baseOsManager.handleImageDownloadHttpRequest(ctx),
);
await this.typedServer.start();
}
@@ -91,6 +137,6 @@ export class CloudlyServer {
* stop the reception instance
*/
public async stop() {
await this.typedServer.stop();
await this.typedServer?.stop();
}
}
+9 -2
View File
@@ -6,7 +6,7 @@ import { Cloudly } from '../classes.cloudly.js';
*/
export class CloudflareConnector {
private cloudlyRef: Cloudly;
public cloudflare: plugins.cloudflare.CloudflareAccount;
public cloudflare?: plugins.cloudflare.CloudflareAccount;
constructor(cloudlyArg: Cloudly) {
this.cloudlyRef = cloudlyArg;
@@ -14,6 +14,13 @@ export class CloudflareConnector {
// init the instance
public async init() {
this.cloudflare = new plugins.cloudflare.CloudflareAccount(this.cloudlyRef.config.data.cfToken);
const cloudflareToken = await this.cloudlyRef.settingsManager.getSetting('cloudflareToken');
if (!cloudflareToken) {
console.log('warn', 'No Cloudflare token configured in settings. Cloudflare features will be disabled.');
return;
}
this.cloudflare = new plugins.cloudflare.CloudflareAccount(cloudflareToken);
}
}
+24 -16
View File
@@ -3,7 +3,7 @@ import { Cloudly } from '../classes.cloudly.js';
export class LetsencryptConnector {
private cloudlyRef: Cloudly;
private smartacme: plugins.smartacme.SmartAcme;
private smartacme!: plugins.smartacme.SmartAcme;
constructor(cloudlyArg: Cloudly) {
this.cloudlyRef = cloudlyArg;
@@ -18,29 +18,37 @@ export class LetsencryptConnector {
* inits letsencrypt
*/
public async init() {
if (!this.cloudlyRef.cloudflareConnector.cloudflare) {
throw new Error('Cloudflare token is required for letsencrypt DNS-01 challenges');
}
// Create DNS-01 challenge handler using Cloudflare
const dnsHandler = new plugins.smartacme.handlers.Dns01Handler(
this.cloudlyRef.cloudflareConnector.cloudflare
);
// Create MongoDB certificate manager
const certManager = new plugins.smartacme.certmanagers.MongoCertManager(
this.cloudlyRef.config.data.mongoDescriptor!
);
this.smartacme = new plugins.smartacme.SmartAcme({
accountEmail: this.cloudlyRef.config.data.letsEncryptEmail,
accountEmail: this.cloudlyRef.config.data.letsEncryptEmail!,
accountPrivateKey: this.cloudlyRef.config.data.letsEncryptPrivateKey,
environment: this.cloudlyRef.config.data.environment,
setChallenge: async (dnsChallenge) => {
await this.cloudlyRef.cloudflareConnector.cloudflare.convenience.acmeSetDnsChallenge(
dnsChallenge
);
},
removeChallenge: async (dnsChallenge) => {
await this.cloudlyRef.cloudflareConnector.cloudflare.convenience.acmeRemoveDnsChallenge(
dnsChallenge
);
},
mongoDescriptor: this.cloudlyRef.config.data.mongoDescriptor,
environment: this.cloudlyRef.config.data.environment!,
certManager: certManager,
challengeHandlers: [dnsHandler],
});
await this.smartacme.start().catch((err) => {
console.error('error in init', err);
console.log(`trying again in a few minutes`);
});
await this.smartacme.init();
}
/**
* stops the instance
*/
public async stop() {
await this.smartacme.stop();
await this.smartacme?.stop();
}
}
+4 -2
View File
@@ -4,14 +4,16 @@ import { Cloudly } from '../classes.cloudly.js';
export class MongodbConnector {
// INSTANCE
private cloudlyRef: Cloudly;
public smartdataDb: plugins.smartdata.SmartdataDb;
public smartdataDb!: plugins.smartdata.SmartdataDb;
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
}
public async init() {
this.smartdataDb = new plugins.smartdata.SmartdataDb(this.cloudlyRef.config.data.mongoDescriptor);
this.smartdataDb = new plugins.smartdata.SmartdataDb(
this.cloudlyRef.config.data.mongoDescriptor!,
);
await this.smartdataDb.init();
}
-9
View File
@@ -1,9 +0,0 @@
export const users = [
{
id: 'user1',
data: {
username: 'admin',
password: 'password',
}
}
]
+9 -5
View File
@@ -7,22 +7,26 @@ import { logger } from './logger.js';
const cloudlyQenv = new plugins.qenv.Qenv(paths.packageDir, paths.nogitDir, true);
early.stop();
/**
* starts the cloudly instance
*/
const runCli = async () => {
logger.log('info', process.env.SERVEZONE_ENVIRONMENT);
logger.log('info', process.env.SERVEZONE_ENVIRONMENT || '');
const cloudlyInstance = new Cloudly();
logger.log(
'info',
`running in environment ${await cloudlyQenv.getEnvVarOnDemand('SERVEZONE_ENVIRONMENT')}`
`running in environment ${await cloudlyQenv.getEnvVarOnDemand('SERVEZONE_ENVIRONMENT')}`,
);
await cloudlyInstance.start();
const demoMod = await import('./demo/index.js');
demoMod.installDemoData(cloudlyInstance);
if (process.env.SERVEZONE_INSTALL_DEMO_DATA === 'true') {
logger.log('warn', 'SERVEZONE_INSTALL_DEMO_DATA=true: installing destructive demo data');
const demoMod = await import('./00demo/index.js');
await demoMod.installDemoData(cloudlyInstance);
}
};
export { runCli, Cloudly };
type ICloudlyConfig = plugins.servezoneInterfaces.data.ICloudlyConfig;
export { type ICloudlyConfig };
+8 -8
View File
@@ -3,14 +3,14 @@ import * as paths from './paths.js';
export const logger = new plugins.smartlog.Smartlog({
logContext: {
company: null,
environment: null,
runtime: null,
zone: null,
companyunit: null,
containerName: null,
}
company: undefined,
environment: undefined,
runtime: undefined,
zone: undefined,
companyunit: undefined,
containerName: undefined,
},
});
logger.enableConsole({
captureAll: false
captureAll: false,
});
File diff suppressed because it is too large Load Diff
+169 -18
View File
@@ -5,14 +5,25 @@ import { logger } from '../logger.js';
import { Authorization } from './classes.authorization.js';
import { User } from './classes.user.js';
export interface IJwtData {
userId: string;
status: 'loggedIn' | 'loggedOut';
expiresAt: number;
}
interface IReq_AdminValidateIdentity {
method: 'adminValidateIdentity';
request: {
identity: plugins.servezoneInterfaces.data.IIdentity;
};
response: {
valid: boolean;
reason?: string;
};
}
export class CloudlyAuthManager {
cloudlyRef: Cloudly
cloudlyRef: Cloudly;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
@@ -20,57 +31,197 @@ export class CloudlyAuthManager {
public CAuthorization = plugins.smartdata.setDefaultManagerForDoc(this, Authorization);
public typedrouter = new plugins.typedrequest.TypedRouter();
public smartjwtInstance: plugins.smartjwt.SmartJwt<IJwtData>;
public smartjwtInstance!: plugins.smartjwt.SmartJwt<IJwtData>;
constructor(cloudlyRef: Cloudly) {
this.cloudlyRef = cloudlyRef;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
}
public async createNewSecureToken() {
return plugins.smartunique.uniSimple('secureToken', 64);
}
public async start() {
// lets setup the smartjwtInstance
this.smartjwtInstance = new plugins.smartjwt.SmartJwt();
await this.smartjwtInstance.init();
const kvStore = await this.cloudlyRef.config.appData.getKvStore();
const existingJwtKeys: plugins.tsclass.network.IJwtKeypair = await kvStore.readKey('jwtKeys');
const existingJwtKeys: plugins.tsclass.network.IJwtKeypair = (await kvStore.readKey(
'jwtKeypair',
)) as plugins.tsclass.network.IJwtKeypair;
if (!existingJwtKeys) {
await this.smartjwtInstance.createNewKeyPair();
const newJwtKeys = this.smartjwtInstance.getKeyPairAsJson();
await kvStore.writeKey('jwtKeys', newJwtKeys);
await kvStore.writeKey('jwtKeypair', newJwtKeys);
} else {
this.smartjwtInstance.setKeyPairAsJson(existingJwtKeys);
}
await this.bootstrapInitialAdmin();
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.secret.IReq_Admin_LoginWithUsernameAndPassword>(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.admin.IReq_Admin_LoginWithUsernameAndPassword>(
'adminLoginWithUsernameAndPassword',
async (dataArg) => {
let jwt: string;
let expiresAtTimestamp: number = Date.now() + 3600 * 1000 * 24 * 7;
const user = await User.findUserByUsernameAndPassword(dataArg.username, dataArg.password);
if (!user) {
logger.log('warn', 'login failed');
throw new plugins.typedrequest.TypedResponseError('login failed');
} else {
jwt = await this.cloudlyRef.config.smartjwtInstance.createJWT({
jwt = await this.smartjwtInstance.createJWT({
userId: user.id,
status: 'loggedIn',
expiresAt: expiresAtTimestamp,
});
logger.log('success', 'login successful');
}
return {
jwt,
identity: {
jwt,
userId: user.id,
name: user.data.username || user.id,
expiresAt: expiresAtTimestamp,
role: user.data.role,
type: user.data.type,
},
};
}
)
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<IReq_AdminValidateIdentity>('adminValidateIdentity', async (dataArg) => {
const valid = await this.adminIdentityGuard.exec(dataArg).catch(() => false);
return {
valid,
reason: valid ? undefined : 'identity is not valid',
};
}),
);
}
public async stop () {}
private async bootstrapInitialAdmin() {
const users = await this.CUser.getInstances({});
const hasHumanUser = users.some((userArg) => userArg.data?.type === 'human');
if (hasHumanUser) {
return;
}
public adminJwtGuard = new plugins.smartguard.Guard<{jwt: string}>(async (dataArg) => {
const jwt = dataArg.jwt;
const jwtData: IJwtData = await this.cloudlyRef.config.smartjwtInstance.verifyJWTAndGetData(jwt);
const user = await this.CUser.getInstance({id: jwtData.userId});
return user.data.role === 'admin';
})
}
const adminAccount = this.cloudlyRef.config.data.servezoneAdminaccount;
let username: string;
let password: string;
let hostedBootstrapActionId: string | undefined;
if (adminAccount) {
const separatorIndex = adminAccount.indexOf(':');
if (separatorIndex <= 0 || separatorIndex === adminAccount.length - 1) {
throw new Error('SERVEZONE_ADMINACCOUNT must use username:password format');
}
username = adminAccount.slice(0, separatorIndex).trim();
password = adminAccount.slice(separatorIndex + 1);
if (!username || !password) {
throw new Error('SERVEZONE_ADMINACCOUNT must include a non-empty username and password');
}
} else {
const hostedBootstrap = await this.cloudlyRef.hostedAppManager.requestParentInitialAdminBootstrap();
if (!hostedBootstrap) {
throw new Error('SERVEZONE_ADMINACCOUNT is required for first-run Cloudly bootstrap unless hosted app lifecycle credentials are available');
}
username = hostedBootstrap.username;
password = hostedBootstrap.password;
hostedBootstrapActionId = hostedBootstrap.actionId;
}
const user = new this.CUser({
id: await this.CUser.getNewId(),
data: {
type: 'human',
username,
password,
role: 'admin',
},
});
await user.save();
logger.log('success', `created initial admin user ${username}`);
if (hostedBootstrapActionId) {
await this.cloudlyRef.hostedAppManager.completeParentBootstrapAction(
hostedBootstrapActionId,
'Cloudly created the initial admin user.',
).catch((errorArg) => {
logger.log('warn', `failed to complete hosted app bootstrap action: ${(errorArg as Error).message}`);
});
}
}
public async stop() {}
public validIdentityGuard = new plugins.smartguard.Guard<{
identity: plugins.servezoneInterfaces.data.IIdentity;
}>(
async (dataArg) => {
try {
const jwt = dataArg.identity?.jwt;
if (!jwt) {
return false;
}
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const expired = jwtData.expiresAt < Date.now();
return (
jwtData.status === 'loggedIn' &&
!expired &&
dataArg.identity.expiresAt === jwtData.expiresAt &&
dataArg.identity.userId === jwtData.userId
);
} catch {
return false;
}
},
{
failedHint: 'identity is not valid.',
name: 'validIdentityGuard',
},
);
public adminIdentityGuard = new plugins.smartguard.Guard<{
identity: plugins.servezoneInterfaces.data.IIdentity;
}>(
async (dataArg) => {
const validIdentity = await this.validIdentityGuard.exec(dataArg);
if (!validIdentity) {
return false;
}
const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const user = await this.CUser.getInstance({ id: jwtData.userId });
return user?.data.role === 'admin';
},
{
failedHint: 'identity is not valid or user is not admin.',
name: 'adminIdentityGuard',
},
);
public adminOrClusterIdentityGuard = new plugins.smartguard.Guard<{
identity: plugins.servezoneInterfaces.data.IIdentity;
}>(
async (dataArg) => {
const validIdentity = await this.validIdentityGuard.exec(dataArg);
if (!validIdentity) {
return false;
}
const jwt = dataArg.identity.jwt;
const jwtData: IJwtData = await this.smartjwtInstance.verifyJWTAndGetData(jwt);
const user = await this.CUser.getInstance({ id: jwtData.userId });
return user?.data.role === 'admin' || user?.data.role === 'cluster';
},
{
failedHint: 'identity is not valid or user is not admin or cluster.',
name: 'adminOrClusterIdentityGuard',
},
);
}
+1 -3
View File
@@ -1,6 +1,4 @@
import * as plugins from '../plugins.js';
@plugins.smartdata.managed()
export class Authorization extends plugins.smartdata.SmartDataDbDoc<Authorization, Authorization> {
}
export class Authorization extends plugins.smartdata.SmartDataDbDoc<Authorization, Authorization> {}
+37 -9
View File
@@ -1,24 +1,52 @@
import * as plugins from '../plugins.js';
@plugins.smartdata.managed()
export class User extends plugins.smartdata.SmartDataDbDoc<User, User> {
export class User extends plugins.smartdata.SmartDataDbDoc<
User,
plugins.servezoneInterfaces.data.IUser
> {
/**
* creates a machine user
*/
public static async createMachineUser(userNameArg: string, roleArg: 'api' | 'cluster') {
const user = new User();
user.id = await User.getNewId();
user.data = {
type: 'machine',
username: userNameArg,
tokens: [
{
token: 'machineUser',
expiresAt: Date.now() + 3600 * 1000 * 24 * 365,
assignedRoles: ['admin'],
},
],
role: 'api',
};
await user.save();
return user;
}
public static async findUserByUsernameAndPassword(usernameArg: string, passwordArg: string) {
return await User.getInstance({
data: {
username: usernameArg,
password: passwordArg,
}
},
});
}
constructor(optionsArg?: plugins.servezoneInterfaces.data.IUser) {
super();
if (optionsArg) {
Object.assign(this, optionsArg);
}
}
// INSTANCE
@plugins.smartdata.unI()
public id: string;
public id!: string;
@plugins.smartdata.svDb()
public data: {
role: 'admin' | 'user';
username: string;
password: string;
}
}
public data!: plugins.servezoneInterfaces.data.IUser['data'];
}
+605
View File
@@ -0,0 +1,605 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import { BackupRecord } from './classes.backuprecord.js';
import { createBackupTargetWriterFromEnv, type IBackupTargetWriter } from './classes.replicationtarget.js';
export type TBackupStatus =
| 'pending'
| 'running'
| 'replicating'
| 'replicated'
| 'ready'
| 'failed'
| 'restoring'
| 'restored';
export type TBackupResourceType = 'volume' | 'database' | 'objectstorage';
export type TBackupReplicationTargetType = 's3' | 'smb';
export interface IBackupArchiveObject {
path: string;
size: number;
sha256: string;
}
export interface IBackupArchiveManifest {
version: 1;
backupId: string;
createdAt: number;
objects: IBackupArchiveObject[];
totalSize: number;
}
export interface IBackupReplicationResult {
targetType: TBackupReplicationTargetType;
targetPath: string;
manifestPath: string;
manifestSha256: string;
objectCount: number;
totalSize: number;
completedAt: number;
}
export interface IBackupSnapshotData {
type: TBackupResourceType;
snapshotId: string;
snapshotName?: string;
originalSize: number;
storedSize: number;
createdAt: number;
tags?: Record<string, string>;
volumeName?: string;
mountPath?: string;
resourceName?: string;
databaseName?: string;
bucketName?: string;
}
export interface IBackupRecordData {
id: string;
serviceId: string;
serviceName?: string;
clusterId?: string;
status: TBackupStatus;
trigger: 'manual' | 'scheduled';
snapshots: IBackupSnapshotData[];
replication?: IBackupReplicationResult;
createdAt: number;
updatedAt: number;
completedAt?: number;
requestedBy?: string;
errorText?: string;
restoreHistory?: Array<{
restoredAt: number;
status: 'restored' | 'failed';
errorText?: string;
}>;
tags?: Record<string, string>;
}
export class CloudlyBackupManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
public cloudlyRef: Cloudly;
private backupTargetWriter?: IBackupTargetWriter;
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CBackupRecord = plugins.smartdata.setDefaultManagerForDoc(this, BackupRecord);
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>('createServiceBackup', async (requestArg) => {
await this.passAdminIdentity(requestArg);
return {
backup: await this.createServiceBackup(requestArg),
};
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>('getServiceBackups', async (requestArg) => {
await this.passValidIdentity(requestArg);
return {
backups: await this.getBackups({
...(requestArg.serviceId ? { serviceId: requestArg.serviceId } : {}),
...(requestArg.status ? { status: requestArg.status } : {}),
}),
};
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>('getBackupById', async (requestArg) => {
await this.passValidIdentity(requestArg);
return {
backup: await this.getBackupById(requestArg.backupId),
};
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>('restoreServiceBackup', async (requestArg) => {
await this.passAdminIdentity(requestArg);
return {
backup: await this.restoreServiceBackup(requestArg),
};
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>('prepareBackupReplication', async (requestArg) => {
await this.passClusterIdentity(requestArg);
return await this.prepareBackupReplication(requestArg);
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>('uploadBackupArchiveObject', async (requestArg) => {
await this.passClusterIdentity(requestArg);
return await this.uploadBackupArchiveObject(requestArg);
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>('completeBackupReplication', async (requestArg) => {
await this.passClusterIdentity(requestArg);
return await this.completeBackupReplication(requestArg);
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>('getBackupArchiveManifest', async (requestArg) => {
await this.passClusterIdentity(requestArg);
return await this.getBackupArchiveManifest(requestArg);
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>('downloadBackupArchiveObject', async (requestArg) => {
await this.passClusterIdentity(requestArg);
return await this.downloadBackupArchiveObject(requestArg);
}),
);
}
public async start() {
const schedule = process.env.CLOUDLY_BACKUP_CRON;
this.cloudlyRef.taskManager.registerTask(
'backup-all-services',
new plugins.taskbuffer.Task({
name: 'backup-all-services',
taskFunction: async () => await this.backupAllServices(),
}),
{
description: 'Create backups for every workload service with backup-enabled resources.',
category: 'backup',
schedule,
enabled: Boolean(schedule),
},
);
}
public async stop() {}
public async getBackups(queryArg: Record<string, unknown> = {}) {
const backups = await this.CBackupRecord.getInstances(queryArg);
return await Promise.all(backups.map((backupArg) => backupArg.createSavableObject()));
}
public async getBackupById(backupIdArg: string) {
const backup = await BackupRecord.getInstance({ id: backupIdArg });
if (!backup) {
throw new plugins.typedrequest.TypedResponseError(`Backup ${backupIdArg} not found`);
}
return await backup.createSavableObject();
}
public async deleteBackupsForService(serviceIdArg: string): Promise<void> {
const backups = await this.CBackupRecord.getInstances({
serviceId: serviceIdArg,
});
for (const backup of backups) {
if (backup.replication?.targetPath) {
await this.getBackupTargetWriter().deletePrefix(backup.replication.targetPath);
}
await backup.delete();
}
}
public async backupAllServices() {
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
const results: Array<{ serviceId: string; backupId?: string; errorText?: string }> = [];
for (const service of services) {
if (service.data.serviceCategory && service.data.serviceCategory !== 'workload') {
continue;
}
try {
const backup = await this.createServiceBackup({
identity: {
name: 'cloudly-backup-scheduler',
role: 'admin',
type: 'machine',
userId: 'system',
expiresAt: Date.now() + 3600 * 1000,
jwt: '',
},
serviceId: service.id,
tags: {
trigger: 'scheduled',
},
});
results.push({ serviceId: service.id, backupId: backup.id });
} catch (error) {
results.push({ serviceId: service.id, errorText: (error as Error).message });
}
}
return { results };
}
public async createServiceBackup(requestArg: {
identity: plugins.servezoneInterfaces.data.IIdentity;
serviceId: string;
clusterId?: string;
tags?: Record<string, string>;
}) {
const service = await this.cloudlyRef.serviceManager.CService.getInstance({
id: requestArg.serviceId,
});
if (!service) {
throw new plugins.typedrequest.TypedResponseError(`Service ${requestArg.serviceId} not found`);
}
const now = Date.now();
const backup = new BackupRecord();
backup.id = await BackupRecord.getNewId();
backup.serviceId = service.id;
backup.serviceName = service.data.name;
backup.clusterId = requestArg.clusterId || (requestArg.identity as any).clusterId;
backup.status = 'running';
backup.trigger = 'manual';
backup.snapshots = [];
backup.createdAt = now;
backup.updatedAt = now;
backup.requestedBy = requestArg.identity.userId;
backup.tags = requestArg.tags;
await backup.save();
const replicationEnabled = (requestArg as any).replicate !== false && !!process.env.CLOUDLY_BACKUP_TARGET_TYPE;
try {
const result = await this.fireCoreflowRequest('executeServiceBackup', {
backupId: backup.id,
service: await service.createSavableObject(),
tags: requestArg.tags,
replication: {
enabled: replicationEnabled,
},
}, backup.clusterId);
backup.snapshots = result.snapshots || [];
if (replicationEnabled && !result.replication) {
throw new Error('Coreflow did not complete remote backup replication');
}
backup.replication = result.replication;
backup.status = 'replicated';
backup.completedAt = Date.now();
backup.updatedAt = Date.now();
await backup.save();
await this.applyRetention(backup.serviceId);
} catch (error) {
backup.status = 'failed';
backup.errorText = (error as Error).message;
backup.completedAt = Date.now();
backup.updatedAt = Date.now();
await backup.save();
throw error;
}
return await backup.createSavableObject();
}
private async applyRetention(serviceIdArg: string) {
const keepLast = Number(process.env.CLOUDLY_BACKUP_KEEP_LAST || '24');
if (!Number.isInteger(keepLast) || keepLast <= 0) {
return;
}
const backups = await this.CBackupRecord.getInstances({
serviceId: serviceIdArg,
});
const completedBackups = backups
.filter((backupArg) => backupArg.status === 'replicated' || backupArg.status === 'restored' || backupArg.status === 'failed')
.sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
for (const backup of completedBackups.slice(keepLast)) {
await backup.delete();
}
}
public async restoreServiceBackup(requestArg: {
identity: plugins.servezoneInterfaces.data.IIdentity;
backupId: string;
clear?: boolean;
resourceTypes?: TBackupResourceType[];
}) {
const backup = await BackupRecord.getInstance({ id: requestArg.backupId });
if (!backup) {
throw new plugins.typedrequest.TypedResponseError(`Backup ${requestArg.backupId} not found`);
}
if (backup.status !== 'replicated' && backup.status !== 'restored') {
throw new plugins.typedrequest.TypedResponseError(`Backup ${backup.id} is not restorable in status ${backup.status}`);
}
const service = await this.cloudlyRef.serviceManager.CService.getInstance({
id: backup.serviceId,
});
if (!service) {
throw new plugins.typedrequest.TypedResponseError(`Service ${backup.serviceId} not found`);
}
const previousStatus = backup.status;
backup.status = 'restoring';
backup.updatedAt = Date.now();
await backup.save();
try {
await this.fireCoreflowRequest('executeServiceRestore', {
backupId: backup.id,
service: await service.createSavableObject(),
snapshots: backup.snapshots || [],
clear: requestArg.clear,
resourceTypes: requestArg.resourceTypes,
replication: {
enabled: true,
},
}, backup.clusterId);
backup.status = 'restored';
backup.restoreHistory = [
...(backup.restoreHistory || []),
{
restoredAt: Date.now(),
status: 'restored',
},
];
backup.updatedAt = Date.now();
await backup.save();
} catch (error) {
backup.status = previousStatus;
backup.restoreHistory = [
...(backup.restoreHistory || []),
{
restoredAt: Date.now(),
status: 'failed',
errorText: (error as Error).message,
},
];
backup.updatedAt = Date.now();
await backup.save();
throw error;
}
return await backup.createSavableObject();
}
private getBackupTargetWriter() {
if (!this.backupTargetWriter) {
this.backupTargetWriter = createBackupTargetWriterFromEnv();
}
return this.backupTargetWriter;
}
private normalizeTargetPath(pathArg: string) {
const normalized = plugins.path.posix
.normalize(String(pathArg || '').replace(/\\/g, '/').trim())
.replace(/^\/+/, '');
if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
throw new Error(`Invalid backup target path ${pathArg}`);
}
return normalized;
}
private getBackupTargetPath(backupArg: BackupRecord) {
return this.normalizeTargetPath([
process.env.CLOUDLY_BACKUP_TARGET_PREFIX || 'serve.zone-backups',
'clusters',
backupArg.clusterId || 'default',
'services',
backupArg.serviceId,
'backups',
backupArg.id,
].filter(Boolean).join('/'));
}
private getArchiveObjectTargetPath(backupArg: BackupRecord, objectPathArg: string) {
return this.normalizeTargetPath(`${this.getBackupTargetPath(backupArg)}/archive/${objectPathArg}`);
}
private getManifestTargetPath(backupArg: BackupRecord) {
return this.normalizeTargetPath(`${this.getBackupTargetPath(backupArg)}/manifest.json`);
}
private getSha256(contentsArg: Buffer) {
return plugins.crypto.createHash('sha256').update(contentsArg).digest('hex');
}
private assertObjectMatches(objectArg: IBackupArchiveObject, contentsArg: Buffer) {
if (contentsArg.length !== objectArg.size || this.getSha256(contentsArg) !== objectArg.sha256) {
throw new Error(`Backup archive object checksum mismatch for ${objectArg.path}`);
}
}
private createManifestBuffer(backupArg: BackupRecord, manifestArg: IBackupArchiveManifest) {
return Buffer.from(`${JSON.stringify({
version: 1,
backupId: backupArg.id,
serviceId: backupArg.serviceId,
serviceName: backupArg.serviceName,
clusterId: backupArg.clusterId,
archive: manifestArg,
}, null, 2)}\n`);
}
private async getBackupForClusterRequest(backupIdArg: string, identityArg: plugins.servezoneInterfaces.data.IIdentity) {
const backup = await BackupRecord.getInstance({ id: backupIdArg });
if (!backup) {
throw new plugins.typedrequest.TypedResponseError(`Backup ${backupIdArg} not found`);
}
const identityClusterId = (identityArg as any).clusterId;
if (backup.clusterId && identityClusterId && backup.clusterId !== identityClusterId) {
throw new plugins.typedrequest.TypedResponseError(`Backup ${backupIdArg} does not belong to this cluster`);
}
return backup;
}
public async prepareBackupReplication(requestArg: {
identity: plugins.servezoneInterfaces.data.IIdentity;
backupId: string;
manifest: IBackupArchiveManifest;
}) {
const backup = await this.getBackupForClusterRequest(requestArg.backupId, requestArg.identity);
const targetWriter = this.getBackupTargetWriter();
const missingObjects: IBackupArchiveObject[] = [];
for (const object of requestArg.manifest.objects || []) {
const targetPath = this.getArchiveObjectTargetPath(backup, object.path);
if (!await targetWriter.hasObject(targetPath, object)) {
missingObjects.push(object);
}
}
backup.status = 'replicating';
backup.updatedAt = Date.now();
await backup.save();
return { missingObjects };
}
public async uploadBackupArchiveObject(requestArg: {
identity: plugins.servezoneInterfaces.data.IIdentity;
backupId: string;
object: IBackupArchiveObject;
contentsBase64: string;
}) {
const backup = await this.getBackupForClusterRequest(requestArg.backupId, requestArg.identity);
const contents = Buffer.from(requestArg.contentsBase64 || '', 'base64');
this.assertObjectMatches(requestArg.object, contents);
await this.getBackupTargetWriter().putObject(
this.getArchiveObjectTargetPath(backup, requestArg.object.path),
requestArg.object,
contents,
);
return { accepted: true };
}
public async completeBackupReplication(requestArg: {
identity: plugins.servezoneInterfaces.data.IIdentity;
backupId: string;
manifest: IBackupArchiveManifest;
}) {
const backup = await this.getBackupForClusterRequest(requestArg.backupId, requestArg.identity);
const targetWriter = this.getBackupTargetWriter();
for (const object of requestArg.manifest.objects || []) {
const targetPath = this.getArchiveObjectTargetPath(backup, object.path);
if (!await targetWriter.hasObject(targetPath, object)) {
throw new Error(`Remote backup target is missing archive object ${object.path}`);
}
}
const manifestPath = this.getManifestTargetPath(backup);
const manifestBuffer = this.createManifestBuffer(backup, requestArg.manifest);
const manifestObject = {
path: 'manifest.json',
size: manifestBuffer.length,
sha256: this.getSha256(manifestBuffer),
};
await targetWriter.putObject(manifestPath, manifestObject, manifestBuffer);
const replication: IBackupReplicationResult = {
targetType: targetWriter.targetType,
targetPath: this.getBackupTargetPath(backup),
manifestPath,
manifestSha256: manifestObject.sha256,
objectCount: requestArg.manifest.objects.length,
totalSize: requestArg.manifest.totalSize,
completedAt: Date.now(),
};
backup.replication = replication;
backup.status = 'replicated';
backup.completedAt = replication.completedAt;
backup.updatedAt = replication.completedAt;
await backup.save();
return { replication };
}
public async getBackupArchiveManifest(requestArg: {
identity: plugins.servezoneInterfaces.data.IIdentity;
backupId: string;
}) {
const backup = await this.getBackupForClusterRequest(requestArg.backupId, requestArg.identity);
if (!backup.replication) {
throw new plugins.typedrequest.TypedResponseError(`Backup ${backup.id} has not been replicated`);
}
const manifestBuffer = await this.getBackupTargetWriter().readObject(backup.replication.manifestPath);
if (this.getSha256(manifestBuffer) !== backup.replication.manifestSha256) {
throw new Error(`Remote manifest checksum mismatch for backup ${backup.id}`);
}
const parsedManifest = JSON.parse(manifestBuffer.toString('utf8'));
return { manifest: parsedManifest.archive as IBackupArchiveManifest };
}
public async downloadBackupArchiveObject(requestArg: {
identity: plugins.servezoneInterfaces.data.IIdentity;
backupId: string;
object: IBackupArchiveObject;
}) {
const backup = await this.getBackupForClusterRequest(requestArg.backupId, requestArg.identity);
if (!backup.replication) {
throw new plugins.typedrequest.TypedResponseError(`Backup ${backup.id} has not been replicated`);
}
const contents = await this.getBackupTargetWriter().readObject(
this.getArchiveObjectTargetPath(backup, requestArg.object.path),
);
this.assertObjectMatches(requestArg.object, contents);
return {
object: requestArg.object,
contentsBase64: contents.toString('base64'),
};
}
private async fireCoreflowRequest(methodArg: string, payloadArg: Record<string, unknown>, clusterIdArg?: string) {
const typedsocket = this.cloudlyRef.server.typedServer?.typedsocket;
if (!typedsocket) {
throw new Error('Cloudly TypedSocket server is not running');
}
const connections = await typedsocket.findAllTargetConnections(async (connectionArg) => {
const identityTag = await connectionArg.getTagById('identity');
const identity = identityTag?.payload as plugins.servezoneInterfaces.data.IIdentity | undefined;
return identity?.role === 'cluster' && (!clusterIdArg || (identity as any).clusterId === clusterIdArg);
});
if (connections.length === 0) {
throw new Error(clusterIdArg
? `No connected coreflow for cluster ${clusterIdArg}`
: 'No connected coreflow');
}
const request = typedsocket.createTypedRequest<any>(methodArg, connections[0]);
return await request.fire(payloadArg as any);
}
private async passValidIdentity(requestData: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
await plugins.smartguard.passGuardsOrReject(requestData, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
}
private async passAdminIdentity(requestData: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
await plugins.smartguard.passGuardsOrReject(requestData, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
}
private async passClusterIdentity(requestData: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
await this.passValidIdentity(requestData);
if (requestData.identity.role !== 'cluster') {
throw new plugins.typedrequest.TypedResponseError('Cluster identity required');
}
}
}
+54
View File
@@ -0,0 +1,54 @@
import * as plugins from '../plugins.js';
import type { CloudlyBackupManager, IBackupRecordData } from './classes.backupmanager.js';
@plugins.smartdata.managed()
export class BackupRecord extends plugins.smartdata.SmartDataDbDoc<
BackupRecord,
IBackupRecordData,
CloudlyBackupManager
> {
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public serviceId!: string;
@plugins.smartdata.svDb()
public serviceName?: string;
@plugins.smartdata.svDb()
public clusterId?: string;
@plugins.smartdata.svDb()
public status!: IBackupRecordData['status'];
@plugins.smartdata.svDb()
public trigger!: IBackupRecordData['trigger'];
@plugins.smartdata.svDb()
public snapshots!: IBackupRecordData['snapshots'];
@plugins.smartdata.svDb()
public replication?: IBackupRecordData['replication'];
@plugins.smartdata.svDb()
public createdAt!: number;
@plugins.smartdata.svDb()
public updatedAt!: number;
@plugins.smartdata.svDb()
public completedAt?: number;
@plugins.smartdata.svDb()
public requestedBy?: string;
@plugins.smartdata.svDb()
public errorText?: string;
@plugins.smartdata.svDb()
public restoreHistory?: IBackupRecordData['restoreHistory'];
@plugins.smartdata.svDb()
public tags?: Record<string, string>;
}
@@ -0,0 +1,224 @@
import * as plugins from '../plugins.js';
type TArchiveObject = {
path: string;
size: number;
sha256: string;
};
type TTargetType = 's3' | 'smb';
export interface IBackupTargetWriter {
targetType: TTargetType;
hasObject(pathArg: string, objectArg: TArchiveObject): Promise<boolean>;
putObject(pathArg: string, objectArg: TArchiveObject, contentsArg: Buffer): Promise<void>;
readObject(pathArg: string): Promise<Buffer>;
deletePrefix(pathPrefixArg: string): Promise<void>;
}
const requiredEnv = (nameArg: string) => {
const value = process.env[nameArg];
if (!value) {
throw new Error(`Missing required backup target env ${nameArg}`);
}
return value;
};
const normalizeRemotePath = (pathArg: string) => {
const normalized = plugins.path.posix
.normalize(String(pathArg || '').replace(/\\/g, '/').trim())
.replace(/^\/+/, '');
if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized.includes('/../')) {
throw new Error(`Invalid backup target path ${pathArg}`);
}
return normalized;
};
const getBufferSha256 = (contentsArg: Buffer) => {
return plugins.crypto.createHash('sha256').update(contentsArg).digest('hex');
};
const assertObjectMatches = (objectArg: TArchiveObject, contentsArg: Buffer, labelArg: string) => {
const sha256 = getBufferSha256(contentsArg);
if (contentsArg.length !== objectArg.size || sha256 !== objectArg.sha256) {
throw new Error(`Backup target checksum mismatch for ${labelArg}`);
}
};
const objectMatches = (objectArg: TArchiveObject, contentsArg: Buffer) => {
return contentsArg.length === objectArg.size && getBufferSha256(contentsArg) === objectArg.sha256;
};
class S3BackupTargetWriter implements IBackupTargetWriter {
public targetType: TTargetType = 's3';
private bucketPromise?: Promise<any>;
private async getBucket() {
if (!this.bucketPromise) {
this.bucketPromise = (async () => {
const smartBucket = new plugins.smartbucket.SmartBucket({
endpoint: requiredEnv('CLOUDLY_BACKUP_S3_ENDPOINT'),
accessKey: requiredEnv('CLOUDLY_BACKUP_S3_ACCESS_KEY'),
accessSecret: requiredEnv('CLOUDLY_BACKUP_S3_SECRET_KEY'),
region: process.env.CLOUDLY_BACKUP_S3_REGION || 'us-east-1',
...(process.env.CLOUDLY_BACKUP_S3_PORT
? { port: Number(process.env.CLOUDLY_BACKUP_S3_PORT) }
: {}),
...(process.env.CLOUDLY_BACKUP_S3_USE_SSL
? { useSsl: process.env.CLOUDLY_BACKUP_S3_USE_SSL !== 'false' }
: {}),
} as any);
const bucketName = requiredEnv('CLOUDLY_BACKUP_S3_BUCKET');
if (await smartBucket.bucketExists(bucketName)) {
return await smartBucket.getBucketByName(bucketName);
}
return await smartBucket.createBucket(bucketName);
})();
}
return await this.bucketPromise;
}
public async hasObject(pathArg: string, objectArg: TArchiveObject) {
try {
return objectMatches(objectArg, await this.readObject(pathArg));
} catch {
return false;
}
}
public async putObject(pathArg: string, objectArg: TArchiveObject, contentsArg: Buffer) {
const targetPath = normalizeRemotePath(pathArg);
assertObjectMatches(objectArg, contentsArg, targetPath);
const bucket = await this.getBucket();
const tempPath = `${targetPath}.upload-${Date.now()}-${plugins.smartunique.shortId()}.tmp`;
try {
await bucket.fastPut({ path: tempPath, contents: contentsArg, overwrite: true });
assertObjectMatches(objectArg, await bucket.fastGet({ path: tempPath }), tempPath);
await bucket.fastMove({ sourcePath: tempPath, destinationPath: targetPath, overwrite: true });
assertObjectMatches(objectArg, await bucket.fastGet({ path: targetPath }), targetPath);
} finally {
await bucket.fastRemove({ path: tempPath }).catch(() => {});
}
}
public async readObject(pathArg: string) {
const bucket = await this.getBucket();
return await bucket.fastGet({ path: normalizeRemotePath(pathArg) });
}
public async deletePrefix(pathPrefixArg: string): Promise<void> {
const bucket = await this.getBucket();
const prefix = normalizeRemotePath(pathPrefixArg).replace(/\/+$/, '');
for await (const objectPath of bucket.listAllObjects(prefix)) {
await bucket.fastRemove({ path: objectPath });
}
}
}
class SmbBackupTargetWriter implements IBackupTargetWriter {
public targetType: TTargetType = 'smb';
private clientPromise?: Promise<plugins.smartsamba.SambaClient>;
private async getClient() {
if (!this.clientPromise) {
this.clientPromise = (async () => {
const client = new plugins.smartsamba.SambaClient({
host: requiredEnv('CLOUDLY_BACKUP_SMB_HOST'),
...(process.env.CLOUDLY_BACKUP_SMB_PORT
? { port: Number(process.env.CLOUDLY_BACKUP_SMB_PORT) }
: {}),
auth: {
...(process.env.CLOUDLY_BACKUP_SMB_USERNAME
? { username: process.env.CLOUDLY_BACKUP_SMB_USERNAME }
: {}),
...(process.env.CLOUDLY_BACKUP_SMB_PASSWORD
? { password: process.env.CLOUDLY_BACKUP_SMB_PASSWORD }
: {}),
...(process.env.CLOUDLY_BACKUP_SMB_DOMAIN
? { domain: process.env.CLOUDLY_BACKUP_SMB_DOMAIN }
: {}),
},
});
await client.start();
return client;
})();
}
return await this.clientPromise;
}
private getShare() {
return requiredEnv('CLOUDLY_BACKUP_SMB_SHARE');
}
private async ensureParentDirectory(pathArg: string) {
const client = await this.getClient();
const parent = plugins.path.posix.dirname(pathArg);
if (!parent || parent === '.') {
return;
}
const parts = parent.split('/').filter(Boolean);
let current = '';
for (const part of parts) {
current = current ? `${current}/${part}` : part;
await client.createDirectory(this.getShare(), current).catch(() => {});
}
}
public async hasObject(pathArg: string, objectArg: TArchiveObject) {
try {
return objectMatches(objectArg, await this.readObject(pathArg));
} catch {
return false;
}
}
public async putObject(pathArg: string, objectArg: TArchiveObject, contentsArg: Buffer) {
const targetPath = normalizeRemotePath(pathArg);
assertObjectMatches(objectArg, contentsArg, targetPath);
const client = await this.getClient();
const share = this.getShare();
const tempPath = `${targetPath}.upload-${Date.now()}-${plugins.smartunique.shortId()}.tmp`;
await this.ensureParentDirectory(targetPath);
try {
await client.writeFile(share, tempPath, contentsArg);
assertObjectMatches(objectArg, await client.readFile(share, tempPath), tempPath);
await client.deleteFile(share, targetPath).catch(() => {});
await client.rename(share, tempPath, targetPath);
assertObjectMatches(objectArg, await client.readFile(share, targetPath), targetPath);
} finally {
await client.deleteFile(share, tempPath).catch(() => {});
}
}
public async readObject(pathArg: string) {
return await (await this.getClient()).readFile(this.getShare(), normalizeRemotePath(pathArg));
}
public async deletePrefix(pathPrefixArg: string): Promise<void> {
const client = await this.getClient();
const share = this.getShare();
const rootPath = normalizeRemotePath(pathPrefixArg).replace(/\/+$/, '');
const deleteDirectoryFiles = async (pathArg: string): Promise<void> => {
const entries = await client.listDirectory(share, pathArg).catch(() => []);
for (const entry of entries) {
const childPath = `${pathArg}/${entry.name}`.replace(/^\/+/, '');
if (entry.isDirectory) {
await deleteDirectoryFiles(childPath);
} else {
await client.deleteFile(share, childPath).catch(() => {});
}
}
};
await deleteDirectoryFiles(rootPath);
}
}
export const createBackupTargetWriterFromEnv = (): IBackupTargetWriter => {
const targetType = process.env.CLOUDLY_BACKUP_TARGET_TYPE as TTargetType | undefined;
if (targetType === 's3') {
return new S3BackupTargetWriter();
}
if (targetType === 'smb') {
return new SmbBackupTargetWriter();
}
throw new Error('No remote backup target configured. Set CLOUDLY_BACKUP_TARGET_TYPE to s3 or smb.');
};
+113
View File
@@ -0,0 +1,113 @@
import * as plugins from '../plugins.js';
/**
* BareMetal represents an actual physical server
*/
@plugins.smartdata.Manager()
export class BareMetal extends plugins.smartdata.SmartDataDbDoc<
BareMetal,
plugins.servezoneInterfaces.data.IBareMetal
> {
// STATIC
public static async createFromHetznerServer(
hetznerServerArg: plugins.hetznercloud.HetznerServer,
) {
const serverData = hetznerServerArg.data;
if (!serverData) {
throw new Error('Hetzner server response is missing server data');
}
const ipv4 = serverData.public_net.ipv4;
if (!ipv4) {
throw new Error(`Hetzner server ${serverData.id} has no primary IPv4 address`);
}
const newBareMetal = new BareMetal();
newBareMetal.id = plugins.smartunique.shortId(8);
const data: plugins.servezoneInterfaces.data.IBareMetal['data'] = {
hostname: serverData.name,
primaryIp: ipv4.ip,
provider: 'hetzner',
location: serverData.datacenter.name,
specs: {
cpuModel: serverData.server_type.cpu_type,
cpuCores: serverData.server_type.cores,
memoryGB: serverData.server_type.memory,
storageGB: serverData.server_type.disk,
storageType: 'nvme',
},
powerState: serverData.status === 'running' ? 'on' : 'off',
osInfo: {
name: 'Debian',
version: '12',
},
assignedNodeIds: [],
providerMetadata: {
hetznerServerId: serverData.id,
hetznerServerName: serverData.name,
},
};
Object.assign(newBareMetal, { data });
await newBareMetal.save();
return newBareMetal;
}
// INSTANCE
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public data!: plugins.servezoneInterfaces.data.IBareMetal['data'];
constructor() {
super();
}
public async assignNode(nodeId: string) {
if (!this.data.assignedNodeIds.includes(nodeId)) {
this.data.assignedNodeIds.push(nodeId);
await this.save();
}
}
public async removeNode(nodeId: string) {
this.data.assignedNodeIds = this.data.assignedNodeIds.filter(id => id !== nodeId);
await this.save();
}
public async updatePowerState(state: 'on' | 'off' | 'unknown') {
this.data.powerState = state;
await this.save();
}
public async powerOn(): Promise<boolean> {
// TODO: Implement IPMI power on
if (this.data.ipmiAddress && this.data.ipmiCredentials) {
// Implement IPMI power on command
console.log(`Powering on BareMetal ${this.id} via IPMI`);
await this.updatePowerState('on');
return true;
}
return false;
}
public async powerOff(): Promise<boolean> {
// TODO: Implement IPMI power off
if (this.data.ipmiAddress && this.data.ipmiCredentials) {
// Implement IPMI power off command
console.log(`Powering off BareMetal ${this.id} via IPMI`);
await this.updatePowerState('off');
return true;
}
return false;
}
public async reset(): Promise<boolean> {
// TODO: Implement IPMI reset
if (this.data.ipmiAddress && this.data.ipmiCredentials) {
// Implement IPMI reset command
console.log(`Resetting BareMetal ${this.id} via IPMI`);
return true;
}
return false;
}
}
@@ -0,0 +1,180 @@
import * as plugins from '../plugins.js';
import { Cloudly } from '../classes.cloudly.js';
import { BareMetal } from './classes.baremetal.js';
import { logger } from '../logger.js';
export class CloudlyBaremetalManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
public hetznerAccount?: plugins.hetznercloud.HetznerAccount;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CBareMetal = plugins.smartdata.setDefaultManagerForDoc(this, BareMetal);
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedRouter);
// API endpoint to get baremetal servers
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.baremetal.IRequest_Any_Cloudly_GetBaremetalServers>(
'getBaremetalServers',
async (requestData) => {
const baremetals = await this.getAllBaremetals();
return {
baremetals: await Promise.all(
baremetals.map((baremetal) => baremetal.createSavableObject())
),
};
},
),
);
// API endpoint to control baremetal via IPMI
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.baremetal.IRequest_Any_Cloudly_ControlBaremetal>(
'controlBaremetal',
async (requestData) => {
const baremetal = await this.CBareMetal.getInstance({
id: requestData.baremetalId,
});
if (!baremetal) {
return {
success: false,
message: 'BareMetal not found',
};
}
let success = false;
switch (requestData.action) {
case 'powerOn':
success = await baremetal.powerOn();
break;
case 'powerOff':
success = await baremetal.powerOff();
break;
case 'reset':
success = await baremetal.reset();
break;
}
return {
success,
message: success ? `Action ${requestData.action} completed` : `Action ${requestData.action} failed`,
};
},
),
);
}
public async start() {
const hetznerToken = await this.cloudlyRef.settingsManager.getSetting('hetznerToken');
if (hetznerToken) {
this.hetznerAccount = new plugins.hetznercloud.HetznerAccount(hetznerToken);
}
logger.log('info', 'BareMetal manager started');
}
public async stop() {
logger.log('info', 'BareMetal manager stopped');
}
/**
* Get all baremetal servers
*/
public async getAllBaremetals(): Promise<BareMetal[]> {
const baremetals = await this.CBareMetal.getInstances({});
return baremetals;
}
/**
* Get baremetal by ID
*/
public async getBaremetalById(id: string): Promise<BareMetal | null> {
const baremetal = await this.CBareMetal.getInstance({
id,
});
return baremetal;
}
/**
* Get baremetals by provider
*/
public async getBaremetalsByProvider(provider: 'hetzner' | 'aws' | 'digitalocean' | 'onpremise'): Promise<BareMetal[]> {
const baremetals = await this.CBareMetal.getInstances({
data: {
provider,
},
});
return baremetals;
}
/**
* Create baremetal from Hetzner server
*/
public async createBaremetalFromHetznerServer(hetznerServer: plugins.hetznercloud.HetznerServer): Promise<BareMetal> {
const serverData = hetznerServer.data;
if (!serverData) {
throw new Error('Hetzner server response is missing server data');
}
// Check if baremetal already exists for this Hetzner server
const existingBaremetals = await this.CBareMetal.getInstances({});
for (const baremetal of existingBaremetals) {
if (baremetal.data.providerMetadata?.hetznerServerId === serverData.id) {
logger.log('info', `BareMetal already exists for Hetzner server ${serverData.id}`);
return baremetal;
}
}
// Create new baremetal
const newBaremetal = await BareMetal.createFromHetznerServer(hetznerServer);
logger.log('success', `Created new BareMetal ${newBaremetal.id} from Hetzner server ${serverData.id}`);
return newBaremetal;
}
/**
* Sync baremetals with Hetzner
*/
public async syncWithHetzner() {
if (!this.hetznerAccount) {
logger.log('warn', 'Cannot sync with Hetzner - no account configured');
return;
}
const hetznerServers = await this.hetznerAccount.getServers();
for (const hetznerServer of hetznerServers) {
await this.createBaremetalFromHetznerServer(hetznerServer);
}
logger.log('success', `Synced ${hetznerServers.length} servers from Hetzner`);
}
/**
* Provision a new baremetal server
*/
public async provisionBaremetal(options: {
provider: 'hetzner' | 'aws' | 'digitalocean';
location: any; // TODO: Import proper type from hetznercloud when available
type: any; // TODO: Import proper type from hetznercloud when available
}): Promise<BareMetal> {
if (options.provider === 'hetzner' && this.hetznerAccount) {
const hetznerServer = await this.hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('baremetal'),
location: options.location,
type: options.type,
});
const baremetal = await this.createBaremetalFromHetznerServer(hetznerServer);
return baremetal;
}
throw new Error(`Provider ${options.provider} not supported or not configured`);
}
}
@@ -0,0 +1,87 @@
import * as plugins from '../plugins.js';
export type TBaseOsImageArchitecture = 'amd64' | 'arm64' | 'rpi';
export type TBaseOsImageKind = 'ubuntu-iso' | 'balena-raw';
export type TBaseOsImageSourcePreset =
| 'balena-generic-amd64'
| 'balena-generic-aarch64'
| 'balena-raspberrypi4-64';
export type TBaseOsImageBuildStatus = 'queued' | 'building' | 'ready' | 'failed' | 'cancelled';
export interface IBaseOsImageArtifact {
bucketName: string;
key: string;
filename: string;
contentType: string;
size: number;
sha256: string;
createdAt: number;
}
export interface IBaseOsImageBuildPublic {
id: string;
data: {
status: TBaseOsImageBuildStatus;
architecture: TBaseOsImageArchitecture;
imageKind?: TBaseOsImageKind;
cloudlyUrl: string;
sourceImageUrl?: string;
sourceImagePreset?: TBaseOsImageSourcePreset;
balenaOsVersion?: string;
ubuntuVersion?: string;
hostname?: string;
wifiSsid?: string;
sshPublicKey?: string;
artifact?: IBaseOsImageArtifact;
errorText?: string;
logs: string[];
createdAt: number;
updatedAt: number;
startedAt?: number;
completedAt?: number;
expiresAt?: number;
};
}
@plugins.smartdata.managed()
export class BaseOsImageBuild extends plugins.smartdata.SmartDataDbDoc<
BaseOsImageBuild,
IBaseOsImageBuildPublic
> {
constructor(optionsArg?: IBaseOsImageBuildPublic & {
provisioningTokenHash?: string;
provisioningTokenConsumedAt?: number;
downloadTokenHash?: string;
downloadTokenExpiresAt?: number;
}) {
super();
if (optionsArg) {
Object.assign(this, optionsArg);
}
}
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public provisioningTokenHash!: string;
@plugins.smartdata.svDb()
public provisioningTokenConsumedAt?: number;
@plugins.smartdata.svDb()
public downloadTokenHash?: string;
@plugins.smartdata.svDb()
public downloadTokenExpiresAt?: number;
@plugins.smartdata.svDb()
public data!: IBaseOsImageBuildPublic['data'];
public toPublicBuild(): IBaseOsImageBuildPublic {
return {
id: this.id,
data: this.data,
};
}
}
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
import * as plugins from '../plugins.js';
export type TBaseOsRuntimeLevel = 'app-layer' | 'host-os' | 'target-state';
export type TBaseOsCloudlyConnectionStatus =
| 'not-configured'
| 'connecting'
| 'connected'
| 'failed';
export interface IBaseOsRuntimeInfo {
runtime: 'baseos';
runtimeLevel: TBaseOsRuntimeLevel;
nodeId: string;
cloudlyUrl?: string;
cloudlyConnectionStatus: TBaseOsCloudlyConnectionStatus;
supervisorAvailable: boolean;
supervisorAddress?: string;
deviceState?: Record<string, unknown>;
stateStatus?: Record<string, unknown>;
checkedAt: number;
}
export interface IBaseOsDesiredState {
release?: string;
targetState?: Record<string, unknown>;
updatedAt?: number;
}
export interface IBaseOsNodeData {
runtimeInfo: IBaseOsRuntimeInfo;
desiredState?: IBaseOsDesiredState;
createdAt: number;
updatedAt: number;
lastHeartbeatAt?: number;
}
export interface IBaseOsNodePublic {
id: string;
data: IBaseOsNodeData;
}
@plugins.smartdata.managed()
export class BaseOsNode extends plugins.smartdata.SmartDataDbDoc<BaseOsNode, IBaseOsNodePublic> {
constructor(optionsArg?: IBaseOsNodePublic & { nodeToken?: string }) {
super();
if (optionsArg) {
Object.assign(this, optionsArg);
}
}
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public nodeToken!: string;
@plugins.smartdata.svDb()
public data!: IBaseOsNodeData;
public toPublicNode(): IBaseOsNodePublic {
return {
id: this.id,
data: this.data,
};
}
}
+3
View File
@@ -0,0 +1,3 @@
import * as plugins from '../plugins.js';
export class Cert extends plugins.smartdata.SmartDataDbDoc<Cert, Cert> {}
+14
View File
@@ -0,0 +1,14 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
export class CertManager {
public cloudlyRef: Cloudly;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
constructor(cloudly: Cloudly) {
this.cloudlyRef = cloudly;
}
}
@@ -4,11 +4,12 @@ import * as plugins from '../plugins.js';
* cluster defines a swarmkit cluster
*/
@plugins.smartdata.managed()
export class Cluster extends plugins.smartdata.SmartDataDbDoc<Cluster, plugins.servezoneInterfaces.data.ICluster> {
export class Cluster extends plugins.smartdata.SmartDataDbDoc<
Cluster,
plugins.servezoneInterfaces.data.ICluster
> {
// STATIC
public static async fromConfigObject(
configObjectArg: plugins.servezoneInterfaces.data.ICluster
) {
public static async fromConfigObject(configObjectArg: plugins.servezoneInterfaces.data.ICluster) {
const newCluster = new Cluster();
Object.assign(newCluster, configObjectArg);
return newCluster;
@@ -16,10 +17,10 @@ export class Cluster extends plugins.smartdata.SmartDataDbDoc<Cluster, plugins.s
// INSTANCE
@plugins.smartdata.unI()
public id: string;
public id!: string;
@plugins.smartdata.svDb()
public data: plugins.servezoneInterfaces.data.ICluster['data'];
public data!: plugins.servezoneInterfaces.data.ICluster['data'];
constructor() {
super();
@@ -0,0 +1,199 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { Cloudly } from '../classes.cloudly.js';
import { logger } from '../logger.js';
import { Cluster } from './classes.cluster.js';
import { data } from '@serve.zone/interfaces';
export class ClusterManager {
public ready = plugins.smartpromise.defer();
public cloudlyRef: Cloudly;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public typedrouter = new plugins.typedrequest.TypedRouter();
public CCluster = plugins.smartdata.setDefaultManagerForDoc(this, Cluster);
constructor(cloudlyRef: Cloudly) {
this.cloudlyRef = cloudlyRef;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.cluster.IRequest_CreateCluster>(
new plugins.typedrequest.TypedHandler('createCluster', async (dataArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], dataArg);
const setupMode = dataArg.setupMode || 'manual'; // Default to manual if not specified
const cluster = await this.createCluster({
id: plugins.smartunique.uniSimple('cluster'),
data: {
userId: '', // this is created by the createCluster method
name: dataArg.clusterName,
setupMode: setupMode,
acmeInfo: {
serverAddress: '',
serverSecret: '',
},
cloudlyUrl: `https://${this.cloudlyRef.config.data.publicUrl}:${this.cloudlyRef.config.data.publicPort}/`,
nodes: [],
sshKeys: [],
},
});
console.log(await cluster.createSavableObject());
// Only auto-provision servers if setupMode is 'hetzner'
if (setupMode === 'hetzner') {
this.cloudlyRef.nodeManager.ensureNodeInfrastructure();
}
return {
cluster: await cluster.createSavableObject(),
};
}),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.cluster.IReq_Any_Cloudly_GetClusters>(
new plugins.typedrequest.TypedHandler('getClusters', async (dataArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], dataArg);
const clusters = await this.getAllClusters();
return {
clusters: await Promise.all(
clusters.map((clusterArg) => clusterArg.createSavableObject()),
),
};
}),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.cluster.IReq_Any_Cloudly_GetClusterById>(
new plugins.typedrequest.TypedHandler('getClusterById', async (dataArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], dataArg);
const cluster = await this.CCluster.getInstance({ id: (dataArg as any).clusterId });
if (!cluster) {
throw new plugins.typedrequest.TypedResponseError('Cluster not found');
}
return {
cluster: await cluster.createSavableObject(),
};
}),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.cluster.IReq_Any_Cloudly_UpdateCluster>(
new plugins.typedrequest.TypedHandler('updateCluster', async (dataArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], dataArg);
const cluster = await this.CCluster.getInstance({ id: (dataArg as any).clusterId });
if (!cluster) {
throw new plugins.typedrequest.TypedResponseError('Cluster not found');
}
cluster.data = {
...cluster.data,
...dataArg.clusterData,
};
await cluster.save();
return {
resultCluster: await cluster.createSavableObject(),
};
}),
);
// delete cluster
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.cluster.IReq_Any_Cloudly_DeleteClusterById>(
new plugins.typedrequest.TypedHandler('deleteClusterById', async (reqDataArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], reqDataArg);
await this.deleteCluster(reqDataArg.clusterId);
return {
ok: true,
};
}),
);
}
public async init() {
// lets read the config folder
logger.log('info', 'config manager is now initializing');
this.ready.resolve();
}
/**
* gets a cluster config by Name
*/
public async getClusterConfigBy_ServerIP() {
await this.ready.promise;
// TODO: implement getclusterConfigByServerIp
}
public async getClusterBy_UserId(userIdArg: string) {
await this.ready.promise;
return await Cluster.getInstance({
data: {
userId: userIdArg,
},
});
}
public async getClusterBy_Identity(clusterIdentity: plugins.servezoneInterfaces.data.IIdentity) {
await this.ready.promise;
return await Cluster.getInstance({
data: {
userId: clusterIdentity.userId,
},
});
}
/**
* get config by id
*/
public async getConfigBy_ConfigID(configId: string) {
await this.ready.promise;
return await Cluster.getInstance({
id: configId,
});
}
/**
* gets all cluster configs
*/
public async getAllClusters() {
await this.ready.promise;
return await Cluster.getInstances({});
}
/**
* creates a cluster (and a new user for it) and saves it
* @param configName
* @param configObjectArg
*/
public async createCluster(configObjectArg: plugins.servezoneInterfaces.data.ICluster) {
// lets create the cluster user
const clusterUser = new this.cloudlyRef.authManager.CUser({
id: await this.cloudlyRef.authManager.CUser.getNewId(),
data: {
username: `cluster-${configObjectArg.id}`,
role: 'cluster',
type: 'machine',
tokens: [
{
expiresAt: Date.now() + 3600 * 1000 * 24 * 365,
assignedRoles: ['cluster'],
token: await this.cloudlyRef.authManager.createNewSecureToken(),
},
],
},
});
await clusterUser.save();
configObjectArg.data.userId = clusterUser.id;
const clusterInstance = await Cluster.fromConfigObject(configObjectArg);
await clusterInstance.save();
return clusterInstance;
}
public async deleteCluster(clusterId: string) {
await this.ready.promise;
const clusterInstance = await Cluster.getInstance({ id: clusterId });
await clusterInstance.delete();
}
}
-131
View File
@@ -1,131 +0,0 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { Cloudly } from '../classes.cloudly.js';
import { logger } from '../logger.js';
import { Cluster } from './cluster.js';
export class ClusterManager {
public ready = plugins.smartpromise.defer();
public cloudlyRef: Cloudly;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public typedrouter = new plugins.typedrequest.TypedRouter();
public CCluster = plugins.smartdata.setDefaultManagerForDoc(this, Cluster);
constructor(cloudlyRef: Cloudly) {
this.cloudlyRef = cloudlyRef;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.cluster.IRequest_CreateCluster>(
new plugins.typedrequest.TypedHandler('createCluster', async (dataArg) => {
const cluster = await this.storeCluster({
id: plugins.smartunique.uniSimple('cluster'),
data: {
name: dataArg.clusterName,
jumpCode: plugins.smartunique.uniSimple('cluster'),
jumpCodeUsedAt: null,
secretKey: plugins.smartunique.shortId(16),
acmeInfo: null,
cloudlyUrl: `https://${this.cloudlyRef.config.data.publicUrl}:${this.cloudlyRef.config.data.publicPort}/`,
servers: [],
sshKeys: [],
},
});
console.log(await cluster.createSavableObject());
this.cloudlyRef.serverManager.ensureServerInfrastructure();
return {
clusterConfig: await cluster.createSavableObject(),
};
})
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.cluster.IRequest_GetAllClusters>(
new plugins.typedrequest.TypedHandler('getAllClusters', async (dataArg) => {
// TODO: do authentication here
const clusters = await this.getAllClusters();
return {
clusters: await Promise.all(
clusters.map((clusterArg) => clusterArg.createSavableObject())
),
};
})
);
}
public async init() {
// lets read the config folder
logger.log('info', 'config manager is now initializing');
this.ready.resolve();
}
/**
* gets a cluster config by Name
*/
public async getClusterConfigBy_ServerIP() {
await this.ready.promise;
// TODO: implement getclusterConfigByServerIp
}
public async getClusterConfigBy_JumpCode(jumpCodeArg: string) {
await this.ready.promise;
return await Cluster.getInstance({
data: {
jumpCode: jumpCodeArg,
},
});
}
public async getClusterConfigBy_ClusterIdentifier(
clusterIdentifier: plugins.servezoneInterfaces.data.IClusterIdentifier
) {
await this.ready.promise;
return await Cluster.getInstance({
data: {
name: clusterIdentifier.clusterName,
secretKey: clusterIdentifier.secretKey,
},
});
}
/**
* get config by id
*/
public async getConfigBy_ConfigID(configId: string) {
await this.ready.promise;
return await Cluster.getInstance({
id: configId,
});
}
/**
* gets all cluster configs
*/
public async getAllClusters() {
await this.ready.promise;
return await Cluster.getInstances({});
}
/**
* allows storage of a config
* @param configName
* @param configObjectArg
*/
public async storeCluster(configObjectArg: plugins.servezoneInterfaces.data.ICluster) {
let clusterInstance = await Cluster.getInstance({ id: configObjectArg.id });
if (!clusterInstance) {
clusterInstance = await Cluster.fromConfigObject(configObjectArg);
} else {
Object.assign(clusterInstance, configObjectArg);
}
await clusterInstance.save();
return clusterInstance;
}
}
+222 -26
View File
@@ -1,5 +1,31 @@
import * as plugins from '../plugins.js';
import { Cloudly } from '../classes.cloudly.js';
import type { Cluster } from '../manager.cluster/classes.cluster.js';
import { logger } from '../logger.js';
type TCoreflowDeploymentRequest =
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_RestartDeployment
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_KillDeployment
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadFile
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceWriteFile
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceReadDir
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceMkdir
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceRm
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExists
| plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_DeploymentWorkspaceExec;
type TCoreflowDeploymentActionMethod =
| 'coreflowRestartDeployment'
| 'coreflowKillDeployment';
type TCoreflowDeploymentActionRequest = Extract<TCoreflowDeploymentRequest, {
method: TCoreflowDeploymentActionMethod;
}>;
export type TCoreflowDeploymentWorkspaceMethod = Exclude<
TCoreflowDeploymentRequest['method'],
TCoreflowDeploymentActionMethod
>;
/**
* in charge of talking to coreflow services on clusters
@@ -13,21 +39,50 @@ export class CloudlyCoreflowManager {
this.cloudlyRef = cloudlyRefArg;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.requests.identity.IRequest_Any_Cloudly_CoreflowManager_GetIdentityByJumpCode>(
new plugins.typedrequest.TypedHandler('getIdentityByJumpCode', async (requestData) => {
const clusterConfig =
await this.cloudlyRef.clusterManager.getClusterConfigBy_JumpCode(
requestData.jumpCode
this.typedRouter.addTypedHandler<plugins.servezoneInterfaces.requests.identity.IRequest_Any_Cloudly_CoreflowManager_GetIdentityByToken>(
new plugins.typedrequest.TypedHandler('getIdentityByToken', async (requestData) => {
// Use getInstance with $elemMatch for querying nested arrays
const user = await this.cloudlyRef.authManager.CUser.getInstance({
data: {
tokens: {
$elemMatch: { token: requestData.token },
},
},
});
if (!user) {
throw new plugins.typedrequest.TypedResponseError(
'The supplied token is not valid. No matching user found.'
);
if (!clusterConfig) {
throw new plugins.typedrequest.TypedResponseError('The supplied jumpCode is not valid.');
}
if (user.data.type !== 'machine') {
throw new plugins.typedrequest.TypedResponseError(
'The supplied token is not valid. The user is not a machine.'
);
}
let cluster: Cluster | undefined;
if (user.data.role === 'cluster') {
cluster = await this.cloudlyRef.clusterManager.getClusterBy_UserId(user.id);
}
const expiryTimestamp = Date.now() + 3600 * 1000 * 24 * 365;
return {
clusterIdentifier: {
clusterName: clusterConfig.data.name,
secretKey: clusterConfig.data.secretKey,
identity: {
name: user.data.username || user.id,
role: user.data.role,
type: 'machine', // if someone authenticates by token, they are a machine, no matter what.
userId: user.id,
expiresAt: expiryTimestamp,
...(cluster
? {
clusterId: cluster.id,
clusterName: cluster.data.name,
}
: {}),
jwt: await this.cloudlyRef.authManager.smartjwtInstance.createJWT({
status: 'loggedIn',
userId: user.id,
expiresAt: expiryTimestamp,
}),
},
};
})
@@ -38,36 +93,177 @@ export class CloudlyCoreflowManager {
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.config.IRequest_Any_Cloudly_GetClusterConfig>(
'getClusterConfig',
async (dataArg) => {
const clusterIdentifier = dataArg.clusterIdentifier;
const identity = dataArg.identity;
console.log('trying to get clusterConfigSet');
console.log(dataArg);
const clusterConfigSet =
await this.cloudlyRef.clusterManager.getClusterConfigBy_ClusterIdentifier(
clusterIdentifier
);
const clusterConfig = await this.getClusterConfigPayloadForIdentity(identity);
console.log('got cluster config and sending it back to coreflow');
return {
configData: await clusterConfigSet.createSavableObject()
};
return clusterConfig;
}
)
);
// lets enable getting of certificates
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.certificate.IRequest_Any_Cloudly_GetSslCertificate>(
'getSslCertificate',
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.certificate.IRequest_Any_Cloudly_GetCertificateForDomain>(
'getCertificateForDomain',
async (dataArg) => {
console.log(`got request for certificate ${dataArg.requiredCertName}`);
console.log(`incoming API request for certificate ${dataArg.domainName}`);
const cert = await this.cloudlyRef.letsencryptConnector.getCertificateForDomain(
dataArg.requiredCertName
dataArg.domainName
);
console.log(`got certificate ready for reponse ${dataArg.requiredCertName}`);
console.log(`got certificate ready for reponse ${dataArg.domainName}`);
return {
certificate: await cert.createSavableObject(),
certificate: cert,
};
}
)
);
}
public async getClusterConfigPayloadForIdentity(
identityArg: plugins.servezoneInterfaces.data.IIdentity,
): Promise<plugins.servezoneInterfaces.requests.config.IRequest_Cloudly_Coreflow_PushClusterConfig['request']> {
const cluster = await this.cloudlyRef.clusterManager.getClusterBy_Identity(identityArg);
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
const platformDesiredState = await this.cloudlyRef.platformManager.getPlatformDesiredState();
const settings = await this.cloudlyRef.settingsManager.getSettings();
const targetPort = Number(settings.dcrouterTargetPort || '80');
const externalGateway = settings.dcrouterGatewayUrl && settings.dcrouterGatewayApiToken
? {
url: settings.dcrouterGatewayUrl,
apiToken: settings.dcrouterGatewayApiToken,
workHosterType: 'cloudly' as const,
workHosterId: settings.dcrouterWorkHosterId || cluster.id,
targetHost: settings.dcrouterTargetHost,
targetPort: Number.isInteger(targetPort) && targetPort > 0 ? targetPort : 80,
}
: undefined;
const payload: plugins.servezoneInterfaces.requests.config.IRequest_Cloudly_Coreflow_PushClusterConfig['request'] & {
externalGateway?: typeof externalGateway;
} = {
configData: await cluster.createSavableObject(),
services: await Promise.all(services.map((service) => service.createSavableObject())),
platformProviderConfigs: platformDesiredState.providerConfigs,
platformBindings: platformDesiredState.bindings,
};
if (externalGateway) {
payload.externalGateway = externalGateway;
}
return payload;
}
public async pushClusterConfigToConnectedCoreflows() {
const typedsocket = this.cloudlyRef.server.typedServer?.typedsocket;
if (!typedsocket) {
return 0;
}
const connections = await typedsocket.findAllTargetConnections(async (connectionArg) => {
const identityTag = await connectionArg.getTagById('identity');
const identity = identityTag?.payload as plugins.servezoneInterfaces.data.IIdentity | undefined;
return identity?.role === 'cluster' && !!identity.userId;
});
await Promise.all(
connections.map(async (connectionArg) => {
const identityTag = await connectionArg.getTagById('identity');
const identity = identityTag?.payload as plugins.servezoneInterfaces.data.IIdentity;
try {
const pushClusterConfig = typedsocket.createTypedRequest<plugins.servezoneInterfaces.requests.config.IRequest_Cloudly_Coreflow_PushClusterConfig>(
'pushClusterConfig',
connectionArg,
);
await pushClusterConfig.fire(await this.getClusterConfigPayloadForIdentity(identity));
} catch (error) {
logger.log('error', `failed to push cluster config to coreflow ${identity.userId}: ${(error as Error).message}`);
}
}),
);
return connections.length;
}
public async getRuntimeDeploymentsForService(
serviceArg: plugins.servezoneInterfaces.data.IService,
): Promise<plugins.servezoneInterfaces.data.IDeployment[]> {
const connections = await this.getConnectedCoreflowConnections();
const deployments: plugins.servezoneInterfaces.data.IDeployment[] = [];
for (const connection of connections) {
try {
const request = this.cloudlyRef.server.typedServer.typedsocket.createTypedRequest<plugins.servezoneInterfaces.requests.deployment.IReq_Cloudly_Coreflow_GetServiceDeployments>(
'coreflowGetServiceDeployments',
connection,
);
const response = await request.fire({ service: serviceArg });
deployments.push(...(response.deployments || []));
} catch (error) {
logger.log('warn', `failed to query coreflow deployments: ${(error as Error).message}`);
}
}
return deployments;
}
public async fireDeploymentRuntimeAction(
methodArg: TCoreflowDeploymentActionMethod,
deploymentIdArg: string,
): Promise<{ deployment: plugins.servezoneInterfaces.data.IDeployment }> {
const response = await this.fireCoreflowRequestUntilFound<TCoreflowDeploymentActionRequest>(methodArg, {
deploymentId: deploymentIdArg,
});
if (!response.deployment) {
throw new plugins.typedrequest.TypedResponseError('Coreflow did not return deployment data');
}
return { deployment: response.deployment };
}
public async fireDeploymentWorkspaceRequest(
methodArg: TCoreflowDeploymentWorkspaceMethod,
payloadArg: Extract<TCoreflowDeploymentRequest, { method: typeof methodArg }>['request'],
) {
return await this.fireCoreflowRequestUntilFound(methodArg, payloadArg);
}
private async fireCoreflowRequestUntilFound<TRequest extends TCoreflowDeploymentRequest>(
methodArg: TRequest['method'],
payloadArg: TRequest['request'],
): Promise<TRequest['response']> {
const connections = await this.getConnectedCoreflowConnections();
if (connections.length === 0) {
throw new plugins.typedrequest.TypedResponseError('No connected coreflow');
}
let lastError: Error | undefined;
for (const connection of connections) {
try {
const request = this.cloudlyRef.server.typedServer.typedsocket.createTypedRequest<TRequest>(
methodArg,
connection,
);
const response = await request.fire(payloadArg);
if (response?.found) {
return response;
}
} catch (error) {
lastError = error as Error;
}
}
throw new plugins.typedrequest.TypedResponseError(
lastError?.message || 'No connected coreflow found the requested deployment',
);
}
private async getConnectedCoreflowConnections() {
const typedsocket = this.cloudlyRef.server.typedServer?.typedsocket;
if (!typedsocket) {
return [];
}
return await typedsocket.findAllTargetConnections(async (connectionArg) => {
const identityTag = await connectionArg.getTagById('identity');
const identity = identityTag?.payload as plugins.servezoneInterfaces.data.IIdentity | undefined;
return identity?.role === 'cluster' && !!identity.userId;
});
}
}
@@ -0,0 +1,98 @@
import * as plugins from '../plugins.js';
@plugins.smartdata.managed()
export class Deployment extends plugins.smartdata.SmartDataDbDoc<
Deployment,
plugins.servezoneInterfaces.data.IDeployment
> {
@plugins.smartdata.unI()
public id: string = plugins.smartunique.uniSimple('deployment');
@plugins.smartdata.svDb()
public serviceId!: string;
@plugins.smartdata.svDb()
public nodeId!: string;
@plugins.smartdata.svDb()
public containerId?: string;
@plugins.smartdata.svDb()
public usedImageId!: string;
@plugins.smartdata.svDb()
public version!: string;
@plugins.smartdata.svDb()
public deployedAt!: number;
@plugins.smartdata.svDb()
public deploymentLog: string[] = [];
@plugins.smartdata.svDb()
public status!: 'scheduled' | 'starting' | 'running' | 'stopping' | 'stopped' | 'failed';
@plugins.smartdata.svDb()
public healthStatus?: 'healthy' | 'unhealthy' | 'unknown';
@plugins.smartdata.svDb()
public resourceUsage?: {
cpuUsagePercent: number;
memoryUsedMB: number;
lastUpdated: number;
};
public static async createDeployment(
deploymentData: Partial<plugins.servezoneInterfaces.data.IDeployment>
): Promise<Deployment> {
const deployment = new Deployment();
if (deploymentData.serviceId) deployment.serviceId = deploymentData.serviceId;
if (deploymentData.nodeId) deployment.nodeId = deploymentData.nodeId;
if (deploymentData.containerId) deployment.containerId = deploymentData.containerId;
if (deploymentData.usedImageId) deployment.usedImageId = deploymentData.usedImageId;
if (deploymentData.version) deployment.version = deploymentData.version;
if (deploymentData.deployedAt) deployment.deployedAt = deploymentData.deployedAt;
if (deploymentData.deploymentLog) deployment.deploymentLog = deploymentData.deploymentLog;
if (deploymentData.status) deployment.status = deploymentData.status;
if (deploymentData.healthStatus) deployment.healthStatus = deploymentData.healthStatus;
if (deploymentData.resourceUsage) deployment.resourceUsage = deploymentData.resourceUsage;
await deployment.save();
return deployment;
}
public async updateHealthStatus(healthStatus: 'healthy' | 'unhealthy' | 'unknown') {
this.healthStatus = healthStatus;
await this.save();
}
public async updateResourceUsage(cpuUsagePercent: number, memoryUsedMB: number) {
this.resourceUsage = {
cpuUsagePercent,
memoryUsedMB,
lastUpdated: Date.now(),
};
await this.save();
}
public async addLogEntry(entry: string) {
this.deploymentLog.push(entry);
await this.save();
}
public async createSavableObject(): Promise<plugins.servezoneInterfaces.data.IDeployment> {
return {
id: this.id,
serviceId: this.serviceId,
nodeId: this.nodeId,
containerId: this.containerId,
usedImageId: this.usedImageId,
version: this.version,
deployedAt: this.deployedAt,
deploymentLog: this.deploymentLog,
status: this.status,
healthStatus: this.healthStatus,
resourceUsage: this.resourceUsage,
};
}
}
@@ -0,0 +1,379 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import { Deployment } from './classes.deployment.js';
import type { TCoreflowDeploymentWorkspaceMethod } from '../manager.coreflow/coreflowmanager.js';
export class DeploymentManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
public cloudlyRef: Cloudly;
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CDeployment = plugins.smartdata.setDefaultManagerForDoc(this, Deployment);
constructor(cloudlyRef: Cloudly) {
this.cloudlyRef = cloudlyRef;
// Connect typedrouter to main router
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
// Get all deployments
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_GetDeployments>(
'getDeployments',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const deployments = await this.CDeployment.getInstances({});
return {
deployments: await Promise.all(
deployments.map((deployment) => deployment.createSavableObject())
),
};
}
)
);
// Get deployment by ID
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_GetDeploymentById>(
'getDeploymentById',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const deployment = await this.CDeployment.getInstance({
id: reqArg.deploymentId,
});
if (!deployment) {
throw new Error('Deployment not found');
}
return {
deployment: await deployment.createSavableObject(),
};
}
)
);
// Get deployments by service
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_GetDeploymentsByService>(
'getDeploymentsByService',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const service = await this.cloudlyRef.serviceManager.CService.getInstance({
id: reqArg.serviceId,
});
if (service) {
const runtimeDeployments = await this.cloudlyRef.coreflowManager.getRuntimeDeploymentsForService(
await service.createSavableObject(),
);
if (runtimeDeployments.length > 0) {
return { deployments: runtimeDeployments };
}
}
const deployments = await this.CDeployment.getInstances({
serviceId: reqArg.serviceId,
});
return {
deployments: await Promise.all(
deployments.map((deployment) => deployment.createSavableObject())
),
};
}
)
);
// Get deployments by node
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_GetDeploymentsByNode>(
'getDeploymentsByNode',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const deployments = await this.CDeployment.getInstances({
nodeId: reqArg.nodeId,
});
return {
deployments: await Promise.all(
deployments.map((deployment) => deployment.createSavableObject())
),
};
}
)
);
// Create deployment
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_CreateDeployment>(
'createDeployment',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const deployment = await Deployment.createDeployment(reqArg.deploymentData);
return {
deployment: await deployment.createSavableObject(),
};
}
)
);
// Update deployment
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_UpdateDeployment>(
'updateDeployment',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const deployment = await this.CDeployment.getInstance({
id: reqArg.deploymentId,
});
if (!deployment) {
throw new Error('Deployment not found');
}
// Update fields
if (reqArg.deploymentData.status !== undefined) {
deployment.status = reqArg.deploymentData.status;
}
if (reqArg.deploymentData.healthStatus !== undefined) {
deployment.healthStatus = reqArg.deploymentData.healthStatus;
}
if (reqArg.deploymentData.containerId !== undefined) {
deployment.containerId = reqArg.deploymentData.containerId;
}
if (reqArg.deploymentData.resourceUsage !== undefined) {
deployment.resourceUsage = reqArg.deploymentData.resourceUsage;
}
await deployment.save();
return {
deployment: await deployment.createSavableObject(),
};
}
)
);
// Delete deployment
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_DeleteDeploymentById>(
'deleteDeploymentById',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const deployment = await this.CDeployment.getInstance({
id: reqArg.deploymentId,
});
if (!deployment) {
throw new Error('Deployment not found');
}
const serviceId = deployment.serviceId;
await deployment.delete();
// Check if this was the last deployment for the service
const remainingDeployments = await this.getDeploymentsForService(serviceId);
if (remainingDeployments.length === 0) {
// Deactivate DNS entries if no more deployments exist
await this.cloudlyRef.dnsManager.deactivateServiceDnsEntries(serviceId);
}
return {
success: true,
};
}
)
);
// Restart deployment
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_RestartDeployment>(
'restartDeployment',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
const result = await this.cloudlyRef.coreflowManager.fireDeploymentRuntimeAction(
'coreflowRestartDeployment',
reqArg.deploymentId,
);
return {
success: true,
deployment: result.deployment,
};
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_KillDeployment>(
'killDeployment',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
const result = await this.cloudlyRef.coreflowManager.fireDeploymentRuntimeAction(
'coreflowKillDeployment',
reqArg.deploymentId,
);
return {
success: true,
deployment: result.deployment,
};
},
),
);
// Scale deployment
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.deployment.IReq_Any_Cloudly_ScaleDeployment>(
'scaleDeployment',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
// TODO: Implement scaling logic
// This would create/delete deployment instances based on replicas count
const deployment = await this.CDeployment.getInstance({
id: reqArg.deploymentId,
});
if (!deployment) {
throw new Error('Deployment not found');
}
return {
success: true,
deployment: await deployment.createSavableObject(),
};
}
)
);
const addDeploymentWorkspaceHandler = (methodArg: string, coreflowMethodArg: TCoreflowDeploymentWorkspaceMethod) => {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<any>(methodArg, async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
const { identity: _identity, ...payload } = reqArg;
const response = await this.cloudlyRef.coreflowManager.fireDeploymentWorkspaceRequest(
coreflowMethodArg,
payload,
);
const { found: _found, ...publicResponse } = response;
return publicResponse;
}),
);
};
addDeploymentWorkspaceHandler('deploymentWorkspaceReadFile', 'coreflowDeploymentWorkspaceReadFile');
addDeploymentWorkspaceHandler('deploymentWorkspaceWriteFile', 'coreflowDeploymentWorkspaceWriteFile');
addDeploymentWorkspaceHandler('deploymentWorkspaceReadDir', 'coreflowDeploymentWorkspaceReadDir');
addDeploymentWorkspaceHandler('deploymentWorkspaceMkdir', 'coreflowDeploymentWorkspaceMkdir');
addDeploymentWorkspaceHandler('deploymentWorkspaceRm', 'coreflowDeploymentWorkspaceRm');
addDeploymentWorkspaceHandler('deploymentWorkspaceExists', 'coreflowDeploymentWorkspaceExists');
addDeploymentWorkspaceHandler('deploymentWorkspaceExec', 'coreflowDeploymentWorkspaceExec');
}
/**
* Get all deployments
*/
public async getAllDeployments(): Promise<Deployment[]> {
return await this.CDeployment.getInstances({});
}
/**
* Get deployments for a specific service
*/
public async getDeploymentsForService(serviceId: string): Promise<Deployment[]> {
return await this.CDeployment.getInstances({
serviceId,
});
}
/**
* Get deployments for a specific node
*/
public async getDeploymentsForNode(nodeId: string): Promise<Deployment[]> {
return await this.CDeployment.getInstances({
nodeId,
});
}
/**
* Create a new deployment
*/
public async createDeployment(
serviceId: string,
nodeId: string,
version: string = 'latest'
): Promise<Deployment> {
const deployment = await Deployment.createDeployment({
serviceId,
nodeId,
version,
status: 'scheduled',
deployedAt: Date.now(),
deploymentLog: [`Deployment created at ${new Date().toISOString()}`],
});
// Activate DNS entries for the service
await this.cloudlyRef.dnsManager.activateServiceDnsEntries(serviceId);
// Get the node's IP address and update DNS entries
const node = await this.cloudlyRef.nodeManager.CClusterNode.getInstance({
id: nodeId,
});
if (node?.data.baremetalId) {
const baremetal = await this.cloudlyRef.baremetalManager.CBareMetal.getInstance({
id: node.data.baremetalId,
});
if (baremetal?.data.primaryIp) {
await this.cloudlyRef.dnsManager.updateServiceDnsEntriesIp(serviceId, baremetal.data.primaryIp);
}
}
return deployment;
}
public async start() {
// DeploymentManager is ready - handlers are already registered in constructor
console.log('DeploymentManager started');
}
public async stop() {
// Cleanup if needed
console.log('DeploymentManager stopped');
}
}
+149
View File
@@ -0,0 +1,149 @@
import * as plugins from '../plugins.js';
import { DnsManager } from './classes.dnsmanager.js';
@plugins.smartdata.managed()
export class DnsEntry extends plugins.smartdata.SmartDataDbDoc<
DnsEntry,
plugins.servezoneInterfaces.data.IDnsEntry,
DnsManager
> {
// STATIC
public static async getDnsEntryById(dnsEntryIdArg: string) {
const dnsEntry = await this.getInstance({
id: dnsEntryIdArg,
});
return dnsEntry;
}
public static async getDnsEntries(filterArg?: { zone?: string }) {
const filter: any = {};
if (filterArg?.zone) {
filter['data.zone'] = filterArg.zone;
}
const dnsEntries = await this.getInstances(filter);
return dnsEntries;
}
public static async getDnsZones() {
const dnsEntries = await this.getInstances({});
const zones = new Set<string>();
for (const entry of dnsEntries) {
if (entry.data.zone) {
zones.add(entry.data.zone);
}
}
return Array.from(zones).sort();
}
public static async createDnsEntry(dnsEntryDataArg: plugins.servezoneInterfaces.data.IDnsEntry['data']) {
const dnsEntry = new DnsEntry();
dnsEntry.id = await DnsEntry.getNewId();
dnsEntry.data = {
...dnsEntryDataArg,
ttl: dnsEntryDataArg.ttl || 3600, // Default TTL: 1 hour
active: dnsEntryDataArg.active !== false, // Default to active
createdAt: Date.now(),
updatedAt: Date.now(),
};
await dnsEntry.save();
return dnsEntry;
}
public static async updateDnsEntry(
dnsEntryIdArg: string,
dnsEntryDataArg: Partial<plugins.servezoneInterfaces.data.IDnsEntry['data']>
) {
const dnsEntry = await this.getInstance({
id: dnsEntryIdArg,
});
if (!dnsEntry) {
throw new Error(`DNS entry with id ${dnsEntryIdArg} not found`);
}
Object.assign(dnsEntry.data, dnsEntryDataArg, {
updatedAt: Date.now(),
});
await dnsEntry.save();
return dnsEntry;
}
public static async deleteDnsEntry(dnsEntryIdArg: string) {
const dnsEntry = await this.getInstance({
id: dnsEntryIdArg,
});
if (!dnsEntry) {
throw new Error(`DNS entry with id ${dnsEntryIdArg} not found`);
}
await dnsEntry.delete();
return true;
}
// INSTANCE
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public data!: plugins.servezoneInterfaces.data.IDnsEntry['data'];
/**
* Validates the DNS entry data
*/
public validateData(): boolean {
const { type, name, value, zone } = this.data;
// Basic validation
if (!type || !name || !value || !zone) {
return false;
}
// Type-specific validation
switch (type) {
case 'A':
// Validate IPv4 address
return /^(\d{1,3}\.){3}\d{1,3}$/.test(value);
case 'AAAA':
// Validate IPv6 address (simplified)
return /^([0-9a-fA-F]{0,4}:){7}[0-9a-fA-F]{0,4}$/.test(value);
case 'MX':
// MX records must have priority
return this.data.priority !== undefined && this.data.priority >= 0;
case 'SRV':
// SRV records must have priority, weight, and port
return (
this.data.priority !== undefined &&
this.data.weight !== undefined &&
this.data.port !== undefined
);
case 'CNAME':
case 'NS':
case 'PTR':
// These should point to valid domain names
return /^[a-zA-Z0-9.-]+$/.test(value);
case 'TXT':
case 'CAA':
case 'SOA':
// These can contain any text
return true;
default:
return false;
}
}
/**
* Get a formatted string representation of the DNS entry
*/
public toFormattedString(): string {
const { type, name, value, ttl, priority } = this.data;
let result = `${name} ${ttl} IN ${type}`;
if (priority !== undefined) {
result += ` ${priority}`;
}
if (type === 'SRV' && this.data.weight !== undefined && this.data.port !== undefined) {
result += ` ${this.data.weight} ${this.data.port}`;
}
result += ` ${value}`;
return result;
}
}
+267
View File
@@ -0,0 +1,267 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import { DnsEntry } from './classes.dnsentry.js';
export class DnsManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
public cloudlyRef: Cloudly;
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CDnsEntry = plugins.smartdata.setDefaultManagerForDoc(this, DnsEntry);
constructor(cloudlyRef: Cloudly) {
this.cloudlyRef = cloudlyRef;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
// Get all DNS entries
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.dns.IRequest_Any_Cloudly_GetDnsEntries>(
'getDnsEntries',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const dnsEntries = await this.CDnsEntry.getDnsEntries(
reqArg.zone ? { zone: reqArg.zone } : undefined
);
return {
dnsEntries: await Promise.all(
dnsEntries.map((entry) => entry.createSavableObject())
),
};
}
)
);
// Get DNS entry by ID
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.dns.IRequest_Any_Cloudly_GetDnsEntryById>(
'getDnsEntryById',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const dnsEntry = await this.CDnsEntry.getDnsEntryById(reqArg.dnsEntryId);
if (!dnsEntry) {
throw new Error(`DNS entry with id ${reqArg.dnsEntryId} not found`);
}
return {
dnsEntry: await dnsEntry.createSavableObject(),
};
}
)
);
// Create DNS entry
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.dns.IRequest_Any_Cloudly_CreateDnsEntry>(
'createDnsEntry',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
// Validate domain exists and is activated if domainId is provided
if (reqArg.dnsEntryData.domainId) {
const domain = await this.cloudlyRef.domainManager.CDomain.getDomainById(reqArg.dnsEntryData.domainId);
if (!domain) {
throw new Error(`Domain with id ${reqArg.dnsEntryData.domainId} not found`);
}
if ((domain.data as any).activationState !== 'activated') {
throw new Error(`Domain ${domain.data.name} is not activated; DNS changes are not allowed.`);
}
// Set the zone from the domain name
reqArg.dnsEntryData.zone = domain.data.name;
}
const dnsEntry = await this.CDnsEntry.createDnsEntry(reqArg.dnsEntryData);
return {
dnsEntry: await dnsEntry.createSavableObject(),
};
}
)
);
// Update DNS entry
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.dns.IRequest_Any_Cloudly_UpdateDnsEntry>(
'updateDnsEntry',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
// Validate domain exists and is activated if domainId is provided
if (reqArg.dnsEntryData.domainId) {
const domain = await this.cloudlyRef.domainManager.CDomain.getDomainById(reqArg.dnsEntryData.domainId);
if (!domain) {
throw new Error(`Domain with id ${reqArg.dnsEntryData.domainId} not found`);
}
if ((domain.data as any).activationState !== 'activated') {
throw new Error(`Domain ${domain.data.name} is not activated; DNS changes are not allowed.`);
}
// Set the zone from the domain name
reqArg.dnsEntryData.zone = domain.data.name;
}
const dnsEntry = await this.CDnsEntry.updateDnsEntry(
reqArg.dnsEntryId,
reqArg.dnsEntryData
);
return {
dnsEntry: await dnsEntry.createSavableObject(),
};
}
)
);
// Delete DNS entry
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.dns.IRequest_Any_Cloudly_DeleteDnsEntry>(
'deleteDnsEntry',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const success = await this.CDnsEntry.deleteDnsEntry(reqArg.dnsEntryId);
return {
success,
};
}
)
);
// Get DNS zones
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.dns.IRequest_Any_Cloudly_GetDnsZones>(
'getDnsZones',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const zones = await this.CDnsEntry.getDnsZones();
return {
zones,
};
}
)
);
}
/**
* Create a DNS entry for a service
* @param dnsEntryData The DNS entry data
*/
public async createServiceDnsEntry(dnsEntryData: plugins.servezoneInterfaces.data.IDnsEntry['data']) {
// If domainId is provided, get the domain and set the zone
if (dnsEntryData.domainId) {
const domain = await this.cloudlyRef.domainManager.CDomain.getInstance({
id: dnsEntryData.domainId,
});
if (domain) {
dnsEntryData.zone = domain.data.name;
}
}
// Create the DNS entry
const dnsEntry = await this.CDnsEntry.createDnsEntry(dnsEntryData);
return dnsEntry;
}
/**
* Activate DNS entries for a service when it's deployed
* @param serviceId The service ID
*/
public async activateServiceDnsEntries(serviceId: string) {
const dnsEntries = await this.CDnsEntry.getInstances({
'data.sourceServiceId': serviceId,
'data.sourceType': 'service',
});
for (const entry of dnsEntries) {
entry.data.active = true;
entry.data.updatedAt = Date.now();
await entry.save();
}
}
/**
* Deactivate DNS entries for a service when it's undeployed
* @param serviceId The service ID
*/
public async deactivateServiceDnsEntries(serviceId: string) {
const dnsEntries = await this.CDnsEntry.getInstances({
'data.sourceServiceId': serviceId,
'data.sourceType': 'service',
});
for (const entry of dnsEntries) {
entry.data.active = false;
entry.data.updatedAt = Date.now();
await entry.save();
}
}
/**
* Remove all DNS entries for a service
* @param serviceId The service ID
*/
public async removeServiceDnsEntries(serviceId: string) {
const dnsEntries = await this.CDnsEntry.getInstances({
'data.sourceServiceId': serviceId,
'data.sourceType': 'service',
});
for (const entry of dnsEntries) {
await entry.delete();
}
}
/**
* Update DNS entry values when deployment happens
* @param serviceId The service ID
* @param ipAddress The IP address to set for the DNS entries
*/
public async updateServiceDnsEntriesIp(serviceId: string, ipAddress: string) {
const dnsEntries = await this.CDnsEntry.getInstances({
'data.sourceServiceId': serviceId,
'data.sourceType': 'service',
});
for (const entry of dnsEntries) {
if (entry.data.type === 'A' || entry.data.type === 'AAAA') {
entry.data.value = ipAddress;
entry.data.updatedAt = Date.now();
await entry.save();
}
}
}
/**
* Initialize the DNS manager
*/
public async init() {
console.log('DNS Manager initialized');
}
/**
* Stop the DNS manager
*/
public async stop() {
console.log('DNS Manager stopped');
}
}
+211
View File
@@ -0,0 +1,211 @@
import * as plugins from '../plugins.js';
import { DomainManager } from './classes.domainmanager.js';
@plugins.smartdata.managed()
export class Domain extends plugins.smartdata.SmartDataDbDoc<
Domain,
plugins.servezoneInterfaces.data.IDomain,
DomainManager
> {
// STATIC
public static async getDomainById(domainIdArg: string) {
const domain = await this.getInstance({
id: domainIdArg,
});
return domain;
}
public static async getDomainByName(domainNameArg: string) {
const domain = await this.getInstance({
'data.name': domainNameArg,
});
return domain;
}
public static async getDomains() {
const domains = await this.getInstances({});
return domains;
}
public static async createDomain(domainDataArg: plugins.servezoneInterfaces.data.IDomain['data']) {
const domain = new Domain();
domain.id = await Domain.getNewId();
domain.data = {
...domainDataArg,
status: domainDataArg.status || 'pending',
verificationStatus: domainDataArg.verificationStatus || 'pending',
nameservers: domainDataArg.nameservers || [],
autoRenew: domainDataArg.autoRenew !== false,
activationState: domainDataArg.activationState || 'available',
syncSource: domainDataArg.syncSource ?? null,
lastSyncAt: domainDataArg.lastSyncAt,
createdAt: Date.now(),
updatedAt: Date.now(),
};
await domain.save();
return domain;
}
public static async updateDomain(
domainIdArg: string,
domainDataArg: Partial<plugins.servezoneInterfaces.data.IDomain['data']>
) {
const domain = await this.getInstance({
id: domainIdArg,
});
if (!domain) {
throw new Error(`Domain with id ${domainIdArg} not found`);
}
// Merge updates and respect incoming activationState when provided
Object.assign(domain.data, domainDataArg);
domain.data.updatedAt = Date.now();
// Ensure activationState has a sensible default if still missing
if (!domain.data.activationState) {
(domain.data as any).activationState = 'available';
}
await domain.save();
return domain;
}
public static async deleteDomain(domainIdArg: string) {
const domain = await this.getInstance({
id: domainIdArg,
});
if (!domain) {
throw new Error(`Domain with id ${domainIdArg} not found`);
}
// Check if there are DNS entries for this domain
const dnsManager = domain.manager.cloudlyRef.dnsManager;
const dnsEntries = await dnsManager.CDnsEntry.getInstances({
'data.zone': domain.data.name,
});
if (dnsEntries.length > 0) {
console.log(`Warning: Deleting domain ${domain.data.name} with ${dnsEntries.length} DNS entries`);
// Optionally delete associated DNS entries
for (const dnsEntry of dnsEntries) {
await dnsEntry.delete();
}
}
await domain.delete();
return true;
}
// INSTANCE
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public data!: plugins.servezoneInterfaces.data.IDomain['data'];
/**
* Verify domain ownership
*/
public async verifyDomain(methodArg?: 'dns' | 'http' | 'email' | 'manual') {
const method = methodArg || this.data.verificationMethod || 'dns';
// Generate verification token if not exists
if (!this.data.verificationToken) {
this.data.verificationToken = plugins.smartunique.shortId();
await this.save();
}
let verificationResult = {
success: false,
message: '',
details: {} as any,
};
switch (method) {
case 'dns':
// Check for TXT record with verification token
verificationResult = await this.verifyViaDns();
break;
case 'http':
// Check for file at well-known URL
verificationResult = await this.verifyViaHttp();
break;
case 'email':
// Send verification email
verificationResult = await this.verifyViaEmail();
break;
case 'manual':
// Manual verification
verificationResult.success = true;
verificationResult.message = 'Manually verified';
break;
}
// Update verification status
if (verificationResult.success) {
this.data.verificationStatus = 'verified';
this.data.lastVerificationAt = Date.now();
this.data.verificationMethod = method;
} else {
this.data.verificationStatus = 'failed';
this.data.lastVerificationAt = Date.now();
}
await this.save();
return verificationResult;
}
private async verifyViaDns(): Promise<{ success: boolean; message: string; details: any }> {
// TODO: Implement DNS verification
// Look for TXT record _cloudly-verify.{domain} with value {verificationToken}
return {
success: false,
message: 'DNS verification not yet implemented',
details: {
expectedRecord: `_cloudly-verify.${this.data.name}`,
expectedValue: this.data.verificationToken,
},
};
}
private async verifyViaHttp(): Promise<{ success: boolean; message: string; details: any }> {
// TODO: Implement HTTP verification
// Check for file at http://{domain}/.well-known/cloudly-verify.txt
return {
success: false,
message: 'HTTP verification not yet implemented',
details: {
expectedUrl: `http://${this.data.name}/.well-known/cloudly-verify.txt`,
expectedContent: this.data.verificationToken,
},
};
}
private async verifyViaEmail(): Promise<{ success: boolean; message: string; details: any }> {
// TODO: Implement email verification
return {
success: false,
message: 'Email verification not yet implemented',
details: {},
};
}
/**
* Check if domain is expiring soon
*/
public isExpiringSoon(daysThreshold: number = 30): boolean {
if (!this.data.expiresAt) {
return false;
}
const daysUntilExpiry = (this.data.expiresAt - Date.now()) / (1000 * 60 * 60 * 24);
return daysUntilExpiry <= daysThreshold;
}
/**
* Get all DNS entries for this domain
*/
public async getDnsEntries() {
const dnsManager = this.manager.cloudlyRef.dnsManager;
const dnsEntries = await dnsManager.CDnsEntry.getInstances({
'data.zone': this.data.name,
});
return dnsEntries;
}
}
+258
View File
@@ -0,0 +1,258 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import { Domain } from './classes.domain.js';
interface IWorkHosterDomain {
name: string;
nameservers?: string[];
capabilities?: {
canCreateSubdomains: boolean;
canManageDnsRecords: boolean;
canIssueCertificates: boolean;
canHostEmail: boolean;
};
}
export class DomainManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
public cloudlyRef: Cloudly;
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CDomain = plugins.smartdata.setDefaultManagerForDoc(this, Domain);
constructor(cloudlyRef: Cloudly) {
this.cloudlyRef = cloudlyRef;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
// Get all domains
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.domain.IRequest_Any_Cloudly_GetDomains>(
'getDomains',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const domains = await this.CDomain.getDomains();
return {
domains: await Promise.all(
domains.map((domain) => domain.createSavableObject())
),
};
}
)
);
// Get domain by ID
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.domain.IRequest_Any_Cloudly_GetDomainById>(
'getDomainById',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const domain = await this.CDomain.getDomainById(reqArg.domainId);
if (!domain) {
throw new Error(`Domain with id ${reqArg.domainId} not found`);
}
return {
domain: await domain.createSavableObject(),
};
}
)
);
// Create domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.domain.IRequest_Any_Cloudly_CreateDomain>(
'createDomain',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
// Check if domain already exists
const existingDomain = await this.CDomain.getDomainByName(reqArg.domainData.name);
if (existingDomain) {
throw new Error(`Domain ${reqArg.domainData.name} already exists`);
}
const domain = await this.CDomain.createDomain(reqArg.domainData);
return {
domain: await domain.createSavableObject(),
};
}
)
);
// Update domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.domain.IRequest_Any_Cloudly_UpdateDomain>(
'updateDomain',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const domain = await this.CDomain.updateDomain(
reqArg.domainId,
reqArg.domainData
);
return {
domain: await domain.createSavableObject(),
};
}
)
);
// Delete domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.domain.IRequest_Any_Cloudly_DeleteDomain>(
'deleteDomain',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const success = await this.CDomain.deleteDomain(reqArg.domainId);
return {
success,
};
}
)
);
// Verify domain
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.domain.IRequest_Any_Cloudly_VerifyDomain>(
'verifyDomain',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const domain = await this.CDomain.getDomainById(reqArg.domainId);
if (!domain) {
throw new Error(`Domain with id ${reqArg.domainId} not found`);
}
const verificationResult = await domain.verifyDomain(reqArg.verificationMethod);
return {
domain: await domain.createSavableObject(),
verificationResult,
};
}
)
);
}
/**
* Initialize the domain manager
*/
public async init() {
await this.syncExternalGatewayDomains().catch((error) => {
console.log(`External gateway domain sync failed: ${(error as Error).message}`);
});
console.log('Domain Manager initialized');
}
public async syncExternalGatewayDomains(): Promise<number> {
const settings = await this.cloudlyRef.settingsManager.getSettings();
if (!settings.dcrouterGatewayUrl || !settings.dcrouterGatewayApiToken) {
return 0;
}
const typedRequest = new plugins.typedrequest.TypedRequest<any>(
`${settings.dcrouterGatewayUrl.replace(/\/+$/, '')}/typedrequest`,
'getWorkHosterDomains',
);
const response = await typedRequest.fire({
apiToken: settings.dcrouterGatewayApiToken,
}) as { domains: IWorkHosterDomain[] };
const activeDomainNames = new Set<string>();
for (const gatewayDomain of response.domains) {
const domainName = gatewayDomain.name.trim().toLowerCase();
if (!domainName) continue;
activeDomainNames.add(domainName);
const existingDomain = await this.CDomain.getDomainByName(domainName);
const tags = Array.from(new Set([...(existingDomain?.data.tags || []), 'dcrouter']));
const domainData: Partial<plugins.servezoneInterfaces.data.IDomain['data']> = {
name: domainName,
status: 'active',
verificationStatus: 'not_required',
nameservers: gatewayDomain.nameservers || existingDomain?.data.nameservers || [],
autoRenew: gatewayDomain.capabilities?.canIssueCertificates !== false,
activationState: 'available',
syncSource: 'manual',
lastSyncAt: Date.now(),
isExternal: true,
tags,
};
if (existingDomain) {
await this.CDomain.updateDomain(existingDomain.id, domainData);
} else {
await this.CDomain.createDomain(domainData as plugins.servezoneInterfaces.data.IDomain['data']);
}
}
const knownDomains = await this.CDomain.getDomains();
for (const domain of knownDomains) {
if (domain.data.tags?.includes('dcrouter') && !activeDomainNames.has(domain.data.name)) {
await this.CDomain.updateDomain(domain.id, {
activationState: 'ignored',
lastSyncAt: Date.now(),
});
}
}
console.log(`Synced ${activeDomainNames.size} domain(s) from external dcrouter gateway`);
return activeDomainNames.size;
}
/**
* Stop the domain manager
*/
public async stop() {
console.log('Domain Manager stopped');
}
/**
* Get all active domains
*/
public async getActiveDomains() {
const domains = await this.CDomain.getInstances({
'data.status': 'active',
});
return domains;
}
/**
* Get domains that are expiring soon
*/
public async getExpiringDomains(daysThreshold: number = 30) {
const domains = await this.CDomain.getDomains();
return domains.filter(domain => domain.isExpiringSoon(daysThreshold));
}
/**
* Check if a domain name is available (not in our system)
*/
public async isDomainAvailable(domainName: string): Promise<boolean> {
const existingDomain = await this.CDomain.getDomainByName(domainName);
return !existingDomain;
}
}
@@ -0,0 +1,207 @@
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import type { Cloudly } from '../classes.cloudly.js';
import type { ExternalRegistryManager } from './classes.externalregistrymanager.js';
@plugins.smartdata.managed()
export class ExternalRegistry extends plugins.smartdata.SmartDataDbDoc<ExternalRegistry, plugins.servezoneInterfaces.data.IExternalRegistry, ExternalRegistryManager> {
// STATIC
public static async getRegistryById(registryIdArg: string) {
const externalRegistry = await this.getInstance({
id: registryIdArg,
});
return externalRegistry;
}
public static async getRegistries() {
const externalRegistries = await this.getInstances({});
return externalRegistries;
}
public static async getDefaultRegistry(type: 'docker' | 'npm' = 'docker') {
const defaultRegistry = await this.getInstance({
'data.type': type,
'data.isDefault': true,
});
return defaultRegistry;
}
public static async createExternalRegistry(registryDataArg: Partial<plugins.servezoneInterfaces.data.IExternalRegistry['data']>) {
const externalRegistry = new ExternalRegistry();
externalRegistry.id = await this.getNewId();
externalRegistry.data = {
type: registryDataArg.type || 'docker',
name: registryDataArg.name || '',
url: registryDataArg.url || '',
username: registryDataArg.username,
password: registryDataArg.password,
description: registryDataArg.description,
isDefault: registryDataArg.isDefault || false,
authType: registryDataArg.authType || (registryDataArg.username || registryDataArg.password ? 'basic' : 'none'),
insecure: registryDataArg.insecure || false,
namespace: registryDataArg.namespace,
proxy: registryDataArg.proxy,
config: registryDataArg.config,
status: 'unverified',
createdAt: Date.now(),
updatedAt: Date.now(),
};
// If this is set as default, unset other defaults of the same type
if (externalRegistry.data.isDefault) {
const existingDefaults = await this.getInstances({
'data.type': externalRegistry.data.type,
'data.isDefault': true,
});
for (const existingDefault of existingDefaults) {
existingDefault.data.isDefault = false;
await existingDefault.save();
}
}
await externalRegistry.save();
return externalRegistry;
}
public static async updateExternalRegistry(
registryIdArg: string,
registryDataArg: Partial<plugins.servezoneInterfaces.data.IExternalRegistry['data']>
) {
const externalRegistry = await this.getRegistryById(registryIdArg);
if (!externalRegistry) {
throw new Error(`Registry with id ${registryIdArg} not found`);
}
// If setting as default, unset other defaults of the same type
if (registryDataArg.isDefault && !externalRegistry.data.isDefault) {
const existingDefaults = await this.getInstances({
'data.type': externalRegistry.data.type,
'data.isDefault': true,
});
for (const existingDefault of existingDefaults) {
if (existingDefault.id !== registryIdArg) {
existingDefault.data.isDefault = false;
await existingDefault.save();
}
}
}
// Update fields
Object.assign(externalRegistry.data, registryDataArg, {
updatedAt: Date.now(),
});
await externalRegistry.save();
return externalRegistry;
}
public static async deleteExternalRegistry(registryIdArg: string) {
const externalRegistry = await this.getRegistryById(registryIdArg);
if (!externalRegistry) {
return false;
}
await externalRegistry.delete();
return true;
}
// INSTANCE
@plugins.smartdata.svDb()
public id!: string;
@plugins.smartdata.svDb()
public data!: plugins.servezoneInterfaces.data.IExternalRegistry['data'];
constructor() {
super();
}
/**
* Verify the registry connection
*/
public async verifyConnection(): Promise<{ success: boolean; message?: string }> {
try {
// For Docker registries, try to access the v2 API
if (this.data.type === 'docker') {
const registryUrl = this.data.url.replace(/\/$/, ''); // Remove trailing slash
// Build headers based on auth type
const headers: any = {};
if (this.data.authType === 'basic' && this.data.username && this.data.password) {
headers['Authorization'] = 'Basic ' + Buffer.from(`${this.data.username}:${this.data.password}`).toString('base64');
} else if (this.data.authType === 'token' && this.data.password) {
// For token auth, password field contains the token
headers['Authorization'] = `Bearer ${this.data.password}`;
}
// For 'none' auth type or missing credentials, no auth header is added
// Try to access the Docker Registry v2 API
const response = await fetch(`${registryUrl}/v2/`, {
headers,
// Allow insecure if configured
...(this.data.insecure ? { rejectUnauthorized: false } : {}),
}).catch(err => {
throw new Error(`Failed to connect: ${err.message}`);
});
if (response.status === 200) {
// 200 means successful (either public or authenticated)
this.data.status = 'active';
this.data.lastVerified = Date.now();
this.data.lastError = undefined;
await this.save();
return { success: true, message: 'Registry connection successful' };
} else if (response.status === 401 && this.data.authType === 'none') {
// 401 with no auth means registry exists but needs auth
throw new Error('Registry requires authentication');
} else if (response.status === 401) {
throw new Error('Authentication failed - check credentials');
} else {
throw new Error(`Registry returned status ${response.status}`);
}
}
// For npm registries, implement npm-specific verification
if (this.data.type === 'npm') {
// TODO: Implement npm registry verification
this.data.status = 'unverified';
return { success: false, message: 'NPM registry verification not yet implemented' };
}
return { success: false, message: 'Unknown registry type' };
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
this.data.status = 'error';
this.data.lastError = errorMessage;
await this.save();
return { success: false, message: errorMessage };
}
}
/**
* Get the full registry URL with namespace if applicable
*/
public getFullRegistryUrl(): string {
let url = this.data.url.replace(/\/$/, ''); // Remove trailing slash
if (this.data.namespace) {
url = `${url}/${this.data.namespace}`;
}
return url;
}
/**
* Get Docker auth config for this registry
*/
public getDockerAuthConfig() {
if (this.data.type !== 'docker') {
return null;
}
return {
username: this.data.username,
password: this.data.password,
email: this.data.config?.dockerConfig?.email,
serveraddress: this.data.config?.dockerConfig?.serverAddress || this.data.url,
};
}
}
@@ -0,0 +1,130 @@
import * as plugins from '../plugins.js';
import { Cloudly } from '../classes.cloudly.js';
import { ExternalRegistry } from './classes.externalregistry.js';
export class ExternalRegistryManager {
public cloudlyRef: Cloudly;
public typedrouter = new plugins.typedrequest.TypedRouter();
public CExternalRegistry = plugins.smartdata.setDefaultManagerForDoc(this, ExternalRegistry);
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
constructor(cloudlyRef: Cloudly) {
this.cloudlyRef = cloudlyRef;
// Add typedrouter to cloudly's main router
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
// Get registry by ID
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.externalRegistry.IReq_GetRegistryById>(
new plugins.typedrequest.TypedHandler('getExternalRegistryById', async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const registry = await this.CExternalRegistry.getRegistryById(dataArg.id);
if (!registry) {
throw new Error(`Registry with id ${dataArg.id} not found`);
}
return {
registry: await registry.createSavableObject(),
};
})
);
// Get all registries
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.externalRegistry.IReq_GetRegistries>(
new plugins.typedrequest.TypedHandler('getExternalRegistries', async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const registries = await this.CExternalRegistry.getRegistries();
return {
registries: await Promise.all(
registries.map((registry) => registry.createSavableObject())
),
};
})
);
// Create registry
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.externalRegistry.IReq_CreateRegistry>(
new plugins.typedrequest.TypedHandler('createExternalRegistry', async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const registry = await this.CExternalRegistry.createExternalRegistry(dataArg.registryData);
return {
registry: await registry.createSavableObject(),
};
})
);
// Update registry
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.externalRegistry.IReq_UpdateRegistry>(
new plugins.typedrequest.TypedHandler('updateExternalRegistry', async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const registry = await this.CExternalRegistry.updateExternalRegistry(
dataArg.registryId,
dataArg.registryData
);
return {
resultRegistry: await registry.createSavableObject(),
};
})
);
// Delete registry
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.externalRegistry.IReq_DeleteRegistryById>(
new plugins.typedrequest.TypedHandler('deleteExternalRegistryById', async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const success = await this.CExternalRegistry.deleteExternalRegistry(dataArg.registryId);
return {
ok: success,
};
})
);
// Verify registry connection
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.externalRegistry.IReq_VerifyRegistry>(
new plugins.typedrequest.TypedHandler('verifyExternalRegistry', async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const registry = await this.CExternalRegistry.getRegistryById(dataArg.registryId);
if (!registry) {
return {
success: false,
message: `Registry with id ${dataArg.registryId} not found`,
};
}
const result = await registry.verifyConnection();
return {
success: result.success,
message: result.message,
registry: await registry.createSavableObject(),
};
})
);
}
public async start() {
console.log('External Registry Manager started');
}
public async stop() {
console.log('External Registry Manager stopped');
}
}
+2
View File
@@ -0,0 +1,2 @@
export * from './classes.externalregistrymanager.js';
export * from './classes.externalregistry.js';
@@ -0,0 +1,451 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import { Service } from '../manager.service/classes.service.js';
type IHostedAppLifecycleState = plugins.servezoneInterfaces.data.IHostedAppLifecycleState;
type IHostedAppUpgradeState = plugins.servezoneInterfaces.data.IHostedAppUpgradeState;
type IHostedAppRuntimeIdentity = plugins.servezoneInterfaces.data.IHostedAppRuntimeIdentity;
interface IHostedAppParentUpgradeResponse {
isHosted: boolean;
unavailableReason?: string;
upgradeState: IHostedAppUpgradeState;
}
type TExtendedServiceData = plugins.servezoneInterfaces.data.IService['data'] & {
hostedAppLifecycle?: IHostedAppLifecycleState;
};
export class CloudlyHostedAppManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
constructor(private cloudlyRef: Cloudly) {
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.registerHandlers();
}
public async start() {}
public async stop() {}
private getParentRuntimeIdentity(): IHostedAppRuntimeIdentity | null {
const appInstanceId = process.env.SERVEZONE_APP_INSTANCE_ID;
const appControlToken = process.env.SERVEZONE_APP_CONTROL_TOKEN;
if (!appInstanceId || !appControlToken) {
return null;
}
return {
appInstanceId,
appControlToken,
hostType: process.env.SERVEZONE_APP_HOST_TYPE || 'onebox',
};
}
private createParentRuntimeTypedRequest<TRequest extends plugins.typedrequestInterfaces.ITypedRequest>(methodArg: TRequest['method']): plugins.typedrequest.TypedRequest<TRequest> | null {
const runtimeUrl = process.env.SERVEZONE_RUNTIME_URL;
if (!runtimeUrl) {
return null;
}
return new plugins.typedrequest.TypedRequest<TRequest>(
`${runtimeUrl.replace(/\/+$/, '')}/typedrequest`,
methodArg,
);
}
private getParentRuntimeUnavailableReason(): string | undefined {
if (!process.env.SERVEZONE_RUNTIME_URL) {
return 'SERVEZONE_RUNTIME_URL is not configured.';
}
if (!process.env.SERVEZONE_APP_INSTANCE_ID || !process.env.SERVEZONE_APP_CONTROL_TOKEN) {
return 'Hosted app runtime identity is not configured.';
}
return undefined;
}
private getErrorMessage(errorArg: unknown): string {
return errorArg instanceof Error ? errorArg.message : String(errorArg);
}
public async getParentUpgradeStatus(): Promise<IHostedAppParentUpgradeResponse> {
const unavailableReason = this.getParentRuntimeUnavailableReason();
const identity = this.getParentRuntimeIdentity();
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus>(
'hostedAppGetManagedUpgradeStatus',
);
if (unavailableReason || !identity || !request) {
return {
isHosted: false,
unavailableReason,
upgradeState: { status: 'unknown' },
};
}
try {
const response = await request.fire({ identity });
return {
isHosted: true,
upgradeState: response.upgradeState,
};
} catch (error) {
const message = this.getErrorMessage(error);
return {
isHosted: true,
unavailableReason: message,
upgradeState: {
status: 'unknown',
error: message,
},
};
}
}
public async startParentUpgrade(targetVersionArg?: string): Promise<IHostedAppParentUpgradeResponse> {
const unavailableReason = this.getParentRuntimeUnavailableReason();
const identity = this.getParentRuntimeIdentity();
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade>(
'hostedAppStartManagedUpgrade',
);
if (unavailableReason || !identity || !request) {
return {
isHosted: false,
unavailableReason,
upgradeState: { status: 'unknown' },
};
}
try {
const response = await request.fire({
identity,
targetVersion: targetVersionArg,
});
return {
isHosted: true,
upgradeState: response.upgradeState,
};
} catch (error) {
const message = this.getErrorMessage(error);
return {
isHosted: true,
unavailableReason: message,
upgradeState: {
status: 'failed',
error: message,
},
};
}
}
public async requestParentInitialAdminBootstrap(): Promise<{
username: string;
password: string;
actionId: string;
} | null> {
const identity = this.getParentRuntimeIdentity();
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
'hostedAppRequestBootstrapAction',
);
if (!identity || !request) {
return null;
}
const username = 'admin';
const password = plugins.smartunique.uniSimple('cloudlyadmin', 32);
const response = await request.fire({
identity,
action: {
type: 'credentials',
label: 'Cloudly initial admin',
url: `https://${this.cloudlyRef.config.data.publicUrl}`,
username,
password,
message: 'Use these credentials to sign in to Cloudly, then change the admin password.',
},
});
return {
username,
password,
actionId: response.action.id,
};
}
public async completeParentBootstrapAction(actionIdArg?: string, messageArg?: string): Promise<void> {
const identity = this.getParentRuntimeIdentity();
const request = this.createParentRuntimeTypedRequest<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
'hostedAppCompleteBootstrapAction',
);
if (!identity || !request) {
return;
}
await request.fire({
identity,
actionId: actionIdArg,
message: messageArg,
});
}
public createHostedAppRuntimeEnvVars(serviceNameArg: string): {
appInstanceId: string;
appControlToken: string;
envVars: Record<string, string>;
lifecycle: IHostedAppLifecycleState;
} {
const appInstanceId = plugins.smartunique.uniSimple('hostedapp');
const appControlToken = plugins.smartunique.uniSimple('hostedapptoken', 64);
const runtimeUrl = `https://${this.cloudlyRef.config.data.publicUrl}`;
return {
appInstanceId,
appControlToken,
envVars: {
SERVEZONE_RUNTIME_URL: runtimeUrl,
SERVEZONE_APP_INSTANCE_ID: appInstanceId,
SERVEZONE_APP_CONTROL_TOKEN: appControlToken,
SERVEZONE_APP_HOST_TYPE: 'cloudly',
},
lifecycle: {
appInstanceId,
hostType: 'cloudly',
appName: serviceNameArg,
runtimeStatus: 'unknown',
},
};
}
private async requireHostedAppIdentity(identityArg: IHostedAppRuntimeIdentity): Promise<Service> {
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
const service = services.find((serviceArg) => {
const serviceData = serviceArg.data as TExtendedServiceData;
return (
serviceData.hostedAppLifecycle?.appInstanceId === identityArg?.appInstanceId ||
serviceData.environment?.SERVEZONE_APP_INSTANCE_ID === identityArg?.appInstanceId
);
});
if (!service) {
throw new plugins.typedrequest.TypedResponseError('Hosted app service not found');
}
const serviceData = service.data as TExtendedServiceData;
if (serviceData.environment?.SERVEZONE_APP_CONTROL_TOKEN !== identityArg?.appControlToken) {
throw new plugins.typedrequest.TypedResponseError('Hosted app identity is invalid');
}
return service;
}
private async getUpgradeState(serviceArg: Service): Promise<IHostedAppUpgradeState> {
const serviceData = serviceArg.data as TExtendedServiceData;
const latestOperation = this.cloudlyRef.appStoreManager
.getUpgradeOperations()
.find((operationArg) => operationArg.serviceId === serviceArg.id);
if (latestOperation) {
return {
status: latestOperation.status === 'running' ? 'running' : latestOperation.status,
appTemplateId: latestOperation.appTemplateId,
currentVersion: latestOperation.fromVersion,
targetVersion: latestOperation.targetVersion,
operationId: latestOperation.id,
warnings: latestOperation.warnings,
error: latestOperation.error,
startedAt: latestOperation.startedAt,
updatedAt: latestOperation.updatedAt,
completedAt: latestOperation.completedAt,
};
}
if (!serviceData.appTemplateId || !serviceData.appTemplateVersion) {
return { status: 'unknown' };
}
const upgradeableServices = await this.cloudlyRef.appStoreManager.getUpgradeableAppStoreServices();
const upgradeable = upgradeableServices.find((serviceArg2) => serviceArg2.serviceId === serviceArg.id);
if (!upgradeable) {
return {
status: 'upToDate',
appTemplateId: serviceData.appTemplateId,
currentVersion: serviceData.appTemplateVersion,
latestVersion: serviceData.appTemplateVersion,
};
}
return {
status: 'available',
appTemplateId: upgradeable.appTemplateId,
currentVersion: upgradeable.currentVersion,
latestVersion: upgradeable.latestVersion,
targetVersion: upgradeable.latestVersion,
};
}
private async getLifecycleState(serviceArg: Service): Promise<IHostedAppLifecycleState> {
const serviceData = serviceArg.data as TExtendedServiceData;
const appInstanceId = serviceData.hostedAppLifecycle?.appInstanceId || serviceData.environment?.SERVEZONE_APP_INSTANCE_ID;
const state: IHostedAppLifecycleState = {
...(serviceData.hostedAppLifecycle || ({} as IHostedAppLifecycleState)),
appInstanceId: appInstanceId || '',
hostType: 'cloudly',
appName: serviceData.hostedAppLifecycle?.appName || serviceData.name,
publicUrl: serviceData.hostedAppLifecycle?.publicUrl || (serviceData.domains?.[0]?.name ? `https://${serviceData.domains[0].name}` : undefined),
upgradeState: await this.getUpgradeState(serviceArg),
};
serviceData.hostedAppLifecycle = state;
serviceArg.data = serviceData;
await serviceArg.save();
return state;
}
private async updateLifecycleState(serviceArg: Service, stateArg: IHostedAppLifecycleState): Promise<IHostedAppLifecycleState> {
const serviceData = serviceArg.data as TExtendedServiceData;
serviceData.hostedAppLifecycle = stateArg;
serviceArg.data = serviceData;
await serviceArg.save();
return await this.getLifecycleState(serviceArg);
}
private registerHandlers() {
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_ReportLifecycleState>(
'hostedAppReportLifecycleState',
async (dataArg) => {
const service = await this.requireHostedAppIdentity(dataArg.identity);
const existingState = await this.getLifecycleState(service);
const state = await this.updateLifecycleState(service, {
...existingState,
...dataArg.report,
appInstanceId: existingState.appInstanceId,
hostType: 'cloudly',
reportedAt: Date.now(),
});
return { state };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetLifecycleState>(
'hostedAppGetLifecycleState',
async (dataArg) => {
const service = await this.requireHostedAppIdentity(dataArg.identity);
return { state: await this.getLifecycleState(service) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_RequestBootstrapAction>(
'hostedAppRequestBootstrapAction',
async (dataArg) => {
const service = await this.requireHostedAppIdentity(dataArg.identity);
const existingState = await this.getLifecycleState(service);
const now = Date.now();
const action = {
...dataArg.action,
id: dataArg.action.id || plugins.smartunique.shortId(12),
status: 'ready' as const,
label: dataArg.action.label || 'Initial setup',
createdAt: now,
updatedAt: now,
};
const state = await this.updateLifecycleState(service, {
...existingState,
runtimeStatus: 'setupRequired',
bootstrapAction: action,
reportedAt: now,
});
return { action, state };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_CompleteBootstrapAction>(
'hostedAppCompleteBootstrapAction',
async (dataArg) => {
const service = await this.requireHostedAppIdentity(dataArg.identity);
const existingState = await this.getLifecycleState(service);
const now = Date.now();
const bootstrapAction = existingState.bootstrapAction
? {
...existingState.bootstrapAction,
id: dataArg.actionId || existingState.bootstrapAction.id,
status: 'completed' as const,
message: dataArg.message || existingState.bootstrapAction.message,
updatedAt: now,
completedAt: now,
}
: undefined;
const state = await this.updateLifecycleState(service, {
...existingState,
runtimeStatus: existingState.runtimeStatus === 'setupRequired' ? 'running' : existingState.runtimeStatus,
bootstrapAction,
reportedAt: now,
});
return { state };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_StartManagedUpgrade>(
'hostedAppStartManagedUpgrade',
async (dataArg) => {
const service = await this.requireHostedAppIdentity(dataArg.identity);
const upgradeState = await this.getUpgradeState(service);
const targetVersion = dataArg.targetVersion || upgradeState.targetVersion || upgradeState.latestVersion;
if (!targetVersion) {
throw new plugins.typedrequest.TypedResponseError('No managed upgrade target is available');
}
const operation = await this.cloudlyRef.appStoreManager.startHostedAppUpgrade(service.id, targetVersion);
const nextUpgradeState: IHostedAppUpgradeState = {
status: 'running',
appTemplateId: operation.appTemplateId,
currentVersion: operation.fromVersion,
targetVersion: operation.targetVersion,
operationId: operation.id,
warnings: operation.warnings,
startedAt: operation.startedAt,
updatedAt: operation.updatedAt,
};
const existingState = await this.getLifecycleState(service);
const state = await this.updateLifecycleState(service, {
...existingState,
upgradeState: nextUpgradeState,
reportedAt: Date.now(),
});
return { upgradeState: nextUpgradeState, state };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_HostedApp_GetManagedUpgradeStatus>(
'hostedAppGetManagedUpgradeStatus',
async (dataArg) => {
const service = await this.requireHostedAppIdentity(dataArg.identity);
return { upgradeState: await this.getUpgradeState(service) };
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_Admin_GetHostedAppParentUpgradeStatus>(
'getHostedAppParentUpgradeStatus',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return await this.getParentUpgradeStatus();
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.hostedapp.IReq_Admin_StartHostedAppParentUpgrade>(
'startHostedAppParentUpgrade',
async (dataArg) => {
await this.passAdminIdentity(dataArg);
return await this.startParentUpgrade(dataArg.targetVersion);
},
),
);
}
private async passAdminIdentity(dataArg: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
}
}
+37 -8
View File
@@ -1,21 +1,50 @@
import * as plugins from '../plugins.js';
import type { ImageManager } from './classes.imagemanager.js';
@plugins.smartdata.Manager()
export class Image extends plugins.smartdata.SmartDataDbDoc<Image, plugins.servezoneInterfaces.data.IImage, ImageManager> {
public static async create(imageDataArg: Partial<plugins.servezoneInterfaces.data.IImage['data']>) {
@plugins.smartdata.managed()
export class Image extends plugins.smartdata.SmartDataDbDoc<
Image,
plugins.servezoneInterfaces.data.IImage,
ImageManager
> {
public static async create(
imageDataArg: Partial<plugins.servezoneInterfaces.data.IImage['data']>,
) {
const image = new Image();
image.id = plugins.smartunique.uni('image');
Object.assign(image.data, imageDataArg);
image.id = await this.getNewId();
Object.assign(image, {
data: {
name: imageDataArg.name,
description: imageDataArg.description,
location: imageDataArg.location || {
internal: true,
externalRegistryId: '',
externalImageTag: '',
},
versions: [],
},
});
await image.save();
return image;
}
@plugins.smartdata.unI()
public id: string;
public id!: string;
@plugins.smartdata.svDb()
public data: plugins.servezoneInterfaces.data.IImage['data'];
public data!: plugins.servezoneInterfaces.data.IImage['data'];
public async getVersions() {}
}
/**
* returns a storage path
* note: this is relative to the storage method defined by the imageManager
*/
public async getStoragePath(versionStringArg: string) {
return `${this.data.name}:${versionStringArg}`.replace('/', '__');
}
public async getWriteStream() {}
public async getReadStream() {}
}
+191 -42
View File
@@ -1,97 +1,246 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import * as paths from '../paths.js';
import { Image } from './classes.image.js';
export class ImageManager {
cloudlyRef: Cloudly;
public typedrouter = new plugins.typedrequest.TypedRouter();
public smartbucketInstance!: plugins.smartbucket.SmartBucket;
public imageDir!: plugins.smartbucket.Directory;
public dockerImageStore!: plugins.docker.DockerImageStore;
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public typedrouter = new plugins.typedrequest.TypedRouter();
public CImage = plugins.smartdata.setDefaultManagerForDoc(this, Image);
smartbucketInstance: plugins.smartbucket.SmartBucket;
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.image.IRequest_CreateImage>(
'createImage',
async (reqArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], reqArg);
const image = await this.CImage.create({
name: reqArg.name,
description: reqArg.description,
versions: [],
});
return {
image: await image.createSavableObject(),
};
},
),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.image.IRequest_GetImage>(
new plugins.typedrequest.TypedHandler('getImage', async (reqArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminOrClusterIdentityGuard], reqArg);
const image = await this.CImage.getInstance({
id: reqArg.imageId,
});
return {
image: await image.createSavableObject(),
};
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.image.IRequest_DeleteImage>(
'deleteImage',
async (reqArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], reqArg);
const image = await this.CImage.getInstance({
id: reqArg.imageId,
});
await image.delete();
return {};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.image.IRequest_GetAllImages>(
'getAllImages',
async (requestArg, toolsArg) => {
await toolsArg.passGuards([this.cloudlyRef.authManager.adminJwtGuard], requestArg);
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], requestArg);
const images = await this.CImage.getInstances({});
return {
images: await Promise.all(
images.map((image) => {
return image.createSavableObject();
})
}),
),
};
}
)
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.image.IRequest_CreateImage>(
'createImage',
async (reqArg) => {
const image = await this.CImage.create({
name: reqArg.name,
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.image.IRequest_PushImageVersion>(
'pushImageVersion',
async (reqArg, toolsArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const refImage = await this.CImage.getInstance({
id: reqArg.imageId,
});
if (!refImage) {
throw new plugins.typedrequest.TypedResponseError('Image not found');
}
const imageVersion = reqArg.versionString;
if (!imageVersion) {
throw new plugins.typedrequest.TypedResponseError('versionString is required');
}
console.log(
`got request to push image version ${imageVersion} for image ${refImage.data.name}`,
);
const storagePath = await refImage.getStoragePath(imageVersion);
refImage.data.versions = [
...refImage.data.versions.filter((version) => version.versionString !== imageVersion),
{
versionString: imageVersion,
source: 'upload',
storagePath,
size: 0,
createdAt: Date.now(),
},
];
await refImage.save();
const imagePushStream = reqArg.imageStream;
(async () => {
const archiveHash = plugins.crypto.createHash('sha256');
let archiveSize = 0;
const smartWebDuplex = new plugins.smartstream.webstream.WebDuplexStream<
Uint8Array,
Uint8Array
>({
writeFunction: async (chunkArg, toolsArg) => {
archiveSize += chunkArg.byteLength;
archiveHash.update(chunkArg);
return chunkArg;
},
});
imagePushStream.writeToWebstream(smartWebDuplex.writable);
await this.dockerImageStore.storeImage(
storagePath,
plugins.smartstream.SmartDuplex.fromWebReadableStream(smartWebDuplex.readable),
);
refImage.data.versions = refImage.data.versions.map((versionArg) => {
if (versionArg.versionString !== imageVersion) {
return versionArg;
}
return {
...versionArg,
size: archiveSize,
digest: `sha256:${archiveHash.digest('hex')}`,
};
});
await refImage.save();
})().catch((error) => {
console.error(`failed to store image ${refImage.id}:${imageVersion}`, error);
});
return {
image: await image.createSavableObject(),
allowed: true,
};
}
)
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.image.IRequest_PushImage>(
'pushImage',
async (reqArg) => {
const pushStream = reqArg.imageStream;
return {};
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.image.IRequest_PullImage>(
'pullImage',
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.image.IRequest_PullImageVersion>(
'pullImageVersion',
async (reqArg) => {
const image = await this.CImage.getInstance({
data: {
name: reqArg.name,
},
id: reqArg.imageId,
});
const imageVersion = null;
const imageVersion = image.data.versions.find(
(version) => version.versionString === reqArg.versionString,
);
if (!imageVersion) {
throw new plugins.typedrequest.TypedResponseError('Image version not found');
}
const readable = await this.imageDir.fastGetStream(
{
path: imageVersion.storagePath || await image.getStoragePath(reqArg.versionString),
},
'webstream',
);
const imageVirtualStream = new plugins.typedrequest.VirtualStream();
(async () => {
await imageVirtualStream.readFromWebstream(readable);
})().catch((error) => {
console.error(`failed to stream image ${image.id}:${reqArg.versionString}`, error);
});
return {
imageStream: imageVirtualStream,
};
}
)
},
),
);
}
public async start() {
const s3Descriptor: plugins.tsclass.storage.IS3Descriptor =
await this.cloudlyRef.config.appData.waitForAndGetKey('s3Descriptor');
// lets setup s3
const s3Descriptor =
await this.cloudlyRef.config.appData.waitForAndGetKey('s3Descriptor') as plugins.tsclass.storage.IS3Descriptor;
console.log(this.cloudlyRef.config.data.s3Descriptor);
this.smartbucketInstance = new plugins.smartbucket.SmartBucket(
this.cloudlyRef.config.data.s3Descriptor
this.cloudlyRef.config.data.s3Descriptor!,
);
const bucket = await this.smartbucketInstance.getBucketByName('cloudly-test');
await bucket.fastPut({ path: 'test/test.txt', contents: 'hello' });
}
const bucketName = s3Descriptor.bucketName!;
const bucket = await this.smartbucketInstance.bucketExists(bucketName)
? await this.smartbucketInstance.getBucketByName(bucketName)
: await this.smartbucketInstance.createBucket(bucketName);
await bucket.fastPut({ path: 'images/00init', contents: 'init', overwrite: true });
public async createImage(nameArg: string) {
const newImage = await this.CImage.create({
name: nameArg,
this.imageDir = await bucket.getDirectoryFromPath({
path: '/images',
});
// lets setup dockerstore
await plugins.fsPromises.mkdir(paths.dockerImageStoreDir, { recursive: true });
this.dockerImageStore = new plugins.docker.DockerImageStore({
localDirPath: paths.dockerImageStoreDir,
bucketDir: this.imageDir,
});
}
public async deleteImageIfUnreferenced(imageIdArg: string, ownerServiceIdArg?: string): Promise<boolean> {
if (!imageIdArg) return false;
const referencingServices = await this.cloudlyRef.serviceManager.CService.getInstances({});
const referencedByOtherService = referencingServices.some((serviceArg) => {
return serviceArg.id !== ownerServiceIdArg && serviceArg.data?.imageId === imageIdArg;
});
if (referencedByOtherService) return false;
const image = await this.CImage.getInstance({
id: imageIdArg,
}).catch(() => null);
if (!image) return false;
for (const version of image.data.versions || []) {
const storagePath = version.storagePath || await image.getStoragePath(version.versionString);
if (!storagePath || !this.imageDir) continue;
await this.imageDir.fastRemove({
path: `${storagePath}.tar`,
}).catch((errorArg) => {
const message = (errorArg as Error).message.toLowerCase();
if (!message.includes('not found') && !message.includes('no such')) {
throw errorArg;
}
});
}
await image.delete();
return true;
}
}
+43
View File
@@ -0,0 +1,43 @@
import * as plugins from '../plugins.js';
export interface IJumpCodeData {
clusterId: string;
createdBy: string;
role: plugins.servezoneInterfaces.data.IClusterNode['data']['role'];
nodeType: plugins.servezoneInterfaces.data.IClusterNode['data']['nodeType'];
createdAt: number;
expiresAt: number;
consumedAt?: number;
consumedByNodeId?: string;
}
export interface IJumpCodePublic {
id: string;
data: IJumpCodeData;
}
@plugins.smartdata.Manager()
export class JumpCode extends plugins.smartdata.SmartDataDbDoc<JumpCode, IJumpCodePublic> {
constructor(optionsArg?: IJumpCodePublic & { tokenHash?: string }) {
super();
if (optionsArg) {
Object.assign(this, optionsArg);
}
}
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public tokenHash!: string;
@plugins.smartdata.svDb()
public data!: IJumpCodeData;
public toPublicObject(): IJumpCodePublic {
return {
id: this.id,
data: this.data,
};
}
}
+379
View File
@@ -0,0 +1,379 @@
import * as plugins from '../plugins.js';
import type { Cloudly } from '../classes.cloudly.js';
import { logger } from '../logger.js';
import { JumpCode } from './classes.jumpcode.js';
type IReqCreateNodeJumpCommand = plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand['request'];
type IResCreateNodeJumpCommand = plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand['response'];
interface IClaimJumpCodeRequest {
jumpCode?: string;
hostname?: string;
}
interface IClaimJumpCodeResponse {
accepted: boolean;
message?: string;
nodeId?: string;
sparkNodeToken?: string;
cloudlyUrl?: string;
coreflowJumpCode?: string;
}
export class CloudlyJumpManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
public CJumpCode = plugins.smartdata.setDefaultManagerForDoc(this, JumpCode);
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
private defaultTtlMs = 1000 * 60 * 30;
private maxTtlMs = 1000 * 60 * 60 * 24;
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedRouter);
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.node.IReq_Any_Cloudly_CreateNodeJumpCommand>('createNodeJumpCommand', async (requestDataArg) => {
await plugins.smartguard.passGuardsOrReject(
{ identity: requestDataArg.identity },
[this.cloudlyRef.authManager.adminIdentityGuard],
);
return await this.createNodeJumpCommand(requestDataArg);
}),
);
}
public async start() {
logger.log('info', 'Jump manager started');
}
public async stop() {
logger.log('info', 'Jump manager stopped');
}
public async createNodeJumpCommand(optionsArg: IReqCreateNodeJumpCommand): Promise<IResCreateNodeJumpCommand> {
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
id: optionsArg.clusterId,
});
if (!cluster) {
throw new plugins.typedrequest.TypedResponseError(`Cluster ${optionsArg.clusterId} not found`);
}
const now = Date.now();
const ttlMs = this.normalizeTtl(optionsArg.ttlMs);
const jumpCode = this.createJumpCode();
const jumpCodeDoc = new this.CJumpCode({
id: await this.CJumpCode.getNewId(),
tokenHash: this.hashSecret(jumpCode),
data: {
clusterId: cluster.id,
createdBy: optionsArg.identity.userId,
role: optionsArg.role || 'worker',
nodeType: optionsArg.nodeType || 'baremetal',
createdAt: now,
expiresAt: now + ttlMs,
},
});
await jumpCodeDoc.save();
const jumpUrl = `${this.getPublicCloudlyUrl()}/jump/${encodeURIComponent(jumpCode)}`;
const setupUrl = `${jumpUrl}/setup.sh`;
return {
jumpCode,
jumpUrl,
setupUrl,
command: `curl -fsSL '${jumpUrl}' | sudo bash`,
expiresAt: jumpCodeDoc.data.expiresAt,
};
}
public async handleJumpHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
const jumpCode = this.getCodeFromContext(ctxArg);
if (this.shouldRenderHtml(ctxArg)) {
return await this.createLandingPageResponse(jumpCode);
}
return await this.createSetupScriptResponse(jumpCode);
}
public async handleSetupScriptHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
return await this.createSetupScriptResponse(this.getCodeFromContext(ctxArg));
}
public async handleClaimHttpRequest(ctxArg: plugins.typedserver.IRequestContext): Promise<Response> {
try {
const requestData = await this.readJsonBody<IClaimJumpCodeRequest>(ctxArg);
const response = await this.claimJumpCode(requestData);
return this.createJsonResponse(200, response);
} catch (error) {
return this.createJsonResponse(400, {
accepted: false,
message: (error as Error).message,
} satisfies IClaimJumpCodeResponse);
}
}
public async claimJumpCode(requestDataArg: IClaimJumpCodeRequest): Promise<IClaimJumpCodeResponse> {
if (!requestDataArg.jumpCode) {
throw new Error('Jump code is missing');
}
const jumpCodeDoc = await this.getJumpCodeByCode(requestDataArg.jumpCode);
if (!jumpCodeDoc) {
throw new Error('Jump code is invalid');
}
if (jumpCodeDoc.data.consumedAt) {
throw new Error('Jump code has already been used');
}
if (jumpCodeDoc.data.expiresAt <= Date.now()) {
throw new Error('Jump code has expired');
}
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
id: jumpCodeDoc.data.clusterId,
});
if (!cluster) {
throw new Error('Jump code references a missing cluster');
}
const clusterUser = await this.cloudlyRef.authManager.CUser.getInstance({
id: cluster.data.userId,
});
const coreflowJumpCode = clusterUser?.data.tokens?.find((tokenArg) => tokenArg.expiresAt > Date.now())?.token;
if (!coreflowJumpCode) {
throw new Error('Cluster runtime token is missing or expired');
}
const nodeId = plugins.smartunique.shortId(8);
const now = Date.now();
const sparkNodeToken = await this.cloudlyRef.authManager.createNewSecureToken();
const node = new this.cloudlyRef.nodeManager.CClusterNode();
node.id = nodeId;
node.sparkNodeTokenHash = this.hashSecret(sparkNodeToken);
node.data = {
clusterId: cluster.id,
nodeType: jumpCodeDoc.data.nodeType,
status: 'initializing',
role: jumpCodeDoc.data.role,
joinedAt: now,
lastHealthCheck: now,
sshKeys: [],
requiredDebianPackages: [],
};
await node.save();
cluster.data.nodes = [
...(cluster.data.nodes || []).filter((nodeArg) => nodeArg.id !== node.id),
await node.createSavableObject(),
];
await cluster.save();
jumpCodeDoc.data = {
...jumpCodeDoc.data,
consumedAt: now,
consumedByNodeId: node.id,
};
await jumpCodeDoc.save();
return {
accepted: true,
nodeId: node.id,
sparkNodeToken,
cloudlyUrl: cluster.data.cloudlyUrl || `${this.getPublicCloudlyUrl()}/`,
coreflowJumpCode,
};
}
private async createLandingPageResponse(jumpCodeArg: string) {
const jumpCodeDoc = await this.getJumpCodeByCode(jumpCodeArg);
let clusterName = 'Unknown cluster';
let isUsable = false;
if (jumpCodeDoc && !jumpCodeDoc.data.consumedAt && jumpCodeDoc.data.expiresAt > Date.now()) {
const cluster = await this.cloudlyRef.clusterManager.CCluster.getInstance({
id: jumpCodeDoc.data.clusterId,
});
clusterName = cluster?.data.name || jumpCodeDoc.data.clusterId;
isUsable = true;
}
const jumpUrl = `${this.getPublicCloudlyUrl()}/jump/${encodeURIComponent(jumpCodeArg)}`;
const command = `curl -fsSL '${jumpUrl}' | sudo bash`;
const html = `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Cloudly Jump</title>
<style>
body { margin: 0; font-family: Inter, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #0d1117; color: #f0f6fc; }
main { max-width: 760px; margin: 10vh auto; padding: 32px; }
.card { border: 1px solid #30363d; border-radius: 18px; background: #161b22; padding: 28px; box-shadow: 0 24px 80px rgba(0, 0, 0, 0.35); }
.label { color: #8b949e; font-size: 13px; text-transform: uppercase; letter-spacing: 0.08em; }
h1 { margin: 8px 0 12px; font-size: 34px; }
p { color: #c9d1d9; line-height: 1.55; }
pre { white-space: pre-wrap; word-break: break-all; background: #0d1117; border: 1px solid #30363d; border-radius: 12px; padding: 16px; color: #7ee787; }
.status { display: inline-block; margin-top: 16px; padding: 6px 10px; border-radius: 999px; background: ${isUsable ? '#17391f' : '#3d1d1d'}; color: ${isUsable ? '#7ee787' : '#ff7b72'}; }
</style>
</head>
<body>
<main>
<div class="card">
<div class="label">Cloudly Jump</div>
<h1>Connect System</h1>
<p>Cluster: <strong>${this.escapeHtml(clusterName)}</strong></p>
<p>Run this command on the Linux system you want to connect:</p>
<pre>${this.escapeHtml(command)}</pre>
<div class="status">${isUsable ? 'Ready to use' : 'This jump code is invalid, expired, or already used'}</div>
</div>
</main>
</body>
</html>`;
return new Response(html, {
status: isUsable ? 200 : 404,
headers: {
'Content-Type': 'text/html; charset=utf-8',
},
});
}
private async createSetupScriptResponse(jumpCodeArg: string) {
if (!jumpCodeArg || !(await this.isJumpCodeUsable(jumpCodeArg))) {
return new Response('jump code is invalid, expired, or already used\n', {
status: 404,
headers: {
'Content-Type': 'text/plain; charset=utf-8',
},
});
}
return new Response(this.createSetupScript(jumpCodeArg), {
headers: {
'Content-Type': 'application/x-sh; charset=utf-8',
},
});
}
private createSetupScript(jumpCodeArg: string) {
const claimUrl = `${this.getPublicCloudlyUrl()}/jump/v1/claim`;
return `#!/usr/bin/env bash
set -euo pipefail
if [ "$(id -u)" -ne 0 ]; then
echo "Cloudly jump setup must run as root. Re-run with sudo." >&2
exit 1
fi
export DEBIAN_FRONTEND=noninteractive
export JUMP_CODE='${this.escapeShellValue(jumpCodeArg)}'
export CLAIM_URL='${this.escapeShellValue(claimUrl)}'
echo "Preparing system for Cloudly jump..."
apt-get update
apt-get install -y --force-yes curl ca-certificates git
if ! command -v docker >/dev/null 2>&1; then
curl -sSL https://get.docker.com/ | sh
fi
if ! command -v node >/dev/null 2>&1; then
curl -sL https://deb.nodesource.com/setup_18.x | bash
apt-get install -y --force-yes nodejs
fi
if ! command -v pnpm >/dev/null 2>&1; then
curl -fsSL https://get.pnpm.io/install.sh | sh -
fi
export PNPM_HOME="\${PNPM_HOME:-/root/.local/share/pnpm}"
export PATH="\${PNPM_HOME}:\${PATH}"
pnpm install -g @serve.zone/spark
REQUEST_BODY="$(node -e 'process.stdout.write(JSON.stringify({ jumpCode: process.env.JUMP_CODE, hostname: require("os").hostname() }))')"
CLAIM_RESPONSE="$(curl -fsSL -X POST "\${CLAIM_URL}" -H 'content-type: application/json' --data "\${REQUEST_BODY}")"
export CLAIM_RESPONSE
CLOUDLY_URL="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.accepted) { throw new Error(data.message || "Cloudly rejected jump code"); } process.stdout.write(data.cloudlyUrl);')"
COREFLOW_JUMPCODE="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.coreflowJumpCode) { throw new Error("Cloudly did not return a Coreflow jump code"); } process.stdout.write(data.coreflowJumpCode);')"
SPARK_NODE_ID="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.nodeId) { throw new Error("Cloudly did not return a Spark node id"); } process.stdout.write(data.nodeId);')"
SPARK_NODE_TOKEN="$(node -e 'const data = JSON.parse(process.env.CLAIM_RESPONSE); if (!data.sparkNodeToken) { throw new Error("Cloudly did not return a Spark node token"); } process.stdout.write(data.sparkNodeToken);')"
spark installdaemon --mode=coreflow-node --cloudlyUrl="\${CLOUDLY_URL}" --jumpcode="\${COREFLOW_JUMPCODE}" --nodeId="\${SPARK_NODE_ID}" --nodeToken="\${SPARK_NODE_TOKEN}"
echo "Cloudly jump completed. This system is now connected."
`;
}
private async getJumpCodeByCode(jumpCodeArg: string) {
const jumpCodes = await this.CJumpCode.getInstances({
tokenHash: this.hashSecret(jumpCodeArg),
});
return jumpCodes[0] || null;
}
private async isJumpCodeUsable(jumpCodeArg: string) {
const jumpCodeDoc = await this.getJumpCodeByCode(jumpCodeArg);
return Boolean(jumpCodeDoc && !jumpCodeDoc.data.consumedAt && jumpCodeDoc.data.expiresAt > Date.now());
}
private getCodeFromContext(ctxArg: plugins.typedserver.IRequestContext) {
return ctxArg.params.code || ctxArg.url.pathname.split('/').filter(Boolean)[1] || '';
}
private shouldRenderHtml(ctxArg: plugins.typedserver.IRequestContext) {
const acceptHeader = ctxArg.headers.get('accept') || '';
const userAgent = ctxArg.headers.get('user-agent') || '';
return acceptHeader.includes('text/html') && !/(curl|wget|httpie|fetch)/i.test(userAgent);
}
private createJumpCode() {
return plugins.crypto.randomBytes(12).toString('base64url');
}
private normalizeTtl(ttlMsArg?: number) {
if (!ttlMsArg || !Number.isFinite(ttlMsArg)) {
return this.defaultTtlMs;
}
return Math.min(Math.max(ttlMsArg, 1000 * 60), this.maxTtlMs);
}
private hashSecret(secretArg: string) {
return plugins.crypto.createHash('sha256').update(secretArg).digest('hex');
}
private getPublicCloudlyUrl() {
const sslMode = this.cloudlyRef.config.data.sslMode;
const protocol = sslMode === 'none' ? 'http' : 'https';
const port = String(this.cloudlyRef.config.data.publicPort || (protocol === 'https' ? '443' : '80'));
const includePort = !((protocol === 'https' && port === '443') || (protocol === 'http' && port === '80'));
return `${protocol}://${this.cloudlyRef.config.data.publicUrl}${includePort ? `:${port}` : ''}`;
}
private async readJsonBody<T>(ctxArg: plugins.typedserver.IRequestContext): Promise<T> {
const bodyString = (await ctxArg.text()).trim();
return bodyString ? JSON.parse(bodyString) as T : {} as T;
}
private createJsonResponse(statusCodeArg: number, bodyArg: object): Response {
return new Response(JSON.stringify(bodyArg), {
status: statusCodeArg,
headers: {
'Content-Type': 'application/json',
},
});
}
private escapeHtml(valueArg: string) {
return valueArg
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
private escapeShellValue(valueArg: string) {
return valueArg.replaceAll("'", "'\\''");
}
}
+1 -1
View File
@@ -12,4 +12,4 @@ export class LogManager {
this.cloudlyRef = cloudlyRefArg;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedRouter);
}
}
}
+78
View File
@@ -0,0 +1,78 @@
import * as plugins from '../plugins.js';
/**
* ClusterNode represents a logical node participating in a cluster
*/
@plugins.smartdata.Manager()
export class ClusterNode extends plugins.smartdata.SmartDataDbDoc<
ClusterNode,
plugins.servezoneInterfaces.data.IClusterNode
> {
// STATIC
public static async createFromHetznerServer(
hetznerServerArg: plugins.hetznercloud.HetznerServer,
clusterId: string,
baremetalId: string,
) {
const newNode = new ClusterNode();
newNode.id = plugins.smartunique.shortId(8);
const data: plugins.servezoneInterfaces.data.IClusterNode['data'] = {
clusterId: clusterId,
baremetalId: baremetalId,
nodeType: 'baremetal',
status: 'initializing',
role: 'worker',
joinedAt: Date.now(),
lastHealthCheck: Date.now(),
sshKeys: [],
requiredDebianPackages: [],
};
Object.assign(newNode, { data });
await newNode.save();
return newNode;
}
// INSTANCE
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public data!: plugins.servezoneInterfaces.data.IClusterNode['data'];
@plugins.smartdata.svDb()
public sparkNodeTokenHash?: string;
constructor() {
super();
}
public async getDeployments(): Promise<plugins.servezoneInterfaces.data.IDeployment[]> {
// TODO: Implement getting deployments for this node
return [];
}
public async updateMetrics(metrics: plugins.servezoneInterfaces.data.IClusterNodeMetrics) {
this.data.metrics = metrics;
this.data.lastHealthCheck = Date.now();
await this.save();
}
public async updateSparkHeartbeat(
metricsArg: plugins.servezoneInterfaces.data.IClusterNodeMetrics,
runtimeInfoArg: plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo,
) {
this.data.metrics = metricsArg;
this.data.sparkRuntimeInfo = runtimeInfoArg;
this.data.status = 'online';
this.data.lastHealthCheck = Date.now();
if (typeof runtimeInfoArg.swarmNodeId === 'string' && runtimeInfoArg.swarmNodeId) {
this.data.swarmNodeId = runtimeInfoArg.swarmNodeId;
}
await this.save();
}
public async updateStatus(status: plugins.servezoneInterfaces.data.IClusterNode['data']['status']) {
this.data.status = status;
await this.save();
}
}
+101
View File
@@ -0,0 +1,101 @@
import { logger } from '../logger.js';
import * as plugins from '../plugins.js';
import type { CloudlyNodeManager } from './classes.nodemanager.js';
import type { Cluster } from '../manager.cluster/classes.cluster.js';
export class CurlFresh {
public optionsArg = {
npmRegistry: 'https://registry.npmjs.org',
};
public scripts = {
'setup.sh': `#!/bin/bash
# lets update the system and install curl
# might be installed already, but entrypoint could have been wget
apt-get update
apt-get install -y --force-yes curl
# Basic updating of the software lists
echo 'debconf debconf/frontend select Noninteractive' | debconf-set-selections
apt-get update
apt-get upgrade -y --force-yes
apt-get install -y --force-yes fail2ban curl git
curl -sL https://deb.nodesource.com/setup_18.x | bash
# Install docker
curl -sSL https://get.docker.com/ | sh
# Install default nodejs to run nodejs tools
apt-get install -y nodejs zsh
zsh -c "$(curl -fsSL https://raw.githubusercontent.com/ohmyzsh/ohmyzsh/master/tools/install.sh)" "" --unattended
npm config set unsafe-perm true
# lets install pnpm
curl -fsSL https://get.pnpm.io/install.sh | sh -
# lets make sure we use the correct npm registry
bash -c "npm config set registry ${this.optionsArg.npmRegistry}"
# lets install spark
bash -c "pnpm install -g @serve.zone/spark"
# lets install the spark daemon
bash -c "spark installdaemon --mode=coreflow-node --cloudlyUrl='__CLOUDLY_URL__' --jumpcode='__JUMPCODE__'"
`,
};
public nodeManagerRef: CloudlyNodeManager;
public async handleRequest(ctx: plugins.typedserver.IRequestContext): Promise<Response> {
logger.log('info', 'curlfresh handler called. a server might be coming online soon :)');
const scriptname = ctx.params.scriptname;
switch (scriptname) {
case 'setup.sh':
logger.log('info', 'sending setup.sh');
return new Response(this.scripts['setup.sh']
.replaceAll('__CLOUDLY_URL__', ctx.url.searchParams.get('cloudlyUrl') || '')
.replaceAll('__JUMPCODE__', ctx.url.searchParams.get('jumpcode') || ''), {
headers: {
'Content-Type': 'application/x-sh',
},
});
default:
return new Response('no script found', { status: 404 });
}
}
constructor(nodeManagerRefArg: CloudlyNodeManager) {
this.nodeManagerRef = nodeManagerRefArg;
}
public async getServerUserData(clusterArg?: Cluster): Promise<string> {
const sslMode =
await this.nodeManagerRef.cloudlyRef.config.appData.waitForAndGetKey('sslMode');
let protocol: 'http' | 'https';
if (sslMode === 'none') {
protocol = 'http';
} else {
protocol = 'https';
}
const domain =
await this.nodeManagerRef.cloudlyRef.config.appData.waitForAndGetKey('publicUrl');
const port =
await this.nodeManagerRef.cloudlyRef.config.appData.waitForAndGetKey('publicPort');
let cloudlyUrl = `${protocol}://${domain}:${port}/`;
let jumpcode = '';
if (clusterArg?.data.userId) {
const clusterUser = await this.nodeManagerRef.cloudlyRef.authManager.CUser.getInstance({
id: clusterArg.data.userId,
});
jumpcode = clusterUser?.data.tokens?.[0]?.token || '';
cloudlyUrl = clusterArg.data.cloudlyUrl || cloudlyUrl;
}
const serverUserData = `#cloud-config
runcmd:
- curl -o- '${protocol}://${domain}:${port}/curlfresh/setup.sh?cloudlyUrl=${encodeURIComponent(cloudlyUrl)}&jumpcode=${encodeURIComponent(jumpcode)}' | sh
`;
console.log(serverUserData);
return serverUserData;
}
}
+259
View File
@@ -0,0 +1,259 @@
import * as plugins from '../plugins.js';
import { Cloudly } from '../classes.cloudly.js';
import { Cluster } from '../manager.cluster/classes.cluster.js';
import { ClusterNode } from './classes.clusternode.js';
import { CurlFresh } from './classes.curlfresh.js';
interface ISparkHeartbeatRequest {
nodeId?: string;
nodeToken?: string;
metrics?: plugins.servezoneInterfaces.data.IClusterNodeMetrics;
runtimeInfo?: plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo;
}
interface ISparkHeartbeatResponse {
accepted: boolean;
message?: string;
}
export class CloudlyNodeManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
public curlfreshInstance = new CurlFresh(this);
public hetznerAccount?: plugins.hetznercloud.HetznerAccount;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CClusterNode = plugins.smartdata.setDefaultManagerForDoc(this, ClusterNode);
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
/**
* is used be serverconfig module on the node to get the actual node config
*/
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.node.IRequest_Any_Cloudly_GetNodeConfig>(
'getNodeConfig',
async (requestData) => {
const nodeId = requestData.nodeId;
const node = await this.CClusterNode.getInstance({
id: nodeId,
});
return {
configData: await node.createSavableObject(),
};
},
),
);
}
public async start() {
const hetznerToken = await this.cloudlyRef.settingsManager.getSetting('hetznerToken');
if (!hetznerToken) {
console.log('warn', 'No Hetzner token configured in settings. Hetzner features will be disabled.');
return;
}
this.hetznerAccount = new plugins.hetznercloud.HetznerAccount(hetznerToken);
}
public async stop() {}
public async handleSparkHeartbeatHttpRequest(
ctxArg: plugins.typedserver.IRequestContext,
): Promise<Response> {
try {
const requestData = await this.readJsonBody<ISparkHeartbeatRequest>(ctxArg);
const response = await this.acceptSparkHeartbeat(requestData);
return this.createJsonResponse(200, response);
} catch (error) {
return this.createJsonResponse(400, {
accepted: false,
message: `Spark heartbeat failed: ${(error as Error).message}`,
} satisfies ISparkHeartbeatResponse);
}
}
public async acceptSparkHeartbeat(
requestDataArg: ISparkHeartbeatRequest,
): Promise<ISparkHeartbeatResponse> {
if (!requestDataArg.nodeId) {
return {
accepted: false,
message: 'Spark node id is missing',
};
}
if (!requestDataArg.nodeToken) {
return {
accepted: false,
message: 'Spark node token is missing',
};
}
if (!this.isSparkMetrics(requestDataArg.metrics)) {
return {
accepted: false,
message: 'Spark metrics are missing or invalid',
};
}
if (!this.isSparkRuntimeInfo(requestDataArg.runtimeInfo, requestDataArg.nodeId)) {
return {
accepted: false,
message: 'Spark runtime info is missing or invalid',
};
}
const node = await this.CClusterNode.getInstance({ id: requestDataArg.nodeId });
if (!node) {
return {
accepted: false,
message: 'Spark node is unknown',
};
}
if (node.sparkNodeTokenHash !== this.hashSecret(requestDataArg.nodeToken)) {
return {
accepted: false,
message: 'Spark node token is invalid',
};
}
await node.updateSparkHeartbeat(requestDataArg.metrics, requestDataArg.runtimeInfo);
return {
accepted: true,
};
}
/**
* creates the node infrastructure on hetzner
* ensures that there are exactly the resources that are needed
* no more, no less
*/
public async ensureNodeInfrastructure() {
// get all clusters
const allClusters = await this.cloudlyRef.clusterManager.getAllClusters();
for (const cluster of allClusters) {
// Skip clusters that are not set up for Hetzner auto-provisioning
if (cluster.data.setupMode !== 'hetzner') {
console.log(`Skipping node provisioning for cluster ${cluster.id} - setupMode is ${cluster.data.setupMode || 'manual'}`);
continue;
}
const hetznerAccount = this.hetznerAccount;
if (!hetznerAccount) {
throw new Error('Hetzner account is not configured');
}
// get existing nodes
const nodes = await this.getNodesByCluster(cluster);
// if there is no node, create one
if (nodes.length === 0) {
const hetznerServer = await hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('node'),
location: 'nbg1',
type: 'cpx41',
labels: {
clusterId: cluster.id,
priority: '1',
},
userData: await this.curlfreshInstance.getServerUserData(cluster),
});
// First create BareMetal record
const baremetal = await this.cloudlyRef.baremetalManager.createBaremetalFromHetznerServer(hetznerServer);
const newNode = await ClusterNode.createFromHetznerServer(hetznerServer, cluster.id, baremetal.id);
await baremetal.assignNode(newNode.id);
console.log(`cluster created new node for cluster ${cluster.id}`);
} else {
console.log(
`cluster ${cluster.id} already has nodes. Making sure that they actually exist in the real world...`,
);
// if there is a node, make sure that it exists
for (const node of nodes) {
const hetznerServers = await hetznerAccount.getServersByLabel({
clusterId: cluster.id,
});
if (!hetznerServers || hetznerServers.length === 0) {
console.log(`node ${node.id} does not exist in the real world. Creating it now...`);
const hetznerServer = await hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('node'),
location: 'nbg1',
type: 'cpx41',
labels: {
clusterId: cluster.id,
priority: '1',
},
userData: await this.curlfreshInstance.getServerUserData(cluster),
});
// First create BareMetal record
const baremetal = await this.cloudlyRef.baremetalManager.createBaremetalFromHetznerServer(hetznerServer);
const newNode = await ClusterNode.createFromHetznerServer(hetznerServer, cluster.id, baremetal.id);
await baremetal.assignNode(newNode.id);
}
}
}
}
}
public async getNodesByCluster(clusterArg: Cluster) {
const results = await this.CClusterNode.getInstances({
data: {
clusterId: clusterArg.id,
},
});
return results;
}
private isSparkMetrics(valueArg: unknown): valueArg is plugins.servezoneInterfaces.data.IClusterNodeMetrics {
if (!valueArg || typeof valueArg !== 'object') {
return false;
}
const metrics = valueArg as Partial<plugins.servezoneInterfaces.data.IClusterNodeMetrics>;
return typeof metrics.cpuUsagePercent === 'number'
&& typeof metrics.memoryUsedMB === 'number'
&& typeof metrics.memoryAvailableMB === 'number'
&& typeof metrics.diskUsedGB === 'number'
&& typeof metrics.diskAvailableGB === 'number'
&& typeof metrics.containerCount === 'number'
&& typeof metrics.timestamp === 'number';
}
private isSparkRuntimeInfo(
valueArg: unknown,
nodeIdArg: string,
): valueArg is plugins.servezoneInterfaces.data.ISparkNodeRuntimeInfo {
if (!valueArg || typeof valueArg !== 'object') {
return false;
}
const runtimeInfo = valueArg as Record<string, unknown>;
return runtimeInfo.runtime === 'spark'
&& runtimeInfo.nodeId === nodeIdArg
&& typeof runtimeInfo.checkedAt === 'number';
}
private hashSecret(secretArg: string) {
return plugins.crypto.createHash('sha256').update(secretArg).digest('hex');
}
private async readJsonBody<T>(ctxArg: plugins.typedserver.IRequestContext): Promise<T> {
const bodyString = (await ctxArg.text()).trim();
return bodyString ? JSON.parse(bodyString) as T : {} as T;
}
private createJsonResponse(
statusCodeArg: number,
bodyArg: object,
): Response {
return new Response(JSON.stringify(bodyArg), {
status: statusCodeArg,
headers: {
'Content-Type': 'application/json',
},
});
}
}
@@ -0,0 +1,68 @@
import * as plugins from '../plugins.js';
import type { CloudlyPlatformManager } from './classes.platformmanager.js';
@plugins.smartdata.managed()
export class PlatformBinding extends plugins.smartdata.SmartDataDbDoc<
PlatformBinding,
plugins.servezoneInterfaces.platform.IPlatformBinding,
CloudlyPlatformManager
> {
public static async upsertBinding(
bindingArg: plugins.servezoneInterfaces.platform.IPlatformBinding,
) {
const existingBinding = bindingArg.id
? await this.getInstance({
id: bindingArg.id,
})
: undefined;
const binding = existingBinding || new PlatformBinding();
const timestamp = Date.now();
Object.assign(binding, {
...bindingArg,
id: bindingArg.id || (await this.getNewId()),
status: bindingArg.status || 'requested',
desiredState: bindingArg.desiredState || 'enabled',
createdAt: bindingArg.createdAt || existingBinding?.createdAt || timestamp,
updatedAt: timestamp,
});
await binding.save();
return binding;
}
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public serviceId!: string;
@plugins.smartdata.svDb()
public capability!: plugins.servezoneInterfaces.platform.TPlatformCapability;
@plugins.smartdata.svDb()
public desiredState!: plugins.servezoneInterfaces.platform.TPlatformDesiredState;
@plugins.smartdata.svDb()
public status!: plugins.servezoneInterfaces.platform.TPlatformBindingStatus;
@plugins.smartdata.svDb()
public providerConfigId?: string;
@plugins.smartdata.svDb()
public config?: { [key: string]: plugins.servezoneInterfaces.platform.TPlatformConfigValue };
@plugins.smartdata.svDb()
public endpoints?: plugins.servezoneInterfaces.platform.IPlatformServiceEndpoint[];
@plugins.smartdata.svDb()
public credentials?: plugins.servezoneInterfaces.platform.IPlatformCredentialRef[];
@plugins.smartdata.svDb()
public createdAt?: number;
@plugins.smartdata.svDb()
public updatedAt?: number;
@plugins.smartdata.svDb()
public errorText?: string;
}
@@ -0,0 +1,228 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import { PlatformBinding } from './classes.platformbinding.js';
import { PlatformProviderConfig } from './classes.platformproviderconfig.js';
export class CloudlyPlatformManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
public cloudlyRef: Cloudly;
public capabilities: plugins.servezoneInterfaces.platform.IPlatformCapability[] = [
{ id: 'email', title: 'Email', accessMode: 'rpc', defaultProviderType: 'cloudly' },
{ id: 'sms', title: 'SMS', accessMode: 'rpc', defaultProviderType: 'cloudly' },
{ id: 'pushnotification', title: 'Push Notifications', accessMode: 'rpc', defaultProviderType: 'cloudly' },
{ id: 'letter', title: 'Letters', accessMode: 'rpc', defaultProviderType: 'cloudly' },
{ id: 'ai', title: 'AI', accessMode: 'rpc', defaultProviderType: 'cloudly' },
{ id: 'database', title: 'Database', accessMode: 'binding', defaultProviderType: 'docker' },
{ id: 'objectstorage', title: 'Object Storage', accessMode: 'binding', defaultProviderType: 's3' },
{ id: 'logging', title: 'Logging', accessMode: 'sidecar', defaultProviderType: 'corelog' },
{ id: 'backup', title: 'Backup', accessMode: 'internal', defaultProviderType: 'corebackup' },
{ id: 'sip', title: 'SIP', accessMode: 'rpc', defaultProviderType: 'cloudly' },
];
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CPlatformProviderConfig = plugins.smartdata.setDefaultManagerForDoc(
this,
PlatformProviderConfig,
);
public CPlatformBinding = plugins.smartdata.setDefaultManagerForDoc(this, PlatformBinding);
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.platform.IReq_Any_Cloudly_GetPlatformDesiredState>(
'getPlatformDesiredState',
async (requestData) => {
await this.passValidIdentity(requestData);
return await this.getPlatformDesiredState();
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.platform.IReq_Any_Cloudly_GetPlatformCapabilities>(
'getPlatformCapabilities',
async (requestData) => {
await this.passValidIdentity(requestData);
return {
capabilities: this.capabilities,
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.platform.IReq_Any_Cloudly_GetPlatformProviderConfigs>(
'getPlatformProviderConfigs',
async (requestData) => {
await this.passValidIdentity(requestData);
const query = requestData.capability ? { capability: requestData.capability } : {};
return {
providerConfigs: await this.getProviderConfigs(query),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.platform.IReq_Any_Cloudly_UpsertPlatformProviderConfig>(
'upsertPlatformProviderConfig',
async (requestData) => {
await this.passAdminIdentity(requestData);
const providerConfig = await PlatformProviderConfig.upsertProviderConfig(
requestData.providerConfig,
);
return {
providerConfig: await providerConfig.createSavableObject(),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.platform.IReq_Any_Cloudly_DeletePlatformProviderConfigById>(
'deletePlatformProviderConfigById',
async (requestData) => {
await this.passAdminIdentity(requestData);
const providerConfig = await PlatformProviderConfig.getInstance({
id: requestData.providerConfigId,
});
if (providerConfig) {
await providerConfig.delete();
}
return {
success: true,
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.platform.IReq_Any_Cloudly_GetPlatformBindings>(
'getPlatformBindings',
async (requestData) => {
await this.passValidIdentity(requestData);
return {
bindings: await this.getBindings({
...(requestData.serviceId ? { serviceId: requestData.serviceId } : {}),
...(requestData.capability ? { capability: requestData.capability } : {}),
}),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.platform.IReq_Any_Cloudly_UpsertPlatformBinding>(
'upsertPlatformBinding',
async (requestData) => {
await this.passAdminIdentity(requestData);
const binding = await PlatformBinding.upsertBinding(requestData.binding);
return {
binding: await binding.createSavableObject(),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.platform.IReq_Any_Cloudly_UpdatePlatformBindingStatus>(
'updatePlatformBindingStatus',
async (requestData) => {
await this.passAdminOrClusterIdentity(requestData);
const binding = await PlatformBinding.getInstance({
id: requestData.bindingId,
});
if (!binding) {
throw new plugins.typedrequest.TypedResponseError(
`Platform binding ${requestData.bindingId} not found`,
);
}
binding.status = requestData.status;
binding.updatedAt = Date.now();
if (requestData.endpoints) {
binding.endpoints = requestData.endpoints;
}
if (requestData.credentials) {
binding.credentials = requestData.credentials;
}
if (requestData.errorText !== undefined) {
binding.errorText = requestData.errorText;
}
await binding.save();
return {
binding: await binding.createSavableObject(),
};
},
),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.platform.IReq_Any_Cloudly_DeletePlatformBindingById>(
'deletePlatformBindingById',
async (requestData) => {
await this.passAdminIdentity(requestData);
const binding = await PlatformBinding.getInstance({
id: requestData.bindingId,
});
if (binding) {
await binding.delete();
}
return {
success: true,
};
},
),
);
}
public async start() {}
public async stop() {}
public async getPlatformDesiredState() {
return {
capabilities: this.capabilities,
providerConfigs: await this.getProviderConfigs(),
bindings: await this.getBindings(),
};
}
public async getProviderConfigs(queryArg: Record<string, unknown> = {}) {
const providerConfigs = await this.CPlatformProviderConfig.getInstances(queryArg);
return await Promise.all(
providerConfigs.map((providerConfig) => providerConfig.createSavableObject()),
);
}
public async getBindings(queryArg: Record<string, unknown> = {}) {
const bindings = await this.CPlatformBinding.getInstances(queryArg);
return await Promise.all(bindings.map((binding) => binding.createSavableObject()));
}
private async passValidIdentity(requestData: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
await plugins.smartguard.passGuardsOrReject(requestData, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
}
private async passAdminIdentity(requestData: { identity: plugins.servezoneInterfaces.data.IIdentity }) {
await plugins.smartguard.passGuardsOrReject(requestData, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
}
private async passAdminOrClusterIdentity(requestData: {
identity: plugins.servezoneInterfaces.data.IIdentity;
}) {
await this.passValidIdentity(requestData);
if (requestData.identity.role !== 'admin' && requestData.identity.role !== 'cluster') {
throw new plugins.typedrequest.TypedResponseError('identity must be admin or cluster');
}
}
}
@@ -0,0 +1,47 @@
import * as plugins from '../plugins.js';
import type { CloudlyPlatformManager } from './classes.platformmanager.js';
@plugins.smartdata.managed()
export class PlatformProviderConfig extends plugins.smartdata.SmartDataDbDoc<
PlatformProviderConfig,
plugins.servezoneInterfaces.platform.IPlatformProviderConfig,
CloudlyPlatformManager
> {
public static async upsertProviderConfig(
providerConfigArg: plugins.servezoneInterfaces.platform.IPlatformProviderConfig,
) {
const providerConfig =
(providerConfigArg.id &&
(await this.getInstance({
id: providerConfigArg.id,
}))) || new PlatformProviderConfig();
Object.assign(providerConfig, {
...providerConfigArg,
id: providerConfigArg.id || (await this.getNewId()),
});
await providerConfig.save();
return providerConfig;
}
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public capability!: plugins.servezoneInterfaces.platform.TPlatformCapability;
@plugins.smartdata.svDb()
public providerType!: string;
@plugins.smartdata.svDb()
public name!: string;
@plugins.smartdata.svDb()
public enabled!: boolean;
@plugins.smartdata.svDb()
public config?: { [key: string]: plugins.servezoneInterfaces.platform.TPlatformConfigValue };
@plugins.smartdata.svDb()
public secretBundleId?: string;
}
@@ -0,0 +1,636 @@
import type { Cloudly } from '../classes.cloudly.js';
import { logger } from '../logger.js';
import * as plugins from '../plugins.js';
import type { Service } from '../manager.service/classes.service.js';
type TAuthenticatedRegistryUser = {
userId: string;
username: string;
canWrite: boolean;
};
type TOciTags = Record<string, string>;
interface IOciDescriptor {
digest?: unknown;
}
interface IOciManifestDocument {
config?: IOciDescriptor;
layers?: IOciDescriptor[];
manifests?: IOciDescriptor[];
}
export class CloudlyRegistryManager {
private cloudlyRef: Cloudly;
private smartRegistry!: plugins.smartregistry.SmartRegistry;
private recordedTagDigests = new Map<string, string>();
private started = false;
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
}
public async start() {
const publicRegistryUrl = this.getPublicRegistryUrl();
const registryJwtSecret = JSON.stringify(this.cloudlyRef.authManager.smartjwtInstance.getKeyPairAsJson());
const s3Descriptor = this.cloudlyRef.config.data.s3Descriptor;
if (!s3Descriptor?.bucketName) {
throw new Error('Cloudly registry requires an S3 bucketName');
}
this.smartRegistry = new plugins.smartregistry.SmartRegistry({
storage: s3Descriptor as plugins.smartregistry.IStorageConfig,
storageHooks: {
afterPut: async (contextArg) => {
await this.handleRegistryStorageAfterPut(contextArg);
},
},
auth: {
jwtSecret: registryJwtSecret,
tokenStore: 'memory',
npmTokens: { enabled: false },
ociTokens: {
enabled: true,
realm: `${publicRegistryUrl}/v2/token`,
service: this.cloudlyRef.config.data.publicUrl || 'cloudly',
},
pypiTokens: { enabled: false },
rubygemsTokens: { enabled: false },
},
oci: {
enabled: true,
basePath: '/v2',
registryUrl: publicRegistryUrl,
},
});
await this.smartRegistry.init();
this.started = true;
logger.log('info', `Cloudly OCI registry available at ${publicRegistryUrl}/v2`);
}
public async stop() {
if (this.smartRegistry) {
this.smartRegistry.destroy();
}
this.started = false;
}
public async handleHttpRequest(
ctx: plugins.typedserver.IRequestContext,
): Promise<Response> {
try {
const requestUrl = ctx.url;
if (requestUrl.pathname === '/v2/token') {
return await this.handleTokenRequest(ctx, requestUrl);
}
if (!this.started) {
return new Response('registry is not ready', { status: 503 });
}
const rawBody = Buffer.from(await ctx.request.arrayBuffer());
const response = await this.smartRegistry.handleRequest({
method: ctx.method || 'GET',
path: requestUrl.pathname,
query: Object.fromEntries(requestUrl.searchParams),
headers: this.headersToRecord(ctx.headers),
rawBody: rawBody.length > 0 ? rawBody : undefined,
});
return this.createRegistryResponse(response);
} catch (error) {
logger.log('error', `registry request failed: ${(error as Error).message}`);
return new Response('registry request failed', { status: 500 });
}
}
public getRegistryHost() {
if (!this.cloudlyRef.config.data.publicUrl) {
throw new Error('Cloudly registry requires publicUrl');
}
const publicPort = this.cloudlyRef.config.data.publicPort;
const includePort =
this.cloudlyRef.config.data.sslMode === 'none' && publicPort && !['80', '443'].includes(publicPort);
return `${this.cloudlyRef.config.data.publicUrl}${includePort ? `:${publicPort}` : ''}`;
}
public getServiceRegistryTarget(
serviceArg: Service,
tagArg = 'latest',
): plugins.servezoneInterfaces.data.IRegistryTarget {
const registryHost = this.getRegistryHost();
const repository = this.getServiceRepository(serviceArg);
return {
protocol: 'oci',
registryHost,
repository,
tag: tagArg,
imageUrl: `${registryHost}/${repository}:${tagArg}`,
serviceId: serviceArg.id,
imageId: serviceArg.data?.imageId,
};
}
public async deleteServiceRepository(serviceArg: Service): Promise<void> {
const repository = serviceArg.data.registryTarget?.repository;
if (!repository) return;
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
const referencedByOtherService = services.some((candidateArg) => {
return candidateArg.id !== serviceArg.id && candidateArg.data.registryTarget?.repository === repository;
});
if (referencedByOtherService) return;
await this.deleteOciRepository(repository);
for (const tagKey of Array.from(this.recordedTagDigests.keys())) {
if (tagKey.startsWith(`${repository}:`)) {
this.recordedTagDigests.delete(tagKey);
}
}
}
private getRegistryStorage(): any {
if (!this.started || !this.smartRegistry) {
throw new Error('Cloudly registry is not started');
}
return this.smartRegistry.getStorage();
}
private getOciTagsPath(repositoryArg: string): string {
return `oci/tags/${repositoryArg}/tags.json`;
}
private normalizeOciDigest(digestArg: string | null | undefined): string | null {
if (typeof digestArg !== 'string') return null;
const normalizedDigest = digestArg.trim().toLowerCase();
return /^sha256:[a-f0-9]{64}$/.test(normalizedDigest) ? normalizedDigest : null;
}
private getSha256HashFromDigest(digestArg: string): string {
const normalizedDigest = this.normalizeOciDigest(digestArg);
if (!normalizedDigest) {
throw new Error(`Invalid OCI digest: ${digestArg}`);
}
return normalizedDigest.slice('sha256:'.length);
}
private getOciManifestPath(repositoryArg: string, digestArg: string): string {
return `oci/manifests/${repositoryArg}/${this.getSha256HashFromDigest(digestArg)}`;
}
private getOciBlobPath(digestArg: string): string {
return `oci/blobs/sha256/${this.getSha256HashFromDigest(digestArg)}`;
}
private async readOciTags(repositoryArg: string, storageArg = this.getRegistryStorage()): Promise<TOciTags> {
const tagsBuffer = await storageArg.getObject(this.getOciTagsPath(repositoryArg));
if (!tagsBuffer) return {};
const parsedTags = JSON.parse(tagsBuffer.toString('utf8'));
if (!parsedTags || typeof parsedTags !== 'object' || Array.isArray(parsedTags)) {
throw new Error(`Invalid OCI tags document for ${repositoryArg}`);
}
const tags: TOciTags = {};
for (const [tagName, digestValue] of Object.entries(parsedTags)) {
const digest = typeof digestValue === 'string' ? this.normalizeOciDigest(digestValue) : null;
if (!digest) {
throw new Error(`Invalid OCI digest for ${repositoryArg}:${tagName}`);
}
tags[tagName] = digest;
}
return tags;
}
private async readOciManifest(
storageArg: any,
repositoryArg: string,
digestArg: string,
): Promise<IOciManifestDocument | null> {
const manifestBuffer = await storageArg.getOciManifest(repositoryArg, digestArg);
if (!manifestBuffer) return null;
try {
const manifest = JSON.parse(manifestBuffer.toString('utf8'));
return manifest && typeof manifest === 'object' ? manifest : null;
} catch (error) {
logger.log('warn', `failed to parse OCI manifest ${repositoryArg}@${digestArg}: ${(error as Error).message}`);
return null;
}
}
private getDescriptorDigest(descriptorArg: IOciDescriptor | undefined): string | null {
return typeof descriptorArg?.digest === 'string' ? this.normalizeOciDigest(descriptorArg.digest) : null;
}
private collectOciManifestReferences(manifestArg: IOciManifestDocument): {
blobDigests: string[];
manifestDigests: string[];
} {
const blobDigests = [
this.getDescriptorDigest(manifestArg.config),
...(manifestArg.layers || []).map((descriptorArg) => this.getDescriptorDigest(descriptorArg)),
].filter((digestArg): digestArg is string => Boolean(digestArg));
const manifestDigests = (manifestArg.manifests || [])
.map((descriptorArg) => this.getDescriptorDigest(descriptorArg))
.filter((digestArg): digestArg is string => Boolean(digestArg));
return { blobDigests, manifestDigests };
}
private async collectReferencedOciObjects(
storageArg: any,
repositoryArg: string,
rootDigestsArg: string[],
): Promise<{ manifestDigests: Set<string>; blobDigests: Set<string> }> {
const manifestDigests = new Set<string>();
const blobDigests = new Set<string>();
const pendingManifestDigests = rootDigestsArg
.map((digestArg) => this.normalizeOciDigest(digestArg))
.filter((digestArg): digestArg is string => Boolean(digestArg));
while (pendingManifestDigests.length > 0) {
const manifestDigest = pendingManifestDigests.shift()!;
if (manifestDigests.has(manifestDigest)) continue;
manifestDigests.add(manifestDigest);
const manifest = await this.readOciManifest(storageArg, repositoryArg, manifestDigest);
if (!manifest) continue;
const references = this.collectOciManifestReferences(manifest);
for (const blobDigest of references.blobDigests) {
blobDigests.add(blobDigest);
}
for (const childManifestDigest of references.manifestDigests) {
if (!manifestDigests.has(childManifestDigest)) {
pendingManifestDigests.push(childManifestDigest);
}
}
}
return { manifestDigests, blobDigests };
}
private async listRepositoryManifestDigests(storageArg: any, repositoryArg: string): Promise<string[]> {
const manifestPrefix = `oci/manifests/${repositoryArg}/`;
const paths = await storageArg.listObjects(manifestPrefix);
return paths
.filter((pathArg: string) => !pathArg.endsWith('.type'))
.map((pathArg: string) => pathArg.slice(manifestPrefix.length))
.filter((hashArg: string) => /^[a-f0-9]{64}$/.test(hashArg))
.map((hashArg: string) => `sha256:${hashArg}`);
}
private async collectAllTaggedOciObjectsExceptRepository(storageArg: any, repositoryArg: string) {
const protectedObjects = {
manifestDigests: new Set<string>(),
blobDigests: new Set<string>(),
};
const tagPaths = await storageArg.listObjects('oci/tags/');
for (const tagPath of tagPaths) {
const match = tagPath.match(/^oci\/tags\/(.+)\/tags\.json$/);
if (!match || match[1] === repositoryArg) continue;
const tags = await this.readOciTags(match[1], storageArg);
const references = await this.collectReferencedOciObjects(storageArg, match[1], Object.values(tags));
for (const digest of references.manifestDigests) {
protectedObjects.manifestDigests.add(digest);
}
for (const digest of references.blobDigests) {
protectedObjects.blobDigests.add(digest);
}
}
return protectedObjects;
}
private async deleteObjectIfExists(storageArg: any, pathArg: string): Promise<void> {
if (typeof storageArg.objectExists === 'function' && !(await storageArg.objectExists(pathArg))) {
return;
}
await storageArg.deleteObject(pathArg);
}
private async deleteOciRepository(repositoryArg: string): Promise<void> {
const storage = this.getRegistryStorage();
const tags = await this.readOciTags(repositoryArg, storage);
const repositoryManifestDigests = await this.listRepositoryManifestDigests(storage, repositoryArg);
const rootDigests = Array.from(new Set([...Object.values(tags), ...repositoryManifestDigests]));
if (rootDigests.length === 0) {
await this.deleteObjectIfExists(storage, this.getOciTagsPath(repositoryArg));
return;
}
const targetObjects = await this.collectReferencedOciObjects(storage, repositoryArg, rootDigests);
const protectedObjects = await this.collectAllTaggedOciObjectsExceptRepository(storage, repositoryArg);
for (const blobDigest of targetObjects.blobDigests) {
if (!protectedObjects.blobDigests.has(blobDigest)) {
await this.deleteObjectIfExists(storage, this.getOciBlobPath(blobDigest));
}
}
for (const manifestDigest of targetObjects.manifestDigests) {
if (!protectedObjects.manifestDigests.has(manifestDigest)) {
const manifestPath = this.getOciManifestPath(repositoryArg, manifestDigest);
await this.deleteObjectIfExists(storage, manifestPath);
await this.deleteObjectIfExists(storage, `${manifestPath}.type`);
}
}
await this.deleteObjectIfExists(storage, this.getOciTagsPath(repositoryArg));
logger.log('info', `deleted Cloudly registry repository ${repositoryArg}`);
}
private async handleRegistryStorageAfterPut(
contextArg: plugins.smartregistry.IStorageHookContext,
) {
try {
if (contextArg.protocol !== 'oci') {
return;
}
if (!contextArg.key.startsWith('oci/tags/') || !contextArg.key.endsWith('/tags.json')) {
return;
}
const repository = contextArg.key.slice('oci/tags/'.length, -'/tags.json'.length);
const tagsBuffer = await this.smartRegistry.getStorage().getObject(contextArg.key);
if (!tagsBuffer) {
return;
}
const tags = JSON.parse(tagsBuffer.toString('utf8')) as Record<string, string>;
for (const [tag, digest] of Object.entries(tags)) {
const tagKey = `${repository}:${tag}`;
if (this.recordedTagDigests.get(tagKey) === digest) {
continue;
}
this.recordedTagDigests.set(tagKey, digest);
await this.recordRegistryPushEvent(repository, tag, digest, contextArg.actor?.userId);
}
} catch (error) {
logger.log('error', `registry push event handling failed: ${(error as Error).message}`);
}
}
private async recordRegistryPushEvent(
repositoryArg: string,
tagArg: string,
digestArg: string,
actorUserIdArg?: string,
) {
const service = await this.getServiceByRegistryRepository(repositoryArg);
if (!service) {
logger.log('info', `registry push for unmapped repository ${repositoryArg}:${tagArg}`);
return;
}
const registryTarget = this.getServiceRegistryTarget(service, tagArg);
const pushEvent: plugins.servezoneInterfaces.data.IRegistryPushEvent = {
protocol: 'oci',
registryHost: registryTarget.registryHost,
repository: repositoryArg,
tag: tagArg,
digest: digestArg,
imageUrl: registryTarget.imageUrl,
pushedAt: Date.now(),
serviceId: service.id,
imageId: service.data.imageId,
actorUserId: actorUserIdArg,
};
service.data = {
...service.data,
...(service.data.deployOnPush === false ? {} : { imageVersion: tagArg }),
registryTarget,
};
await service.save();
await this.recordImagePushEvent(service, pushEvent);
if (service.data.deployOnPush !== false) {
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
}
logger.log('info', `recorded registry push ${repositoryArg}:${tagArg} -> ${digestArg}`);
}
private async recordImagePushEvent(
serviceArg: Service,
pushEventArg: plugins.servezoneInterfaces.data.IRegistryPushEvent,
) {
if (!serviceArg.data.imageId) {
return;
}
const image = await this.cloudlyRef.imageManager.CImage.getInstance({
id: serviceArg.data.imageId,
}).catch(() => null);
if (!image) {
return;
}
image.data.versions = image.data.versions || [];
const existingVersion = image.data.versions.find((versionArg) => {
return versionArg.versionString === pushEventArg.tag;
});
const versionData = {
versionString: pushEventArg.tag,
digest: pushEventArg.digest,
registryRepository: pushEventArg.repository,
registryTag: pushEventArg.tag,
source: 'registry' as const,
size: existingVersion?.size || 0,
createdAt: existingVersion?.createdAt || pushEventArg.pushedAt,
};
if (existingVersion) {
Object.assign(existingVersion, versionData);
} else {
image.data.versions.push(versionData);
}
image.data.lastPushEvent = pushEventArg;
await image.save();
}
private async getServiceByRegistryRepository(repositoryArg: string) {
const services = await this.cloudlyRef.serviceManager.CService.getInstances({});
return services.find((serviceArg) => {
return this.getServiceRepository(serviceArg) === repositoryArg;
});
}
private getServiceRepository(serviceArg: Service) {
const serviceName = this.slugify(serviceArg.data?.name || serviceArg.id);
const serviceId = this.slugify(serviceArg.id).slice(0, 12) || serviceArg.id;
return `workloads/${this.slugify(`${serviceName}-${serviceId}`)}`;
}
private slugify(valueArg: string) {
return valueArg
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '')
|| 'service';
}
private async handleTokenRequest(
ctx: plugins.typedserver.IRequestContext,
requestUrl: URL,
): Promise<Response> {
const user = await this.authenticateRequest(ctx);
if (!user) {
return new Response('authentication required', {
status: 401,
headers: {
'WWW-Authenticate': 'Basic realm="Cloudly Registry"',
},
});
}
const requestedScopes = this.getRequestedOciScopes(requestUrl.searchParams);
const requestedWriteAccess = requestedScopes.some((scopeArg) => {
const action = scopeArg.split(':').at(-1);
return action === 'push' || action === 'delete';
});
if (requestedWriteAccess && !user.canWrite) {
return new Response('registry write access denied', { status: 403 });
}
const token = await this.smartRegistry.getAuthManager().createOciToken(
user.userId,
requestedScopes,
3600,
);
return new Response(
JSON.stringify({
token,
access_token: token,
expires_in: 3600,
issued_at: new Date().toISOString(),
}),
{
status: 200,
headers: {
'Content-Type': 'application/json',
},
},
);
}
private async authenticateRequest(
ctx: plugins.typedserver.IRequestContext,
): Promise<TAuthenticatedRegistryUser | null> {
const credentials = this.getBasicCredentials(ctx);
if (!credentials) {
return null;
}
const users = await this.cloudlyRef.authManager.CUser.getInstances({});
for (const user of users) {
if (user.data?.username !== credentials.username) {
continue;
}
const passwordMatches = user.data.password === credentials.password;
const matchingToken = user.data.tokens?.find((tokenArg) => {
return tokenArg.token === credentials.password && tokenArg.expiresAt > Date.now();
});
if (!passwordMatches && !matchingToken) {
continue;
}
const assignedRoles = matchingToken?.assignedRoles || [];
return {
userId: user.id,
username: user.data.username,
canWrite: user.data.role === 'admin' || assignedRoles.includes('admin'),
};
}
return null;
}
private getBasicCredentials(ctx: plugins.typedserver.IRequestContext) {
const authHeader = ctx.headers.get('authorization');
if (!authHeader?.startsWith('Basic ')) {
return null;
}
const decoded = Buffer.from(authHeader.slice('Basic '.length), 'base64').toString('utf8');
const separatorIndex = decoded.indexOf(':');
if (separatorIndex <= 0) {
return null;
}
return {
username: decoded.slice(0, separatorIndex),
password: decoded.slice(separatorIndex + 1),
};
}
private getRequestedOciScopes(searchParamsArg: URLSearchParams) {
const scopes: string[] = [];
for (const scope of searchParamsArg.getAll('scope')) {
const [scopeType, scopeName, actionsString] = scope.split(':');
if (scopeType !== 'repository' || !scopeName || !actionsString) {
continue;
}
for (const action of actionsString.split(',')) {
if (action) {
scopes.push(`oci:${scopeType}:${scopeName}:${action}`);
}
}
}
return scopes;
}
private getPublicRegistryUrl() {
return `${this.cloudlyRef.config.data.sslMode === 'none' ? 'http' : 'https'}://${this.getRegistryHost()}`;
}
private headersToRecord(headersArg: Headers) {
const headers: Record<string, string> = {};
headersArg.forEach((value, key) => {
headers[key.toLowerCase()] = value;
});
return headers;
}
private createRegistryResponse(
responseArg: plugins.smartregistry.IResponse,
): Response {
const headers = new Headers();
for (const [key, value] of Object.entries(responseArg.headers)) {
headers.set(key, value);
}
if (!responseArg.body) {
return new Response(null, {
status: responseArg.status,
headers,
});
}
if (responseArg.body instanceof ReadableStream) {
return new Response(responseArg.body, {
status: responseArg.status,
headers,
});
}
if (Buffer.isBuffer(responseArg.body) || typeof responseArg.body === 'string') {
return new Response(responseArg.body as BodyInit, {
status: responseArg.status,
headers,
});
}
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
return new Response(JSON.stringify(responseArg.body), {
status: responseArg.status,
headers,
});
}
}
+1
View File
@@ -0,0 +1 @@
export * from './classes.registrymanager.js';
+15 -3
View File
@@ -12,10 +12,10 @@ export class SecretBundle extends plugins.smartdata.SmartDataDbDoc<
// INSTANCE
@plugins.smartdata.unI()
public id: string;
public id!: string;
@plugins.smartdata.svDb()
public data: plugins.servezoneInterfaces.data.ISecretBundle['data'];
public data!: plugins.servezoneInterfaces.data.ISecretBundle['data'];
public async getSecretGroups() {
const secretGroups: SecretGroup[] = [];
@@ -23,7 +23,7 @@ export class SecretBundle extends plugins.smartdata.SmartDataDbDoc<
secretGroups.push(
await SecretGroup.getInstance({
id: secretGroupId,
})
}),
);
}
return secretGroups;
@@ -59,4 +59,16 @@ export class SecretBundle extends plugins.smartdata.SmartDataDbDoc<
}
return returnObject;
}
public async getFlatKeyValueObject(environmentArg: string) {
if (!environmentArg) {
throw new Error('environment is required');
}
const secretGroups = await this.getSecretGroups();
const returnObject = {};
for (const secretGroup of secretGroups) {
returnObject[secretGroup.data.key] = secretGroup.data.environments[environmentArg].value;
}
return returnObject;
}
}
+2 -2
View File
@@ -14,8 +14,8 @@ export class SecretGroup extends plugins.smartdata.SmartDataDbDoc<
* the insatnce id. This should be a random id, except for default
*/
@plugins.smartdata.unI()
id: string;
id!: string;
@plugins.smartdata.svDb()
data: plugins.servezoneInterfaces.data.ISecretGroup['data'];
data!: plugins.servezoneInterfaces.data.ISecretGroup['data'];
}
+123 -65
View File
@@ -18,9 +18,9 @@ export class CloudlySecretManager {
// INSTANCE
public cloudlyRef: Cloudly;
public projectinfo = new plugins.projectinfo.ProjectinfoNpm(paths.packageDir);
public projectinfo = plugins.projectinfo.ProjectinfoNpm.create(paths.packageDir);
public serviceQenv = new plugins.qenv.Qenv(paths.packageDir, paths.nogitDir);
public typedrouter: plugins.typedrequest.TypedRouter;
public typedrouter!: plugins.typedrequest.TypedRouter;
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
@@ -35,99 +35,157 @@ export class CloudlySecretManager {
this.typedrouter = new plugins.typedrequest.TypedRouter();
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.secret.IReq_Admin_GetConfigBundlesAndSecretGroups>(
'adminGetConfigBundlesAndSecretGroups',
async (dataArg) => {
dataArg.jwt
// secretbundle routes
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secretbundle.IReq_GetSecretBundles>(
new plugins.typedrequest.TypedHandler(
'getSecretBundles',
async (dataArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], dataArg);
dataArg.identity.jwt;
const secretBundles = await SecretBundle.getInstances({});
const secretGroups = await SecretGroup.getInstances({});
return {
secretBundles: [
...(await Promise.all(
secretBundles.map((configBundle) => configBundle.createSavableObject())
secretBundles.map((configBundle) => configBundle.createSavableObject()),
)),
],
};
},
),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secretbundle.IReq_GetSecretBundleById>(
new plugins.typedrequest.TypedHandler('getSecretBundleById', async (dataArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminOrClusterIdentityGuard], dataArg);
const secretBundle = await SecretBundle.getInstance({
id: dataArg.secretBundleId,
});
return {
secretBundle: await secretBundle.createSavableObject(),
};
}),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secretbundle.IReq_CreateSecretBundle>(
new plugins.typedrequest.TypedHandler('createSecretBundle', async (dataArg) => {
const secretBundle = new SecretBundle();
secretBundle.id = plugins.smartunique.shortId(8);
secretBundle.data = dataArg.secretBundle.data;
await secretBundle.save();
return {
resultSecretBundle: await secretBundle.createSavableObject(),
};
}),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secretbundle.IReq_UpdateSecretBundle>(
new plugins.typedrequest.TypedHandler('updateSecretBundle', async (dataArg) => {
const secretBundle = await SecretBundle.getInstance({
id: dataArg.secretBundle.id,
});
secretBundle.data = dataArg.secretBundle.data;
await secretBundle.save();
return {
resultSecretBundle: await secretBundle.createSavableObject(),
};
}),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secretbundle.IReq_DeleteSecretBundleById>(
new plugins.typedrequest.TypedHandler('deleteSecretBundleById', async (dataArg) => {
const secretBundle = await SecretBundle.getInstance({
id: dataArg.secretBundleId,
});
await secretBundle.delete();
return {
ok: true,
};
}),
);
// secretgroup routes
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secretgroup.IReq_GetSecretGroups>(
new plugins.typedrequest.TypedHandler(
'getSecretGroups',
async (dataArg, toolsArg) => {
await toolsArg!.passGuards([this.cloudlyRef.authManager.adminIdentityGuard], dataArg);
dataArg.identity.jwt;
const secretGroups = await SecretGroup.getInstances({});
return {
secretGroups: [
...(await Promise.all(
secretGroups.map((secretGroup) => secretGroup.createSavableObject())
secretGroups.map((secretGroup) => secretGroup.createSavableObject()),
)),
],
};
}
)
},
),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secret.IReq_Admin_CreateConfigBundlesAndSecretGroups>(
new plugins.typedrequest.TypedHandler(
'adminCreateConfigBundlesAndSecretGroups',
async (dataArg) => {
for (const secretGroupObject of dataArg.secretGroups) {
const secretGroup = new SecretGroup();
secretGroup.id = plugins.smartunique.shortId(8);
secretGroup.data = secretGroupObject.data;
await secretGroup.save();
}
return {
ok: true,
};
}
)
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secretgroup.IReq_CreateSecretGroup>(
new plugins.typedrequest.TypedHandler('createSecretGroup', async (dataArg) => {
const secretGroup = new SecretGroup();
secretGroup.id = plugins.smartunique.shortId(8);
secretGroup.data = dataArg.secretGroup.data;
await secretGroup.save();
return {
resultSecretGroup: await secretGroup.createSavableObject(),
};
}),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secretgroup.IReq_UpdateSecretGroup>(
new plugins.typedrequest.TypedHandler('updateSecretGroup', async (dataArg) => {
const secretGroup = await SecretGroup.getInstance({
id: dataArg.secretGroup.id,
});
secretGroup.data = dataArg.secretGroup.data;
await secretGroup.save();
return {
resultSecretGroup: await secretGroup.createSavableObject(),
};
}),
);
this.typedrouter.addTypedHandler<plugins.servezoneInterfaces.requests.secretgroup.IReq_DeleteSecretGroupById>(
new plugins.typedrequest.TypedHandler('deleteSecretGroupById', async (dataArg) => {
const secretGroup = await SecretGroup.getInstance({
id: dataArg.secretGroupId,
});
await secretGroup.delete();
return {
ok: true,
};
}),
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.secret.IReq_Admin_DeleteConfigBundlesAndSecretGroups>(
'adminDeleteConfigBundlesAndSecretGroups',
async (dataArg) => {
for (const secretGroupId of dataArg.secretGroupIds) {
const secretGroup = await SecretGroup.getInstance({
id: secretGroupId,
});
await secretGroup.delete();
}
for (const secretBundleId of dataArg.secretBundleIds) {
const configBundle = await SecretBundle.getInstance({
id: secretBundleId,
});
await configBundle.delete();
console.log(`deleted configbundle ${secretBundleId}`);
}
return {
ok: true,
};
}
)
);
// lets add typedrouter routes for accessing the configvailt from apps
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.secret.IReq_GetEnvBundle>(
'getEnvBundle',
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.secretbundle.IReq_GetFlatKeyValueObject>(
'getFlatKeyValueObject',
async (dataArg) => {
const wantedBundle = await SecretBundle.getInstance({
data: {
authorizations: {
// @ts-ignore
$elemMatch: {
secretAccessKey: dataArg.authorization,
secretAccessKey: dataArg.secretBundleAuthorization.secretAccessKey,
},
},
},
});
const authorization = await wantedBundle.getAuthorizationFromAuthKey(
dataArg.authorization
dataArg.secretBundleAuthorization.secretAccessKey,
);
if (!authorization) {
throw new plugins.typedrequest.TypedResponseError('secret bundle authorization not found');
}
return {
envBundle: {
configKeyValueObject: await wantedBundle.getKeyValueObjectForEnvironment(
authorization.environment
),
environment: authorization.environment,
timeSensitive: false,
},
flatKeyValueObject: await wantedBundle.getKeyValueObjectForEnvironment(
authorization.environment,
),
};
}
)
},
),
);
}
-39
View File
@@ -1,39 +0,0 @@
import * as plugins from '../plugins.js';
/*
* cluster defines a swarmkit cluster
*/
@plugins.smartdata.Manager()
export class Server extends plugins.smartdata.SmartDataDbDoc<Server, plugins.servezoneInterfaces.data.IServer> {
// STATIC
public static async createFromHetznerServer(
hetznerServerArg: plugins.hetznercloud.HetznerServer
) {
const newServer = new Server();
newServer.id = plugins.smartunique.shortId(8);
const data: plugins.servezoneInterfaces.data.IServer['data'] = {
assignedClusterId: hetznerServerArg.data.labels.clusterId,
requiredDebianPackages: [],
sshKeys: [],
type: 'hetzner',
}
Object.assign(newServer, { data });
await newServer.save();
return newServer;
}
// INSTANCE
@plugins.smartdata.unI()
public id: string;
@plugins.smartdata.svDb()
public data: plugins.servezoneInterfaces.data.IServer['data'];
constructor() {
super();
}
public async getServices(): Promise<plugins.servezoneInterfaces.data.IService[]> {
return [];
}
}
-103
View File
@@ -1,103 +0,0 @@
import * as plugins from '../plugins.js';
import { Cloudly } from '../classes.cloudly.js';
import { Cluster } from '../manager.cluster/cluster.js';
import { Server } from './server.js';
export class CloudlyServerManager {
public cloudlyRef: Cloudly;
public typedRouter = new plugins.typedrequest.TypedRouter();
public hetznerAccount: plugins.hetznercloud.HetznerAccount;
public get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CServer = plugins.smartdata.setDefaultManagerForDoc(this, Server);
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
/**
* is used be serverconfig module on the server to get the actual server config
*/
this.typedRouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.config.IRequest_Any_Cloudly_GetServerConfig>(
'getServerConfig',
async (requestData) => {
const serverId = requestData.serverId;
const server = await this.CServer.getInstance({
id: serverId,
})
return {
configData: await server.createSavableObject(),
};
}
)
);
}
public async start() {
this.hetznerAccount = new plugins.hetznercloud.HetznerAccount(this.cloudlyRef.config.data.hetznerToken);
}
public async stop() {}
/**
* creates the server infrastructure on hetzner
* ensures that there are exactly the reources that are needed
* no more, no less
*/
public async ensureServerInfrastructure() {
// get all clusters
const allClusters = await this.cloudlyRef.clusterManager.getAllClusters();
for (const cluster of allClusters) {
// get existing servers
const servers = await this.getServersByCluster(cluster);
// if there is no server, create one
if (servers.length === 0) {
const server = await this.hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('server'),
location: 'nbg1',
type: 'cpx41',
labels: {
clusterId: cluster.id,
priority: '1',
}
});
const newServer = await Server.createFromHetznerServer(server);
console.log(`cluster created new server for cluster ${cluster.id}`);
} else {
console.log(`cluster ${cluster.id} already has servers. Making sure that they actually exist in the real world...`);
// if there is a server, make sure that it exists
for (const server of servers) {
const hetznerServer = await this.hetznerAccount.getServersByLabel({
'clusterId': cluster.id
});
if (!hetznerServer) {
console.log(`server ${server.id} does not exist in the real world. Creating it now...`);
const hetznerServer = await this.hetznerAccount.createServer({
name: plugins.smartunique.uniSimple('server'),
location: 'nbg1',
type: 'cpx41',
labels: {
clusterId: cluster.id,
priority: '1',
}
});
const newServer = await Server.createFromHetznerServer(hetznerServer);
}
}
}
}
}
public async getServersByCluster(clusterArg: Cluster) {
const results = await this.CServer.getInstances({
data: {
assignedClusterId: clusterArg.id,
}
});
return results;
}
}
+100
View File
@@ -0,0 +1,100 @@
import { SecretBundle } from '../manager.secret/classes.secretbundle.js';
import * as plugins from '../plugins.js';
import { ServiceManager } from './classes.servicemanager.js';
@plugins.smartdata.managed()
export class Service extends plugins.smartdata.SmartDataDbDoc<
Service,
plugins.servezoneInterfaces.data.IService,
ServiceManager
> {
// STATIC
public static async getServiceById(serviceIdArg: string) {
const service = await this.getInstance({
id: serviceIdArg,
});
return service;
}
public static async getServices() {
const services = await this.getInstances({});
return services;
}
public static async createService(serviceDataArg: Partial<plugins.servezoneInterfaces.data.IService['data']>) {
const service = new Service();
service.id = await Service.getNewId();
service.data = serviceDataArg as plugins.servezoneInterfaces.data.IService['data'];
await service.save();
// Create DNS entries if service has web port and domains configured
if (service.data.ports?.web && service.data.domains?.length > 0) {
await service.createDnsEntries();
}
return service;
}
// INSTANCE
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public data!: plugins.servezoneInterfaces.data.IService['data'];
/**
* a service runs in a specific environment
* so -> this method returns the secret bundles as a flat object accordingly.
* in other words, it resolves secret groups for the relevant environment
* @param environmentArg
*/
public async getSecretBundlesAsFlatObject(environmentArg: string = 'production') {
const secreBundleIds = this.data.additionalSecretBundleIds || [];
secreBundleIds.push(this.data.secretBundleId); // put this last, so it overwrites any other secret bundles.
let finalFlatObject = {};
for (const secretBundleId of secreBundleIds) {
const secretBundle = await SecretBundle.getInstance({
id: secretBundleId,
});
const flatObject = await secretBundle.getFlatKeyValueObject(environmentArg);
Object.assign(finalFlatObject, flatObject);
}
return finalFlatObject;
}
/**
* Creates DNS entries for this service (in inactive state)
* These will be activated when the service is deployed
*/
public async createDnsEntries() {
const dnsManager = this.manager.cloudlyRef.dnsManager;
for (const domain of this.data.domains) {
const dnsEntryData: plugins.servezoneInterfaces.data.IDnsEntry['data'] = {
type: 'A', // Default to A record, could be made configurable
name: domain.name,
value: '0.0.0.0', // Placeholder, will be updated on deployment
ttl: 3600,
zone: '', // Will be set based on domainId
domainId: domain.domainId,
active: false, // Created as inactive
description: `Auto-generated DNS entry for service ${this.data.name}`,
createdAt: Date.now(),
isAutoGenerated: true,
sourceServiceId: this.id,
sourceType: 'service',
};
// Create the DNS entry
await dnsManager.createServiceDnsEntry(dnsEntryData);
}
}
/**
* Removes DNS entries for this service
*/
public async removeDnsEntries() {
const dnsManager = this.manager.cloudlyRef.dnsManager;
await dnsManager.removeServiceDnsEntries(this.id);
}
}
@@ -0,0 +1,325 @@
import type { Cloudly } from '../classes.cloudly.js';
import * as plugins from '../plugins.js';
import { Service } from './classes.service.js';
type TServiceWithDomains = Service & {
data: Service['data'] & {
appTemplateId?: string;
domains?: Array<{ name?: string }>;
};
};
interface IWorkAppRouteSyncResult {
success: boolean;
action?: 'created' | 'updated' | 'deleted' | 'unchanged';
routeId?: string;
message?: string;
}
export class ServiceManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
public cloudlyRef: Cloudly;
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
public CService = plugins.smartdata.setDefaultManagerForDoc(this, Service);
constructor(cloudlyRef: Cloudly) {
this.cloudlyRef = cloudlyRef;
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServices>(
'getServices',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const services = await this.CService.getInstances({});
return {
services: await Promise.all(
services.map((service) => {
return service.createSavableObject();
})
),
};
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceById>(
'getServiceById',
async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const service = await Service.getInstance({
id: dataArg.serviceId,
});
return {
service: await service.createSavableObject(),
};
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceSecretBundlesAsFlatObject>(
'getServiceSecretBundlesAsFlatObject',
async (dataArg) => {
const service = await Service.getInstance({
id: dataArg.serviceId,
});
const flatKeyValueObject = await service.getSecretBundlesAsFlatObject(dataArg.environment);
return {
flatKeyValueObject: flatKeyValueObject,
};
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_GetServiceRegistryTarget>(
'getServiceRegistryTarget',
async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const service = await Service.getInstance({
id: dataArg.serviceId,
});
return {
registryTarget: this.cloudlyRef.registryManager.getServiceRegistryTarget(
service,
dataArg.tag || service.data.imageVersion || 'latest',
),
};
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_CreateService>(
'createService',
async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
const service = await Service.createService(dataArg.serviceData);
service.data.registryTarget = this.cloudlyRef.registryManager.getServiceRegistryTarget(
service,
service.data.imageVersion || 'latest',
);
await service.save();
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
return {
service: await service.createSavableObject(),
};
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_UpdateService>(
'updateService',
async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
const service = await Service.getInstance({
id: dataArg.serviceId,
});
service.data = {
...service.data,
...dataArg.serviceData,
};
service.data.registryTarget = this.cloudlyRef.registryManager.getServiceRegistryTarget(
service,
service.data.imageVersion || 'latest',
);
await service.save();
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
return {
service: await service.createSavableObject(),
};
}
)
);
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.service.IRequest_Any_Cloudly_DeleteServiceById>(
'deleteServiceById',
async (dataArg) => {
await plugins.smartguard.passGuardsOrReject(dataArg, [
this.cloudlyRef.authManager.adminIdentityGuard,
]);
await this.deleteServiceById(dataArg.serviceId);
return {
success: true,
};
}
)
);
}
public async start() {
// ServiceManager is ready - handlers are already registered in constructor
console.log('ServiceManager started');
}
public async stop() {
// Cleanup if needed
console.log('ServiceManager stopped');
}
public async deleteServiceById(serviceIdArg: string): Promise<void> {
const service = await this.CService.getInstance({
id: serviceIdArg,
});
if (!service) {
throw new plugins.typedrequest.TypedResponseError(`Service not found: ${serviceIdArg}`);
}
await this.deleteExternalGatewayRoutes(service as TServiceWithDomains);
this.cloudlyRef.appStoreManager?.clearOperationsForService?.(service.id);
await this.deleteDeploymentsForService(service.id);
await service.removeDnsEntries();
await this.deletePlatformBindingsForService(service.id, service.data.name);
await this.cloudlyRef.backupManager?.deleteBackupsForService?.(service.id);
await this.cloudlyRef.registryManager?.deleteServiceRepository?.(service);
await this.deleteServiceOwnedSecretBundles(service);
await this.deleteServiceOwnedImage(service as TServiceWithDomains);
await service.delete();
await this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows();
}
private async deleteDeploymentsForService(serviceIdArg: string): Promise<void> {
const deployments = await this.cloudlyRef.deploymentManager.CDeployment.getInstances({
serviceId: serviceIdArg,
});
for (const deployment of deployments) {
await deployment.delete();
}
}
private async deletePlatformBindingsForService(
serviceIdArg: string,
serviceNameArg: string,
): Promise<void> {
const bindingsById = await this.cloudlyRef.platformManager.CPlatformBinding.getInstances({
serviceId: serviceIdArg,
});
const bindingsByName = serviceNameArg
? await this.cloudlyRef.platformManager.CPlatformBinding.getInstances({ serviceId: serviceNameArg })
: [];
const bindings = new Map<string, typeof bindingsById[number]>();
for (const binding of [...bindingsById, ...bindingsByName]) {
bindings.set(binding.id, binding);
}
for (const binding of bindings.values()) {
await binding.delete();
}
}
private async deleteServiceOwnedSecretBundles(serviceArg: Service): Promise<void> {
const secretBundleIds = [serviceArg.data.secretBundleId]
.filter((secretBundleIdArg): secretBundleIdArg is string => Boolean(secretBundleIdArg));
if (secretBundleIds.length === 0) return;
for (const secretBundleId of secretBundleIds) {
const secretBundle = await this.cloudlyRef.secretManager.CSecretBundle.getInstance({
id: secretBundleId,
}).catch(() => null);
if (!secretBundle || (secretBundle.data as { serviceId?: string }).serviceId !== serviceArg.id) {
continue;
}
const secretGroupIds = [...(secretBundle.data.includedSecretGroupIds || [])];
await secretBundle.delete();
await this.deleteUnreferencedSecretGroups(secretGroupIds);
}
}
private async deleteUnreferencedSecretGroups(secretGroupIdsArg: string[]): Promise<void> {
if (secretGroupIdsArg.length === 0) return;
const remainingBundles = await this.cloudlyRef.secretManager.CSecretBundle.getInstances({});
for (const secretGroupId of secretGroupIdsArg) {
const stillReferenced = remainingBundles.some((bundleArg) => {
return (bundleArg.data.includedSecretGroupIds || []).includes(secretGroupId);
});
if (stillReferenced) continue;
const secretGroup = await this.cloudlyRef.secretManager.CSecretGroup.getInstance({
id: secretGroupId,
}).catch(() => null);
if (secretGroup) {
await secretGroup.delete();
}
}
}
private async deleteServiceOwnedImage(serviceArg: TServiceWithDomains): Promise<void> {
if (!serviceArg.data.appTemplateId || !serviceArg.data.imageId) return;
await this.cloudlyRef.imageManager.deleteImageIfUnreferenced(serviceArg.data.imageId, serviceArg.id);
}
private async deleteExternalGatewayRoutes(serviceArg: TServiceWithDomains): Promise<void> {
const domains = (serviceArg.data.domains || [])
.map((domainArg) => domainArg.name?.trim().toLowerCase())
.filter((domainArg): domainArg is string => Boolean(domainArg));
if (domains.length === 0) return;
const settings = await this.cloudlyRef.settingsManager.getSettings().catch(() => undefined);
if (!settings?.dcrouterGatewayUrl || !settings.dcrouterGatewayApiToken) return;
const clusters = await this.cloudlyRef.clusterManager.getAllClusters().catch(() => []);
const workHosterIds = new Set<string>();
if (settings.dcrouterWorkHosterId) {
workHosterIds.add(settings.dcrouterWorkHosterId);
} else {
for (const cluster of clusters) {
workHosterIds.add(cluster.id);
}
}
if (workHosterIds.size === 0) return;
for (const domain of domains) {
for (const workHosterId of workHosterIds) {
const result = await this.fireDcRouterRequest<IWorkAppRouteSyncResult>(
settings.dcrouterGatewayUrl,
'syncWorkAppRoute',
{
apiToken: settings.dcrouterGatewayApiToken,
ownership: {
workHosterType: 'cloudly',
workHosterId,
workAppId: serviceArg.id || serviceArg.data.name,
hostname: domain,
},
delete: true,
},
);
if (!result.success) {
throw new Error(result.message || `dcrouter route delete failed for ${domain}`);
}
}
}
}
private async fireDcRouterRequest<TResponse>(
gatewayUrlArg: string,
methodArg: string,
requestDataArg: Record<string, unknown>,
): Promise<TResponse> {
const typedRequest = new plugins.typedrequest.TypedRequest<any>(
`${gatewayUrlArg.replace(/\/+$/, '')}/typedrequest`,
methodArg,
);
return await typedRequest.fire(requestDataArg) as TResponse;
}
}
@@ -0,0 +1,298 @@
import * as plugins from '../plugins.js';
import type { Cloudly } from '../classes.cloudly.js';
import * as servezoneInterfaces from '@serve.zone/interfaces';
export class CloudlySettingsManager {
public cloudlyRef: Cloudly;
public readyDeferred = plugins.smartpromise.defer();
public settingsStore!: plugins.smartdata.EasyStore<servezoneInterfaces.data.ICloudlySettings>;
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
}
/**
* Initialize the settings manager and create the EasyStore
*/
public async init() {
this.settingsStore = await this.cloudlyRef.mongodbConnector.smartdataDb
.createEasyStore('cloudly-settings') as plugins.smartdata.EasyStore<servezoneInterfaces.data.ICloudlySettings>;
// Setup API route handlers
await this.setupRoutes();
this.readyDeferred.resolve();
}
/**
* Get all settings
*/
public async getSettings(): Promise<servezoneInterfaces.data.ICloudlySettings> {
await this.readyDeferred.promise;
return await this.settingsStore.readAll();
}
/**
* Get all settings with masked sensitive values (for API responses)
*/
public async getSettingsMasked(): Promise<servezoneInterfaces.data.ICloudlySettingsMasked> {
await this.readyDeferred.promise;
const settings = await this.getSettings();
const masked: servezoneInterfaces.data.ICloudlySettingsMasked = {};
for (const [key, value] of Object.entries(settings)) {
if (this.isSensitiveSettingKey(key) && typeof value === 'string' && value.length > 4) {
// Mask the token, showing only last 4 characters
masked[key] = '****' + value.slice(-4);
} else {
masked[key] = value;
}
}
return masked;
}
private isSensitiveSettingKey(key: string): boolean {
if (key === 'corebuildWorkersJson') {
return true;
}
const normalizedKey = key.toLowerCase();
return [
'token',
'secret',
'apikey',
'accesskey',
'applicationkey',
'consumerkey',
'keyjson',
'privatekey',
'password',
].some((sensitivePart) => normalizedKey.includes(sensitivePart));
}
/**
* Update multiple settings at once
*/
public async updateSettings(updates: Partial<servezoneInterfaces.data.ICloudlySettings>): Promise<void> {
await this.readyDeferred.promise;
for (const [key, value] of Object.entries(updates)) {
if (value !== undefined && value !== '') {
await this.settingsStore.writeKey(key as keyof servezoneInterfaces.data.ICloudlySettings, value);
} else if (value === '') {
// Empty string means clear the setting
await this.settingsStore.deleteKey(key as keyof servezoneInterfaces.data.ICloudlySettings);
}
}
if (Object.keys(updates).some((key) => this.isExternalGatewaySettingKey(key))) {
this.refreshExternalGatewayConfig().catch((error) => {
console.log(`External gateway settings refresh failed: ${(error as Error).message}`);
});
}
}
private isExternalGatewaySettingKey(key: string): boolean {
return [
'dcrouterGatewayUrl',
'dcrouterGatewayApiToken',
'dcrouterWorkHosterId',
'dcrouterTargetHost',
'dcrouterTargetPort',
].includes(key);
}
private async refreshExternalGatewayConfig(): Promise<void> {
await Promise.all([
this.cloudlyRef.domainManager.syncExternalGatewayDomains(),
this.cloudlyRef.coreflowManager.pushClusterConfigToConnectedCoreflows(),
]);
}
/**
* Get a specific setting value
*/
public async getSetting<K extends keyof servezoneInterfaces.data.ICloudlySettings>(key: K): Promise<servezoneInterfaces.data.ICloudlySettings[K]> {
await this.readyDeferred.promise;
return await this.settingsStore.readKey(key);
}
/**
* Set a specific setting value
*/
public async setSetting<K extends keyof servezoneInterfaces.data.ICloudlySettings>(key: K, value: servezoneInterfaces.data.ICloudlySettings[K]): Promise<void> {
await this.readyDeferred.promise;
if (value !== undefined && value !== '') {
await this.settingsStore.writeKey(key, value);
}
}
/**
* Clear a specific setting
*/
public async clearSetting(key: keyof servezoneInterfaces.data.ICloudlySettings): Promise<void> {
await this.readyDeferred.promise;
await this.settingsStore.deleteKey(key);
}
/**
* Clear all settings
*/
public async clearAllSettings(): Promise<void> {
await this.readyDeferred.promise;
await this.settingsStore.wipe();
}
/**
* Test connection for a specific provider
*/
public async testProviderConnection(provider: string): Promise<{success: boolean; message: string}> {
await this.readyDeferred.promise;
try {
switch (provider) {
case 'hetzner':
const hetznerToken = await this.getSetting('hetznerToken');
if (!hetznerToken) {
return { success: false, message: 'No Hetzner token configured' };
}
// TODO: Implement actual Hetzner API test
return { success: true, message: 'Hetzner connection test successful' };
case 'cloudflare':
const cloudflareToken = await this.getSetting('cloudflareToken');
if (!cloudflareToken) {
return { success: false, message: 'No Cloudflare token configured' };
}
// TODO: Implement actual Cloudflare API test
return { success: true, message: 'Cloudflare connection test successful' };
case 'aws':
const awsKey = await this.getSetting('awsAccessKey');
const awsSecret = await this.getSetting('awsSecretKey');
if (!awsKey || !awsSecret) {
return { success: false, message: 'AWS credentials not configured' };
}
// TODO: Implement actual AWS API test
return { success: true, message: 'AWS connection test successful' };
case 'digitalocean':
const doToken = await this.getSetting('digitalOceanToken');
if (!doToken) {
return { success: false, message: 'No DigitalOcean token configured' };
}
// TODO: Implement actual DigitalOcean API test
return { success: true, message: 'DigitalOcean connection test successful' };
case 'azure':
const azureClientId = await this.getSetting('azureClientId');
const azureClientSecret = await this.getSetting('azureClientSecret');
const azureTenantId = await this.getSetting('azureTenantId');
if (!azureClientId || !azureClientSecret || !azureTenantId) {
return { success: false, message: 'Azure credentials not configured' };
}
// TODO: Implement actual Azure API test
return { success: true, message: 'Azure connection test successful' };
default:
return { success: false, message: `Unknown provider: ${provider}` };
}
} catch (error) {
return { success: false, message: `Connection test failed: ${error instanceof Error ? error.message : String(error)}` };
}
}
/**
* Setup API route handlers for settings management
*/
private async setupRoutes() {
// Get Settings Handler
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_GetSettings>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_GetSettings>(
'getSettings',
async (requestData) => {
// TODO: Add authentication check for admin users
const maskedSettings = await this.getSettingsMasked();
return {
settings: maskedSettings
};
}
)
);
// Update Settings Handler
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_UpdateSettings>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_UpdateSettings>(
'updateSettings',
async (requestData) => {
// TODO: Add authentication check for admin users
try {
await this.updateSettings(requestData.updates);
return {
success: true,
message: 'Settings updated successfully'
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to update settings: ${errorMessage}`
};
}
}
)
);
// Clear Setting Handler
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_ClearSetting>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_ClearSetting>(
'clearSetting',
async (requestData) => {
// TODO: Add authentication check for admin users
try {
await this.clearSetting(requestData.key);
return {
success: true,
message: `Setting ${requestData.key} cleared successfully`
};
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
return {
success: false,
message: `Failed to clear setting: ${errorMessage}`
};
}
}
)
);
// Test Provider Connection Handler
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_TestProviderConnection>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_TestProviderConnection>(
'testProviderConnection',
async (requestData) => {
// TODO: Add authentication check for admin users
const testResult = await this.testProviderConnection(requestData.provider);
return {
success: testResult.success,
message: testResult.message,
connectionValid: testResult.success
};
}
)
);
// Get Single Setting Handler (for internal use)
this.cloudlyRef.typedrouter.addTypedHandler<servezoneInterfaces.requests.settings.IRequest_GetSetting>(
new plugins.typedrequest.TypedHandler<servezoneInterfaces.requests.settings.IRequest_GetSetting>(
'getSetting',
async (requestData) => {
// TODO: Add authentication check for admin users
const value = await this.getSetting(requestData.key);
return {
value
};
}
)
);
}
}
+1
View File
@@ -0,0 +1 @@
export * from './classes.settingsmanager.js';
+1 -1
View File
@@ -16,7 +16,7 @@ export class ExternalApiManager {
return {
networkNodes,
};
})
}),
);
}
}
+165
View File
@@ -0,0 +1,165 @@
import * as plugins from '../plugins.js';
import { CloudlyTaskManager } from './classes.taskmanager.js';
@plugins.smartdata.managed()
export class TaskExecution extends plugins.smartdata.SmartDataDbDoc<
TaskExecution,
plugins.servezoneInterfaces.data.ITaskExecution,
CloudlyTaskManager
> {
// STATIC
public static async getTaskExecutionById(executionIdArg: string) {
const execution = await this.getInstance({
id: executionIdArg,
});
return execution;
}
public static async getTaskExecutions(filterArg?: {
taskName?: string;
status?: string;
startedAfter?: number;
startedBefore?: number;
}) {
const query: any = {};
if (filterArg?.taskName) {
query['data.taskName'] = filterArg.taskName;
}
if (filterArg?.status) {
query['data.status'] = filterArg.status;
}
if (filterArg?.startedAfter || filterArg?.startedBefore) {
query['data.startedAt'] = {};
if (filterArg.startedAfter) {
query['data.startedAt'].$gte = filterArg.startedAfter;
}
if (filterArg.startedBefore) {
query['data.startedAt'].$lte = filterArg.startedBefore;
}
}
const executions = await this.getInstances(query);
return executions;
}
public static async createTaskExecution(
taskName: string,
triggeredBy: 'schedule' | 'manual' | 'system',
userId?: string
) {
const execution = new TaskExecution();
execution.id = await TaskExecution.getNewId();
execution.data = {
taskName,
startedAt: Date.now(),
status: 'running',
triggeredBy,
userId,
logs: [],
};
await execution.save();
return execution;
}
// INSTANCE
@plugins.smartdata.unI()
public id!: string;
@plugins.smartdata.svDb()
public data!: plugins.servezoneInterfaces.data.ITaskExecution['data'];
/**
* Add a log entry to the execution
*/
public async addLog(message: string, severity: 'info' | 'warning' | 'error' | 'success' = 'info') {
this.data.logs.push({
timestamp: Date.now(),
message,
severity,
});
await this.save();
}
/**
* Set a metric for the execution
*/
public async setMetric(key: string, value: any) {
if (!this.data.metrics) {
this.data.metrics = {};
}
this.data.metrics[key] = value;
await this.save();
}
/**
* Mark the execution as completed
*/
public async complete(result?: any) {
this.data.completedAt = Date.now();
this.data.duration = this.data.completedAt - this.data.startedAt;
this.data.status = 'completed';
if (result !== undefined) {
this.data.result = result;
}
await this.save();
}
/**
* Mark the execution as failed
*/
public async fail(error: Error | string) {
this.data.completedAt = Date.now();
this.data.duration = this.data.completedAt - this.data.startedAt;
this.data.status = 'failed';
if (typeof error === 'string') {
this.data.error = {
message: error,
};
} else {
this.data.error = {
message: error.message,
stack: error.stack,
code: (error as any).code,
};
}
await this.save();
}
/**
* Cancel the execution
*/
public async cancel() {
this.data.completedAt = Date.now();
this.data.duration = this.data.completedAt - this.data.startedAt;
this.data.status = 'cancelled';
await this.save();
}
/**
* Get running executions
*/
public static async getRunningExecutions() {
return await this.getInstances({
'data.status': 'running',
});
}
/**
* Clean up old executions
*/
public static async cleanupOldExecutions(olderThanDays: number = 30) {
const cutoffTime = Date.now() - (olderThanDays * 24 * 60 * 60 * 1000);
const oldExecutions = await this.getInstances({
'data.completedAt': { $lt: cutoffTime },
});
for (const execution of oldExecutions) {
await execution.delete();
}
return oldExecutions.length;
}
}
+353
View File
@@ -0,0 +1,353 @@
import * as plugins from '../plugins.js';
import { Cloudly } from '../classes.cloudly.js';
import { TaskExecution } from './classes.taskexecution.js';
import { createPredefinedTasks } from './predefinedtasks.js';
import { logger } from '../logger.js';
export interface ITaskInfo {
name: string;
description: string;
category: 'maintenance' | 'deployment' | 'backup' | 'monitoring' | 'cleanup' | 'system' | 'security';
schedule?: string; // Cron expression if scheduled
lastRun?: number;
enabled: boolean;
}
export class CloudlyTaskManager {
public typedrouter = new plugins.typedrequest.TypedRouter();
public cloudlyRef: Cloudly;
// TaskBuffer integration
private taskBufferManager = new plugins.taskbuffer.TaskManager();
private taskRegistry = new Map<string, plugins.taskbuffer.Task>();
private taskInfo = new Map<string, ITaskInfo>();
private currentExecutions = new Map<string, TaskExecution>();
private cancellationRequests = new Set<string>();
// Database connection helper
get db() {
return this.cloudlyRef.mongodbConnector.smartdataDb;
}
// Set up TaskExecution document manager
public CTaskExecution = plugins.smartdata.setDefaultManagerForDoc(this, TaskExecution);
constructor(cloudlyRefArg: Cloudly) {
this.cloudlyRef = cloudlyRefArg;
// Add router to main router
this.cloudlyRef.typedrouter.addTypedRouter(this.typedrouter);
// Set up API endpoints
this.setupApiEndpoints();
// Register predefined tasks
createPredefinedTasks(this);
}
/**
* Register a task with the manager
*/
public registerTask(
name: string,
task: plugins.taskbuffer.Task,
info: Omit<ITaskInfo, 'name' | 'lastRun'>
) {
this.taskRegistry.set(name, task);
this.taskInfo.set(name, {
name,
...info,
lastRun: undefined,
});
// Schedule if cron expression provided
if (info.schedule && info.enabled) {
this.scheduleTask(name, info.schedule);
}
logger.log('info', `Registered task: ${name}`);
}
/**
* Execute a task with tracking
*/
public async executeTask(
taskName: string,
triggeredBy: 'schedule' | 'manual' | 'system',
userId?: string
): Promise<TaskExecution | null> {
const task = this.taskRegistry.get(taskName);
const info = this.taskInfo.get(taskName);
if (!task) {
throw new Error(`Task ${taskName} not found`);
}
if (!info?.enabled && triggeredBy === 'schedule') {
logger.log('warn', `Skipping disabled scheduled task: ${taskName}`);
return null;
}
// Create execution record
const execution = await TaskExecution.createTaskExecution(taskName, triggeredBy, userId);
if (info?.description) {
execution.data.taskDescription = info.description;
}
if (info?.category) {
execution.data.category = info.category;
}
await execution.save();
// Store current execution for task to access
this.currentExecutions.set(taskName, execution);
try {
await execution.addLog(`Starting task: ${taskName}`, 'info');
// Execute the task
const result = await task.trigger();
// If a cancellation was requested during execution, don't mark as completed
if (execution.data.status === 'cancelled' || this.cancellationRequests.has(execution.id)) {
await execution.addLog('Task cancelled during execution', 'warning');
} else {
// Task completed successfully
await execution.complete(result);
await execution.addLog(`Task completed successfully`, 'success');
}
// Update last run time
if (info) {
info.lastRun = Date.now();
}
} catch (error) {
// If already cancelled, don't mark as failed
if (execution.data.status === 'cancelled' || this.cancellationRequests.has(execution.id)) {
await execution.addLog('Task was cancelled', 'warning');
} else {
// Task failed
await execution.fail(error as any);
await execution.addLog(`Task failed: ${(error as any).message}`, 'error');
logger.log('error', `Task ${taskName} failed: ${(error as any).message}`);
}
} finally {
// Clean up current execution
this.currentExecutions.delete(taskName);
this.cancellationRequests.delete(execution.id);
}
return execution;
}
/**
* Get current execution for a task (used by tasks to log)
*/
public getCurrentExecution(taskName: string): TaskExecution | undefined {
return this.currentExecutions.get(taskName);
}
/**
* Schedule a task with cron expression
*/
public scheduleTask(taskName: string, cronExpression: string) {
const task = this.taskRegistry.get(taskName);
if (!task) {
throw new Error(`Task ${taskName} not found`);
}
// Wrap task execution with tracking
const wrappedTask = new plugins.taskbuffer.Task({
name: `${taskName}-scheduled`,
taskFunction: async () => {
await this.executeTask(taskName, 'schedule');
},
});
this.taskBufferManager.addAndScheduleTask(wrappedTask, cronExpression);
logger.log('info', `Scheduled task ${taskName} with cron: ${cronExpression}`);
}
/**
* Cancel a running task
*/
public async cancelTask(executionId: string): Promise<boolean> {
const execution = await TaskExecution.getTaskExecutionById(executionId);
if (!execution || execution.data.status !== 'running') {
return false;
}
await execution.cancel();
await execution.addLog('Task cancelled by user', 'warning');
// mark cancellation request so running task functions can react cooperatively
this.cancellationRequests.add(execution.id);
return true;
}
/**
* Check if cancellation is requested for an execution
*/
public isCancellationRequested(executionId: string): boolean {
return this.cancellationRequests.has(executionId);
}
/**
* Get all registered tasks
*/
public getAllTasks(): ITaskInfo[] {
return Array.from(this.taskInfo.values());
}
/**
* Enable or disable a task
*/
public async setTaskEnabled(taskName: string, enabled: boolean) {
const info = this.taskInfo.get(taskName);
if (!info) {
throw new Error(`Task ${taskName} not found`);
}
info.enabled = enabled;
if (!enabled) {
// TODO: Remove from scheduler if disabled
logger.log('info', `Disabled task: ${taskName}`);
} else if (info.schedule) {
// Reschedule if enabled with schedule
this.scheduleTask(taskName, info.schedule);
}
}
/**
* Set up API endpoints
*/
private setupApiEndpoints() {
// Get all tasks
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.task.IRequest_Any_Cloudly_GetTasks>(
'getTasks',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const tasks = this.getAllTasks();
return {
tasks,
};
}
)
);
// Get task executions
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.task.IRequest_Any_Cloudly_GetTaskExecutions>(
'getTaskExecutions',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const executions = await TaskExecution.getTaskExecutions(reqArg.filter);
return {
executions: await Promise.all(
executions.map(e => e.createSavableObject())
),
};
}
)
);
// Get task execution by ID
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.task.IRequest_Any_Cloudly_GetTaskExecutionById>(
'getTaskExecutionById',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const execution = await TaskExecution.getTaskExecutionById(reqArg.executionId);
if (!execution) {
throw new Error('Task execution not found');
}
return {
execution: await execution.createSavableObject(),
};
}
)
);
// Trigger task manually
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.task.IRequest_Any_Cloudly_TriggerTask>(
'triggerTask',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const execution = await this.executeTask(
reqArg.taskName,
'manual',
reqArg.userId
);
if (!execution) {
throw new Error(`Task ${reqArg.taskName} did not start`);
}
return {
execution: await execution.createSavableObject(),
};
}
)
);
// Cancel task
this.typedrouter.addTypedHandler(
new plugins.typedrequest.TypedHandler<plugins.servezoneInterfaces.requests.task.IRequest_Any_Cloudly_CancelTask>(
'cancelTask',
async (reqArg) => {
await plugins.smartguard.passGuardsOrReject(reqArg, [
this.cloudlyRef.authManager.validIdentityGuard,
]);
const success = await this.cancelTask(reqArg.executionId);
return {
success,
};
}
)
);
}
/**
* Initialize the task manager
*/
public async init() {
logger.log('info', 'Task Manager initialized');
// Clean up old executions on startup
const deletedCount = await TaskExecution.cleanupOldExecutions(30);
if (deletedCount > 0) {
logger.log('info', `Cleaned up ${deletedCount} old task executions`);
}
await this.taskBufferManager.start();
}
/**
* Stop the task manager
*/
public async stop() {
// Stop all scheduled tasks
await this.taskBufferManager.stop();
logger.log('info', 'Task Manager stopped');
}
}
+574
View File
@@ -0,0 +1,574 @@
import * as plugins from '../plugins.js';
import { CloudlyTaskManager } from './classes.taskmanager.js';
import { logger } from '../logger.js';
const getErrorMessage = (errorArg: unknown) => errorArg instanceof Error ? errorArg.message : String(errorArg);
/**
* Create and register all predefined tasks
*/
export function createPredefinedTasks(taskManager: CloudlyTaskManager) {
// Cloudflare Domain Sync Task
const cfDomainSync = new plugins.taskbuffer.Task({
name: 'cloudflare-domain-sync',
taskFunction: async () => {
const execution = taskManager.getCurrentExecution('cloudflare-domain-sync');
try {
await execution?.addLog('Starting Cloudflare domain sync…', 'info');
const cf = taskManager.cloudlyRef.cloudflareConnector?.cloudflare;
if (!cf) {
await execution?.addLog('Cloudflare not configured; skipping sync.', 'warning');
return { created: 0, updated: 0, totalZones: 0 };
}
const zones = await cf.convenience.listZones();
await execution?.setMetric('totalZones', zones.length);
await execution?.addLog(`Fetched ${zones.length} zones from Cloudflare`, 'info');
let created = 0;
let updated = 0;
const now = Date.now();
for (const zone of zones) {
// zone fields from Cloudflare typings
const zoneName = (zone as any).name as string;
const zoneId = (zone as any).id as string;
const zoneStatus = ((zone as any).status || 'active') as 'active'|'pending'|'suspended'|'transferred'|'expired';
const nameServers: string[] = (zone as any).name_servers || [];
const existing = await taskManager.cloudlyRef.domainManager.CDomain.getDomainByName(zoneName);
if (existing) {
if (execution && (taskManager.isCancellationRequested(execution.id) || existing.data == null)) {
await execution?.addLog('Cancellation requested. Stopping CF sync…', 'warning');
break;
}
await execution?.addLog(`Updating domain: ${zoneName}`, 'info');
await taskManager.cloudlyRef.domainManager.CDomain.updateDomain(existing.id, {
status: zoneStatus as any,
nameservers: nameServers,
cloudflareZoneId: zoneId,
syncSource: 'cloudflare',
lastSyncAt: now,
activationState: existing.data.activationState || 'available',
});
updated++;
} else {
await execution?.addLog(`Creating domain: ${zoneName}`, 'info');
await taskManager.cloudlyRef.domainManager.CDomain.createDomain({
name: zoneName,
description: `Synced from Cloudflare zone ${zoneId}`,
status: zoneStatus as any,
verificationStatus: 'pending',
nameservers: nameServers,
autoRenew: true,
cloudflareZoneId: zoneId,
activationState: 'available',
syncSource: 'cloudflare',
lastSyncAt: now,
} as any);
created++;
}
}
await execution?.setMetric('created', created);
await execution?.setMetric('updated', updated);
await execution?.addLog(`Cloudflare sync done: ${created} created, ${updated} updated`, 'success');
return { created, updated, totalZones: zones.length };
} catch (error) {
await execution?.addLog(`Cloudflare sync error: ${getErrorMessage(error)}`, 'error');
throw error;
}
},
});
taskManager.registerTask('cloudflare-domain-sync', cfDomainSync, {
description: 'Import and update domains from Cloudflare zones',
category: 'system',
schedule: '0 3 * * *', // Daily at 3 AM
enabled: true,
});
// DNS Sync Task
const dnsSync = new plugins.taskbuffer.Task({
name: 'dns-sync',
taskFunction: async () => {
const execution = taskManager.getCurrentExecution('dns-sync');
const dnsManager = taskManager.cloudlyRef.dnsManager;
try {
await execution?.addLog('Starting DNS synchronization...', 'info');
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Aborting DNS sync...', 'warning');
return;
}
// Get all DNS entries marked as external
const dnsEntries = await dnsManager.CDnsEntry.getInstances({
'data.sourceType': 'external',
});
await execution?.addLog(`Found ${dnsEntries.length} external DNS entries to sync`, 'info');
await execution?.setMetric('totalEntries', dnsEntries.length);
let syncedCount = 0;
let failedCount = 0;
for (const entry of dnsEntries) {
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Stopping loop...', 'warning');
break;
}
try {
// TODO: Implement actual sync with external DNS provider
await execution?.addLog(`Syncing DNS entry: ${entry.data.name}.${entry.data.zone}`, 'info');
syncedCount++;
} catch (error) {
await execution?.addLog(`Failed to sync ${entry.data.name}: ${getErrorMessage(error)}`, 'warning');
failedCount++;
}
}
await execution?.setMetric('syncedCount', syncedCount);
await execution?.setMetric('failedCount', failedCount);
await execution?.addLog(`DNS sync completed: ${syncedCount} synced, ${failedCount} failed`, 'success');
return { synced: syncedCount, failed: failedCount };
} catch (error) {
await execution?.addLog(`DNS sync error: ${getErrorMessage(error)}`, 'error');
throw error;
}
},
});
taskManager.registerTask('dns-sync', dnsSync, {
description: 'Synchronize DNS entries with external providers',
category: 'system',
schedule: '0 */6 * * *', // Every 6 hours
enabled: true,
});
// Certificate Renewal Task
const certRenewal = new plugins.taskbuffer.Task({
name: 'cert-renewal',
taskFunction: async () => {
const execution = taskManager.getCurrentExecution('cert-renewal');
try {
await execution?.addLog('Checking certificates for renewal...', 'info');
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Aborting certificate renewal...', 'warning');
return;
}
// Get all domains (only activated ones are considered for renewal)
const domains = await taskManager.cloudlyRef.domainManager.CDomain.getInstances({
'data.activationState': 'activated',
} as any);
await execution?.setMetric('totalDomains', domains.length);
let renewedCount = 0;
let upToDateCount = 0;
for (const domain of domains) {
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Stopping loop...', 'warning');
break;
}
// TODO: Check certificate expiry and renew if needed
await execution?.addLog(`Checking certificate for ${domain.data.name}`, 'info');
// Placeholder logic
const needsRenewal = Math.random() > 0.8; // 20% chance for demo
if (needsRenewal) {
await execution?.addLog(`Renewing certificate for ${domain.data.name}`, 'info');
// TODO: Actual renewal logic
renewedCount++;
} else {
upToDateCount++;
}
}
await execution?.setMetric('renewedCount', renewedCount);
await execution?.setMetric('upToDateCount', upToDateCount);
await execution?.addLog(`Certificate check completed: ${renewedCount} renewed, ${upToDateCount} up to date`, 'success');
return { renewed: renewedCount, upToDate: upToDateCount };
} catch (error) {
await execution?.addLog(`Certificate renewal error: ${getErrorMessage(error)}`, 'error');
throw error;
}
},
});
taskManager.registerTask('cert-renewal', certRenewal, {
description: 'Check and renew SSL certificates',
category: 'security',
schedule: '0 2 * * *', // Daily at 2 AM
enabled: true,
});
// Cleanup Task
const cleanup = new plugins.taskbuffer.Task({
name: 'cleanup',
taskFunction: async () => {
const execution = taskManager.getCurrentExecution('cleanup');
try {
await execution?.addLog('Starting cleanup tasks...', 'info');
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Aborting cleanup...', 'warning');
return;
}
// Clean up old task executions
await execution?.addLog('Cleaning old task executions...', 'info');
const deletedExecutions = await taskManager.CTaskExecution.cleanupOldExecutions(30);
await execution?.setMetric('deletedExecutions', deletedExecutions);
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) return;
// TODO: Clean up old logs
await execution?.addLog('Cleaning old logs...', 'info');
// Placeholder
const deletedLogs = 0;
await execution?.setMetric('deletedLogs', deletedLogs);
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) return;
// TODO: Clean up Docker images
await execution?.addLog('Cleaning unused Docker images...', 'info');
// Placeholder
const deletedImages = 0;
await execution?.setMetric('deletedImages', deletedImages);
await execution?.addLog(`Cleanup completed: ${deletedExecutions} executions, ${deletedLogs} logs, ${deletedImages} images deleted`, 'success');
return {
executions: deletedExecutions,
logs: deletedLogs,
images: deletedImages,
};
} catch (error) {
await execution?.addLog(`Cleanup error: ${getErrorMessage(error)}`, 'error');
throw error;
}
},
});
taskManager.registerTask('cleanup', cleanup, {
description: 'Remove old logs, executions, and temporary files',
category: 'cleanup',
schedule: '0 3 * * *', // Daily at 3 AM
enabled: true,
});
// Health Check Task
const healthCheck = new plugins.taskbuffer.Task({
name: 'health-check',
taskFunction: async () => {
const execution = taskManager.getCurrentExecution('health-check');
try {
await execution?.addLog('Starting health checks...', 'info');
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Aborting health checks...', 'warning');
return;
}
// Check all deployments
const deployments = await taskManager.cloudlyRef.deploymentManager.getAllDeployments();
await execution?.setMetric('totalDeployments', deployments.length);
let healthyCount = 0;
let unhealthyCount = 0;
const issues: Array<{ deploymentId: string; serviceId: string; issue: string }> = [];
for (const deployment of deployments) {
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Stopping loop...', 'warning');
break;
}
if (deployment.status === 'running') {
// TODO: Actual health check logic
const isHealthy = Math.random() > 0.1; // 90% healthy for demo
if (isHealthy) {
healthyCount++;
} else {
unhealthyCount++;
issues.push({
deploymentId: deployment.id,
serviceId: deployment.serviceId,
issue: 'Health check failed',
});
await execution?.addLog(`Deployment ${deployment.id} is unhealthy`, 'warning');
}
}
}
await execution?.setMetric('healthyCount', healthyCount);
await execution?.setMetric('unhealthyCount', unhealthyCount);
await execution?.setMetric('issues', issues);
const severity = unhealthyCount > 0 ? 'warning' : 'success';
await execution?.addLog(
`Health check completed: ${healthyCount} healthy, ${unhealthyCount} unhealthy`,
severity as any
);
return { healthy: healthyCount, unhealthy: unhealthyCount, issues };
} catch (error) {
await execution?.addLog(`Health check error: ${getErrorMessage(error)}`, 'error');
throw error;
}
},
});
taskManager.registerTask('health-check', healthCheck, {
description: 'Monitor service health across deployments',
category: 'monitoring',
schedule: '*/15 * * * *', // Every 15 minutes
enabled: true,
});
// Resource Usage Report
const resourceReport = new plugins.taskbuffer.Task({
name: 'resource-report',
taskFunction: async () => {
const execution = taskManager.getCurrentExecution('resource-report');
try {
await execution?.addLog('Generating resource usage report...', 'info');
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Aborting report...', 'warning');
return;
}
// Get all nodes
const nodes = await taskManager.cloudlyRef.nodeManager.CClusterNode.getInstances({});
const report: {
timestamp: number;
nodes: Array<{ nodeId: string; nodeName: string; cpu: number; memory: number; disk: number }>;
totalCpu: number;
totalMemory: number;
totalDisk: number;
} = {
timestamp: Date.now(),
nodes: [],
totalCpu: 0,
totalMemory: 0,
totalDisk: 0,
};
for (const node of nodes) {
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Stopping loop...', 'warning');
break;
}
// TODO: Get actual resource usage
const nodeUsage = {
nodeId: node.id,
nodeName: node.data.swarmNodeId || node.id,
cpu: Math.random() * 100, // Placeholder
memory: Math.random() * 100, // Placeholder
disk: Math.random() * 100, // Placeholder
};
report.nodes.push(nodeUsage);
report.totalCpu += nodeUsage.cpu;
report.totalMemory += nodeUsage.memory;
report.totalDisk += nodeUsage.disk;
}
// Calculate averages
if (nodes.length > 0) {
report.totalCpu /= nodes.length;
report.totalMemory /= nodes.length;
report.totalDisk /= nodes.length;
}
await execution?.setMetric('report', report);
await execution?.addLog(
`Resource report generated: Avg CPU ${report.totalCpu.toFixed(1)}%, Memory ${report.totalMemory.toFixed(1)}%, Disk ${report.totalDisk.toFixed(1)}%`,
'success'
);
return report;
} catch (error) {
await execution?.addLog(`Resource report error: ${getErrorMessage(error)}`, 'error');
throw error;
}
},
});
taskManager.registerTask('resource-report', resourceReport, {
description: 'Generate resource usage reports',
category: 'monitoring',
schedule: '0 * * * *', // Every hour
enabled: true,
});
// Database Maintenance
const dbMaintenance = new plugins.taskbuffer.Task({
name: 'db-maintenance',
taskFunction: async () => {
const execution = taskManager.getCurrentExecution('db-maintenance');
try {
await execution?.addLog('Starting database maintenance...', 'info');
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Aborting DB maintenance...', 'warning');
return;
}
// TODO: Implement actual database maintenance
await execution?.addLog('Analyzing indexes...', 'info');
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) return;
await execution?.addLog('Compacting collections...', 'info');
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) return;
await execution?.addLog('Updating statistics...', 'info');
await execution?.setMetric('collectionsOptimized', 5); // Placeholder
await execution?.setMetric('indexesRebuilt', 3); // Placeholder
await execution?.addLog('Database maintenance completed', 'success');
return { success: true };
} catch (error) {
await execution?.addLog(`Database maintenance error: ${getErrorMessage(error)}`, 'error');
throw error;
}
},
});
taskManager.registerTask('db-maintenance', dbMaintenance, {
description: 'Optimize database performance',
category: 'maintenance',
schedule: '0 4 * * 0', // Weekly on Sunday at 4 AM
enabled: true,
});
// Security Scan
const securityScan = new plugins.taskbuffer.Task({
name: 'security-scan',
taskFunction: async () => {
const execution = taskManager.getCurrentExecution('security-scan');
try {
await execution?.addLog('Starting security scan...', 'info');
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Aborting security scan...', 'warning');
return;
}
const vulnerabilities: Array<{ type: string; severity: string; image: string; version: string }> = [];
// Check for exposed ports
await execution?.addLog('Checking for exposed ports...', 'info');
// TODO: Actual port scanning logic
// Check for outdated images
await execution?.addLog('Checking for outdated images...', 'info');
const images = await taskManager.cloudlyRef.imageManager.CImage.getInstances({});
for (const image of images) {
if (execution && (taskManager.isCancellationRequested(execution.id) || execution.data.status === 'cancelled')) {
await execution.addLog('Cancellation requested. Stopping loop...', 'warning');
break;
}
// TODO: Check if image is outdated
const isOutdated = Math.random() > 0.7; // 30% outdated for demo
if (isOutdated) {
vulnerabilities.push({
type: 'outdated-image',
severity: 'medium',
image: image.data.name,
version: image.data.versions[0]?.versionString || 'unknown',
});
}
}
// Check for weak passwords
await execution?.addLog('Checking for weak configurations...', 'info');
// TODO: Configuration checks
await execution?.setMetric('vulnerabilitiesFound', vulnerabilities.length);
await execution?.setMetric('vulnerabilities', vulnerabilities);
const severity = vulnerabilities.length > 0 ? 'warning' : 'success';
await execution?.addLog(
`Security scan completed: ${vulnerabilities.length} vulnerabilities found`,
severity as any
);
return { vulnerabilities };
} catch (error) {
await execution?.addLog(`Security scan error: ${getErrorMessage(error)}`, 'error');
throw error;
}
},
});
taskManager.registerTask('security-scan', securityScan, {
description: 'Run security checks on services',
category: 'security',
schedule: '0 1 * * *', // Daily at 1 AM
enabled: true,
});
// Docker Cleanup
const dockerCleanup = new plugins.taskbuffer.Task({
name: 'docker-cleanup',
taskFunction: async () => {
const execution = taskManager.getCurrentExecution('docker-cleanup');
try {
await execution?.addLog('Starting Docker cleanup...', 'info');
// TODO: Implement actual Docker cleanup
await execution?.addLog('Removing stopped containers...', 'info');
const removedContainers = 0; // Placeholder
await execution?.addLog('Removing unused images...', 'info');
const removedImages = 0; // Placeholder
await execution?.addLog('Removing unused volumes...', 'info');
const removedVolumes = 0; // Placeholder
await execution?.addLog('Removing unused networks...', 'info');
const removedNetworks = 0; // Placeholder
await execution?.setMetric('removedContainers', removedContainers);
await execution?.setMetric('removedImages', removedImages);
await execution?.setMetric('removedVolumes', removedVolumes);
await execution?.setMetric('removedNetworks', removedNetworks);
await execution?.addLog(
`Docker cleanup completed: ${removedContainers} containers, ${removedImages} images, ${removedVolumes} volumes, ${removedNetworks} networks removed`,
'success'
);
return {
containers: removedContainers,
images: removedImages,
volumes: removedVolumes,
networks: removedNetworks,
};
} catch (error) {
await execution?.addLog(`Docker cleanup error: ${getErrorMessage(error)}`, 'error');
throw error;
}
},
});
taskManager.registerTask('docker-cleanup', dockerCleanup, {
description: 'Remove unused Docker images and containers',
category: 'cleanup',
schedule: '0 5 * * *', // Daily at 5 AM
enabled: true,
});
logger.log('info', 'Predefined tasks registered successfully');
}

Some files were not shown because too many files have changed in this diff Show More