diff --git a/internal/schema/pkl/schema/forma.pkl b/internal/schema/pkl/schema/forma.pkl index b4f231bbe..004e2f202 100644 --- a/internal/schema/pkl/schema/forma.pkl +++ b/internal/schema/pkl/schema/forma.pkl @@ -4,12 +4,16 @@ * SPDX-License-Identifier: FSL-1.1-ALv2 */ -module Forma +open module Forma import "formae.pkl" description: formae.Description? -properties: Dynamic? +properties: Any? + +/// fill: entry point used by the self-injecting output below; constructs a +/// typed instance of the user's properties class from external `prop:` values. +function fill(clazz: Class): Typed = formae.fillProps(clazz) hidden forma: Listing /// defaultStack: returns the first defined Stack @@ -22,17 +26,27 @@ hidden defaultTarget: formae.TargetResolvable = forma.toList().filterIsInstance(formae.Target).firstOrNull?.res ?? forma.toList().filterIsInstance(formae.TargetResolvable).firstOrNull ?? throw("no default target found") +// hidden guard for the self-injecting output: the first evaluation amends this +// module with a filled `properties` instance (reading `prop:` values), the +// second renders normally with the injected values visible everywhere via +// late binding. Same mechanism pkl:Command's runtime uses ("synthesize a +// module that amends the command module and sets options"). +hidden injected: Boolean = false + // NOTE: a lot of this would be uneeded if bugs or unfinished implementation issues were resolved in PKL output { value = - if (formae.mode() == "properties") + if (!injected && properties != null && properties is Typed) + let (m = module) + ((m) { injected = true; properties = m.fill(m.properties.getClass()) }).output.value + else if (formae.mode() == "properties") new formae.FormaRender { - Properties = module.properties + Properties = formae.renderProperties(module.properties) } else new formae.FormaRender { Description = module.description - Properties = module.properties + Properties = formae.renderProperties(module.properties) Stacks = forma.toList().filterIsInstance(formae.Stack).toListing() Targets = forma.toList().filterIsInstance(formae.Target).toListing() Policies = forma.toList().filterIsInstance(formae.Policy).map((p) -> p.render()).toListing() diff --git a/internal/schema/pkl/schema/formae.pkl b/internal/schema/pkl/schema/formae.pkl index deb42fe3c..77f1ba2b0 100644 --- a/internal/schema/pkl/schema/formae.pkl +++ b/internal/schema/pkl/schema/formae.pkl @@ -432,12 +432,58 @@ open class Prop { fixed Value: (String|Boolean|Int|Float)? = value } +// ---- typed CLI properties (RFC-105) ---------------------------------------- + +/// Resolve a reflected type to its simple name, unwrapping nullables. +function typeName(t): String = + if (t is reflect.DeclaredType) t.referent.name + else if (t is reflect.NullableType) typeName(t.member) + else "unknown" + +/// fillProps constructs a typed instance of `clazz`, reading `prop:` +/// per member, coercing to the declared type, and falling back to the declared +/// default. Type constraints on the class members are validated by `toTyped`. +/// Prior art: pkl-pantry `pkl.experimental.structuredRead`. +function fillProps(clazz: Class): Typed = + new Mapping { + for (n, p in reflect.Class(clazz).properties) { + [n] = let (raw = read?("prop:\(n)")) + if (raw == null) p.defaultValue + else + let (tn = typeName(p.type)) + if (tn == "Int") raw.toInt() + else if (tn == "Float") raw.toFloat() + else if (tn == "Boolean") raw.toBoolean() + else raw + } + }.toMap().toTyped(clazz) + +/// renderProperties turns the module's `properties` value into the manifest the +/// CLI/agent consume. Typed instances (the extends shape) are reflected into +/// `{ name -> Prop }`; legacy Dynamic blocks pass through unchanged. The +/// manifest Value always comes from the INSTANCE (never a self-reading Prop), +/// so manifest and resources cannot disagree. +function renderProperties(opts: Any?): Any? = + if (opts == null) new Mapping {} + else if (!(opts is Typed)) opts + else + let (inst = opts.toDynamic().toMap()) + new Mapping { + for (name, p in reflect.Class(opts.getClass()).properties) { + [name] = new Prop { + flag = name + default = p.defaultValue as (String|Boolean|Int|Float)? + value = inst[name] as (String|Boolean|Int|Float)? + } + } + } + /// FormaeRender is an ouput class notice the casing difference class FormaRender { local self = this Description: Description? - Properties: Dynamic? + Properties: Any? Stacks: Listing Targets: Listing Policies: Listing? diff --git a/internal/schema/pkl/testdata/forma/extends_props_test.pkl b/internal/schema/pkl/testdata/forma/extends_props_test.pkl new file mode 100644 index 000000000..393438d6f --- /dev/null +++ b/internal/schema/pkl/testdata/forma/extends_props_test.pkl @@ -0,0 +1,39 @@ +/* + * © 2025 Platform Engineering Labs Inc. + * + * SPDX-License-Identifier: FSL-1.1-ALv2 + */ + +/// RFC-105 target shape: extends + pure-schema typed properties. +extends "@formae/forma.pkl" + +import "@formae/formae.pkl" +import "@fakeaws/fakeaws.pkl" +import "@fakeaws/sqs/queue.pkl" + +properties: Props + +class Props { + /// The name to deploy + name: String = "pel" + + /// Port to listen on + port: Int(this > 0) = 8080 +} + +forma { + new formae.Stack { + label = "\(properties.name)-stack" + description = "stack for \(properties.name)" + } + + new formae.Target { + label = "default-target" + config = new fakeaws.Config { region = "us-east-1" } + } + + new queue.Queue { + label = "q" + queueName = "\(properties.name)-\(properties.port)-queue" + } +}