@omnitron-dev/omnitron 0.1.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/BUILD.json +5 -0
- package/dist/cli/omnitron.js +378 -25
- package/dist/cli/omnitron.js.map +1 -1
- package/dist/cluster/cluster.rpc-service.d.ts +40 -0
- package/dist/cluster/cluster.rpc-service.d.ts.map +1 -1
- package/dist/cluster/cluster.rpc-service.js +49 -3
- package/dist/cluster/cluster.rpc-service.js.map +1 -1
- package/dist/cluster/config-sync.d.ts.map +1 -1
- package/dist/cluster/config-sync.js.map +1 -1
- package/dist/cluster/leader-election.d.ts +90 -2
- package/dist/cluster/leader-election.d.ts.map +1 -1
- package/dist/cluster/leader-election.js +176 -24
- package/dist/cluster/leader-election.js.map +1 -1
- package/dist/cluster/mesh-link.d.ts +136 -0
- package/dist/cluster/mesh-link.d.ts.map +1 -0
- package/dist/cluster/mesh-link.js +212 -0
- package/dist/cluster/mesh-link.js.map +1 -0
- package/dist/cluster/mesh.d.ts +59 -0
- package/dist/cluster/mesh.d.ts.map +1 -0
- package/dist/cluster/mesh.js +117 -0
- package/dist/cluster/mesh.js.map +1 -0
- package/dist/cluster/slave-connector.d.ts +193 -9
- package/dist/cluster/slave-connector.d.ts.map +1 -1
- package/dist/cluster/slave-connector.js +409 -37
- package/dist/cluster/slave-connector.js.map +1 -1
- package/dist/commands/audit.d.ts +65 -0
- package/dist/commands/audit.d.ts.map +1 -0
- package/dist/commands/audit.js +233 -0
- package/dist/commands/audit.js.map +1 -0
- package/dist/commands/backup.d.ts +4 -0
- package/dist/commands/backup.d.ts.map +1 -1
- package/dist/commands/backup.js +255 -33
- package/dist/commands/backup.js.map +1 -1
- package/dist/commands/cluster.d.ts.map +1 -1
- package/dist/commands/cluster.js +78 -47
- package/dist/commands/cluster.js.map +1 -1
- package/dist/commands/daemon-cmd.d.ts +7 -1
- package/dist/commands/daemon-cmd.d.ts.map +1 -1
- package/dist/commands/daemon-cmd.js +87 -21
- package/dist/commands/daemon-cmd.js.map +1 -1
- package/dist/commands/daemon-required.d.ts +34 -0
- package/dist/commands/daemon-required.d.ts.map +1 -0
- package/dist/commands/daemon-required.js +92 -0
- package/dist/commands/daemon-required.js.map +1 -0
- package/dist/commands/deploy.d.ts +70 -8
- package/dist/commands/deploy.d.ts.map +1 -1
- package/dist/commands/deploy.js +99 -64
- package/dist/commands/deploy.js.map +1 -1
- package/dist/commands/discover.d.ts +25 -4
- package/dist/commands/discover.d.ts.map +1 -1
- package/dist/commands/discover.js +155 -102
- package/dist/commands/discover.js.map +1 -1
- package/dist/commands/doctor.d.ts +293 -0
- package/dist/commands/doctor.d.ts.map +1 -0
- package/dist/commands/doctor.js +1706 -0
- package/dist/commands/doctor.js.map +1 -0
- package/dist/commands/env.d.ts +8 -1
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +18 -5
- package/dist/commands/env.js.map +1 -1
- package/dist/commands/exec.d.ts.map +1 -1
- package/dist/commands/exec.js +3 -2
- package/dist/commands/exec.js.map +1 -1
- package/dist/commands/fleet-asking.d.ts +87 -0
- package/dist/commands/fleet-asking.d.ts.map +1 -0
- package/dist/commands/fleet-asking.js +176 -0
- package/dist/commands/fleet-asking.js.map +1 -0
- package/dist/commands/fleet.d.ts +18 -0
- package/dist/commands/fleet.d.ts.map +1 -1
- package/dist/commands/fleet.js +314 -74
- package/dist/commands/fleet.js.map +1 -1
- package/dist/commands/health-check.d.ts +8 -6
- package/dist/commands/health-check.d.ts.map +1 -1
- package/dist/commands/health-check.js +26 -23
- package/dist/commands/health-check.js.map +1 -1
- package/dist/commands/health.d.ts.map +1 -1
- package/dist/commands/health.js +26 -4
- package/dist/commands/health.js.map +1 -1
- package/dist/commands/infra.d.ts +42 -1
- package/dist/commands/infra.d.ts.map +1 -1
- package/dist/commands/infra.js +418 -49
- package/dist/commands/infra.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +21 -36
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/inspect-graph.d.ts +20 -0
- package/dist/commands/inspect-graph.d.ts.map +1 -0
- package/dist/commands/inspect-graph.js +74 -0
- package/dist/commands/inspect-graph.js.map +1 -0
- package/dist/commands/inspect.d.ts.map +1 -1
- package/dist/commands/inspect.js +115 -11
- package/dist/commands/inspect.js.map +1 -1
- package/dist/commands/k8s.d.ts.map +1 -1
- package/dist/commands/k8s.js +23 -18
- package/dist/commands/k8s.js.map +1 -1
- package/dist/commands/kb.d.ts.map +1 -1
- package/dist/commands/kb.js +88 -29
- package/dist/commands/kb.js.map +1 -1
- package/dist/commands/list.d.ts.map +1 -1
- package/dist/commands/list.js +25 -3
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/log-health.d.ts +120 -0
- package/dist/commands/log-health.d.ts.map +1 -0
- package/dist/commands/log-health.js +212 -0
- package/dist/commands/log-health.js.map +1 -0
- package/dist/commands/logs.d.ts +96 -2
- package/dist/commands/logs.d.ts.map +1 -1
- package/dist/commands/logs.js +532 -192
- package/dist/commands/logs.js.map +1 -1
- package/dist/commands/metrics.d.ts.map +1 -1
- package/dist/commands/metrics.js +40 -14
- package/dist/commands/metrics.js.map +1 -1
- package/dist/commands/monit.d.ts.map +1 -1
- package/dist/commands/monit.js +12 -2
- package/dist/commands/monit.js.map +1 -1
- package/dist/commands/node.d.ts +68 -0
- package/dist/commands/node.d.ts.map +1 -1
- package/dist/commands/node.js +285 -68
- package/dist/commands/node.js.map +1 -1
- package/dist/commands/output.d.ts +45 -0
- package/dist/commands/output.d.ts.map +1 -0
- package/dist/commands/output.js +103 -0
- package/dist/commands/output.js.map +1 -0
- package/dist/commands/pipeline.d.ts.map +1 -1
- package/dist/commands/pipeline.js +26 -19
- package/dist/commands/pipeline.js.map +1 -1
- package/dist/commands/project.d.ts +19 -0
- package/dist/commands/project.d.ts.map +1 -1
- package/dist/commands/project.js +125 -27
- package/dist/commands/project.js.map +1 -1
- package/dist/commands/release-store.d.ts +28 -0
- package/dist/commands/release-store.d.ts.map +1 -0
- package/dist/commands/release-store.js +158 -0
- package/dist/commands/release-store.js.map +1 -0
- package/dist/commands/release.d.ts +61 -0
- package/dist/commands/release.d.ts.map +1 -0
- package/dist/commands/release.js +555 -0
- package/dist/commands/release.js.map +1 -0
- package/dist/commands/reload.d.ts +16 -0
- package/dist/commands/reload.d.ts.map +1 -1
- package/dist/commands/reload.js +21 -5
- package/dist/commands/reload.js.map +1 -1
- package/dist/commands/remote.d.ts +40 -0
- package/dist/commands/remote.d.ts.map +1 -1
- package/dist/commands/remote.js +146 -35
- package/dist/commands/remote.js.map +1 -1
- package/dist/commands/restart.d.ts +16 -0
- package/dist/commands/restart.d.ts.map +1 -1
- package/dist/commands/restart.js +21 -5
- package/dist/commands/restart.js.map +1 -1
- package/dist/commands/scale.d.ts.map +1 -1
- package/dist/commands/scale.js +4 -3
- package/dist/commands/scale.js.map +1 -1
- package/dist/commands/secret.d.ts +54 -0
- package/dist/commands/secret.d.ts.map +1 -1
- package/dist/commands/secret.js +236 -34
- package/dist/commands/secret.js.map +1 -1
- package/dist/commands/service-units.d.ts +181 -0
- package/dist/commands/service-units.d.ts.map +1 -0
- package/dist/commands/service-units.js +291 -0
- package/dist/commands/service-units.js.map +1 -0
- package/dist/commands/service.d.ts +39 -0
- package/dist/commands/service.d.ts.map +1 -0
- package/dist/commands/service.js +370 -0
- package/dist/commands/service.js.map +1 -0
- package/dist/commands/spinner.d.ts +29 -0
- package/dist/commands/spinner.d.ts.map +1 -0
- package/dist/commands/spinner.js +44 -0
- package/dist/commands/spinner.js.map +1 -0
- package/dist/commands/stack.d.ts +49 -1
- package/dist/commands/stack.d.ts.map +1 -1
- package/dist/commands/stack.js +341 -13
- package/dist/commands/stack.js.map +1 -1
- package/dist/commands/start.d.ts +16 -0
- package/dist/commands/start.d.ts.map +1 -1
- package/dist/commands/start.js +20 -3
- package/dist/commands/start.js.map +1 -1
- package/dist/commands/status.d.ts +9 -0
- package/dist/commands/status.d.ts.map +1 -1
- package/dist/commands/status.js +62 -29
- package/dist/commands/status.js.map +1 -1
- package/dist/commands/stop.d.ts +16 -0
- package/dist/commands/stop.d.ts.map +1 -1
- package/dist/commands/stop.js +34 -6
- package/dist/commands/stop.js.map +1 -1
- package/dist/commands/tor.d.ts +20 -0
- package/dist/commands/tor.d.ts.map +1 -0
- package/dist/commands/tor.js +180 -0
- package/dist/commands/tor.js.map +1 -0
- package/dist/commands/up.d.ts +85 -1
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +181 -33
- package/dist/commands/up.js.map +1 -1
- package/dist/commands/webapp.d.ts.map +1 -1
- package/dist/commands/webapp.js +2 -1
- package/dist/commands/webapp.js.map +1 -1
- package/dist/config/defaults.d.ts +16 -0
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +26 -3
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/define-ecosystem.d.ts.map +1 -1
- package/dist/config/define-ecosystem.js +2 -0
- package/dist/config/define-ecosystem.js.map +1 -1
- package/dist/config/index.d.ts +1 -1
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +1 -1
- package/dist/config/index.js.map +1 -1
- package/dist/config/loader.d.ts +45 -1
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +93 -11
- package/dist/config/loader.js.map +1 -1
- package/dist/config/omnitron-config.schema.d.ts.map +1 -1
- package/dist/config/omnitron-config.schema.js +16 -0
- package/dist/config/omnitron-config.schema.js.map +1 -1
- package/dist/config/types.d.ts +425 -42
- package/dist/config/types.d.ts.map +1 -1
- package/dist/daemon/config-reload.d.ts +27 -0
- package/dist/daemon/config-reload.d.ts.map +1 -0
- package/dist/daemon/config-reload.js +42 -0
- package/dist/daemon/config-reload.js.map +1 -0
- package/dist/daemon/daemon-client.d.ts +150 -2
- package/dist/daemon/daemon-client.d.ts.map +1 -1
- package/dist/daemon/daemon-client.js +311 -21
- package/dist/daemon/daemon-client.js.map +1 -1
- package/dist/daemon/daemon-entry.js +92 -38
- package/dist/daemon/daemon-entry.js.map +1 -1
- package/dist/daemon/daemon-scheduler.d.ts +4 -0
- package/dist/daemon/daemon-scheduler.d.ts.map +1 -1
- package/dist/daemon/daemon-scheduler.js +137 -7
- package/dist/daemon/daemon-scheduler.js.map +1 -1
- package/dist/daemon/daemon-state-store.service.d.ts +263 -0
- package/dist/daemon/daemon-state-store.service.d.ts.map +1 -0
- package/dist/daemon/daemon-state-store.service.js +446 -0
- package/dist/daemon/daemon-state-store.service.js.map +1 -0
- package/dist/daemon/daemon.d.ts +207 -1
- package/dist/daemon/daemon.d.ts.map +1 -1
- package/dist/daemon/daemon.js +1254 -143
- package/dist/daemon/daemon.js.map +1 -1
- package/dist/daemon/daemon.module.d.ts.map +1 -1
- package/dist/daemon/daemon.module.js +186 -79
- package/dist/daemon/daemon.module.js.map +1 -1
- package/dist/daemon/daemon.rpc-service.d.ts +109 -1
- package/dist/daemon/daemon.rpc-service.d.ts.map +1 -1
- package/dist/daemon/daemon.rpc-service.js +451 -95
- package/dist/daemon/daemon.rpc-service.js.map +1 -1
- package/dist/daemon/pid-manager.d.ts +69 -5
- package/dist/daemon/pid-manager.d.ts.map +1 -1
- package/dist/daemon/pid-manager.js +151 -17
- package/dist/daemon/pid-manager.js.map +1 -1
- package/dist/daemon/spawn-daemon.d.ts +73 -0
- package/dist/daemon/spawn-daemon.d.ts.map +1 -0
- package/dist/daemon/spawn-daemon.js +101 -0
- package/dist/daemon/spawn-daemon.js.map +1 -0
- package/dist/daemon/state-store.d.ts +65 -3
- package/dist/daemon/state-store.d.ts.map +1 -1
- package/dist/daemon/state-store.js +103 -19
- package/dist/daemon/state-store.js.map +1 -1
- package/dist/database/connection.d.ts +71 -0
- package/dist/database/connection.d.ts.map +1 -0
- package/dist/database/connection.js +129 -0
- package/dist/database/connection.js.map +1 -0
- package/dist/database/dialect.d.ts +60 -0
- package/dist/database/dialect.d.ts.map +1 -0
- package/dist/database/dialect.js +80 -0
- package/dist/database/dialect.js.map +1 -0
- package/dist/database/migrate.d.ts +5 -2
- package/dist/database/migrate.d.ts.map +1 -1
- package/dist/database/migrate.js +23 -33
- package/dist/database/migrate.js.map +1 -1
- package/dist/database/migration-runner.d.ts +50 -0
- package/dist/database/migration-runner.d.ts.map +1 -0
- package/dist/database/migration-runner.js +143 -0
- package/dist/database/migration-runner.js.map +1 -0
- package/dist/database/migrations/006_login_throttle.d.ts +17 -0
- package/dist/database/migrations/006_login_throttle.d.ts.map +1 -0
- package/dist/database/migrations/006_login_throttle.js +33 -0
- package/dist/database/migrations/006_login_throttle.js.map +1 -0
- package/dist/database/migrations/007_sync_ingested.d.ts +21 -0
- package/dist/database/migrations/007_sync_ingested.d.ts.map +1 -0
- package/dist/database/migrations/007_sync_ingested.js +39 -0
- package/dist/database/migrations/007_sync_ingested.js.map +1 -0
- package/dist/database/migrations/008_node_health_version_text.d.ts +24 -0
- package/dist/database/migrations/008_node_health_version_text.d.ts.map +1 -0
- package/dist/database/migrations/008_node_health_version_text.js +35 -0
- package/dist/database/migrations/008_node_health_version_text.js.map +1 -0
- package/dist/database/migrations/009_node_health_unmeasured.d.ts +40 -0
- package/dist/database/migrations/009_node_health_unmeasured.d.ts.map +1 -0
- package/dist/database/migrations/009_node_health_unmeasured.js +63 -0
- package/dist/database/migrations/009_node_health_unmeasured.js.map +1 -0
- package/dist/database/migrations/010_metrics_raw_had_no_reader.d.ts +22 -0
- package/dist/database/migrations/010_metrics_raw_had_no_reader.d.ts.map +1 -0
- package/dist/database/migrations/010_metrics_raw_had_no_reader.js +26 -0
- package/dist/database/migrations/010_metrics_raw_had_no_reader.js.map +1 -0
- package/dist/database/migrations/011_alerts_nobody_had_to_write.d.ts +56 -0
- package/dist/database/migrations/011_alerts_nobody_had_to_write.d.ts.map +1 -0
- package/dist/database/migrations/011_alerts_nobody_had_to_write.js +79 -0
- package/dist/database/migrations/011_alerts_nobody_had_to_write.js.map +1 -0
- package/dist/database/migrations/012_a_disk_nobody_watched.d.ts +26 -0
- package/dist/database/migrations/012_a_disk_nobody_watched.d.ts.map +1 -0
- package/dist/database/migrations/012_a_disk_nobody_watched.js +40 -0
- package/dist/database/migrations/012_a_disk_nobody_watched.js.map +1 -0
- package/dist/database/migrations/013_an_alarm_delivered.d.ts +14 -0
- package/dist/database/migrations/013_an_alarm_delivered.d.ts.map +1 -0
- package/dist/database/migrations/013_an_alarm_delivered.js +28 -0
- package/dist/database/migrations/013_an_alarm_delivered.js.map +1 -0
- package/dist/database/migrations/index.d.ts +21 -0
- package/dist/database/migrations/index.d.ts.map +1 -0
- package/dist/database/migrations/index.js +47 -0
- package/dist/database/migrations/index.js.map +1 -0
- package/dist/database/schema.d.ts +27 -11
- package/dist/database/schema.d.ts.map +1 -1
- package/dist/database/sqlite-date-binding.d.ts +71 -0
- package/dist/database/sqlite-date-binding.d.ts.map +1 -0
- package/dist/database/sqlite-date-binding.js +174 -0
- package/dist/database/sqlite-date-binding.js.map +1 -0
- package/dist/execution/data-channel.d.ts +48 -0
- package/dist/execution/data-channel.d.ts.map +1 -0
- package/dist/execution/data-channel.js +55 -0
- package/dist/execution/data-channel.js.map +1 -0
- package/dist/execution/execution.service.d.ts +139 -0
- package/dist/execution/execution.service.d.ts.map +1 -1
- package/dist/execution/execution.service.js +273 -10
- package/dist/execution/execution.service.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/infrastructure/bare-metal-plan.d.ts +208 -0
- package/dist/infrastructure/bare-metal-plan.d.ts.map +1 -0
- package/dist/infrastructure/bare-metal-plan.js +363 -0
- package/dist/infrastructure/bare-metal-plan.js.map +1 -0
- package/dist/infrastructure/bare-metal-runner.d.ts +65 -0
- package/dist/infrastructure/bare-metal-runner.d.ts.map +1 -0
- package/dist/infrastructure/bare-metal-runner.js +354 -0
- package/dist/infrastructure/bare-metal-runner.js.map +1 -0
- package/dist/infrastructure/config-payload.d.ts +149 -0
- package/dist/infrastructure/config-payload.d.ts.map +1 -0
- package/dist/infrastructure/config-payload.js +235 -0
- package/dist/infrastructure/config-payload.js.map +1 -0
- package/dist/infrastructure/container-runtime.d.ts +179 -1
- package/dist/infrastructure/container-runtime.d.ts.map +1 -1
- package/dist/infrastructure/container-runtime.js +612 -45
- package/dist/infrastructure/container-runtime.js.map +1 -1
- package/dist/infrastructure/gateway-reachability.d.ts +85 -0
- package/dist/infrastructure/gateway-reachability.d.ts.map +1 -0
- package/dist/infrastructure/gateway-reachability.js +116 -0
- package/dist/infrastructure/gateway-reachability.js.map +1 -0
- package/dist/infrastructure/gateway-redis-db.d.ts +29 -0
- package/dist/infrastructure/gateway-redis-db.d.ts.map +1 -0
- package/dist/infrastructure/gateway-redis-db.js +32 -0
- package/dist/infrastructure/gateway-redis-db.js.map +1 -0
- package/dist/infrastructure/host-inspection.d.ts +223 -0
- package/dist/infrastructure/host-inspection.d.ts.map +1 -0
- package/dist/infrastructure/host-inspection.js +501 -0
- package/dist/infrastructure/host-inspection.js.map +1 -0
- package/dist/infrastructure/infrastructure-gate.d.ts.map +1 -1
- package/dist/infrastructure/infrastructure.service.d.ts +247 -6
- package/dist/infrastructure/infrastructure.service.d.ts.map +1 -1
- package/dist/infrastructure/infrastructure.service.js +571 -96
- package/dist/infrastructure/infrastructure.service.js.map +1 -1
- package/dist/infrastructure/known-machines.d.ts +76 -0
- package/dist/infrastructure/known-machines.d.ts.map +1 -0
- package/dist/infrastructure/known-machines.js +94 -0
- package/dist/infrastructure/known-machines.js.map +1 -0
- package/dist/infrastructure/node-credentials.d.ts +45 -0
- package/dist/infrastructure/node-credentials.d.ts.map +1 -0
- package/dist/infrastructure/node-credentials.js +80 -0
- package/dist/infrastructure/node-credentials.js.map +1 -0
- package/dist/infrastructure/orphan-containers.d.ts +99 -0
- package/dist/infrastructure/orphan-containers.d.ts.map +1 -0
- package/dist/infrastructure/orphan-containers.js +106 -0
- package/dist/infrastructure/orphan-containers.js.map +1 -0
- package/dist/infrastructure/phantom-endpoint-janitor.d.ts +92 -0
- package/dist/infrastructure/phantom-endpoint-janitor.d.ts.map +1 -0
- package/dist/infrastructure/phantom-endpoint-janitor.js +199 -0
- package/dist/infrastructure/phantom-endpoint-janitor.js.map +1 -0
- package/dist/infrastructure/presets/gateway-openresty.d.ts.map +1 -1
- package/dist/infrastructure/presets/gateway-openresty.js +29 -3
- package/dist/infrastructure/presets/gateway-openresty.js.map +1 -1
- package/dist/infrastructure/presets/index.d.ts.map +1 -1
- package/dist/infrastructure/presets/index.js +2 -0
- package/dist/infrastructure/presets/index.js.map +1 -1
- package/dist/infrastructure/presets/minio.d.ts.map +1 -1
- package/dist/infrastructure/presets/minio.js +36 -3
- package/dist/infrastructure/presets/minio.js.map +1 -1
- package/dist/infrastructure/presets/postgres.d.ts.map +1 -1
- package/dist/infrastructure/presets/postgres.js +78 -5
- package/dist/infrastructure/presets/postgres.js.map +1 -1
- package/dist/infrastructure/presets/redis.d.ts.map +1 -1
- package/dist/infrastructure/presets/redis.js +23 -4
- package/dist/infrastructure/presets/redis.js.map +1 -1
- package/dist/infrastructure/presets/registry.d.ts.map +1 -1
- package/dist/infrastructure/presets/registry.js +85 -4
- package/dist/infrastructure/presets/registry.js.map +1 -1
- package/dist/infrastructure/presets/tor.d.ts +112 -0
- package/dist/infrastructure/presets/tor.d.ts.map +1 -0
- package/dist/infrastructure/presets/tor.js +185 -0
- package/dist/infrastructure/presets/tor.js.map +1 -0
- package/dist/infrastructure/presets/torrc.d.ts +54 -0
- package/dist/infrastructure/presets/torrc.d.ts.map +1 -0
- package/dist/infrastructure/presets/torrc.js +153 -0
- package/dist/infrastructure/presets/torrc.js.map +1 -0
- package/dist/infrastructure/presets/types.d.ts +17 -0
- package/dist/infrastructure/presets/types.d.ts.map +1 -1
- package/dist/infrastructure/provisioning-outcome.d.ts +85 -0
- package/dist/infrastructure/provisioning-outcome.d.ts.map +1 -0
- package/dist/infrastructure/provisioning-outcome.js +122 -0
- package/dist/infrastructure/provisioning-outcome.js.map +1 -0
- package/dist/infrastructure/server-registry.d.ts +27 -0
- package/dist/infrastructure/server-registry.d.ts.map +1 -1
- package/dist/infrastructure/server-registry.js +60 -5
- package/dist/infrastructure/server-registry.js.map +1 -1
- package/dist/infrastructure/service-binding.d.ts +60 -0
- package/dist/infrastructure/service-binding.d.ts.map +1 -0
- package/dist/infrastructure/service-binding.js +90 -0
- package/dist/infrastructure/service-binding.js.map +1 -0
- package/dist/infrastructure/service-credentials.d.ts +105 -0
- package/dist/infrastructure/service-credentials.d.ts.map +1 -0
- package/dist/infrastructure/service-credentials.js +141 -0
- package/dist/infrastructure/service-credentials.js.map +1 -0
- package/dist/infrastructure/service-resolver.d.ts +191 -12
- package/dist/infrastructure/service-resolver.d.ts.map +1 -1
- package/dist/infrastructure/service-resolver.js +521 -60
- package/dist/infrastructure/service-resolver.js.map +1 -1
- package/dist/infrastructure/shipped-config.d.ts +43 -0
- package/dist/infrastructure/shipped-config.d.ts.map +1 -0
- package/dist/infrastructure/shipped-config.js +159 -0
- package/dist/infrastructure/shipped-config.js.map +1 -0
- package/dist/infrastructure/stack-infra-manager.d.ts +7 -1
- package/dist/infrastructure/stack-infra-manager.d.ts.map +1 -1
- package/dist/infrastructure/stack-infra-manager.js +32 -14
- package/dist/infrastructure/stack-infra-manager.js.map +1 -1
- package/dist/infrastructure/types.d.ts +278 -6
- package/dist/infrastructure/types.d.ts.map +1 -1
- package/dist/mcp/mcp-bridge.d.ts.map +1 -1
- package/dist/mcp/mcp-bridge.js.map +1 -1
- package/dist/mcp/tool-groups/apps.tools.d.ts +2 -1
- package/dist/mcp/tool-groups/apps.tools.d.ts.map +1 -1
- package/dist/mcp/tool-groups/apps.tools.js +2 -2
- package/dist/mcp/tool-groups/apps.tools.js.map +1 -1
- package/dist/mcp/tool-groups/infra.tools.d.ts +16 -2
- package/dist/mcp/tool-groups/infra.tools.d.ts.map +1 -1
- package/dist/mcp/tool-groups/infra.tools.js +51 -41
- package/dist/mcp/tool-groups/infra.tools.js.map +1 -1
- package/dist/mcp/tool-groups/kb.tools.d.ts.map +1 -1
- package/dist/mcp/tool-groups/kb.tools.js +4 -6
- package/dist/mcp/tool-groups/kb.tools.js.map +1 -1
- package/dist/mcp/tool-groups/management.tools.d.ts +20 -3
- package/dist/mcp/tool-groups/management.tools.d.ts.map +1 -1
- package/dist/mcp/tool-groups/management.tools.js +158 -125
- package/dist/mcp/tool-groups/management.tools.js.map +1 -1
- package/dist/mcp/tool-groups/monitoring.tools.d.ts +10 -1
- package/dist/mcp/tool-groups/monitoring.tools.d.ts.map +1 -1
- package/dist/mcp/tool-groups/monitoring.tools.js +65 -18
- package/dist/mcp/tool-groups/monitoring.tools.js.map +1 -1
- package/dist/mcp/unavailable-tools.d.ts +35 -0
- package/dist/mcp/unavailable-tools.d.ts.map +1 -0
- package/dist/mcp/unavailable-tools.js +59 -0
- package/dist/mcp/unavailable-tools.js.map +1 -0
- package/dist/monitoring/app-health.indicator.d.ts.map +1 -1
- package/dist/monitoring/app-health.indicator.js +12 -0
- package/dist/monitoring/app-health.indicator.js.map +1 -1
- package/dist/monitoring/desktop-notifier.d.ts +26 -0
- package/dist/monitoring/desktop-notifier.d.ts.map +1 -0
- package/dist/monitoring/desktop-notifier.js +49 -0
- package/dist/monitoring/desktop-notifier.js.map +1 -0
- package/dist/monitoring/docker-health.indicator.d.ts +17 -1
- package/dist/monitoring/docker-health.indicator.d.ts.map +1 -1
- package/dist/monitoring/docker-health.indicator.js +38 -8
- package/dist/monitoring/docker-health.indicator.js.map +1 -1
- package/dist/monitoring/event-loop-stall.indicator.d.ts +26 -0
- package/dist/monitoring/event-loop-stall.indicator.d.ts.map +1 -0
- package/dist/monitoring/event-loop-stall.indicator.js +47 -0
- package/dist/monitoring/event-loop-stall.indicator.js.map +1 -0
- package/dist/monitoring/event-loop-watch.d.ts +124 -0
- package/dist/monitoring/event-loop-watch.d.ts.map +1 -0
- package/dist/monitoring/event-loop-watch.js +179 -0
- package/dist/monitoring/event-loop-watch.js.map +1 -0
- package/dist/monitoring/host-disk.d.ts +6 -0
- package/dist/monitoring/host-disk.d.ts.map +1 -0
- package/dist/monitoring/host-disk.js +26 -0
- package/dist/monitoring/host-disk.js.map +1 -0
- package/dist/monitoring/log-line.d.ts +188 -0
- package/dist/monitoring/log-line.d.ts.map +1 -0
- package/dist/monitoring/log-line.js +458 -0
- package/dist/monitoring/log-line.js.map +1 -0
- package/dist/monitoring/log-manager.d.ts +89 -8
- package/dist/monitoring/log-manager.d.ts.map +1 -1
- package/dist/monitoring/log-manager.js +246 -77
- package/dist/monitoring/log-manager.js.map +1 -1
- package/dist/observability/index.d.ts +4 -0
- package/dist/observability/index.d.ts.map +1 -0
- package/dist/observability/index.js +3 -0
- package/dist/observability/index.js.map +1 -0
- package/dist/observability/metrics-bridge.d.ts +87 -0
- package/dist/observability/metrics-bridge.d.ts.map +1 -0
- package/dist/observability/metrics-bridge.js +295 -0
- package/dist/observability/metrics-bridge.js.map +1 -0
- package/dist/observability/metrics-server.d.ts +68 -0
- package/dist/observability/metrics-server.d.ts.map +1 -0
- package/dist/observability/metrics-server.js +146 -0
- package/dist/observability/metrics-server.js.map +1 -0
- package/dist/orchestrator/app-handle.d.ts +170 -3
- package/dist/orchestrator/app-handle.d.ts.map +1 -1
- package/dist/orchestrator/app-handle.js +177 -3
- package/dist/orchestrator/app-handle.js.map +1 -1
- package/dist/orchestrator/app-health.d.ts +33 -0
- package/dist/orchestrator/app-health.d.ts.map +1 -0
- package/dist/orchestrator/app-health.js +74 -0
- package/dist/orchestrator/app-health.js.map +1 -0
- package/dist/orchestrator/app-traffic.d.ts +14 -0
- package/dist/orchestrator/app-traffic.d.ts.map +1 -0
- package/dist/orchestrator/app-traffic.js +33 -0
- package/dist/orchestrator/app-traffic.js.map +1 -0
- package/dist/orchestrator/bootstrap-loader-child.d.ts +19 -0
- package/dist/orchestrator/bootstrap-loader-child.d.ts.map +1 -0
- package/dist/orchestrator/bootstrap-loader-child.js +43 -0
- package/dist/orchestrator/bootstrap-loader-child.js.map +1 -0
- package/dist/orchestrator/bootstrap-loader.d.ts +72 -0
- package/dist/orchestrator/bootstrap-loader.d.ts.map +1 -1
- package/dist/orchestrator/bootstrap-loader.js +350 -5
- package/dist/orchestrator/bootstrap-loader.js.map +1 -1
- package/dist/orchestrator/bootstrap-process.d.ts +148 -4
- package/dist/orchestrator/bootstrap-process.d.ts.map +1 -1
- package/dist/orchestrator/bootstrap-process.js +496 -18
- package/dist/orchestrator/bootstrap-process.js.map +1 -1
- package/dist/orchestrator/build-service.d.ts +50 -3
- package/dist/orchestrator/build-service.d.ts.map +1 -1
- package/dist/orchestrator/build-service.js +357 -85
- package/dist/orchestrator/build-service.js.map +1 -1
- package/dist/orchestrator/call-exposed-service.d.ts +7 -0
- package/dist/orchestrator/call-exposed-service.d.ts.map +1 -0
- package/dist/orchestrator/call-exposed-service.js +50 -0
- package/dist/orchestrator/call-exposed-service.js.map +1 -0
- package/dist/orchestrator/classic-launcher.d.ts +1 -0
- package/dist/orchestrator/classic-launcher.d.ts.map +1 -1
- package/dist/orchestrator/classic-launcher.js +34 -3
- package/dist/orchestrator/classic-launcher.js.map +1 -1
- package/dist/orchestrator/file-watcher.d.ts +78 -1
- package/dist/orchestrator/file-watcher.d.ts.map +1 -1
- package/dist/orchestrator/file-watcher.js +240 -43
- package/dist/orchestrator/file-watcher.js.map +1 -1
- package/dist/orchestrator/module-worker-process.d.ts.map +1 -1
- package/dist/orchestrator/module-worker-process.js +10 -0
- package/dist/orchestrator/module-worker-process.js.map +1 -1
- package/dist/orchestrator/orchestrator.service.d.ts +540 -7
- package/dist/orchestrator/orchestrator.service.d.ts.map +1 -1
- package/dist/orchestrator/orchestrator.service.js +2106 -242
- package/dist/orchestrator/orchestrator.service.js.map +1 -1
- package/dist/orchestrator/owned-pids.d.ts +83 -0
- package/dist/orchestrator/owned-pids.d.ts.map +1 -0
- package/dist/orchestrator/owned-pids.js +93 -0
- package/dist/orchestrator/owned-pids.js.map +1 -0
- package/dist/orchestrator/port-conflict.d.ts +102 -0
- package/dist/orchestrator/port-conflict.d.ts.map +1 -0
- package/dist/orchestrator/port-conflict.js +131 -0
- package/dist/orchestrator/port-conflict.js.map +1 -0
- package/dist/orchestrator/process-janitor.d.ts +227 -0
- package/dist/orchestrator/process-janitor.d.ts.map +1 -0
- package/dist/orchestrator/process-janitor.js +495 -0
- package/dist/orchestrator/process-janitor.js.map +1 -0
- package/dist/orchestrator/ps-metrics.d.ts +37 -0
- package/dist/orchestrator/ps-metrics.d.ts.map +1 -0
- package/dist/orchestrator/ps-metrics.js +60 -0
- package/dist/orchestrator/ps-metrics.js.map +1 -0
- package/dist/orchestrator/service-router.d.ts +58 -2
- package/dist/orchestrator/service-router.d.ts.map +1 -1
- package/dist/orchestrator/service-router.js +157 -25
- package/dist/orchestrator/service-router.js.map +1 -1
- package/dist/orchestrator/topology-query.d.ts +49 -0
- package/dist/orchestrator/topology-query.d.ts.map +1 -0
- package/dist/orchestrator/topology-query.js +54 -0
- package/dist/orchestrator/topology-query.js.map +1 -0
- package/dist/project/artifact-builder.d.ts +174 -3
- package/dist/project/artifact-builder.d.ts.map +1 -1
- package/dist/project/artifact-builder.js +454 -42
- package/dist/project/artifact-builder.js.map +1 -1
- package/dist/project/build-decision.d.ts +58 -0
- package/dist/project/build-decision.d.ts.map +1 -0
- package/dist/project/build-decision.js +59 -0
- package/dist/project/build-decision.js.map +1 -0
- package/dist/project/config-resolver.d.ts +6 -4
- package/dist/project/config-resolver.d.ts.map +1 -1
- package/dist/project/config-resolver.js +158 -78
- package/dist/project/config-resolver.js.map +1 -1
- package/dist/project/declared-config.d.ts +42 -0
- package/dist/project/declared-config.d.ts.map +1 -0
- package/dist/project/declared-config.js +60 -0
- package/dist/project/declared-config.js.map +1 -0
- package/dist/project/dependency-analyzer.d.ts.map +1 -1
- package/dist/project/dependency-analyzer.js.map +1 -1
- package/dist/project/deploy-phases.d.ts +35 -0
- package/dist/project/deploy-phases.d.ts.map +1 -0
- package/dist/project/deploy-phases.js +75 -0
- package/dist/project/deploy-phases.js.map +1 -0
- package/dist/project/exec-in-group.d.ts +49 -0
- package/dist/project/exec-in-group.d.ts.map +1 -0
- package/dist/project/exec-in-group.js +100 -0
- package/dist/project/exec-in-group.js.map +1 -0
- package/dist/project/node-app-config.d.ts +101 -0
- package/dist/project/node-app-config.d.ts.map +1 -0
- package/dist/project/node-app-config.js +134 -0
- package/dist/project/node-app-config.js.map +1 -0
- package/dist/project/node-app-health.d.ts +76 -0
- package/dist/project/node-app-health.d.ts.map +1 -0
- package/dist/project/node-app-health.js +119 -0
- package/dist/project/node-app-health.js.map +1 -0
- package/dist/project/operator-account.d.ts +287 -0
- package/dist/project/operator-account.d.ts.map +1 -0
- package/dist/project/operator-account.js +373 -0
- package/dist/project/operator-account.js.map +1 -0
- package/dist/project/registry.d.ts +46 -8
- package/dist/project/registry.d.ts.map +1 -1
- package/dist/project/registry.js +238 -40
- package/dist/project/registry.js.map +1 -1
- package/dist/project/requirements-scanner.d.ts +1 -0
- package/dist/project/requirements-scanner.d.ts.map +1 -1
- package/dist/project/requirements-scanner.js +28 -3
- package/dist/project/requirements-scanner.js.map +1 -1
- package/dist/project/stack-env.d.ts +37 -0
- package/dist/project/stack-env.d.ts.map +1 -0
- package/dist/project/stack-env.js +85 -0
- package/dist/project/stack-env.js.map +1 -0
- package/dist/project/stand-containers.d.ts +4 -0
- package/dist/project/stand-containers.d.ts.map +1 -0
- package/dist/project/stand-containers.js +27 -0
- package/dist/project/stand-containers.js.map +1 -0
- package/dist/project/working-tree.d.ts +62 -0
- package/dist/project/working-tree.d.ts.map +1 -0
- package/dist/project/working-tree.js +101 -0
- package/dist/project/working-tree.js.map +1 -0
- package/dist/release/artifact-store.d.ts +360 -0
- package/dist/release/artifact-store.d.ts.map +1 -0
- package/dist/release/artifact-store.js +1040 -0
- package/dist/release/artifact-store.js.map +1 -0
- package/dist/release/attest-on-node.d.ts +92 -0
- package/dist/release/attest-on-node.d.ts.map +1 -0
- package/dist/release/attest-on-node.js +207 -0
- package/dist/release/attest-on-node.js.map +1 -0
- package/dist/release/attest.d.ts +135 -0
- package/dist/release/attest.d.ts.map +1 -0
- package/dist/release/attest.js +199 -0
- package/dist/release/attest.js.map +1 -0
- package/dist/release/build-run.d.ts +151 -0
- package/dist/release/build-run.d.ts.map +1 -0
- package/dist/release/build-run.js +609 -0
- package/dist/release/build-run.js.map +1 -0
- package/dist/release/builder.d.ts +57 -0
- package/dist/release/builder.d.ts.map +1 -0
- package/dist/release/builder.js +120 -0
- package/dist/release/builder.js.map +1 -0
- package/dist/release/daemon-omni.d.ts +41 -0
- package/dist/release/daemon-omni.d.ts.map +1 -0
- package/dist/release/daemon-omni.js +59 -0
- package/dist/release/daemon-omni.js.map +1 -0
- package/dist/release/definition-inputs.d.ts +34 -0
- package/dist/release/definition-inputs.d.ts.map +1 -0
- package/dist/release/definition-inputs.js +90 -0
- package/dist/release/definition-inputs.js.map +1 -0
- package/dist/release/delivered.d.ts +53 -0
- package/dist/release/delivered.d.ts.map +1 -0
- package/dist/release/delivered.js +81 -0
- package/dist/release/delivered.js.map +1 -0
- package/dist/release/layout.d.ts +61 -0
- package/dist/release/layout.d.ts.map +1 -0
- package/dist/release/layout.js +114 -0
- package/dist/release/layout.js.map +1 -0
- package/dist/release/load.d.ts +44 -0
- package/dist/release/load.d.ts.map +1 -0
- package/dist/release/load.js +84 -0
- package/dist/release/load.js.map +1 -0
- package/dist/release/manifest.d.ts +299 -0
- package/dist/release/manifest.d.ts.map +1 -0
- package/dist/release/manifest.js +176 -0
- package/dist/release/manifest.js.map +1 -0
- package/dist/release/migrations.d.ts +32 -0
- package/dist/release/migrations.d.ts.map +1 -0
- package/dist/release/migrations.js +51 -0
- package/dist/release/migrations.js.map +1 -0
- package/dist/release/publish.d.ts +71 -0
- package/dist/release/publish.d.ts.map +1 -0
- package/dist/release/publish.js +102 -0
- package/dist/release/publish.js.map +1 -0
- package/dist/release/store.d.ts +174 -0
- package/dist/release/store.d.ts.map +1 -0
- package/dist/release/store.js +257 -0
- package/dist/release/store.js.map +1 -0
- package/dist/services/alert.rpc-service.d.ts +11 -15
- package/dist/services/alert.rpc-service.d.ts.map +1 -1
- package/dist/services/alert.rpc-service.js +29 -14
- package/dist/services/alert.rpc-service.js.map +1 -1
- package/dist/services/alert.service.d.ts +96 -59
- package/dist/services/alert.service.d.ts.map +1 -1
- package/dist/services/alert.service.js +455 -70
- package/dist/services/alert.service.js.map +1 -1
- package/dist/services/anonymous-input.d.ts +26 -0
- package/dist/services/anonymous-input.d.ts.map +1 -0
- package/dist/services/anonymous-input.js +44 -0
- package/dist/services/anonymous-input.js.map +1 -0
- package/dist/services/audit.rpc-service.d.ts +27 -0
- package/dist/services/audit.rpc-service.d.ts.map +1 -0
- package/dist/services/audit.rpc-service.js +57 -0
- package/dist/services/audit.rpc-service.js.map +1 -0
- package/dist/services/audit.service.d.ts +165 -0
- package/dist/services/audit.service.d.ts.map +1 -0
- package/dist/services/audit.service.js +242 -0
- package/dist/services/audit.service.js.map +1 -0
- package/dist/services/auth-context.d.ts +21 -1
- package/dist/services/auth-context.d.ts.map +1 -1
- package/dist/services/auth-context.js +51 -5
- package/dist/services/auth-context.js.map +1 -1
- package/dist/services/auth.rpc-service.d.ts +0 -1
- package/dist/services/auth.rpc-service.d.ts.map +1 -1
- package/dist/services/auth.rpc-service.js +23 -9
- package/dist/services/auth.rpc-service.js.map +1 -1
- package/dist/services/auth.service.d.ts +47 -33
- package/dist/services/auth.service.d.ts.map +1 -1
- package/dist/services/auth.service.js +163 -24
- package/dist/services/auth.service.js.map +1 -1
- package/dist/services/backup-pipeline.d.ts +97 -0
- package/dist/services/backup-pipeline.d.ts.map +1 -0
- package/dist/services/backup-pipeline.js +286 -0
- package/dist/services/backup-pipeline.js.map +1 -0
- package/dist/services/backup-schedule.d.ts +69 -0
- package/dist/services/backup-schedule.d.ts.map +1 -0
- package/dist/services/backup-schedule.js +132 -0
- package/dist/services/backup-schedule.js.map +1 -0
- package/dist/services/backup.rpc-service.d.ts +31 -2
- package/dist/services/backup.rpc-service.d.ts.map +1 -1
- package/dist/services/backup.rpc-service.js +53 -0
- package/dist/services/backup.rpc-service.js.map +1 -1
- package/dist/services/backup.service.d.ts +458 -14
- package/dist/services/backup.service.d.ts.map +1 -1
- package/dist/services/backup.service.js +1368 -170
- package/dist/services/backup.service.js.map +1 -1
- package/dist/services/bundle-build-worker.d.ts +19 -0
- package/dist/services/bundle-build-worker.d.ts.map +1 -0
- package/dist/services/bundle-build-worker.js +40 -0
- package/dist/services/bundle-build-worker.js.map +1 -0
- package/dist/services/bundle-builder.d.ts +487 -0
- package/dist/services/bundle-builder.d.ts.map +1 -0
- package/dist/services/bundle-builder.js +1083 -0
- package/dist/services/bundle-builder.js.map +1 -0
- package/dist/services/bundle-worker-protocol.d.ts +19 -0
- package/dist/services/bundle-worker-protocol.d.ts.map +1 -0
- package/dist/services/bundle-worker-protocol.js +32 -0
- package/dist/services/bundle-worker-protocol.js.map +1 -0
- package/dist/services/deploy.rpc-service.d.ts +4 -1
- package/dist/services/deploy.rpc-service.d.ts.map +1 -1
- package/dist/services/deploy.rpc-service.js +10 -0
- package/dist/services/deploy.rpc-service.js.map +1 -1
- package/dist/services/deploy.service.d.ts +14 -24
- package/dist/services/deploy.service.d.ts.map +1 -1
- package/dist/services/deploy.service.js +46 -5
- package/dist/services/deploy.service.js.map +1 -1
- package/dist/services/discovery.rpc-service.d.ts +2 -1
- package/dist/services/discovery.rpc-service.d.ts.map +1 -1
- package/dist/services/discovery.rpc-service.js.map +1 -1
- package/dist/services/discovery.service.d.ts +32 -28
- package/dist/services/discovery.service.d.ts.map +1 -1
- package/dist/services/discovery.service.js +108 -133
- package/dist/services/discovery.service.js.map +1 -1
- package/dist/services/event-broadcaster.rpc-service.d.ts +36 -23
- package/dist/services/event-broadcaster.rpc-service.d.ts.map +1 -1
- package/dist/services/event-broadcaster.rpc-service.js +70 -32
- package/dist/services/event-broadcaster.rpc-service.js.map +1 -1
- package/dist/services/event-broadcaster.service.d.ts.map +1 -1
- package/dist/services/event-broadcaster.service.js.map +1 -1
- package/dist/services/fleet.rpc-service.d.ts +2 -1
- package/dist/services/fleet.rpc-service.d.ts.map +1 -1
- package/dist/services/fleet.rpc-service.js +3 -1
- package/dist/services/fleet.rpc-service.js.map +1 -1
- package/dist/services/fleet.service.d.ts +32 -34
- package/dist/services/fleet.service.d.ts.map +1 -1
- package/dist/services/fleet.service.js +66 -10
- package/dist/services/fleet.service.js.map +1 -1
- package/dist/services/health-check.rpc-service.d.ts +2 -1
- package/dist/services/health-check.rpc-service.d.ts.map +1 -1
- package/dist/services/health-check.rpc-service.js.map +1 -1
- package/dist/services/health-check.service.d.ts +50 -36
- package/dist/services/health-check.service.d.ts.map +1 -1
- package/dist/services/health-check.service.js +179 -182
- package/dist/services/health-check.service.js.map +1 -1
- package/dist/services/infrastructure.rpc-service.d.ts +297 -2
- package/dist/services/infrastructure.rpc-service.d.ts.map +1 -1
- package/dist/services/infrastructure.rpc-service.js +523 -11
- package/dist/services/infrastructure.rpc-service.js.map +1 -1
- package/dist/services/kubernetes.rpc-service.d.ts +2 -1
- package/dist/services/kubernetes.rpc-service.d.ts.map +1 -1
- package/dist/services/kubernetes.rpc-service.js.map +1 -1
- package/dist/services/kubernetes.service.d.ts +5 -39
- package/dist/services/kubernetes.service.d.ts.map +1 -1
- package/dist/services/kubernetes.service.js +29 -5
- package/dist/services/kubernetes.service.js.map +1 -1
- package/dist/services/local-bundle.d.ts +151 -0
- package/dist/services/local-bundle.d.ts.map +1 -0
- package/dist/services/local-bundle.js +233 -0
- package/dist/services/local-bundle.js.map +1 -0
- package/dist/services/lockout.d.ts +28 -0
- package/dist/services/lockout.d.ts.map +1 -0
- package/dist/services/lockout.js +41 -0
- package/dist/services/lockout.js.map +1 -0
- package/dist/services/log-collector.rpc-service.d.ts +16 -0
- package/dist/services/log-collector.rpc-service.d.ts.map +1 -1
- package/dist/services/log-collector.rpc-service.js +16 -0
- package/dist/services/log-collector.rpc-service.js.map +1 -1
- package/dist/services/log-collector.service.d.ts +172 -54
- package/dist/services/log-collector.service.d.ts.map +1 -1
- package/dist/services/log-collector.service.js +492 -102
- package/dist/services/log-collector.service.js.map +1 -1
- package/dist/services/log-retention.d.ts +50 -0
- package/dist/services/log-retention.d.ts.map +1 -0
- package/dist/services/log-retention.js +52 -0
- package/dist/services/log-retention.js.map +1 -0
- package/dist/services/maintenance-lock.d.ts +162 -0
- package/dist/services/maintenance-lock.d.ts.map +1 -0
- package/dist/services/maintenance-lock.js +316 -0
- package/dist/services/maintenance-lock.js.map +1 -0
- package/dist/services/master-address.d.ts +107 -0
- package/dist/services/master-address.d.ts.map +1 -0
- package/dist/services/master-address.js +154 -0
- package/dist/services/master-address.js.map +1 -0
- package/dist/services/migration-output.d.ts +16 -0
- package/dist/services/migration-output.d.ts.map +1 -0
- package/dist/services/migration-output.js +22 -0
- package/dist/services/migration-output.js.map +1 -0
- package/dist/services/netron-telemetry-transport.d.ts.map +1 -1
- package/dist/services/netron-telemetry-transport.js +0 -4
- package/dist/services/netron-telemetry-transport.js.map +1 -1
- package/dist/services/node-deploy-lease.d.ts +154 -0
- package/dist/services/node-deploy-lease.d.ts.map +1 -0
- package/dist/services/node-deploy-lease.js +386 -0
- package/dist/services/node-deploy-lease.js.map +1 -0
- package/dist/services/node-health.repository.d.ts +63 -4
- package/dist/services/node-health.repository.d.ts.map +1 -1
- package/dist/services/node-health.repository.js +108 -67
- package/dist/services/node-health.repository.js.map +1 -1
- package/dist/services/node-manager.rpc-service.d.ts +355 -10
- package/dist/services/node-manager.rpc-service.d.ts.map +1 -1
- package/dist/services/node-manager.rpc-service.js +730 -34
- package/dist/services/node-manager.rpc-service.js.map +1 -1
- package/dist/services/node-manager.service.d.ts +267 -95
- package/dist/services/node-manager.service.d.ts.map +1 -1
- package/dist/services/node-manager.service.js +806 -104
- package/dist/services/node-manager.service.js.map +1 -1
- package/dist/services/node-upgrade.d.ts +129 -0
- package/dist/services/node-upgrade.d.ts.map +1 -0
- package/dist/services/node-upgrade.js +161 -0
- package/dist/services/node-upgrade.js.map +1 -0
- package/dist/services/node-upgrade.service.d.ts +232 -0
- package/dist/services/node-upgrade.service.d.ts.map +1 -0
- package/dist/services/node-upgrade.service.js +468 -0
- package/dist/services/node-upgrade.service.js.map +1 -0
- package/dist/services/pipeline.rpc-service.d.ts +2 -1
- package/dist/services/pipeline.rpc-service.d.ts.map +1 -1
- package/dist/services/pipeline.rpc-service.js.map +1 -1
- package/dist/services/pipeline.service.d.ts +30 -46
- package/dist/services/pipeline.service.d.ts.map +1 -1
- package/dist/services/pipeline.service.js +125 -11
- package/dist/services/pipeline.service.js.map +1 -1
- package/dist/services/project.rpc-service.d.ts +137 -5
- package/dist/services/project.rpc-service.d.ts.map +1 -1
- package/dist/services/project.rpc-service.js +258 -22
- package/dist/services/project.rpc-service.js.map +1 -1
- package/dist/services/project.service.d.ts +680 -9
- package/dist/services/project.service.d.ts.map +1 -1
- package/dist/services/project.service.js +2914 -293
- package/dist/services/project.service.js.map +1 -1
- package/dist/services/redeploy-decision.d.ts +176 -0
- package/dist/services/redeploy-decision.d.ts.map +1 -0
- package/dist/services/redeploy-decision.js +142 -0
- package/dist/services/redeploy-decision.js.map +1 -0
- package/dist/services/release.rpc-service.d.ts +159 -0
- package/dist/services/release.rpc-service.d.ts.map +1 -0
- package/dist/services/release.rpc-service.js +395 -0
- package/dist/services/release.rpc-service.js.map +1 -0
- package/dist/services/release.service.d.ts +114 -0
- package/dist/services/release.service.d.ts.map +1 -0
- package/dist/services/release.service.js +208 -0
- package/dist/services/release.service.js.map +1 -0
- package/dist/services/remote-deployer.service.d.ts +569 -8
- package/dist/services/remote-deployer.service.d.ts.map +1 -1
- package/dist/services/remote-deployer.service.js +1618 -169
- package/dist/services/remote-deployer.service.js.map +1 -1
- package/dist/services/remote-ops.service.d.ts +51 -9
- package/dist/services/remote-ops.service.d.ts.map +1 -1
- package/dist/services/remote-ops.service.js +235 -39
- package/dist/services/remote-ops.service.js.map +1 -1
- package/dist/services/remote-provisioner.d.ts +122 -0
- package/dist/services/remote-provisioner.d.ts.map +1 -0
- package/dist/services/remote-provisioner.js +258 -0
- package/dist/services/remote-provisioner.js.map +1 -0
- package/dist/services/secrets.rpc-service.d.ts +51 -2
- package/dist/services/secrets.rpc-service.d.ts.map +1 -1
- package/dist/services/secrets.rpc-service.js +81 -2
- package/dist/services/secrets.rpc-service.js.map +1 -1
- package/dist/services/secrets.service.d.ts +108 -9
- package/dist/services/secrets.service.d.ts.map +1 -1
- package/dist/services/secrets.service.js +313 -48
- package/dist/services/secrets.service.js.map +1 -1
- package/dist/services/slave-storage.service.d.ts +107 -0
- package/dist/services/slave-storage.service.d.ts.map +1 -1
- package/dist/services/slave-storage.service.js +171 -2
- package/dist/services/slave-storage.service.js.map +1 -1
- package/dist/services/static-carry-forward.d.ts +85 -0
- package/dist/services/static-carry-forward.d.ts.map +1 -0
- package/dist/services/static-carry-forward.js +159 -0
- package/dist/services/static-carry-forward.js.map +1 -0
- package/dist/services/sync-policy.d.ts +167 -0
- package/dist/services/sync-policy.d.ts.map +1 -0
- package/dist/services/sync-policy.js +161 -0
- package/dist/services/sync-policy.js.map +1 -0
- package/dist/services/sync-summary.d.ts +39 -0
- package/dist/services/sync-summary.d.ts.map +1 -0
- package/dist/services/sync-summary.js +62 -0
- package/dist/services/sync-summary.js.map +1 -0
- package/dist/services/sync.rpc-service.d.ts +19 -7
- package/dist/services/sync.rpc-service.d.ts.map +1 -1
- package/dist/services/sync.rpc-service.js +36 -5
- package/dist/services/sync.rpc-service.js.map +1 -1
- package/dist/services/sync.service.d.ts +350 -18
- package/dist/services/sync.service.d.ts.map +1 -1
- package/dist/services/sync.service.js +864 -116
- package/dist/services/sync.service.js.map +1 -1
- package/dist/services/system-info.rpc-service.d.ts +2 -1
- package/dist/services/system-info.rpc-service.d.ts.map +1 -1
- package/dist/services/system-info.rpc-service.js.map +1 -1
- package/dist/services/system-info.service.d.ts +56 -68
- package/dist/services/system-info.service.d.ts.map +1 -1
- package/dist/services/system-info.service.js +240 -127
- package/dist/services/system-info.service.js.map +1 -1
- package/dist/services/telemetry.rpc-service.d.ts.map +1 -1
- package/dist/services/telemetry.rpc-service.js +9 -3
- package/dist/services/telemetry.rpc-service.js.map +1 -1
- package/dist/services/telemetry.service.d.ts +4 -2
- package/dist/services/telemetry.service.d.ts.map +1 -1
- package/dist/services/telemetry.service.js +4 -2
- package/dist/services/telemetry.service.js.map +1 -1
- package/dist/services/trace-collector.rpc-service.d.ts +2 -1
- package/dist/services/trace-collector.rpc-service.d.ts.map +1 -1
- package/dist/services/trace-collector.rpc-service.js.map +1 -1
- package/dist/services/trace-collector.service.d.ts +2 -41
- package/dist/services/trace-collector.service.d.ts.map +1 -1
- package/dist/services/trace-collector.service.js +27 -7
- package/dist/services/trace-collector.service.js.map +1 -1
- package/dist/services/wait-for-postgres.d.ts +28 -0
- package/dist/services/wait-for-postgres.d.ts.map +1 -0
- package/dist/services/wait-for-postgres.js +77 -0
- package/dist/services/wait-for-postgres.js.map +1 -0
- package/dist/shared/alert-expression.d.ts +60 -0
- package/dist/shared/alert-expression.d.ts.map +1 -0
- package/dist/shared/alert-expression.js +124 -0
- package/dist/shared/alert-expression.js.map +1 -0
- package/dist/shared/audit-outcome.d.ts +39 -0
- package/dist/shared/audit-outcome.d.ts.map +1 -0
- package/dist/shared/audit-outcome.js +45 -0
- package/dist/shared/audit-outcome.js.map +1 -0
- package/dist/shared/build-freshness.d.ts +59 -0
- package/dist/shared/build-freshness.d.ts.map +1 -0
- package/dist/shared/build-freshness.js +113 -0
- package/dist/shared/build-freshness.js.map +1 -0
- package/dist/shared/build-stamp.d.ts +26 -0
- package/dist/shared/build-stamp.d.ts.map +1 -0
- package/dist/shared/build-stamp.js +36 -0
- package/dist/shared/build-stamp.js.map +1 -0
- package/dist/shared/describe-error.d.ts +22 -0
- package/dist/shared/describe-error.d.ts.map +1 -0
- package/dist/shared/describe-error.js +39 -0
- package/dist/shared/describe-error.js.map +1 -0
- package/dist/shared/dto/alerts.d.ts +101 -0
- package/dist/shared/dto/alerts.d.ts.map +1 -0
- package/dist/shared/dto/alerts.js +9 -0
- package/dist/shared/dto/alerts.js.map +1 -0
- package/dist/shared/dto/auth.d.ts +49 -0
- package/dist/shared/dto/auth.d.ts.map +1 -0
- package/dist/shared/dto/auth.js +14 -0
- package/dist/shared/dto/auth.js.map +1 -0
- package/dist/shared/dto/backups.d.ts +16 -0
- package/dist/shared/dto/backups.d.ts.map +1 -0
- package/dist/shared/dto/backups.js +9 -0
- package/dist/shared/dto/backups.js.map +1 -0
- package/dist/shared/dto/deploy.d.ts +28 -0
- package/dist/shared/dto/deploy.d.ts.map +1 -0
- package/dist/shared/dto/deploy.js +8 -0
- package/dist/shared/dto/deploy.js.map +1 -0
- package/dist/shared/dto/discovery.d.ts +24 -0
- package/dist/shared/dto/discovery.d.ts.map +1 -0
- package/dist/shared/dto/discovery.js +9 -0
- package/dist/shared/dto/discovery.js.map +1 -0
- package/dist/shared/dto/fleet.d.ts +35 -0
- package/dist/shared/dto/fleet.d.ts.map +1 -0
- package/dist/shared/dto/fleet.js +8 -0
- package/dist/shared/dto/fleet.js.map +1 -0
- package/dist/shared/dto/health.d.ts +26 -0
- package/dist/shared/dto/health.d.ts.map +1 -0
- package/dist/shared/dto/health.js +9 -0
- package/dist/shared/dto/health.js.map +1 -0
- package/dist/shared/dto/kubernetes.d.ts +44 -0
- package/dist/shared/dto/kubernetes.d.ts.map +1 -0
- package/dist/shared/dto/kubernetes.js +9 -0
- package/dist/shared/dto/kubernetes.js.map +1 -0
- package/dist/shared/dto/logs.d.ts +77 -0
- package/dist/shared/dto/logs.d.ts.map +1 -0
- package/dist/shared/dto/logs.js +10 -0
- package/dist/shared/dto/logs.js.map +1 -0
- package/dist/shared/dto/metrics.d.ts +65 -0
- package/dist/shared/dto/metrics.d.ts.map +1 -0
- package/dist/shared/dto/metrics.js +17 -0
- package/dist/shared/dto/metrics.js.map +1 -0
- package/dist/shared/dto/nodes.d.ts +347 -0
- package/dist/shared/dto/nodes.d.ts.map +1 -0
- package/dist/shared/dto/nodes.js +8 -0
- package/dist/shared/dto/nodes.js.map +1 -0
- package/dist/shared/dto/pipelines.d.ts +53 -0
- package/dist/shared/dto/pipelines.d.ts.map +1 -0
- package/dist/shared/dto/pipelines.js +9 -0
- package/dist/shared/dto/pipelines.js.map +1 -0
- package/dist/shared/dto/project.d.ts +143 -9
- package/dist/shared/dto/project.d.ts.map +1 -1
- package/dist/shared/dto/services.d.ts +855 -7
- package/dist/shared/dto/services.d.ts.map +1 -1
- package/dist/shared/dto/system-info.d.ts +93 -0
- package/dist/shared/dto/system-info.d.ts.map +1 -0
- package/dist/shared/dto/system-info.js +9 -0
- package/dist/shared/dto/system-info.js.map +1 -0
- package/dist/shared/dto/traces.d.ts +49 -0
- package/dist/shared/dto/traces.d.ts.map +1 -0
- package/dist/shared/dto/traces.js +9 -0
- package/dist/shared/dto/traces.js.map +1 -0
- package/dist/shared/env-config.d.ts +74 -0
- package/dist/shared/env-config.d.ts.map +1 -0
- package/dist/shared/env-config.js +99 -0
- package/dist/shared/env-config.js.map +1 -0
- package/dist/shared/events.d.ts +45 -39
- package/dist/shared/events.d.ts.map +1 -1
- package/dist/shared/events.js +6 -0
- package/dist/shared/events.js.map +1 -1
- package/dist/shared/format.d.ts +1 -1
- package/dist/shared/format.d.ts.map +1 -1
- package/dist/shared/format.js +4 -0
- package/dist/shared/format.js.map +1 -1
- package/dist/shared/local-infra-host.d.ts +28 -0
- package/dist/shared/local-infra-host.d.ts.map +1 -0
- package/dist/shared/local-infra-host.js +28 -0
- package/dist/shared/local-infra-host.js.map +1 -0
- package/dist/shared/node-check.d.ts +37 -0
- package/dist/shared/node-check.d.ts.map +1 -0
- package/dist/shared/node-check.js +41 -0
- package/dist/shared/node-check.js.map +1 -0
- package/dist/shared/paths.d.ts +29 -0
- package/dist/shared/paths.d.ts.map +1 -0
- package/dist/shared/paths.js +54 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/pnpm.d.ts +29 -0
- package/dist/shared/pnpm.d.ts.map +1 -0
- package/dist/shared/pnpm.js +57 -0
- package/dist/shared/pnpm.js.map +1 -0
- package/dist/shared/private-files.d.ts +29 -0
- package/dist/shared/private-files.d.ts.map +1 -0
- package/dist/shared/private-files.js +109 -0
- package/dist/shared/private-files.js.map +1 -0
- package/dist/shared/redact-env.d.ts +24 -0
- package/dist/shared/redact-env.d.ts.map +1 -0
- package/dist/shared/redact-env.js +36 -0
- package/dist/shared/redact-env.js.map +1 -0
- package/dist/shared/release-reading.d.ts +85 -0
- package/dist/shared/release-reading.d.ts.map +1 -0
- package/dist/shared/release-reading.js +81 -0
- package/dist/shared/release-reading.js.map +1 -0
- package/dist/shared/roles.d.ts +58 -3
- package/dist/shared/roles.d.ts.map +1 -1
- package/dist/shared/roles.js +55 -0
- package/dist/shared/roles.js.map +1 -1
- package/dist/shared/rpcauth.d.ts +13 -0
- package/dist/shared/rpcauth.d.ts.map +1 -0
- package/dist/shared/rpcauth.js +23 -0
- package/dist/shared/rpcauth.js.map +1 -0
- package/dist/shared/shell-escape.d.ts +9 -0
- package/dist/shared/shell-escape.d.ts.map +1 -0
- package/dist/shared/shell-escape.js +11 -0
- package/dist/shared/shell-escape.js.map +1 -0
- package/dist/shared/sync-reading.d.ts +45 -0
- package/dist/shared/sync-reading.d.ts.map +1 -0
- package/dist/shared/sync-reading.js +42 -0
- package/dist/shared/sync-reading.js.map +1 -0
- package/dist/shared/tokens.d.ts +19 -0
- package/dist/shared/tokens.d.ts.map +1 -1
- package/dist/shared/tokens.js +28 -0
- package/dist/shared/tokens.js.map +1 -1
- package/dist/webapp/webapp.service.d.ts +33 -2
- package/dist/webapp/webapp.service.d.ts.map +1 -1
- package/dist/webapp/webapp.service.js +143 -31
- package/dist/webapp/webapp.service.js.map +1 -1
- package/dist/workers/health-monitor-process.d.ts.map +1 -1
- package/dist/workers/health-monitor-process.js +10 -0
- package/dist/workers/health-monitor-process.js.map +1 -1
- package/dist/workers/health-monitor.service.d.ts +45 -3
- package/dist/workers/health-monitor.service.d.ts.map +1 -1
- package/dist/workers/health-monitor.service.js +156 -26
- package/dist/workers/health-monitor.service.js.map +1 -1
- package/dist/workers/system-worker-manager.d.ts +43 -0
- package/dist/workers/system-worker-manager.d.ts.map +1 -1
- package/dist/workers/system-worker-manager.js +150 -3
- package/dist/workers/system-worker-manager.js.map +1 -1
- package/dist/workers/types.d.ts +11 -2
- package/dist/workers/types.d.ts.map +1 -1
- package/package.json +64 -29
- package/webapp/dist/BUILD.json +5 -0
- package/webapp/dist/assets/FormControlLabel-BrH2diyl.js +1 -0
- package/webapp/dist/assets/Grid-BZ7xhoHQ.js +1 -0
- package/webapp/dist/assets/Switch-DGSAxQPM.js +1 -0
- package/webapp/dist/assets/alerts-sXqHJbsa.js +1 -0
- package/webapp/dist/assets/apps-B_Ah8Flh.js +1 -0
- package/webapp/dist/assets/audit-YXihwyui.js +1 -0
- package/webapp/dist/assets/client-CGbBvy9k.js +1 -0
- package/webapp/dist/assets/constants-veKGFkC6.js +1 -0
- package/webapp/dist/assets/containers-Di4NOTLf.js +2 -0
- package/webapp/dist/assets/daemon-memory-CCPZ4cV_.js +1 -0
- package/webapp/dist/assets/dashboard-Cfuf95N3.js +1 -0
- package/webapp/dist/assets/dashboard-builder-CowaYb-M.js +1 -0
- package/webapp/dist/assets/deploy-release-dialog-D6C-XcKY.js +1 -0
- package/webapp/dist/assets/deployments-ChTq5kCw.js +1 -0
- package/webapp/dist/assets/detail-BsGWv7Dj.js +1 -0
- package/webapp/dist/assets/detail-CyVf_cw5.js +2 -0
- package/webapp/dist/assets/detail-D027j6aO.js +8 -0
- package/webapp/dist/assets/formatters-BqjL73N3.js +1 -0
- package/webapp/dist/assets/index-B8AHIPc-.css +1 -0
- package/webapp/dist/assets/index-DiZbrwxT.js +414 -0
- package/webapp/dist/assets/logs-L2b8Oqmo.js +12 -0
- package/webapp/dist/assets/maplibre-gl-MlmG2kbn.js +801 -0
- package/webapp/dist/assets/metrics-CQgjz2Lz.js +2 -0
- package/webapp/dist/assets/nodes-Nwia2Ty6.js +7 -0
- package/webapp/dist/assets/pipelines-DCu_rXOr.js +4 -0
- package/webapp/dist/assets/projects-Bb4aV76h.js +1 -0
- package/webapp/dist/assets/react-CwJFpaho.js +1 -0
- package/webapp/dist/assets/react-apexcharts.esm-Dy0Fez42.js +1956 -0
- package/webapp/dist/assets/release-bits-Dj4EOhhb.js +1 -0
- package/webapp/dist/assets/release-build-panel-D6OCVlhT.js +1 -0
- package/webapp/dist/assets/releases-CnAt3yO6.js +2 -0
- package/webapp/dist/assets/rolldown-runtime-hePW80VL.js +1 -0
- package/webapp/dist/assets/settings-DU-mjiwb.js +1 -0
- package/webapp/dist/assets/settled-pair-C7SjZuns.js +1 -0
- package/webapp/dist/assets/sign-in-Bcvjrr4C.js +1 -0
- package/webapp/dist/assets/stacks-B-e8p50I.js +1 -0
- package/webapp/dist/assets/sync-reading-C3PnJ7AX.js +1 -0
- package/webapp/dist/assets/system-info-BtE3dgfw.js +1 -0
- package/webapp/dist/assets/topology-Cwch-uF1.js +12 -0
- package/webapp/dist/assets/topology-DLioOiRN.css +1 -0
- package/webapp/dist/assets/traces-BbShR-4P.js +1 -0
- package/webapp/dist/assets/use-stack-context-CZUgPq88.js +1 -0
- package/webapp/dist/index.html +6 -7
- package/dist/commands/dev.d.ts +0 -23
- package/dist/commands/dev.d.ts.map +0 -1
- package/dist/commands/dev.js +0 -76
- package/dist/commands/dev.js.map +0 -1
- package/dist/config/define-app.d.ts +0 -6
- package/dist/config/define-app.d.ts.map +0 -1
- package/dist/config/define-app.js +0 -38
- package/dist/config/define-app.js.map +0 -1
- package/dist/infrastructure/gateway-generator.d.ts +0 -63
- package/dist/infrastructure/gateway-generator.d.ts.map +0 -1
- package/dist/infrastructure/gateway-generator.js +0 -253
- package/dist/infrastructure/gateway-generator.js.map +0 -1
- package/dist/monitoring/health.service.d.ts +0 -14
- package/dist/monitoring/health.service.d.ts.map +0 -1
- package/dist/monitoring/health.service.js +0 -50
- package/dist/monitoring/health.service.js.map +0 -1
- package/dist/monitoring/metrics.service.d.ts +0 -14
- package/dist/monitoring/metrics.service.d.ts.map +0 -1
- package/dist/monitoring/metrics.service.js +0 -40
- package/dist/monitoring/metrics.service.js.map +0 -1
- package/dist/orchestrator/bootstrap-runner.d.ts +0 -15
- package/dist/orchestrator/bootstrap-runner.d.ts.map +0 -1
- package/dist/orchestrator/bootstrap-runner.js +0 -152
- package/dist/orchestrator/bootstrap-runner.js.map +0 -1
- package/dist/orchestrator/topology-proxy.d.ts +0 -65
- package/dist/orchestrator/topology-proxy.d.ts.map +0 -1
- package/dist/orchestrator/topology-proxy.js +0 -209
- package/dist/orchestrator/topology-proxy.js.map +0 -1
- package/dist/orchestrator/topology-relay.d.ts +0 -111
- package/dist/orchestrator/topology-relay.d.ts.map +0 -1
- package/dist/orchestrator/topology-relay.js +0 -255
- package/dist/orchestrator/topology-relay.js.map +0 -1
- package/dist/services/auth-middleware.d.ts +0 -47
- package/dist/services/auth-middleware.d.ts.map +0 -1
- package/dist/services/auth-middleware.js +0 -158
- package/dist/services/auth-middleware.js.map +0 -1
- package/dist/services/environment.rpc-service.d.ts +0 -22
- package/dist/services/environment.rpc-service.d.ts.map +0 -1
- package/dist/services/environment.rpc-service.js +0 -65
- package/dist/services/environment.rpc-service.js.map +0 -1
- package/dist/services/environment.service.d.ts +0 -87
- package/dist/services/environment.service.d.ts.map +0 -1
- package/dist/services/environment.service.js +0 -220
- package/dist/services/environment.service.js.map +0 -1
- package/dist/services/metrics-collector.service.d.ts +0 -103
- package/dist/services/metrics-collector.service.d.ts.map +0 -1
- package/dist/services/metrics-collector.service.js +0 -235
- package/dist/services/metrics-collector.service.js.map +0 -1
- package/dist/services/metrics.rpc-service.d.ts +0 -41
- package/dist/services/metrics.rpc-service.d.ts.map +0 -1
- package/dist/services/metrics.rpc-service.js +0 -90
- package/dist/services/metrics.rpc-service.js.map +0 -1
- package/dist/services/metrics.service.d.ts +0 -72
- package/dist/services/metrics.service.d.ts.map +0 -1
- package/dist/services/metrics.service.js +0 -185
- package/dist/services/metrics.service.js.map +0 -1
- package/dist/supervisor/omnitron-supervisor.d.ts +0 -21
- package/dist/supervisor/omnitron-supervisor.d.ts.map +0 -1
- package/dist/supervisor/omnitron-supervisor.js +0 -43
- package/dist/supervisor/omnitron-supervisor.js.map +0 -1
- package/dist/webapp/static-server.d.ts +0 -24
- package/dist/webapp/static-server.d.ts.map +0 -1
- package/dist/webapp/static-server.js +0 -152
- package/dist/webapp/static-server.js.map +0 -1
- package/webapp/dist/assets/Chip-BySxTjyN.js +0 -1
- package/webapp/dist/assets/DialogContentText-YQWiAVrU.js +0 -1
- package/webapp/dist/assets/DialogTitle-CDkgUIwp.js +0 -1
- package/webapp/dist/assets/FormControlLabel-Bu3CCsBM.js +0 -1
- package/webapp/dist/assets/Grid-BFY8hAzL.js +0 -1
- package/webapp/dist/assets/InputAdornment-_Dayk-3y.js +0 -3
- package/webapp/dist/assets/Skeleton-BGj6Sw8m.js +0 -37
- package/webapp/dist/assets/Switch-CA6682vv.js +0 -1
- package/webapp/dist/assets/TableContainer-4epq10KJ.js +0 -1
- package/webapp/dist/assets/TextField-C73KHMVN.js +0 -1
- package/webapp/dist/assets/alerts-JEQALS7Y.js +0 -1
- package/webapp/dist/assets/apps-DMPysEKB.js +0 -1
- package/webapp/dist/assets/client-DlGPter5.js +0 -110
- package/webapp/dist/assets/constants-DqFQJ27U.js +0 -1
- package/webapp/dist/assets/containers-CvkC7do8.js +0 -1
- package/webapp/dist/assets/dashboard-C0Fre9pC.js +0 -1
- package/webapp/dist/assets/dashboard-builder-DAghqdpE.js +0 -1
- package/webapp/dist/assets/deployments-Cg1lI1uT.js +0 -1
- package/webapp/dist/assets/detail-CyFtEson.js +0 -1
- package/webapp/dist/assets/detail-DqoSGKT1.js +0 -8
- package/webapp/dist/assets/formatters-J3Xqn6BQ.js +0 -1
- package/webapp/dist/assets/icons-ynIWYfBP.js +0 -3
- package/webapp/dist/assets/index-CHVXOsTS.css +0 -1
- package/webapp/dist/assets/index-vKrDmEA_.js +0 -259
- package/webapp/dist/assets/isMuiElement-BRL3YuCT.js +0 -1
- package/webapp/dist/assets/logs-IovIij1C.js +0 -11
- package/webapp/dist/assets/metrics-Dufwtwjq.js +0 -1
- package/webapp/dist/assets/nodes-Brvu8W3j.js +0 -7
- package/webapp/dist/assets/pipelines-t40X4MuC.js +0 -4
- package/webapp/dist/assets/prop-types-CvVtHxp4.js +0 -1
- package/webapp/dist/assets/react-apexcharts.esm-CLO6gala.js +0 -954
- package/webapp/dist/assets/settings-DTAIST3v.js +0 -1
- package/webapp/dist/assets/sign-in-DQEAkJum.js +0 -1
- package/webapp/dist/assets/stacks-BjM1f45N.js +0 -1
- package/webapp/dist/assets/system-info-BQSbSEK-.js +0 -1
- package/webapp/dist/assets/topology-6N8-Az_Z.js +0 -12
- package/webapp/dist/assets/topology-CHpVij2M.css +0 -1
- package/webapp/dist/assets/traces-DVXyb-4m.js +0 -1
- package/webapp/dist/assets/use-stack-context-CbE8fxBA.js +0 -1
- /package/webapp/dist/assets/{fs-uP8eiZAR.js → fs-Ccl3sAcp.js} +0 -0
|
@@ -14,42 +14,180 @@
|
|
|
14
14
|
*/
|
|
15
15
|
import fs from 'node:fs';
|
|
16
16
|
import path from 'node:path';
|
|
17
|
+
import { LOGGER_SERVICE_TOKEN } from '@omnitron-dev/titan/module/logger';
|
|
18
|
+
import { AUDIT_SERVICE_TOKEN, DAEMON_STATE_STORE_TOKEN, ORCHESTRATOR_TOKEN, SECRETS_SERVICE_TOKEN } from '../shared/tokens.js';
|
|
19
|
+
import { gatewayStaticLinkName } from './static-carry-forward.js';
|
|
17
20
|
import { EventEmitter } from '@omnitron-dev/eventemitter';
|
|
18
21
|
import { ProjectRegistry } from '../project/registry.js';
|
|
22
|
+
import { describeWorkingTrees, refusalForDirtyTree } from '../project/working-tree.js';
|
|
23
|
+
import { reportPhases } from '../project/deploy-phases.js';
|
|
24
|
+
import { effectiveAppName } from '../orchestrator/orchestrator.service.js';
|
|
25
|
+
import { summariseSync } from './sync-summary.js';
|
|
19
26
|
import { StackInfrastructureManager } from '../infrastructure/stack-infra-manager.js';
|
|
27
|
+
import { waitForPostgres } from './wait-for-postgres.js';
|
|
20
28
|
import { resolveStack, resolvedConfigToEnv } from '../project/config-resolver.js';
|
|
29
|
+
import { resolveStartupOrder } from '../orchestrator/dependency-resolver.js';
|
|
21
30
|
import { SlaveConnector } from '../cluster/slave-connector.js';
|
|
22
|
-
import {
|
|
31
|
+
import { staleBuild } from './bundle-builder.js';
|
|
32
|
+
import { NODE_STACK } from '../project/node-app-config.js';
|
|
33
|
+
import { overlayCredentials } from '../infrastructure/node-credentials.js';
|
|
34
|
+
import { readDeclaredConfig, withDeclaredConfig } from '../project/declared-config.js';
|
|
35
|
+
import { bindService } from '../infrastructure/service-binding.js';
|
|
36
|
+
import { RemoteDeployer, stackNodeToDeployTarget, withNodeCredentials, } from './remote-deployer.service.js';
|
|
37
|
+
import { withNodeLeases } from './node-deploy-lease.js';
|
|
38
|
+
import { DEFAULT_MAINTENANCE_TIMING, MaintenanceLock } from './maintenance-lock.js';
|
|
39
|
+
import { gatewayRedisDb } from '../infrastructure/gateway-redis-db.js';
|
|
40
|
+
/**
|
|
41
|
+
* What a deployment asks through the gateway before it lets the platform go:
|
|
42
|
+
* the portal's page and main's health. Each app already passed its own health
|
|
43
|
+
* check inside the deployment; these prove the path a person's request takes.
|
|
44
|
+
*/
|
|
45
|
+
const MAINTENANCE_PROBE_PATHS = ['/', '/api/main/health'];
|
|
46
|
+
import { migrationOutput } from './migration-output.js';
|
|
47
|
+
import { ExecutionService } from '../execution/execution.service.js';
|
|
48
|
+
/** Whether two paths name one directory — symlinks and `..` resolved. */
|
|
49
|
+
function samePath(a, b) {
|
|
50
|
+
const real = (p) => {
|
|
51
|
+
try {
|
|
52
|
+
return fs.realpathSync(path.resolve(p));
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return path.resolve(p);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
return real(a) === real(b);
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The infrastructure a stack actually gets: the ecosystem's, plus its own.
|
|
62
|
+
*
|
|
63
|
+
* Shallow for the top-level keys — a stack that declares `postgres` replaces
|
|
64
|
+
* the ecosystem's — and merged for `services`, because those are named
|
|
65
|
+
* things and a stack adding one must not remove the rest.
|
|
66
|
+
*/
|
|
67
|
+
export function mergeInfrastructure(ecosystemConfig, stackConfig) {
|
|
68
|
+
if (!stackConfig.infrastructure && !ecosystemConfig.infrastructure)
|
|
69
|
+
return undefined;
|
|
70
|
+
return {
|
|
71
|
+
...ecosystemConfig.infrastructure,
|
|
72
|
+
...stackConfig.infrastructure,
|
|
73
|
+
services: {
|
|
74
|
+
...ecosystemConfig.infrastructure?.services,
|
|
75
|
+
...stackConfig.infrastructure?.services,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
23
79
|
// =============================================================================
|
|
24
80
|
// ProjectService
|
|
25
81
|
// =============================================================================
|
|
82
|
+
/**
|
|
83
|
+
* The stack's frontend could not be put on a node, twice.
|
|
84
|
+
*
|
|
85
|
+
* Its own class because it ends the deployment where every other failure to
|
|
86
|
+
* bring up a node's infrastructure is logged and deployed past: nothing on
|
|
87
|
+
* the node has changed yet, and a deployment that continued would recreate
|
|
88
|
+
* the gateway without a web root — see `shipStackStatics`.
|
|
89
|
+
*/
|
|
90
|
+
export class FrontendNotDeliveredError extends Error {
|
|
91
|
+
constructor(host, cause) {
|
|
92
|
+
super(`The frontend could not be delivered to ${host} (two attempts): ${cause.slice(0, 400)}. ` +
|
|
93
|
+
'Nothing on the node was changed — its gateway keeps serving the frontend it had.');
|
|
94
|
+
this.name = 'FrontendNotDeliveredError';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
/** The services whose credentials a node generates, and a deployment reads back from it. */
|
|
98
|
+
const CREDENTIALED_SERVICES = ['postgres', 'redis', 'minio'];
|
|
26
99
|
export class ProjectService extends EventEmitter {
|
|
27
100
|
logger;
|
|
28
101
|
orchestrator;
|
|
29
|
-
fleetService;
|
|
30
|
-
syncService;
|
|
31
102
|
registry;
|
|
32
103
|
configRegistry = new Map();
|
|
33
104
|
stackStates = new Map();
|
|
34
105
|
infraManager;
|
|
35
106
|
slaveConnector = null;
|
|
36
107
|
deployer;
|
|
37
|
-
|
|
108
|
+
// Enabled-stacks reconciler state (see startEnabledStacksReconciler)
|
|
109
|
+
resumeTimer = null;
|
|
110
|
+
resumeBackoff = new Map();
|
|
111
|
+
dockerWasAvailable = null;
|
|
112
|
+
syncService;
|
|
113
|
+
secrets;
|
|
114
|
+
audit;
|
|
115
|
+
constructor(logger, orchestrator, daemonStateStore,
|
|
116
|
+
/**
|
|
117
|
+
* What a master has and a slave does not — by NAME. These were four more
|
|
118
|
+
* positional parameters, and the daemon's factory passed them through an
|
|
119
|
+
* `any`: `new ProjectService(logger, orchestrator, dStore, fleet,
|
|
120
|
+
* undefined, secrets, audit)`. A slot removed or reordered there moves
|
|
121
|
+
* every later argument into its neighbour's place and still compiles. The
|
|
122
|
+
* fourth had been read by nothing since the connector stopped writing the
|
|
123
|
+
* fleet table (f785d7f0); it went, and the rest became names.
|
|
124
|
+
*/
|
|
125
|
+
options = {}) {
|
|
38
126
|
super();
|
|
39
127
|
this.logger = logger;
|
|
40
128
|
this.orchestrator = orchestrator;
|
|
41
|
-
this.
|
|
42
|
-
this.
|
|
43
|
-
this.
|
|
129
|
+
this.syncService = options.syncService;
|
|
130
|
+
this.secrets = options.secrets;
|
|
131
|
+
this.audit = options.audit;
|
|
132
|
+
// T-7 — registry persistence routed through DaemonStateStore
|
|
133
|
+
// (SQLite, transactional, co-located with daemon-state-kv,
|
|
134
|
+
// pid-lock, etc.). The registry itself is unchanged in API;
|
|
135
|
+
// only its backing storage moved.
|
|
136
|
+
this.registry = new ProjectRegistry(daemonStateStore);
|
|
44
137
|
this.infraManager = new StackInfrastructureManager(logger);
|
|
45
|
-
|
|
138
|
+
// The deployer reaches nodes through the same SSH implementation as the
|
|
139
|
+
// health checks, which is the only one that can present a password or a
|
|
140
|
+
// key passphrase — see the note at the top of `remote-deployer.service.ts`.
|
|
141
|
+
this.deployer = new RemoteDeployer(logger, new ExecutionService(logger));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Adopt the daemon's mesh connector.
|
|
145
|
+
*
|
|
146
|
+
* There must be ONE of these per master. It holds a live connection — and,
|
|
147
|
+
* for a node whose daemon port is firewalled, an SSH tunnel — per node, so
|
|
148
|
+
* a second instance means a second connection to every node and two
|
|
149
|
+
* independent pulls of the same buffer, racing over which one acks an
|
|
150
|
+
* entry the other has not ingested yet.
|
|
151
|
+
*
|
|
152
|
+
* The daemon builds it, because the daemon is what knows how to reach a
|
|
153
|
+
* node: the SSH credentials live in the node registry's vault, not here.
|
|
154
|
+
* Absent one, the lazy fallback below keeps the behaviour this class had
|
|
155
|
+
* on its own, which is what the tests construct.
|
|
156
|
+
*/
|
|
157
|
+
setSlaveConnector(connector) {
|
|
158
|
+
this.slaveConnector = connector;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* How to reach a host the node registry knows about.
|
|
162
|
+
*
|
|
163
|
+
* A stack says which host; the registry says how to reach it — the user,
|
|
164
|
+
* the port, and the credential, which is in the daemon's vault because a
|
|
165
|
+
* config file in a repository is not where those go. Wired by the daemon,
|
|
166
|
+
* which is the side that has both.
|
|
167
|
+
*/
|
|
168
|
+
setNodeCredentialResolver(resolve) {
|
|
169
|
+
this.resolveNodeCredentials = resolve;
|
|
170
|
+
}
|
|
171
|
+
resolveNodeCredentials = null;
|
|
172
|
+
/** A deploy target for a stack node, with the registry filling the gaps. */
|
|
173
|
+
async targetForStackNode(node) {
|
|
174
|
+
const declared = stackNodeToDeployTarget(node);
|
|
175
|
+
if (!this.resolveNodeCredentials)
|
|
176
|
+
return declared;
|
|
177
|
+
const registered = await this.resolveNodeCredentials(node.host).catch(() => null);
|
|
178
|
+
return withNodeCredentials(declared, registered);
|
|
46
179
|
}
|
|
47
180
|
/**
|
|
48
|
-
* Get or create the SlaveConnector
|
|
181
|
+
* Get or create the SlaveConnector.
|
|
182
|
+
*
|
|
183
|
+
* Created lazily here only when the daemon has not handed one over —
|
|
184
|
+
* historically this was the sole owner, and it created one when the first
|
|
185
|
+
* remote or cluster stack started. That is also why a node with no stack
|
|
186
|
+
* on it was never connected to at all: nothing else ever asked.
|
|
49
187
|
*/
|
|
50
188
|
getSlaveConnector() {
|
|
51
189
|
if (!this.slaveConnector) {
|
|
52
|
-
this.slaveConnector = new SlaveConnector(this.logger, this.
|
|
190
|
+
this.slaveConnector = new SlaveConnector(this.logger, this.syncService ?? null);
|
|
53
191
|
}
|
|
54
192
|
return this.slaveConnector;
|
|
55
193
|
}
|
|
@@ -64,6 +202,19 @@ export class ProjectService extends EventEmitter {
|
|
|
64
202
|
const detected = this.registry.autoDetect(cwd);
|
|
65
203
|
return detected ? this.toProjectInfo(detected) : null;
|
|
66
204
|
}
|
|
205
|
+
/**
|
|
206
|
+
* What each app on each node is doing, for a console that polls.
|
|
207
|
+
*
|
|
208
|
+
* A stack deployment publishes progress as it goes — transferring,
|
|
209
|
+
* installing, the message naming the step that failed — and the console
|
|
210
|
+
* polls. Without somewhere for an event to wait, those two never met: a
|
|
211
|
+
* deployment that takes a quarter of an hour showed one row reading
|
|
212
|
+
* `deploying` from the first second to the last, and every event that said
|
|
213
|
+
* WHERE it was reached a handler that re-emitted it to nobody.
|
|
214
|
+
*/
|
|
215
|
+
getDeployProgress() {
|
|
216
|
+
return this.deployer.getProgress();
|
|
217
|
+
}
|
|
67
218
|
listProjects() {
|
|
68
219
|
return this.registry.list().map((p) => this.toProjectInfo(p));
|
|
69
220
|
}
|
|
@@ -73,12 +224,85 @@ export class ProjectService extends EventEmitter {
|
|
|
73
224
|
throw new Error(`Project '${name}' not found`);
|
|
74
225
|
return this.toProjectInfo(project);
|
|
75
226
|
}
|
|
76
|
-
addProject(name, projectPath) {
|
|
227
|
+
async addProject(name, projectPath) {
|
|
228
|
+
// Already registered AT THIS PATH: read it again rather than refuse.
|
|
229
|
+
//
|
|
230
|
+
// A deployment registers a node's project after writing its config, on
|
|
231
|
+
// every deployment. The node answered «Project 'daos' already registered»
|
|
232
|
+
// to every one after the first (four in two days, measured), the
|
|
233
|
+
// deployer's SSH step failed on the exit code, and the node never read
|
|
234
|
+
// the config it had just been given — a redeployment that changed an
|
|
235
|
+
// app's environment restarted the app on the old one (paysys on
|
|
236
|
+
// daos/test, pointed at the chain's previous address, 2026-09-23). A
|
|
237
|
+
// different path under the same name is still refused: that is another
|
|
238
|
+
// project, not the same one again.
|
|
239
|
+
const existing = this.registry.get(name);
|
|
240
|
+
if (existing && samePath(existing.path, projectPath)) {
|
|
241
|
+
const redefined = await this.rereadProject(name);
|
|
242
|
+
return { ...this.toProjectInfo(existing), reread: { redefined } };
|
|
243
|
+
}
|
|
77
244
|
const project = this.registry.add(name, projectPath);
|
|
78
245
|
this.logger.info({ project: name, path: projectPath }, 'Project registered');
|
|
79
246
|
this.emit('project:added', name, projectPath);
|
|
80
247
|
return this.toProjectInfo(project);
|
|
81
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Read a registered project's config again, and give every app its running
|
|
251
|
+
* local stacks supervise the definition that config now describes.
|
|
252
|
+
*
|
|
253
|
+
* The processes are not restarted here: each takes its new definition on
|
|
254
|
+
* its next start (`AppHandle.redefine`) — which a deployment asks for per
|
|
255
|
+
* app, only where something changed (`decideRedeploy`). Returns how many
|
|
256
|
+
* running apps were redefined.
|
|
257
|
+
*/
|
|
258
|
+
async rereadProject(name) {
|
|
259
|
+
this.configRegistry.delete(name);
|
|
260
|
+
const config = await this.loadProjectConfig(name);
|
|
261
|
+
const stacks = this.resolveStacks(config, name);
|
|
262
|
+
let redefined = 0;
|
|
263
|
+
for (const [key, state] of this.stackStates) {
|
|
264
|
+
if (!key.startsWith(`${name}/`) || state.status === 'stopped' || state.status === 'error')
|
|
265
|
+
continue;
|
|
266
|
+
const stackConfig = stacks[state.stack];
|
|
267
|
+
// A remote stack's apps run on its nodes; they are redefined there.
|
|
268
|
+
if (!stackConfig || stackConfig.type !== 'local')
|
|
269
|
+
continue;
|
|
270
|
+
const definitions = await this.loadAppDefinitions(name, stackConfig, config);
|
|
271
|
+
const entries = this.resolveStackApps(stackConfig, config);
|
|
272
|
+
const stackEnv = await this.stackEnvFor(name, state.stack, stackConfig, entries.map((e) => e.name));
|
|
273
|
+
const build = this.stackEntryBuilder(name, state.stack, stackConfig, config, definitions, stackEnv);
|
|
274
|
+
for (const entry of entries) {
|
|
275
|
+
const built = build(entry);
|
|
276
|
+
if (this.orchestrator.redefineApp(built.name, built))
|
|
277
|
+
redefined += 1;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
this.logger.info({ project: name, redefined }, 'Project read again — running apps take the new definition on their next start');
|
|
281
|
+
this.emit('project:updated', name, {});
|
|
282
|
+
return redefined;
|
|
283
|
+
}
|
|
284
|
+
updateProject(name, data) {
|
|
285
|
+
const project = this.registry.get(name);
|
|
286
|
+
if (!project)
|
|
287
|
+
throw new Error(`Project '${name}' not found`);
|
|
288
|
+
if (data.path) {
|
|
289
|
+
// Invalidate cached config so next access reloads from new path
|
|
290
|
+
this.configRegistry.delete(name);
|
|
291
|
+
const updated = this.registry.updatePath(name, data.path);
|
|
292
|
+
// Registry may have renamed the project (basename of new path)
|
|
293
|
+
if (updated.name !== name) {
|
|
294
|
+
this.configRegistry.delete(updated.name);
|
|
295
|
+
this.logger.info({ oldName: name, newName: updated.name, path: data.path }, 'Project renamed and path updated');
|
|
296
|
+
this.emit('project:renamed', name, updated.name);
|
|
297
|
+
}
|
|
298
|
+
else {
|
|
299
|
+
this.logger.info({ project: name, path: data.path }, 'Project path updated');
|
|
300
|
+
}
|
|
301
|
+
this.emit('project:updated', updated.name, data);
|
|
302
|
+
return this.toProjectInfo(updated);
|
|
303
|
+
}
|
|
304
|
+
return this.toProjectInfo(project);
|
|
305
|
+
}
|
|
82
306
|
removeProject(name) {
|
|
83
307
|
// Stop all running stacks for this project first
|
|
84
308
|
const runningStacks = this.getRunningStacks(name);
|
|
@@ -186,14 +410,25 @@ export class ProjectService extends EventEmitter {
|
|
|
186
410
|
const { isDockerAvailable, ensureImage } = await import('../infrastructure/container-runtime.js');
|
|
187
411
|
const dockerReady = await isDockerAvailable();
|
|
188
412
|
if (dockerReady) {
|
|
189
|
-
// Pull images in background (don't block stack creation)
|
|
413
|
+
// Pull images in background (don't block stack creation).
|
|
414
|
+
//
|
|
415
|
+
// Asked of the presets, not spelled again here. These three were
|
|
416
|
+
// written out a second time, and the copies had already drifted:
|
|
417
|
+
// this one named `minio/minio:latest`, which Docker Hub answers
|
|
418
|
+
// `pull access denied` for, while the preset named the image the
|
|
419
|
+
// resolver actually uses. A prefetch that pulls something else is
|
|
420
|
+
// worse than none — it reports success and warms nothing.
|
|
421
|
+
const { createDefaultRegistry } = await import('../infrastructure/presets/index.js');
|
|
422
|
+
const presets = createDefaultRegistry();
|
|
190
423
|
const images = new Set();
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
424
|
+
for (const name of ['postgres', 'redis', 'minio']) {
|
|
425
|
+
if (!stackConfig.infrastructure[name])
|
|
426
|
+
continue;
|
|
427
|
+
const declared = stackConfig.infrastructure[name].image;
|
|
428
|
+
const image = declared ?? presets.get(name)?.defaultImage;
|
|
429
|
+
if (image)
|
|
430
|
+
images.add(image);
|
|
431
|
+
}
|
|
197
432
|
for (const img of images) {
|
|
198
433
|
ensureImage(img).catch((err) => this.logger.warn({ image: img, error: err.message }, 'Failed to pull image'));
|
|
199
434
|
}
|
|
@@ -282,17 +517,221 @@ export class ProjectService extends EventEmitter {
|
|
|
282
517
|
throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
|
|
283
518
|
return this.toStackInfo(projectName, stackName, stackConfig);
|
|
284
519
|
}
|
|
285
|
-
|
|
520
|
+
/**
|
|
521
|
+
* Stack starts in flight, by `project/stack`.
|
|
522
|
+
*
|
|
523
|
+
* `stackStates` records `starting`, and it is written AFTER the config is
|
|
524
|
+
* loaded and the short-circuits above have run — so two callers that arrive
|
|
525
|
+
* together both see no state and both proceed. Measured on the test stack:
|
|
526
|
+
* the daemon's own `startProjectStacks` on boot and an operator's
|
|
527
|
+
* `omnitron stack start daos test` landed within twenty seconds of each
|
|
528
|
+
* other, and ran the whole remote deployment TWICE, concurrently.
|
|
529
|
+
*
|
|
530
|
+
* That is not merely wasteful. The artifact build rebuilds shared packages
|
|
531
|
+
* whose `build` script is `rm -rf dist && tsc`, so one pass emptied
|
|
532
|
+
* `@omnitron-dev/titan-database/dist` while the other compiled `@daos/main`
|
|
533
|
+
* against it:
|
|
534
|
+
*
|
|
535
|
+
* src/modules/rbac/rls-schema.ts(53,41): error TS2307: Cannot find
|
|
536
|
+
* module '@omnitron-dev/titan-database/rls'
|
|
537
|
+
*
|
|
538
|
+
* — an error about a subpath that exists, reported against a build that is
|
|
539
|
+
* correct, which sends the reader to the wrong package entirely. Building
|
|
540
|
+
* the same app by hand a minute later exits zero.
|
|
541
|
+
*
|
|
542
|
+
* A second caller gets the FIRST call's promise rather than a refusal:
|
|
543
|
+
* asking for a stack that is already being started should end when it has
|
|
544
|
+
* been started, which is what the caller meant.
|
|
545
|
+
*/
|
|
546
|
+
startsInFlight = new Map();
|
|
547
|
+
async startStack(projectName, stackName, opts) {
|
|
548
|
+
const inFlightKey = `${projectName}/${stackName}`;
|
|
549
|
+
const running = this.startsInFlight.get(inFlightKey);
|
|
550
|
+
if (running) {
|
|
551
|
+
this.logger.info({ project: projectName, stack: stackName }, 'This stack is already being started — joining the run in progress');
|
|
552
|
+
return running;
|
|
553
|
+
}
|
|
554
|
+
const started = this.startStackOnce(projectName, stackName, opts?.source ?? 'unknown', opts?.allowDirty === true, opts?.release, opts?.reinstall === true).finally(() => {
|
|
555
|
+
this.startsInFlight.delete(inFlightKey);
|
|
556
|
+
});
|
|
557
|
+
this.startsInFlight.set(inFlightKey, started);
|
|
558
|
+
return started;
|
|
559
|
+
}
|
|
560
|
+
/**
|
|
561
|
+
* One start of a stack, recorded however it ends.
|
|
562
|
+
*
|
|
563
|
+
* The row was written on the success path only — inside the `try` at the
|
|
564
|
+
* end of `runStackStart` — so a start that was refused or failed left
|
|
565
|
+
* nothing in the trail. Measured on the master 2026-09-23: 178 rows since
|
|
566
|
+
* the trail began (2026-09-20 15:37Z), not one of them a failure, against
|
|
567
|
+
* the log's eight operator starts that threw — five dirty-tree refusals
|
|
568
|
+
* (09-22 04:37:24 … 08:27:49), a release refused for a failed gate
|
|
569
|
+
* (15:12:41), «daos/test takes releases only» (15:32:43), and the
|
|
570
|
+
* deployment of 21:19:02 that failed on 6 of 6 apps, followed at 21:20:37
|
|
571
|
+
* by the redeploy the trail DID record, alone, as if it had been the only
|
|
572
|
+
* attempt.
|
|
573
|
+
*
|
|
574
|
+
* Written as `stack.start.failed`, not as `stack.start` with a flag. Two
|
|
575
|
+
* readers take every `stack.start` row as a deployment that happened —
|
|
576
|
+
* `ReleaseRpcService.deployments` (the console's «last deployed») and
|
|
577
|
+
* `lastDeployedAt` (the attestation freshness refusal) — and a refused
|
|
578
|
+
* deployment is not one; filed under the same action it would become the
|
|
579
|
+
* stack's «last deployment». Same shape as `node.upgrade.failed`.
|
|
580
|
+
*
|
|
581
|
+
* A remote stack that a restart re-attaches is left out both ways: it
|
|
582
|
+
* deploys nothing, and the reconciler re-asks a node that does not answer
|
|
583
|
+
* every few minutes — rows about looking, not about changing anything.
|
|
584
|
+
*/
|
|
585
|
+
async startStackOnce(projectName, stackName, source, allowDirty, releaseId, reinstall = false) {
|
|
586
|
+
const known = { attach: false };
|
|
587
|
+
try {
|
|
588
|
+
return await this.runStackStart(projectName, stackName, source, allowDirty, releaseId, known, reinstall);
|
|
589
|
+
}
|
|
590
|
+
catch (err) {
|
|
591
|
+
if (!known.attach) {
|
|
592
|
+
await this.audit?.record({
|
|
593
|
+
action: 'stack.start.failed',
|
|
594
|
+
resourceType: 'stack',
|
|
595
|
+
resourceId: `${projectName}/${stackName}`,
|
|
596
|
+
outcome: 'failed',
|
|
597
|
+
error: err,
|
|
598
|
+
details: {
|
|
599
|
+
source,
|
|
600
|
+
...(known.type ? { type: known.type } : {}),
|
|
601
|
+
...(releaseId ? { release: releaseId } : {}),
|
|
602
|
+
...(known.commit !== undefined ? { commit: known.commit } : {}),
|
|
603
|
+
},
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
throw err;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
async runStackStart(projectName, stackName, source, allowDirty, releaseId, known, reinstall = false) {
|
|
286
610
|
const config = await this.loadProjectConfig(projectName);
|
|
287
611
|
const stacks = this.resolveStacks(config, projectName);
|
|
288
612
|
const stackConfig = stacks[stackName];
|
|
289
613
|
if (!stackConfig)
|
|
290
614
|
throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
|
|
615
|
+
known.type = stackConfig.type;
|
|
616
|
+
// A restart of this daemon re-attaches a remote stack; it never deploys
|
|
617
|
+
// one. See `attachRemoteStack`.
|
|
618
|
+
if (stackConfig.type !== 'local' && (source === 'boot' || source === 'auto-resume')) {
|
|
619
|
+
known.attach = true;
|
|
620
|
+
return this.attachRemoteStack(projectName, stackName, stackConfig, source);
|
|
621
|
+
}
|
|
622
|
+
// A release, when one is named — and a stack that takes nothing else.
|
|
623
|
+
let release = null;
|
|
624
|
+
if (releaseId) {
|
|
625
|
+
release = await this.admitRelease(projectName, stackName, stackConfig, config, releaseId);
|
|
626
|
+
}
|
|
627
|
+
else if (stackConfig.release?.mode === 'required') {
|
|
628
|
+
throw new Error(`${projectName}/${stackName} takes releases only: \`omnitron release build ${projectName} --for ${stackName}\`, ` +
|
|
629
|
+
`then \`omnitron stack start ${projectName} ${stackName} --release <id>\`.`);
|
|
630
|
+
}
|
|
631
|
+
// A remote deployment ships what is on DISK, so before anything is built
|
|
632
|
+
// the disk has to be a commit.
|
|
633
|
+
//
|
|
634
|
+
// Measured over one night with three sessions in one checkout: an
|
|
635
|
+
// automatic resume compiled somebody's half-finished edit, `main` failed
|
|
636
|
+
// to build, and five artifacts of six went out under `Stack started`.
|
|
637
|
+
// The hazard is not the operator typing the command — it is that every
|
|
638
|
+
// master restart runs this path, and a restart happens for reasons
|
|
639
|
+
// nobody chose: a crash, a laptop asleep, launchd. Four of them did so
|
|
640
|
+
// that day.
|
|
641
|
+
//
|
|
642
|
+
// Local stacks are exempt on purpose. Compiling the working tree is what
|
|
643
|
+
// a development stand is FOR — hot reload is that feature — and a rule
|
|
644
|
+
// that refused there would be turned off within the hour.
|
|
645
|
+
const projectForTree = this.registry.get(projectName);
|
|
646
|
+
// Not only the project's own repository. The static bundle INLINES the
|
|
647
|
+
// sources of the packages it links — the portal's vite alias resolves
|
|
648
|
+
// prism and netron-browser to their `src` in another checkout — so an
|
|
649
|
+
// uncommitted change there rides to the node inside `dist` while this
|
|
650
|
+
// tree is spotless. The backends differ: their vendored
|
|
651
|
+
// `@omnitron-dev/*` are replaced on the node by symlinks to the node
|
|
652
|
+
// daemon's copy, so what was packed for them never runs.
|
|
653
|
+
//
|
|
654
|
+
// The list is derived from the mechanism, not written down:
|
|
655
|
+
// `linkedSourceDirs` is the same function the staleness check uses to
|
|
656
|
+
// decide what the bundle is built from.
|
|
657
|
+
const treeInputs = [];
|
|
658
|
+
if (projectForTree) {
|
|
659
|
+
treeInputs.push(projectForTree.path);
|
|
660
|
+
const staticAbs = this.staticDirOf(stackConfig.infrastructure, projectForTree.path);
|
|
661
|
+
if (staticAbs) {
|
|
662
|
+
const { linkedSourceDirs } = await import('./bundle-builder.js');
|
|
663
|
+
const pkgDir = staticAbs.replace(/\/[^/]+\/?$/, '');
|
|
664
|
+
treeInputs.push(...linkedSourceDirs(pkgDir));
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
const trees = projectForTree
|
|
668
|
+
? await describeWorkingTrees(treeInputs)
|
|
669
|
+
: [
|
|
670
|
+
{
|
|
671
|
+
root: projectName,
|
|
672
|
+
tree: { checked: false, why: `project '${projectName}' is not in the registry`, dirty: [] },
|
|
673
|
+
},
|
|
674
|
+
];
|
|
675
|
+
const tree = trees[0]?.tree ?? { checked: false, why: 'no tree to read', dirty: [] };
|
|
676
|
+
known.commit = tree.checked ? (tree.head ?? null) : null;
|
|
677
|
+
// With a release the disk has already been held to the release's commit
|
|
678
|
+
// (`admitRelease`) — stricter than HEAD, and the artifacts are not built
|
|
679
|
+
// from it at all.
|
|
680
|
+
if (stackConfig.type === 'remote' && !release) {
|
|
681
|
+
if (!allowDirty) {
|
|
682
|
+
for (const { root, tree: t } of trees) {
|
|
683
|
+
const refusal = refusalForDirtyTree(t, `${projectName}/${stackName}`);
|
|
684
|
+
if (refusal)
|
|
685
|
+
throw new Error(trees.length > 1 ? `${refusal}\n(in ${root})` : refusal);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
else if (tree.checked && tree.dirty.length > 0) {
|
|
689
|
+
this.logger.warn({ project: projectName, stack: stackName, dirty: tree.dirty.length, head: tree.head }, 'Deploying a working tree that is not its commit — asked for with --allow-dirty');
|
|
690
|
+
}
|
|
691
|
+
if (!tree.checked) {
|
|
692
|
+
// Not a pass. The deployment goes on, because refusing every project
|
|
693
|
+
// that is not a git checkout would be a rule about git rather than
|
|
694
|
+
// about deployments — but the log says the check did not run.
|
|
695
|
+
this.logger.warn({ project: projectName, stack: stackName, why: tree.why }, 'Could not tell whether the working tree matches its commit — shipping what is on disk');
|
|
696
|
+
}
|
|
697
|
+
}
|
|
291
698
|
const stateKey = `${projectName}/${stackName}`;
|
|
292
699
|
const existing = this.stackStates.get(stateKey);
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
700
|
+
// Not for a release: putting a release onto a running stack is the point.
|
|
701
|
+
if (existing?.status === 'running' && !release) {
|
|
702
|
+
// `running` is a claim about the past, and nothing revises it when the
|
|
703
|
+
// apps underneath fall over — so this short-circuit used to hand the
|
|
704
|
+
// operator a stack it had not touched. `toStackInfo` reads live
|
|
705
|
+
// orchestrator statuses, which means the very response we returned
|
|
706
|
+
// already said how many apps were up; nothing compared the two halves.
|
|
707
|
+
// Observed on the dev stand with all six apps dead: `stack start`
|
|
708
|
+
// printed "Stack acme/dev started — 0/6 apps online", exited 0, and
|
|
709
|
+
// started nothing. Ask what is actually online before believing it.
|
|
710
|
+
//
|
|
711
|
+
// And ask the machine that runs them. `toStackInfo` reads THIS
|
|
712
|
+
// daemon's orchestrator, which lists none of a remote stack's
|
|
713
|
+
// applications because they run on the node — so `down` was all six,
|
|
714
|
+
// every time, and this short-circuit could never fire for a remote
|
|
715
|
+
// stack. Measured 2026-09-22: six occurrences, every one of them
|
|
716
|
+
// `down: [main, storage, priceverse, paysys, messaging, geo]` against
|
|
717
|
+
// a node where all six were online with pids and uptimes to show for
|
|
718
|
+
// it. Each fell through to a full deployment of a stack that needed
|
|
719
|
+
// nothing — which is most of the «four master restarts, four full
|
|
720
|
+
// redeploys, twenty-four application restarts» that `decideRedeploy`
|
|
721
|
+
// was written to stop. Same reader, same mistake, third caller: see
|
|
722
|
+
// `getStackStatus` and `withRemoteAppStatuses`, which is what this
|
|
723
|
+
// now goes through. For a local stack it changes nothing — the
|
|
724
|
+
// wrapper returns the info untouched.
|
|
725
|
+
const current = await this.withRemoteAppStatuses(projectName, this.toStackInfo(projectName, stackName, stackConfig));
|
|
726
|
+
const down = current.apps.filter((a) => a.status !== 'online');
|
|
727
|
+
if (down.length === 0) {
|
|
728
|
+
this.logger.warn({ project: projectName, stack: stackName }, 'Stack already running');
|
|
729
|
+
return current;
|
|
730
|
+
}
|
|
731
|
+
this.logger.warn({ project: projectName, stack: stackName, down: down.map((a) => a.name) }, 'Stack is marked running but some of its apps are not — starting them');
|
|
732
|
+
// Fall through. Starting is idempotent for the apps still up:
|
|
733
|
+
// `startAppInternal` returns the existing handle for an online app.
|
|
734
|
+
this.stackStates.delete(stateKey);
|
|
296
735
|
}
|
|
297
736
|
// Reset stale "starting" state (daemon may have restarted mid-launch)
|
|
298
737
|
if (existing?.status === 'starting') {
|
|
@@ -310,12 +749,13 @@ export class ProjectService extends EventEmitter {
|
|
|
310
749
|
};
|
|
311
750
|
this.stackStates.set(stateKey, state);
|
|
312
751
|
this.emit('stack:starting', projectName, stackName, stackConfig.type);
|
|
752
|
+
let reach = null;
|
|
313
753
|
try {
|
|
314
754
|
if (stackConfig.type === 'local') {
|
|
315
755
|
await this.startLocalStack(projectName, stackName, stackConfig, config);
|
|
316
756
|
}
|
|
317
757
|
else if (stackConfig.type === 'remote') {
|
|
318
|
-
await this.startRemoteStack(projectName, stackName, stackConfig, config);
|
|
758
|
+
reach = await this.startRemoteStack(projectName, stackName, stackConfig, config, release, reinstall);
|
|
319
759
|
}
|
|
320
760
|
else if (stackConfig.type === 'cluster') {
|
|
321
761
|
await this.startClusterStack(projectName, stackName, stackConfig, config);
|
|
@@ -325,8 +765,76 @@ export class ProjectService extends EventEmitter {
|
|
|
325
765
|
// Update registry with enabled stack
|
|
326
766
|
this.updateEnabledStacks(projectName, stackName, true);
|
|
327
767
|
this.emit('stack:started', projectName, stackName, stackConfig.type);
|
|
328
|
-
this.logger.info({ project: projectName, stack: stackName }, 'Stack started');
|
|
329
|
-
|
|
768
|
+
this.logger.info({ project: projectName, stack: stackName, source }, 'Stack started');
|
|
769
|
+
const info = this.toStackInfo(projectName, stackName, stackConfig);
|
|
770
|
+
// What did not come up, said in the row. A boot at 12:14 UTC on
|
|
771
|
+
// 2026-09-23 started daos/dev with paysys and messaging errored and
|
|
772
|
+
// recorded `outcome: ok, apps: 6`: the stack «started», two of its six
|
|
773
|
+
// apps never did. For a local stack the apps are this daemon's; for a
|
|
774
|
+
// remote one, what did not happen is a node that was skipped.
|
|
775
|
+
const notUp = stackConfig.type === 'local'
|
|
776
|
+
? info.apps.filter((a) => a.status !== 'online').map((a) => `${a.name} (${a.status})`)
|
|
777
|
+
: [
|
|
778
|
+
...(reach?.skipped ?? []).map((n) => `node ${typeof n === 'string' ? n : JSON.stringify(n)}`),
|
|
779
|
+
...(reach?.notReady ?? []).map((n) => `infrastructure on ${n}`),
|
|
780
|
+
];
|
|
781
|
+
// Recorded for every caller, and saying WHICH one. `source: 'unknown'`
|
|
782
|
+
// is deliberate rather than a default of `'operator'`: a caller that
|
|
783
|
+
// has not been taught to identify itself should be visible in the
|
|
784
|
+
// trail, not quietly attributed to a person.
|
|
785
|
+
//
|
|
786
|
+
// The online count the RPC layer used to attach is gone on purpose —
|
|
787
|
+
// it was measured AFTER this returned, by re-asking the node, so it
|
|
788
|
+
// described a later moment than the row it sat in.
|
|
789
|
+
await this.audit?.record({
|
|
790
|
+
action: 'stack.start',
|
|
791
|
+
resourceType: 'stack',
|
|
792
|
+
resourceId: `${projectName}/${stackName}`,
|
|
793
|
+
// Said, now that the other ending is recorded too (`startStackOnce`).
|
|
794
|
+
outcome: notUp.length > 0 ? 'partial' : 'ok',
|
|
795
|
+
// The commit, so the trail answers what went out and not only when.
|
|
796
|
+
// A node's artifacts carry no revision (every app is version `0.0.1`
|
|
797
|
+
// forever, and `BUNDLE.json` is excluded from the checksum and read
|
|
798
|
+
// by nobody), so this row is the only place the two can be joined.
|
|
799
|
+
details: {
|
|
800
|
+
type: stackConfig.type,
|
|
801
|
+
// How many the stack DECLARES. What a remote deployment reached is
|
|
802
|
+
// a different number and now sits beside it: a row saying `apps: 6`
|
|
803
|
+
// described a deployment whose only node could not be provisioned,
|
|
804
|
+
// and nothing in it said so.
|
|
805
|
+
apps: info.apps.length,
|
|
806
|
+
...(notUp.length > 0 ? { notUp: notUp.join(', ') } : {}),
|
|
807
|
+
source,
|
|
808
|
+
...(reach ? { nodes: reach.nodes, reached: reach.reached } : {}),
|
|
809
|
+
...(reach && reach.skipped.length > 0 ? { skipped: reach.skipped } : {}),
|
|
810
|
+
...(reach && (reach.notReady?.length ?? 0) > 0 ? { notReady: reach.notReady } : {}),
|
|
811
|
+
...(tree.checked ? { commit: tree.head, dirty: tree.dirty.length } : { commit: null }),
|
|
812
|
+
// Every tree the artifacts were built from, not only the project's:
|
|
813
|
+
// the omni checkout's packages are vendored into them, and the
|
|
814
|
+
// project's commit alone could not say which omni went out.
|
|
815
|
+
// The release, when that is what went out: its id and the two
|
|
816
|
+
// commits its artifacts were built from in clean clones. Flat,
|
|
817
|
+
// because `scrubDetails` keeps facts and replaces a nested object
|
|
818
|
+
// with `[object]` — measured on the first release deployed, whose
|
|
819
|
+
// row said exactly that. Not the same as `trees` below, which
|
|
820
|
+
// describes THIS master's disk: the artifacts came from the
|
|
821
|
+
// release's omni, and the master's may have moved since.
|
|
822
|
+
...(release
|
|
823
|
+
? {
|
|
824
|
+
release: release.id,
|
|
825
|
+
releaseProjectCommit: release.manifest.project.commit.slice(0, 8),
|
|
826
|
+
releaseOmniCommit: release.manifest.omni.commit.slice(0, 8),
|
|
827
|
+
}
|
|
828
|
+
: {}),
|
|
829
|
+
trees: trees.map(({ root, tree: t }) => ({
|
|
830
|
+
repo: path.basename(root),
|
|
831
|
+
...(t.checked ? { commit: t.head ?? null, dirty: t.dirty.length } : { commit: null, why: t.why ?? null }),
|
|
832
|
+
})),
|
|
833
|
+
},
|
|
834
|
+
});
|
|
835
|
+
// For a remote stack, what did not come up is not in its app list — a
|
|
836
|
+
// node skipped, infrastructure not up — so the answer says it too.
|
|
837
|
+
return stackConfig.type !== 'local' && notUp.length > 0 ? { ...info, notUp } : info;
|
|
330
838
|
}
|
|
331
839
|
catch (err) {
|
|
332
840
|
state.status = 'error';
|
|
@@ -380,7 +888,24 @@ export class ProjectService extends EventEmitter {
|
|
|
380
888
|
throw err;
|
|
381
889
|
}
|
|
382
890
|
}
|
|
383
|
-
|
|
891
|
+
/**
|
|
892
|
+
* What is running in this stack, asked of whoever is running it.
|
|
893
|
+
*
|
|
894
|
+
* For a remote stack that is the node. This counted `this.orchestrator
|
|
895
|
+
* .list()` — the MASTER's process table — which lists none of a remote
|
|
896
|
+
* stack's applications, so `stack runtime daos test` answered
|
|
897
|
+
* `totalApps: 0, onlineApps: 0, connectedNodes: 0` while `stack status`
|
|
898
|
+
* listed six apps online from the same node, three times in three seconds
|
|
899
|
+
* (measured 2026-09-22). The JSON surface is the one a script and a
|
|
900
|
+
* dashboard read.
|
|
901
|
+
*
|
|
902
|
+
* The sibling readers were taught this already — `listStacks`, `getStack`
|
|
903
|
+
* and `startStack` all pass through `withRemoteAppStatuses`, and the
|
|
904
|
+
* comment beside one of them names the same symptom: «only 0/6 apps came
|
|
905
|
+
* online» about six that were running. The fix reached three callers of
|
|
906
|
+
* four.
|
|
907
|
+
*/
|
|
908
|
+
async getStackStatus(projectName, stackName) {
|
|
384
909
|
const stateKey = `${projectName}/${stackName}`;
|
|
385
910
|
const state = this.stackStates.get(stateKey);
|
|
386
911
|
const config = this.getLoadedConfig(projectName);
|
|
@@ -389,148 +914,955 @@ export class ProjectService extends EventEmitter {
|
|
|
389
914
|
if (!stackConfig)
|
|
390
915
|
throw new Error(`Stack '${stackName}' not found`);
|
|
391
916
|
const prefix = `${projectName}/${stackName}/`;
|
|
392
|
-
|
|
917
|
+
// Match by namespaced prefix OR by bare-name when the bare entry's
|
|
918
|
+
// effective name is part of this stack's configured app set. See
|
|
919
|
+
// toStackInfo for the full rationale; we keep the same rule here so
|
|
920
|
+
// CLI `omnitron list` and the webapp's stack status agree.
|
|
921
|
+
const configForStatus = this.getLoadedConfig(projectName);
|
|
922
|
+
const configuredNames = configForStatus
|
|
923
|
+
? new Set(this.resolveStackApps(stackConfig, configForStatus).map((a) => a.name))
|
|
924
|
+
: new Set();
|
|
925
|
+
const apps = this.orchestrator.list().filter((a) => {
|
|
926
|
+
if (a.name.startsWith(prefix))
|
|
927
|
+
return true;
|
|
928
|
+
if (a.name.includes('/'))
|
|
929
|
+
return false;
|
|
930
|
+
return configuredNames.has(effectiveAppName(a.name));
|
|
931
|
+
});
|
|
393
932
|
const onlineApps = apps.filter((a) => a.status === 'online');
|
|
394
933
|
// Derive status from app health if running
|
|
395
934
|
let status = state?.status ?? 'stopped';
|
|
396
935
|
if (status === 'running' && apps.length > 0 && onlineApps.length < apps.length) {
|
|
397
936
|
status = 'degraded';
|
|
398
937
|
}
|
|
399
|
-
|
|
938
|
+
const base = {
|
|
400
939
|
name: stackName,
|
|
401
940
|
type: stackConfig.type,
|
|
402
941
|
status,
|
|
403
942
|
totalApps: apps.length,
|
|
404
943
|
onlineApps: onlineApps.length,
|
|
405
944
|
totalNodes: stackConfig.nodes?.length ?? 1,
|
|
406
|
-
connectedNodes: stackConfig.type === 'local'
|
|
407
|
-
? 1
|
|
408
|
-
: (this.slaveConnector?.getConnections().filter((c) => c.stack === stackName && c.status === 'connected').length ?? 0),
|
|
945
|
+
connectedNodes: stackConfig.type === 'local' ? 1 : 0,
|
|
409
946
|
totalCpu: apps.reduce((sum, a) => sum + a.cpu, 0),
|
|
410
947
|
totalMemory: apps.reduce((sum, a) => sum + a.memory, 0),
|
|
411
948
|
syncSummary: (stackConfig.type === 'remote' || stackConfig.type === 'cluster')
|
|
412
|
-
?
|
|
413
|
-
totalSlaves: stackConfig.nodes?.filter((n) => n.role !== 'master').length ?? 0,
|
|
414
|
-
syncedSlaves: 0,
|
|
415
|
-
totalPending: 0,
|
|
416
|
-
}
|
|
949
|
+
? summariseSync(this.nodesWithSync(stackConfig))
|
|
417
950
|
: null,
|
|
418
951
|
};
|
|
952
|
+
if (stackConfig.type === 'local')
|
|
953
|
+
return base;
|
|
954
|
+
// The node is asked, and what it answers replaces every count that was
|
|
955
|
+
// taken from this machine. `connectedNodes` is the number of nodes that
|
|
956
|
+
// ANSWERED rather than the number of entries in the connector's
|
|
957
|
+
// registry: that registry reported 0 connections for a node that was
|
|
958
|
+
// answering RPCs in the same second.
|
|
959
|
+
const { info, answered } = await this.askNodes(projectName, this.getStack(projectName, stackName));
|
|
960
|
+
const remote = info.apps;
|
|
961
|
+
const up = remote.filter((a) => a.status === 'online');
|
|
962
|
+
return {
|
|
963
|
+
...base,
|
|
964
|
+
status: info.status,
|
|
965
|
+
totalApps: remote.length,
|
|
966
|
+
onlineApps: up.length,
|
|
967
|
+
connectedNodes: answered,
|
|
968
|
+
totalCpu: remote.reduce((sum, a) => sum + (a.cpu ?? 0), 0),
|
|
969
|
+
totalMemory: remote.reduce((sum, a) => sum + (a.memory ?? 0), 0),
|
|
970
|
+
};
|
|
419
971
|
}
|
|
420
972
|
/**
|
|
421
|
-
*
|
|
973
|
+
* Each configured node paired with what it last said about its own
|
|
974
|
+
* replication — the input the summary is counted from.
|
|
975
|
+
*
|
|
976
|
+
* Matching is by `host` and effective `port`, the same rule the node list
|
|
977
|
+
* below uses; a node with no connection contributes `null`, which the
|
|
978
|
+
* summary reads as «not heard from» rather than «nothing pending».
|
|
422
979
|
*/
|
|
423
|
-
|
|
424
|
-
|
|
980
|
+
nodesWithSync(stackConfig) {
|
|
981
|
+
const connections = this.slaveConnector?.getConnections() ?? [];
|
|
982
|
+
return (stackConfig.nodes ?? []).map((node) => {
|
|
983
|
+
const port = node.port ?? 9700;
|
|
984
|
+
const conn = connections.find((c) => c.host === node.host && c.port === port);
|
|
985
|
+
return { role: node.role, syncStatus: conn?.syncStatus ?? null };
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
/** Where releases are kept on this master — `~/.omnitron/releases`. */
|
|
989
|
+
async releaseStore() {
|
|
990
|
+
const { releasesRoot } = await import('../release/load.js');
|
|
991
|
+
return releasesRoot();
|
|
425
992
|
}
|
|
426
993
|
/**
|
|
427
|
-
*
|
|
428
|
-
*
|
|
994
|
+
* Take a release for a stack, or say why not.
|
|
995
|
+
*
|
|
996
|
+
* Three things are checked, each refusing by name. The stack's own
|
|
997
|
+
* requirements (`decideStackRelease`): every gate the release recorded
|
|
998
|
+
* passed, the floor it names is among them, every app the stack runs is
|
|
999
|
+
* carried. The disk: the stack definition is still evaluated HERE — the
|
|
1000
|
+
* project's config keeps machine-local state beside itself (a generated
|
|
1001
|
+
* dev signing key, an admin's saved coverage), so it cannot be run from a
|
|
1002
|
+
* snapshot — and therefore this directory must be exactly the release's
|
|
1003
|
+
* project commit, or the definition deployed is not the release's. And
|
|
1004
|
+
* the statics: a stack whose gateway serves a bundle takes it from the
|
|
1005
|
+
* release, built for this stack, or not at all.
|
|
429
1006
|
*/
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
1007
|
+
/**
|
|
1008
|
+
* Would this release be taken by this stack? Asked without deploying it.
|
|
1009
|
+
*
|
|
1010
|
+
* The console shows this before an operator presses Deploy, and it is the
|
|
1011
|
+
* DEPLOYMENT'S OWN decision — the same `admitRelease` a start runs, with
|
|
1012
|
+
* nothing skipped and nothing re-implemented. A second copy of these rules
|
|
1013
|
+
* in the browser would drift from these ones within a week, and the only
|
|
1014
|
+
* thing worse than a refusal at deploy time is a console that promised the
|
|
1015
|
+
* opposite.
|
|
1016
|
+
*/
|
|
1017
|
+
async checkRelease(projectName, stackName, releaseId) {
|
|
1018
|
+
try {
|
|
1019
|
+
const config = await this.loadProjectConfig(projectName);
|
|
1020
|
+
const stacks = this.resolveStacks(config, projectName);
|
|
1021
|
+
const stackConfig = stacks[stackName];
|
|
1022
|
+
if (!stackConfig)
|
|
1023
|
+
throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
|
|
1024
|
+
const release = await this.admitRelease(projectName, stackName, stackConfig, config, releaseId, 'check');
|
|
1025
|
+
const gates = release.manifest.gates;
|
|
1026
|
+
const passed = gates.filter((g) => g.status === 'passed').length;
|
|
1027
|
+
return {
|
|
1028
|
+
ok: true,
|
|
1029
|
+
because: `${projectName}/${stackName} would take ${release.id}: ${passed} of ${gates.length} gates passed, ` +
|
|
1030
|
+
`${release.files.length} artifact(s) match the manifest` +
|
|
1031
|
+
`${release.staticsDir ? `, static bundle built for ${release.manifest.statics?.stack}` : ''}.`,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
catch (err) {
|
|
1035
|
+
return { ok: false, because: err.message };
|
|
1036
|
+
}
|
|
433
1037
|
}
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
1038
|
+
/**
|
|
1039
|
+
* Run a release's probes on the node of the stack that carries it, and
|
|
1040
|
+
* hand back what the producer printed and how it exited.
|
|
1041
|
+
*
|
|
1042
|
+
* Deciding whether to KEEP the result is not done here: that is
|
|
1043
|
+
* `release/attest.ts`, reached through the release service, which also
|
|
1044
|
+
* holds the audit trail the freshness refusal needs. This method is the
|
|
1045
|
+
* transport — the release's own scripts and the stack definition at its
|
|
1046
|
+
* commit, uploaded and run on the node under its deploy lease, so an
|
|
1047
|
+
* attestation never interleaves with a deployment.
|
|
1048
|
+
*
|
|
1049
|
+
* One node only, and refused by name otherwise. The probes reach
|
|
1050
|
+
* `localhost` and the stack's containers; on a stack of several nodes one
|
|
1051
|
+
* run measures one machine, and an attestation is stored per STACK — kept
|
|
1052
|
+
* as it is, it would claim for all of them what was measured on one.
|
|
1053
|
+
*/
|
|
1054
|
+
async attestOnNode(projectName, stackName, releaseId) {
|
|
1055
|
+
const config = await this.loadProjectConfig(projectName);
|
|
1056
|
+
const stacks = this.resolveStacks(config, projectName);
|
|
1057
|
+
const stackConfig = stacks[stackName];
|
|
1058
|
+
if (!stackConfig)
|
|
1059
|
+
throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
|
|
1060
|
+
if (stackConfig.type === 'local') {
|
|
1061
|
+
throw new Error(`${projectName}/${stackName} is local: its probes run on this machine with \`node scripts/attest.mjs --stack=${stackName}\`, and need no transport`);
|
|
1062
|
+
}
|
|
1063
|
+
// The probes write to the system they measure — accounts, organisations,
|
|
1064
|
+
// paysys accounts with their deposit addresses (on daos/test, 2026-09-24:
|
|
1065
|
+
// 91 probe organisations in the public catalogue, 10 mainnet addresses on
|
|
1066
|
+
// accounts nobody owns). So they run only where a stack said they may,
|
|
1067
|
+
// and never on one that takes other stacks' attestations: a stack that
|
|
1068
|
+
// declares `verifiedOn` is where people are, and is verified elsewhere.
|
|
1069
|
+
const releaseRules = stackConfig.release;
|
|
1070
|
+
if (releaseRules?.verifiedOn) {
|
|
1071
|
+
throw new Error(`${projectName}/${stackName} takes releases verified on '${releaseRules.verifiedOn.stack}' — it is verified there, ` +
|
|
1072
|
+
`and the probes, which write accounts and organisations into what they measure, do not run here`);
|
|
1073
|
+
}
|
|
1074
|
+
if (!releaseRules?.attest) {
|
|
1075
|
+
throw new Error(`${projectName}/${stackName} does not declare \`release.attest\` — the probes write accounts and organisations ` +
|
|
1076
|
+
`into the stack they measure, and run only on a stack that said they may`);
|
|
1077
|
+
}
|
|
1078
|
+
const nodes = stackConfig.nodes ?? [];
|
|
1079
|
+
if (nodes.length === 0)
|
|
1080
|
+
throw new Error(`${projectName}/${stackName} has no nodes to run the probes on`);
|
|
1081
|
+
if (nodes.length > 1) {
|
|
1082
|
+
throw new Error(`${projectName}/${stackName} has ${nodes.length} nodes. The probes reach localhost and the stack's containers, so one run ` +
|
|
1083
|
+
`measures one machine, and an attestation is stored per stack — it would claim for ${nodes.length} what was measured ` +
|
|
1084
|
+
`on 1. Not supported until attestations are kept per node.`);
|
|
1085
|
+
}
|
|
438
1086
|
const project = this.registry.get(projectName);
|
|
439
1087
|
if (!project)
|
|
440
|
-
throw new Error(`Project '${projectName}' not
|
|
441
|
-
const {
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
1088
|
+
throw new Error(`Project '${projectName}' is not in the registry`);
|
|
1089
|
+
const { loadRelease } = await import('../release/load.js');
|
|
1090
|
+
const release = await loadRelease(releaseId, await this.releaseStore());
|
|
1091
|
+
const { stageAttestation, attestationCommand, producerProvisions } = await import('../release/attest-on-node.js');
|
|
1092
|
+
const staged = await stageAttestation({
|
|
1093
|
+
releaseRoot: release.root,
|
|
1094
|
+
projectPath: fs.realpathSync(project.path),
|
|
1095
|
+
projectCommit: release.manifest.project.commit,
|
|
1096
|
+
});
|
|
1097
|
+
// The stack decides whether its probes may create accounts; the release's
|
|
1098
|
+
// producer decides whether it can. Both, or the flag is not passed — a
|
|
1099
|
+
// producer handed a flag it does not know measures nothing (exit 2).
|
|
1100
|
+
const accounts = stackConfig.release?.attest?.provision !== true
|
|
1101
|
+
? 'not-declared'
|
|
1102
|
+
: producerProvisions(staged.dir)
|
|
1103
|
+
? 'provisioned'
|
|
1104
|
+
: 'producer-cannot';
|
|
1105
|
+
const target = await this.targetForStackNode(nodes[0]);
|
|
1106
|
+
const machine = `${target.host}:${target.sshPort ?? 22}`;
|
|
1107
|
+
const containerPrefix = stackConfig.settings?.containerPrefix ?? `${projectName}-${stackName}`;
|
|
1108
|
+
try {
|
|
1109
|
+
return await this.deployer.underLease(target, `attestation of ${releaseId} on ${projectName}/${stackName}`, async () => {
|
|
1110
|
+
const { remoteDir } = await this.deployer.uploadStaticBundle(target, staged.dir, '/opt/omnitron/attest');
|
|
1111
|
+
this.logger.info({
|
|
1112
|
+
project: projectName,
|
|
1113
|
+
stack: stackName,
|
|
1114
|
+
release: releaseId,
|
|
1115
|
+
node: machine,
|
|
1116
|
+
remoteDir,
|
|
1117
|
+
scriptsFrom: staged.scriptsFrom,
|
|
1118
|
+
sourceFiles: staged.sourceFiles,
|
|
1119
|
+
accounts,
|
|
1120
|
+
}, 'Running the release\'s probes on its node');
|
|
1121
|
+
try {
|
|
1122
|
+
// Through the data channel: the producer's line is evidence to
|
|
1123
|
+
// store, and a probe's output that said `password=…` was cut by
|
|
1124
|
+
// the transport's masker through its closing quote.
|
|
1125
|
+
const run = await this.deployer.readFromNode(target, attestationCommand({ remoteDir, stack: stackName, releaseId, containerPrefix, provision: accounts === 'provisioned' }));
|
|
1126
|
+
return { ...run, node: machine, scriptsFrom: staged.scriptsFrom, sourceFiles: staged.sourceFiles, accounts };
|
|
1127
|
+
}
|
|
1128
|
+
finally {
|
|
1129
|
+
// The stage carries the application's source. The run's output is
|
|
1130
|
+
// the record, kept on this side; the node keeps its artifacts and
|
|
1131
|
+
// not a copy of the code they were built from. With its marker, so
|
|
1132
|
+
// the next run uploads again instead of trusting a directory gone.
|
|
1133
|
+
//
|
|
1134
|
+
// This ended in `.catch(() => undefined)`, which catches a throw —
|
|
1135
|
+
// and `runOnNode` answers a failed command with its exit code, over
|
|
1136
|
+
// a transport that turns even an unreachable node into `exit 1`. An
|
|
1137
|
+
// `rm` the node refused left the source where it was, the
|
|
1138
|
+
// attestation finished without a word, and nothing else ever
|
|
1139
|
+
// removes `/opt/omnitron/attest/`. So it is said here, and only
|
|
1140
|
+
// said: the probes' result stands, since this `rm` measured none of
|
|
1141
|
+
// it, and a failure in a `finally` must not replace what it guards.
|
|
1142
|
+
const { shellEscape } = await import('../shared/shell-escape.js');
|
|
1143
|
+
const removed = await this.deployer
|
|
1144
|
+
.runOnNode(target, `rm -rf ${shellEscape(remoteDir)} ${shellEscape(`${remoteDir}.delivered`)}`, 60_000)
|
|
1145
|
+
.catch((err) => ({ stdout: '', stderr: err instanceof Error ? err.message : String(err), code: -1 }));
|
|
1146
|
+
if (removed.code !== 0) {
|
|
1147
|
+
const words = (removed.stderr || removed.stdout).trim().split('\n').slice(-3).join(' | ');
|
|
1148
|
+
this.logger.warn({
|
|
1149
|
+
project: projectName,
|
|
1150
|
+
stack: stackName,
|
|
1151
|
+
release: releaseId,
|
|
1152
|
+
node: machine,
|
|
1153
|
+
remoteDir,
|
|
1154
|
+
code: removed.code,
|
|
1155
|
+
reason: words || `exit ${removed.code}`,
|
|
1156
|
+
}, 'The release\'s source is still on the node after its probes, and nothing else removes it');
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
});
|
|
459
1160
|
}
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
// Helpers
|
|
463
|
-
// ===========================================================================
|
|
464
|
-
getRunningStacks(projectName) {
|
|
465
|
-
const result = [];
|
|
466
|
-
for (const [, state] of this.stackStates) {
|
|
467
|
-
if (state.project === projectName && (state.status === 'running' || state.status === 'starting')) {
|
|
468
|
-
result.push(state.stack);
|
|
469
|
-
}
|
|
1161
|
+
finally {
|
|
1162
|
+
fs.rmSync(staged.dir, { recursive: true, force: true });
|
|
470
1163
|
}
|
|
471
|
-
return result;
|
|
472
|
-
}
|
|
473
|
-
/** Build the stack handle key for an app */
|
|
474
|
-
static handleKey(project, stack, app) {
|
|
475
|
-
return `${project}/${stack}/${app}`;
|
|
476
1164
|
}
|
|
477
|
-
// ===========================================================================
|
|
478
|
-
// Infrastructure Generation & Validation
|
|
479
|
-
// ===========================================================================
|
|
480
1165
|
/**
|
|
481
|
-
*
|
|
1166
|
+
* Make a named account with a platform role on a remote stack, and keep
|
|
1167
|
+
* its password in this daemon's vault — never in the answer.
|
|
482
1168
|
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
1169
|
+
* The project's own tool makes it (`scripts/operator-account.mjs` at the
|
|
1170
|
+
* project's HEAD commit), on the stack's node, under its deploy lease
|
|
1171
|
+
* (`withOperatorTool`). The password comes back sealed to a key made for
|
|
1172
|
+
* this run and is opened only to go into the vault — the SSH layer masks
|
|
1173
|
+
* anything that looks like a password on its way back (see
|
|
1174
|
+
* `project/operator-account.ts`).
|
|
1175
|
+
*
|
|
1176
|
+
* Two steps that must both happen or neither: the account on the stand and
|
|
1177
|
+
* its password in the vault. The vault is asked first whether the key is
|
|
1178
|
+
* free — a key already there is somebody's secret, and a new password would
|
|
1179
|
+
* replace it — and if the write still fails, the account just made is taken
|
|
1180
|
+
* away again, by its name AND the id the tool printed: an account whose
|
|
1181
|
+
* password nobody holds is a privileged row under a name every later run
|
|
1182
|
+
* refuses as taken. When the run's outcome is not known at all, the stand is
|
|
1183
|
+
* asked what it holds, and the answer says it — with the command that takes
|
|
1184
|
+
* the account away if it is there.
|
|
488
1185
|
*/
|
|
489
|
-
async
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
1186
|
+
async createOperatorAccount(projectName, stackName, input) {
|
|
1187
|
+
const secrets = this.secrets;
|
|
1188
|
+
if (!secrets) {
|
|
1189
|
+
throw new Error('This daemon has no vault: a password made on the node would have nowhere to go, so nothing was made');
|
|
1190
|
+
}
|
|
1191
|
+
const username = input.username?.trim();
|
|
1192
|
+
if (!username)
|
|
1193
|
+
throw new Error('An account needs a name (--username)');
|
|
1194
|
+
const tool = await import('../project/operator-account.js');
|
|
1195
|
+
const vaultKey = input.vaultKey?.trim() || tool.accountVaultKey(projectName, stackName, username);
|
|
1196
|
+
// Before the node is touched.
|
|
1197
|
+
if ((await secrets.get(vaultKey)) !== null) {
|
|
1198
|
+
throw new Error(`The vault already holds '${vaultKey}' — somebody's secret, which a new password would replace. ` +
|
|
1199
|
+
'Name another key with --vault-key; nothing was made');
|
|
1200
|
+
}
|
|
1201
|
+
const key = await tool.sealingKey();
|
|
1202
|
+
return this.withOperatorTool(projectName, stackName, `operator account ${username}`, async (on) => {
|
|
1203
|
+
const facts = { project: projectName, stack: stackName, node: on.machine, username };
|
|
1204
|
+
const run = await on.run((remoteDir, containerPrefix) => tool.operatorAccountCommand({
|
|
1205
|
+
remoteDir,
|
|
1206
|
+
containerPrefix,
|
|
1207
|
+
username,
|
|
1208
|
+
role: input.role,
|
|
1209
|
+
displayName: input.displayName,
|
|
1210
|
+
sealTo: key.spki,
|
|
1211
|
+
}), 180_000);
|
|
1212
|
+
const outcome = tool.readAccountRun(run, username, (sealed) => key.open(sealed));
|
|
1213
|
+
if (!outcome.made) {
|
|
1214
|
+
this.logger.error({ ...facts, code: run.code, uncertain: outcome.uncertain }, 'No operator account was made');
|
|
1215
|
+
// Not known whether it exists: ask the stand, so the answer can say
|
|
1216
|
+
// what is there and how to take it away.
|
|
1217
|
+
let holds = '';
|
|
1218
|
+
if (outcome.uncertain) {
|
|
1219
|
+
const shown = tool.readShowRun(await on.run((remoteDir, containerPrefix) => tool.operatorAccountShowCommand({ remoteDir, containerPrefix, username }), 60_000));
|
|
1220
|
+
holds = !shown.ok
|
|
1221
|
+
? ` What the stand holds could not be read either: ${shown.because}`
|
|
1222
|
+
: shown.account
|
|
1223
|
+
? ` The stand holds ${username}: id ${shown.account.id}, role ${shown.account.role}, made ${shown.account.createdAt ?? '(unknown)'}, ` +
|
|
1224
|
+
`${shown.account.lastActiveAt ? `last active ${shown.account.lastActiveAt}` : 'never signed in'}. Nobody holds its password; ` +
|
|
1225
|
+
`take it away with \`omnitron stack account ${projectName} ${stackName} --remove ${username} --id ${shown.account.id}\``
|
|
1226
|
+
: ` The stand holds no ${username} — nothing was made.`;
|
|
508
1227
|
}
|
|
1228
|
+
throw new Error(`${on.where}: ${outcome.because}.${holds}`);
|
|
1229
|
+
}
|
|
1230
|
+
const { account } = outcome;
|
|
1231
|
+
try {
|
|
1232
|
+
await secrets.set(vaultKey, account.password);
|
|
509
1233
|
}
|
|
510
1234
|
catch (err) {
|
|
511
|
-
|
|
1235
|
+
const why = err instanceof Error ? err.message : String(err);
|
|
1236
|
+
const undo = tool.readRemoveRun(await on.run((remoteDir, containerPrefix) => tool.operatorAccountRemoveCommand({ remoteDir, containerPrefix, username, id: account.id }), 120_000), username, account.id);
|
|
1237
|
+
this.logger.error({ ...facts, id: account.id, vaultKey, undone: undo.removed, error: why }, 'The vault refused an operator account\'s password');
|
|
1238
|
+
throw new Error(undo.removed
|
|
1239
|
+
? `${on.where}: the vault refused the password of ${username} (${why}) — the account was taken away again; nothing is left on the stand`
|
|
1240
|
+
: `${on.where}: the vault refused the password of ${username} (${why}), and taking the account away failed too ` +
|
|
1241
|
+
`(${undo.because}): ${username} (id ${account.id}) is on the stand with a password nobody holds`, { cause: err });
|
|
512
1242
|
}
|
|
1243
|
+
this.logger.info({ ...facts, role: account.role, id: account.id, vaultKey, commit: on.commit }, 'Operator account made — its password is in the vault');
|
|
1244
|
+
return { username, role: account.role, id: account.id, node: on.machine, vaultKey, commit: on.commit };
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* The stack's maintenance lock by hand (`maintenance-lock.ts`): the state
|
|
1249
|
+
* as its gateway enforces it, or held for a bounded time, or let go — a lock
|
|
1250
|
+
* a failed deployment left (it also expires by itself), or work outside a
|
|
1251
|
+
* deployment. Not under the node's deploy lease: letting go of the lock is
|
|
1252
|
+
* exactly what an operator does while a stuck deployment holds that lease.
|
|
1253
|
+
* Only omnitron's keys; the administrators' mode is main's, in /admin.
|
|
1254
|
+
*/
|
|
1255
|
+
async stackMaintenance(projectName, stackName, request) {
|
|
1256
|
+
const config = await this.loadProjectConfig(projectName);
|
|
1257
|
+
const stackConfig = this.resolveStacks(config, projectName)[stackName];
|
|
1258
|
+
if (!stackConfig)
|
|
1259
|
+
throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
|
|
1260
|
+
if (stackConfig.type === 'local') {
|
|
1261
|
+
throw new Error(`${projectName}/${stackName} is local: its gateway is on this machine, and so is anyone using it`);
|
|
513
1262
|
}
|
|
514
|
-
|
|
515
|
-
|
|
1263
|
+
const nodes = stackConfig.nodes ?? [];
|
|
1264
|
+
if (nodes.length !== 1) {
|
|
1265
|
+
throw new Error(`${projectName}/${stackName} has ${nodes.length} node(s); which one runs the gateway is not decided here — one is supported`);
|
|
1266
|
+
}
|
|
1267
|
+
const lockModule = await import('./maintenance-lock.js');
|
|
1268
|
+
const target = await this.targetForStackNode(nodes[0]);
|
|
1269
|
+
const prefix = stackConfig.settings?.containerPrefix ?? `${projectName}-${stackName}`;
|
|
1270
|
+
// Where the gateway that is RUNNING reads — asked of it, not computed: by
|
|
1271
|
+
// hand one acts on the platform as it is.
|
|
1272
|
+
const site = await lockModule.runningGatewaySite(this.deployer.leaseRunner(target), prefix, 'by-hand');
|
|
1273
|
+
if (!site)
|
|
1274
|
+
throw new Error(`${projectName}/${stackName} has no running gateway (${prefix}-gateway) on ${target.host}`);
|
|
1275
|
+
if (request.action === 'on') {
|
|
1276
|
+
await lockModule.holdByHand(site, request.minutes ?? 15);
|
|
1277
|
+
}
|
|
1278
|
+
else if (request.action === 'off') {
|
|
1279
|
+
await lockModule.releaseByHand(site);
|
|
1280
|
+
}
|
|
1281
|
+
else if (request.action !== 'status') {
|
|
1282
|
+
throw new Error(`'${String(request.action)}' is not a maintenance action — status, on or off`);
|
|
1283
|
+
}
|
|
1284
|
+
// What the gateway enforces — asked after its 2-second cache has turned.
|
|
1285
|
+
if (request.action !== 'status')
|
|
1286
|
+
await new Promise((resolve) => setTimeout(resolve, 2_500));
|
|
1287
|
+
const state = await lockModule.readMaintenanceState(site);
|
|
1288
|
+
this.logger.info({ project: projectName, stack: stackName, action: request.action, active: state.active, reason: state.reason }, 'Maintenance lock by hand');
|
|
1289
|
+
return { where: `${projectName}/${stackName} on ${target.host}`, ...state };
|
|
1290
|
+
}
|
|
1291
|
+
/**
|
|
1292
|
+
* What the probes left on a stand — the project's own census of it,
|
|
1293
|
+
* `scripts/probe-leftovers.mjs` without a flag: SELECTs and nothing else,
|
|
1294
|
+
* the accounts, organisations and orphans it would take and what would hold
|
|
1295
|
+
* each back. Its removal is not a door here: that takes the owner's word
|
|
1296
|
+
* each time, and this census is what the word is given on.
|
|
1297
|
+
*/
|
|
1298
|
+
async probeLeftovers(projectName, stackName, request) {
|
|
1299
|
+
const tool = await import('../project/operator-account.js');
|
|
1300
|
+
const mode = request.mode;
|
|
1301
|
+
if (mode !== 'census' && mode !== 'rehearse' && mode !== 'apply') {
|
|
1302
|
+
throw new Error(`'${String(mode)}' is not asked for from here — the census, a rehearsal, or the removal`);
|
|
1303
|
+
}
|
|
1304
|
+
const what = mode === 'census'
|
|
1305
|
+
? 'census of what the probes left'
|
|
1306
|
+
: mode === 'rehearse'
|
|
1307
|
+
? 'rehearsal of removing what the probes left'
|
|
1308
|
+
: 'removal of what the probes left';
|
|
1309
|
+
return this.withOperatorTool(projectName, stackName, what, async (on) => {
|
|
1310
|
+
const read = tool.readLeftoversRun(await on.run((remoteDir, containerPrefix) => tool.probeLeftoversCommand({ remoteDir, containerPrefix, mode, also: request.also }),
|
|
1311
|
+
// A rehearsal or a removal runs every database's removal and its
|
|
1312
|
+
// invariants — longer than counting.
|
|
1313
|
+
mode === 'census' ? 300_000 : 900_000), mode);
|
|
1314
|
+
if (!read.ok)
|
|
1315
|
+
throw new Error(`${on.where}: could not take the ${what}: ${read.because}`);
|
|
1316
|
+
return { node: on.machine, commit: on.commit, mode, report: read.census.report, lines: read.census.lines };
|
|
1317
|
+
}, tool.PROBE_LEFTOVERS_TOOL);
|
|
1318
|
+
}
|
|
1319
|
+
/**
|
|
1320
|
+
* The stand's accounts counted — by role, by status, by the form their MFA
|
|
1321
|
+
* data is in, and whether the seeded admin still opens with its published
|
|
1322
|
+
* password. Counts only. For decisions that depend on what a stand holds
|
|
1323
|
+
* without reading it: a new MFA root cannot open what the old one sealed,
|
|
1324
|
+
* so how many rows are sealed is asked first.
|
|
1325
|
+
*/
|
|
1326
|
+
async censusOperatorAccounts(projectName, stackName) {
|
|
1327
|
+
const tool = await import('../project/operator-account.js');
|
|
1328
|
+
return this.withOperatorTool(projectName, stackName, 'census of accounts', async (on) => {
|
|
1329
|
+
// Three minutes, not one: the census checks privileged accounts against
|
|
1330
|
+
// every password the repository publishes with bcrypt at the stand's
|
|
1331
|
+
// cost — 18 s for 17 accounts on the dev stand.
|
|
1332
|
+
const read = tool.readCensusRun(await on.run((remoteDir, containerPrefix) => tool.operatorCensusCommand({ remoteDir, containerPrefix }), 180_000));
|
|
1333
|
+
if (!read.ok)
|
|
1334
|
+
throw new Error(`${on.where}: could not take the census: ${read.because}`);
|
|
1335
|
+
return { node: on.machine, commit: on.commit, census: read.census };
|
|
1336
|
+
});
|
|
1337
|
+
}
|
|
1338
|
+
/** What a remote stack's stand holds under a name — never a secret. */
|
|
1339
|
+
async showOperatorAccount(projectName, stackName, username) {
|
|
1340
|
+
const name = username?.trim();
|
|
1341
|
+
if (!name)
|
|
1342
|
+
throw new Error('Which account? (--show <name>)');
|
|
1343
|
+
const tool = await import('../project/operator-account.js');
|
|
1344
|
+
return this.withOperatorTool(projectName, stackName, `reading operator account ${name}`, async (on) => {
|
|
1345
|
+
const shown = tool.readShowRun(await on.run((remoteDir, containerPrefix) => tool.operatorAccountShowCommand({ remoteDir, containerPrefix, username: name }), 60_000));
|
|
1346
|
+
if (!shown.ok)
|
|
1347
|
+
throw new Error(`${on.where}: could not read ${name}: ${shown.because}`);
|
|
1348
|
+
return { node: on.machine, commit: on.commit, account: shown.account };
|
|
1349
|
+
});
|
|
1350
|
+
}
|
|
1351
|
+
/**
|
|
1352
|
+
* Take an account away from a remote stack's stand — the row with this
|
|
1353
|
+
* name AND this id, or nothing — and the password this daemon keeps for it,
|
|
1354
|
+
* if it keeps one: a password for an account that is gone is not a secret
|
|
1355
|
+
* worth keeping, and a vault that still lists it says the account exists.
|
|
1356
|
+
*/
|
|
1357
|
+
async removeOperatorAccount(projectName, stackName, input) {
|
|
1358
|
+
const username = input.username?.trim();
|
|
1359
|
+
const id = input.id?.trim();
|
|
1360
|
+
if (!username || !id)
|
|
1361
|
+
throw new Error('Taking an account away needs its name and its id (--remove <name> --id <uuid>)');
|
|
1362
|
+
const tool = await import('../project/operator-account.js');
|
|
1363
|
+
const vaultKey = input.vaultKey?.trim() || tool.accountVaultKey(projectName, stackName, username);
|
|
1364
|
+
const removed = await this.withOperatorTool(projectName, stackName, `removing operator account ${username}`, async (on) => {
|
|
1365
|
+
const outcome = tool.readRemoveRun(await on.run((remoteDir, containerPrefix) => tool.operatorAccountRemoveCommand({ remoteDir, containerPrefix, username, id }), 120_000), username, id);
|
|
1366
|
+
if (!outcome.removed)
|
|
1367
|
+
throw new Error(`${on.where}: ${username} (id ${id}) was not removed: ${outcome.because}`);
|
|
1368
|
+
return { node: on.machine, commit: on.commit };
|
|
1369
|
+
});
|
|
1370
|
+
// After the stand, never before: a key removed for an account that stayed
|
|
1371
|
+
// would be a password lost.
|
|
1372
|
+
const dropped = (await this.secrets?.delete(vaultKey)) === true;
|
|
1373
|
+
this.logger.info({ project: projectName, stack: stackName, node: removed.node, username, id, vaultKey: dropped ? vaultKey : null }, 'Operator account removed');
|
|
1374
|
+
return { username, id, node: removed.node, commit: removed.commit, vaultKeyRemoved: dropped ? vaultKey : null };
|
|
1375
|
+
}
|
|
1376
|
+
/**
|
|
1377
|
+
* The release this stack last started with, as the audit trail recorded it
|
|
1378
|
+
* (`stack.start`, `details.release`) — or null: no trail, no start, or a
|
|
1379
|
+
* start that was not a release.
|
|
1380
|
+
*/
|
|
1381
|
+
async releaseRunningOn(projectName, stackName) {
|
|
1382
|
+
if (!this.audit)
|
|
1383
|
+
return null;
|
|
1384
|
+
try {
|
|
1385
|
+
const rows = await this.audit.latestPerResource('stack.start');
|
|
1386
|
+
const row = rows.find((r) => r.resourceId === `${projectName}/${stackName}`);
|
|
1387
|
+
const release = row?.details?.['release'];
|
|
1388
|
+
return typeof release === 'string' && release !== '[object]' ? release : null;
|
|
516
1389
|
}
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
1390
|
+
catch (err) {
|
|
1391
|
+
this.logger.warn({ project: projectName, stack: stackName, error: err.message }, 'The audit trail could not be read — which release runs on this stack is unknown');
|
|
1392
|
+
return null;
|
|
1393
|
+
}
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* What every operator-account action shares: a remote stack of one node,
|
|
1397
|
+
* the project's tool staged from its HEAD commit, the node's deploy lease,
|
|
1398
|
+
* and the stage taken away again on both sides, whatever `work` did.
|
|
1399
|
+
*
|
|
1400
|
+
* One node, like the attestation: the tool reaches the node's containers,
|
|
1401
|
+
* and which of several nodes holds the stack's database is not decided
|
|
1402
|
+
* here.
|
|
1403
|
+
*/
|
|
1404
|
+
async withOperatorTool(projectName, stackName, purpose, work,
|
|
1405
|
+
/** The project's tool the work runs — its commit must have it. */
|
|
1406
|
+
toolPath) {
|
|
1407
|
+
const config = await this.loadProjectConfig(projectName);
|
|
1408
|
+
const stackConfig = this.resolveStacks(config, projectName)[stackName];
|
|
1409
|
+
if (!stackConfig)
|
|
1410
|
+
throw new Error(`Stack '${stackName}' not found in project '${projectName}'`);
|
|
1411
|
+
const tool = await import('../project/operator-account.js');
|
|
1412
|
+
if (stackConfig.type === 'local') {
|
|
1413
|
+
throw new Error(`${projectName}/${stackName} is local: its containers are on this machine — run ` +
|
|
1414
|
+
`\`node ${tool.OPERATOR_ACCOUNT_TOOL}\` in the project`);
|
|
1415
|
+
}
|
|
1416
|
+
const nodes = stackConfig.nodes ?? [];
|
|
1417
|
+
if (nodes.length === 0)
|
|
1418
|
+
throw new Error(`${projectName}/${stackName} has no node to reach its stand on`);
|
|
1419
|
+
if (nodes.length > 1) {
|
|
1420
|
+
throw new Error(`${projectName}/${stackName} has ${nodes.length} nodes. The tool reaches one node's containers, and which of them ` +
|
|
1421
|
+
'holds the stack\'s database is not decided here — not supported until it is');
|
|
1422
|
+
}
|
|
1423
|
+
const project = this.registry.get(projectName);
|
|
1424
|
+
if (!project)
|
|
1425
|
+
throw new Error(`Project '${projectName}' is not in the registry`);
|
|
1426
|
+
const staged = await tool.stageOperatorTool(fs.realpathSync(project.path), toolPath ?? tool.OPERATOR_ACCOUNT_TOOL);
|
|
1427
|
+
const target = await this.targetForStackNode(nodes[0]);
|
|
1428
|
+
const machine = `${target.host}:${target.sshPort ?? 22}`;
|
|
1429
|
+
const containerPrefix = stackConfig.settings?.containerPrefix ?? `${projectName}-${stackName}`;
|
|
1430
|
+
const { shellEscape } = await import('../shared/shell-escape.js');
|
|
1431
|
+
try {
|
|
1432
|
+
return await this.deployer.underLease(target, `${purpose} on ${projectName}/${stackName}`, async () => {
|
|
1433
|
+
const { remoteDir } = await this.deployer.uploadStaticBundle(target, staged.dir, '/opt/omnitron/operator');
|
|
1434
|
+
try {
|
|
1435
|
+
return await work({
|
|
1436
|
+
// The tool's answer line is data; the sealed password needs no
|
|
1437
|
+
// help past the masker, and the rest of the line gets it here.
|
|
1438
|
+
run: (command, timeoutMs) => this.deployer.readFromNode(target, command(remoteDir, containerPrefix), timeoutMs),
|
|
1439
|
+
machine,
|
|
1440
|
+
where: `${projectName}/${stackName} on ${machine}`,
|
|
1441
|
+
commit: staged.commit,
|
|
1442
|
+
});
|
|
1443
|
+
}
|
|
1444
|
+
finally {
|
|
1445
|
+
// The tool leaves the node with its run, as the attestation's stage
|
|
1446
|
+
// does, and a removal the node refused is said rather than swallowed.
|
|
1447
|
+
const removed = await this.deployer
|
|
1448
|
+
.runOnNode(target, `rm -rf ${shellEscape(remoteDir)} ${shellEscape(`${remoteDir}.delivered`)}`, 60_000)
|
|
1449
|
+
.catch((err) => ({ stdout: '', stderr: err instanceof Error ? err.message : String(err), code: -1 }));
|
|
1450
|
+
if (removed.code !== 0) {
|
|
1451
|
+
this.logger.warn({
|
|
1452
|
+
project: projectName,
|
|
1453
|
+
stack: stackName,
|
|
1454
|
+
node: machine,
|
|
1455
|
+
remoteDir,
|
|
1456
|
+
code: removed.code,
|
|
1457
|
+
reason: removed.stderr.trim().split('\n').slice(-3).join(' | ') || `exit ${removed.code}`,
|
|
1458
|
+
}, 'The operator tool is still on the node after its run, and nothing else removes it');
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
finally {
|
|
1464
|
+
fs.rmSync(staged.dir, { recursive: true, force: true });
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* The paths of the working tree a deployment of this stack reads, relative
|
|
1469
|
+
* to its root — what admission requires to be the release's commit.
|
|
1470
|
+
*
|
|
1471
|
+
* The definition (`release/definition-inputs.ts`), and the directories the
|
|
1472
|
+
* deployment ships to its nodes (`shippedDirsOf`): the gateway's nginx tree,
|
|
1473
|
+
* a service's scripts. Those were read from the working tree and compared
|
|
1474
|
+
* with nothing, so an uncommitted edit to the gateway's template reached the
|
|
1475
|
+
* test node past every gate a release has.
|
|
1476
|
+
*/
|
|
1477
|
+
async deploymentReads(projectName, stackConfig, config, root) {
|
|
1478
|
+
const { definitionInputs } = await import('../release/definition-inputs.js');
|
|
1479
|
+
const { shippedDirsOf } = await import('../infrastructure/shipped-config.js');
|
|
1480
|
+
const shipped = shippedDirsOf(mergeInfrastructure(config, stackConfig), await this.collectDeclaredServices(projectName, stackConfig, config));
|
|
1481
|
+
return definitionInputs(root, this.resolveStackApps(stackConfig, config).flatMap((a) => (a.bootstrap ? [a.bootstrap] : [])), shipped.map(([, dir]) => dir));
|
|
1482
|
+
}
|
|
1483
|
+
async admitRelease(projectName, stackName, stackConfig, config, releaseId, purpose = 'deploy') {
|
|
1484
|
+
if (stackConfig.type === 'local') {
|
|
1485
|
+
throw new Error(`A release is for a remote stack — ${projectName}/${stackName} is local and runs its working tree.`);
|
|
1486
|
+
}
|
|
1487
|
+
const { loadRelease, treeEqualsCommit } = await import('../release/load.js');
|
|
1488
|
+
const { decideStackRelease } = await import('../release/manifest.js');
|
|
1489
|
+
const release = await loadRelease(releaseId, await this.releaseStore());
|
|
1490
|
+
const apps = this.resolveStackApps(stackConfig, config).map((a) => a.name);
|
|
1491
|
+
// The fourth argument, which was `[]` for as long as nothing wrote one:
|
|
1492
|
+
// a stack declaring `verifiedOn` refused every release forever, with a
|
|
1493
|
+
// refusal that read «nothing has been verified on 'test'» while there
|
|
1494
|
+
// was no way for anything to be. See `release/attest.ts`.
|
|
1495
|
+
const { loadAttestations } = await import('../release/attest.js');
|
|
1496
|
+
const attestations = loadAttestations(release.id, await this.releaseStore());
|
|
1497
|
+
const verdict = decideStackRelease(release.manifest, stackConfig.release, apps, attestations);
|
|
1498
|
+
if (verdict.action === 'refuse') {
|
|
1499
|
+
throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: ${verdict.because}.`);
|
|
1500
|
+
}
|
|
1501
|
+
// A migration that ran on this stack is history: the release running
|
|
1502
|
+
// here says which ran, and a file among them that changed since is
|
|
1503
|
+
// refused now — not at the migration step, after the build and the
|
|
1504
|
+
// transfer. See `release/migrations.ts`.
|
|
1505
|
+
const running = await this.releaseRunningOn(projectName, stackName);
|
|
1506
|
+
if (!running) {
|
|
1507
|
+
this.logger.info({ project: projectName, stack: stackName }, 'No release is recorded as running on this stack — applied migrations are not compared');
|
|
1508
|
+
}
|
|
1509
|
+
else if (running !== release.id) {
|
|
1510
|
+
let ran;
|
|
1511
|
+
try {
|
|
1512
|
+
ran = (await loadRelease(running, await this.releaseStore())).manifest.migrations;
|
|
1513
|
+
}
|
|
1514
|
+
catch (err) {
|
|
1515
|
+
this.logger.warn({ project: projectName, stack: stackName, running, error: err.message }, 'The release running on this stack could not be read — its migrations are not compared');
|
|
1516
|
+
}
|
|
1517
|
+
if (ran && release.manifest.migrations) {
|
|
1518
|
+
const { changedReleasedMigrations } = await import('../release/migrations.js');
|
|
1519
|
+
const changed = changedReleasedMigrations(ran, release.manifest.migrations);
|
|
1520
|
+
if (changed.length > 0) {
|
|
1521
|
+
throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: ${changed.length} migration(s) that ran here ` +
|
|
1522
|
+
`with ${running} changed or went away (${changed.slice(0, 5).join(', ')}${changed.length > 5 ? ', …' : ''}). ` +
|
|
1523
|
+
`An applied migration is history — put the change in a new migration.`);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
else {
|
|
1527
|
+
this.logger.info({ project: projectName, stack: stackName, running }, 'A release without migration digests on one side — applied migrations are not compared this time');
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
const project = this.registry.get(projectName);
|
|
1531
|
+
if (!project)
|
|
1532
|
+
throw new Error(`Project '${projectName}' is not in the registry`);
|
|
1533
|
+
const commit = release.manifest.project.commit;
|
|
1534
|
+
// Only what the definition reads has to be the release's — see
|
|
1535
|
+
// `release/definition-inputs.ts`. The rest of the checkout is other
|
|
1536
|
+
// people's work in progress, which no deployment reads. When the files
|
|
1537
|
+
// cannot be named, the whole tree is compared, as before.
|
|
1538
|
+
const root = fs.realpathSync(project.path);
|
|
1539
|
+
let inputs;
|
|
1540
|
+
try {
|
|
1541
|
+
inputs = await this.deploymentReads(projectName, stackConfig, config, root);
|
|
1542
|
+
}
|
|
1543
|
+
catch (err) {
|
|
1544
|
+
this.logger.warn({ project: projectName, stack: stackName, error: err.message }, 'Could not name the files the definition reads — the whole working tree has to be the release\'s commit');
|
|
1545
|
+
}
|
|
1546
|
+
const tree = await treeEqualsCommit(root, commit, inputs);
|
|
1547
|
+
if (!tree.equal) {
|
|
1548
|
+
throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: ` +
|
|
1549
|
+
`${inputs ? `of the ${inputs.length} path(s) the stack definition and what it ships are read from, ` : ''}` +
|
|
1550
|
+
`${tree.files.length} differ from its commit ${commit.slice(0, 8)} ` +
|
|
1551
|
+
`(${tree.files.slice(0, 5).join(', ')}${tree.files.length > 5 ? ', …' : ''}). ` +
|
|
1552
|
+
`The definition is read from ${project.path}, so those have to be the release's: commit or set them aside, ` +
|
|
1553
|
+
`check out ${commit.slice(0, 8)}, or build a release from what is here.`);
|
|
1554
|
+
}
|
|
1555
|
+
// The omni an application runs on a node is that node daemon's, not the
|
|
1556
|
+
// release's — see `release/daemon-omni.ts`.
|
|
1557
|
+
const { refuseForeignOmni } = await import('../release/daemon-omni.js');
|
|
1558
|
+
const foreign = refuseForeignOmni(release.manifest.omni.commit, await this.askDaemonVersions(stackConfig));
|
|
1559
|
+
if (foreign) {
|
|
1560
|
+
throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: ${foreign}.`);
|
|
1561
|
+
}
|
|
1562
|
+
const infra = mergeInfrastructure(config, stackConfig);
|
|
1563
|
+
const servesStatics = Boolean(infra?.services?.['gateway']?.config?.staticDir ?? infra?.gateway?.staticDir);
|
|
1564
|
+
if (servesStatics && (!release.staticsDir || release.manifest.statics?.stack !== stackName)) {
|
|
1565
|
+
throw new Error(`Refusing release ${release.id} for ${projectName}/${stackName}: the stack's gateway serves a static bundle, and the ` +
|
|
1566
|
+
`release carries ${release.manifest.statics ? `one built for '${release.manifest.statics.stack}'` : 'none'} — ` +
|
|
1567
|
+
`\`omnitron release build ${projectName} --for ${stackName}\` builds it with this stack's environment.`);
|
|
1568
|
+
}
|
|
1569
|
+
this.logger.info({
|
|
1570
|
+
project: projectName,
|
|
1571
|
+
stack: stackName,
|
|
1572
|
+
release: release.id,
|
|
1573
|
+
because: verdict.because,
|
|
1574
|
+
projectCommit: commit.slice(0, 8),
|
|
1575
|
+
omniCommit: release.manifest.omni.commit.slice(0, 8),
|
|
1576
|
+
}, purpose === 'check' ? 'Release would be admitted — asked, not deployed' : 'Release admitted');
|
|
1577
|
+
return release;
|
|
1578
|
+
}
|
|
1579
|
+
/** What each of a stack's nodes says its daemon is — the omni its applications load. */
|
|
1580
|
+
async askDaemonVersions(stackConfig) {
|
|
1581
|
+
const connector = this.getSlaveConnector();
|
|
1582
|
+
return Promise.all((stackConfig.nodes ?? []).map(async (node) => {
|
|
1583
|
+
try {
|
|
1584
|
+
const pong = (await connector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronDaemon', 'ping', []));
|
|
1585
|
+
return { host: node.host, version: pong?.version ?? null };
|
|
1586
|
+
}
|
|
1587
|
+
catch (err) {
|
|
1588
|
+
return { host: node.host, version: null, error: err.message };
|
|
1589
|
+
}
|
|
1590
|
+
}));
|
|
1591
|
+
}
|
|
1592
|
+
/**
|
|
1593
|
+
* What a restart of THIS daemon may do to a remote stack: look, not ship.
|
|
1594
|
+
*
|
|
1595
|
+
* Boot and the enabled-stacks reconciler resumed every enabled stack through
|
|
1596
|
+
* a full start, and for a remote stack a full start is a deployment — build,
|
|
1597
|
+
* deliver, restart — of whatever this machine's working tree holds, onto
|
|
1598
|
+
* machines other people deploy to as well. Measured 2026-09-22: a master
|
|
1599
|
+
* restarted on a clean tree shipped its tree to the one test server by
|
|
1600
|
+
* itself (`source: 'auto-resume'`, 13:56:56 → 14:00:05, five applications
|
|
1601
|
+
* restarted onto new artifacts); the three restarts before it that morning
|
|
1602
|
+
* were stopped only because the tree happened to be dirty — a condition
|
|
1603
|
+
* that has nothing to do with intent, and a clean tree of an old commit
|
|
1604
|
+
* passes it as easily. Every development machine with the stack enabled did
|
|
1605
|
+
* the same, on every restart, past every gate.
|
|
1606
|
+
*
|
|
1607
|
+
* The applications on a node are supervised by the node's own daemon. What
|
|
1608
|
+
* this master lost in its restart is its own picture of them, so that is
|
|
1609
|
+
* what is rebuilt: the nodes are asked what they run, and the answer becomes
|
|
1610
|
+
* the stack's state — `running`, or `degraded` when some application is not
|
|
1611
|
+
* online (the node restarts its own; this master does not ship over it). A
|
|
1612
|
+
* stack no node answered for is an error the reconciler retries, by asking
|
|
1613
|
+
* again. Deploying stays something an operator asks for.
|
|
1614
|
+
*/
|
|
1615
|
+
async attachRemoteStack(projectName, stackName, stackConfig, source) {
|
|
1616
|
+
const { info, answered } = await this.askNodes(projectName, this.toStackInfo(projectName, stackName, stackConfig));
|
|
1617
|
+
const why = source === 'boot' ? 'a daemon boot' : 'an automatic resume';
|
|
1618
|
+
if (answered === 0) {
|
|
1619
|
+
throw new Error(`Not deploying ${projectName}/${stackName} from ${why}: none of its node(s) answered, so there is nothing ` +
|
|
1620
|
+
`to attach to yet. \`omnitron stack start ${projectName} ${stackName}\` deploys it, deliberately.`);
|
|
1621
|
+
}
|
|
1622
|
+
const down = info.apps.filter((a) => a.status !== 'online');
|
|
1623
|
+
this.stackStates.set(`${projectName}/${stackName}`, {
|
|
1624
|
+
project: projectName,
|
|
1625
|
+
stack: stackName,
|
|
1626
|
+
status: down.length === 0 ? 'running' : 'degraded',
|
|
1627
|
+
config: stackConfig,
|
|
1628
|
+
startedAt: Date.now(),
|
|
1629
|
+
infraService: null,
|
|
1630
|
+
});
|
|
1631
|
+
this.logger.info({
|
|
1632
|
+
project: projectName,
|
|
1633
|
+
stack: stackName,
|
|
1634
|
+
source,
|
|
1635
|
+
online: info.apps.length - down.length,
|
|
1636
|
+
total: info.apps.length,
|
|
1637
|
+
...(down.length > 0 ? { down: down.map((a) => a.name) } : {}),
|
|
1638
|
+
}, down.length === 0
|
|
1639
|
+
? 'Attached to a remote stack running on its nodes — nothing deployed'
|
|
1640
|
+
: 'Attached to a remote stack with applications not online — its nodes supervise them; nothing deployed from a restart');
|
|
1641
|
+
return info;
|
|
1642
|
+
}
|
|
1643
|
+
/**
|
|
1644
|
+
* Get the infrastructure manager (for daemon-level access).
|
|
1645
|
+
*/
|
|
1646
|
+
getInfraManager() {
|
|
1647
|
+
return this.infraManager;
|
|
1648
|
+
}
|
|
1649
|
+
// ===========================================================================
|
|
1650
|
+
// Enabled-stacks reconciler — desired-state convergence
|
|
1651
|
+
// ===========================================================================
|
|
1652
|
+
/**
|
|
1653
|
+
* Periodically converge running stacks onto the persisted desired state
|
|
1654
|
+
* (`project.enabledStacks`). Boot-time resume in daemon.startApps() is
|
|
1655
|
+
* one-shot — on 2026-07-11 it fired seconds after login while Docker
|
|
1656
|
+
* (OrbStack) was still coming up, failed with "Docker is not available",
|
|
1657
|
+
* and the platform stayed appless for days despite a healthy supervised
|
|
1658
|
+
* daemon. The reconciler retries with per-stack exponential backoff until
|
|
1659
|
+
* the stack is running, and picks up any later divergence (a stack that
|
|
1660
|
+
* crashed into 'error', infra that vanished) the same way.
|
|
1661
|
+
*
|
|
1662
|
+
* Manual intent is respected by construction: `stack stop` removes the
|
|
1663
|
+
* stack from enabledStacks (persisted), so a deliberately stopped stack is
|
|
1664
|
+
* never resurrected; 'starting'/'stopping' states are skipped as in-flight.
|
|
1665
|
+
*/
|
|
1666
|
+
startEnabledStacksReconciler(intervalMs = 60_000) {
|
|
1667
|
+
if (this.resumeTimer)
|
|
1668
|
+
return;
|
|
1669
|
+
this.resumeTimer = setInterval(() => {
|
|
1670
|
+
void this.reconcileEnabledStacks().catch((err) => {
|
|
1671
|
+
this.logger.warn({ error: err.message }, 'Enabled-stacks reconciler tick failed');
|
|
1672
|
+
});
|
|
1673
|
+
}, intervalMs);
|
|
1674
|
+
this.resumeTimer.unref();
|
|
1675
|
+
this.logger.info({ intervalMs }, 'Enabled-stacks reconciler started');
|
|
1676
|
+
}
|
|
1677
|
+
async reconcileEnabledStacks() {
|
|
1678
|
+
// Collect the divergence set first — cheap, no I/O.
|
|
1679
|
+
const pending = [];
|
|
1680
|
+
for (const project of this.registry.list()) {
|
|
1681
|
+
for (const stackName of project.enabledStacks ?? []) {
|
|
1682
|
+
const state = this.stackStates.get(`${project.name}/${stackName}`);
|
|
1683
|
+
const status = state?.status;
|
|
1684
|
+
// Converge only from fully-down states (absent/stopped/error).
|
|
1685
|
+
// 'degraded' means mostly-up — restarting the whole stack over it
|
|
1686
|
+
// would churn healthy apps; per-app recovery owns that case.
|
|
1687
|
+
if (status === 'running' || status === 'starting' || status === 'stopping' || status === 'degraded') {
|
|
1688
|
+
continue;
|
|
1689
|
+
}
|
|
1690
|
+
pending.push({ project: project.name, stack: stackName });
|
|
1691
|
+
}
|
|
1692
|
+
}
|
|
1693
|
+
if (pending.length === 0)
|
|
1694
|
+
return;
|
|
1695
|
+
// One cheap probe gates the whole round: without Docker every start is
|
|
1696
|
+
// guaranteed to fail, so don't burn backoff budget on it.
|
|
1697
|
+
const { isDockerAvailable } = await import('../infrastructure/container-runtime.js');
|
|
1698
|
+
if (!(await isDockerAvailable())) {
|
|
1699
|
+
if (this.dockerWasAvailable !== false) {
|
|
1700
|
+
this.logger.warn({ pending: pending.map((p) => `${p.project}/${p.stack}`) }, 'Reconciler: Docker unavailable — deferring enabled-stack resume');
|
|
1701
|
+
}
|
|
1702
|
+
this.dockerWasAvailable = false;
|
|
1703
|
+
return;
|
|
1704
|
+
}
|
|
1705
|
+
this.dockerWasAvailable = true;
|
|
1706
|
+
const now = Date.now();
|
|
1707
|
+
for (const { project, stack } of pending) {
|
|
1708
|
+
const key = `${project}/${stack}`;
|
|
1709
|
+
const backoff = this.resumeBackoff.get(key);
|
|
1710
|
+
if (backoff && now < backoff.nextAt)
|
|
1711
|
+
continue;
|
|
1712
|
+
this.logger.info({ project, stack, attemptDelayMs: backoff?.delayMs ?? 0 }, 'Reconciler: resuming enabled stack');
|
|
1713
|
+
try {
|
|
1714
|
+
// Config may not be loaded yet on a fresh daemon (boot resume died
|
|
1715
|
+
// before reaching this project) — load lazily, same as boot does.
|
|
1716
|
+
if (!this.getLoadedConfig(project)) {
|
|
1717
|
+
await this.loadProjectConfig(project);
|
|
1718
|
+
}
|
|
1719
|
+
await this.startStack(project, stack, { source: 'auto-resume' });
|
|
1720
|
+
this.resumeBackoff.delete(key);
|
|
1721
|
+
this.logger.info({ project, stack }, 'Reconciler: stack resumed');
|
|
1722
|
+
}
|
|
1723
|
+
catch (err) {
|
|
1724
|
+
const prev = backoff?.delayMs ?? 0;
|
|
1725
|
+
const delayMs = Math.min(prev > 0 ? prev * 2 : 60_000, 15 * 60_000);
|
|
1726
|
+
this.resumeBackoff.set(key, { nextAt: Date.now() + delayMs, delayMs });
|
|
1727
|
+
this.logger.error({ project, stack, error: err.message, retryInMs: delayMs }, 'Reconciler: stack resume failed — will retry');
|
|
1728
|
+
}
|
|
1729
|
+
}
|
|
1730
|
+
}
|
|
1731
|
+
/**
|
|
1732
|
+
* Dispose of all resources held by this service.
|
|
1733
|
+
* Called during daemon shutdown.
|
|
1734
|
+
*/
|
|
1735
|
+
async dispose() {
|
|
1736
|
+
if (this.resumeTimer) {
|
|
1737
|
+
clearInterval(this.resumeTimer);
|
|
1738
|
+
this.resumeTimer = null;
|
|
1739
|
+
}
|
|
1740
|
+
this.slaveConnector?.dispose();
|
|
1741
|
+
await this.infraManager.teardownAll();
|
|
1742
|
+
}
|
|
1743
|
+
// ===========================================================================
|
|
1744
|
+
// Config Management
|
|
1745
|
+
// ===========================================================================
|
|
1746
|
+
async loadProjectConfig(projectName) {
|
|
1747
|
+
const project = this.registry.get(projectName);
|
|
1748
|
+
if (!project)
|
|
1749
|
+
throw new Error(`Project '${projectName}' not found`);
|
|
1750
|
+
const { loadEcosystemConfig } = await import('../config/loader.js');
|
|
1751
|
+
const config = await loadEcosystemConfig(project.path);
|
|
1752
|
+
this.configRegistry.set(projectName, { config, loadedAt: Date.now() });
|
|
1753
|
+
return config;
|
|
1754
|
+
}
|
|
1755
|
+
getLoadedConfig(projectName) {
|
|
1756
|
+
return this.configRegistry.get(projectName)?.config ?? null;
|
|
1757
|
+
}
|
|
1758
|
+
/** In-flight loads, so callers arriving together load a project once. */
|
|
1759
|
+
configLoads = new Map();
|
|
1760
|
+
/**
|
|
1761
|
+
* The project's config, loaded now if the daemon has not got to it yet.
|
|
1762
|
+
*
|
|
1763
|
+
* A reader that arrived in the first seconds after a daemon start found no
|
|
1764
|
+
* config and was answered with an error: measured on the master restarted
|
|
1765
|
+
* at 12:07:48 UTC, 20 ERROR lines «Project 'daos' config not loaded» from
|
|
1766
|
+
* the console's `getStack` polls, logged as server failures and counted by
|
|
1767
|
+
* `doctor` — for a project that loads a moment later, which is what the
|
|
1768
|
+
* reconciler already does for itself (`loadProjectConfig` when absent).
|
|
1769
|
+
*/
|
|
1770
|
+
async ensureConfig(projectName) {
|
|
1771
|
+
const loaded = this.getLoadedConfig(projectName);
|
|
1772
|
+
if (loaded)
|
|
1773
|
+
return loaded;
|
|
1774
|
+
let pending = this.configLoads.get(projectName);
|
|
1775
|
+
if (!pending) {
|
|
1776
|
+
pending = this.loadProjectConfig(projectName).finally(() => this.configLoads.delete(projectName));
|
|
1777
|
+
this.configLoads.set(projectName, pending);
|
|
1778
|
+
}
|
|
1779
|
+
return pending;
|
|
1780
|
+
}
|
|
1781
|
+
async reloadConfig(projectName) {
|
|
1782
|
+
if (projectName) {
|
|
1783
|
+
await this.loadProjectConfig(projectName);
|
|
1784
|
+
this.emit('project:config_reloaded', projectName);
|
|
1785
|
+
}
|
|
1786
|
+
else {
|
|
1787
|
+
for (const project of this.registry.list()) {
|
|
1788
|
+
await this.loadProjectConfig(project.name);
|
|
1789
|
+
this.emit('project:config_reloaded', project.name);
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
// ===========================================================================
|
|
1794
|
+
// Helpers
|
|
1795
|
+
// ===========================================================================
|
|
1796
|
+
getRunningStacks(projectName) {
|
|
1797
|
+
const result = [];
|
|
1798
|
+
for (const [, state] of this.stackStates) {
|
|
1799
|
+
if (state.project === projectName && (state.status === 'running' || state.status === 'starting')) {
|
|
1800
|
+
result.push(state.stack);
|
|
1801
|
+
}
|
|
1802
|
+
}
|
|
1803
|
+
return result;
|
|
1804
|
+
}
|
|
1805
|
+
/** Build the stack handle key for an app */
|
|
1806
|
+
static handleKey(project, stack, app) {
|
|
1807
|
+
return `${project}/${stack}/${app}`;
|
|
1808
|
+
}
|
|
1809
|
+
// ===========================================================================
|
|
1810
|
+
// Infrastructure Generation & Validation
|
|
1811
|
+
// ===========================================================================
|
|
1812
|
+
/**
|
|
1813
|
+
* Generate optimal default infrastructure config by scanning app requirements.
|
|
1814
|
+
*
|
|
1815
|
+
* Reads each app's config/default.json `omnitron` section (same pattern as
|
|
1816
|
+
* requirements-scanner) and aggregates needs into a minimal InfrastructureConfig:
|
|
1817
|
+
* - Any app with `database` → shared PostgreSQL with per-app databases
|
|
1818
|
+
* - Any app with `redis` → shared Redis with auto-allocated DB indices
|
|
1819
|
+
* - Any app with `s3` → shared MinIO with per-app buckets
|
|
1820
|
+
*/
|
|
1821
|
+
async generateDefaultInfrastructure(projectConfig, projectPath, appNames) {
|
|
1822
|
+
// Resolve which apps to scan
|
|
1823
|
+
const apps = appNames === 'all'
|
|
1824
|
+
? projectConfig.apps.filter((a) => a.enabled !== false)
|
|
1825
|
+
: projectConfig.apps.filter((a) => a.enabled !== false && appNames.includes(a.name));
|
|
1826
|
+
// Read omnitron config from each app's config/default.json
|
|
1827
|
+
const appConfigs = new Map();
|
|
1828
|
+
for (const app of apps) {
|
|
1829
|
+
if (!app.bootstrap)
|
|
1830
|
+
continue;
|
|
1831
|
+
const bootstrapPath = `${projectPath}/${app.bootstrap}`;
|
|
1832
|
+
const srcDir = path.dirname(bootstrapPath);
|
|
1833
|
+
const appRoot = path.resolve(srcDir, '..');
|
|
1834
|
+
const configPath = path.join(appRoot, 'config', 'default.json');
|
|
1835
|
+
try {
|
|
1836
|
+
const content = fs.readFileSync(configPath, 'utf-8');
|
|
1837
|
+
const json = JSON.parse(content);
|
|
1838
|
+
if (json.omnitron) {
|
|
1839
|
+
appConfigs.set(app.name, json.omnitron);
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
catch (err) {
|
|
1843
|
+
this.logger.debug({ app: app.name, configPath, error: err.message }, 'Could not read app config for infrastructure scan');
|
|
1844
|
+
}
|
|
1845
|
+
}
|
|
1846
|
+
if (appConfigs.size === 0) {
|
|
1847
|
+
return {};
|
|
1848
|
+
}
|
|
1849
|
+
// Aggregate needs
|
|
1850
|
+
const needsPostgres = [...appConfigs.values()].some((c) => !!c.database);
|
|
1851
|
+
const needsRedis = [...appConfigs.values()].some((c) => !!c.redis);
|
|
1852
|
+
const needsS3 = [...appConfigs.values()].some((c) => !!c.s3);
|
|
1853
|
+
const infrastructure = {};
|
|
1854
|
+
// PostgreSQL: shared instance with per-app databases
|
|
1855
|
+
if (needsPostgres) {
|
|
1856
|
+
const databases = {};
|
|
1857
|
+
for (const [appName, config] of appConfigs) {
|
|
1858
|
+
if (config.database) {
|
|
1859
|
+
const dbConfig = typeof config.database === 'object' ? config.database : {};
|
|
1860
|
+
databases[appName] = {
|
|
1861
|
+
migrate: true,
|
|
1862
|
+
...(dbConfig.extensions?.length ? { extensions: dbConfig.extensions } : {}),
|
|
1863
|
+
};
|
|
1864
|
+
}
|
|
1865
|
+
}
|
|
534
1866
|
infrastructure.postgres = {
|
|
535
1867
|
port: 5432,
|
|
536
1868
|
databases,
|
|
@@ -579,6 +1911,48 @@ export class ProjectService extends EventEmitter {
|
|
|
579
1911
|
* Validate that a stack's infrastructure config satisfies all app requirements.
|
|
580
1912
|
* Returns issues if infrastructure is missing for apps that need it.
|
|
581
1913
|
*/
|
|
1914
|
+
/**
|
|
1915
|
+
* What each node of a stack would find and do on its host for the services
|
|
1916
|
+
* the stack declares — asked, not done (`host-inspection.ts`).
|
|
1917
|
+
*
|
|
1918
|
+
* Built from the parts a deployment sends — the applications'
|
|
1919
|
+
* declarations and the stack's overrides with their references resolved
|
|
1920
|
+
* from this vault — plus the services reached elsewhere, to say whether
|
|
1921
|
+
* they answer. The node that runs mainnet chains could otherwise be read
|
|
1922
|
+
* only by deploying to it: it takes SSH by password, and that stays in
|
|
1923
|
+
* the vault.
|
|
1924
|
+
*/
|
|
1925
|
+
async inspectStackHost(data) {
|
|
1926
|
+
const config = await this.loadProjectConfig(data.project);
|
|
1927
|
+
const stackConfig = this.resolveStacks(config, data.project)[data.stack];
|
|
1928
|
+
if (!stackConfig)
|
|
1929
|
+
throw new Error(`No stack '${data.stack}' in ${data.project}`);
|
|
1930
|
+
const nodes = stackConfig.nodes ?? [];
|
|
1931
|
+
if (stackConfig.type === 'local' || nodes.length === 0) {
|
|
1932
|
+
throw new Error(`${data.project}/${data.stack} runs on no node of its own — its host is this machine`);
|
|
1933
|
+
}
|
|
1934
|
+
const services = await this.collectDeclaredServices(data.project, stackConfig, config, { external: true });
|
|
1935
|
+
const overrides = await this.resolveOverrideSecrets(data.project, stackConfig.serviceOverrides);
|
|
1936
|
+
const { project: _project, stack: _stack, ...asked } = data;
|
|
1937
|
+
const connector = this.getSlaveConnector();
|
|
1938
|
+
const readings = [];
|
|
1939
|
+
for (const node of nodes) {
|
|
1940
|
+
const host = node.host;
|
|
1941
|
+
const port = node.port ?? 9700;
|
|
1942
|
+
try {
|
|
1943
|
+
await connector.addSlave({ host, port, label: node.label });
|
|
1944
|
+
if (!(await connector.waitUntilConnected(host, port, 60_000))) {
|
|
1945
|
+
throw new Error('it did not join the mesh within a minute');
|
|
1946
|
+
}
|
|
1947
|
+
const inspection = (await connector.invokeOnSlave(host, port, 'OmnitronInfra', 'inspectHostServices', [{ services, overrides, ...asked }], { retryOnDisconnect: true }));
|
|
1948
|
+
readings.push({ node: `${host}:${port}`, inspection });
|
|
1949
|
+
}
|
|
1950
|
+
catch (err) {
|
|
1951
|
+
readings.push({ node: `${host}:${port}`, error: err.message });
|
|
1952
|
+
}
|
|
1953
|
+
}
|
|
1954
|
+
return readings;
|
|
1955
|
+
}
|
|
582
1956
|
async validateStackInfrastructure(projectName, stackName) {
|
|
583
1957
|
const config = await this.loadProjectConfig(projectName);
|
|
584
1958
|
const stacks = this.resolveStacks(config, projectName);
|
|
@@ -647,52 +2021,18 @@ export class ProjectService extends EventEmitter {
|
|
|
647
2021
|
// Validation is best-effort — don't block stack start
|
|
648
2022
|
}
|
|
649
2023
|
// 1. Load bootstrap definitions (needed for both infra provisioning and config resolution)
|
|
650
|
-
const appDefinitions = new Map();
|
|
651
2024
|
const appEntries = this.resolveStackApps(stackConfig, ecosystemConfig);
|
|
2025
|
+
// What the stack gives its apps, vault references resolved — now, before
|
|
2026
|
+
// anything is provisioned: a key the vault does not hold refuses the
|
|
2027
|
+
// start here (project/stack-env.ts).
|
|
2028
|
+
const stackEnv = await this.stackEnvFor(projectName, stackName, stackConfig, appEntries.map((e) => e.name));
|
|
652
2029
|
const project = this.registry.get(projectName);
|
|
653
|
-
|
|
654
|
-
if (entry.bootstrap && project) {
|
|
655
|
-
try {
|
|
656
|
-
const bootstrapAbsPath = path.resolve(project.path, entry.bootstrap);
|
|
657
|
-
const { loadBootstrapConfig } = await import('../orchestrator/bootstrap-loader.js');
|
|
658
|
-
const definition = await loadBootstrapConfig(bootstrapAbsPath, { devMode: false });
|
|
659
|
-
// Populate omnitronConfig from app's config/default.json if not already set
|
|
660
|
-
if (!definition.omnitronConfig) {
|
|
661
|
-
const srcDir = path.dirname(bootstrapAbsPath);
|
|
662
|
-
const appRoot = path.resolve(srcDir, '..');
|
|
663
|
-
const configPath = path.join(appRoot, 'config', 'default.json');
|
|
664
|
-
try {
|
|
665
|
-
const content = fs.readFileSync(configPath, 'utf-8');
|
|
666
|
-
const json = JSON.parse(content);
|
|
667
|
-
if (json.omnitron) {
|
|
668
|
-
definition.omnitronConfig = json.omnitron;
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
catch { /* config file missing — skip */ }
|
|
672
|
-
}
|
|
673
|
-
appDefinitions.set(entry.name, definition);
|
|
674
|
-
}
|
|
675
|
-
catch {
|
|
676
|
-
// Non-critical — app can start without definition
|
|
677
|
-
}
|
|
678
|
-
}
|
|
679
|
-
}
|
|
2030
|
+
const appDefinitions = await this.loadAppDefinitions(projectName, stackConfig, ecosystemConfig);
|
|
680
2031
|
// 2. Provision per-stack infrastructure (built-in + app-declared services)
|
|
681
2032
|
// Auto-detect core services (postgres, redis, minio) from app requirements,
|
|
682
2033
|
// then merge with explicit infrastructure config (gateway, custom services).
|
|
683
2034
|
// Merge stack-level + ecosystem-level infrastructure (stack overrides ecosystem, but both contribute)
|
|
684
|
-
let effectiveInfra;
|
|
685
|
-
if (stackConfig.infrastructure || ecosystemConfig.infrastructure) {
|
|
686
|
-
effectiveInfra = {
|
|
687
|
-
...ecosystemConfig.infrastructure,
|
|
688
|
-
...stackConfig.infrastructure,
|
|
689
|
-
// Deep-merge services maps from both levels
|
|
690
|
-
services: {
|
|
691
|
-
...ecosystemConfig.infrastructure?.services,
|
|
692
|
-
...stackConfig.infrastructure?.services,
|
|
693
|
-
},
|
|
694
|
-
};
|
|
695
|
-
}
|
|
2035
|
+
let effectiveInfra = mergeInfrastructure(ecosystemConfig, stackConfig);
|
|
696
2036
|
if (project) {
|
|
697
2037
|
try {
|
|
698
2038
|
const { scanRequirements, buildInfraFromRequirements } = await import('../project/requirements-scanner.js');
|
|
@@ -720,111 +2060,928 @@ export class ProjectService extends EventEmitter {
|
|
|
720
2060
|
state.infraService = infraService;
|
|
721
2061
|
}
|
|
722
2062
|
catch (err) {
|
|
723
|
-
|
|
2063
|
+
const reason = err.message;
|
|
2064
|
+
this.logger.error({ project: projectName, stack: stackName, error: reason }, 'Failed to provision stack infrastructure');
|
|
2065
|
+
// Fail-fast by default: apps almost always depend on the
|
|
2066
|
+
// stack-provisioned infrastructure (postgres, redis, …) and starting
|
|
2067
|
+
// them on top of broken infra produces cascading misleading errors
|
|
2068
|
+
// ("Redis client connection timed out", "Container not found", …).
|
|
2069
|
+
// Bail out with a clear, structured error so the operator sees the
|
|
2070
|
+
// real cause first.
|
|
2071
|
+
//
|
|
2072
|
+
// Edge case: stacks that intentionally talk to *external* infra and
|
|
2073
|
+
// declare nothing in their `infrastructure` section never reach this
|
|
2074
|
+
// branch (`effectiveInfra` is falsy). For deliberate degraded-mode
|
|
2075
|
+
// operation the stack settings can opt in via
|
|
2076
|
+
// `settings.allowDegradedInfra: true` (the field is namespaced under
|
|
2077
|
+
// settings to remain forward-compatible with stack schema).
|
|
2078
|
+
const allowDegraded = stackConfig.settings?.['allowDegradedInfra'] === true;
|
|
2079
|
+
if (!allowDegraded) {
|
|
2080
|
+
const cleanMsg = `Stack '${projectName}/${stackName}' infrastructure provisioning failed:\n` +
|
|
2081
|
+
` ${reason}\n\n` +
|
|
2082
|
+
`Apps were not started. Fix the underlying infrastructure issue (Docker socket, ` +
|
|
2083
|
+
`image pull, port conflict, …) and rerun 'omnitron stack start ${projectName} ${stackName}'.\n` +
|
|
2084
|
+
`\nIf you intentionally run apps against external infrastructure, set ` +
|
|
2085
|
+
`'settings.allowDegradedInfra: true' in your stack config.`;
|
|
2086
|
+
const throwable = new Error(cleanMsg);
|
|
2087
|
+
throwable.code = 'INFRA_PROVISIONING_FAILED';
|
|
2088
|
+
throwable.cause = err;
|
|
2089
|
+
throw throwable;
|
|
2090
|
+
}
|
|
2091
|
+
this.logger.warn({ project: projectName, stack: stackName }, 'allowDegradedInfra=true — proceeding with app start despite infra failure');
|
|
724
2092
|
}
|
|
725
2093
|
}
|
|
726
2094
|
// 2b. Run database migrations for apps that have migrate: true
|
|
727
2095
|
if (effectiveInfra && project) {
|
|
728
2096
|
await this.runStackMigrations(projectName, stackName, effectiveInfra, appEntries, project.path);
|
|
729
2097
|
}
|
|
730
|
-
// 3.
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
const
|
|
734
|
-
// 4. Start
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
2098
|
+
// 3. How each app is started: its entry, built from the stack's resolved
|
|
2099
|
+
// configuration (`stackEntryBuilder` — the same builder a re-read of
|
|
2100
|
+
// the project uses to redefine running apps).
|
|
2101
|
+
const appConfigBuilder = this.stackEntryBuilder(projectName, stackName, stackConfig, ecosystemConfig, appDefinitions, stackEnv);
|
|
2102
|
+
// 4. Start apps in dependency-aware parallel batches.
|
|
2103
|
+
//
|
|
2104
|
+
// Pre-fix this was a serial `for ... await` loop in declaration
|
|
2105
|
+
// order. For the downstream stack that meant geo + pricing (zero
|
|
2106
|
+
// deps) waited 30+ seconds for unrelated apps to finish booting
|
|
2107
|
+
// before they themselves were even spawned — every app paid
|
|
2108
|
+
// the cumulative cold-start cost. The dependency-aware batched
|
|
2109
|
+
// start that `OrchestratorService.startAll` uses was bypassed
|
|
2110
|
+
// entirely because stack-mode goes through this code path
|
|
2111
|
+
// instead of startAll.
|
|
2112
|
+
//
|
|
2113
|
+
// resolveStartupOrder returns waves: every app in wave N has
|
|
2114
|
+
// all its `dependsOn` already in wave 0..N-1. We start each
|
|
2115
|
+
// wave with Promise.all so apps without deps run truly in
|
|
2116
|
+
// parallel, and a wave can't start until every member of the
|
|
2117
|
+
// previous wave is online. Same skip-on-failed-dep semantics
|
|
2118
|
+
// as startAll.
|
|
2119
|
+
const failed = new Set();
|
|
2120
|
+
const blocked = new Set();
|
|
2121
|
+
for (const wave of resolveStartupOrder(appEntries)) {
|
|
2122
|
+
await Promise.all(wave.map(async (entry) => {
|
|
2123
|
+
const blockingDep = (entry.dependsOn ?? []).find((d) => failed.has(d) || blocked.has(d));
|
|
2124
|
+
if (blockingDep) {
|
|
2125
|
+
blocked.add(entry.name);
|
|
2126
|
+
this.logger.warn({ app: entry.name, project: projectName, stack: stackName, blockedBy: blockingDep }, 'Skipping app — dependency failed or was blocked');
|
|
2127
|
+
return;
|
|
2128
|
+
}
|
|
2129
|
+
const namespacedEntry = appConfigBuilder(entry);
|
|
2130
|
+
const envMap = namespacedEntry.env;
|
|
2131
|
+
this.logger.info({
|
|
2132
|
+
app: entry.name,
|
|
2133
|
+
wave: 'parallel',
|
|
2134
|
+
envKeys: envMap ? Object.keys(envMap) : [],
|
|
2135
|
+
hasS3: !!envMap?.['S3_ENDPOINT'],
|
|
2136
|
+
hasDb: !!envMap?.['DATABASE_URL'],
|
|
2137
|
+
}, 'Infrastructure env vars for app');
|
|
2138
|
+
try {
|
|
2139
|
+
await this.orchestrator.startApp(namespacedEntry, ecosystemConfig);
|
|
2140
|
+
}
|
|
2141
|
+
catch (err) {
|
|
2142
|
+
failed.add(entry.name);
|
|
2143
|
+
this.logger.error({ app: entry.name, project: projectName, stack: stackName, error: err.message }, 'Failed to start app in stack');
|
|
2144
|
+
}
|
|
2145
|
+
}));
|
|
2146
|
+
}
|
|
2147
|
+
if (failed.size > 0 || blocked.size > 0) {
|
|
2148
|
+
this.logger.warn({ project: projectName, stack: stackName, failed: [...failed], blocked: [...blocked] }, 'Stack startup finished with failures — some apps did not launch');
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
/**
|
|
2152
|
+
* Deploy a stack to its nodes, and say what it is doing while it does it.
|
|
2153
|
+
*
|
|
2154
|
+
* Measured on 2026-09-22: 829 seconds between this method's first log line
|
|
2155
|
+
* and its next one, with the work in flight the whole time. The CLI stops
|
|
2156
|
+
* waiting at 600 and says the operation is «probably still running» — true,
|
|
2157
|
+
* and silent about which of its seven steps was running. See
|
|
2158
|
+
* `deploy-phases.ts`.
|
|
2159
|
+
*/
|
|
2160
|
+
async startRemoteStack(projectName, stackName, stackConfig, ecosystemConfig, release = null, reinstall = false) {
|
|
2161
|
+
const phases = reportPhases(this.logger, { project: projectName, stack: stackName });
|
|
2162
|
+
try {
|
|
2163
|
+
return await this.deployRemoteStack(projectName, stackName, stackConfig, ecosystemConfig, phases, release, reinstall);
|
|
2164
|
+
}
|
|
2165
|
+
finally {
|
|
2166
|
+
// Every exit, including the refusals: a reporter that outlives its
|
|
2167
|
+
// deployment narrates a step nobody is taking.
|
|
2168
|
+
phases.done();
|
|
766
2169
|
}
|
|
767
2170
|
}
|
|
768
|
-
async
|
|
2171
|
+
async deployRemoteStack(projectName, stackName, stackConfig, ecosystemConfig, phases, release = null, reinstall = false) {
|
|
769
2172
|
const nodes = stackConfig.nodes ?? [];
|
|
770
2173
|
if (nodes.length === 0) {
|
|
771
2174
|
throw new Error(`Remote stack '${stackName}' has no nodes configured`);
|
|
772
2175
|
}
|
|
773
2176
|
this.logger.info({ project: projectName, stack: stackName, nodes: nodes.length }, 'Starting remote stack — deploying to slave daemons');
|
|
774
2177
|
const connector = this.getSlaveConnector();
|
|
2178
|
+
// Which nodes this deployment actually reached.
|
|
2179
|
+
//
|
|
2180
|
+
// A node that fails to provision is skipped and the loop moves on —
|
|
2181
|
+
// right when there are others, and a lie when there are not. Measured
|
|
2182
|
+
// 2026-09-22: the only node's SSH stopped completing handshakes, both
|
|
2183
|
+
// attempts logged «Failed to provision slave — skipping», and the
|
|
2184
|
+
// deployment went on to mark the stack running and write a `stack.start`
|
|
2185
|
+
// row saying `apps: 6`. The CLI was honest — «only 0/6 apps came
|
|
2186
|
+
// online» — and the audit trail, which is what anyone reads a week
|
|
2187
|
+
// later, recorded a deployment that never touched the machine.
|
|
2188
|
+
const reached = [];
|
|
2189
|
+
const skipped = [];
|
|
2190
|
+
/** Nodes that took the deployment with infrastructure not up, in the node's words. */
|
|
2191
|
+
const notReady = [];
|
|
2192
|
+
// What the applications in this stack declare they need.
|
|
2193
|
+
//
|
|
2194
|
+
// Collected here and sent to the node, because the node does not have
|
|
2195
|
+
// the application definitions when it is asked to provision — and
|
|
2196
|
+
// reading them again on that side would be a second implementation of
|
|
2197
|
+
// variant selection and override merging, on the side with less
|
|
2198
|
+
// information.
|
|
2199
|
+
phases.enter('reading what the applications declare');
|
|
2200
|
+
const declaredServices = await this.collectDeclaredServices(projectName, stackConfig, ecosystemConfig);
|
|
775
2201
|
// Build artifacts for apps in this stack
|
|
776
2202
|
const appEntries = this.resolveStackApps(stackConfig, ecosystemConfig);
|
|
2203
|
+
// What the stack gives its apps, vault references resolved — before any
|
|
2204
|
+
// node is touched: a key the vault does not hold refuses the deployment
|
|
2205
|
+
// here, not after a node was provisioned (project/stack-env.ts). A remote
|
|
2206
|
+
// stack's `settings.env` reached no app before this; a local one's did.
|
|
2207
|
+
const stackEnv = await this.stackEnvFor(projectName, stackName, stackConfig, appEntries.map((e) => e.name));
|
|
777
2208
|
const project = this.registry.get(projectName);
|
|
778
2209
|
let artifacts = [];
|
|
779
|
-
|
|
2210
|
+
// Why a variable and not a `throw` inside the `try` below: the `catch`
|
|
2211
|
+
// is right there, and it swallowed exactly this. Caught by the test for
|
|
2212
|
+
// this change on its first run — the refusal fired, the catch logged
|
|
2213
|
+
// "deploying without rebuild", and the deployment carried on to the node
|
|
2214
|
+
// as if nothing had happened.
|
|
2215
|
+
let refusal = null;
|
|
2216
|
+
if (release) {
|
|
2217
|
+
// Built in clean clones, gated, and checked against the manifest by
|
|
2218
|
+
// `loadRelease` — nothing is compiled here, from this machine's disk.
|
|
2219
|
+
const files = new Map(release.files.map((f) => [f.app, f]));
|
|
2220
|
+
artifacts = appEntries.flatMap((entry) => {
|
|
2221
|
+
const file = files.get(entry.name);
|
|
2222
|
+
return file
|
|
2223
|
+
? [{
|
|
2224
|
+
app: file.app,
|
|
2225
|
+
version: file.version,
|
|
2226
|
+
path: file.path,
|
|
2227
|
+
size: file.bytes,
|
|
2228
|
+
builtAt: release.manifest.builtAt,
|
|
2229
|
+
checksum: file.inputs,
|
|
2230
|
+
tarballSha256: file.sha256,
|
|
2231
|
+
}]
|
|
2232
|
+
: [];
|
|
2233
|
+
});
|
|
2234
|
+
const missing = appEntries.filter((e) => !files.has(e.name)).map((e) => e.name);
|
|
2235
|
+
if (missing.length > 0) {
|
|
2236
|
+
refusal = `Refusing to deploy ${stackName}: release ${release.id} carries no artifact for ${missing.join(', ')}.`;
|
|
2237
|
+
}
|
|
2238
|
+
else {
|
|
2239
|
+
this.logger.info({ release: release.id, artifacts: artifacts.map((a) => `${a.app}@${a.version}`), stack: stackName }, 'Deploying a release — artifacts from its clean build, nothing compiled here');
|
|
2240
|
+
}
|
|
2241
|
+
}
|
|
2242
|
+
else if (project) {
|
|
780
2243
|
try {
|
|
781
2244
|
const { ArtifactBuilder } = await import('../project/artifact-builder.js');
|
|
782
|
-
const builder = new ArtifactBuilder(project.path
|
|
783
|
-
|
|
784
|
-
|
|
2245
|
+
const builder = new ArtifactBuilder(project.path, undefined, {
|
|
2246
|
+
info: (msg) => this.logger.info({ project: project.name }, msg),
|
|
2247
|
+
});
|
|
2248
|
+
phases.enter('building artifacts');
|
|
2249
|
+
const outcome = await builder.buildAll(appEntries);
|
|
2250
|
+
artifacts = outcome.built;
|
|
2251
|
+
if (outcome.failed.length > 0) {
|
|
2252
|
+
// Said at error level and per app: a stack whose artifacts did not
|
|
2253
|
+
// build deploys nothing, and the reason is in the builder's hands
|
|
2254
|
+
// and nowhere else.
|
|
2255
|
+
for (const f of outcome.failed) {
|
|
2256
|
+
this.logger.error({ app: f.app, stack: stackName, error: f.error }, 'Artifact build failed');
|
|
2257
|
+
}
|
|
2258
|
+
// ...and then the deployment STOPS, which it did not.
|
|
2259
|
+
//
|
|
2260
|
+
// Measured on 2026-09-21: `main` failed to compile at 15:17:27,
|
|
2261
|
+
// the five that did compile were deployed, and at 15:19:02 this
|
|
2262
|
+
// method's caller logged `Stack started`. `omnitron stack status`
|
|
2263
|
+
// then reported `6/6 online` — true and useless, because the node
|
|
2264
|
+
// daemon is restarted unconditionally on every deploy and
|
|
2265
|
+
// auto-starts whatever its config still lists. `main` kept serving
|
|
2266
|
+
// the artifact from the PREVIOUS cycle while the operator was
|
|
2267
|
+
// shown a green stack. Earlier the same day three of six failed
|
|
2268
|
+
// and the deployment went out with three.
|
|
2269
|
+
//
|
|
2270
|
+
// A partial deployment is worse than none: the apps that landed
|
|
2271
|
+
// are newer than the ones that did not, and nothing on the node or
|
|
2272
|
+
// in this log says which is which. The one place that can refuse
|
|
2273
|
+
// is here, before a single byte leaves the machine.
|
|
2274
|
+
refusal =
|
|
2275
|
+
`Refusing to deploy ${stackName}: ${outcome.failed.length} of ${appEntries.length} artifact(s) failed to build — ` +
|
|
2276
|
+
outcome.failed.map((f) => f.app).join(', ') +
|
|
2277
|
+
'. Nothing was deployed; the per-app errors are above.';
|
|
2278
|
+
}
|
|
2279
|
+
else {
|
|
2280
|
+
this.logger.info({ artifacts: artifacts.map((a) => `${a.app}@${a.version}`), stack: stackName }, 'Artifacts built for deployment');
|
|
2281
|
+
}
|
|
785
2282
|
}
|
|
786
2283
|
catch (err) {
|
|
787
|
-
|
|
2284
|
+
// The build system itself did not run — a missing module, a spawn
|
|
2285
|
+
// that threw, a timeout. `deploying without rebuild` described what
|
|
2286
|
+
// came next and did not object to it: the node was handed whatever
|
|
2287
|
+
// it had from the last cycle and told it was current.
|
|
2288
|
+
this.logger.error({ error: err.message, stack: stackName }, 'The artifact build did not run — nothing will be deployed');
|
|
2289
|
+
refusal = `Refusing to deploy ${stackName}: the artifact build did not run — ${err.message}`;
|
|
788
2290
|
}
|
|
789
2291
|
}
|
|
790
|
-
|
|
2292
|
+
else {
|
|
2293
|
+
// `if (project)` with nothing on the other side meant a name the
|
|
2294
|
+
// registry does not know produced NO log line at all: no build, no
|
|
2295
|
+
// error, an empty artifact list, and step 3 below skipped by
|
|
2296
|
+
// `if (artifacts.length > 0)`. The deployment then opened the mesh,
|
|
2297
|
+
// told the node to start what it already had, and reported success —
|
|
2298
|
+
// a stack "deployed" without a single byte leaving this machine.
|
|
2299
|
+
this.logger.error({ project: projectName, stack: stackName, known: this.registry.list().map((p) => p.name) }, 'This project is not in the registry, so nothing was built and nothing will be deployed');
|
|
2300
|
+
refusal =
|
|
2301
|
+
`Refusing to deploy ${stackName}: project '${projectName}' is not in the registry, so nothing was built. ` +
|
|
2302
|
+
`Known projects: ${this.registry.list().map((p) => p.name).join(', ') || '(none)'}.`;
|
|
2303
|
+
}
|
|
2304
|
+
// Before the mesh is opened and before the node is touched at all.
|
|
2305
|
+
if (refusal)
|
|
2306
|
+
throw new Error(refusal);
|
|
2307
|
+
// Resolve master address (from the SLAVE's perspective — what it dials)
|
|
791
2308
|
const { DEFAULT_DAEMON_CONFIG: _dc } = await import('../config/defaults.js');
|
|
792
|
-
const
|
|
2309
|
+
const { resolveMasterHost } = await import('./master-address.js');
|
|
793
2310
|
const masterPort = _dc.port;
|
|
794
|
-
//
|
|
2311
|
+
// One writer per node. Every development machine is a master deploying
|
|
2312
|
+
// to these same nodes, and nothing made them see each other: two
|
|
2313
|
+
// `stack start`s interleaved on one node. Each node's lease is taken
|
|
2314
|
+
// before anything on it changes, confirmed before every step that changes
|
|
2315
|
+
// it, and given back when this deployment is done — `node-deploy-lease.ts`.
|
|
2316
|
+
const targets = new Map();
|
|
795
2317
|
for (const node of nodes) {
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
2318
|
+
targets.set(`${node.host}:${node.port ?? 9700}`, await this.targetForStackNode(node));
|
|
2319
|
+
}
|
|
2320
|
+
phases.enter(`leasing ${nodes.length} node(s)`);
|
|
2321
|
+
return withNodeLeases([...targets].map(([node, target]) => ({
|
|
2322
|
+
node,
|
|
2323
|
+
machine: `${target.host}:${target.sshPort ?? 22}`,
|
|
2324
|
+
run: this.deployer.leaseRunner(target),
|
|
2325
|
+
})), `${projectName}/${stackName}`, this.logger, async (leases) => {
|
|
2326
|
+
// Each node's platform is held at its gateway from before the first
|
|
2327
|
+
// step that can stop an app until the apps answer through it again
|
|
2328
|
+
// (`maintenance-lock.ts`). A node whose deployment throws or is
|
|
2329
|
+
// skipped keeps its lock only until this callback ends.
|
|
2330
|
+
const locks = new Map();
|
|
2331
|
+
try {
|
|
2332
|
+
// Deploy to each node: provision slave → deploy artifacts → connect
|
|
2333
|
+
for (const node of nodes) {
|
|
2334
|
+
const nodeKey = `${node.host}:${node.port ?? 9700}`;
|
|
2335
|
+
if (!leases.has(nodeKey)) {
|
|
2336
|
+
skipped.push(nodeKey);
|
|
2337
|
+
this.logger.error({ node: nodeKey, because: leases.unreachable.get(nodeKey) }, 'Could not reach this node to lease it — skipping');
|
|
2338
|
+
continue;
|
|
2339
|
+
}
|
|
2340
|
+
// 1. Provision slave: install runtime + omnitron + generate slave config + start daemon
|
|
2341
|
+
const unsubProvision = this.deployer.onProgress((progress) => {
|
|
2342
|
+
this.emit('stack:deploy_progress', projectName, stackName, progress);
|
|
2343
|
+
});
|
|
2344
|
+
const target = targets.get(nodeKey);
|
|
2345
|
+
const master = await resolveMasterHost({ advertiseHost: _dc.advertiseHost, bindHost: _dc.host }, target);
|
|
2346
|
+
if (master.host) {
|
|
2347
|
+
this.logger.info({ node: nodeKey, masterHost: master.host, from: master.source }, 'Resolved the master address this slave will dial');
|
|
2348
|
+
}
|
|
2349
|
+
else {
|
|
2350
|
+
// Not an obstacle: the mesh connects the other way. Said once, at
|
|
2351
|
+
// warn level, because a deployment that hands out no master address
|
|
2352
|
+
// is worth noticing if somebody expected the push path to exist.
|
|
2353
|
+
this.logger.warn({ node: nodeKey, because: master.reason }, 'Provisioning this node without a master address — it will be pulled from, not dial in');
|
|
2354
|
+
}
|
|
2355
|
+
await leases.confirm(nodeKey, `provisioning ${node.host}`);
|
|
2356
|
+
const lock = await this.holdForDeployment(target, projectName, stackName, stackConfig, ecosystemConfig, release, phases);
|
|
2357
|
+
if (lock)
|
|
2358
|
+
locks.set(nodeKey, lock);
|
|
2359
|
+
phases.enter(`provisioning ${node.host}`);
|
|
2360
|
+
const provisioned = await this.deployer.provisionSlaveNode(target, master.host, masterPort, projectName);
|
|
2361
|
+
unsubProvision();
|
|
2362
|
+
if (!provisioned) {
|
|
2363
|
+
skipped.push(nodeKey);
|
|
2364
|
+
this.logger.error({ node: nodeKey }, 'Failed to provision slave — skipping');
|
|
2365
|
+
continue;
|
|
2366
|
+
}
|
|
2367
|
+
reached.push(nodeKey);
|
|
2368
|
+
// 2. Bring up the stack's infrastructure, ON the node, before the
|
|
2369
|
+
// applications that need it arrive.
|
|
2370
|
+
//
|
|
2371
|
+
// This step did not exist. A remote stack provisioned the daemon,
|
|
2372
|
+
// shipped the artifacts and opened the mesh connection, and the
|
|
2373
|
+
// stack's `infrastructure` block — its Postgres, its Redis, its
|
|
2374
|
+
// MinIO — was read by the master and never left it. The
|
|
2375
|
+
// applications started on the node and had nothing to connect to,
|
|
2376
|
+
// which surfaces as every one of them failing its first query
|
|
2377
|
+
// rather than as a missing deployment step.
|
|
2378
|
+
//
|
|
2379
|
+
// Ordered before the artifacts deliberately: an app that starts
|
|
2380
|
+
// against a database that is not there yet spends its startup
|
|
2381
|
+
// budget retrying, and a supervisor's deadline turns that into a
|
|
2382
|
+
// crash loop over a condition that would have resolved.
|
|
2383
|
+
// The same infrastructure a local stack would get: the ecosystem's
|
|
2384
|
+
// services — gateway, Tor, tiles — plus whatever this stack declares.
|
|
2385
|
+
// Sending only the stack's own block is why a remote deployment had
|
|
2386
|
+
// databases and no gateway, and no onion address to reach it by.
|
|
2387
|
+
const nodeInfra = mergeInfrastructure(ecosystemConfig, stackConfig);
|
|
2388
|
+
if (nodeInfra || Object.keys(declaredServices).length > 0) {
|
|
2389
|
+
await leases.confirm(nodeKey, `bringing up infrastructure on ${node.host}`);
|
|
2390
|
+
phases.enter(`bringing up infrastructure on ${node.host}`);
|
|
2391
|
+
const infrastructure = await this.provisionNodeInfrastructure(connector, node, (nodeInfra ?? {}), declaredServices,
|
|
2392
|
+
// Resolved, as `resolveNodeAppEnv` resolves them: the node renders
|
|
2393
|
+
// a host service's config from these, and a reference it cannot
|
|
2394
|
+
// resolve — it has no copy of this vault — left the bare-metal
|
|
2395
|
+
// templates with nothing but the declaration's own credentials.
|
|
2396
|
+
{
|
|
2397
|
+
project: projectName,
|
|
2398
|
+
stack: stackName,
|
|
2399
|
+
overrides: await this.resolveOverrideSecrets(projectName, stackConfig.serviceOverrides),
|
|
2400
|
+
}, project?.path, release?.staticsDir ?? undefined);
|
|
2401
|
+
if (!infrastructure.ready) {
|
|
2402
|
+
// Not fatal: a node whose infrastructure is incomplete can still
|
|
2403
|
+
// be looked at, and stopping here would leave the fleet in a state
|
|
2404
|
+
// no command describes. It is said at error level with the node —
|
|
2405
|
+
// and carried to the start's answer and its audit row, where it
|
|
2406
|
+
// used to stop: bitcoind failed to start on the test node and the
|
|
2407
|
+
// deployment reported «6/6 apps online», exit 0, `outcome: ok`.
|
|
2408
|
+
this.logger.error({ node: nodeKey, stack: stackName, detail: infrastructure.detail }, 'Node infrastructure is not ready — deploying anyway, applications may not reach their databases');
|
|
2409
|
+
notReady.push(`${nodeKey}: ${infrastructure.detail}`);
|
|
2410
|
+
}
|
|
2411
|
+
}
|
|
2412
|
+
// 2b. Ask the node what it actually provisioned.
|
|
2413
|
+
//
|
|
2414
|
+
// The credentials are generated ON THE NODE — `provisionStack` runs
|
|
2415
|
+
// `withGeneratedCredentials` against the node's own vault — so the
|
|
2416
|
+
// master's copy of the stack's infrastructure carries whatever was
|
|
2417
|
+
// DECLARED, which for a stack that declares none is nothing at all.
|
|
2418
|
+
// Writing that into the node's config left `resolveStackAddresses`
|
|
2419
|
+
// with no `infrastructure` block, and its fallback chain ends in a
|
|
2420
|
+
// literal:
|
|
2421
|
+
//
|
|
2422
|
+
// infra?.postgres?.password ?? getEnv().POSTGRES_PASSWORD ?? 'postgres'
|
|
2423
|
+
//
|
|
2424
|
+
// Six apps were handed `postgres://postgres:postgres@localhost:5432/…`
|
|
2425
|
+
// against a container holding a 43-character generated secret, and
|
|
2426
|
+
// every one of them died with `password authentication failed for user
|
|
2427
|
+
// "postgres" (28P01)` after five retries.
|
|
2428
|
+
//
|
|
2429
|
+
// Only the secrets are taken from the node: ports, database names and
|
|
2430
|
+
// the rest stay as this stack declared them, because those are the
|
|
2431
|
+
// master's to decide and the node merely carried them out.
|
|
2432
|
+
phases.enter(`reading credentials from ${node.host}`);
|
|
2433
|
+
const nodeCredentials = await this.readNodeCredentials(connector, node, CREDENTIALED_SERVICES.filter((service) => Boolean(nodeInfra?.[service])));
|
|
2434
|
+
const deployedInfra = overlayCredentials(nodeInfra, nodeCredentials);
|
|
2435
|
+
// Resolved as a LOCAL stack, because from the node's side these
|
|
2436
|
+
// services are: containers on that machine, ports on its loopback. The
|
|
2437
|
+
// remote branch of `resolveStackAddresses` answers with the node's
|
|
2438
|
+
// public host, which is the master's way of reaching it and not the
|
|
2439
|
+
// app's — and omnitron publishes every managed port on 127.0.0.1, so
|
|
2440
|
+
// that address reaches nothing from inside the node.
|
|
2441
|
+
//
|
|
2442
|
+
// Per app, and NOT as an `infrastructure` block in the node's config.
|
|
2443
|
+
// Writing one was the first attempt and it was worse than the problem:
|
|
2444
|
+
// a stack with an infrastructure block is a stack the node PROVISIONS,
|
|
2445
|
+
// so the node autostarted its own `deployed` stack, built a second
|
|
2446
|
+
// complete set of containers under `daos-deployed-*` on empty volumes,
|
|
2447
|
+
// and swept the master's `daos-test-*` as orphans. An address is not an
|
|
2448
|
+
// instruction to build what it points at.
|
|
2449
|
+
phases.enter(`resolving the environment for ${node.host}`);
|
|
2450
|
+
const appEnv = await this.resolveNodeAppEnv(ecosystemConfig, projectName, stackConfig, appEntries, deployedInfra);
|
|
2451
|
+
// The stack's own, over the computed — as on a local stack, where
|
|
2452
|
+
// `settings.env` wins over both.
|
|
2453
|
+
for (const [app, env] of Object.entries(stackEnv))
|
|
2454
|
+
appEnv[app] = { ...(appEnv[app] ?? {}), ...env };
|
|
2455
|
+
// 3. Deploy app artifacts via SSH
|
|
2456
|
+
if (artifacts.length > 0) {
|
|
2457
|
+
const unsubDeploy = this.deployer.onProgress((progress) => {
|
|
2458
|
+
this.emit('stack:deploy_progress', projectName, stackName, progress);
|
|
2459
|
+
});
|
|
2460
|
+
// The definitions travel with the artifacts. A node that receives
|
|
2461
|
+
// one without the other has files it cannot run, and says so only if
|
|
2462
|
+
// someone asks it directly.
|
|
2463
|
+
await leases.confirm(nodeKey, `delivering ${artifacts.length} artifact(s) to ${node.host}`);
|
|
2464
|
+
phases.enter(`delivering ${artifacts.length} artifact(s) to ${node.host}`);
|
|
2465
|
+
const sink = ecosystemConfig.monitoring?.alertSink;
|
|
2466
|
+
const results = await this.deployer.deployToStack([target], artifacts, projectName, {
|
|
2467
|
+
apps: appEntries,
|
|
2468
|
+
appEnv,
|
|
2469
|
+
stack: stackName,
|
|
2470
|
+
// Past the node's record of what each app runs: ship and restart all.
|
|
2471
|
+
...(reinstall ? { force: true } : {}),
|
|
2472
|
+
// The node counts its own alarms and hands them to this project's
|
|
2473
|
+
// sink on the stack it runs; named as the master names the stack.
|
|
2474
|
+
...(sink && { alertSink: { ...sink, host: `${projectName}/${stackName}` } }),
|
|
2475
|
+
});
|
|
2476
|
+
unsubDeploy();
|
|
2477
|
+
const failed = results.filter((r) => r.status === 'failed');
|
|
2478
|
+
if (failed.length > 0) {
|
|
2479
|
+
// The other half of the same invariant. Building every artifact
|
|
2480
|
+
// and then failing to DELIVER some of them leaves exactly the
|
|
2481
|
+
// split state the throw above exists to prevent, and it was a
|
|
2482
|
+
// WARN — a level nothing acts on, under a `Stack started` that
|
|
2483
|
+
// follows regardless.
|
|
2484
|
+
this.logger.error({ node: nodeKey, failed: failed.map((f) => f.app) }, 'App deployments failed');
|
|
2485
|
+
throw new Error(`Deployment to ${nodeKey} failed for ${failed.length} of ${results.length} app(s): ` +
|
|
2486
|
+
failed.map((f) => f.app).join(', ') +
|
|
2487
|
+
'. The node is in a mixed state — re-run the deployment once the cause is fixed.');
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
// 3b. Let the platform go — once it answers through the gateway, as
|
|
2491
|
+
// a person's request will. One that does not keeps the lock,
|
|
2492
|
+
// which then expires by itself; said, and carried to the answer.
|
|
2493
|
+
const held = locks.get(nodeKey);
|
|
2494
|
+
if (held) {
|
|
2495
|
+
locks.delete(nodeKey);
|
|
2496
|
+
phases.enter(`lifting maintenance on ${node.host}`);
|
|
2497
|
+
const lifted = await held.liftWhenAnswering(MAINTENANCE_PROBE_PATHS);
|
|
2498
|
+
if (!lifted.lifted) {
|
|
2499
|
+
notReady.push(`${nodeKey}: the platform did not answer through the gateway (${lifted.failed.join(', ')}) — ` +
|
|
2500
|
+
'it stays in maintenance until the lock expires, or `omnitron stack maintenance … off`');
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
// 4. Connect master to slave daemon via Netron TCP
|
|
2504
|
+
await leases.confirm(nodeKey, `joining ${node.host} to the mesh`);
|
|
2505
|
+
phases.enter(`joining ${node.host} to the mesh`);
|
|
2506
|
+
await connector.addSlave({
|
|
2507
|
+
host: node.host,
|
|
2508
|
+
port: node.port ?? 9700,
|
|
2509
|
+
label: node.label,
|
|
2510
|
+
stack: stackName,
|
|
2511
|
+
project: projectName,
|
|
2512
|
+
});
|
|
2513
|
+
}
|
|
2514
|
+
// Not one node took the deployment. Nothing was installed, nothing was
|
|
2515
|
+
// started, and the applications on those machines — if any are still up
|
|
2516
|
+
// — are running whatever they were running before. Marking the stack
|
|
2517
|
+
// `running` and recording a `stack.start` after that is how a trail
|
|
2518
|
+
// acquires a deployment that did not happen.
|
|
2519
|
+
if (reached.length === 0 && nodes.length > 0) {
|
|
2520
|
+
throw new Error(`Refusing to call ${stackName} started: none of its ${nodes.length} node(s) could be provisioned — ` +
|
|
2521
|
+
`${skipped.join(', ')}. The per-node errors are above.`);
|
|
2522
|
+
}
|
|
2523
|
+
return { nodes: nodes.length, reached: reached.length, skipped, notReady };
|
|
807
2524
|
}
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
const results = await this.deployer.deployToStack([node], artifacts, projectName);
|
|
814
|
-
unsubDeploy();
|
|
815
|
-
const failed = results.filter((r) => r.status === 'failed');
|
|
816
|
-
if (failed.length > 0) {
|
|
817
|
-
this.logger.warn({ node: nodeKey, failed: failed.map((f) => f.app) }, 'Some app deployments failed');
|
|
2525
|
+
finally {
|
|
2526
|
+
// A node whose deployment threw or was skipped: its platform is not
|
|
2527
|
+
// held past this deployment — the state it is in is the one to see.
|
|
2528
|
+
for (const [nodeKey, lock] of locks) {
|
|
2529
|
+
await lock.lift('failed').catch((err) => this.logger.error({ node: nodeKey, err: err.message }, 'Could not lift maintenance — it expires by itself'));
|
|
818
2530
|
}
|
|
819
2531
|
}
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
2532
|
+
});
|
|
2533
|
+
}
|
|
2534
|
+
/**
|
|
2535
|
+
* Warn the platform on this node, then hold it at its gateway for the
|
|
2536
|
+
* deployment (`maintenance-lock.ts`). `null` when the stack opted out, or
|
|
2537
|
+
* when the node has no running gateway and Redis to hold with — a first
|
|
2538
|
+
* deployment, which nobody is using yet.
|
|
2539
|
+
*/
|
|
2540
|
+
async holdForDeployment(target, projectName, stackName, stackConfig, ecosystemConfig, release, phases) {
|
|
2541
|
+
const settings = stackConfig.settings?.maintenance;
|
|
2542
|
+
if (settings?.disabled) {
|
|
2543
|
+
this.logger.warn({ stack: stackName }, 'Maintenance lock disabled for this stack — deploying under its users');
|
|
2544
|
+
return null;
|
|
2545
|
+
}
|
|
2546
|
+
const timing = {
|
|
2547
|
+
...DEFAULT_MAINTENANCE_TIMING,
|
|
2548
|
+
...(settings?.noticeSeconds !== undefined ? { noticeSeconds: settings.noticeSeconds } : {}),
|
|
2549
|
+
...(settings?.etaSeconds !== undefined ? { etaSeconds: settings.etaSeconds } : {}),
|
|
2550
|
+
};
|
|
2551
|
+
// The database by the one rule the gateway on the node used for its own
|
|
2552
|
+
// REDIS_DB — from the same merged infrastructure the node was sent.
|
|
2553
|
+
const infra = mergeInfrastructure(ecosystemConfig, stackConfig);
|
|
2554
|
+
if (timing.noticeSeconds > 0)
|
|
2555
|
+
phases.enter(`warning the platform: maintenance in ${timing.noticeSeconds} s`);
|
|
2556
|
+
return MaintenanceLock.open({
|
|
2557
|
+
run: this.deployer.leaseRunner(target),
|
|
2558
|
+
prefix: stackConfig.settings?.containerPrefix ?? `${projectName}-${stackName}`,
|
|
2559
|
+
db: gatewayRedisDb(infra?.redis?.databases),
|
|
2560
|
+
release: release?.id ?? 'unreleased',
|
|
2561
|
+
}, this.logger, timing);
|
|
2562
|
+
}
|
|
2563
|
+
/**
|
|
2564
|
+
* Ask a node to bring up the infrastructure a stack declares.
|
|
2565
|
+
*
|
|
2566
|
+
* Over the mesh connection rather than over SSH: the node already runs a
|
|
2567
|
+
* reconciler that knows how to wait on a container's health, how to detect
|
|
2568
|
+
* a drifted spec, and how to clean up a phantom endpoint. Driving `docker`
|
|
2569
|
+
* over SSH from here would be a second implementation of all of it, on the
|
|
2570
|
+
* side of the wire with the least information.
|
|
2571
|
+
*
|
|
2572
|
+
* The connection is opened for this and kept — it is the same one the mesh
|
|
2573
|
+
* uses afterwards, so the node is joined by the time its applications
|
|
2574
|
+
* start rather than at the next heartbeat.
|
|
2575
|
+
*/
|
|
2576
|
+
/**
|
|
2577
|
+
* Deliver each service's static content to the node, and report where.
|
|
2578
|
+
*
|
|
2579
|
+
* Only services that declare a `staticDir`, which today is the gateway
|
|
2580
|
+
* serving the portal. A stack that declares none sends nothing and this
|
|
2581
|
+
* costs nothing.
|
|
2582
|
+
*
|
|
2583
|
+
* A failure is reported and not thrown: a node whose databases came up and
|
|
2584
|
+
* whose frontend did not is a node worth looking at, and stopping the whole
|
|
2585
|
+
* provisioning over a frontend leaves less working, not more.
|
|
2586
|
+
*/
|
|
2587
|
+
/**
|
|
2588
|
+
* What each deployed app should connect to, once it is on the node.
|
|
2589
|
+
*
|
|
2590
|
+
* The same `resolveStack` + `resolvedConfigToEnv` pair a local stack uses,
|
|
2591
|
+
* fed the infrastructure the node actually provisioned — so there is one
|
|
2592
|
+
* implementation of "what is DATABASE_URL" rather than a second one written
|
|
2593
|
+
* for nodes, which would drift from the first the first time either
|
|
2594
|
+
* changed.
|
|
2595
|
+
*
|
|
2596
|
+
* `type: 'local'` is not a pretence: the apps run as host processes on the
|
|
2597
|
+
* node, beside containers whose ports are published on that node's
|
|
2598
|
+
* loopback. Local is what they are from where they stand.
|
|
2599
|
+
*/
|
|
2600
|
+
async resolveNodeAppEnv(ecosystemConfig, projectName, stackConfig, appEntries, infrastructure) {
|
|
2601
|
+
if (!infrastructure)
|
|
2602
|
+
return {};
|
|
2603
|
+
try {
|
|
2604
|
+
// The app DEFINITIONS, not an empty map. `resolveStack` reads each
|
|
2605
|
+
// app's `omnitron.infrastructure` to learn which database it wants,
|
|
2606
|
+
// which redis index, which bucket — and without them it produces an
|
|
2607
|
+
// app config with no `database` at all, so `resolvedConfigToEnv` emits
|
|
2608
|
+
// `APP_NAME`, `STACK_NAME` and `JWT_SECRET` and no `DATABASE_URL`.
|
|
2609
|
+
//
|
|
2610
|
+
// Measured: the node's config carried five environment variables per
|
|
2611
|
+
// app and not the one the apps were failing for. An empty map is not a
|
|
2612
|
+
// map of nothing declared; it is a map nobody filled.
|
|
2613
|
+
const definitions = await this.loadAppDefinitions(projectName, stackConfig, ecosystemConfig);
|
|
2614
|
+
// The stack's answers about services it does not provision.
|
|
2615
|
+
//
|
|
2616
|
+
// `serviceOverrides` is where a stack says that bitcoin and monero are
|
|
2617
|
+
// not containers here — they are daemons already running on the node,
|
|
2618
|
+
// on mainnet, at an address only this stack knows. It was left out of
|
|
2619
|
+
// the config this resolver was given, so a deployed app was configured
|
|
2620
|
+
// with the defaults its own declaration carries. Measured on the test
|
|
2621
|
+
// node, whose chains run at 192.168.100.2 with mainnet credentials:
|
|
2622
|
+
//
|
|
2623
|
+
// BITCOIN_RPC_URL http://localhost:18443 (regtest)
|
|
2624
|
+
// MONERO_DAEMON_URL http://localhost:38081 (stagenet)
|
|
2625
|
+
// MONERO_RPC_PASS omni_stagenet_dev_password
|
|
2626
|
+
//
|
|
2627
|
+
// — three facts about a laptop, written into the configuration of a
|
|
2628
|
+
// server, in the one subsystem where being wrong costs money.
|
|
2629
|
+
const serviceOverrides = await this.resolveOverrideSecrets(projectName, stackConfig.serviceOverrides);
|
|
2630
|
+
const resolved = resolveStack({ ...ecosystemConfig, infrastructure }, projectName, NODE_STACK, {
|
|
2631
|
+
type: 'local',
|
|
2632
|
+
apps: appEntries.map((a) => a.name),
|
|
2633
|
+
...(serviceOverrides ? { serviceOverrides } : {}),
|
|
2634
|
+
}, definitions, undefined);
|
|
2635
|
+
const out = {};
|
|
2636
|
+
for (const entry of appEntries) {
|
|
2637
|
+
const appConfig = resolved.appConfigs.get(entry.name);
|
|
2638
|
+
if (!appConfig)
|
|
2639
|
+
continue;
|
|
2640
|
+
out[entry.name] = resolvedConfigToEnv(appConfig, entry.name, NODE_STACK);
|
|
2641
|
+
}
|
|
2642
|
+
return out;
|
|
2643
|
+
}
|
|
2644
|
+
catch (err) {
|
|
2645
|
+
// Not fatal, and loud: the apps would start with whatever their own
|
|
2646
|
+
// definitions carry, which is how they came to be started with
|
|
2647
|
+
// `postgres:postgres` in the first place.
|
|
2648
|
+
this.logger.error({ project: projectName, error: err.message }, 'Could not resolve what the deployed apps connect to — they will start with their declared env only');
|
|
2649
|
+
return {};
|
|
2650
|
+
}
|
|
2651
|
+
}
|
|
2652
|
+
/**
|
|
2653
|
+
* A stack's service overrides with their secret references resolved.
|
|
2654
|
+
*
|
|
2655
|
+
* An override names its credentials rather than carrying them:
|
|
2656
|
+
*
|
|
2657
|
+
* "secrets": { "rpc_password": { "secret": "monero.mainnet.rpc_password" } }
|
|
2658
|
+
*
|
|
2659
|
+
* `resolveSecretRefs` has existed for that since it was written and had no
|
|
2660
|
+
* caller at all, so the synchronous resolver downstream turned every one of
|
|
2661
|
+
* these into the literal string `<secret:monero.mainnet.rpc_password>` and
|
|
2662
|
+
* handed it to the application as a password.
|
|
2663
|
+
*
|
|
2664
|
+
* A key the vault does not hold resolves to an empty string, which an
|
|
2665
|
+
* application reports as an authentication failure against a credential it
|
|
2666
|
+
* was never given. That is worth a line naming the key.
|
|
2667
|
+
*/
|
|
2668
|
+
async resolveOverrideSecrets(projectName, overrides) {
|
|
2669
|
+
if (!overrides || Object.keys(overrides).length === 0)
|
|
2670
|
+
return overrides;
|
|
2671
|
+
if (!this.secrets) {
|
|
2672
|
+
this.logger.warn({ project: projectName, services: Object.keys(overrides) }, 'No secrets store here — service overrides keep their references, and an app given one as a password cannot authenticate');
|
|
2673
|
+
return overrides;
|
|
2674
|
+
}
|
|
2675
|
+
const missing = [];
|
|
2676
|
+
const { resolveSecretRefs } = await import('../project/config-resolver.js');
|
|
2677
|
+
const resolved = await resolveSecretRefs(overrides, async (key) => {
|
|
2678
|
+
const value = await this.secrets.get(key);
|
|
2679
|
+
if (value === null)
|
|
2680
|
+
missing.push(key);
|
|
2681
|
+
return value;
|
|
2682
|
+
});
|
|
2683
|
+
if (missing.length > 0) {
|
|
2684
|
+
this.logger.error({ project: projectName, missing }, 'These secrets are named by the stack and absent from the vault — the apps will be given an empty credential');
|
|
2685
|
+
}
|
|
2686
|
+
return resolved;
|
|
2687
|
+
}
|
|
2688
|
+
/**
|
|
2689
|
+
* The credentials the NODE resolved, read back from the node.
|
|
2690
|
+
*
|
|
2691
|
+
* `getConnectionInfo` answers from the config `provisionStack` stored after
|
|
2692
|
+
* running `withGeneratedCredentials` against the node's vault, so it is the
|
|
2693
|
+
* only account of these secrets that is true on the machine the apps will
|
|
2694
|
+
* run on.
|
|
2695
|
+
*
|
|
2696
|
+
* `null` is an answer — the node runs no such service — and leaves it as
|
|
2697
|
+
* the stack declared it. A read that did not happen is not an answer. This
|
|
2698
|
+
* was «best-effort per service»: a failed read fell back to the declared
|
|
2699
|
+
* credentials, and for a secret the node generated those are wrong by
|
|
2700
|
+
* construction. Test node, 2026-09-22 21:17:51 UTC: the heartbeat dropped
|
|
2701
|
+
* the peer mid-read («manual disconnect»), the apps were configured with
|
|
2702
|
+
* the declared postgres password, 6 of 6 migrations failed on it, and the
|
|
2703
|
+
* node was left in a mixed state. So a dropped connection is reconnected
|
|
2704
|
+
* and asked once more — this is a read, it can be — and a read that still
|
|
2705
|
+
* fails stops the deployment HERE, before any application is configured
|
|
2706
|
+
* with a password that is not the node's.
|
|
2707
|
+
*/
|
|
2708
|
+
async readNodeCredentials(connector, node,
|
|
2709
|
+
/** The services this stack declares — each must be answered for. */
|
|
2710
|
+
declared = []) {
|
|
2711
|
+
const out = {};
|
|
2712
|
+
for (const service of CREDENTIALED_SERVICES) {
|
|
2713
|
+
let info;
|
|
2714
|
+
try {
|
|
2715
|
+
info = (await connector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronInfra', 'getConnectionInfo', [{ service }], { retryOnDisconnect: true }));
|
|
2716
|
+
}
|
|
2717
|
+
catch (err) {
|
|
2718
|
+
throw new Error(`Could not read ${service}'s credentials from ${node.host}: ${err.message}. ` +
|
|
2719
|
+
'A secret the node generated is known only there, and the one this stack declares would be wrong — ' +
|
|
2720
|
+
'stopping before any application is configured with it.', { cause: err });
|
|
2721
|
+
}
|
|
2722
|
+
if (info)
|
|
2723
|
+
out[service] = info;
|
|
2724
|
+
}
|
|
2725
|
+
// Nothing is an answer only for a service the stack does not run. For one
|
|
2726
|
+
// it does, it means the node's daemon holds no infrastructure for it — it
|
|
2727
|
+
// restarted since it last provisioned, and the step that would have
|
|
2728
|
+
// provisioned it did not reach it. The declared password in its place is
|
|
2729
|
+
// wrong by construction: on daos/test (2026-09-25) a deployment right
|
|
2730
|
+
// after a `fleet upgrade` found the node 66 s into a 60 s mesh wait,
|
|
2731
|
+
// «deployed anyway», configured six apps with `postgres`, and was stopped
|
|
2732
|
+
// only because each app's migrations had to log in first.
|
|
2733
|
+
const missing = declared.filter((service) => !out[service]);
|
|
2734
|
+
if (missing.length > 0) {
|
|
2735
|
+
throw new Error(`${node.host} answered nothing for ${missing.join(', ')}, which this stack runs there: its daemon holds no ` +
|
|
2736
|
+
'infrastructure for them — it restarted since it last provisioned, and this deployment did not reach it to ' +
|
|
2737
|
+
'provision them again (see the infrastructure step above). Their generated credentials are known only there, ' +
|
|
2738
|
+
'so no application is configured with the declared ones. Run the deployment again once the node is in the mesh.');
|
|
2739
|
+
}
|
|
2740
|
+
return out;
|
|
2741
|
+
}
|
|
2742
|
+
async shipStackStatics(infrastructure, projectRoot, node,
|
|
2743
|
+
/**
|
|
2744
|
+
* A release's bundle, built in its clean clone with this stack's
|
|
2745
|
+
* `staticEnv`. Shipped as it is: rebuilding it from this disk would put
|
|
2746
|
+
* the developer's tree back into a release.
|
|
2747
|
+
*/
|
|
2748
|
+
releasedStatics,
|
|
2749
|
+
/**
|
|
2750
|
+
* Whose stack this is. Given, the delivery also points
|
|
2751
|
+
* `current-<project>-<stack>` at what it unpacked, and the roots come back
|
|
2752
|
+
* beside the build directories so the gateway can mount the ROOT instead
|
|
2753
|
+
* of the build — which is what stops it being recreated, and with it the
|
|
2754
|
+
* second of 000 a deployment costs.
|
|
2755
|
+
*/
|
|
2756
|
+
owner) {
|
|
2757
|
+
// Both spellings, like the config reader beside it: a stack may declare
|
|
2758
|
+
// the gateway as a preset service or through the legacy top-level block,
|
|
2759
|
+
// and reading only one is how `serviceOverrides` was honoured for half
|
|
2760
|
+
// the services in this codebase once already.
|
|
2761
|
+
const legacy = infrastructure
|
|
2762
|
+
.gateway;
|
|
2763
|
+
const preset = infrastructure.services?.['gateway'];
|
|
2764
|
+
const staticDir = preset?.config?.staticDir ?? legacy?.staticDir;
|
|
2765
|
+
if (!staticDir)
|
|
2766
|
+
return { dirs: {}, roots: {} };
|
|
2767
|
+
// How to build it, when it turns out to be stale. See `staticEnv`.
|
|
2768
|
+
const staticEnv = preset?.config?.staticEnv ??
|
|
2769
|
+
legacy?.staticEnv ??
|
|
2770
|
+
{};
|
|
2771
|
+
const abs = releasedStatics ??
|
|
2772
|
+
(staticDir.startsWith('/')
|
|
2773
|
+
? staticDir
|
|
2774
|
+
: `${projectRoot.replace(/\/$/, '')}/${staticDir.replace(/^\.\//, '')}`);
|
|
2775
|
+
try {
|
|
2776
|
+
// Build it if it is behind, for the same reason a vendored package is
|
|
2777
|
+
// built if its `dist` is behind: "deploy" has to mean "deploy what is
|
|
2778
|
+
// in the tree". Measured on the test stack before this existed — the
|
|
2779
|
+
// `index.html` the gateway was serving had been built ten days and 879
|
|
2780
|
+
// source files earlier, so the test portal was not a test of anything
|
|
2781
|
+
// in the working tree, and nothing said so because nothing looked.
|
|
2782
|
+
//
|
|
2783
|
+
// Best-effort: a frontend that will not build is still worth shipping
|
|
2784
|
+
// as it stands, beside an error that says which it is. Refusing would
|
|
2785
|
+
// block a deployment whose BACKENDS are what changed.
|
|
2786
|
+
const stale = releasedStatics ? null : staleBuild(`${path.dirname(abs)}/src`, abs);
|
|
2787
|
+
if (stale) {
|
|
2788
|
+
this.logger.info({ dir: abs, stale }, 'The built frontend is behind its sources — rebuilding');
|
|
2789
|
+
try {
|
|
2790
|
+
const { resolvePnpm } = await import('../shared/pnpm.js');
|
|
2791
|
+
const { execFile } = await import('node:child_process');
|
|
2792
|
+
const { promisify } = await import('node:util');
|
|
2793
|
+
await promisify(execFile)(resolvePnpm(), ['run', 'build'], {
|
|
2794
|
+
cwd: path.dirname(abs),
|
|
2795
|
+
timeout: 900_000,
|
|
2796
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
2797
|
+
env: { ...process.env, ...staticEnv },
|
|
2798
|
+
});
|
|
2799
|
+
}
|
|
2800
|
+
catch (err) {
|
|
2801
|
+
this.logger.error({
|
|
2802
|
+
dir: abs,
|
|
2803
|
+
error: err.message.slice(0, 800),
|
|
2804
|
+
staticEnv: Object.keys(staticEnv),
|
|
2805
|
+
}, 'Could not rebuild the frontend — shipping the build that is there. ' +
|
|
2806
|
+
'A build that refuses to guess needs `staticEnv` on the gateway service.');
|
|
2807
|
+
}
|
|
2808
|
+
}
|
|
2809
|
+
// The same target the deployer uses for artifacts — with the node's SSH
|
|
2810
|
+
// user and credential. Passing a bare `{ host }` is how the first
|
|
2811
|
+
// attempt failed: `Failed to connect to 37.27.130.185`, an error about
|
|
2812
|
+
// credentials that were sitting in the registry all along.
|
|
2813
|
+
const target = await this.targetForStackNode(node);
|
|
2814
|
+
// Shared by every stack on the NODE — the literal names no project and
|
|
2815
|
+
// no stack — which is why the link inside it is per stack.
|
|
2816
|
+
const gatewayRoot = '/opt/omnitron/stack-static/gateway';
|
|
2817
|
+
const link = owner ? gatewayStaticLinkName(owner.project, owner.stack) : undefined;
|
|
2818
|
+
const deliver = () => this.deployer.uploadStaticBundle(target, abs, gatewayRoot, link);
|
|
2819
|
+
// Once more before giving up: `uploadStaticBundle` starts from nothing
|
|
2820
|
+
// each time — it removes what a failed attempt left — so a second try
|
|
2821
|
+
// is a clean one, and a transfer that broke once on a loaded link
|
|
2822
|
+
// usually does not break twice.
|
|
2823
|
+
const { remoteDir, bytes } = await deliver().catch((first) => {
|
|
2824
|
+
this.logger.warn({ node: node.host, service: 'gateway', dir: abs, error: first.message.slice(0, 400) }, 'The frontend did not reach the node whole — sending it once more');
|
|
2825
|
+
return deliver();
|
|
827
2826
|
});
|
|
2827
|
+
this.logger.info({ node: node.host, service: 'gateway', from: abs, remoteDir, bytes }, bytes === 0 ? 'The node already has this build' : 'Frontend delivered to the node');
|
|
2828
|
+
// The root travels only when the link was made: a node that gets a root
|
|
2829
|
+
// mounts it and serves through the link, and one that does not behaves
|
|
2830
|
+
// exactly as it did before. An ADDITIVE field for the same reason — the
|
|
2831
|
+
// master's omni may legitimately be newer than the node's, so a changed
|
|
2832
|
+
// value type would break both mixing directions while a field an old
|
|
2833
|
+
// node ignores breaks neither.
|
|
2834
|
+
return { dirs: { gateway: remoteDir }, roots: link ? { gateway: gatewayRoot } : {} };
|
|
2835
|
+
}
|
|
2836
|
+
catch (err) {
|
|
2837
|
+
// Fatal, and before anything on the node changes. This returned `{}`,
|
|
2838
|
+
// and the node was then provisioned with no static root: the gateway,
|
|
2839
|
+
// recreated without its `/var/www/portal`, fell back to proxying `/` to
|
|
2840
|
+
// a Vite that is not there, and answered 504 after 60 s to every page
|
|
2841
|
+
// — while the deployment reported «6/6 apps online» and exited 0.
|
|
2842
|
+
// Measured on test, 2026-09-22 20:12–20:20 UTC. Refusing here leaves
|
|
2843
|
+
// the gateway serving the frontend it had.
|
|
2844
|
+
this.logger.error({ node: node.host, service: 'gateway', dir: abs, error: err.message }, 'Could not deliver the frontend — refusing to provision a gateway that would serve nothing at /');
|
|
2845
|
+
throw new FrontendNotDeliveredError(node.host, err.message);
|
|
2846
|
+
}
|
|
2847
|
+
}
|
|
2848
|
+
/**
|
|
2849
|
+
* Where the stack's static build lives, absolute, or undefined.
|
|
2850
|
+
*
|
|
2851
|
+
* Both spellings, and the same resolution `shipStackStatics` performs —
|
|
2852
|
+
* named once because two questions ask it: what to ship, and whether the
|
|
2853
|
+
* trees it is built from are the commits they claim.
|
|
2854
|
+
*/
|
|
2855
|
+
staticDirOf(infrastructure, projectRoot) {
|
|
2856
|
+
if (!infrastructure)
|
|
2857
|
+
return undefined;
|
|
2858
|
+
const legacy = infrastructure.gateway;
|
|
2859
|
+
const preset = infrastructure
|
|
2860
|
+
.services?.['gateway'];
|
|
2861
|
+
const staticDir = preset?.config?.staticDir ?? legacy?.staticDir;
|
|
2862
|
+
if (!staticDir)
|
|
2863
|
+
return undefined;
|
|
2864
|
+
return staticDir.startsWith('/')
|
|
2865
|
+
? staticDir
|
|
2866
|
+
: `${projectRoot.replace(/\/$/, '')}/${staticDir.replace(/^\.\//, '')}`;
|
|
2867
|
+
}
|
|
2868
|
+
/**
|
|
2869
|
+
* The config files the stack's services need, read from this master.
|
|
2870
|
+
*
|
|
2871
|
+
* Only services that name a directory to ship (`shippedDirOf`) — the
|
|
2872
|
+
* gateway's nginx tree, Nominatim's import scripts. A stack that names none
|
|
2873
|
+
* sends nothing, so this costs nothing where it is not needed.
|
|
2874
|
+
*/
|
|
2875
|
+
async readStackConfigFiles(infrastructure, projectRoot,
|
|
2876
|
+
/** Filled with each service whose directory was sent → that directory's absolute path here. */
|
|
2877
|
+
shippedDirs,
|
|
2878
|
+
/** The services the stack's apps require — each may name a directory of its own, as the stack's may. */
|
|
2879
|
+
services) {
|
|
2880
|
+
const { readConfigDirectory } = await import('../infrastructure/config-payload.js');
|
|
2881
|
+
const { shippedDirsOf } = await import('../infrastructure/shipped-config.js');
|
|
2882
|
+
const fsp = await import('node:fs/promises');
|
|
2883
|
+
const payload = {};
|
|
2884
|
+
// The stack's own services first — an app's requirement of the same name
|
|
2885
|
+
// is the same service, and one directory is shipped for it.
|
|
2886
|
+
const candidates = shippedDirsOf(infrastructure, services);
|
|
2887
|
+
for (const [name, configDir] of candidates) {
|
|
2888
|
+
const abs = configDir.startsWith('/')
|
|
2889
|
+
? configDir
|
|
2890
|
+
: `${projectRoot.replace(/\/$/, '')}/${configDir.replace(/^\.\//, '')}`;
|
|
2891
|
+
try {
|
|
2892
|
+
const { files, skipped } = await readConfigDirectory(abs, {
|
|
2893
|
+
readdir: (d) => fsp.readdir(d, { withFileTypes: true }),
|
|
2894
|
+
readFile: (f) => fsp.readFile(f, 'utf-8'),
|
|
2895
|
+
stat: (f) => fsp.stat(f),
|
|
2896
|
+
});
|
|
2897
|
+
if (files.length > 0) {
|
|
2898
|
+
payload[name] = files;
|
|
2899
|
+
shippedDirs?.set(name, abs);
|
|
2900
|
+
}
|
|
2901
|
+
if (skipped.length > 0) {
|
|
2902
|
+
// Said out loud: a file left behind changes what the service does,
|
|
2903
|
+
// and silence here would make the node's behaviour unexplainable
|
|
2904
|
+
// from the master.
|
|
2905
|
+
this.logger.warn({ service: name, dir: abs, skipped }, 'Some files were not sent to the node');
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
catch (err) {
|
|
2909
|
+
// Not fatal. A stack whose gateway config is missing still gets its
|
|
2910
|
+
// databases, and the gateway fails visibly rather than the whole
|
|
2911
|
+
// provisioning stopping before anything was done.
|
|
2912
|
+
this.logger.error({ service: name, dir: abs, error: err.message }, 'Could not read this service\'s config directory — the node will run it unconfigured');
|
|
2913
|
+
}
|
|
2914
|
+
}
|
|
2915
|
+
return payload;
|
|
2916
|
+
}
|
|
2917
|
+
async provisionNodeInfrastructure(connector, node, infrastructure, services, owner,
|
|
2918
|
+
/**
|
|
2919
|
+
* This project's root on the MASTER, for reading the config files a
|
|
2920
|
+
* service needs on the node.
|
|
2921
|
+
*
|
|
2922
|
+
* The gateway is configured by files — an nginx template, an entrypoint,
|
|
2923
|
+
* Lua modules — and `resolveGateway` mounts them from this path. A node
|
|
2924
|
+
* has no copy of the project, so on a node those mounts do not exist and
|
|
2925
|
+
* the container comes up as bare openresty: measured on the test server,
|
|
2926
|
+
* an empty `Mounts` array, a null entrypoint, zero UPSTREAM variables,
|
|
2927
|
+
* and an onion serving `Welcome to OpenResty!` over Tor.
|
|
2928
|
+
*/
|
|
2929
|
+
projectRoot,
|
|
2930
|
+
/** A release's static bundle, shipped as it was built — see `shipStackStatics`. */
|
|
2931
|
+
releasedStatics) {
|
|
2932
|
+
const host = node.host;
|
|
2933
|
+
const port = node.port ?? 9700;
|
|
2934
|
+
await connector.addSlave({ host, port, label: node.label });
|
|
2935
|
+
// `addSlave` starts the connection and returns; invoking straight after
|
|
2936
|
+
// races it. Measured: "Slave 37.27.130.185:9700 not connected", against
|
|
2937
|
+
// a node the master connected to successfully two seconds later.
|
|
2938
|
+
if (!(await connector.waitUntilConnected(host, port, 60_000))) {
|
|
2939
|
+
this.logger.error({ node: `${host}:${port}` }, 'This node did not join the mesh within a minute — its infrastructure cannot be brought up from here');
|
|
2940
|
+
return { ready: false, detail: 'the node did not join the mesh within a minute' };
|
|
2941
|
+
}
|
|
2942
|
+
try {
|
|
2943
|
+
const shippedDirs = new Map();
|
|
2944
|
+
const configFiles = projectRoot ? await this.readStackConfigFiles(infrastructure, projectRoot, shippedDirs, services) : {};
|
|
2945
|
+
// A service's bind mounts inside its shipped directory name the node's
|
|
2946
|
+
// copy, not this machine's path — the stack's own services and the ones
|
|
2947
|
+
// its apps require alike. See `shipped-config.ts`.
|
|
2948
|
+
const { pointMountsAtShippedConfig } = await import('../infrastructure/shipped-config.js');
|
|
2949
|
+
const configForNode = projectRoot && infrastructure.services
|
|
2950
|
+
? { ...infrastructure, services: pointMountsAtShippedConfig(infrastructure.services, shippedDirs, projectRoot) }
|
|
2951
|
+
: infrastructure;
|
|
2952
|
+
const servicesForNode = projectRoot && services ? pointMountsAtShippedConfig(services, shippedDirs, projectRoot) : services;
|
|
2953
|
+
// The frontend the gateway serves, if this stack declares one. It goes
|
|
2954
|
+
// by SSH rather than inside the call: 32 MB is the wrong size for an
|
|
2955
|
+
// RPC argument, which is held whole on both sides and blocks the call
|
|
2956
|
+
// it rides on. Config files are 65 KB and ride along; a build does not.
|
|
2957
|
+
const shipped = projectRoot
|
|
2958
|
+
? await this.shipStackStatics(infrastructure, projectRoot, node, releasedStatics, owner)
|
|
2959
|
+
: { dirs: {}, roots: {} };
|
|
2960
|
+
const staticRoots = shipped.dirs;
|
|
2961
|
+
const staticLinkRoots = shipped.roots;
|
|
2962
|
+
// Retry once if the connection turns out to be gone: the deployer just
|
|
2963
|
+
// restarted this node's daemon, so the mesh connection the master holds
|
|
2964
|
+
// was established to the process that exited. `provisionStack` is a
|
|
2965
|
+
// reconciler, so applying it twice is applying it once.
|
|
2966
|
+
const report = (await connector.invokeOnSlave(host, port, 'OmnitronInfra', 'provisionStack', [{ config: configForNode, services: servicesForNode, ...(owner ?? {}), configFiles, staticRoots, staticLinkRoots }], { retryOnDisconnect: true }));
|
|
2967
|
+
this.logger.info({
|
|
2968
|
+
node: `${host}:${port}`,
|
|
2969
|
+
ready: report?.ready ?? false,
|
|
2970
|
+
running: report?.running?.length ?? 0,
|
|
2971
|
+
missing: report?.missing ?? [],
|
|
2972
|
+
failed: report?.failed ?? [],
|
|
2973
|
+
}, report?.detail ?? 'Node infrastructure provisioned');
|
|
2974
|
+
// The node's own words travel with the verdict: «not ready» without
|
|
2975
|
+
// them is a sentence nobody downstream can act on.
|
|
2976
|
+
return { ready: report?.ready === true, detail: report?.detail ?? 'the node did not say' };
|
|
2977
|
+
}
|
|
2978
|
+
catch (err) {
|
|
2979
|
+
// Not «infrastructure incomplete, deploying anyway»: the node was not
|
|
2980
|
+
// touched, and going on would put new backends behind the old frontend.
|
|
2981
|
+
if (err instanceof FrontendNotDeliveredError)
|
|
2982
|
+
throw err;
|
|
2983
|
+
this.logger.error({ node: `${host}:${port}`, error: err.message }, 'Could not bring up this node\'s infrastructure');
|
|
2984
|
+
return { ready: false, detail: `could not ask the node: ${err.message}` };
|
|
828
2985
|
}
|
|
829
2986
|
}
|
|
830
2987
|
async startClusterStack(projectName, stackName, stackConfig, ecosystemConfig) {
|
|
@@ -841,37 +2998,70 @@ export class ProjectService extends EventEmitter {
|
|
|
841
2998
|
if (project) {
|
|
842
2999
|
try {
|
|
843
3000
|
const { ArtifactBuilder } = await import('../project/artifact-builder.js');
|
|
844
|
-
const builder = new ArtifactBuilder(project.path
|
|
845
|
-
|
|
3001
|
+
const builder = new ArtifactBuilder(project.path, undefined, {
|
|
3002
|
+
info: (msg) => this.logger.info({ project: project.name }, msg),
|
|
3003
|
+
});
|
|
3004
|
+
const outcome = await builder.buildAll(appEntries);
|
|
3005
|
+
artifacts = outcome.built;
|
|
3006
|
+
for (const f of outcome.failed) {
|
|
3007
|
+
this.logger.error({ app: f.app, error: f.error }, 'Artifact build failed');
|
|
3008
|
+
}
|
|
846
3009
|
}
|
|
847
3010
|
catch (err) {
|
|
848
3011
|
this.logger.error({ error: err.message }, 'Artifact build failed');
|
|
849
3012
|
}
|
|
850
3013
|
}
|
|
851
|
-
// Resolve master address
|
|
3014
|
+
// Resolve master address (from the SLAVE's perspective — what it dials)
|
|
852
3015
|
const { DEFAULT_DAEMON_CONFIG: _dc } = await import('../config/defaults.js');
|
|
853
|
-
const
|
|
3016
|
+
const { resolveMasterHost } = await import('./master-address.js');
|
|
854
3017
|
const masterPort = _dc.port;
|
|
855
3018
|
// Provision all slave nodes in parallel (install runtime + omnitron + config)
|
|
856
3019
|
const appNodes = nodes.filter((n) => n.role !== 'database' && n.role !== 'cache');
|
|
3020
|
+
// One writer per machine, as for a remote stack — see `deployRemoteStack`
|
|
3021
|
+
// and `node-deploy-lease.ts`.
|
|
3022
|
+
const targets = new Map();
|
|
857
3023
|
for (const node of appNodes) {
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
const
|
|
867
|
-
this.
|
|
868
|
-
}
|
|
869
|
-
const
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
3024
|
+
targets.set(`${node.host}:${node.port ?? 9700}`, await this.targetForStackNode(node));
|
|
3025
|
+
}
|
|
3026
|
+
await withNodeLeases([...targets].map(([node, target]) => ({
|
|
3027
|
+
node,
|
|
3028
|
+
machine: `${target.host}:${target.sshPort ?? 22}`,
|
|
3029
|
+
run: this.deployer.leaseRunner(target),
|
|
3030
|
+
})), `${projectName}/${stackName}`, this.logger, async (leases) => {
|
|
3031
|
+
const leased = appNodes.filter((n) => leases.has(`${n.host}:${n.port ?? 9700}`));
|
|
3032
|
+
for (const [node, because] of leases.unreachable) {
|
|
3033
|
+
this.logger.error({ node, because }, 'Could not reach this node to lease it — skipping');
|
|
3034
|
+
}
|
|
3035
|
+
for (const node of leased) {
|
|
3036
|
+
const nodeKey = `${node.host}:${node.port ?? 9700}`;
|
|
3037
|
+
const unsubProvision = this.deployer.onProgress((progress) => {
|
|
3038
|
+
this.emit('stack:deploy_progress', projectName, stackName, progress);
|
|
3039
|
+
});
|
|
3040
|
+
const target = targets.get(nodeKey);
|
|
3041
|
+
const master = await resolveMasterHost({ advertiseHost: _dc.advertiseHost, bindHost: _dc.host }, target);
|
|
3042
|
+
this.logger.info({ node: node.host, masterHost: master.host ?? '(none — this node is pulled from)', from: master.source }, 'Resolved the master address this slave will dial');
|
|
3043
|
+
await leases.confirm(nodeKey, `provisioning ${node.host}`);
|
|
3044
|
+
await this.deployer.provisionSlaveNode(target, master.host, masterPort, projectName);
|
|
3045
|
+
unsubProvision();
|
|
3046
|
+
}
|
|
3047
|
+
// Deploy app artifacts to all provisioned nodes
|
|
3048
|
+
if (artifacts.length > 0 && leased.length > 0) {
|
|
3049
|
+
for (const node of leased) {
|
|
3050
|
+
await leases.confirm(`${node.host}:${node.port ?? 9700}`, `delivering to ${node.host}`);
|
|
3051
|
+
}
|
|
3052
|
+
const unsubDeploy = this.deployer.onProgress((progress) => {
|
|
3053
|
+
this.emit('stack:deploy_progress', projectName, stackName, progress);
|
|
3054
|
+
});
|
|
3055
|
+
const results = await this.deployer.deployToStack(leased.map((n) => targets.get(`${n.host}:${n.port ?? 9700}`)), artifacts, projectName,
|
|
3056
|
+
// Same on the cluster path: artifacts without definitions are files a
|
|
3057
|
+
// node cannot run.
|
|
3058
|
+
{ concurrency: 5, apps: appEntries });
|
|
3059
|
+
unsubDeploy();
|
|
3060
|
+
const successful = results.filter((r) => r.status === 'success').length;
|
|
3061
|
+
const failed = results.filter((r) => r.status === 'failed').length;
|
|
3062
|
+
this.logger.info({ successful, failed, total: results.length }, 'Cluster deployment complete');
|
|
3063
|
+
}
|
|
3064
|
+
});
|
|
875
3065
|
// Connect to all slave daemons
|
|
876
3066
|
for (const node of nodes) {
|
|
877
3067
|
await connector.addSlave({
|
|
@@ -903,6 +3093,19 @@ export class ProjectService extends EventEmitter {
|
|
|
903
3093
|
const port = pgConfig.port ?? 5432;
|
|
904
3094
|
const user = pgConfig.user ?? 'postgres';
|
|
905
3095
|
const password = typeof pgConfig.password === 'string' ? pgConfig.password : 'postgres';
|
|
3096
|
+
// Wait for postgres to actually accept connections before running ANY
|
|
3097
|
+
// migrations. Containers can be reported "running" by Docker before
|
|
3098
|
+
// pg_isready would pass, especially right after a fresh provision. Without
|
|
3099
|
+
// this we hit ECONNREFUSED on the first migration and fail-fast bubbles
|
|
3100
|
+
// into the user's face.
|
|
3101
|
+
//
|
|
3102
|
+
// Three minutes, not one. A minute fits a container that already has its
|
|
3103
|
+
// data directory; it does not fit a cold one, which has to initdb and
|
|
3104
|
+
// install extensions before it listens — and on a loaded machine that is
|
|
3105
|
+
// several minutes. When the wait gives up the whole `stack start` aborts
|
|
3106
|
+
// and no app is started at all, so a deadline set for the warm case turns
|
|
3107
|
+
// a slow first boot into a stack that will not come up.
|
|
3108
|
+
await waitForPostgres('localhost', port, user, password, 180_000);
|
|
906
3109
|
for (const [dbName, dbConfig] of Object.entries(pgConfig.databases)) {
|
|
907
3110
|
// Only run if migrate flag is set AND a matching app exists
|
|
908
3111
|
if (!dbConfig || typeof dbConfig !== 'object' || !dbConfig.migrate)
|
|
@@ -918,28 +3121,69 @@ export class ProjectService extends EventEmitter {
|
|
|
918
3121
|
continue;
|
|
919
3122
|
}
|
|
920
3123
|
this.logger.info({ project: projectName, stack: stackName, database: dbName }, 'Running database migrations');
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
3124
|
+
// Retry policy: up to 5 attempts with exponential backoff (1s, 2s, 4s, 8s, 16s).
|
|
3125
|
+
// Migration tools are typically idempotent, so retrying after a transient
|
|
3126
|
+
// ECONNREFUSED / role-creation race is safe.
|
|
3127
|
+
const maxAttempts = 5;
|
|
3128
|
+
let attempt = 0;
|
|
3129
|
+
let lastErr = null;
|
|
3130
|
+
while (attempt < maxAttempts) {
|
|
3131
|
+
attempt++;
|
|
3132
|
+
try {
|
|
3133
|
+
// Asynchronously. This was `execFileSync`, and a synchronous spawn
|
|
3134
|
+
// holds the daemon's whole event loop for as long as the child runs
|
|
3135
|
+
// — no RPC answered, no heartbeat, no mesh — which the event-loop
|
|
3136
|
+
// watch caught at a master boot on 2026-09-23: six migrations back
|
|
3137
|
+
// to back, 1 539 / 871 / 727 / 941 / 740 / 782 ms, 5.6 s of a daemon
|
|
3138
|
+
// that answered nothing, on every restart. And with the retries
|
|
3139
|
+
// below, a migration that hangs held it up to 60 s per attempt.
|
|
3140
|
+
const { execFile } = await import('node:child_process');
|
|
3141
|
+
const { promisify } = await import('node:util');
|
|
3142
|
+
const running = promisify(execFile)(process.execPath, ['--import', 'tsx/esm', migrateScript], {
|
|
3143
|
+
cwd: path.join(projectPath, 'apps', dbName),
|
|
3144
|
+
env: {
|
|
3145
|
+
...process.env,
|
|
3146
|
+
DATABASE_URL: `postgresql://${user}:${password}@localhost:${port}/${dbName}`,
|
|
3147
|
+
// App-specific env vars (various naming conventions)
|
|
3148
|
+
[`${dbName.toUpperCase()}__DATABASE__HOST`]: 'localhost',
|
|
3149
|
+
[`${dbName.toUpperCase()}__DATABASE__PORT`]: String(port),
|
|
3150
|
+
[`${dbName.toUpperCase()}__DATABASE__DATABASE`]: dbName,
|
|
3151
|
+
[`${dbName.toUpperCase()}__DATABASE__USER`]: user,
|
|
3152
|
+
[`${dbName.toUpperCase()}__DATABASE__PASSWORD`]: password,
|
|
3153
|
+
},
|
|
3154
|
+
timeout: 60_000,
|
|
3155
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
3156
|
+
});
|
|
3157
|
+
// Nothing to read from us, as `stdio: 'ignore'` said before.
|
|
3158
|
+
running.child.stdin?.end();
|
|
3159
|
+
const { stdout } = await running;
|
|
3160
|
+
// What the migrations said, in order — see `migrationOutput`.
|
|
3161
|
+
for (const line of migrationOutput(String(stdout))) {
|
|
3162
|
+
this.logger.info({ database: dbName, line }, 'Migration said');
|
|
3163
|
+
}
|
|
3164
|
+
this.logger.info({ database: dbName, attempts: attempt }, 'Migrations applied');
|
|
3165
|
+
lastErr = null;
|
|
3166
|
+
break;
|
|
3167
|
+
}
|
|
3168
|
+
catch (err) {
|
|
3169
|
+
lastErr = err;
|
|
3170
|
+
const stderr = err.stderr?.toString?.()?.trim?.() ?? '';
|
|
3171
|
+
// Only retry on transient connection errors. Schema / SQL errors
|
|
3172
|
+
// are deterministic and won't go away by trying again.
|
|
3173
|
+
const transient = /ECONNREFUSED|ECONNRESET|ENOTCONN|terminat(ing|ed)|server closing|kysely.*Connection/i.test(stderr) ||
|
|
3174
|
+
/ECONNREFUSED|ECONNRESET/i.test(err.message ?? '');
|
|
3175
|
+
if (!transient || attempt >= maxAttempts) {
|
|
3176
|
+
this.logger.error({ database: dbName, attempt, error: err.message, stderr }, 'Migration failed — app may fail to start');
|
|
3177
|
+
break;
|
|
3178
|
+
}
|
|
3179
|
+
const delayMs = Math.min(1000 * 2 ** (attempt - 1), 16_000);
|
|
3180
|
+
this.logger.warn({ database: dbName, attempt, nextRetryInMs: delayMs, error: err.message }, 'Migration transient failure — retrying');
|
|
3181
|
+
await new Promise((r) => setTimeout(r, delayMs));
|
|
3182
|
+
}
|
|
939
3183
|
}
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
3184
|
+
if (lastErr) {
|
|
3185
|
+
// Already logged above; intentionally do NOT throw here so other
|
|
3186
|
+
// databases can still attempt their migrations.
|
|
943
3187
|
}
|
|
944
3188
|
}
|
|
945
3189
|
}
|
|
@@ -957,6 +3201,161 @@ export class ProjectService extends EventEmitter {
|
|
|
957
3201
|
}
|
|
958
3202
|
return configStacks;
|
|
959
3203
|
}
|
|
3204
|
+
/**
|
|
3205
|
+
* The environment a stack gives its apps, vault references resolved
|
|
3206
|
+
* (`project/stack-env.ts`). Throws — the start is refused — for a key the
|
|
3207
|
+
* vault does not hold, an `appEnv` for an app the stack does not run, or a
|
|
3208
|
+
* malformed entry. Values never leave this call except into the apps' env.
|
|
3209
|
+
*/
|
|
3210
|
+
async stackEnvFor(projectName, stackName, stackConfig, apps) {
|
|
3211
|
+
const { resolveStackEnv } = await import('../project/stack-env.js');
|
|
3212
|
+
const secrets = this.secrets;
|
|
3213
|
+
return resolveStackEnv({
|
|
3214
|
+
settings: stackConfig.settings,
|
|
3215
|
+
apps,
|
|
3216
|
+
getSecret: secrets ? (key) => secrets.get(key) : undefined,
|
|
3217
|
+
where: `${projectName}/${stackName}`,
|
|
3218
|
+
});
|
|
3219
|
+
}
|
|
3220
|
+
/**
|
|
3221
|
+
* How each app of a local stack is started: the entry handed to the
|
|
3222
|
+
* orchestrator — named into its stack, its environment computed from the
|
|
3223
|
+
* stack's resolved configuration and then the project config's own.
|
|
3224
|
+
*
|
|
3225
|
+
* One builder for the start and for a re-read of the project
|
|
3226
|
+
* (`rereadProject`), so a running app is redefined exactly as it would be
|
|
3227
|
+
* started.
|
|
3228
|
+
*/
|
|
3229
|
+
stackEntryBuilder(projectName, stackName, stackConfig, ecosystemConfig, appDefinitions, stackEnv) {
|
|
3230
|
+
const project = this.registry.get(projectName);
|
|
3231
|
+
const portAllocation = this.infraManager.getPortAllocation(projectName, stackName);
|
|
3232
|
+
const normalizedSvcs = this.infraManager.getNormalizedServices(projectName, stackName);
|
|
3233
|
+
const resolvedStack = resolveStack(ecosystemConfig, projectName, stackName, stackConfig, appDefinitions, portAllocation ?? undefined, normalizedSvcs ?? undefined);
|
|
3234
|
+
return (entry) => {
|
|
3235
|
+
const appConfig = resolvedStack.appConfigs.get(entry.name);
|
|
3236
|
+
const infraEnv = appConfig ? resolvedConfigToEnv(appConfig, entry.name, stackName) : {};
|
|
3237
|
+
return {
|
|
3238
|
+
...entry,
|
|
3239
|
+
name: ProjectService.handleKey(projectName, stackName, entry.name),
|
|
3240
|
+
...(entry.bootstrap && project
|
|
3241
|
+
? { bootstrap: path.resolve(project.path, entry.bootstrap) }
|
|
3242
|
+
: {}),
|
|
3243
|
+
...(project ? { cwd: project.path } : {}),
|
|
3244
|
+
env: {
|
|
3245
|
+
// Derived first, stated second. `infraEnv` is COMPUTED from the
|
|
3246
|
+
// stack's configuration; `entry.env` is WRITTEN — by a developer
|
|
3247
|
+
// in the project config, or, on a node, by the master that
|
|
3248
|
+
// provisioned the services and read their credentials back from
|
|
3249
|
+
// the machine they run on.
|
|
3250
|
+
//
|
|
3251
|
+
// The other order made the computed value win, and on a node the
|
|
3252
|
+
// computation has nothing to compute from: the generated config
|
|
3253
|
+
// carries no `infrastructure` block, so `resolveStackAddresses`
|
|
3254
|
+
// ends at its literal and overwrote a correct
|
|
3255
|
+
// `postgres://postgres:<43-char secret>@…` with
|
|
3256
|
+
// `postgres://postgres:postgres@…`. The apps then failed with
|
|
3257
|
+
// `password authentication failed for user "postgres" (28P01)`
|
|
3258
|
+
// against credentials that had been handed to them correctly and
|
|
3259
|
+
// thrown away one line later.
|
|
3260
|
+
//
|
|
3261
|
+
// It also meant an operator could not override a computed address
|
|
3262
|
+
// at all, which is not a thing a config system should refuse.
|
|
3263
|
+
// The stack's own (`settings.env`, then `settings.appEnv[app]`,
|
|
3264
|
+
// vault references resolved by `stackEnvFor`) still wins over both.
|
|
3265
|
+
...infraEnv,
|
|
3266
|
+
...entry.env,
|
|
3267
|
+
...stackEnv[entry.name],
|
|
3268
|
+
OMNITRON_PROJECT: projectName,
|
|
3269
|
+
OMNITRON_STACK: stackName,
|
|
3270
|
+
OMNITRON_STACK_TYPE: 'local',
|
|
3271
|
+
},
|
|
3272
|
+
};
|
|
3273
|
+
};
|
|
3274
|
+
}
|
|
3275
|
+
/**
|
|
3276
|
+
* Every application in a stack, with what it declares it needs.
|
|
3277
|
+
*
|
|
3278
|
+
* Extracted because a remote stack needs exactly this and had no way to
|
|
3279
|
+
* get it: `startRemoteStack` built its own list of app ENTRIES and never
|
|
3280
|
+
* their definitions, so the `infrastructure` an application declares —
|
|
3281
|
+
* its chain daemon, its cache — was invisible on that path. The local
|
|
3282
|
+
* path had it all along, twenty lines up from where it was needed.
|
|
3283
|
+
*/
|
|
3284
|
+
async loadAppDefinitions(projectName, stackConfig, ecosystemConfig) {
|
|
3285
|
+
const appDefinitions = new Map();
|
|
3286
|
+
const appEntries = this.resolveStackApps(stackConfig, ecosystemConfig);
|
|
3287
|
+
const project = this.registry.get(projectName);
|
|
3288
|
+
for (const entry of appEntries) {
|
|
3289
|
+
if (entry.bootstrap && project) {
|
|
3290
|
+
try {
|
|
3291
|
+
const bootstrapAbsPath = path.resolve(project.path, entry.bootstrap);
|
|
3292
|
+
const { loadBootstrapConfig } = await import('../orchestrator/bootstrap-loader.js');
|
|
3293
|
+
const loaded = await loadBootstrapConfig(bootstrapAbsPath, { devMode: false });
|
|
3294
|
+
// The app's `config/default.json`, read now and put on a COPY of
|
|
3295
|
+
// the definition: the loaded one is the loader's cache entry, and
|
|
3296
|
+
// writing to it made the first read the only one until the daemon
|
|
3297
|
+
// restarted (`project/declared-config.ts`).
|
|
3298
|
+
const declared = readDeclaredConfig(bootstrapAbsPath);
|
|
3299
|
+
if (declared.malformed) {
|
|
3300
|
+
this.logger.error({ app: entry.name, configPath: declared.configPath, error: declared.malformed }, 'config/default.json does not parse — its `omnitron` section is being ignored');
|
|
3301
|
+
}
|
|
3302
|
+
const definition = withDeclaredConfig(loaded, declared);
|
|
3303
|
+
appDefinitions.set(entry.name, definition);
|
|
3304
|
+
}
|
|
3305
|
+
catch (err) {
|
|
3306
|
+
// An app can start without its definition; a STACK cannot be
|
|
3307
|
+
// provisioned without it. The definition is where an application
|
|
3308
|
+
// declares what it needs — its database, its cache, its chain
|
|
3309
|
+
// daemon — so a swallowed failure here does not degrade the
|
|
3310
|
+
// deployment, it silently removes a whole category from it.
|
|
3311
|
+
//
|
|
3312
|
+
// Measured: a remote stack reported `declared by apps: (none)` and
|
|
3313
|
+
// provisioned no infrastructure, because every bootstrap import
|
|
3314
|
+
// had failed in a process that could not load TypeScript. Nothing
|
|
3315
|
+
// said so at any level.
|
|
3316
|
+
this.logger.warn({ app: entry.name, bootstrap: entry.bootstrap, error: err.message }, 'Could not load this app\'s definition — anything it declares it needs will not be provisioned');
|
|
3317
|
+
}
|
|
3318
|
+
}
|
|
3319
|
+
}
|
|
3320
|
+
return appDefinitions;
|
|
3321
|
+
}
|
|
3322
|
+
/**
|
|
3323
|
+
* The infrastructure a stack's applications declare, merged and overridden.
|
|
3324
|
+
*
|
|
3325
|
+
* One entry per service name: two applications that both declare
|
|
3326
|
+
* `postgres` mean one Postgres, which is what `serviceOverrides` keys on
|
|
3327
|
+
* and what the container resolver has always assumed. A later declaration
|
|
3328
|
+
* that disagrees with an earlier one is not merged silently — the first
|
|
3329
|
+
* wins and the disagreement is said, because two applications wanting
|
|
3330
|
+
* different versions of one service is a fact about the stack, not a
|
|
3331
|
+
* detail to resolve by ordering.
|
|
3332
|
+
*/
|
|
3333
|
+
async collectDeclaredServices(projectName, stackConfig, ecosystemConfig,
|
|
3334
|
+
/** Keep the ones reached elsewhere too — to ask whether they answer, not to provision them. */
|
|
3335
|
+
options = {}) {
|
|
3336
|
+
const merged = {};
|
|
3337
|
+
const definitions = await this.loadAppDefinitions(projectName, stackConfig, ecosystemConfig);
|
|
3338
|
+
for (const [appName, definition] of definitions) {
|
|
3339
|
+
const declared = definition.omnitronConfig?.infrastructure;
|
|
3340
|
+
if (!declared)
|
|
3341
|
+
continue;
|
|
3342
|
+
for (const [name, requirement] of Object.entries(declared)) {
|
|
3343
|
+
const override = stackConfig.serviceOverrides?.[`${appName}/${name}`] ?? stackConfig.serviceOverrides?.[name];
|
|
3344
|
+
// Disabled, or pointed at something that already exists: there is
|
|
3345
|
+
// nothing to provision, and an address reaches the application
|
|
3346
|
+
// through its environment instead.
|
|
3347
|
+
const { provisioning } = bindService(requirement, override);
|
|
3348
|
+
if (provisioning === 'disabled' || (provisioning === 'external' && !options.external))
|
|
3349
|
+
continue;
|
|
3350
|
+
if (merged[name]) {
|
|
3351
|
+
this.logger.debug({ service: name, app: appName }, 'Service already declared by another app in this stack — the first declaration stands');
|
|
3352
|
+
continue;
|
|
3353
|
+
}
|
|
3354
|
+
merged[name] = requirement;
|
|
3355
|
+
}
|
|
3356
|
+
}
|
|
3357
|
+
return merged;
|
|
3358
|
+
}
|
|
960
3359
|
resolveStackApps(stackConfig, ecosystemConfig) {
|
|
961
3360
|
const allApps = ecosystemConfig.apps.filter((a) => a.enabled !== false);
|
|
962
3361
|
if (!stackConfig.apps || stackConfig.apps === 'all') {
|
|
@@ -998,16 +3397,195 @@ export class ProjectService extends EventEmitter {
|
|
|
998
3397
|
totalStacks: Object.keys(stacks).length,
|
|
999
3398
|
};
|
|
1000
3399
|
}
|
|
3400
|
+
/**
|
|
3401
|
+
* A remote stack's apps, as the NODES report them.
|
|
3402
|
+
*
|
|
3403
|
+
* `toStackInfo` reads this daemon's own orchestrator handles. For a remote
|
|
3404
|
+
* stack there are none — the applications run on the nodes, under the
|
|
3405
|
+
* node's own naming (`<project>/deployed/<app>`) — so every app came back
|
|
3406
|
+
* `stopped` and the report was:
|
|
3407
|
+
*
|
|
3408
|
+
* Stack daos/test: only 0/6 apps came online.
|
|
3409
|
+
* Not online: main (stopped), storage (stopped), priceverse (stopped),
|
|
3410
|
+
* paysys (stopped), messaging (stopped), geo (stopped)
|
|
3411
|
+
*
|
|
3412
|
+
* measured against a node answering `appsTotal: 6, appsOnline: 6` with
|
|
3413
|
+
* every port listening, thirty seconds after this same master installed,
|
|
3414
|
+
* migrated and started them. The CLI exits 1 on that count, so a correct
|
|
3415
|
+
* deployment fails a script; and the console's stack page shows the same
|
|
3416
|
+
* six rows, stopped.
|
|
3417
|
+
*
|
|
3418
|
+
* A node that cannot be asked leaves its apps exactly as they were, with a
|
|
3419
|
+
* line saying why. "We could not ask" is not "they are down" — and the
|
|
3420
|
+
* local rows are at least honest about being local.
|
|
3421
|
+
*/
|
|
3422
|
+
async withRemoteAppStatuses(projectName, info) {
|
|
3423
|
+
return (await this.askNodes(projectName, info)).info;
|
|
3424
|
+
}
|
|
3425
|
+
/**
|
|
3426
|
+
* The same question, with the count of nodes that answered it.
|
|
3427
|
+
*
|
|
3428
|
+
* `connectedNodes` used to be read from the connector's registry, which
|
|
3429
|
+
* said 0 about a node that was answering RPCs at that moment — measured
|
|
3430
|
+
* 2026-09-22, three times in three seconds, beside a `stack status` that
|
|
3431
|
+
* listed six apps online from the very same node. A registry is
|
|
3432
|
+
* bookkeeping; an answer is a fact, and this is where the answers are.
|
|
3433
|
+
*/
|
|
3434
|
+
async askNodes(projectName, info) {
|
|
3435
|
+
if (info.type !== 'remote' && info.type !== 'cluster')
|
|
3436
|
+
return { info, answered: 0 };
|
|
3437
|
+
const nodes = info.config.nodes ?? [];
|
|
3438
|
+
if (nodes.length === 0 || !this.slaveConnector)
|
|
3439
|
+
return { info, answered: 0 };
|
|
3440
|
+
const reported = new Map();
|
|
3441
|
+
let remoteInfra = null;
|
|
3442
|
+
let answered = 0;
|
|
3443
|
+
for (const node of nodes) {
|
|
3444
|
+
try {
|
|
3445
|
+
const status = (await this.slaveConnector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronDaemon', 'status', []));
|
|
3446
|
+
answered += 1;
|
|
3447
|
+
// The containers are on the node too — `infraManager.getInstance`
|
|
3448
|
+
// answers about THIS machine, where a remote stack has none, so the
|
|
3449
|
+
// stack read "Infrastructure: not provisioned" beside a node running
|
|
3450
|
+
// postgres, redis, minio, the gateway and tor.
|
|
3451
|
+
remoteInfra ??= await this.remoteInfraStatus(projectName, info.name, node);
|
|
3452
|
+
for (const app of status?.apps ?? []) {
|
|
3453
|
+
// The node names them `<project>/deployed/<app>`; the stack knows
|
|
3454
|
+
// them by the app's own name.
|
|
3455
|
+
const bare = app.name.includes('/') ? app.name.slice(app.name.lastIndexOf('/') + 1) : app.name;
|
|
3456
|
+
reported.set(bare, app);
|
|
3457
|
+
}
|
|
3458
|
+
}
|
|
3459
|
+
catch (err) {
|
|
3460
|
+
this.logger.warn({ project: projectName, stack: info.name, node: node.host, error: err.message }, 'Could not ask this node what it is running — its apps are reported as this master sees them');
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
if (answered === 0)
|
|
3464
|
+
return { info, answered };
|
|
3465
|
+
const apps = info.apps.map((app) => {
|
|
3466
|
+
const running = reported.get(app.name);
|
|
3467
|
+
if (!running)
|
|
3468
|
+
return app;
|
|
3469
|
+
return {
|
|
3470
|
+
...app,
|
|
3471
|
+
handleKey: running.name,
|
|
3472
|
+
status: running.status,
|
|
3473
|
+
pid: running.pid,
|
|
3474
|
+
instances: running.instances,
|
|
3475
|
+
uptime: running.uptime,
|
|
3476
|
+
restarts: running.restarts,
|
|
3477
|
+
cpu: running.cpu,
|
|
3478
|
+
memory: running.memory,
|
|
3479
|
+
port: running.port ?? null,
|
|
3480
|
+
};
|
|
3481
|
+
});
|
|
3482
|
+
// The stack's own status is this master's memory of having started it,
|
|
3483
|
+
// and a daemon restart forgets it — so a stack whose six applications
|
|
3484
|
+
// are running on a node read `stopped` a minute after an upgrade. The
|
|
3485
|
+
// apps are the fact here too.
|
|
3486
|
+
const online = apps.filter((a) => a.status === 'online').length;
|
|
3487
|
+
const status = online === apps.length && apps.length > 0
|
|
3488
|
+
? 'running'
|
|
3489
|
+
: online > 0
|
|
3490
|
+
? 'degraded'
|
|
3491
|
+
: info.status;
|
|
3492
|
+
// And when the stack started is when its applications did: the
|
|
3493
|
+
// longest-running one a node reports online. An uptime is a duration the
|
|
3494
|
+
// node measured on its own clock, so taking it from this one needs no
|
|
3495
|
+
// agreement between the two clocks.
|
|
3496
|
+
const longest = apps.reduce((max, a) => (reported.has(a.name) && a.status === 'online' && a.uptime > max ? a.uptime : max), 0);
|
|
3497
|
+
return {
|
|
3498
|
+
info: {
|
|
3499
|
+
...info,
|
|
3500
|
+
status,
|
|
3501
|
+
infrastructure: remoteInfra ?? info.infrastructure,
|
|
3502
|
+
apps,
|
|
3503
|
+
startedAt: longest > 0 ? new Date(Date.now() - longest).toISOString() : null,
|
|
3504
|
+
uptime: longest,
|
|
3505
|
+
},
|
|
3506
|
+
answered,
|
|
3507
|
+
};
|
|
3508
|
+
}
|
|
3509
|
+
/**
|
|
3510
|
+
* One node's infrastructure, named the way the stack names it.
|
|
3511
|
+
*
|
|
3512
|
+
* The node's container names carry ITS prefix (`daos-test-postgres`), and
|
|
3513
|
+
* a reader wants the service — `postgres` — so the same stripping the
|
|
3514
|
+
* local path does is done here. `null` when the node cannot answer or has
|
|
3515
|
+
* no infrastructure of its own, which leaves the master's view in place.
|
|
3516
|
+
*/
|
|
3517
|
+
async remoteInfraStatus(projectName, stackName, node) {
|
|
3518
|
+
if (!this.slaveConnector)
|
|
3519
|
+
return null;
|
|
3520
|
+
try {
|
|
3521
|
+
const state = (await this.slaveConnector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronInfra', 'getState', []));
|
|
3522
|
+
const prefix = `${projectName}-${stackName}-`;
|
|
3523
|
+
const services = {};
|
|
3524
|
+
// A node's infra state is IN MEMORY. After its daemon restarts it
|
|
3525
|
+
// answers `null` until a master provisions the stack again, while the
|
|
3526
|
+
// containers keep running — so the stack read "not provisioned" about
|
|
3527
|
+
// six healthy containers. The containers are the fact; the state is a
|
|
3528
|
+
// cache of it.
|
|
3529
|
+
const entries = state?.services
|
|
3530
|
+
? Object.entries(state.services)
|
|
3531
|
+
: ((await this.slaveConnector.invokeOnSlave(node.host, node.port ?? 9700, 'OmnitronInfra', 'listContainers', [])) ?? [])
|
|
3532
|
+
.filter((c) => c.name.startsWith(prefix))
|
|
3533
|
+
.map((c) => [c.name, c]);
|
|
3534
|
+
if (entries.length === 0)
|
|
3535
|
+
return null;
|
|
3536
|
+
for (const [containerName, svc] of entries) {
|
|
3537
|
+
const name = containerName.startsWith(prefix) ? containerName.slice(prefix.length) : containerName;
|
|
3538
|
+
services[name] = {
|
|
3539
|
+
status: svc.status === 'running' ? 'running' : svc.error ? 'error' : 'stopped',
|
|
3540
|
+
containerName: svc.name,
|
|
3541
|
+
port: svc.ports ? Object.values(svc.ports)[0] ?? null : null,
|
|
3542
|
+
};
|
|
3543
|
+
}
|
|
3544
|
+
// Without the node's own verdict, "ready" is what the containers say:
|
|
3545
|
+
// every one of them running.
|
|
3546
|
+
const ready = state?.ready ?? Object.values(services).every((svc) => svc.status === 'running');
|
|
3547
|
+
return { ready, services };
|
|
3548
|
+
}
|
|
3549
|
+
catch (err) {
|
|
3550
|
+
this.logger.warn({ project: projectName, stack: stackName, node: node.host, error: err.message }, 'Could not ask this node about its infrastructure — the stack shows this master\'s view');
|
|
3551
|
+
return null;
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
1001
3554
|
toStackInfo(projectName, stackName, config) {
|
|
1002
3555
|
const stateKey = `${projectName}/${stackName}`;
|
|
1003
3556
|
const state = this.stackStates.get(stateKey);
|
|
1004
3557
|
const prefix = `${projectName}/${stackName}/`;
|
|
1005
|
-
//
|
|
1006
|
-
const stackApps = this.orchestrator.list({ prefix });
|
|
1007
|
-
const runningByName = new Map(stackApps.map((a) => [a.name.replace(prefix, ''), a]));
|
|
1008
|
-
// Build app list from config — include both running and stopped apps
|
|
3558
|
+
// Build app list from config — include both running and stopped apps.
|
|
1009
3559
|
const ecosystemConfig = this.getLoadedConfig(projectName);
|
|
1010
3560
|
const configuredApps = ecosystemConfig ? this.resolveStackApps(config, ecosystemConfig) : [];
|
|
3561
|
+
const configuredNames = new Set(configuredApps.map((a) => a.name));
|
|
3562
|
+
// Match running handles by effective (un-prefixed) name. Two
|
|
3563
|
+
// registration paths can land an app in the orchestrator's handle
|
|
3564
|
+
// map: the namespaced stack-mode path (`omni/dev/main`) and the
|
|
3565
|
+
// bare-name daemon-RPC path (`main`). Pre-fix the UI only saw the
|
|
3566
|
+
// first; a CLI `omnitron start main` (or any ecosystem.config entry
|
|
3567
|
+
// started before the stack-mode env vars were known) registered
|
|
3568
|
+
// bare and silently disappeared from the project/stack view, even
|
|
3569
|
+
// though `omnitron list` happily reported it online. By keying off
|
|
3570
|
+
// `effectiveAppName` we accept either form so the UI matches the
|
|
3571
|
+
// CLI's worldview. Cross-stack collisions on the same effective
|
|
3572
|
+
// name still resolve via the namespaced handle's prefix; the bare
|
|
3573
|
+
// fallback only applies when that name appears in this stack's
|
|
3574
|
+
// configured-app set, so an unrelated bare handle in another stack
|
|
3575
|
+
// never bleeds in.
|
|
3576
|
+
const runningByName = new Map();
|
|
3577
|
+
for (const a of this.orchestrator.list()) {
|
|
3578
|
+
const effective = effectiveAppName(a.name);
|
|
3579
|
+
const inThisPrefix = a.name.startsWith(prefix);
|
|
3580
|
+
const inThisStackByName = !a.name.includes('/') && configuredNames.has(effective);
|
|
3581
|
+
if (!inThisPrefix && !inThisStackByName)
|
|
3582
|
+
continue;
|
|
3583
|
+
// Namespaced wins over bare on collision.
|
|
3584
|
+
const existing = runningByName.get(effective);
|
|
3585
|
+
if (!existing || a.name.startsWith(prefix)) {
|
|
3586
|
+
runningByName.set(effective, a);
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
1011
3589
|
const apps = configuredApps.map((entry) => {
|
|
1012
3590
|
const running = runningByName.get(entry.name);
|
|
1013
3591
|
if (running) {
|
|
@@ -1019,6 +3597,10 @@ export class ProjectService extends EventEmitter {
|
|
|
1019
3597
|
pid: running.pid,
|
|
1020
3598
|
instances: running.instances,
|
|
1021
3599
|
uptime: running.uptime,
|
|
3600
|
+
restarts: running.restarts,
|
|
3601
|
+
cpu: running.cpu,
|
|
3602
|
+
memory: running.memory,
|
|
3603
|
+
port: running.port ?? null,
|
|
1022
3604
|
};
|
|
1023
3605
|
}
|
|
1024
3606
|
// App is configured but not running
|
|
@@ -1029,6 +3611,10 @@ export class ProjectService extends EventEmitter {
|
|
|
1029
3611
|
pid: null,
|
|
1030
3612
|
instances: 0,
|
|
1031
3613
|
uptime: 0,
|
|
3614
|
+
restarts: 0,
|
|
3615
|
+
cpu: 0,
|
|
3616
|
+
memory: 0,
|
|
3617
|
+
port: null,
|
|
1032
3618
|
};
|
|
1033
3619
|
});
|
|
1034
3620
|
// Include any running apps not in config (shouldn't happen, but be safe)
|
|
@@ -1040,6 +3626,10 @@ export class ProjectService extends EventEmitter {
|
|
|
1040
3626
|
pid: running.pid,
|
|
1041
3627
|
instances: running.instances,
|
|
1042
3628
|
uptime: running.uptime,
|
|
3629
|
+
restarts: running.restarts,
|
|
3630
|
+
cpu: running.cpu,
|
|
3631
|
+
memory: running.memory,
|
|
3632
|
+
port: running.port ?? null,
|
|
1043
3633
|
});
|
|
1044
3634
|
}
|
|
1045
3635
|
// Populate real node connectivity from SlaveConnector
|
|
@@ -1056,7 +3646,7 @@ export class ProjectService extends EventEmitter {
|
|
|
1056
3646
|
daemonRole: n.role === 'master' ? 'master' : 'slave',
|
|
1057
3647
|
connected,
|
|
1058
3648
|
lastSeen: conn?.lastHeartbeat ?? null,
|
|
1059
|
-
syncStatus: null,
|
|
3649
|
+
syncStatus: (conn?.syncStatus ?? null),
|
|
1060
3650
|
};
|
|
1061
3651
|
});
|
|
1062
3652
|
// For local stacks, add implicit local node
|
|
@@ -1095,6 +3685,15 @@ export class ProjectService extends EventEmitter {
|
|
|
1095
3685
|
ready: infraState?.ready ?? false,
|
|
1096
3686
|
services: infraServices,
|
|
1097
3687
|
};
|
|
3688
|
+
// A remote stack's applications run on its nodes, and when they started
|
|
3689
|
+
// is the nodes' to say (`askNodes`). What this master remembers is when
|
|
3690
|
+
// IT took charge — deployed the stack, or attached to it after its own
|
|
3691
|
+
// restart — and that was printed as the stack's start: «13:03:46», 25
|
|
3692
|
+
// minutes of uptime, on a stack whose six applications had been running
|
|
3693
|
+
// on the node since 12:20:48 (measured 2026-09-23, after a master
|
|
3694
|
+
// restart). It is reported as what it is.
|
|
3695
|
+
const onNodes = config.type === 'remote' || config.type === 'cluster';
|
|
3696
|
+
const since = state?.startedAt ?? null;
|
|
1098
3697
|
return {
|
|
1099
3698
|
name: stackName,
|
|
1100
3699
|
type: config.type,
|
|
@@ -1104,9 +3703,31 @@ export class ProjectService extends EventEmitter {
|
|
|
1104
3703
|
apps,
|
|
1105
3704
|
infrastructure,
|
|
1106
3705
|
portRange: config.portRange ?? null,
|
|
1107
|
-
startedAt:
|
|
1108
|
-
uptime:
|
|
3706
|
+
startedAt: !onNodes && since ? new Date(since).toISOString() : null,
|
|
3707
|
+
uptime: !onNodes && since ? Date.now() - since : 0,
|
|
3708
|
+
attachedAt: onNodes && since ? new Date(since).toISOString() : null,
|
|
1109
3709
|
};
|
|
1110
3710
|
}
|
|
1111
3711
|
}
|
|
3712
|
+
/**
|
|
3713
|
+
* How a master's daemon builds this service: the tokens it resolves, in
|
|
3714
|
+
* order, and the factory that receives them.
|
|
3715
|
+
*
|
|
3716
|
+
* Here and not inline in daemon.module.ts so that the ORDER can be held to
|
|
3717
|
+
* account: `inject` and the factory's parameters are two lists that must
|
|
3718
|
+
* agree position by position, and the container hands the values over as
|
|
3719
|
+
* `any`. Swapped there, the vault arrives as the audit trail and neither
|
|
3720
|
+
* complains until a deployment needs one of them. See
|
|
3721
|
+
* `test/unit/a-factory-that-passed-its-arguments-by-position.test.ts`.
|
|
3722
|
+
*/
|
|
3723
|
+
export const MASTER_PROJECT_SERVICE_PROVIDER = {
|
|
3724
|
+
inject: [LOGGER_SERVICE_TOKEN, ORCHESTRATOR_TOKEN, DAEMON_STATE_STORE_TOKEN, SECRETS_SERVICE_TOKEN, AUDIT_SERVICE_TOKEN],
|
|
3725
|
+
useFactory: (loggerModule, orchestrator, daemonStateStore, secrets, audit) =>
|
|
3726
|
+
// `syncService` has never been passed here; `secrets` is what a stack's
|
|
3727
|
+
// service overrides name their credentials with, and without it a
|
|
3728
|
+
// deployed app is handed `<secret:…>` as a password. `audit` is here
|
|
3729
|
+
// because two of the three callers of `startStack` never pass through the
|
|
3730
|
+
// RPC layer that used to record it.
|
|
3731
|
+
new ProjectService(loggerModule.logger, orchestrator, daemonStateStore, { secrets, audit }),
|
|
3732
|
+
};
|
|
1112
3733
|
//# sourceMappingURL=project.service.js.map
|