@varde-flyt/vfac 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +31 -0
  2. package/dist/vfac.mjs +218 -7
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -101,6 +101,37 @@ service, exactly as it resolves the service's own credentials. So there is no
101
101
  second copy to rotate, and no value for you to read — `vfac get product-instance`
102
102
  shows one as `[secret]`. `vfac guide` is authoritative.
103
103
 
104
+ ## Settings that go together
105
+
106
+ A Product may declare that a group of settings is all-or-nothing: leave every one
107
+ of them alone, or supply all of them. A provider connection is the usual shape —
108
+ an address without its credential is worse than neither, because the service
109
+ starts and then fails on its first call.
110
+
111
+ ```yaml
112
+ spec:
113
+ configuration:
114
+ upstreamUrl: https://upstream.example.com
115
+ upstreamAccount: acct-9
116
+ # and the credential, beside the apply rather than in the file:
117
+ # vfac apply -f resource.yaml --secret upstreamToken=env:UPSTREAM_TOKEN
118
+ ```
119
+
120
+ Supply the whole group or none of it. A partial answer is refused, and the
121
+ refusal names the setting that is missing and the one that made it necessary.
122
+
123
+ `vfac manifest init` lists each group and seeds nothing for it — half a group is
124
+ exactly the state that gets refused, so a placeholder would only produce a file
125
+ that fails `vfac validate`.
126
+
127
+ **`validate` and `plan` answer different questions here, and the difference is
128
+ not a bug.** A manifest may never carry a secret, so `vfac validate` — which
129
+ reads a file with no Project and no credential — cannot tell whether the
130
+ credential in a group is configured, and does not refuse a document over it.
131
+ `vfac plan` knows what the apply would carry and what the resource already holds,
132
+ so it is where a missing member is reported. A credential the resource already
133
+ holds does not have to be sent again to change one of its siblings.
134
+
104
135
  ## Connecting to another resource
105
136
 
106
137
  A Product may declare a **service dependency** — a connection to another service
package/dist/vfac.mjs CHANGED
@@ -7880,14 +7880,26 @@ function renderOperation(operationId, data) {
7880
7880
  lines.push(` Failed${typeof stage === "string" ? ` at ${stage}` : ""}`);
7881
7881
  const summary = failure["summary"];
7882
7882
  if (typeof summary === "string") lines.push(` ${summary}`);
7883
- const detail = failure["detail"];
7884
- if (typeof detail === "string" && detail !== "") lines.push(` ${detail}`);
7885
7883
  const cause = failure["cause"];
7886
7884
  if (typeof cause === "string") lines.push(` cause: ${cause}`);
7885
+ const services = failure["services"];
7886
+ if (Array.isArray(services)) {
7887
+ for (const entry of services) {
7888
+ const one = asRecord(entry);
7889
+ if (one === null) continue;
7890
+ const dependency = one["dependency"];
7891
+ if (typeof dependency !== "string" || dependency === "") continue;
7892
+ const code = one["code"];
7893
+ lines.push(
7894
+ typeof code === "string" && code !== "" ? ` ${dependency} \u2014 the service reported: ${code}` : ` ${dependency} \u2014 the service reported nothing usable`
7895
+ );
7896
+ }
7897
+ }
7887
7898
  const retryable = failure["retryable"];
7888
7899
  if (typeof retryable === "boolean") {
7900
+ const again = data["type"] === "PRODUCT_INSTANCE_UPGRADE" ? "`vfac lifecycle upgrade <pri_\u2026>`" : "`vfac lifecycle reconcile <pri_\u2026>`";
7889
7901
  lines.push(
7890
- retryable ? " Another attempt can succeed: `vfac lifecycle reconcile <pri_\u2026>`." : " Another attempt will fail the same way. Change something first."
7902
+ retryable ? ` Another attempt can succeed: ${again}.` : ` Repeating this unchanged fails again \u2014 fix what the lines above name, then ${again}.`
7891
7903
  );
7892
7904
  }
7893
7905
  }
