diff --git a/docs/schemas/task-catalog-v1.schema.json b/docs/schemas/task-catalog-v1.schema.json index 5a22ea0..8a74e05 100644 --- a/docs/schemas/task-catalog-v1.schema.json +++ b/docs/schemas/task-catalog-v1.schema.json @@ -248,13 +248,20 @@ "dynamic_property": { "type": "object", "additionalProperties": false, - "required": ["prefix", "probeable"], + "required": ["prefix", "probeable", "scopes"], "properties": { "prefix": { "type": "string", "description": "What a member's name starts with." }, "probeable": { "type": "boolean", "description": "Whether the plugin's report surfaces a row per set member, so docket can read the value back. `false` means a recipe using the family reports drift on every run and never converges." }, + "scopes": { + "type": "array", + "items": { "enum": ["app", "global"] }, + "minItems": 1, + "uniqueItems": true, + "description": "Where a member of the family may be used. Using one in a scope it does not list is rejected at validate time, matching dokku's own rejection." + }, "sensitive": { "const": true, "description": "Members' values are treated as secrets." diff --git a/docs/task-catalog.md b/docs/task-catalog.md index 1a4b1b7..22a877d 100644 --- a/docs/task-catalog.md +++ b/docs/task-catalog.md @@ -193,7 +193,8 @@ set subcommand rather than through its report schema. Those are published as pre consumer does not reject a legal recipe: ```json -"dynamic": [ { "prefix": "dns-provider-", "probeable": true, "sensitive": true } ] +"dynamic": [ { "prefix": "dns-provider-", "probeable": true, "sensitive": true, + "scopes": ["global"] } ] ``` `probeable: false` means docket cannot read those values back, so a recipe using that family @@ -203,6 +204,11 @@ reports drift on every run and never converges. them into an input on export. It is independent of `probeable` - a credential docket cannot read back is still masked on the way out. +`scopes` reads exactly as it does on an enumerable property: a family listed only under `global` may +be used with `global: true` and is rejected for an app, matching dokku's own rejection. The +`dns-provider-*` credentials are global-only on traefik and app-or-global on letsencrypt, so a +consumer that ignored this would accept a recipe dokku refuses. + A name can also be absent from `properties` because another task manages it. That is a different answer from "no such property", and the catalog says so rather than leaving a consumer to report an unknown name and offer a list it will never be in: diff --git a/docs/tasks/dokku_letsencrypt_property.md b/docs/tasks/dokku_letsencrypt_property.md index 8352805..d4d1279 100644 --- a/docs/tasks/dokku_letsencrypt_property.md +++ b/docs/tasks/dokku_letsencrypt_property.md @@ -43,7 +43,7 @@ Keyed by `app`, `global`, and `property`. Fields left empty are omitted from the | `lego-docker-options` | app, global | `lego-docker-options` | `global-lego-docker-options` | | `server` | app, global | `server` | `global-server` | -Names starting with `dns-provider-` are also accepted. dokku validates them through `letsencrypt:set` rather than through its report schema, so they cannot be listed above. The plugin reports each one it has been given, so they probe for drift like any listed property. Their values are treated as secrets and masked. +Names starting with `dns-provider-` are also accepted in the app and global scopes. dokku validates them through `letsencrypt:set` rather than through its report schema, so they cannot be listed above. The plugin reports each one it has been given, so they probe for drift like any listed property. Their values are treated as secrets and masked. ## Examples diff --git a/docs/tasks/dokku_traefik_property.md b/docs/tasks/dokku_traefik_property.md index 05ee1a1..a6e90ea 100644 --- a/docs/tasks/dokku_traefik_property.md +++ b/docs/tasks/dokku_traefik_property.md @@ -10,7 +10,7 @@ Supported. ## Probe support -Partial - the mapped properties are probed; the dynamic `dns-provider-*` family has no report key and plans as drift on every run. +Supported. ## Identity @@ -48,7 +48,7 @@ Keyed by `app`, `global`, and `property`. Fields left empty are omitted from the | `letsencrypt-server` | global | | `global-letsencrypt-server` | | `log-level` | global | | `global-log-level` | -Names starting with `dns-provider-` are also accepted. dokku validates them through `traefik:set` rather than through its report schema, so they cannot be listed above. The plugin does not report them, so they are applied on every run and never converge. Their values are treated as secrets and masked. +Names starting with `dns-provider-` are also accepted in the global scope only. dokku validates them through `traefik:set` rather than through its report schema, so they cannot be listed above. The plugin reports each one it has been given, so they probe for drift like any listed property. Their values are treated as secrets and masked. ## Examples @@ -72,6 +72,16 @@ dokku_traefik_property: value: INFO ``` +### Setting a dns-provider-* env var globally + +```yaml +dokku_traefik_property: + app: "" + global: true + property: dns-provider-CLOUDFLARE_DNS_API_TOKEN + value: cf-token +``` + ### Clearing the letsencrypt email globally ```yaml diff --git a/docs/writing-tasks.md b/docs/writing-tasks.md index 8158663..b76d522 100644 --- a/docs/writing-tasks.md +++ b/docs/writing-tasks.md @@ -362,12 +362,20 @@ Those are declared in `dynamicPropertyFamilies` in `tasks/properties.go`, which validation accept a name the table has never heard of, and is published to consumers so a linter does not reject a legal recipe. How they plan depends on the plugin: -- The plugin reports the family (letsencrypt on 0.25.0+ emits a row per set property): declare it - `Probeable`, and the scope keys are synthesized so the property probes like any mapped one. - Because the row only exists once the property has a value, an absent row reads as unset. Note the - minimum plugin version in `Requirements()`. +- The plugin reports the family (letsencrypt on 0.25.0+ and traefik on dokku 0.38.27+ emit a row per + set property): declare it `Probeable`, and the scope keys are synthesized so the property probes + like any mapped one. Because the row only exists once the property has a value, an absent row reads + as unset. Note the minimum plugin version in `Requirements()`. - The plugin does not report it: leave `Probeable` false. The property skips probing and is applied - unconditionally, and the task is `ProbePartial` with a caveat naming the family. + unconditionally, and the task is `ProbePartial` with a caveat naming the family. No family declared + today is in this state, so `runUnprobedSet`/`runUnprobedUnset` have no live caller - they are the + other half of the contract, kept for the next plugin that takes a family it does not report. + +Every family also declares `Scopes`, the same `app`/`global` vocabulary an enumerable property states +by leaving a `PropertyKeys` half empty. A mapped property has an entry to say it with and a dynamic +one does not, so the family says it instead, and `validateProperty` holds a member to it: traefik's +`traefik:set` refuses a `dns-provider-*` key outside `--global`, so an app-scoped one is rejected with +the same sentence a mapped global-only property gets rather than probing a row that cannot exist. Mark the family `Sensitive` whenever its values are credentials, whichever of those two it is. Whether a value is a secret and whether docket can read it back are separate questions, and diff --git a/generate/docs.go b/generate/docs.go index 8a65f52..e92cdb2 100644 --- a/generate/docs.go +++ b/generate/docs.go @@ -137,8 +137,8 @@ func propertiesSection(schema *tasks.PropertySchema) string { } for _, family := range schema.Dynamic { - b.WriteString(fmt.Sprintf("\nNames starting with `%s` are also accepted. dokku validates them through `%s` rather than through its report schema, so they cannot be listed above. ", - family.Prefix, schema.Subcommand)) + b.WriteString(fmt.Sprintf("\nNames starting with `%s` are also accepted %s. dokku validates them through `%s` rather than through its report schema, so they cannot be listed above. ", + family.Prefix, scopePhrase(family.Scopes), schema.Subcommand)) if family.Probeable { b.WriteString("The plugin reports each one it has been given, so they probe for drift like any listed property.") } else { @@ -157,6 +157,17 @@ func propertiesSection(schema *tasks.PropertySchema) string { return b.String() } +// scopePhrase renders a dynamic family's scopes as the clause that follows +// "are also accepted": a family with one scope is spelled out as a restriction, +// since that is the part a reader has to know, and one with both reads as no +// restriction at all. +func scopePhrase(scopes []string) string { + if len(scopes) == 1 { + return fmt.Sprintf("in the %s scope only", scopes[0]) + } + return "in the " + strings.Join(scopes, " and ") + " scopes" +} + // codeOrBlank renders a report key as inline code, or an empty cell when the // property has no form in that scope. func codeOrBlank(key string) string { diff --git a/tasks/catalog.go b/tasks/catalog.go index 82c6959..9d554e0 100644 --- a/tasks/catalog.go +++ b/tasks/catalog.go @@ -317,6 +317,13 @@ type DynamicPropertySchema struct { // Sensitive is true when docket treats members as secrets. Sensitive bool `json:"sensitive,omitempty"` + + // Scopes is a non-empty subset of ["app", "global"], in that order, read + // the same way as PropertyEntrySchema.Scopes: a family listed only under + // "global" is rejected for an app at validate time, matching dokku's own + // rejection. traefik's `dns-provider-*` credentials are global-only that + // way, while letsencrypt's identically named family takes both. + Scopes []string `json:"scopes"` } // RejectedPropertySchema is a family of property names the task refuses, diff --git a/tasks/catalog_test.go b/tasks/catalog_test.go index f28891e..b5b1b61 100644 --- a/tasks/catalog_test.go +++ b/tasks/catalog_test.go @@ -436,19 +436,30 @@ func TestCatalogPropertySchemaSpotChecks(t *testing.T) { if letsencrypt == nil { t.Fatal("dokku_letsencrypt_property has no property schema") } - want := []DynamicPropertySchema{{Prefix: "dns-provider-", Probeable: true, Sensitive: true}} + want := []DynamicPropertySchema{{ + Prefix: "dns-provider-", + Probeable: true, + Sensitive: true, + Scopes: []string{PropertyScopeApp, PropertyScopeGlobal}, + }} if !reflect.DeepEqual(letsencrypt.Dynamic, want) { t.Errorf("letsencrypt dynamic = %+v; want %+v", letsencrypt.Dynamic, want) } - // traefik holds the same credentials, but the plugin does not report them, - // so a recipe using the family never converges - and the catalog has to say - // so without implying the values are any less secret (#457). + // traefik holds the same credentials and reports them the same way as of + // dokku 0.38.27 (#450), but `traefik:set` refuses the family outside + // --global, so the catalog has to publish it as global-only or a consumer + // validating offline accepts a recipe dokku will reject (#457, #458). traefik := schemaFor(t, catalog, "dokku_traefik_property").PropertySchema if traefik == nil { t.Fatal("dokku_traefik_property has no property schema") } - want = []DynamicPropertySchema{{Prefix: "dns-provider-", Probeable: false, Sensitive: true}} + want = []DynamicPropertySchema{{ + Prefix: "dns-provider-", + Probeable: true, + Sensitive: true, + Scopes: []string{PropertyScopeGlobal}, + }} if !reflect.DeepEqual(traefik.Dynamic, want) { t.Errorf("traefik dynamic = %+v; want %+v", traefik.Dynamic, want) } diff --git a/tasks/export_test.go b/tasks/export_test.go index d8bc056..9ede1ae 100644 --- a/tasks/export_test.go +++ b/tasks/export_test.go @@ -1089,6 +1089,133 @@ func TestExportGlobalTraefikPasswordLiftedAsSensitiveInput(t *testing.T) { } } +// TestExportGlobalTraefikDynamicProperties covers the export half of #450: +// dokku 0.38.27 reports every set `dns-provider-` credential as a `global-` +// row, so the exporter lifts them straight out of the payload instead of +// dropping the whole family. +func TestExportGlobalTraefikDynamicProperties(t *testing.T) { + t.Parallel() + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet traefik:report --global --format json": `{"global-log-level":"","global-dns-provider":"cloudflare","global-dns-provider-CLOUDFLARE_API_TOKEN":"globaltoken"}`, + })) + + bodies, err := exportGlobalProperties(ctx, TraefikPropertyTask{}, func(property, value string) interface{} { + return TraefikPropertyTask{Global: true, Property: property, Value: value} + }) + if err != nil { + t.Fatalf("exportGlobalProperties: %v", err) + } + got := map[string]string{} + for _, b := range bodies { + p := b.(TraefikPropertyTask) + if !p.Global { + t.Errorf("expected Global:true for %q", p.Property) + } + got[p.Property] = p.Value + } + if got["dns-provider-CLOUDFLARE_API_TOKEN"] != "globaltoken" { + t.Errorf("dns-provider-CLOUDFLARE_API_TOKEN = %q, want globaltoken", got["dns-provider-CLOUDFLARE_API_TOKEN"]) + } + if got["dns-provider"] != "cloudflare" { + t.Errorf("mapped properties should still export, got %v", got) + } + if _, ok := got["log-level"]; ok { + t.Error("an empty global property must be skipped") + } +} + +// TestExportTraefikDynamicPropertiesSkipAppScope pins the global-only half. A +// traefik report is global state whichever scope it is asked for, so the app +// payload carries the same `global-dns-provider-*` rows; none of them are the +// app's own value, and the family synthesizes no per-app key to lift them with. +func TestExportTraefikDynamicPropertiesSkipAppScope(t *testing.T) { + t.Parallel() + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet traefik:report web --format json": `{"global-dns-provider-CLOUDFLARE_API_TOKEN":"globaltoken","computed-dns-provider":"cloudflare"}`, + })) + + bodies, err := exportProperties(ctx, TraefikPropertyTask{}, "web", func(app, property, value string) interface{} { + return TraefikPropertyTask{App: app, Property: property, Value: value} + }) + if err != nil { + t.Fatalf("exportProperties: %v", err) + } + if len(bodies) != 0 { + t.Errorf("a global-only family must export nothing per app, got %+v", bodies) + } +} + +// TestExportGlobalTraefikCredentialLiftedAsSensitiveInput proves the newly +// exported credential never lands in the recipe in cleartext. Unlike the +// letsencrypt property task, this one is not built from +// SensitivePropertyFields, so nothing but the family's Sensitive mark says the +// value is a secret - which is exactly the coupling #457 broke. +func TestExportGlobalTraefikCredentialLiftedAsSensitiveInput(t *testing.T) { + t.Parallel() + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet apps:list": "", + "--quiet traefik:report --global --format json": `{"global-dns-provider-CLOUDFLARE_API_TOKEN":"cf-s3cr3t"}`, + })) + + res, err := ExportRecipe(ctx, ExportOptions{}) + if err != nil { + t.Fatalf("ExportRecipe: %v", err) + } + if got := res.Vars["global_dns_provider_CLOUDFLARE_API_TOKEN"]; got != "cf-s3cr3t" { + t.Errorf("vars[global_dns_provider_CLOUDFLARE_API_TOKEN] = %q, want the credential lifted (%v)", got, res.Vars) + } + recipe, _ := res.MarshalRecipe("yaml") + out := string(recipe) + if strings.Contains(out, "cf-s3cr3t") { + t.Errorf("recipe leaked the traefik dns provider credential:\n%s", out) + } + for _, want := range []string{ + "dokku_traefik_property", + "dns-provider-CLOUDFLARE_API_TOKEN", + "{{ .global_dns_provider_CLOUDFLARE_API_TOKEN }}", + "sensitive: true", + } { + if !strings.Contains(out, want) { + t.Errorf("recipe missing %q:\n%s", want, out) + } + } +} + +// TestExportGlobalDynamicCredentialsCollideDeterministically covers a case only +// reachable now that both families export: the same provider env var name set +// under letsencrypt and under traefik wants the same input name. Export order +// is fixed, so the second one is suffixed rather than overwriting the first, and +// both values survive. +func TestExportGlobalDynamicCredentialsCollideDeterministically(t *testing.T) { + t.Parallel() + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet apps:list": "", + "--quiet letsencrypt:report --global --format json": `{"global-dns-provider-CLOUDFLARE_API_TOKEN":"le-token"}`, + "--quiet traefik:report --global --format json": `{"global-dns-provider-CLOUDFLARE_API_TOKEN":"traefik-token"}`, + })) + + res, err := ExportRecipe(ctx, ExportOptions{}) + if err != nil { + t.Fatalf("ExportRecipe: %v", err) + } + values := map[string]bool{} + for _, value := range res.Vars { + values[value] = true + } + for _, want := range []string{"le-token", "traefik-token"} { + if !values[want] { + t.Errorf("expected %q lifted into its own input, got %v", want, res.Vars) + } + } + recipe, _ := res.MarshalRecipe("yaml") + out := string(recipe) + for _, unwanted := range []string{"le-token", "traefik-token"} { + if strings.Contains(out, unwanted) { + t.Errorf("recipe leaked %q:\n%s", unwanted, out) + } + } +} + func TestExportGlobalCertDisabledEmitsNoTask(t *testing.T) { t.Parallel() ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ diff --git a/tasks/properties.go b/tasks/properties.go index e85f0f1..f27e03e 100644 --- a/tasks/properties.go +++ b/tasks/properties.go @@ -418,9 +418,11 @@ func unknownPropertyWarning(plugin, property string, err error) (PlanWarning, bo var unknown *errUnknownProperty if errors.As(err, &unknown) { - // Skip the warning for known dynamic-property families (e.g. - // traefik dns-provider-*) where missing-from-report is the - // normal pre-set state, not a typo. + // Skip the warning for known dynamic-property families, where + // missing-from-report is the normal pre-set state, not a typo. + // A probeable family no longer reaches this from planProperty - + // getProperty reads its absent row as unset - so this now only + // guards a family docket cannot probe at all. if isDynamicProperty(plugin, property) { return PlanWarning{}, false } @@ -462,16 +464,44 @@ type dynamicPropertyFamily struct { // independent of Probeable: whether a value is a credential and whether // docket can read it back are separate questions (#457). Sensitive bool + + // Scopes is the non-empty subset of ["app", "global"] the plugin accepts a + // member in, the same vocabulary PropertyEntrySchema.Scopes publishes for + // an enumerable property. A mapped property says this by leaving one of its + // PropertyKeys halves empty; a dynamic one has no map entry to say it with, + // so the family carries it and keysFor, validateProperty and the catalog all + // read this one field. traefik's `traefik:set` refuses a `dns-provider-*` + // key outside `--global`, so without it an app-scoped member would probe a + // row that cannot exist: `state: present` would plan create forever and + // `state: absent` would report in sync while never unsetting the credential. + Scopes []string +} + +// scoped reports whether the family may be used in a scope. +func (f dynamicPropertyFamily) scoped(scope string) bool { + for _, s := range f.Scopes { + if s == scope { + return true + } + } + return false } // keysFor returns the PropertyKeys one member of the family gets. The report // keys exist only when the plugin reports the family, so an unprobeable member // carries no lookup keys - but it still carries the family's Sensitive mark, so -// its value is masked on the way out even though it can never be read back. +// its value is masked on the way out even though it can never be read back. A +// scope the family does not list gets no key either, which is how a global-only +// family reaches validateProperty's existing "no per-app form" rejection. func (f dynamicPropertyFamily) keysFor(property string) PropertyKeys { entry := PropertyKeys{Sensitive: f.Sensitive} - if f.Probeable { + if !f.Probeable { + return entry + } + if f.scoped(PropertyScopeApp) { entry.PerApp = property + } + if f.scoped(PropertyScopeGlobal) { entry.Global = "global-" + property } return entry @@ -494,20 +524,33 @@ var dynamicPropertyFamilies = map[string][]dynamicPropertyFamily{ // credentials, hence Sensitive: planProperty registers both the desired // value and, because this family is probed, the value read back before it // can reach a `(was %q)` drift reason. - "letsencrypt": {{Prefix: "dns-provider-", Probeable: true, Sensitive: true}}, - - // traefik's family holds the same credentials but is still absent from - // `traefik:report` (dokku/dokku#8928, tracked for docket in #450), so it - // stays on the unprobed path. Sensitive is set anyway: there is no probed - // value to mask, but the desired one still reaches argv and the plan - // mutation line (#457). - "traefik": {{Prefix: "dns-provider-", Sensitive: true}}, + "letsencrypt": {{ + Prefix: "dns-provider-", + Probeable: true, + Sensitive: true, + Scopes: []string{PropertyScopeApp, PropertyScopeGlobal}, + }}, + + // traefik's family holds the same credentials and reports them the same + // way, as of dokku 0.38.27 (dokku/dokku#8928, #450). It is global-only: + // `traefik:set` refuses a `dns-provider-*` key outside `--global`, so there + // is a `global-dns-provider-` row and no per-app one. The version is + // not gated because docket's dokku floor is already 0.38.27; below it the + // rows are genuinely absent and a `state: absent` task would read an unset + // property as already gone. + "traefik": {{ + Prefix: "dns-provider-", + Probeable: true, + Sensitive: true, + Scopes: []string{PropertyScopeGlobal}, + }}, } // isDynamicProperty reports whether a (plugin, property) pair belongs to a // dynamic property family. This is what lets validateProperty accept a name -// that cannot be enumerated in the key map. It says nothing about whether the -// property can be probed - that is the family's Probeable flag, read by +// that cannot be enumerated in the key map - the scope it accepts it in still +// comes from the family's Scopes. It says nothing about whether the property +// can be probed - that is the family's Probeable flag, read by // dynamicPropertyKeys. func isDynamicProperty(plugin, property string) bool { _, ok := dynamicPropertyFamilyFor(plugin, property) @@ -525,10 +568,17 @@ func DynamicPropertyFamilies(plugin string) []DynamicPropertySchema { } out := make([]DynamicPropertySchema, 0, len(families)) for _, family := range families { + scopes := make([]string, 0, len(family.Scopes)) + for _, scope := range []string{PropertyScopeApp, PropertyScopeGlobal} { + if family.scoped(scope) { + scopes = append(scopes, scope) + } + } out = append(out, DynamicPropertySchema{ Prefix: family.Prefix, Probeable: family.Probeable, Sensitive: family.Sensitive, + Scopes: scopes, }) } sort.Slice(out, func(i, j int) bool { return out[i].Prefix < out[j].Prefix }) @@ -567,8 +617,8 @@ func dynamicPropertyFamilyFor(plugin, property string) (dynamicPropertyFamily, b // dynamicPropertyKeys returns the report keys for a dynamic property whose // plugin surfaces the family in its `:report` payload, synthesized from the -// property name. A family the plugin does not report has no keys to synthesize -// and falls through to the unprobed path. +// property name and the scopes the family declares. A family the plugin does +// not report has no keys to synthesize and falls through to the unprobed path. func dynamicPropertyKeys(plugin, property string) (PropertyKeys, bool) { family, ok := dynamicPropertyFamilyFor(plugin, property) if !ok || !family.Probeable { @@ -833,13 +883,18 @@ func planProperty(ctx context.Context, task PropertyTableDocer, state State, app } // validateProperty rejects unsupported properties or scope mismatches before -// any subprocess call. Dynamic property families bypass validation since they -// can't be enumerated in the map. +// any subprocess call. A dynamic property family bypasses the name check, since +// its members can't be enumerated in the map, but not the scope check: the +// family declares its scopes and a member is held to them the same way a mapped +// property is, so an app-scoped traefik `dns-provider-*` is refused here rather +// than planning forever and failing on `traefik:set`'s own rejection. func validateProperty(plugin, property string, global bool, keys map[string]PropertyKeys) error { entry, ok := keys[property] if !ok { - if isDynamicProperty(plugin, property) { - return nil + family, dynamic := dynamicPropertyFamilyFor(plugin, property) + if dynamic { + return validatePropertyScope(plugin, property, global, + family.scoped(PropertyScopeApp), family.scoped(PropertyScopeGlobal)) } supported := make([]string, 0, len(keys)) for k := range keys { @@ -848,17 +903,29 @@ func validateProperty(plugin, property string, global bool, keys map[string]Prop sort.Strings(supported) return fmt.Errorf("dokku %s: unsupported property %q (supported: %s)", plugin, property, strings.Join(supported, ", ")) } - if global && entry.Global == "" { + return validatePropertyScope(plugin, property, global, entry.PerApp != "", entry.Global != "") +} + +// validatePropertyScope rejects a property used in a scope it has no form in, +// matching dokku's own CLI rejection. Shared by the mapped and the dynamic arms +// of validateProperty so both report the same sentence: a mapped property says +// which scopes it has by leaving a PropertyKeys half empty, a dynamic family +// says it in Scopes, and the user sees no difference. +func validatePropertyScope(plugin, property string, global, hasApp, hasGlobal bool) error { + if global && !hasGlobal { return fmt.Errorf("property %q on plugin %s has no global form", property, plugin) } - if !global && entry.PerApp == "" { + if !global && !hasApp { return fmt.Errorf("property %q on plugin %s has no per-app form", property, plugin) } return nil } // runUnprobedSet returns a PlanResult that runs `:set` unconditionally for -// dynamic properties that have no probe key (e.g. traefik dns-provider-*). +// dynamic properties that have no probe key. Every family docket knows about is +// probeable today, so nothing reaches this; it is the other half of the +// Probeable contract, kept for the next plugin that takes a family it does not +// report. See dynamicPropertyFamilies. func runUnprobedSet(ctx context.Context, subcommand, target, property, value string) PlanResult { inputs := propertySetInputs(subcommand, target, property, value) return PlanResult{ @@ -872,7 +939,8 @@ func runUnprobedSet(ctx context.Context, subcommand, target, property, value str } // runUnprobedUnset returns a PlanResult that runs `:set` (no value, the unset -// form) unconditionally for dynamic properties with no probe key. +// form) unconditionally for dynamic properties with no probe key. Unreached for +// the same reason runUnprobedSet is. func runUnprobedUnset(ctx context.Context, subcommand, target, property string) PlanResult { inputs := propertyUnsetInputs(subcommand, target, property) return PlanResult{ diff --git a/tasks/properties_test.go b/tasks/properties_test.go index 0ba9aff..4637ff9 100644 --- a/tasks/properties_test.go +++ b/tasks/properties_test.go @@ -277,9 +277,12 @@ func TestUnknownPropertyWarningIgnoresOtherErrors(t *testing.T) { } } -// TestUnknownPropertyWarningDynamicPropertySkipsWarning uses traefik because -// letsencrypt's dns-provider-* family is probed now (#449) and its missing rows -// never reach this branch; traefik is the family that still does. +// TestUnknownPropertyWarningDynamicPropertySkipsWarning pins the helper +// directly. Every declared family is probed now (#449, #450), so getProperty +// reads a missing row as unset and no family reaches this branch through +// planProperty any more - it is the guard a plugin that stops reporting a family +// would fall back on, and a missing row must read as "not set yet", not as a +// typo the user should be warned about. func TestUnknownPropertyWarningDynamicPropertySkipsWarning(t *testing.T) { t.Parallel() err := &errUnknownProperty{ @@ -391,9 +394,13 @@ func TestDynamicPropertyKeys(t *testing.T) { // mapped property. {"letsencrypt", "dns-provider", PropertyKeys{}, false}, {"letsencrypt", "email", PropertyKeys{}, false}, - // traefik's family has the same shape but is still absent from - // traefik:report (#450), so it must stay unprobed. - {"traefik", "dns-provider-CLOUDFLARE_API_TOKEN", PropertyKeys{}, false}, + // traefik's family reports the same way as of dokku 0.38.27, but + // `traefik:set` refuses it outside --global, so only the global half + // of the entry is synthesized (#450). + {"traefik", "dns-provider-CLOUDFLARE_API_TOKEN", PropertyKeys{ + Global: "global-dns-provider-CLOUDFLARE_API_TOKEN", + Sensitive: true, + }, true}, {"nginx", "dns-provider-X", PropertyKeys{}, false}, } for _, tc := range cases { @@ -438,9 +445,20 @@ func TestDynamicPropertiesFromReport(t *testing.T) { t.Errorf("global scope = %v; want %v", got, want) } - // traefik is not probeable, so its report rows are never lifted. - if got := dynamicPropertiesFromReport("traefik", map[string]string{"global-dns-provider-CLOUDFLARE_API_TOKEN": "token"}, true); got != nil { - t.Errorf("traefik = %v; want nil", got) + // traefik reports the same family, global-only. Its global rows lift, and + // an app report - which carries the same global- rows, since the traefik + // report is global state whichever scope it is asked for - lifts nothing, + // because the family synthesizes no per-app key to round-trip through. + traefikPayload := map[string]string{ + "global-dns-provider-CLOUDFLARE_API_TOKEN": "token", + "global-dns-provider": "cloudflare", + } + got = dynamicPropertiesFromReport("traefik", traefikPayload, true) + if !reflect.DeepEqual(got, []string{"dns-provider-CLOUDFLARE_API_TOKEN"}) { + t.Errorf("traefik global scope = %v; want the one credential row", got) + } + if got := dynamicPropertiesFromReport("traefik", traefikPayload, false); got != nil { + t.Errorf("traefik app scope = %v; want nil for a global-only family", got) } } @@ -574,51 +592,168 @@ func TestPlanPropertyDynamicLetsencryptAbsentPlansDestroy(t *testing.T) { } } -// TestPlanPropertyDynamicTraefikStaysUnprobed keeps the traefik half of the -// family on the unprobed path while dokku/dokku#8928 is open (#450): it must not -// even attempt a report read. -func TestPlanPropertyDynamicTraefikStaysUnprobed(t *testing.T) { +// TestPlanPropertyDynamicTraefikGlobalInSync is the core of #450: dokku 0.38.27 +// reports every set `dns-provider-` credential as a `global-` row, so one +// that already matches the recipe plans as in sync instead of reporting drift on +// every run. +func TestPlanPropertyDynamicTraefikGlobalInSync(t *testing.T) { t.Parallel() masker := subprocess.NewMasker() - var ran []string - ctx := subprocess.ContextWithRunner(testCtx(), func(_ context.Context, in subprocess.ExecCommandInput) (subprocess.ExecCommandResponse, error) { - ran = append(ran, strings.Join(in.Args, " ")) - return subprocess.ExecCommandResponse{}, nil - }) + + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet traefik:report --global --format json": `{"global-log-level":"INFO","global-dns-provider-CLOUDFLARE_API_TOKEN":"token123"}`, + })) res := planProperty(subprocess.ContextWithMasker(ctx, masker), TraefikPropertyTask{}, StatePresent, "", true, "dns-provider-CLOUDFLARE_API_TOKEN", "token123") if res.Error != nil { t.Fatalf("planProperty error: %v", res.Error) } - if !strings.Contains(res.Reason, "(no probe key)") { - t.Errorf("reason = %q; want the unprobed reason", res.Reason) + if !res.InSync { + t.Errorf("expected in sync, got status %q reason %q", res.Status, res.Reason) } - for _, cmd := range ran { - if strings.Contains(cmd, "traefik:report") { - t.Errorf("traefik dynamic properties must not be probed, ran %q", cmd) - } + if res.Status != PlanStatusOK { + t.Errorf("status = %q; want %q", res.Status, PlanStatusOK) } } -// TestPlanPropertyDynamicTraefikMasksCredential is the core of #457: the -// traefik family cannot be probed, but its values are DNS provider credentials -// all the same, so the desired value must reach the masker before it lands in -// the command echo or the plan mutation line. -func TestPlanPropertyDynamicTraefikMasksCredential(t *testing.T) { +// TestPlanPropertyDynamicTraefikDriftMasksProbedValue covers the drift half: the +// value read back is a DNS provider credential and must not reach the +// `(was %q)` reason in the clear (#457). +func TestPlanPropertyDynamicTraefikDriftMasksProbedValue(t *testing.T) { + t.Parallel() + masker := subprocess.NewMasker() + + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet traefik:report --global --format json": `{"global-dns-provider-CLOUDFLARE_API_TOKEN":"livetoken"}`, + })) + + res := planProperty(subprocess.ContextWithMasker(ctx, masker), TraefikPropertyTask{}, StatePresent, "", true, "dns-provider-CLOUDFLARE_API_TOKEN", "newtoken") + if res.Error != nil { + t.Fatalf("planProperty error: %v", res.Error) + } + if res.Status != PlanStatusModify { + t.Errorf("status = %q; want %q", res.Status, PlanStatusModify) + } + if masked := masker.String(res.Reason); strings.Contains(masked, "livetoken") { + t.Errorf("drift reason leaked the probed credential: %q -> %q", res.Reason, masked) + } +} + +// TestPlanPropertyDynamicTraefikMissingRowPlansCreate pins the pre-set state: a +// credential the server has never been given has no report row, which reads as +// unset rather than as a stale key map, so it plans as a create with no +// unknown_property warning. +func TestPlanPropertyDynamicTraefikMissingRowPlansCreate(t *testing.T) { + t.Parallel() + masker := subprocess.NewMasker() + + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet traefik:report --global --format json": `{"global-log-level":"INFO"}`, + })) + + res := planProperty(subprocess.ContextWithMasker(ctx, masker), TraefikPropertyTask{}, StatePresent, "", true, "dns-provider-CLOUDFLARE_API_TOKEN", "token123") + if res.Error != nil { + t.Fatalf("planProperty error: %v", res.Error) + } + if res.Status != PlanStatusCreate { + t.Errorf("status = %q; want %q", res.Status, PlanStatusCreate) + } + if len(res.Warnings) != 0 { + t.Errorf("an unset dynamic property is not an unknown key: %v", res.Warnings) + } +} + +// TestPlanPropertyDynamicTraefikAbsentMissingRowIsInSync completes the pair: +// `state: absent` on a credential that was never set converges instead of +// running an unset on every apply. +func TestPlanPropertyDynamicTraefikAbsentMissingRowIsInSync(t *testing.T) { t.Parallel() masker := subprocess.NewMasker() - ctx := subprocess.ContextWithRunner(testCtx(), func(_ context.Context, _ subprocess.ExecCommandInput) (subprocess.ExecCommandResponse, error) { + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet traefik:report --global --format json": `{"global-log-level":"INFO"}`, + })) + + res := planProperty(subprocess.ContextWithMasker(ctx, masker), TraefikPropertyTask{}, StateAbsent, "", true, "dns-provider-CLOUDFLARE_API_TOKEN", "") + if res.Error != nil { + t.Fatalf("planProperty error: %v", res.Error) + } + if !res.InSync { + t.Errorf("expected in sync, got status %q reason %q", res.Status, res.Reason) + } +} + +func TestPlanPropertyDynamicTraefikAbsentPlansDestroy(t *testing.T) { + t.Parallel() + masker := subprocess.NewMasker() + + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet traefik:report --global --format json": `{"global-dns-provider-CLOUDFLARE_API_TOKEN":"livetoken"}`, + })) + + res := planProperty(subprocess.ContextWithMasker(ctx, masker), TraefikPropertyTask{}, StateAbsent, "", true, "dns-provider-CLOUDFLARE_API_TOKEN", "") + if res.Error != nil { + t.Fatalf("planProperty error: %v", res.Error) + } + if res.Status != PlanStatusDestroy { + t.Errorf("status = %q; want %q", res.Status, PlanStatusDestroy) + } + if masked := masker.String(res.Reason); strings.Contains(masked, "livetoken") { + t.Errorf("unset reason leaked the probed credential: %q -> %q", res.Reason, masked) + } +} + +// TestPlanPropertyDynamicTraefikAppScopeIsRejected pins the other half of #450. +// `traefik:set` refuses a `dns-provider-*` key outside --global, and the family +// says so, so the app scope is turned away before any probe. Without it the +// empty per-app lookup would read as unset: `state: present` would plan create +// forever and `state: absent` would report in sync while never unsetting a live +// credential. +func TestPlanPropertyDynamicTraefikAppScopeIsRejected(t *testing.T) { + t.Parallel() + masker := subprocess.NewMasker() + var ran []string + ctx := subprocess.ContextWithRunner(testCtx(), func(_ context.Context, in subprocess.ExecCommandInput) (subprocess.ExecCommandResponse, error) { + ran = append(ran, strings.Join(in.Args, " ")) return subprocess.ExecCommandResponse{}, nil }) + for _, tc := range []struct { + state State + value string + }{ + {StatePresent, "token123"}, + {StateAbsent, ""}, + } { + res := planProperty(subprocess.ContextWithMasker(ctx, masker), TraefikPropertyTask{}, tc.state, "myapp", false, "dns-provider-CLOUDFLARE_API_TOKEN", tc.value) + if res.Error == nil { + t.Fatalf("state %q: expected an error, got status %q reason %q", tc.state, res.Status, res.Reason) + } + if !strings.Contains(res.Error.Error(), "no per-app form") { + t.Errorf("state %q: error = %v; want the no-per-app-form rejection", tc.state, res.Error) + } + } + if len(ran) != 0 { + t.Errorf("a rejected scope must not reach the server, ran %v", ran) + } +} + +// TestPlanPropertyDynamicTraefikMasksCredential is the core of #457: the desired +// value is a DNS provider credential and must reach the masker before it lands +// in the command echo or the plan mutation line. It stayed true when the family +// could not be probed and has to stay true now that it can. +func TestPlanPropertyDynamicTraefikMasksCredential(t *testing.T) { + t.Parallel() + masker := subprocess.NewMasker() + + ctx := subprocess.ContextWithRunner(testCtx(), fakeDokku(map[string]string{ + "--quiet traefik:report --global --format json": `{}`, + })) + res := planProperty(subprocess.ContextWithMasker(ctx, masker), TraefikPropertyTask{}, StatePresent, "", true, "dns-provider-CLOUDFLARE_API_TOKEN", "traefiktoken") if res.Error != nil { t.Fatalf("planProperty error: %v", res.Error) } - if !strings.Contains(res.Reason, "(no probe key)") { - t.Errorf("reason = %q; want the unprobed reason", res.Reason) - } // Commands are masked as they are resolved, so a leak here means the value // was never registered; mutations are masked by the emitter instead. for _, cmd := range res.Commands { @@ -633,10 +768,37 @@ func TestPlanPropertyDynamicTraefikMasksCredential(t *testing.T) { } } -// TestPropertyEntry pins the three arms the export path and planProperty read -// sensitivity through: a mapped property answers for itself, a probeable -// dynamic member is synthesized whole, and an unprobeable one carries the -// family's Sensitive mark with no lookup keys (#457). +// TestRunUnprobedPlansMutateUnconditionally covers the half of the Probeable +// contract no declared family reaches any more: a family its plugin does not +// report skips the probe and runs the mutation on every apply. The helpers are +// kept for the next such plugin, so they are exercised directly rather than +// through a family that no longer exists (#450). +func TestRunUnprobedPlansMutateUnconditionally(t *testing.T) { + t.Parallel() + ctx := subprocess.ContextWithMasker(testCtx(), subprocess.NewMasker()) + + set := runUnprobedSet(ctx, "traefik:set", "--global", "secret-TOKEN", "value") + if set.InSync || set.Status != PlanStatusModify { + t.Errorf("set = {InSync:%v Status:%q}; want a modify that never converges", set.InSync, set.Status) + } + if !strings.Contains(set.Reason, "(no probe key)") { + t.Errorf("set reason = %q; want the unprobed reason", set.Reason) + } + + unset := runUnprobedUnset(ctx, "traefik:set", "--global", "secret-TOKEN") + if unset.InSync || unset.Status != PlanStatusDestroy { + t.Errorf("unset = {InSync:%v Status:%q}; want a destroy that never converges", unset.InSync, unset.Status) + } + if !strings.Contains(unset.Reason, "(no probe key)") { + t.Errorf("unset reason = %q; want the unprobed reason", unset.Reason) + } +} + +// TestPropertyEntry pins the arms the export path and planProperty read +// sensitivity through: a mapped property answers for itself, and a dynamic +// member is synthesized from its family, in the scopes that family declares. +// The unprobeable arm has no live family left to use, so it is pinned against a +// synthetic one in TestDynamicFamilySensitivityIsIndependentOfProbing (#457). func TestPropertyEntry(t *testing.T) { t.Parallel() cases := []struct { @@ -665,11 +827,14 @@ func TestPropertyEntry(t *testing.T) { }, }, { - name: "unprobeable dynamic member is still sensitive", + name: "global-only dynamic member synthesizes only its global key", plugin: "traefik", property: "dns-provider-CLOUDFLARE_API_TOKEN", keys: traefikPropertyTable.Keys, - want: PropertyKeys{Sensitive: true}, + want: PropertyKeys{ + Global: "global-dns-provider-CLOUDFLARE_API_TOKEN", + Sensitive: true, + }, }, { name: "unknown property has no entry", diff --git a/tasks/property_coverage_test.go b/tasks/property_coverage_test.go index 6c53b95..fc9305c 100644 --- a/tasks/property_coverage_test.go +++ b/tasks/property_coverage_test.go @@ -246,10 +246,15 @@ func TestDynamicPropertyFamiliesArePublished(t *testing.T) { continue } for _, family := range families { - want := DynamicPropertySchema{Prefix: family.Prefix, Probeable: family.Probeable, Sensitive: family.Sensitive} + want := DynamicPropertySchema{ + Prefix: family.Prefix, + Probeable: family.Probeable, + Sensitive: family.Sensitive, + Scopes: family.Scopes, + } found := false for _, got := range published { - if got == want { + if reflect.DeepEqual(got, want) { found = true } } @@ -270,6 +275,32 @@ func TestDynamicPropertyFamiliesArePublished(t *testing.T) { } } +// TestDynamicPropertyFamiliesDeclareTheirScopes asserts every family names the +// scopes its members may be used in. The field is what validateProperty holds a +// dynamic name to and what keysFor synthesizes from, so a family that leaves it +// empty accepts its members nowhere while still publishing them as legal names. +func TestDynamicPropertyFamiliesDeclareTheirScopes(t *testing.T) { + valid := map[string]bool{PropertyScopeApp: true, PropertyScopeGlobal: true} + for plugin, families := range dynamicPropertyFamilies { + for _, family := range families { + if len(family.Scopes) == 0 { + t.Errorf("plugin %q family %q declares no scopes", plugin, family.Prefix) + continue + } + seen := map[string]bool{} + for _, scope := range family.Scopes { + if !valid[scope] { + t.Errorf("plugin %q family %q declares unknown scope %q", plugin, family.Prefix, scope) + } + if seen[scope] { + t.Errorf("plugin %q family %q repeats scope %q", plugin, family.Prefix, scope) + } + seen[scope] = true + } + } + } +} + // TestDynamicFamilySensitivityIsIndependentOfProbing asserts every family's // Sensitive mark survives the lookup planProperty and the exporters make. // Sensitivity used to be read off an entry only a probeable family ever got, so @@ -285,6 +316,19 @@ func TestDynamicFamilySensitivityIsIndependentOfProbing(t *testing.T) { } } } + + // Every declared family is probeable today, so the loop above can no longer + // reach the arm that regressed: sensitivity read off an entry only a + // probeable family ever got. Pin it against a synthetic family so the + // coupling cannot come back with the next unreported plugin (#457). + unprobeable := dynamicPropertyFamily{Prefix: "secret-", Sensitive: true, Scopes: []string{PropertyScopeGlobal}} + entry := unprobeable.keysFor("secret-TOKEN") + if !entry.Sensitive { + t.Error("an unprobeable family must still mark its members sensitive") + } + if entry.PerApp != "" || entry.Global != "" { + t.Errorf("an unprobeable family must synthesize no lookup keys, got %+v", entry) + } } // TestPropertyTablesAreDistinct asserts no two property tasks share a table. diff --git a/tasks/property_keys_test.go b/tasks/property_keys_test.go index b05f7d1..756361a 100644 --- a/tasks/property_keys_test.go +++ b/tasks/property_keys_test.go @@ -422,8 +422,12 @@ func TestTraefikPropertyKeys(t *testing.T) { }) checkUnsupportedProperty(t, traefikPropertyTable) checkScopeMismatch(t, traefikPropertyTable, "", "image") - // dns-provider-* are dynamic and should bypass map validation. + // dns-provider-* are dynamic and bypass the map's name check, but not its + // scope check: `traefik:set` refuses the family outside --global, so the + // app scope is rejected with the same sentence a mapped global-only + // property gets (#450). if err := validateProperty("traefik", "dns-provider-CLOUDFLARE_API_TOKEN", true, traefikPropertyTable.Keys); err != nil { t.Errorf("dynamic property should pass validation, got %v", err) } + checkScopeMismatch(t, traefikPropertyTable, "", "dns-provider-CLOUDFLARE_API_TOKEN") } diff --git a/tasks/traefik_property_task.go b/tasks/traefik_property_task.go index d9ea6be..e0b138a 100644 --- a/tasks/traefik_property_task.go +++ b/tasks/traefik_property_task.go @@ -30,8 +30,12 @@ func (t TraefikPropertyTask) ExportSupport() ExportSupport { } // ProbeSupport reports whether Plan() can read this task's current state. +// +// Supported without a caveat because dokku 0.38.27+ reports the dynamic +// `dns-provider-*` family alongside the mapped properties, so every property +// this task manages is readable and converges (#450). func (t TraefikPropertyTask) ProbeSupport() ProbeSupport { - return ProbeSupport{Status: ProbePartial, Caveat: "the mapped properties are probed; the dynamic `dns-provider-*` family has no report key and plans as drift on every run"} + return ProbeSupport{Status: ProbeSupported} } // Examples returns the examples for the traefik property task @@ -53,6 +57,14 @@ func (t TraefikPropertyTask) Examples() ([]Doc, error) { Value: "INFO", }, }, + { + Name: "Setting a dns-provider-* env var globally", + TraefikPropertyTask: TraefikPropertyTask{ + Global: true, + Property: "dns-provider-CLOUDFLARE_DNS_API_TOKEN", + Value: "cf-token", + }, + }, { Name: "Clearing the letsencrypt email globally", TraefikPropertyTask: TraefikPropertyTask{ @@ -71,8 +83,9 @@ func (t TraefikPropertyTask) Execute(ctx context.Context) TaskOutputState { // traefikPropertyTable maps traefik property names to the JSON keys emitted // by `dokku traefik:report --format json` on dokku 0.38.8+. All properties -// are global-only. The `dns-provider-*` family is dynamic and handled by -// isDynamicProperty without a map entry. +// are global-only. The `dns-provider-*` family takes an arbitrary provider env +// var name, so it cannot be enumerated here; its global-only key is synthesized +// per property by dynamicPropertyKeys, which dokku 0.38.27+ reports. var traefikPropertyTable = PropertyTable{ Subcommand: "traefik:set", Keys: map[string]PropertyKeys{ diff --git a/tasks/traefik_property_task_integration_test.go b/tasks/traefik_property_task_integration_test.go index 2c03439..fe13123 100644 --- a/tasks/traefik_property_task_integration_test.go +++ b/tasks/traefik_property_task_integration_test.go @@ -21,6 +21,11 @@ func TestIntegrationTraefikPropertyAll(t *testing.T) { {"challenge-mode", "tls"}, {"dashboard-enabled", "true"}, {"dns-provider", "cloudflare"}, + // A dns-provider- credential has no map entry - its global key is + // synthesized from the property name - so it exercises the dynamic + // probe path added in #450. dokku 0.38.27+ reports it, so it converges + // like any mapped property. + {"dns-provider-CLOUDFLARE_API_TOKEN", "token123"}, {"http-entry-point", "http"}, {"https-entry-point", "https"}, {"image", "traefik:v3.7.1"}, @@ -39,17 +44,4 @@ func TestIntegrationTraefikPropertyAll(t *testing.T) { }) }) } - - // dns-provider- are dynamic; exercise one to confirm the - // isDynamicProperty fallback path. - t.Run("dns-provider-CLOUDFLARE_API_TOKEN/dynamic", func(t *testing.T) { - set := TraefikPropertyTask{Global: true, Property: "dns-provider-CLOUDFLARE_API_TOKEN", Value: "token123", State: StatePresent} - if r := set.Execute(testCtx()); r.Error != nil { - t.Fatalf("set dynamic dns-provider key: %v", r.Error) - } - unset := TraefikPropertyTask{Global: true, Property: "dns-provider-CLOUDFLARE_API_TOKEN", State: StateAbsent} - if r := unset.Execute(testCtx()); r.Error != nil { - t.Fatalf("unset dynamic dns-provider key: %v", r.Error) - } - }) } diff --git a/tests/bats/masking.bats b/tests/bats/masking.bats index 8aa59d6..c252123 100644 --- a/tests/bats/masking.bats +++ b/tests/bats/masking.bats @@ -198,10 +198,10 @@ EOF } @test "docket plan masks a traefik dns-provider credential" { - # traefik does not report its dns-provider-* family, so the property takes the - # unprobed path and no traefik state is read or written here. The value is a - # DNS provider credential all the same and must not reach the mutation line or - # the --json commands array (#457). + # The property is probed against traefik:report but never written here, so the + # plan reports it missing. The value is a DNS provider credential and must not + # reach the mutation line or the --json commands array, whether it is read back + # or not (#457). write_tasks_file <<'EOF' --- - tasks: diff --git a/tests/bats/schema.bats b/tests/bats/schema.bats index c53278a..0622e7a 100644 --- a/tests/bats/schema.bats +++ b/tests/bats/schema.bats @@ -148,15 +148,19 @@ setup() { assert_success echo "$output" | jq -e '.tasks[] | select(.type == "dokku_letsencrypt_property") | .property_schema.dynamic[] - | select(.prefix == "dns-provider-") | .probeable == true and .sensitive == true' >/dev/null || + | select(.prefix == "dns-provider-") + | .probeable == true and .sensitive == true and .scopes == ["app", "global"]' >/dev/null || fail "letsencrypt does not publish its dns-provider- family" - # traefik holds the same credentials but does not report them, so the family - # is sensitive without being probeable (#457). + # traefik holds the same credentials and reports them the same way, but + # traefik:set refuses the family outside --global, so it is published + # global-only (#450). A consumer that ignored scopes would accept a recipe + # dokku rejects. echo "$output" | jq -e '.tasks[] | select(.type == "dokku_traefik_property") | .property_schema.dynamic[] - | select(.prefix == "dns-provider-") | .probeable == false and .sensitive == true' >/dev/null || - fail "traefik does not publish its dns-provider- family as sensitive" + | select(.prefix == "dns-provider-") + | .probeable == true and .sensitive == true and .scopes == ["global"]' >/dev/null || + fail "traefik does not publish its dns-provider- family as global-only" } @test "docket schema publishes property name families a task refuses (#458)" {