Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/renovate.json
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@
"automerge": false,
"addLabels": ["talos"],
"prBodyNotes": [
"**Before merging, complete the pins Renovate cannot derive:**\n\n- [ ] `TALOSCTL_SHA256` in `.github/scripts/setup-talosctl.sh`: the `🧪 Validate Talos Machine Config` setup step prints the value to set once it has matched the download against the release's `sha256sum.txt`.\n- [ ] The Hetzner `iso` snapshot id in `ksail.prod.yaml`.\n- [ ] `kubernetesVersion` in `ksail.prod.yaml`, if this Talos release moves the supported Kubernetes range.\n- [ ] `expected_talos_version` in `scripts/tests/test-talos-apparmor-lsm.sh`, after confirming this release's kernel still enables the LSM order that test expects.\n- [ ] The Talos version named in the comment of `talos/cluster/enable-apparmor.yaml`, together with the AppArmor test above.\n- [ ] The Talos version in the `kube-system/coredns` row of `scripts/pdb-selector-exceptions.tsv`, after confirming this release still labels the CoreDNS pods `k8s-app=kube-dns`.\n- [ ] The `current production` row in `scripts/validate-talos-kubernetes-compatibility/main_test.go`, with the Kubernetes version production will run.\n\nA file that names the Talos version without being on this list fails the repository guards."
"**Before merging, complete the pins Renovate cannot derive:**\n\n- [ ] `TALOSCTL_SHA256` in `.github/scripts/setup-talosctl.sh`: the `🧪 Validate Talos Machine Config` setup step prints the value to set once it has matched the download against the release's `sha256sum.txt`.\n- [ ] The Hetzner `iso` snapshot id in `ksail.prod.yaml`.\n- [ ] `kubernetesVersion` in `ksail.prod.yaml`, if this Talos release moves the supported Kubernetes range.\n- [ ] `expected_talos_version` in `scripts/tests/test-talos-apparmor-lsm.sh`, after confirming this release's kernel still enables the LSM order that test expects.\n- [ ] The Talos version named in the comment of `talos/cluster/enable-apparmor.yaml`, together with the AppArmor test above.\n- [ ] The Talos version in the `kube-system/coredns` row of `scripts/pdb-selector-exceptions.tsv`, after confirming this release still labels the CoreDNS pods `k8s-app=kube-dns`.\n- [ ] The `current production` row in `scripts/validate-talos-kubernetes-compatibility/main_test.go`, with the Kubernetes version production will run.\n- [ ] The version-specific resource-output fixture in `scripts/tests/test-validate-image-verifier-liveness.sh`, after confirming the selected client's YAML output preserves the original zero-prefixed IDs and complete ordered decisions.\n\nA file that names the Talos version without being on this list fails the repository guards."
]
},
{
Expand Down
20 changes: 18 additions & 2 deletions scripts/tests/test-validate-image-verifier-liveness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ refute_text() {
}

# ---------------------------------------------------------------------------
# Fake talosctl: serves `get <type> -o json` from a fixture tree, and `ls /` as
# Fake talosctl: serves resource output from a fixture tree, and `ls /` as
# the reachability probe.
#
# Three behaviours mirror the real tool because the script depends on each:
Expand Down Expand Up @@ -101,7 +101,18 @@ while [[ "$#" -gt 0 ]]; do
resource="${FIXTURES}/${node}/resources/${type}"
# No instances of this type: exit 0, emit nothing.
[[ -f "${resource}" ]] || exit 0
cat "${resource}"
if [[ " $* " == *" -o yaml "* ]]; then
# Native YAML keeps zero-prefixed IDs. Emit one document per resource.
jq -c . "${resource}" | while IFS= read -r doc; do
printf '%s\n' '---'
printf '%s\n' "$doc" | yq -P '.metadata.id style = ""'
done
elif [[ -f "${FIXTURES}/${node}/lossy_json_ids" ]]; then
# Talos 1.13.11's untyped YAML-to-JSON path reads 0010 as octal 8.
jq '.metadata.id |= (if . == "0010" then 8 elif test("^[0-9]+$") then tonumber else . end)' "${resource}"
else
cat "${resource}"
fi
exit 0
;;
ls)
Expand Down Expand Up @@ -256,6 +267,11 @@ require_text "${output}" '11 rule(s) in phase running' 'case 1: counts every dec
require_text "${output}" 'with exact declared decisions' 'case 1: confirms complete rule decisions were compared'
require_text "${output}" 'All 1 node(s) can enforce image verification.' 'case 1: reports the summary'

