Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions collectivus-plugin-kernel-types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -323,10 +323,60 @@ export interface PluginActivationContext {
* before appending to the cache.
*/
backfillMaterializers: BackfillMaterializerRegistry
/**
* Narrow facade over the kernel config apply engine (LLP 0023). Only
* present when the host process runs an apply engine (the daemon);
* absent in plain CLI boots, so transport plugins must treat it as
* optional and skip their pull loops when it is missing. The facade
* is the only channel a plugin has into config application — the
* kernel owns validation, install, persistence, restart, probation,
* and rollback.
*/
configControl?: ConfigControlFacade
requireCapability<T = unknown>(name: CapabilityName, range?: SemverRange): T
provideCapability<T = unknown>(name: CapabilityName, version: SemverVersion, value: T): void
}

/**
* Plugin-facing surface of the kernel config apply engine. Handed to
* transport plugins (e.g. `@hypaware/central`) so they can deliver a
* downloaded config document and report poll liveness. Deliberately
* narrow: plugins never see probation state, slot paths, or rollback
* bookkeeping.
*/
export interface ConfigControlFacade {
/**
* Deliver a downloaded config document (parsed JSON) plus the ETag it
* was served under. The kernel validates, installs pinned plugins,
* persists, swaps, and requests a staged restart. Resolves before the
* restart happens; callers should treat `{ ok: true }` as "apply
* committed, restart pending".
*/
stage(document: unknown, etag: string): Promise<ConfigStageResult>
/**
* Report a successful authenticated config poll (200 or 304). Clears
* the post-apply probation window when one is active; a no-op
* otherwise.
*/
confirmPoll(): void
/** ETag of the *running* config, for `If-None-Match`. Undefined when the operative config was never applied from the server (e.g. seed). */
runningEtag(): string | undefined
}

export type ConfigStageResult =
| { ok: true, action: 'applied' | 'noop_same_etag' | 'skipped_bad_etag' }
| { ok: false, errorKind: ConfigApplyErrorKind, message: string }

export type ConfigApplyErrorKind =
| 'config_invalid'
| 'plugin_install_failed'
| 'artifact_hash_mismatch'
| 'bundled_version_mismatch'
| 'document_too_large'
| 'apply_engine_not_ready'
| 'restart_pending'
| 'apply_io_error'

export interface PluginDeactivationContext {
plugin: ActivePlugin
log: PluginLogger
Expand Down Expand Up @@ -405,6 +455,26 @@ export interface PluginConfigInstance {
name: PluginName
enabled?: boolean
config?: JsonObject
/**
* Pinned plugin version. Set by centrally-served configs (LLP 0023):
* the apply engine refuses a config whose pins it cannot satisfy.
* For bundled first-party plugins the pin is checked strictly against
* the bundled version; for fetched plugins it selects the artifact.
*/
version?: SemverVersion
/**
* Pinned artifact content hash for fetched plugins. The apply engine
* verifies the fetched artifact against this before committing the
* install; a mismatch is an apply failure. Ignored (not checked) for
* plugins bundled with the running kernel.
*/
artifact_hash?: string
/**
* Optional explicit install source (raw source string accepted by the
* plugin installer). Defaults to the plugin name, which the resolver
* maps to its canonical git source.
*/
source?: string
}

/**
Expand Down
29 changes: 28 additions & 1 deletion hypaware-core/plugins-workspace/central/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import path from 'node:path'

import { validateCentralConfig } from './src/config.js'
import { createConfigPullLoop } from './src/config_client.js'
import { IdentityClient } from './src/identity_client.js'
import { createForwardSink } from './src/sink.js'

Expand All @@ -26,6 +27,10 @@ import { createForwardSink } from './src/sink.js'
export async function activate(ctx) {
const query = ctx.query
const storage = ctx.storage
// Present only in daemon mode. Without an apply engine there is no
// one to hand a pulled document to, so the pull loop stays off (CLI
// boots must not fire config polls as a side effect of `hyp status`).
const configControl = ctx.configControl

ctx.sinks.register({
name: 'forward',
Expand Down Expand Up @@ -55,13 +60,35 @@ export async function activate(ctx) {
hyp_identity_source: source,
})

return createForwardSink({
const sink = createForwardSink({
config,
identityClient,
query,
storage,
log: sinkCtx.log,
})

if (!configControl) return sink

// @ref LLP 0025#config-pull-loop [implements] — pull immediately on bootstrap success, then on the steady timer
const pullLoop = createConfigPullLoop({
centralUrl: config.url,
identityClient,
configControl,
...(config.poll_interval_seconds !== undefined
? { pollIntervalSeconds: config.poll_interval_seconds }
: {}),
log: sinkCtx.log,
})
pullLoop.start()

return {
...sink,
async close() {
await pullLoop.stop()
await sink.close()
},
}
},
})
}
29 changes: 22 additions & 7 deletions hypaware-core/plugins-workspace/central/proto.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,9 @@ atomic tmp+rename).
### POST `/v1/identity/bootstrap`

Exchange an operator-issued bootstrap token for a long-lived JWT.
Bootstrap tokens are single-use; a successful bootstrap response also
invalidates the bootstrap token server-side.
Bootstrap tokens are **policy tokens** (server LLP 0008): multi-use, so
one token can be deployed fleet-wide via MDM, and every token references
a config at mint (see "Config pull" below).

Request:

Expand Down Expand Up @@ -78,6 +79,12 @@ Headers (request):
- `Authorization: Bearer <jwt>`
- `If-None-Match: <etag>` (optional)

`If-None-Match` reflects the **running** config, never a
downloaded-but-not-yet-applied one. The server reads this header to
track fleet convergence, so a gateway mid-install/mid-apply keeps
presenting its old etag until the new config has taken effect
(LLP 0025).

Response 200:

```json
Expand All @@ -89,15 +96,23 @@ Response 200:
}
```

The body is a full HypAware v2 config and replaces the gateway's
operative config wholesale. Plugin entries are pinned by **version +
artifact content hash**; the gateway verifies the artifact hash on
install and treats a mismatch as an apply failure (LLP 0025).

`ETag: <hex>` accompanies every 200 response. Clients persist the etag
in a sidecar (`<plugin.stateDir>/config-etag.json`) so a restart
short-circuits to 304 instead of re-pulling and re-validating.
of the *running* config in kernel-managed state (it transitions
atomically with the operative config on apply and rollback — LLP 0025)
so a restart short-circuits to 304 instead of re-pulling and
re-validating.

Response 304: no body. The gateway keeps its current config.

Response 404: the operator has not registered a config for this
gateway. Gateways back off to 5 minutes and log once until the state
clears.
Response 404: legacy-only branch — every token now references a config
at mint (server LLP 0009), so gateways enrolled under that flow always
resolve. Kept for conformance against older servers: back off to
5 minutes and log once until the state clears.

Response 401: see "Refresh window" above.

Expand Down
4 changes: 0 additions & 4 deletions hypaware-core/plugins-workspace/central/src/config.js
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,6 @@ export function validateCentralConfig(value) {
}
}

if (cfg.config_etag_path !== undefined && typeof cfg.config_etag_path !== 'string') {
return invalid('central.config_etag_path must be a string when set')
}

return { ok: true, config: /** @type {CentralSinkConfig} */ (/** @type {unknown} */ (cfg)) }
}

Expand Down
Loading