Project Publishing Design
Overview
This page is the design record for publishing a project's artifacts: every microservice
under packages/microservices runs in a VM of its own, and every UI under packages/ui
is built by the deployment, uploaded to organization-owned Azure Blob storage, and served
on its own hostname through the Cloudflare sites Worker. It describes the target shape, and the
Built so far section at the end says which parts exist today. The
user-facing view of the same flow is Push to Deploy.
Entities
ProjectDeployment one per project: checkout dir, sync and publish VMs, job run, status,
│ the artifacts the last sync found and the commit it found them in
├─ MicroserviceDeployment one per microservice artifact: its VM, its machine identity, the
│ commit it was ensured for, status
└─ UiDeployment one per UI artifact: its hostname, live
commit, status
"Deployment" names standing deployed state, as ProjectDeployment already does. Both
children are plain AbstractRepository entities in management-api beside Workload, not
organization-scoped ones. Each child has a published service for the console with
findAllForProject and remove, and UIs also have findAllForApplication; removal is the
only path that destroys anything.
Module placement follows the two servers the platform is splitting into: a management server
running management-api alone, which the portal and every organization machine reach, and a
system server running management-api and system-api, which the system console reaches. So
the records, their repositories and every service the portal calls live in management-api;
everything that touches nodes, workloads or Azure lives in system-api; and the management
plane reaches the second only through DeploymentOperationsProxy, a @Proxy interface onto
the system zone's DeploymentOperationsService (restartMicroservice, removeMicroservice,
removeUiSite), each of which writes intent that the deployment's worker carries out. The
management services authorize a request against the caller's organization, then delegate; the
operations service trusts its callers. A cluster member that commits to a project's repository on
an organization's behalf gets a GitHub installation token with contents write
access to that one repository from the system zone's
ProjectRepoCredentialService.issueWriteToken(projectId), calling it as the OrganizationParticipant
of the organization's user it acts for; the service issues tokens only for that organization's projects.
ProjectDeployment keeps the sync identity and loses runtimeWorkloadId and
runtimeMachineIdentityId, which move to the per-microservice rows.
Artifacts and their identity
The artifacts a commit contains are found
inside the sync VM, over the checkout, by TypeScript in the workload-runner: code that sits
in the Bun ecosystem and can later resolve packages the way the project's own tooling
(bunup) does. For now the rule is naive and reads no Bun or bunup configuration: a
microservice is a package directly under packages/microservices with entry
package.json.main, else src/main.ts; a UI is a package directly under packages/ui with
a build script that writes dist/index.html. The UI location is the one requirement that
stays hard-coded when the resolution improves.
An artifact's identity is the unscoped part of the name in its package.json
(@acme/admin is admin), a single zone label, and a UI's without --; a name breaking
either rule fails the deploy naming the package. The directory name never matters. The sync VM reports what it found
through ProjectArtifactService.recordArtifacts, authenticated as the project's sync
machine identity, as one ProjectArtifacts: the commit it was found in, the artifacts, and the
dependencyHash of the checkout (a SHA-256 of bun.lock and of the version of the tree the SBOM
reads from it). The server
records it on ProjectDeployment and applies the same name rule to what it is told.
The deploy job
| # | Task | What it does |
|---|---|---|
| 1 | Resolve deployment target | DeployTarget(nodeId, hostDir, syncWorkloadId, uiPublishWorkloadId, sbomWorkloadId), recorded on the ProjectDeployment before any workload runs, since the microservice workers place their VMs on its node |
| 2 | Sync project source | git fetch/checkout · bun install · find artifacts · kinotic sync --publish · build UIs · report artifacts and the dependency hash · write the sentinel (still last) |
| 3 | Resolve artifacts | binds the ProjectArtifacts(commitSha, microservices, uis, dependencyHash) the sync VM reported for the commit into the run; fails when the record names another commit. Store.state(ARTIFACTS).wire() |
| 4 | Ensure runtime workloads | one VM per microservice artifact with one machine identity each; running ones restart on the sentinel, ended ones are replaced, missing ones created, vanished ones marked ORPHANED. Store.state(MICROSERVICE_DEPLOYMENTS).wire() |
| 5 | Publish UIs | mints a hostname for each first-published UI → one directory SAS per site → publish VM uploads → finalize rows and wait for each site to serve; vanished UIs marked ORPHANED. Store.state(UI_DEPLOYMENTS).wire() |
| 6 | Generate SBOM | while ProjectDeployment.sbomGenerated is false, which recordArtifacts makes it whenever the reported dependencyHash changes: SBOM VM reads the dependency tree of bun.lock and records it through ProjectArtifactService.recordSbom → verify the flag is set; otherwise nothing runs. Store.state(SBOM).wire(), whether the run generated the SBOM |
Placement: every VM of a project shares the node holding its checkout. A first deployment's
probe wants SYNC_MEMORY_MB + microservices × RUNTIME_MEMORY_MB, but the artifacts are only known
after the sync VM has run on the chosen node; how the probe accounts for the microservices is
decided with the runtime-workload phase. The runtime workload of
each service sets KINOTIC_APP_ENTRY = <dir>/<entry>; the supervisor is unchanged.
ProjectDeployIdentityService.issueRuntimeCredentials reads and writes the identity id on
the MicroserviceDeployment row; the sync identity stays on ProjectDeployment.
Storage
Published UIs live in the platform's sites account, one per environment (st<env>sites,
created by terraform, hierarchical namespace on, anonymous access off, public network open
since the edge reads from addresses the storage firewall cannot name, every read signed by a
read-only SAS on the container that terraform mints and binds to the Worker as a secret), where every site is one directory of the sites container named by its hostname, which is
what the sites Worker derives from a request's host:
sites/orders--admin.acme.kinotic.io/index.html Cache-Control: no-cache; uploaded last: the atomic switch
sites/orders--admin.acme.kinotic.io/version.json Cache-Control: no-cache; { "commitSha": "<sha>" }
sites/orders--admin.acme.kinotic.io/assets/… Cache-Control: public, max-age=31536000, immutable; names carry a content hash
sites/orders--admin.acme.kinotic.io/… Cache-Control: no-cache; the rest of dist, e.g. favicon.ico
The server never touches a site's files. SiteStorageService issues the credentials the
workloads act with, each a user delegation SAS scoped to the one site's directory (sr=d,
signed through the Data Lake SDK): an upload URL (create, write, list, delete) for the
publish workload, and a removal URL (list, delete) for the removal workload. dist is
uploaded as it is, so a build needs no base path; every blob carries metadata commit=<sha>,
and once the index has switched the publish workload deletes every blob of the site stamped
with a commit other than the current one, listing its own directory through the blob
endpoint. A removal deletes the directory through the account's Data Lake endpoint in one
request.
Serving
Nothing at the edge changes when a UI is published. Terraform's modules/cloudflare-sites
gives an environment, once: the zone's settings and Total TLS, the proxied records for the
app server's hosts, and the sites Worker (deployment/cloudflare/sites-worker/worker.js)
bound to every host of the sites domain and to none of the apps-api hosts. The Worker reads
the site from the request's host, <app>--<ui>.<org>.<sitesDomain>, or the application's
site for a tenant's host, <tenant>--<app>--<ui>.<org>, and the file from the path:
a path whose last segment has an extension → sites/<site>/<path> the file
any other path → sites/<site>/index.html a route of the single-page application
<org>.<sitesDomain> → 302 <portal>/login/<org>
Assets under assets/ carry a content hash and are cached at the edge for a year; the
index and version.json for a minute, so a publish needs no purge. The account's container is
private: the Worker signs every read with a read-only SAS on the container, which terraform
mints for two years and re-mints yearly, so a yearly apply renews it with a year to spare; the
browser only ever sees the response.
Per organization the edge needs two proxied records, *.<org>.<sitesDomain> for its sites and
<org>.<sitesDomain> for its own host (a wildcard never matches its own label), which the
platform creates through OrganizationDnsRecordProvisioner when the organization is created and before
its first publish, and which Total TLS issues the organization's certificates from.
That is the one thing between a sign-up and a served site, and it completes in minutes with
nothing else to propagate; per-site domains, certificates and routes could never make a new
UI available within minutes of a publish, and that is why they do not exist.
HttpUiDeploymentProvisioner only reports: a site
is PROVISIONING from its first publish until https://<hostname>/version.json answers with
the deployment's commit and https://<hostname>/ answers with HTML (a file bypasses the spa
rule), then READY: the deployment's worker, UiDeployOrchestrator, checks every 30 seconds
until the site serves the published commit, recording what the site answered on the row
meanwhile, and the reconcile master's resync brings a site back under check after a server
restart.
The hostname is {app}--{ui}.{org} under the sites domain (kinotic.io), minted at first
publish, stored as UiDeployment.id and looked up by hostname: the UI's label within the
organization's own subdomain. No organization id, application id or UI name contains --, and
the organization id xn is reserved so no label starts with DNS's xn--, so a hostname names
exactly one UI of one application of one organization; a site addressed to one tenant of the
application puts the tenant first, {tenant}--{app}--{ui}.{org}, which the edge serves from the
application's site. The repository's create enforces uniqueness: a UI name another project of
the application already published fails the publish. The first label is at most 37 characters,
DNS's 63 less the room a tenant's name (at most 24) and the separator take in front of it,
else the publish fails naming the organization and application.
Auth
A site, <app>--<ui>.<org>.kinotic.io, is same-site with its application's API host,
<org>--<app>.apps-api.kinotic.io, so the app server's session cookie flows, and the app server's
CORS pattern admits the sites domain. The app server's session cookie is named
__Host-kinotic-app-session (Secure and Path=/ are already set), SameSite=Lax unless
kinotic.apiGateway.sessionCookieSameSite says otherwise, as a developer's profile does when
the sites and the API sit on unrelated domains. kinotic.io is never put on the Public Suffix
List: an entry would make every site cross-site with the API hosts, and the host-only cookie
and the origin-bound login are what keep one site from reaching another's login.
Build and upload contracts
The sync step sets, per UI build, VITE_KINOTIC_HOST, VITE_KINOTIC_PORT and
VITE_KINOTIC_USE_SSL, split from KINOTIC_UI_SERVER_URL: the application's API host,
HostLabelUtil.apiUrl(kinotic.domain.appApiBaseUrl) (https://<org>--<app>.<apps-api domain>),
placed on the sync workload by the job factory. The address the workload itself dials is for
egress and not usable by a browser. They are the variables the platform's own consoles
connect with, and Vite exposes them to the page without configuration. A build that leaves no dist/index.html fails the deploy before the
sentinel. artifacts.ts in workload-runner is the one enumeration of the artifacts, shared
by the sync and publish entrypoints.
The publish workload is named project-ui-publish-<projectId>, with id
DeployTarget.uiPublishWorkloadId decided in resolveTarget like syncWorkloadId. It runs
the same image in the foreground with entrypoint bun src/publish-ui.ts, the checkout mounted
read-only at /workspace, env KINOTIC_UI_COMMIT, and the secret KINOTIC_UI_UPLOAD_URLS, a
JSON object of UI name to <blob endpoint>/sites/<hostname>?<directory SAS, create+write, TTL the run>. Its allowed hosts are the sites account's host only; it carries no Kinotic
credentials and no machine identity, and is destroyed by the orchestrator as its run ends, the
way every foreground workload is. Its exit check is the one syncSource uses, extracted to one
method with two consumers. publish-ui.ts uploads, per UI, everything in dist into the site's directory: the files under
assets/ with the immutable header, the rest uncached, then version.json, then
index.html, each a PUT with x-ms-blob-type: BlockBlob, x-ms-blob-cache-control,
x-ms-meta-commit, and a Content-Type from Bun.file().type; small concurrency, one retry
on 5xx, non-zero exit on failure.
Orphans and removal
An artifact missing from a deploy marks its deployment ORPHANED; it keeps running or serving
and nothing is deleted. An artifact that returns is adopted (status back to DEPLOYED or
READY), never re-provisioned. MicroserviceDeploymentService.remove destroys the VM,
removes the identity, and deletes the row. UiDeploymentService.remove runs a removal
workload (remove-ui.ts, on the node the project deploys to, holding nothing but a removal
URL for the site's directory) that deletes the directory, then deletes the row; a project
never deployed has no node and its site no files, and a removal that fails leaves the files
for a later publish of the same label to adopt. Both confirm in the console when the
deployment is not orphaned.
Properties and dependencies
kinotic.domain.publishedSites.domain, the sites domain every server shares;
kinotic.systemApi.uiDeployment.sitesStorageEndpoint; and
kinotic.systemApi.disableAzureStorage, which turns off the sites account's uses for an
environment without it. Nothing else is a property; an organization has no storage of its own,
and nothing is provisioned when one is created.
Managed artifacts, each a *Version in gradle.properties and one line in the conventions
dependencyManagement block: com.azure:azure-storage-file-datalake, which signs the
directory SAS, and com.azure:azure-storage-blob, which only the integration test reads
with. azure-identity is already managed; no management-plane SDK is.
Deferred
Not part of this design: customer domains, an Environment entity, customer file storage, and push notification of publishes.
Built so far
- Artifact discovery. The sync VM finds the commit's artifacts (
src/artifacts.tsin the workload-runner, the naive rule above) and reports them throughProjectArtifactService.recordArtifacts, which recordsartifacts, the commit they were found in included, onProjectDeployment. The deploy job's third task, Resolve artifacts, binds them into the run as aProjectArtifacts(itsMicroserviceArtifacts andUiArtifacts) under theartifactsstore name, wired to watchers. The job run page of the portal and of the system console lists what the commit contains on that task's row, and the sync task's row expands into the build VM's log in both. - One VM per microservice.
MicroserviceDeploymentrows, keyed<projectId>:<name>so the store enforces one per microservice, record each microservice's workload, machine identity, entry point and commit. The deploy job's fourth task, Ensure runtime workloads, keeps running VMs, replaces ended ones and ones whose entry point moved, creates missing ones, orphans vanished ones, records failures per row, and stores the rows undermicroserviceDeployments.ProjectDeploymentno longer carries a runtime workload or identity;findProjectMachineslists the sync identity then one per microservice. - Microservices in the console.
MicroserviceDeploymentService(findAllForProject,restart,remove), published from management-api and reaching the VM throughDeploymentOperationsProxy, and the portal's deployment page: a microservices table with logs, restart and remove, and the machines labelled by the deployment that records them. - UIs built in the sync VM. After the entity sync,
sync.tsrunsbun run buildin every UI artifact withVITE_KINOTIC_HOST,VITE_KINOTIC_PORTandVITE_KINOTIC_USE_SSL, split from theKINOTIC_UI_SERVER_URLplaced on the sync workload fromHostLabelUtil.apiUrl(kinotic.domain.appApiBaseUrl), and fails the run naming a UI whose build leaves nodist/index.html. - UI deployments and the storage credentials.
UiDeploymentrows keyed by the site's hostname label,SiteStorageService(issueUploadUrl,issueRemovalUrl) signing directory-scoped SAS through the Data Lake SDK on the sites account, each site's directory named by its hostname, and theUiDeploymentProvisionercontract with a mock that marks sites ready at once.AzureProvisioningIntegrationTestin system-api publishes a site and checks the scope of the URLs against a developer's subscription, from thelocalprofile and.env.local, and skips elsewhere. - The publish task. The deploy job's fifth task, Publish UIs, mints the hostname
{app}--{ui}.{org}under the sites domain for each UI published for the first time, then runsproject-ui-publish-<projectId>underDeployTarget.uiPublishWorkloadIdwithpublish-ui.ts, the checkout read-only,KINOTIC_UI_COMMIT, the secretKINOTIC_UI_UPLOAD_URLS(one one-hour directory SAS per site, by UI name) and the sites account's hosts as its only egress, then finalizes: waits for new sites to serve, adopts returning ones and orphans vanished ones; the workload itself deletes the files of other commits once the index has switched. Without Azure storage (kinotic.systemApi.disableAzureStorage) nothing serves, so nothing is uploaded. The exit check is shared with the sync task, and the previous publish workload is retired by the next run's target resolution.SiteWorkloadFactorybuilds the publish workload and the removal workload alike. - The project SBOM. A project has one commit and one dependency hash, both on
ProjectDeployment.artifacts;ProjectDeployment.sbomGeneratedsays whether the project'sProjectDependencieslist those dependencies, andrecordArtifactsclears it when a sync reports another hash. The deploy job's sixth task, Generate SBOM, runsproject-sbom-<projectId>underDeployTarget.sbomWorkloadIdonly while the flag is clear:generate-sbom.tsreads the dependency tree of the read-only checkout'sbun.lock, resolving each dependency the way bun loads it (for a package at keya/b, the first ofa/b/d,a/danddthe lockfile holds), and records it withrecordSbomas the sync machine, whose secret is reissued for it; the workload's only egress is the server, and the record is refused unless its hash is the one the sync reported. The server stores the tree as one document per project inkinotic_project_dependencies:packages, a keyword list of package URLs, so the projects holding a package are one term query, anddirect,development,optionalandedgesas unindexed positions in it. The portal's SBOM page reads the tree throughProjectService.findDependenciesand builds the CycloneDX document it downloads in the browser. A project's deletion deletes the tree with its deployment. - Sites on Cloudflare. Terraform's
modules/cloudflare-sitesowns everything a site needs, once per environment: the zone's settings and Total TLS, the app server's proxied hosts, and the sites Worker that servessites/<hostname>/from the sites account, whichmodules/sitesowns with the server identity's Storage Blob Data Contributor on it. An organization owns no storage; its one provisioned thing is the record*.<org>.<sitesDomain>, whichOrganizationDnsRecordProvisionercreates at sign-up and before the first publish.HttpUiDeploymentProvisionercreates nothing: it answers whether a published site serves its commit throughversion.jsonand its index through the edge, and the deployment's worker keeps asking until it does, then reportsREADY. - UI deployments in the console.
UiDeploymentService(findAllForProject,findAllForApplication,remove), published from management-api and delegating to the system server throughDeploymentOperationsProxy: remove asks for the deployment's removal, and its worker takes the site down, deletes the UI's prefix in storage and deletes the row. Deleting a project removes its UI deployments the same way.@kinotic-ai/management-apiexposes it asKinotic.uiDeploymentswith theUiDeploymentmodel. - Sites in the console.
UiDeployment.urlis minted with the label, so the portal's deployment page lists each UI with a link to its site, the phase it reports and the commit it serves, with removal. Each server's session cookie is__Host-kinotic-<server>-session, named once inApiGatewayVertcleFactoryfrom the server'sZonePartitioningServiceand handed to the gateway's session handler and to theSessionBindingthat resolves each page's login from it.