healthy_node wireids
touch "${fixtures}/wireids/lossy_json_ids"
output="$(run_script TALOS_NODES=wireids 2>&1)" || fail 'native zero-prefixed resource IDs must retain their declaration order'
require_text "${output}" 'with exact declared decisions' 'native ID parsing still compares all decisions'

# Exercise the workflow's normal build path rather than only an injected
# compiled helper, and refuse an unavailable comparator before inspecting nodes.
output="$(run_script TALOS_NODES=good IMAGE_POLICY_CHECKER= 2>&1)" || fail 'normal helper build must inspect a healthy node'
Expand Down
60 changes: 59 additions & 1 deletion scripts/validate-image-verification-policy/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -147,9 +147,67 @@ func compare(policy string, input io.Reader, _ io.Writer, diagnostic io.Writer)
return 0
}

// Decode metadata IDs into strings directly. talosctl's JSON writer first
// decodes resource YAML into an untyped map, interpreting 0010 as octal 8.
// Keep the original ID and full rule spec, so ordering and signer comparison
// use the node's actual decisions rather than that lossy intermediate form.
func observeResources(kind string, input io.Reader, output, diagnostic io.Writer) int {
if kind != "imageverificationrules.security.talos.dev" && kind != "tuftrustedroots.security.talos.dev" {
fmt.Fprintln(diagnostic, "unsupported resource observation")
return 2
}
type observation struct {
ID string `json:"id"`
Phase string `json:"phase"`
Owner string `json:"owner"`
ImagePattern string `json:"imagePattern"`
Spec map[string]any `json:"spec"`
}
observations := make([]observation, 0)
seen := make(map[string]bool)
decoder := yaml.NewDecoder(input)
for {
var resource struct {
Metadata struct {
ID string `yaml:"id"`
Phase string `yaml:"phase"`
Owner string `yaml:"owner"`
} `yaml:"metadata"`
Spec map[string]any `yaml:"spec"`
}
err := decoder.Decode(&resource)
if err == io.EOF {
break
}
if err != nil || resource.Metadata.ID == "" || seen[resource.Metadata.ID] {
fmt.Fprintln(diagnostic, "invalid or duplicate resource identity")
return 2
}
seen[resource.Metadata.ID] = true
row := observation{ID: resource.Metadata.ID, Phase: resource.Metadata.Phase, Owner: resource.Metadata.Owner}
if kind == "imageverificationrules.security.talos.dev" {
row.Spec = resource.Spec
row.ImagePattern, _ = resource.Spec["imagePattern"].(string)
}
observations = append(observations, row)
}
encoded, err := json.Marshal(observations)
if err != nil {
fmt.Fprintln(diagnostic, "could not encode complete resource observations")
return 2
}
if _, err := output.Write(append(encoded, '\n')); err != nil {
return 2
}
return 0
}

