Skip to content

Partially supplied object params lose their ParamSpec defaults in a PipelineRun #10560

Description

@l-qing

Expected Behavior

An object param can be supplied partially: the keys a PipelineRun leaves out fall back to the values
in the ParamSpec default. docs/tasks.md states it explicitly:

When providing value for an object param, one may provide values for just a subset of keys in
spec's default, and provide values for the rest of keys at runtime.

Validation implements exactly that. MissingKeysObjectParamNames
(pkg/reconciler/taskrun/validate_taskrun.go#L115)
builds its set of provided keys from the union of the ParamSpec default's keys and the keys the
run supplies, so a partially supplied object is accepted on the understanding that the default covers
the rest.

So for a Pipeline declaring

params:
  - name: prepare
    type: object
    default:
      command: echo default-command
      image: docker.io/library/busybox:latest

and a PipelineRun supplying only command, both of these should see image come from the default:

  • $(params.prepare.image) — individual key reference
  • $(params.prepare[*]) — whole-object reference

Actual Behavior

Neither does. Parameter resolution drops the default for every key the run omitted, and the two
reference forms fail in two different ways.

Case 1 — individual key $(params.prepare.image)

buildUnresolvedObjectParamDefaults
(pkg/reconciler/pipelinerun/resources/apply.go#L249)
skips the whole default as soon as the run mentions the param:

if paramExists(param.Name, resolvedObjectParams) {
    continue
}

so the omitted keys are never registered as replacements. The reference survives substitution
untouched and the run only fails later, when the TaskRun is turned into a pod:

PodCreationFailed
failed to create task run pod "partial-object-param-run-build": non-existent variable in
"$(params.prepare.image)": steps[0].image. Maybe invalid TaskSpec

This is a regression. Before f7d3aee, ApplyParameters registered every default key as an
individual $(params.<name>.<key>) replacement first and then let the run's values overwrite them key
by key, so a partial object inherited the rest of its default.

Case 2 — whole object $(params.prepare[*])

The whole-object form resolves to the run-supplied object alone, so the Task receives an object that
is missing the omitted key and the run is rejected before any TaskRun exists:

PipelineValidationFailed
[User error] Validation failed for pipelinerun partial-object-param-star-run with error
invalid input params for task : missing keys for these params which are required in ParamSpec's
properties map[prepare:[image]]

Note what this says: the same PipelineRun is accepted at Pipeline level (validation counts the
default's keys as provided) and then rejected at Task level for missing a key the user did supply
— in the default. Following the documented "supply a subset of keys" pattern produces a Pipeline that
cannot pass its object param on.

This one is not part of the regression above; $(params.<name>[*]) never merged the default. It
is filed here because it is the same contract being broken, from the same root cause (the default
fill-in is not applied to partially supplied object params), and because a fix for Case 1 that leaves
Case 2 alone makes the two reference forms mean different things.

Steps to Reproduce the Problem

  1. Apply the manifest below. It declares the same object param twice — once referenced by individual
    key (Case 1) and once referenced as a whole object (Case 2) — and a PipelineRun for each that
    supplies only command.
reproduction manifest
apiVersion: v1
kind: Namespace
metadata:
  name: objparam-repro
---
# ---------- Case 1: individual key reference ----------
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: partial-object-param
  namespace: objparam-repro
spec:
  params:
    - name: prepare
      type: object
      properties:
        command:
          type: string
        image:
          type: string
      default:
        command: echo default-command
        image: docker.io/library/busybox:latest
  tasks:
    - name: build
      params:
        - name: command
          value: $(params.prepare.command)
        - name: image
          value: $(params.prepare.image)
      taskSpec:
        params:
          - name: command
            type: string
          - name: image
            type: string
        steps:
          - name: run
            image: $(params.image)
            script: |
              #!/bin/sh
              echo "command param resolved to: $(params.command)"
---
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
  name: partial-object-param-run
  namespace: objparam-repro
spec:
  pipelineRef:
    name: partial-object-param
  # Only `command` is supplied; `image` is expected to come from the Pipeline's default.
  params:
    - name: prepare
      value:
        command: echo supplied-command
---
# ---------- Case 2: whole-object reference ----------
apiVersion: tekton.dev/v1
kind: Pipeline
metadata:
  name: partial-object-param-star
  namespace: objparam-repro
spec:
  params:
    - name: prepare
      type: object
      properties:
        command:
          type: string
        image:
          type: string
      default:
        command: echo default-command
        image: docker.io/library/busybox:latest
  tasks:
    - name: build
      params:
        # No $(params.prepare.<key>) reference appears anywhere in this Pipeline, so this case
        # exercises the whole-object path on its own.
        - name: prepare
          value: $(params.prepare[*])
      taskSpec:
        params:
          - name: prepare
            type: object
            properties:
              command:
                type: string
              image:
                type: string
        steps:
          - name: run
            image: $(params.prepare.image)
            script: |
              #!/bin/sh
              echo "resolved: $(params.prepare.command)"
---
apiVersion: tekton.dev/v1
kind: PipelineRun
metadata:
  name: partial-object-param-star-run
  namespace: objparam-repro
spec:
  pipelineRef:
    name: partial-object-param-star
  params:
    - name: prepare
      value:
        command: echo supplied-command
  1. kubectl get pipelinerun -n objparam-repro.

    Expected: both runs succeed, the step running on busybox from the default.

    Actual:

    PipelineRun reason
    partial-object-param-run PodCreationFailednon-existent variable in "$(params.prepare.image)"
    partial-object-param-star-run PipelineValidationFailedmissing keys ... map[prepare:[image]]
  2. For Case 1, the unresolved reference is visible on the TaskRun itself:

    $ kubectl get taskrun -n objparam-repro -o jsonpath='{.items[0].spec.params}'
    [{"name":"command","value":"echo supplied-command"},{"name":"image","value":"$(params.prepare.image)"}]

Case 1 also reproduces as a unit test in the existing TestApplyParameters table
(pkg/reconciler/pipelinerun/resources/apply_test.go):

{
    name: "object param supplied partially keeps default values for the missing keys",
    original: v1.PipelineSpec{
        Params: []v1.ParamSpec{
            {Name: "prepare", Type: v1.ParamTypeObject, Default: v1.NewObject(map[string]string{
                "command": "make build",
                "image":   "golang:1.24",
            })},
        },
        Tasks: []v1.PipelineTask{{
            Params: v1.Params{
                {Name: "task-command", Value: *v1.NewStructuredValues("$(params.prepare.command)")},
                {Name: "task-image", Value: *v1.NewStructuredValues("$(params.prepare.image)")},
            },
        }},
    },
    params: v1.Params{
        {Name: "prepare", Value: *v1.NewObject(map[string]string{"command": "make test"})},
    },
    // expected: task-command "make test", task-image "golang:1.24"
}

On main this fails with task-image still holding $(params.prepare.image); on f7d3aee's parent
(af4c8bd) it passes.

Additional Info

  • Kubernetes version:

    Output of kubectl version:

Client Version: v1.36.3
Server Version: v1.34.5-1
  • Tekton Pipeline version:
v1.12.2

Both cases were also reproduced with a controller built from main (a1272d4) — this is not
fixed by upgrading.

Introducing commit

Case 1 was introduced by f7d3aee ("fix: re-work parameter resolution
algorithm", 2025-11-28), so it affects v1.9.0 through v1.15.0 (21 tags). The commit message does
not mention any intended change to object-default semantics, and apply_test.go has no case covering
"partial object + default fill-in", which is likely why it went unnoticed.

Behaviour across trees

Measured, with default = {command: make build, image: golang:1.24} and the run supplying only
command: make test:

reference af4c8bd (before) main (today) with the proposed fix
$(params.prepare.image) golang:1.24 $(params.prepare.image) (unresolved) golang:1.24
$(params.prepare[*]) {command: make test} {command: make test} {command: make test, image: golang:1.24}

Only the PipelineRun side is affected

The TaskRun side already implements merge semantics: getTaskParameters
(pkg/reconciler/taskrun/resources/apply.go)
clones the ParamSpec default and layers the run's values onto it key by key, so a partially supplied
object param on a TaskRun behaves as documented. A fix on the PipelineRun side makes the same
expression mean the same thing at both levels.


I have a fix ready (two commits: the Case 1 regression, then the Case 2 alignment) and will open a PR
referencing this issue. If maintainers prefer to keep the change to the strict regression, the second
commit can be dropped — happy to go either way.

Metadata

Metadata

Assignees

Labels

kind/bugCategorizes issue or PR as related to a bug.

Type

Projects

Status
In Progress

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions