AI Decision System
Replayable, offline-verifiable records for AI-assisted go/no-go decisions: experiment promotions, typed model answers and human-approved actions.
翻譯準備中
個人 GitHub 帳號下公開儲存庫的擁有者可以登入並驗證所有權,自行管理介紹,無需管理員核准。
登入並產生驗證檔案,在 30 分鐘內將其提交至此儲存庫的預設分支。其他分支或分叉儲存庫中的檔案無效。然後在下方檢查檔案,即可無需管理員核准直接管理專案。請保留檔案以維持管理權限。
如需登入,將前往登入頁面。登入後請回到此處,再次點擊所選的驗證按鈕。
在專案資料夾中執行 /nomorevibe verify,即可驗證所有權並自行管理介紹。
一分鐘完成驗證docuconf for server-side Swift, on swift-configuration.
翻譯準備中
取自儲存庫 README
正在載入完整 README。
檢視完整 README ↗The Swift SDK for docuconf: typed configuration contracts between an application and
the Kubernetes platform that runs it. You declare your configuration once, as a Swift struct. docuconf then checks the
environment and mounted files at boot, reports every problem at once, and exports a contract.cue that the platform
checks before it deploys.
Documentation: docuconf.dev · Swift guide · API reference
It builds on Apple's swift-configuration: values are read through a
ConfigReader, with its key names and parsing, so reader.int(forKey: "http.port") elsewhere in your app reads the
same value. It works with Hummingbird, Vapor or plain SwiftNIO.
Status: pre-1.0, implementing spec v1alpha1. Expect breaking changes until v1. Licensed under the MIT License.
Contents: Install · Declare and run · See an error · Test your config · Export the contract · Deploy · Hummingbird · Vapor · Reference
This is a complete Package.swift for an app:
// swift-tools-version: 6.2
import PackageDescription
let package = Package(
name: "App",
platforms: [.macOS(.v15)],
dependencies: [
.package(url: "https://github.com/docuconf/docuconf-swift", from: "0.2.0"),
],
targets: [
.executableTarget(name: "App", dependencies: [
.product(name: "Docuconf", package: "docuconf-swift"),
]),
]
)
Requires Swift 6.2 or later (swift-configuration's minimum), on Linux or macOS 15+.
Two optional additions:
ConfigReader (as the Hummingbird section does), also add
.package(url: "https://github.com/apple/swift-configuration", from: "1.2.0") and
.product(name: "Configuration", package: "swift-configuration").TLS trait. Turn it on if you declare those file inputs:
.package(url: "https://github.com/docuconf/docuconf-swift", from: "0.2.0", traits: ["TLS"]). Without it, such a
declaration fails at load with a message that says so.Sources/App/main.swift:
import Docuconf
import Foundation
enum LogLevel: String, ConfigEnum { case debug, info, warn, error }
struct DatabaseConfig: Sendable {
@Env("database.url", "Primary Postgres connection string", .secret, .schemes("postgres", "postgresql"))
var url: URL // no default: required
@Env("database.pool.size", "Connections in the pool", .range(1...100))
var poolSize = 10
}
struct AppConfig: DocuconfConfig {
@Env("http.port", "HTTP listen port", .range(1...65535))
var port = 8080
@Env("log.level", "Minimum log level")
var logLevel = LogLevel.info
@Env("request.timeout", "Upstream request timeout", .range(.seconds(1) ... .seconds(300)))
var requestTimeout: Duration = .seconds(30) // REQUEST_TIMEOUT=30, in seconds
var database = DatabaseConfig() // a nested struct groups variables
}
Docuconf.exportIfRequested(AppConfig.self, name: "quickstart") // the `docuconf-export` command, see below
let config = await Docuconf.loadOrExit(AppConfig.self) // on a problem: prints them all, exits 1
print("listening on :\(config.port), log level \(config.logLevel), pool of \(config.database.poolSize)")
print(config) // secrets print as <redacted>
Each @Env takes a swift-configuration key, a description and rules. The environment variable is the name
EnvironmentVariablesProvider reads for the key: http.port is HTTP_PORT, database.pool.size is
DATABASE_POOL_SIZE. A non-optional property with no initial value is required; an optional one (URL?) is nil
when unset. A nested struct, like DatabaseConfig here, groups variables; its @Env keys are used as written.
DATABASE_URL=postgres://localhost/app swift run App
This repository has the same program as Examples/Quickstart: run it with
DATABASE_URL=postgres://localhost/app swift run Quickstart.
With HTTP_PORT=0 and DATABASE_URL misspelt as DATABSE_URL, the app does not start. It prints every problem and
exits with status 1:
docuconf: warning: DATABSE_URL is set but not declared; did you mean DATABASE_URL?
docuconf: 2 configuration problems:
- HTTP_PORT [out_of_range]: is below min 1 (got "0")
- DATABASE_URL [missing_required]: is required but not set (Primary Postgres connection string); DATABSE_URL is set, is it a typo?
out_of_range, missing_required, ...), listed under Boot behaviour./dev/termination-log, so kubectl describe pod shows it.print(config) or dump(config).loadOrExit prints no backtrace. Keep it in main: if try await Docuconf.load(...) throws out of top-level
code or an async throws main, Swift crashes the process with a long backtrace.Docuconf.load(_:environment:) loads from a dictionary. It never reads or changes the process environment, writes
no termination log and starts nothing in the background, so tests can run in parallel. With Swift Testing:
@testable import Quickstart
import Docuconf
import Testing
@Suite struct AppConfigTests {
let valid = ["DATABASE_URL": "postgres://app@localhost/app"]
@Test func defaults() async throws {
let config = try await Docuconf.load(AppConfig.self, environment: valid)
#expect(config.port == 8080)
#expect(config.database.poolSize == 10)
}
@Test func rejectsPortZero() async throws {
let error = await #expect(throws: ConfigurationError.self) {
try await Docuconf.load(AppConfig.self, environment: valid.merging(["HTTP_PORT": "0"]) { $1 })
}
#expect(error?.violations.map(\.code) == [.outOfRange])
}
@Test func contractListsEveryVariable() throws {
let names = try Declaration(AppConfig.self).vars.map(\.name)
#expect(names.contains("DATABASE_POOL_SIZE"))
}
}
The same call builds a configuration for handler tests. load(_:environment:fileRoot:) also checks file inputs
under a test directory. To load through a reader, pass one built from swift-configuration's InMemoryProvider to
Docuconf.load(_:from:).
Docuconf.exportIfRequested turns the executable into an exporter when its first argument is docuconf-export. It
reads no environment and checks no files, so it runs in CI without production values:
swift run App docuconf-export --out contract.cue --app-version "$(git rev-parse HEAD)"
| Option | |
|---|---|
--out, -o | File to write. Default: standard output. |
--app-version | metadata.appVersion, such as the git SHA. |
--package | CUE package name. Default: the service name with - replaced by _. |
--help | Usage. |
Commit contract.cue and have CI re-export it and fail on a diff, as this repository does for its examples. You can
also call try Contract.cue(for: AppConfig.self, name: "app") from a test.
The platform never runs the app to learn what it needs: it reads contract.cue. The
docuconf CLI checks a deployment's values against
the contract with docuconf vet, and turns them into the container's environment with docuconf render. The
Helm chart does the same at helm install time.
Examples/Orders is a small HTTP service with its exported contract, the same service every
docuconf SDK ships, with a smoke test.
Hummingbird apps already build a ConfigReader: pass the same reader to loadOrExit, and feed the validated values
into ApplicationConfiguration. Inputs declared .reload(.watch) promise the platform that the app picks up
changes; a small Service in the app's ServiceGroup keeps that promise and stops on graceful shutdown.
import Configuration
import Docuconf
import Foundation
import Hummingbird
import Logging
import ServiceLifecycle
struct Greeting: Decodable, Sendable { var text: String }
struct HelloConfig: DocuconfConfig {
@Env("http.host", "Address to listen on") var host = "0.0.0.0"
@Env("http.port", "HTTP listen port", .range(1...65535)) var port = 8080
@Env("database.url", "Primary Postgres connection string", .secret, .schemes("postgres")) var databaseURL: URL
@FileInput("greeting", "Greeting served on /", path: "/etc/hello/greeting.json", .reload(.watch))
var greeting: ConfigFile<Greeting>
}
/// Keeps the `.reload(.watch)` promise: applies changes to the greeting while the app runs.
struct GreetingReloader: Service {
let config: HelloConfig
let logger: Logger
func run() async {
for await change in config.$greeting.changes(every: .seconds(5)).cancelOnGracefulShutdown() {
switch change {
case .updated(let greeting): logger.info("greeting is now \(greeting.text)")
case .rejected(let violations): logger.error("kept the old greeting: \(violations)")
}
}
}
}
Docuconf.exportIfRequested(HelloConfig.self, name: "hello")
// One ConfigReader for docuconf and the rest of the app.
let reader = ConfigReader(provider: EnvironmentVariablesProvider())
let config = await Docuconf.loadOrExit(HelloConfig.self, from: reader)
let logger = Logger(label: "hello")
let router = Router()
router.get("/") { _, _ in config.greeting.text } // always the latest content that passed its checks
router.get("/healthz") { _, _ in "ok" }
var app = Application(
router: router,
configuration: .init(address: .hostname(config.host, port: config.port)),
logger: logger
)
app.addServices(GreetingReloader(config: config, logger: logger))
try await app.runService()
The full package is Examples/HelloHummingbird; CI builds it and runs its smoke test, which
changes the greeting file while the app runs. It needs hummingbird, swift-configuration and
swift-service-lifecycle as dependencies.
Vapor reads its environment with Environment.get rather than swift-configuration, so call loadOrExit without a
reader, which reads the process environment. Call it after Application.make: that is where Vapor loads .env
and .env.<environment> into the process environment, and loading earlier would miss local values from them. Keep
the configuration in app.storage so route handlers can reach it.
import Docuconf
import Vapor
struct HelloConfig: DocuconfConfig {
@Env("port", "HTTP listen port", .range(1...65535)) var port = 8080
@Env("database.url", "Primary Postgres connection string", .secret, .schemes("postgres")) var databaseURL: URL
@Env("greeting", "Greeting served on /", .length(1...200)) var greeting = "hello"
}
extension Application {
private struct HelloConfigKey: StorageKey { typealias Value = HelloConfig }
/// The validated configuration, for route handlers: `req.application.config.greeting`.
var config: HelloConfig {
get { storage[HelloConfigKey.self]! }
set { storage[HelloConfigKey.self] = newValue }
}
}
@main enum Entrypoint {
static func main() async throws {
Docuconf.exportIfRequested(HelloConfig.self, name: "hello") // before Vapor parses the arguments
var env = try Environment.detect()
try LoggingSystem.bootstrap(from: &env)
// Application.make loads .env and .env.<environment> into the process environment,
// so load the configuration after it, or local values from .env are not seen.
let app = try await Application.make(env)
app.config = await Docuconf.loadOrExit(HelloConfig.self)
app.http.server.configuration.port = app.config.port
app.get { req in req.application.config.greeting }
app.get("healthz") { _ in "ok" }
do {
try await app.execute()
} catch {
app.logger.report(error: error)
try? await app.asyncShutdown()
throw error
}
try await app.asyncShutdown()
}
}
The full package is Examples/HelloVapor. Its smoke test, run in CI, supplies DATABASE_URL only
through a .env file, which proves the order above. Run the exporter as swift run App docuconf-export.
Both framework sections are tested recipes rather than DocuconfHummingbird / DocuconfVapor modules: the
integration is a handful of lines, and a module would make every docuconf user resolve both frameworks.
| Swift type | Contract type | Rules |
|---|---|---|
String | string | .length(1...64), .minLength, .maxLength, .pattern("^[a-z]+$") |
Int | int | .range(1...65535) or .range(1..<65536), .min, .max |
Int8 ... Int64, UInt8 ... UInt32 | int | as Int; a type narrower than 64 bits exports its own range as min / max |
Double | float | .range(0.0...1.0), .min, .max |
Bool | bool | |
Duration | duration | .range(.seconds(1) ... .seconds(300)), .min, .max |
URL | url | .schemes("postgres", "postgresql"), .maxLength |
a ConfigEnum (String raw values) | enum | the cases are the allowed values |
[String], [Int] | list | .items(1...5), .minItems, .maxItems |
[String] | list of string | as above, plus .itemLength(2...4), .itemMinLength, .itemMaxLength |
[Int32], [UInt16], any fixed-width integer list | list of int | as above, plus .itemRange(0...1023), .itemMin, .itemMax |
KeySet | keySet (always secret) |
Float is not supported (declare Double); the compiler says so.
Every variable also takes .secret, .group("database"), .examples("eu-west-1") and
.deprecated("Use REQUEST_TIMEOUT", replacedBy: "REQUEST_TIMEOUT") (the message is not blank and at most 500
characters, and a required input cannot be deprecated; when a deprecated input is set, boot warns with its name and the
message, never the value). Lists and key sets take .separator(";") for their csv separator. The description may
be labelled:
@Env("http.port", description: "HTTP listen port") var port = 8080. The rules a type accepts are checked by the
compiler: .schemes on an Int does not build.
Descriptions and details. Every input needs a description of at least 5 characters, and may have details:
CommonMark used only in generated docs, never at runtime, at most 4000 characters. A property wrapper cannot see the
/// comment above it, so write the doc comment as the description argument instead, as a multi-line string: its
first paragraph is the description, on one line, and the rest the details. DocC syntax becomes CommonMark: symbol
links (``Name``) become code spans, <doc:Article> the article's name, callouts such as - Note: and
- Important: bold labels, and - Parameter, - Returns: and - Throws: are dropped. .details("...") sets the
details explicitly, on a variable or a file input:
@Env("request.timeout", """
Upstream request timeout
The gateway gives up on an upstream after this long and answers 504. Keep it below the load
balancer's idle timeout; see ``LoadBalancer/idleTimeout``.
- Note: Read in seconds, as `REQUEST_TIMEOUT=30`.
""", .range(.seconds(1) ... .seconds(300)))
var requestTimeout: Duration = .seconds(30)
@Env("worker.count", "Number of request workers", .details("Each holds one database connection.")) var workers = 4
REQUEST_TIMEOUT exports the description Upstream request timeout, and the rest of the text as its details, with
the symbol link as a code span and the note as **Note:**. Blank details, or details
over 4000 characters (Unicode scalars), are a DeclarationError, as a missing description is. Contract-first mode
accepts details and ignores them. docuconf docs (in the docuconf CLI)
generates CONFIG.md and CONFIG.agents.md from the exported contract; the SDK only exports the text.
Nested structs. A plain stored struct property (var database = DatabaseConfig()) is searched for inputs, to any
depth, and its variables are read, checked and exported like the others. Inputs inside an optional, a collection, an
enum or a class are a DeclarationError naming the property, since docuconf could not read them reliably.
Item bounds. .itemRange, .itemMin and .itemMax bound every item of an integer list and are exported as
itemMin / itemMax; an item outside them is out_of_range at boot. A list of an integer type narrower than 64
bits exports the type's own range without any rule, so the platform never sends an item the app cannot hold:
@Env("shard.ids", "Shard ids this instance owns", .itemRange(0...1023)) var shardIDs: [UInt16] = [0]
// exports itemMin: 0, itemMax: 1023
@Env("listen.ports", "Extra ports to listen on") var ports: [UInt16]?
// exports itemMin: 0, itemMax: 65535
A rule bound outside the item type's range (.itemRange(0...70000) on [UInt16]) is a declaration error.
Lengths count characters, meaning Unicode scalars (unicodeScalars.count), never bytes or UTF-16 units or
grapheme clusters: "日本" is 2 and "ZÜ01" fits .itemMaxLength(4). .maxLength on a URL bounds the string as
given; on a JSONConfigValue it bounds the wire string, the raw value as received at boot (whitespace included) and
the compact JSON for a default. .itemLength, .itemMinLength and .itemMaxLength bound each item of a string list
after it is split, so separators never count. A value outside a length limit is out_of_range, and a secret reports
its length, never its value:
@Env("callback.url", "Where to report each run", .schemes("https"), .maxLength(40)) var callback: URL?
@Env("branches", "Branch codes, two to four characters each", .itemLength(2...4)) var branches: [String] = ["BE"]
Key sets. A KeySet is a set of secret keys that are all valid at once, so a key can be rotated without
downtime (spec section 6.1): the
platform sets old,new during the overlap. It is always secret, holds 1 to 2 keys unless .keys says otherwise, and
never trims a key; an empty key (a stray separator) is always out_of_range, reported as key N is empty with N
counted from 1 as received (old, has an empty key 2, ,new an empty key 1), and the number of keys too_few_items
or too_many_items. No message, print or dump shows a key:
@Env("webhook.keys", "Keys that verify webhook signatures", .keyLength(32...256)) var webhookKeys: KeySet?
keys gives them in order; contains(_:) checks a presented API key in constant time, and verify(_:) runs a check
of yours, such as an HMAC comparison, against every key without stopping at the first match. The
orders example verifies webhook signatures with it.
Declaration checks. The declaration itself is checked before any value is read (DeclarationError): names,
descriptions of at least 5 characters, defaults that break their own constraints, secrets with defaults or examples,
patterns outside RE2 (lookaround, backreferences, possessive quantifiers), mount directories that clash or hide system
directories, a pathEnv that is also a variable, a passwordVar that is not a declared secret, inputs nested where
they cannot be read. Names that look like feature flags (ENABLE_, FF_, FEATURE_) produce a warning (SPEC §10).
swift-configuration reads the values. A value given as one string (an environment variable, a .env file) is
parsed by docuconf with the SPEC §5 rules, the same code contract-first mode runs, so both paths accept exactly the
same text; a provider with typed values (a JSON or YAML config file) is read with the reader's typed accessors:
seconds encoding: REQUEST_TIMEOUT=30 or 1.5. swift-configuration has no duration type,
and a number of seconds is the simplest wire form (an overlay may hold it as a JSON number). The contract still shows defaults
and bounds in Go syntax (30s, 1m30s), and the platform renders the number. A Go-style value in the environment
is rejected with the number to write instead: REQUEST_TIMEOUT [invalid_type]: is not a number of seconds; durations are read as plain seconds, so write 30 (got "30s").csv encoding (a,b). docuconf splits a list given as one string on , itself, because
EnvironmentVariablesProvider's array decoder trims whitespace around items and SPEC §5 forbids that: " a,b"
is [" a", "b"], and "80, 443" for a list of integers is invalid_type. A provider with real arrays (a JSON
or YAML config file) is read with stringArray/intArray.true/false in any case, nothing else (yes, no, 1, 0, t, on are invalid_type);
integers match ^[+-]?[0-9]+$ and read as base 10 (+5, 007 is 7), never 0x10, 1_000 or 1e3;
floats match ^[+-]?[0-9]+(\.[0-9]+)?([eE][+-]?[0-9]+)?$ (1e3, 0.5), never .5, 5., hexadecimal
(0x1p3), NaN, infinite or too large for a double (1e400), whatever the locale; durations follow their
encoding's grammar exactly (seconds: ^[0-9]+(\.[0-9]+)?$; in contract-first mode also Go's
time.ParseDuration, ISO 8601 P[nD][T[nH][nM][nS]] and .NET [d.]hh:mm:ss[.f]). Nothing is trimmed, csv
items included: " true" and "5\n" are invalid_type.Values come from whatever providers your ConfigReader has. Docuconf.load(AppConfig.self) (and loadOrExit)
without a reader uses EnvironmentVariablesProvider on the process environment with the declared secrets marked
secret, and Docuconf.load(AppConfig.self, dotEnvPath: ".env") adds a .env file for local development, which real
environment variables override.
print(config), string interpolation, dump(config) and debuggers show each input's value, and <redacted> for a
secret (TLS key pairs and keystores are always secret). The quickstart prints:
AppConfig(_port: 8080, _logLevel: Quickstart.LogLevel.info, _requestTimeout: 30.0 seconds, database: Quickstart.DatabaseConfig(_url: <redacted>, _poolSize: 10))
The property-wrapper storage shows as _port. They never show the declaration's internals.
| Property type | Contract type | Checked at boot | Rules |
|---|---|---|---|
ConfigFile<T: Decodable> | config | parses as JSON, YAML or TOML (by extension, or .format(.yaml)) and decodes into T | schema from T |
TLSKeyPair (TLS trait) | tls | tls.crt and tls.key parse and match, validity, minRemaining, DNS names (one-label wildcards), key algorithm, chain to ca.crt; a file with no PEM certificate or key is file_malformed, a PEM certificate that does not parse certificate_invalid | .dnsNames, .keyAlgorithms, .minRemaining, .requireCA |
CABundle (TLS trait) | caBundle | at least minCertificates parseable certificates | .minCertificates(2) |
Keystore (TLS trait) | keystore | PKCS#12 MAC (SHA-1 or SHA-2, so AES-256 keystores too) or JKS digest verifies with the password variable (an empty password when it is unset); the bags are not decrypted | .format(.jks), .passwordVar("KEYSTORE_PASSWORD") |
TextFile | text | UTF-8, length and pattern | .pattern, .length, .minLength, .maxLength |
BinaryFile | binary | size |
Every input takes .pathEnv("ROUTES_FILE") (the path is read from that variable when it is set), .maxSize(bytes),
.reload(.watch), .group, .deprecated and, for config, text and binary files, .secret. TLS key pairs and
keystores are always secret. Locally, set DOCUCONF_FILE_ROOT to a directory that mirrors the container's paths.
This is the gateway from Examples/Gateway:
enum LogLevel: String, ConfigEnum { case debug, info, warn, error }
struct Routes: Decodable, Sendable {
var routes: [Route]
struct Route: Decodable, Sendable { var prefix: String; var upstream: URL }
}
struct GatewayConfig: DocuconfConfig {
@Env("http.port", "HTTP listen port", .range(1...65535))
var port = 8443
@Env("database.url", "Primary Postgres connection string", .secret, .schemes("postgres", "postgresql"), .maxLength(2048))
var databaseURL: URL
@Env("log.level", "Minimum log level")
var logLevel = LogLevel.info
@Env("request.timeout", "Upstream request timeout", .range(.seconds(1) ... .seconds(300)))
var requestTimeout: Duration = .seconds(30)
@FileInput("routes", "Routing table: path prefixes and their upstreams",
path: "/etc/gateway/routes/routes.json", .reload(.watch))
var routes: ConfigFile<Routes> // JSON Schema derived from Routes
@FileInput("tls", "Certificate the gateway serves HTTPS with", path: "/etc/gateway/tls",
.dnsNames("gateway.internal"), .keyAlgorithms(.ecdsa), .minRemaining(.seconds(30 * 24 * 3600)))
var tls: TLSKeyPair // needs the TLS trait
}
Run it from this repository. make-dev-tls.sh writes a self-signed development certificate under dev-root:
Examples/Gateway/make-dev-tls.sh
DOCUCONF_FILE_ROOT=Examples/Gateway/dev-root DATABASE_URL=postgres://localhost/gw swift run --traits TLS GatewayExample
Its contract is Examples/Gateway/contract.cue, and the docs generated from it by
docuconf docs, as for the orders example, are
CONFIG.md and CONFIG.agents.md.
Schemas from code. The JSON Schema for a ConfigFile<T> (or a JSONConfigValue) is derived from T's
Decodable conformance by decoding it once with a recording decoder: properties read with decode are required,
decodeIfPresent optional, arrays and dictionaries record their element types, CaseIterable enums become enum
lists, and URL, Date and UUID become formatted strings. Swift has no runtime access to doc comments or custom
validation, so the derived schema has types and required properties only. Conform a type to JSONSchemaProviding to
supply a richer schema, and to ValidatedConfig to check invariants after decoding (reported as schema_mismatch;
for a secret input, the validator's text is replaced by a generic message, since it could quote the content).
TLS trait. TLS checks use swift-certificates and
swift-crypto, which work the same on Linux and macOS, and are built only with
the TLS trait (see Install). Keystores are opened far enough to prove the password: the PKCS#12 MAC
(SHA-1 to SHA-512, so both OpenSSL 3 defaults and -legacy files) or the JKS keyed digest. PKCS#12 files that use
PBMAC1 or public-key integrity are reported as unreadable. The types (TLSKeyPair, ...) are always available, so a
declaration compiles and exports without the trait; loading it fails with a DeclarationError that says to turn the
trait on.
Reloading. .reload(.watch) tells the platform it need not restart the pod when the source changes. Your app
keeps that promise by following the input's changes, with onChange hooks or a changes(every:) stream (see
Using a watched value). docuconf detects a change by comparing a hash of the content
(Kubernetes swaps a symlink, so every file of the input is re-read together). In a service, run the loop in a
Service, as in With Hummingbird:
for await change in config.$routes.changes(every: .seconds(10)) {
switch change {
case .updated(let routes): print("new routes: \(routes.routes.map(\.prefix))") // passed every boot check
case .rejected(let violations): print("kept the old routes: \(violations)") // the old value stays
}
}
With .reload(.watch), docuconf swaps in changed content once it passes the checks it passed at boot. Content that
fails them is not used: the previous value stays current, and the rejection is logged through LoadOptions.warn with
the input's name and the violation codes, never the content. docuconf checks the input in one background task per
input, which runs while at least one onChange hook or changes(every:) stream is registered, at the shortest
interval any of them asked for (10 seconds by default). Reading the property never touches the disk: it returns the
value of the last accepted reload. With no hook and no stream, nothing checks the input and the value read at boot
stays.
An app that copies a watched value once, into a TLS server context, an HTTP client or a connection pool, keeps the
old value until the certificate expires. Either read the property at each use (for a TLS server, when it builds the
context for a new connection), or rebuild what you made from it in an onChange hook:
// A client copies its trust roots when it is built: build a new one each time the bundle changes.
let holder = ClientHolder(UpstreamClient(trusting: config.upstreamCAs.pem))
let subscription = config.$upstreamCAs.onChange { bundle in
holder.replace(with: UpstreamClient(trusting: bundle.pem)).shutdown()
}
// ... and on shutdown:
subscription.cancel()
$input.onChange(every:_:) takes an async throws closure that receives the new value, and returns a
ReloadSubscription; cancel() unregisters the hook, and the background check stops when nothing is registered.
Dropping the subscription does not cancel it. A hook is called after a reload is accepted, never after a rejected
change. Several hooks may follow one input; they run one at a time, in the order they were registered, after the
property already returns the new value. A hook that throws is logged with the input's name and the error's type only,
and the other hooks still run. Hooks and changes(every:) streams share the background check, so one change is one
reload, whichever of them is following it.
$input.reloadStatus gives the input's reload state, for a health check or a metric:
let status = config.$tls.reloadStatus // generation, lastReload, lastRejected (time, input, codes)
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let body = try encoder.encode(["serving-tls": status]) // {"serving-tls":{"generation":1}} after boot
generation is 1 after boot (0 for an optional input that was absent) and grows by one with each accepted reload;
lastReload is the time of the last accepted reload; lastRejected is the last rejected change, as its time, the
input's name and the violation codes, and a later accepted reload clears it.
Keystores. A reload opens the new keystore with the password read at boot: docuconf never reads the environment
again, and a process's environment does not change anyway. Rotating a keystore's password therefore needs a rollout,
which also delivers the new keystore. A changed keystore that does not open with the boot password is rejected as
keystore_unreadable, and the previous one stays current.
Contract-first mode (ContractDocument) returns values read once, so a contract that declares reload: watch for
a file input or an overlay is a DeclarationError that names the input. Declare the input with @FileInput and
.reload(.watch) to reload it.
swift-configuration layers providers, and the first one with a value wins. An app that bakes a JSON or YAML file
into its image reads it with a FileProvider below the environment. An overlay (SPEC §4.7) is one more file that
the platform mounts between the two, so the order is baked-in file < overlay < environment. Declare it on the
configuration type:
struct OverlaidConfig: DocuconfConfig {
static let overlays = [
ConfigOverlay("platform", "Settings the platform manages", path: "/etc/gateway/overlay/gateway.json"),
]
@Env("http.port", "HTTP listen port", .range(1...65535)) var port = 8443
}
let base = try await FileProvider<JSONSnapshot>(filePath: "/app/config/gateway.json")
let config = await Docuconf.loadOrExit(OverlaidConfig.self, files: [base])
// providers, first match wins: environment, overlays, then `files`
If you compose your own ConfigReader, put try await Docuconf.overlayProviders(for: OverlaidConfig.self) after the
environment provider and before your file providers. Docuconf.load(_:from:) reads only the providers the reader
has.
JSONSnapshot and YAMLSnapshot. The format comes from
the extension, or pass format:. TOML overlays are not supported in declaration mode..:
http.port becomes {"http": {"port": 8443}}. The contract declares keySeparator: ".", and every variable gets
a configKey, even when the key is already the variable name. The exception is json variables: a file
snapshot splits a nested object into separate keys, so the app could not read one back. They get no configKey,
and the platform has to supply them through the environment.90, 1.5), as the platform renders it; a string of seconds ("90") is also accepted. A
missing overlay file is fine. A file that does not parse is reported as file_malformed, together with every other
problem.reload: restart, so a changed overlay rolls the pods. docuconf reads variables
once, at boot, so declaring .watch is a declaration error.LoadOptions.appDirectory).
DOCUCONF_FILE_ROOT is prepended to the overlay path, as for file inputs.DOCUCONF_FILE_ROOT is prepended to every absolute file path, including one read from a pathEnv variable, for
local development and tests./dev/termination-log when it exists, or to DOCUCONF_TERMINATION_LOG (an empty value
writes no log).HOME is not taken for HOST) produces a warning naming both. Other undeclared variables
are ignored.missing_required, invalid_type, out_of_range, pattern_mismatch, not_in_enum,
invalid_scheme, too_few_items, too_many_items, file_missing, file_unreadable (with an fsGroup hint),
file_too_large, file_malformed, schema_mismatch, certificate_invalid, certificate_expiring,
certificate_name_mismatch, key_mismatch, keystore_unreadable.Docuconf.load throws ConfigurationError (every violation) or DeclarationError; Docuconf.loadOrExit prints
either and exits 1.Platforms often supply secrets at container start instead of in the pod spec: Bank-Vaults' vault-env resolves
values such as vault:secret/data/db#url, and wrappers such as op run resolve their own references. Nothing changes
in your code: docuconf reads the process environment as it is when the process starts, after injection, and validates
the injected values like any other. It never resolves a reference itself.
If the injector did not run, the app would receive the reference itself. A secret variable whose value starts with
vault:, op:// or ref+ therefore fails with invalid_type, naming the scheme but never the value:
DATABASE_URL [invalid_type]: holds an unresolved vault: reference; the injector that should resolve it did not run
iOS apps do not get per-environment configuration from Kubernetes: their configuration is compiled in, through
xcconfig files and Info.plist. This SDK does not solve that yet. It is split so a later build-time contract for iOS
can reuse the parts that matter:
DocuconfCore holds the declaration model (@Env, @FileInput, DocuconfConfig), the declaration checks,
JSON Schema derivation, value parsing and constraint checks, and the CUE contract writer. It imports nothing but
Foundation, so it builds for iOS, and CI checks that it stays that way.Docuconf adds the server side: reading through swift-configuration, file checks (and, with the TLS trait,
certificate and keystore checks with swift-crypto and swift-certificates), the termination log, reloading and the
export command.ContractDocument (in DocuconfCore) validates an environment, and the files it points at, against a contract
given as JSON, with no Swift declaration: for a hand-written contract.cue exported with
cue export contract.cue --out json, or for tooling. It parses every wire encoding in SPEC §5, whatever the contract
records: lists and key sets as csv (with its separator), json or indexed (NAME__0, NAME__1, ...),
durations as go, iso8601, seconds or timespan. The checks are the ones Docuconf.load runs on a
declaration, and every violation is reported together.
let contract = try ContractDocument(json: Data(contentsOf: URL(fileURLWithPath: "contract.json")))
let values = try await contract.load(support: DocuconfFileSupport()) // or load(environment: [...], support: ...)
if case .int(let port)? = values["PORT"] { print(port) }
Values are ParsedValues, file inputs ContractFileValues (values.json gives them all as JSON, durations in
canonical Go form). Unset optional variables take their contract default, or are absent. The contract's whole
surface is loaded:
default, then the selected profile's default
(profiles.selector from the environment, else profiles.default; names are case-sensitive), then a config-file
overlay (JSON, YAML or TOML, read at the variable's configKey split on the overlay's keySeparator, native values
converted to their wire string and checked like env values), then the environment. A profile or overlay value
satisfies a required variable. A missing overlay is not an error; one that does not parse, or is not an object, is
file_malformed for the overlay.DOCUCONF_FILE_ROOT when the environment sets it: config files in json, yaml or
toml, checked against their JSON Schema (a config file's value is its data); text files against their
constraints; tls key pairs, caBundles and PKCS#12 or JKS keystores with the checks the declaration path runs;
binary files for presence and maxSize.warn:: a deprecated input that is set (its name and message, never the value), and a variable
set both in the environment and in an overlay.reload: watch for a file input or an overlay is a
DeclarationError naming the input, rather than a promise silently broken.load is async. support parses YAML and runs the certificate and keystore checks: pass the server SDK's
DocuconfFileSupport() (certificates and keystores need the TLS trait). The default, FoundationFileSupport(),
keeps DocuconfCore Foundation-only: it reads JSON and TOML (with the built-in TOML reader) and reports YAML files
and certificate inputs as problems instead of skipping them.
A json variable must be valid JSON and match its schema, or it is schema_mismatch, and so must a config file.
The validator (JSONSchemaValidator, no dependency) enforces the keywords docuconf contracts use: type, enum,
const, properties, required, additionalProperties, minProperties, maxProperties, items, minItems,
maxItems, uniqueItems, minimum, maximum, exclusiveMinimum, exclusiveMaximum, multipleOf, minLength
and maxLength (in Unicode scalars), pattern (RE2), anyOf, oneOf, allOf and not; annotations such as
title, description and format are ignored. A schema with any other keyword ($ref, patternProperties, ...)
is a DeclarationError, not a check silently skipped. Declared JSONConfigValue variables are still checked by
decoding into the Swift type.
The test suite runs the shared conformance suite (SPEC §12) from docuconf-go through contract-first mode
(Tests/DocuconfTests/ConformanceTests.swift). For each case it writes the case's files into a fresh temporary
directory and loads with the case's environment plus DOCUCONF_FILE_ROOT set to it. It reads cases.json from
DOCUCONF_CONFORMANCE, or from a docuconf-go checkout next to this repository, and is skipped when neither exists
unless DOCUCONF_REQUIRE_CONFORMANCE=1 (as in CI):
DOCUCONF_CONFORMANCE=../docuconf-go/conformance/cases.json DOCUCONF_REQUIRE_CONFORMANCE=1 \
swift test --traits TLS --filter ConformanceTests
A failing case is reported by its id (int/below min), which points at its YAML source in
conformance/load/. Capability tags: none are skipped. The runner keeps an allow-list of the tags it supports,
int64, json-schema, key-set, deprecated, strict-parsing, files, profiles and overlays, and skips a
case with any other tag, so a tag added to the suite later never runs against an SDK that predates it. files
needs the TLS trait (for the certificate and keystore cases); a build without it skips those cases, which is why
CI runs the suite with --traits TLS. With DOCUCONF_REQUIRE_CONFORMANCE=1, as in CI, any skipped case fails the
suite. int64 holds because Int is 64 bits on the platforms the suite runs on (Linux and macOS).
Export. Tests/DocuconfCoreTests/SharedExportTests.swift declares the shared export fixture
(conformance/export/fixture.yaml) in Swift, exports it, and runs
docuconf conformance export --golden conformance/export/golden.cue exported.cue, which compares the two as data.
It finds the CLI through DOCUCONF_CLI, ~/go/bin or PATH, and the golden file through DOCUCONF_EXPORT_GOLDEN or
next to DOCUCONF_CONFORMANCE; with DOCUCONF_REQUIRE_EXPORT=1, as in CI, a missing one fails the test. The SDK's
own golden contract (Tests/DocuconfCoreTests/Golden/gateway.cue) stays: it also covers what the shared fixture does
not, such as details taken from DocC text and narrow integer types.
profiles. If you layer a JSON file provider under the environment, its values are not in the
contract. Contract-first mode reads profiles from a contract.reload: watch): variables are read once, at boot.reload: watch is rejected when it is read.contract.cue itself in contract-first mode: export it to JSON with cue export first.ca.crt chain check validates against the system clock, not LoadOptions.now.DocuconfCore light for iOS.swift build
swift test --traits TLS # everything, including the TLS and keystore checks
swift test # the default build, without the TLS trait
DOCUCONF_UPDATE_GOLDEN=1 swift test --filter ExportTests # rewrite the golden contract, then review the diff
The export tests run cue vet -c on the generated contracts against the meta-schema in docuconf-go/spec/cue. Set
DOCUCONF_SPEC_CUE to that directory (a sibling docuconf-go checkout is found automatically) and have cue v0.17.1
on PATH or in ~/go/bin. Without them the vet is skipped, unless DOCUCONF_REQUIRE_VET=1, as in CI. The
conformance suite is found the same way (see Conformance). Test certificates are generated by the
tests with swift-certificates; the keystore fixtures come from scripts/make-keystore-fixtures.sh.
README snippets are checked. Every swift and text block in this README is preceded by a
<!-- snippet: file#region --> marker, and ReadmeTests fails unless the block is exactly that file or region. The
files are compiled in CI: the quickstart and its test, the gateway, Tests/DocuconfTests/ReadmeSnippets.swift and the
Hummingbird and Vapor packages. The install block is built against the pushed commit by
scripts/check-readme-install.sh, and the "See an error" output is compared with the real output by
Examples/Quickstart/smoke.sh.
What has been verified. The package was built and its tests run on Linux x86_64 with Swift 6.3.3 (the official
swift:6.3.3-noble image). It has not yet been built on macOS or for iOS; the macOS job in CI is the first place
that will happen, and it should pass before the first release.
Releases: see RELEASING.md.
MIT. See LICENSE.
.keys(1...2), .minKeys, .maxKeys, .keyLength(32...256), .keyMinLength, .keyMaxLength, .separator |
a JSONConfigValue (Codable struct) | json | JSON Schema derived from the type; .maxLength |
stringout_of_rangeinvalid_typeNaN--from-file\d, \w, \s, \b and POSIX classes, $ only at the end of the text, (?P<name>...) groups.