func run(args []string, input io.Reader, output, diagnostic io.Writer) int {
if len(args) == 2 && args[0] == "resources" {
return observeResources(args[1], input, output, diagnostic)
}
if len(args) != 2 || (args[0] != "normalize" && args[0] != "compare") {
fmt.Fprintln(diagnostic, "usage: validate-image-verification-policy <normalize|compare> <policy.yaml>")
fmt.Fprintln(diagnostic, "usage: validate-image-verification-policy <normalize|compare> <policy.yaml> | resources <resource-type>")
return 2
}
data, err := os.ReadFile(args[1])
Expand Down
53 changes: 53 additions & 0 deletions scripts/validate-image-verification-policy/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,63 @@ package main

import (
"bytes"
"encoding/json"
"strings"
"testing"
)

func TestResourceStreamPreservesOriginalIDs(t *testing.T) {
input := `metadata:
id: 0008
namespace: security
phase: running
owner: security.ImageVerificationConfigController
spec:
imagePattern: registry.example/eighth
skip: false
deny: true
---
metadata:
id: 0010
namespace: security
phase: running
owner: security.ImageVerificationConfigController
spec:
imagePattern: registry.example/tenth
skip: false
deny: false
keylessVerifier:
issuer: https://issuer.example
subjectRegex: ^release$
`
var out, diagnostic bytes.Buffer
if got := run([]string{"resources", "imageverificationrules.security.talos.dev"}, strings.NewReader(input), &out, &diagnostic); got != 0 {
t.Fatalf("status = %d, want 0: %s", got, &diagnostic)
}
var resources []struct {
ID string `json:"id"`
Spec map[string]any `json:"spec"`
}
if err := json.Unmarshal(out.Bytes(), &resources); err != nil {
t.Fatal(err)
}
if len(resources) != 2 || resources[0].ID != "0008" || resources[1].ID != "0010" {
t.Fatalf("original ID spelling lost: %s", &out)
}
if resources[1].Spec["imagePattern"] != "registry.example/tenth" || resources[1].Spec["deny"] != false {
t.Fatalf("native decisions changed: %s", &out)
}
if resources[1].Spec["keylessVerifier"].(map[string]any)["subjectRegex"] != "^release$" {
t.Fatal("native signer decision lost")
}
for _, bad := range []string{strings.Replace(input, "0010", "0008", 1), strings.Replace(input, "id: 0010", "id: ''", 1), input + "---\nmetadata: [\n"} {
out.Reset()
if got := run([]string{"resources", "imageverificationrules.security.talos.dev"}, strings.NewReader(bad), &out, &diagnostic); got != 2 || out.Len() != 0 {
t.Fatalf("invalid stream accepted or partially emitted: status=%d output=%s", got, &out)
}
}
}

const keylessPolicy = `apiVersion: v1alpha1
kind: ImageVerificationConfig
rules:
Expand Down
18 changes: 6 additions & 12 deletions scripts/validate-image-verifier-liveness.sh
Original file line number Diff line number Diff line change
Expand Up @@ -318,9 +318,10 @@ readonly rules_owner='security.ImageVerificationConfigController'
readonly trustroot_owner='security.TUFTrustedRootController'

# Emits ONE resource type's rows on one node as a compact JSON array, or returns
# non-zero. `talosctl get -o json` emits a STREAM of objects (not an array), and
# emits nothing at all when the type has no instances, so `jq -s` is what turns
# both shapes into something countable.
# non-zero. Read YAML directly: talosctl's JSON conversion interprets the
# zero-prefixed ID 0010 as octal 8, losing the original declaration order.
# The reviewed helper preserves IDs as strings and refuses duplicate identities.
# No instances is an empty stream and becomes an empty array.
#
# An empty result and a failed query are NOT the same thing, and this returns
# them differently on purpose: "the node holds no rules" is a verdict, "the
Expand All @@ -330,16 +331,9 @@ readonly trustroot_owner='security.TUFTrustedRootController'
# script was rewritten to remove rather than reintroduce one level down.
get_resource_json() {
local node="$1" type="$2" out status=0
out="$("${talosctl_bin}" -n "${node}" get "${type}" -o json 2>/dev/null)" || status=$?
out="$("${talosctl_bin}" -n "${node}" get "${type}" -o yaml 2>/dev/null)" || status=$?
[[ "${status}" -eq 0 ]] || return "${status}"
# `jq -s` on empty input yields `[]`, which is the "none present" verdict.
printf '%s' "${out}" | jq -s -c --arg type "${type}" '[.[] | {
id: (.metadata.id // ""),
phase: (.metadata.phase // ""),
owner: (.metadata.owner // ""),
imagePattern: (.spec.imagePattern // ""),
spec: (if $type == "imageverificationrules.security.talos.dev" then .spec else null end)
}]' 2>/dev/null
printf '%s' "${out}" | "${policy_checker}" resources "${type}" 2>/dev/null
}

# Verdict for ONE node. Returns 0 when that node's verification mechanism is
Expand Down
Loading