@@ -12261,6 +12273,27 @@ var CustomerConfigurationObjectSchema = external_exports.object({
12261
12273
  type: external_exports.literal("object"),
12262
12274
  properties: external_exports.record(PropertyNameSchema, ConfigurationPropertySchema).describe("Customer-editable settings, keyed by camelCase property name."),
12263
12275
  required: external_exports.array(PropertyNameSchema).max(40).optional(),
12276
+ /**
12277
+ * Settings that must be configured TOGETHER, keyed by the one that triggers
12278
+ * the requirement.
12279
+ *
12280
+ * `required` says a setting must always have a value. This says a group is
12281
+ * either wholly absent or wholly complete — the shape a provider connection
12282
+ * has, where an endpoint without its credential is worse than neither.
12283
+ *
12284
+ * JSON SCHEMA 2020-12 SEMANTICS, DELIBERATELY AND EXACTLY: if the trigger
12285
+ * key is present in the submitted object, every name in its list must be
12286
+ * present too. The key is published under its standard name in
12287
+ * `schemas/product-definition.schema.json`, inside a node that already looks
12288
+ * like JSON Schema, so a publisher running an off-the-shelf validator over
12289
+ * that file must get the answer the platform gives. The refinement below is
12290
+ * what keeps that promise true — see the `default` rule, which exists for
12291
+ * this reason and no other.
12292
+ *
12293
+ * CYCLES ARE LEGAL. `{a: ["b"], b: ["a"]}` is how "all or none" is written,
12294
+ * and refusing it would remove the primitive's main use.
12295
+ */
12296
+ dependentRequired: external_exports.record(PropertyNameSchema, external_exports.array(PropertyNameSchema).min(1).max(40)).optional().describe("Settings that must be supplied together, keyed by the triggering property."),
12264
12297
  /**
12265
12298
  * Literal `false`, not merely defaulted: docs/CONVENTIONS.md §8 requires
12266
12299
  * contracts to reject unknown fields, and an unknown key accepted here
@@ -12455,7 +12488,129 @@ var CustomerConfigurationSchema = CustomerConfigurationObjectSchema.superRefine(
12455
12488
  message: `Duplicate required entries: ${[...new Set(duplicates)].join(", ")}.`
12456
12489
  });
12457
12490
  }
12491
+ checkDependentRequired(config, ctx);
12458
12492
  });
12493
+ function checkDependentRequired(config, ctx) {
12494
+ const clauses = Object.entries(config.dependentRequired ?? {});
12495
+ if (clauses.length === 0)
12496
+ return;
12497
+ if (clauses.length > MAX_CONFIGURATION_PROPERTIES) {
12498
+ ctx.addIssue({
12499
+ code: external_exports.ZodIssueCode.custom,
12500
+ path: ["dependentRequired"],
12501
+ message: `A Product may declare at most ${MAX_CONFIGURATION_PROPERTIES} dependent-required groups; found ${clauses.length}.`
12502
+ });
12503
+ }
12504
+ for (const [trigger, dependents] of clauses) {
12505
+ const path = ["dependentRequired", trigger];
12506
+ const declaredTrigger = config.properties[trigger];
12507
+ if (declaredTrigger === void 0) {
12508
+ ctx.addIssue({
12509
+ code: external_exports.ZodIssueCode.custom,
12510
+ path,
12511
+ message: `"${trigger}" requires other settings but is not a declared property.`
12512
+ });
12513
+ } else if (declaredTrigger.type !== "string") {
12514
+ ctx.addIssue({
12515
+ code: external_exports.ZodIssueCode.custom,
12516
+ path,
12517
+ message: `"${trigger}" is a ${declaredTrigger.type} setting and cannot require others. Only a string setting can, because presence is the question this rule asks and a boolean or an empty list is an answer the customer gave rather than a setting they left alone.`
12518
+ });
12519
+ } else if (declaredTrigger.default !== void 0) {
12520
+ ctx.addIssue({
12521
+ code: external_exports.ZodIssueCode.custom,
12522
+ path,
12523
+ message: `"${trigger}" declares a default, so it is never absent and would require the settings below of every customer unconditionally. Remove the default, or make the requirement unconditional with \`required\`.`
12524
+ });
12525
+ }
12526
+ const seen = /* @__PURE__ */ new Set();
12527
+ for (const dependent of dependents) {
12528
+ if (dependent === trigger) {
12529
+ ctx.addIssue({
12530
+ code: external_exports.ZodIssueCode.custom,
12531
+ path,
12532
+ message: `"${trigger}" cannot require itself.`
12533
+ });
12534
+ continue;
12535
+ }
12536
+ if (seen.has(dependent)) {
12537
+ ctx.addIssue({
12538
+ code: external_exports.ZodIssueCode.custom,
12539
+ path,
12540
+ message: `"${trigger}" lists "${dependent}" more than once.`
12541
+ });
12542
+ continue;
12543
+ }
12544
+ seen.add(dependent);
12545
+ const declared = config.properties[dependent];
12546
+ if (declared === void 0) {
12547
+ ctx.addIssue({
12548
+ code: external_exports.ZodIssueCode.custom,
12549
+ path,
12550
+ message: `"${trigger}" requires "${dependent}", which is not a declared property.`
12551
+ });
12552
+ continue;
12553
+ }
12554
+ if (declared.default !== void 0) {
12555
+ ctx.addIssue({
12556
+ code: external_exports.ZodIssueCode.custom,
12557
+ path,
12558
+ message: `"${dependent}" declares a default, so it is never absent and this requirement could never fail. Remove the default, or drop it from this group.`
12559
+ });
12560
+ }
12561
+ }
12562
+ }
12563
+ checkOneSecretPerGroup(config, ctx);
12564
+ }
12565
+ function checkOneSecretPerGroup(config, ctx) {
12566
+ const clauses = Object.entries(config.dependentRequired ?? {});
12567
+ if (clauses.length === 0)
12568
+ return;
12569
+ const neighbours = /* @__PURE__ */ new Map();
12570
+ const link = (a, b) => {
12571
+ if (!neighbours.has(a))
12572
+ neighbours.set(a, /* @__PURE__ */ new Set());
12573
+ neighbours.get(a).add(b);
12574
+ };
12575
+ for (const [trigger, dependents] of clauses) {
12576
+ for (const dependent of dependents) {
12577
+ link(trigger, dependent);
12578
+ link(dependent, trigger);
12579
+ }
12580
+ }
12581
+ const isSecret2 = (name) => {
12582
+ const property = config.properties[name];
12583
+ return property !== void 0 && property.type === "string" && property["x-secret"] === true;
12584
+ };
12585
+ const seen = /* @__PURE__ */ new Set();
12586
+ for (const start of neighbours.keys()) {
12587
+ if (seen.has(start))
12588
+ continue;
12589
+ const component = [];
12590
+ const queue = [start];
12591
+ seen.add(start);
12592
+ while (queue.length > 0) {
12593
+ const name = queue.pop();
12594
+ component.push(name);
12595
+ for (const next of neighbours.get(name) ?? []) {
12596
+ if (seen.has(next))
12597
+ continue;
12598
+ seen.add(next);
12599
+ queue.push(next);
12600
+ }
12601
+ }
12602
+ const secrets = component.filter(isSecret2).sort();
12603
+ if (secrets.length <= 1)
12604
+ continue;
12605
+ ctx.addIssue({
12606
+ code: external_exports.ZodIssueCode.custom,
12607
+ // The component has no single name, so the issue is reported against the
12608
+ // map rather than against whichever member happened to be visited first.
12609
+ path: ["dependentRequired"],
12610
+ message: `These settings are configured together and include more than one secret (${secrets.join(", ")}). Only one secret can be supplied per change, so a group must ask for at most one \u2014 otherwise completing it on a running resource takes several, in an order the customer has to work out.`
12611
+ });
12612
+ }
12613
+ }
12459
12614
  function enumerableValues(property) {
12460
12615
  const out = [];
12461
12616
  if (property.type === "string") {
@@ -12490,8 +12645,9 @@ function isPlainObject(value) {
12490
12645
  function isSecretProperty(property) {
12491
12646
  return property.type === "string" && property["x-secret"] === true;
12492
12647
  }
12493
- function validateSubmittedConfiguration(schema, submitted) {
12648
+ function validateSubmittedConfiguration(schema, submitted, presence = {}) {
12494
12649
  const issues = [];
12650
+ const supplied = /* @__PURE__ */ new Set();
12495
12651
  if (submitted !== void 0 && submitted !== null && !isPlainObject(submitted)) {
12496
12652
  return {
12497
12653
  ok: false,
@@ -12522,6 +12678,8 @@ function validateSubmittedConfiguration(schema, submitted) {
12522
12678
  const blank = typeof input[name] === "string" && input[name].trim().length === 0;
12523
12679
  const present = Object.hasOwn(input, name) && input[name] !== void 0 && !blank;
12524
12680
  const secret = isSecretProperty(property);
12681
+ if (present)
12682
+ supplied.add(name);
12525
12683
  if (!present) {
12526
12684
  const fallback = secret ? void 0 : defaultOf(property);
12527
12685
  if (fallback !== void 0) {
@@ -12546,10 +12704,48 @@ function validateSubmittedConfiguration(schema, submitted) {
12546
12704
  }
12547
12705
  configuration[name] = checked;
12548
12706
  }
12707
+ issues.push(...crossFieldIssues(schema, supplied, presence));
12549
12708
  if (issues.length > 0)
12550
12709
  return { ok: false, issues, partial: configuration };
12551
12710
  return { ok: true, configuration, secrets };
12552
12711
  }
12712
+ function crossFieldIssues(schema, supplied, presence) {
12713
+ const clauses = Object.entries(schema.dependentRequired ?? {});
12714
+ if (clauses.length === 0)
12715
+ return [];
12716
+ const alsoPresent = new Set(presence.present ?? []);
12717
+ const unknown = new Set(presence.unknown ?? []);
12718
+ const known = (name) => !unknown.has(name);
12719
+ const held = (name) => supplied.has(name) || alsoPresent.has(name);
12720
+ const issues = [];
12721
+ const reported = /* @__PURE__ */ new Set();
12722
+ for (const [trigger, dependents] of clauses) {
12723
+ if (!known(trigger) || !held(trigger))
12724
+ continue;
12725
+ for (const dependent of dependents) {
12726
+ if (!known(dependent) || held(dependent))
12727
+ continue;
12728
+ if (reported.has(dependent))
12729
+ continue;
12730
+ reported.add(dependent);
12731
+ issues.push({
12732
+ field: dependent,
12733
+ message: `This setting is required when ${labelOf(schema, trigger)} is configured.`
12734
+ });
12735
+ }
12736
+ }
12737
+ return issues;
12738
+ }
12739
+ function labelOf(schema, name) {
12740
+ const property = schema.properties[name];
12741
+ return property === void 0 ? `"${name}"` : `"${property.title}"`;
12742
+ }
12743
+ function withoutCrossFieldRules(schema) {
12744
+ if (schema.dependentRequired === void 0)
12745
+ return schema;
12746
+ const { dependentRequired: _removed, ...rest } = schema;
12747
+ return rest;
12748
+ }
12553
12749
  function defaultOf(property) {
12554
12750
  switch (property.type) {
12555
12751
  case "string":
@@ -12698,6 +12894,14 @@ function seedConfiguration(configuration) {
12698
12894
  const values = {};
12699
12895
  const open = [];
12700
12896
  const requiredSecrets = [];
12897
+ const groups = Object.entries(configuration?.dependentRequired ?? {}).map(([trigger, dependents]) => {
12898
+ const members = [trigger, ...dependents];
12899
+ return {
12900
+ trigger,
12901
+ settings: members.filter((name) => !isSecret(properties[name] ?? {})),
12902
+ secrets: members.filter((name) => isSecret(properties[name] ?? {}))
12903
+ };
12904
+ }).sort((a, b) => a.trigger.localeCompare(b.trigger));
12701
12905
  for (const [name, declaration] of Object.entries(properties)) {
12702
12906
  if (isSecret(declaration)) {
12703
12907
  if (required.has(name))
@@ -12713,7 +12917,7 @@ function seedConfiguration(configuration) {
12713
12917
  open.push({ name, type: declaration.type ?? null });
12714
12918
  }
12715
12919
  }
12716
- return { values, open, requiredSecrets };
12920
+ return { values, open, requiredSecrets, groups };
12717
12921
  }
12718
12922
  function productInstanceExample(input) {
12719
12923
  return {
@@ -13584,11 +13788,11 @@ var ProductDefinitionSchema = ProductDefinitionObjectSchema.superRefine((definit
13584
13788
  });
13585
13789
  return;
13586
13790
  }
13587
- const single = {
13791
+ const single = withoutCrossFieldRules({
13588
13792
  ...definition.configuration,
13589
13793
  properties: { [condition.property]: property },
13590
13794
  required: [condition.property]
13591
- };
13795
+ });
13592
13796
  const outside = condition.anyOf.filter((value) => !validateSubmittedConfiguration(single, { [condition.property]: value }).ok);
13593
13797
  if (outside.length > 0) {
13594
13798
  ctx.addIssue({
@@ -16893,6 +17097,13 @@ async function runManifestInit(parsed) {
16893
17097
  `spec.configuration.${setting.name} \u2014 required${setting.type ? ` (${setting.type})` : ""}`
16894
17098
  );
16895
17099
  }
17100
+ for (const group of seed.groups) {
17101
+ const settings = group.settings.map((name2) => `spec.configuration.${name2}`);
17102
+ const secrets = group.secrets.map((name2) => `--secret ${name2}=env:VAR`);
17103
+ notes.push(
17104
+ `${[...settings, ...secrets].join(" + ")} \u2014 all of these together, or none of them`
17105
+ );
17106
+ }
16896
17107
  const document = productInstanceExample({
16897
17108
  name,
16898
17109
  ...key ? { key } : {},
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@varde-flyt/vfac",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Deploy and manage Varde Flyt Products from a manifest.",
5
5
  "repository": {
6
6
  "type": "git",