diff --git a/devex/cmd/mcdiff/PR_DESCRIPTION.md b/devex/cmd/mcdiff/PR_DESCRIPTION.md new file mode 100644 index 0000000000..a36eb96bbc --- /dev/null +++ b/devex/cmd/mcdiff/PR_DESCRIPTION.md @@ -0,0 +1,61 @@ +# PR Title + +``` +devex/mcdiff: Add MachineConfig diff, attribution, and node drift scanner CLI +``` + +# Short Summary + +Replaces opaque MachineConfigDaemon byte-count errors with file-level expected content, last-writer attribution, and whole-node drift scanning. + +The existing `mcdiff diff MC1 MC2` dyff helper is unchanged. This PR adds `mcdiff file` and `mcdiff node` as a `devex` diagnostic: it consumes the **rendered** MachineConfig as source of truth (no client-side re-merge), attributes last writer using MCO merge order, and diffs against a local file, a live node (MCD `/rootfs`), or an unpacked must-gather archive. + +This is not remediation, MachineConfig editing, or a CI gate. Drift findings exit 0. + +# Scope of Changes + +All new and updated code lives under `devex/cmd/mcdiff`: + +| Package | Role | +| --- | --- | +| `devex/cmd/mcdiff` | CLI: `file`, `node`, existing `diff`, shell completion | +| `internal/cluster` | Load pool rendered MC (`status.configuration`, else `spec.configuration`) | +| `internal/ignition` | Decode Ignition files (`data:` base64 and percent-encoded) | +| `internal/attribution` | Last-writer using MergeMachineConfigs order | +| `internal/diff` | Byte compare, unified diff, mode compare | +| `internal/node` | Live node read via machine-config-daemon exec (`/rootfs`) | +| `internal/mustgather` | Offline Getter + NodeReader from unpacked archives | +| `internal/scanner` | Whole-node scan + MCP detection from node labels | +| `internal/report` | Text and JSON reports | + +Docs: `README.md`, `TESTING.md`, this file. + +# Verification Checklist for Reviewers + +- [ ] `go test ./devex/cmd/mcdiff/...` passes +- [ ] `mcdiff file` works with `--pool`, `--from-file`, `--node`, and `--must-gather` +- [ ] `mcdiff node` performs whole-node scan +- [ ] Base64 and percent-encoded Ignition payloads decode seamlessly +- [ ] Offline must-gather mode functions without kubeconfig + +See [TESTING.md](./TESTING.md) for unit, live-cluster, and must-gather steps. + +# Local verification (author) + +From the MCO repo root: + +```console +go build ./devex/cmd/mcdiff/... +go vet ./devex/cmd/mcdiff/... +gofmt -s -l devex/cmd/mcdiff/ +go test ./devex/cmd/mcdiff/... -count=1 +``` + +# Notes for reviewers + +- Expected bytes always come from the rendered MachineConfig, never `MergeMachineConfigs`. +- Last-writer uses `configuration.source` and the same fragment sort as `pkg/controller/common.MergeMachineConfigs`. +- Live `--node` execs into the existing `machine-config-daemon` pod (`k8s-app=machine-config-daemon` in `openshift-machine-config-operator`), host tree at `/rootfs`. +- Standard must-gather does **not** dump `/etc`. Paths without a snapshot are **MISSING ON NODE**. +- Unified diffs and `--show-content` can include secrets. Treat output as sensitive. +- Do not run the live drift-injection scenario from TESTING.md on a production cluster. diff --git a/devex/cmd/mcdiff/README.md b/devex/cmd/mcdiff/README.md new file mode 100644 index 0000000000..1a7e5d0258 --- /dev/null +++ b/devex/cmd/mcdiff/README.md @@ -0,0 +1,145 @@ +# mcdiff + +MCDiff explains what the Machine Config Operator (MCO) thinks a file should contain, which MachineConfig last wrote it, and how that differs from a local copy, a live node, or a must-gather archive. + +The MCD reports on-disk mismatches as byte counts on purpose (those files can hold secrets). That is enough to know something drifted, and not enough to debug it. MCDiff is the explanation layer: it reads the **rendered** MachineConfig as source of truth, attributes last-writer using MCO merge order, and prints a unified diff when you ask it to compare. + +This is a `devex` helper. Do not use it as an unsupervised production remediation tool. + +## Build + +From the MCO repo root: + +```console +go build -o mcdiff ./devex/cmd/mcdiff +``` + +Or: `make install-helpers` + +## Commands + +```console +mcdiff file PATH --pool POOL [flags] +mcdiff node NODE [flags] +mcdiff diff MC1 MC2 +mcdiff completion bash|zsh|fish|powershell +``` + +`file` inspects one path. `node` scans every Ignition file in the node's rendered MachineConfig against the host filesystem. `diff` is the older helper that runs `dyff` between two MachineConfig objects. + +## Examples + +Inspect expected content and last writer (does not print file bytes by default): + +```console +mcdiff file /etc/ssh/sshd_config --pool worker +``` + +Compare against a live node (execs into the machine-config-daemon pod, host root at `/rootfs`): + +```console +mcdiff file /etc/ssh/sshd_config --pool worker --node worker-0 +``` + +Compare against a local file: + +```console +mcdiff file /etc/ssh/sshd_config --pool worker --from-file ./sshd_config +``` + +Offline analysis from an unpacked must-gather: + +```console +mcdiff file /etc/ssh/sshd_config --pool worker --must-gather ./must-gather.local +mcdiff file /etc/ssh/sshd_config --pool worker --node worker-0 --must-gather ./must-gather.local +``` + +Print expected bytes or JSON: + +```console +mcdiff file /etc/ssh/sshd_config --pool worker --show-content +mcdiff file /etc/ssh/sshd_config --pool worker -o json +``` + +Scan every managed file on a node (pool is detected from node labels): + +```console +mcdiff node worker-0 +mcdiff node worker-0 --pool worker +mcdiff node worker-0 --show-diffs +mcdiff node worker-0 --must-gather ./must-gather.local --pool worker +mcdiff node worker-0 -o json +``` + +Replace the KCS `oc debug` + `jq` + `base64`/`urldecode` walkthrough for a degraded MachineConfigDaemon: + +```console +mcdiff file /etc/chrony.conf --pool worker --node worker-0 +mcdiff file /etc/resolv.conf --pool worker --node worker-0 +mcdiff file /etc/kubernetes/kubelet-ca.crt --pool master --node master-0 +``` + +Ignition `data:,…` percent-encoding and `data:text/plain;charset=utf-8;base64,…` are decoded automatically. Missing host files (`could not stat file`) are reported as **MISSING ON NODE** without failing the command. Mode drift (for example 0644 vs 0755) is reported next to size and content deltas. + +## Flag matrix + +`--pool` is required for `file`. For `node` it is optional: omitted means detect the pool from the node's labels the same way the Machine Config Operator does. `--show-content` (file) / `--show-diffs` (node) and `-o json` are optional in every valid mode. + +| Mode | `--from-file` | `--node` | `--must-gather` | Result | +| --- | --- | --- | --- | --- | +| Live inspect | | | | Expected bytes + last writer from the cluster | +| Local compare | yes | | | Diff expected vs a local file | +| Live node compare | | yes | | Diff expected vs the file on the node | +| Offline inspect | | | yes | Same as live inspect, from must-gather CRs (no kubeconfig) | +| Offline node compare | | yes | yes | Diff expected vs a must-gather node snapshot | +| **Invalid** | yes | yes | | Error: `cannot use --from-file and --node together` | +| **Invalid** | yes | | yes | Error: `cannot use --must-gather and --from-file together` | +| **Invalid** | yes | yes | yes | Same `--from-file` / `--node` error | + +`mcdiff node NODE` always compares against the node (live or must-gather). There is no `--from-file` on this command. + +| Mode | `--pool` | `--must-gather` | `--show-diffs` | Result | +| --- | --- | --- | --- | --- | +| Live whole-node scan | optional | | | Summary of every managed file vs the node | +| Live whole-node scan with diffs | optional | | yes | Same, plus unified diffs for mismatches | +| Offline whole-node scan | recommended | yes | | Same, from must-gather CRs and snapshots | +| Pool override | yes | | | Skip label-based pool detection | + +Pass `--pool` when the node is unassigned, Windows, or matches more than one custom pool. + +Live modes use the standard kubeconfig flags (`--kubeconfig`, `--context`, `KUBECONFIG`). `--must-gather` skips kubeconfig. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Inspection succeeded. Includes MATCH, CONTENT MISMATCH, MODE MISMATCH, unmanaged paths, MISSING ON NODE, CLEAN, and DRIFT DETECTED. | +| non-zero | The tool could not perform the inspection: invalid flags, missing pool / rendered MachineConfig / node, unreadable `--from-file` or must-gather directory, RBAC, or network errors. | + +MCDiff is a diagnostic tool, not a CI gate. A drift finding is still a successful inspection. + +## Must-gather caveat + +Standard `oc adm must-gather` archives include: + +- Cluster-scoped MachineConfig and MachineConfigPool YAML under `cluster-scoped-resources/machineconfiguration.openshift.io/` +- Node objects under `cluster-scoped-resources/core/nodes/` +- On degraded nodes, MCO’s `machine_config_ondisk//currentconfig` + +They do **not** snapshot the entire host `/etc` tree. `--node` with `--must-gather` (and `mcdiff node --must-gather`) only diffs a host file when the archive contains a snapshot (`nodes//host/...`, `host_files//...`, `machine_config_ondisk//files/...`) or when that path can be decoded from `currentconfig`. Files without a snapshot are reported as missing. Extract the archive to a directory first; do not pass a tarball. + +## Shell completion + +```console +source <(mcdiff completion bash) +source <(mcdiff completion zsh) +mcdiff completion fish | source +``` + +## Testing + +Unit tests, live-cluster scenarios, and must-gather steps for reviewers and QE: [TESTING.md](./TESTING.md). + +Suggested PR title and description: [PR_DESCRIPTION.md](./PR_DESCRIPTION.md). + +Expected file contents are omitted unless `--show-content` is set. Unified diffs from `--from-file`, `--node`, or `mcdiff node --show-diffs` do print changed lines, because that is the comparison result. Treat those outputs as sensitive. diff --git a/devex/cmd/mcdiff/TESTING.md b/devex/cmd/mcdiff/TESTING.md new file mode 100644 index 0000000000..322f553f6c --- /dev/null +++ b/devex/cmd/mcdiff/TESTING.md @@ -0,0 +1,158 @@ +# Testing MCDiff + +Instructions for peer reviewers and QE. Run all commands from the machine-config-operator repository root unless noted. + +MCDiff is a diagnostic. A content mismatch, mode mismatch, or **MISSING ON NODE** is a successful inspection (exit 0). Non-zero means the tool could not inspect (bad flags, missing pool/rendered MC/node, RBAC, or I/O). + +Do **not** run the live drift-injection scenario on a production cluster. Use a disposable cluster or skip to unit tests and must-gather. + +## 1. Local unit and package tests + +```console +go test ./devex/cmd/mcdiff/... -v -count=1 +``` + +Expect every package under `devex/cmd/mcdiff` to print `PASS`, including: + +- `internal/ignition` — base64 and percent-encoded `data:` URLs +- `internal/diff` — content, CRLF, trailing newline, mode +- `internal/scanner` — all-match, mismatch, missing, mode, pool detection +- `devex/cmd/mcdiff` — `file` and `node` CLI, JSON, must-gather fixtures + +Optional gates used before opening a PR: + +```console +go build ./devex/cmd/mcdiff/... +go vet ./devex/cmd/mcdiff/... +gofmt -s -l devex/cmd/mcdiff/ +``` + +`gofmt -s -l` should print nothing. + +## 2. Binary compilation + +Build the whole `main` package (not `main.go` alone; the command is split across several files): + +```console +mkdir -p bin +go build -o bin/mcdiff ./devex/cmd/mcdiff +./bin/mcdiff --help +./bin/mcdiff file --help +./bin/mcdiff node --help +``` + +Or: `make install-helpers` (installs all `devex/cmd` helpers). + +Confirm the help lists `file`, `node`, `diff`, and `completion`. + +## 3. Live OpenShift cluster testing + +Prerequisites: + +- `oc` logged in with rights to get MachineConfigPools, MachineConfigs, Nodes, and to exec into `machine-config-daemon` pods in `openshift-machine-config-operator` +- Standard kubeconfig (`KUBECONFIG`, `--kubeconfig`, or `--context`) + +### a. Identify a worker node + +```console +oc get nodes -l node-role.kubernetes.io/worker +``` + +Pick one `Ready` node. In the steps below, replace `` with that name (for example `worker-0`). + +### b. Inspect expected content (no host read) + +```console +./bin/mcdiff file /etc/ssh/sshd_config --pool worker +``` + +Expect: pool `worker`, a `rendered-worker-*` MachineConfig, `Exists: yes`, last writer (often `99-worker-ssh` or similar), expected content omitted unless `--show-content`. + +### c. Introduce intentional drift (disposable cluster only) + +```console +oc debug node/ -- chroot /host sh -c "echo '# drift' >> /etc/ssh/sshd_config" +``` + +The node may go degraded. That is the point of this scenario. + +### d. Live single-file diff + +```console +./bin/mcdiff file /etc/ssh/sshd_config --pool worker --node +``` + +Expect: `CONTENT MISMATCH`, a size delta, and a unified diff that includes `+# drift`. Exit 0. + +Also useful KCS-style paths (no extra drift required): + +```console +./bin/mcdiff file /etc/chrony.conf --pool worker --node +./bin/mcdiff file /etc/resolv.conf --pool worker --node +``` + +### e. Whole-node scan + +```console +./bin/mcdiff node --show-diffs +``` + +Expect: `DRIFT DETECTED`, `/etc/ssh/sshd_config` in mismatched files, last writer, size delta, and a unified diff because `--show-diffs` is set. Other managed files should `MATCH` unless the node was already drifted. + +If pool detection fails (`not assigned` / multiple custom pools), add `--pool worker`. + +### f. Clean up drift + +```console +oc debug node/ -- chroot /host sh -c "sed -i '/# drift/d' /etc/ssh/sshd_config" +``` + +Re-run the file diff and node scan. Expect `MATCH` / `CLEAN` unless other drift remains. The MCD may take a short time to clear degraded. + +## 4. Offline must-gather testing + +Standard `oc adm must-gather` does **not** snapshot all of `/etc`. Offline `--node` / `mcdiff node` only diffs a path when the archive has a host snapshot (`nodes//host/...`, `host_files//...`, `machine_config_ondisk//files/...`) or that path can be decoded from `currentconfig`. Other managed paths are **MISSING ON NODE**. Extract the tarball first; do not pass a `.tar.gz`. + +### a. Unpack + +```console +mkdir -p /tmp/must-gather.local +tar -C /tmp/must-gather.local -xf must-gather.tar.gz +``` + +Use the directory that contains `cluster-scoped-resources` (sometimes one level down under an image directory). + +### b. Offline inspect (no kubeconfig) + +```console +unset KUBECONFIG +./bin/mcdiff file /etc/ssh/sshd_config --pool worker --must-gather /tmp/must-gather.local +``` + +Expect: rendered MC and last writer from YAML in the archive. Does not need a live cluster. + +### c. Offline whole-node scan + +```console +./bin/mcdiff node worker-0 --must-gather /tmp/must-gather.local --pool worker +``` + +Replace `worker-0` with a node name present in the archive (`cluster-scoped-resources/core/nodes/` or `nodes/`). Pass `--pool` if the Node object has no role labels. + +Expect: scan completes (exit 0). Files without snapshots are listed as missing. That is expected for a stock must-gather. + +## QE pass / fail + +| Check | Pass | +| --- | --- | +| Unit tests | All `devex/cmd/mcdiff` packages PASS | +| `file --pool` | Prints pool, rendered MC, last writer; omits file bytes | +| `file --from-file` | MATCH or CONTENT MISMATCH with unified diff | +| `file --node` after step c | CONTENT MISMATCH, `+# drift`, exit 0 | +| `file --node` missing path | **MISSING ON NODE**, exit 0 | +| `node` scan after step c | DRIFT DETECTED includes sshd_config | +| Encoding | Unit tests for base64 and `data:,` percent-encoding PASS; live inspect does not require manual decode | +| Must-gather | Inspect works with `KUBECONFIG` unset; no API server required | +| Invalid flags | `cannot use --from-file and --node together` | + +Treat `--show-content` and unified diffs as sensitive. Do not paste them into public bugs. diff --git a/devex/cmd/mcdiff/file.go b/devex/cmd/mcdiff/file.go new file mode 100644 index 0000000000..680ecd2c65 --- /dev/null +++ b/devex/cmd/mcdiff/file.go @@ -0,0 +1,255 @@ +package main + +import ( + "context" + "errors" + "fmt" + "io" + "os" + + mcfgclientset "github.com/openshift/client-go/machineconfiguration/clientset/versioned" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/diff" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/mustgather" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/node" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/report" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/kubernetes" +) + +type fileOptions struct { + pool string + showContent bool + output string + fromFile string + node string + mustGather string + configFlags *genericclioptions.ConfigFlags + // getter, if set, is used instead of building a kube client. Tests inject this. + getter cluster.Getter + // nodeReader, if set, is used instead of a live MCD exec reader. Tests inject this. + nodeReader node.Reader + out io.Writer +} + +func newFileCommand() *cobra.Command { + o := &fileOptions{ + configFlags: genericclioptions.NewConfigFlags(true), + out: os.Stdout, + } + cmd := &cobra.Command{ + Use: "file PATH", + Short: "Show the rendered MachineConfig for a file path in a pool", + Long: `Inspect a file path against the rendered MachineConfig for a MachineConfigPool. + +Without --from-file or --node, this reports what the pool's rendered MachineConfig +says the file should contain, and which source MachineConfig last wrote it. +Ignition data URLs are decoded automatically (base64 and percent-encoded), so you +do not need the jq / urllib / base64 steps from KCS articles. + +With --from-file, compare a local file against that expected content. +With --node, compare the on-disk file on a live node (via the machine-config-daemon +pod host rootfs, equivalent to oc debug node/ -- cat /host/). +A missing host file is reported as MISSING ON NODE (exit 0). Mode drift +(0644 vs 0755) is reported in addition to content and size deltas. +With --must-gather, read MachineConfigs (and optional node snapshots) from an +unpacked oc adm must-gather directory instead of a live cluster. No kubeconfig +is required. + +Common MachineConfigDaemon degraded cases this replaces a debug-node walkthrough +for: /etc/chrony.conf, /etc/resolv.conf, /usr/local/bin/configure-ovs.sh, +and /etc/kubernetes/kubelet-ca.crt (including could-not-stat missing files). + +--from-file cannot be combined with --node or --must-gather. +--must-gather and --node may be combined to diff against host files in the archive. + +Exit 0 means the inspection succeeded, including MATCH, CONTENT MISMATCH, +MODE MISMATCH, unmanaged paths, and files MISSING ON NODE. Non-zero means the +tool could not inspect the pool, could not read inputs, or could not reach the node. + +Expected file contents are omitted unless --show-content is set. Unified diffs +are printed because they are the comparison result.`, + Example: ` # Inspect expected content and last writer + mcdiff file /etc/ssh/sshd_config --pool worker + + # Compare against the file on a live node (replaces oc debug + jq/base64/urldecode) + mcdiff file /etc/chrony.conf --pool worker --node worker-0 + + # Compare against a local copy + mcdiff file /etc/ssh/sshd_config --pool worker --from-file ./sshd_config + + # Offline analysis from a must-gather + mcdiff file /etc/ssh/sshd_config --pool worker --must-gather ./must-gather.local + + # Offline node diff from a must-gather snapshot + mcdiff file /etc/kubernetes/kubelet-ca.crt --pool master --node master-0 --must-gather ./must-gather.local + + # JSON output + mcdiff file /etc/resolv.conf --pool worker -o json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if o.out == nil || o.out == os.Stdout { + o.out = cmd.OutOrStdout() + } + return o.run(cmd.Context(), args[0]) + }, + } + cmd.Flags().StringVar(&o.pool, "pool", "", "MachineConfigPool name (required)") + _ = cmd.MarkFlagRequired("pool") + cmd.Flags().BoolVar(&o.showContent, "show-content", false, "Print expected file contents from the rendered MachineConfig") + cmd.Flags().StringVar(&o.fromFile, "from-file", "", "Compare a local file against the rendered MachineConfig") + cmd.Flags().StringVar(&o.node, "node", "", "Compare the on-disk file on a live node (or must-gather snapshot) against the rendered MachineConfig") + cmd.Flags().StringVar(&o.mustGather, "must-gather", "", "Unpacked must-gather directory (offline; skips kubeconfig)") + cmd.Flags().StringVarP(&o.output, "output", "o", "text", "Output format: text or json") + _ = cmd.MarkFlagDirname("must-gather") + _ = cmd.MarkFlagFilename("from-file") + o.configFlags.AddFlags(cmd.Flags()) + return cmd +} + +func validateFileFlags(nodeName, fromFile, mustGather string) error { + if nodeName != "" && fromFile != "" { + return fmt.Errorf("cannot use --from-file and --node together") + } + if mustGather != "" && fromFile != "" { + return fmt.Errorf("cannot use --must-gather and --from-file together") + } + return nil +} + +func (o *fileOptions) run(ctx context.Context, path string) error { + if err := validateFileFlags(o.node, o.fromFile, o.mustGather); err != nil { + return err + } + + g := o.getter + nr := o.nodeReader + if o.mustGather != "" { + mg, err := mustgather.Open(o.mustGather) + if err != nil { + return err + } + if g == nil { + g = mg.Getter() + } + if o.node != "" && nr == nil { + nr = mg.NodeReader() + } + } else { + if g == nil { + var err error + g, err = getterFromFlags(o.configFlags) + if err != nil { + return err + } + } + if o.node != "" && nr == nil { + var err error + nr, err = nodeReaderFromFlags(o.configFlags) + if err != nil { + return err + } + } + } + return runFile(ctx, g, inspectArgs{ + path: path, + pool: o.pool, + output: o.output, + showContent: o.showContent, + fromFile: o.fromFile, + node: o.node, + nodeReader: nr, + mustGather: o.mustGather, + }, o.out) +} + +func getterFromFlags(flags *genericclioptions.ConfigFlags) (cluster.Getter, error) { + restConfig, err := flags.ToRESTConfig() + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + client, err := mcfgclientset.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create machineconfiguration client: %w", err) + } + return cluster.NewKubeGetter(client), nil +} + +func nodeReaderFromFlags(flags *genericclioptions.ConfigFlags) (node.Reader, error) { + restConfig, err := flags.ToRESTConfig() + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + kube, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes client: %w", err) + } + return node.NewKubeReader(kube, restConfig), nil +} + +type inspectArgs struct { + path string + pool string + output string + showContent bool + fromFile string + node string + nodeReader node.Reader + mustGather string +} + +func runFile(ctx context.Context, g cluster.Getter, args inspectArgs, w io.Writer) error { + if err := validateFileFlags(args.node, args.fromFile, args.mustGather); err != nil { + return err + } + + pf, err := cluster.LoadPoolFile(ctx, g, args.pool, args.path) + if err != nil { + return err + } + + opts := report.Options{ + ShowContent: args.showContent, + Format: args.output, + MustGather: args.mustGather, + } + switch { + case args.fromFile != "": + actual, err := os.ReadFile(args.fromFile) + if err != nil { + return fmt.Errorf("failed to read --from-file %q: %w", args.fromFile, err) + } + opts.FromFile = args.fromFile + opts.Actual = actual + if pf.Found { + cmp := diff.Compare(pf.Expected, actual, args.path, args.fromFile) + opts.Diff = &cmp + } + case args.node != "": + if args.nodeReader == nil { + return fmt.Errorf("node reader is not configured") + } + actual, actualMode, err := args.nodeReader.ReadFile(ctx, args.node, args.path) + opts.Node = args.node + if err != nil { + if errors.Is(err, node.ErrFileNotFound) { + opts.ActualMissing = true + break + } + return fmt.Errorf("failed to read %q from node %q: %w", args.path, args.node, err) + } + opts.Actual = actual + if pf.Found { + cmp := diff.WithModes(diff.Compare(pf.Expected, actual, args.path, "node:"+args.node), pf.Mode, actualMode) + opts.Diff = &cmp + } + } + return report.Write(w, pf, opts) +} + +func init() { + rootCmd.AddCommand(newFileCommand()) +} diff --git a/devex/cmd/mcdiff/file_test.go b/devex/cmd/mcdiff/file_test.go new file mode 100644 index 0000000000..a670a30607 --- /dev/null +++ b/devex/cmd/mcdiff/file_test.go @@ -0,0 +1,702 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + ign3types "github.com/coreos/ignition/v2/config/v3_5/types" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/client-go/machineconfiguration/clientset/versioned/fake" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/node" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" +) + +const ( + sshdPath = "/etc/ssh/sshd_config" + renderedMC = "rendered-worker-abc" +) + +func TestRunFileManaged(t *testing.T) { + t.Parallel() + + base := mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-00\n") + overlay := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-99\n") + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "PermitRootLogin no\n") + pool := mcpWithSources(t, "worker", renderedMC, "99-worker-ssh", "00-worker") + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, base, overlay, rendered), inspectArgs{path: sshdPath, pool: "worker", output: "text"}, &buf) + require.NoError(t, err) + out := buf.String() + + assert.Contains(t, out, "Pool: worker") + assert.Contains(t, out, "Configuration: current") + assert.Contains(t, out, "Source: MCP status.configuration") + assert.Contains(t, out, "Rendered MC: rendered-worker-abc") + assert.Contains(t, out, "File: /etc/ssh/sshd_config") + assert.Contains(t, out, " 00-worker") + assert.Contains(t, out, " 99-worker-ssh") + assert.Contains(t, out, "Last writer:") + assert.NotContains(t, out, "PermitRootLogin no") + assert.Contains(t, out, "omitted (pass --show-content to print)") +} + +func TestRunFileShowContent(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "PermitRootLogin no\n") + pool := mcpWithSources(t, "worker", renderedMC) + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{path: sshdPath, pool: "worker", output: "text", showContent: true}, &buf) + require.NoError(t, err) + assert.Contains(t, buf.String(), "PermitRootLogin no") +} + +func TestRunFileMissing(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "present\n") + pool := mcpWithSources(t, "worker", renderedMC) + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{path: "/etc/example", pool: "worker", output: "text", showContent: true}, &buf) + require.NoError(t, err, "absent managed file is a successful inspection") + out := buf.String() + assert.Contains(t, out, "This path is not managed by the rendered MachineConfig.") + assert.NotContains(t, out, "Expected content:") + assert.NotContains(t, out, "present") +} + +func TestRunFileEmpty(t *testing.T) { + t.Parallel() + + rendered := mcWithFileBytes(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, "/etc/empty", nil) + pool := mcpWithSources(t, "worker", renderedMC) + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{path: "/etc/empty", pool: "worker", output: "text", showContent: true}, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Exists: yes") + assert.Contains(t, out, "Expected size: 0 bytes") + assert.Contains(t, out, "") +} + +func TestRunFileAttributionUnavailable(t *testing.T) { + t.Parallel() + + overlay := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-99\n") + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "canonical-from-render\n") + pool := mcpWithSources(t, "worker", renderedMC, "00-worker", "99-worker-ssh") + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, overlay, rendered), inspectArgs{path: sshdPath, pool: "worker", output: "text"}, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Rendered MC: rendered-worker-abc") + assert.Contains(t, out, "Expected size:") + assert.Contains(t, out, "Attribution: unavailable") + assert.Contains(t, out, "00-worker") +} + +func TestRunFileJSON(t *testing.T) { + t.Parallel() + + base := mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-00\n") + overlay := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-99\n") + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "secret\n") + pool := mcpWithSources(t, "worker", renderedMC, "99-worker-ssh", "00-worker") + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, base, overlay, rendered), inspectArgs{path: sshdPath, pool: "worker", output: "json"}, &buf) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "worker", got["pool"]) + assert.Equal(t, "current", got["configuration"]) + assert.Equal(t, "MCP status.configuration", got["configurationSource"]) + assert.Equal(t, renderedMC, got["renderedMachineConfig"]) + assert.Equal(t, sshdPath, got["path"]) + assert.Equal(t, true, got["found"]) + assert.Equal(t, []any{"00-worker", "99-worker-ssh"}, got["writers"]) + assert.Equal(t, "99-worker-ssh", got["lastWriter"]) + assert.Equal(t, true, got["attributionAvailable"]) + _, hasContent := got["expectedContent"] + assert.False(t, hasContent) +} + +func TestRunFileTargetOrigin(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-spec\n") + pool := mcpWithSources(t, "worker", renderedMC) + pool.Status.Configuration = mcfgv1.MachineConfigPoolStatusConfiguration{} + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{path: sshdPath, pool: "worker", output: "text"}, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Configuration: target") + assert.Contains(t, out, "Source: MCP spec.configuration") +} + +func TestRunFilePoolMissingIsError(t *testing.T) { + t.Parallel() + + err := runFile(context.Background(), newFakeGetter(t), inspectArgs{path: sshdPath, pool: "worker", output: "text"}, &bytes.Buffer{}) + require.Error(t, err) + assert.ErrorIs(t, err, cluster.ErrPoolNotFound) +} + +func TestFileCommandRequiresPool(t *testing.T) { + t.Parallel() + + cmd := newFileCommand() + cmd.SetArgs([]string{sshdPath}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "required flag") +} + +func TestFileCommandHasKubeconfigFlags(t *testing.T) { + t.Parallel() + + cmd := newFileCommand() + assert.NotNil(t, cmd.Flags().Lookup("kubeconfig")) + assert.NotNil(t, cmd.Flags().Lookup("context")) + assert.NotNil(t, cmd.Flags().Lookup("from-file")) + assert.NotNil(t, cmd.Flags().Lookup("node")) + assert.NotNil(t, cmd.Flags().Lookup("must-gather")) +} + +func TestRunFileFromFileMatch(t *testing.T) { + t.Parallel() + + contents := "PermitRootLogin no\n" + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, contents) + pool := mcpWithSources(t, "worker", renderedMC) + local := writeTempFile(t, "sshd_config", contents) + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "text", + fromFile: local, + }, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Comparison: MATCH") + assert.Contains(t, out, "From file: "+local) + assert.NotContains(t, out, "CONTENT MISMATCH") + assert.NotContains(t, out, "Unified diff:") + assert.NotContains(t, out, "PermitRootLogin no") +} + +func TestRunFileFromFileMismatch(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "PermitRootLogin no\n") + pool := mcpWithSources(t, "worker", renderedMC) + local := writeTempFile(t, "sshd_config", "PermitRootLogin yes\n") + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "text", + fromFile: local, + }, &buf) + require.NoError(t, err, "content mismatch is a successful inspection") + out := buf.String() + assert.Contains(t, out, "Comparison: CONTENT MISMATCH") + assert.Contains(t, out, "expected 19 bytes, got 20 bytes") + assert.Contains(t, out, "Unified diff:") + assert.Contains(t, out, "-PermitRootLogin no") + assert.Contains(t, out, "+PermitRootLogin yes") +} + +func TestRunFileFromFileJSON(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "PermitRootLogin no\n") + pool := mcpWithSources(t, "worker", renderedMC) + local := writeTempFile(t, "sshd_config", "PermitRootLogin yes\n") + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "json", + fromFile: local, + }, &buf) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, local, got["fromFile"]) + assert.Equal(t, false, got["match"]) + assert.Equal(t, float64(20), got["actualSize"]) + assert.Equal(t, float64(19), got["expectedSize"]) + diffStr, ok := got["diff"].(string) + require.True(t, ok) + assert.Contains(t, diffStr, "-PermitRootLogin no") + assert.Contains(t, diffStr, "+PermitRootLogin yes") + _, hasContent := got["expectedContent"] + assert.False(t, hasContent) +} + +func TestRunFileFromFileUnmanaged(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "present\n") + pool := mcpWithSources(t, "worker", renderedMC) + local := writeTempFile(t, "example", "local-only\n") + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: "/etc/example", + pool: "worker", + output: "text", + fromFile: local, + }, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "This path is not managed by the rendered MachineConfig.") + assert.Contains(t, out, "Local file: "+local+" (11 bytes)") + assert.Contains(t, out, "No content comparison was performed") + assert.NotContains(t, out, "Unified diff:") + assert.NotContains(t, out, "local-only") +} + +func TestRunFileFromFileMissingLocal(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "present\n") + pool := mcpWithSources(t, "worker", renderedMC) + missing := filepath.Join(t.TempDir(), "does-not-exist") + + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "text", + fromFile: missing, + }, &bytes.Buffer{}) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to read --from-file") + assert.Contains(t, err.Error(), missing) +} + +func TestFileCommandNodeAndFromFileMutuallyExclusive(t *testing.T) { + t.Parallel() + + cmd := newFileCommand() + cmd.SetArgs([]string{sshdPath, "--pool", "worker", "--node", "worker-0", "--from-file", "./sshd_config"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := cmd.Execute() + require.Error(t, err) + assert.Equal(t, "cannot use --from-file and --node together", err.Error()) +} + +func TestRunFileNodeAndFromFileMutuallyExclusive(t *testing.T) { + t.Parallel() + + err := runFile(context.Background(), newFakeGetter(t), inspectArgs{ + path: sshdPath, + pool: "worker", + fromFile: "./sshd_config", + node: "worker-0", + }, &bytes.Buffer{}) + require.Error(t, err) + assert.Equal(t, "cannot use --from-file and --node together", err.Error()) +} + +func TestRunFileNodeMatch(t *testing.T) { + t.Parallel() + + contents := "PermitRootLogin no\n" + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, contents) + pool := mcpWithSources(t, "worker", renderedMC) + nr := &fakeNodeReader{content: []byte(contents)} + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "text", + node: "worker-0", + nodeReader: nr, + }, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Node: worker-0") + assert.Contains(t, out, "Comparison: MATCH") + assert.NotContains(t, out, "CONTENT MISMATCH") + assert.NotContains(t, out, "Unified diff:") + assert.Equal(t, "worker-0", nr.node) + assert.Equal(t, sshdPath, nr.path) +} + +func TestRunFileNodeMismatch(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "PermitRootLogin no\n") + pool := mcpWithSources(t, "worker", renderedMC) + nr := &fakeNodeReader{content: []byte("PermitRootLogin yes\n")} + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "text", + node: "worker-0", + nodeReader: nr, + }, &buf) + require.NoError(t, err, "content mismatch is a successful inspection") + out := buf.String() + assert.Contains(t, out, "Comparison: CONTENT MISMATCH") + assert.Contains(t, out, "Node: worker-0") + assert.Contains(t, out, "expected 19 bytes, got 20 bytes") + assert.Contains(t, out, "-PermitRootLogin no") + assert.Contains(t, out, "+PermitRootLogin yes") +} + +func TestRunFileNodeModeMismatch(t *testing.T) { + t.Parallel() + + contents := "PermitRootLogin no\n" + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, contents) + pool := mcpWithSources(t, "worker", renderedMC) + mode := 0o755 + nr := &fakeNodeReader{content: []byte(contents), mode: &mode} + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "text", + node: "worker-0", + nodeReader: nr, + }, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Comparison: MODE MISMATCH") + assert.Contains(t, out, "Mode: expected 0644, actual 0755") + assert.NotContains(t, out, "Unified diff:") +} + +func TestRunFileNodeMissing(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "PermitRootLogin no\n") + pool := mcpWithSources(t, "worker", renderedMC) + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "text", + node: "worker-0", + nodeReader: &fakeNodeReader{err: node.ErrFileNotFound}, + }, &buf) + require.NoError(t, err, "missing node file is a successful inspection") + out := buf.String() + assert.Contains(t, out, "File exists in rendered MC, but is MISSING ON NODE worker-0.") + assert.NotContains(t, out, "Unified diff:") +} + +func TestRunFileNodeUnmanagedExists(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "present\n") + pool := mcpWithSources(t, "worker", renderedMC) + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: "/etc/example", + pool: "worker", + output: "text", + node: "worker-0", + nodeReader: &fakeNodeReader{content: []byte("local-only\n")}, + }, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "This path is not managed by the rendered MachineConfig.") + assert.Contains(t, out, "Node file: exists (11 bytes)") + assert.NotContains(t, out, "Unified diff:") + assert.NotContains(t, out, "local-only") +} + +func TestRunFileNodeReadError(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "present\n") + pool := mcpWithSources(t, "worker", renderedMC) + + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "text", + node: "worker-0", + nodeReader: &fakeNodeReader{err: node.ErrMCDUnavailable}, + }, &bytes.Buffer{}) + require.Error(t, err) + assert.ErrorIs(t, err, node.ErrMCDUnavailable) + assert.Contains(t, err.Error(), `failed to read "/etc/ssh/sshd_config" from node "worker-0"`) +} + +func TestRunFileNodeJSON(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "PermitRootLogin no\n") + pool := mcpWithSources(t, "worker", renderedMC) + + var buf bytes.Buffer + err := runFile(context.Background(), newFakeGetter(t, pool, rendered), inspectArgs{ + path: sshdPath, + pool: "worker", + output: "json", + node: "worker-0", + nodeReader: &fakeNodeReader{content: []byte("PermitRootLogin yes\n")}, + }, &buf) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "worker-0", got["node"]) + assert.Equal(t, false, got["match"]) + assert.Equal(t, true, got["nodeFileFound"]) + assert.Equal(t, float64(20), got["actualSize"]) + diffStr, ok := got["diff"].(string) + require.True(t, ok) + assert.Contains(t, diffStr, "-PermitRootLogin no") +} + +func TestFileCommandMustGatherAndFromFileMutuallyExclusive(t *testing.T) { + t.Parallel() + + cmd := newFileCommand() + cmd.SetArgs([]string{sshdPath, "--pool", "worker", "--must-gather", "./mg", "--from-file", "./sshd_config"}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + err := cmd.Execute() + require.Error(t, err) + assert.Equal(t, "cannot use --must-gather and --from-file together", err.Error()) +} + +func TestRunFileMustGatherAndFromFileMutuallyExclusive(t *testing.T) { + t.Parallel() + + err := runFile(context.Background(), newFakeGetter(t), inspectArgs{ + path: sshdPath, + pool: "worker", + fromFile: "./sshd_config", + mustGather: "./mg", + }, &bytes.Buffer{}) + require.Error(t, err) + assert.Equal(t, "cannot use --must-gather and --from-file together", err.Error()) +} + +func TestFileCommandHelpContainsExamples(t *testing.T) { + t.Parallel() + + cmd := newFileCommand() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs([]string{"--help"}) + require.NoError(t, cmd.Execute()) + out := buf.String() + assert.Contains(t, out, "mcdiff file /etc/chrony.conf --pool worker --node worker-0") + assert.Contains(t, out, "--from-file ./sshd_config") + assert.Contains(t, out, "--must-gather ./must-gather.local") +} + +func TestRunFileMustGatherOffline(t *testing.T) { + t.Parallel() + + dir := writeMustGatherFixture(t, "canonical-from-render\n", nil) + var buf bytes.Buffer + o := &fileOptions{pool: "worker", mustGather: dir, output: "text", out: &buf} + err := o.run(context.Background(), sshdPath) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Archive: Must-Gather Archive ("+dir+")") + assert.Contains(t, out, "Pool: worker") + assert.Contains(t, out, "Rendered MC: rendered-worker-abc") + assert.Contains(t, out, " 99-worker-ssh") + assert.Contains(t, out, "Last writer:") +} + +func TestRunFileMustGatherNodeDiff(t *testing.T) { + t.Parallel() + + dir := writeMustGatherFixture(t, "PermitRootLogin no\n", map[string]string{"worker-0": "PermitRootLogin yes\n"}) + var buf bytes.Buffer + o := &fileOptions{pool: "worker", mustGather: dir, node: "worker-0", output: "text", out: &buf} + err := o.run(context.Background(), sshdPath) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Archive: Must-Gather Archive ("+dir+")") + assert.Contains(t, out, "Comparison: CONTENT MISMATCH") + assert.Contains(t, out, "Node: worker-0") + assert.Contains(t, out, "-PermitRootLogin no") + assert.Contains(t, out, "+PermitRootLogin yes") +} + +func TestRunFileMustGatherJSON(t *testing.T) { + t.Parallel() + + dir := writeMustGatherFixture(t, "PermitRootLogin no\n", nil) + var buf bytes.Buffer + o := &fileOptions{pool: "worker", mustGather: dir, output: "json", out: &buf} + err := o.run(context.Background(), sshdPath) + require.NoError(t, err) + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, dir, got["mustGatherDir"]) + assert.Equal(t, "worker", got["pool"]) + assert.Equal(t, "99-worker-ssh", got["lastWriter"]) +} + +func TestRunFileMustGatherMissingPool(t *testing.T) { + t.Parallel() + + dir := writeMustGatherFixture(t, "x\n", nil) + o := &fileOptions{pool: "infra", mustGather: dir, output: "text", out: &bytes.Buffer{}} + err := o.run(context.Background(), sshdPath) + require.Error(t, err) + assert.ErrorIs(t, err, cluster.ErrPoolNotFound) +} + +func TestRunFileMustGatherUnmanagedPath(t *testing.T) { + t.Parallel() + + dir := writeMustGatherFixture(t, "present\n", nil) + var buf bytes.Buffer + o := &fileOptions{pool: "worker", mustGather: dir, output: "text", out: &buf} + err := o.run(context.Background(), "/etc/example") + require.NoError(t, err) + assert.Contains(t, buf.String(), "This path is not managed by the rendered MachineConfig.") +} + +func writeMustGatherFixture(t *testing.T, renderedContents string, nodeFiles map[string]string) string { + t.Helper() + root := t.TempDir() + mcDir := filepath.Join(root, "cluster-scoped-resources", "machineconfiguration.openshift.io", "machineconfigs") + poolDir := filepath.Join(root, "cluster-scoped-resources", "machineconfiguration.openshift.io", "machineconfigpools") + nodeDir := filepath.Join(root, "cluster-scoped-resources", "core", "nodes") + require.NoError(t, os.MkdirAll(mcDir, 0o755)) + require.NoError(t, os.MkdirAll(poolDir, 0o755)) + require.NoError(t, os.MkdirAll(nodeDir, 0o755)) + + writeGatherYAML(t, filepath.Join(mcDir, "00-worker.yaml"), mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-00\n")) + writeGatherYAML(t, filepath.Join(mcDir, "99-worker-ssh.yaml"), mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-99\n")) + writeGatherYAML(t, filepath.Join(mcDir, renderedMC+".yaml"), mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, renderedContents)) + writeGatherYAML(t, filepath.Join(poolDir, "worker.yaml"), mcpWithSources(t, "worker", renderedMC, "99-worker-ssh", "00-worker")) + + for name, contents := range nodeFiles { + writeGatherYAML(t, filepath.Join(nodeDir, name+".yaml"), &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: name}}) + host := filepath.Join(root, "nodes", name, "host", "etc", "ssh") + require.NoError(t, os.MkdirAll(host, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(host, "sshd_config"), []byte(contents), 0o644)) + } + return root +} + +func writeGatherYAML(t *testing.T, path string, obj any) { + t.Helper() + data, err := yaml.Marshal(obj) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) +} + +type fakeNodeReader struct { + content []byte + mode *int + err error + node string + path string +} + +func (f *fakeNodeReader) ReadFile(_ context.Context, nodeName, path string) ([]byte, *int, error) { + f.node = nodeName + f.path = path + return f.content, f.mode, f.err +} + +func writeTempFile(t *testing.T, name, contents string) string { + t.Helper() + p := filepath.Join(t.TempDir(), name) + require.NoError(t, os.WriteFile(p, []byte(contents), 0o600)) + return p +} + +func newFakeGetter(t *testing.T, objs ...runtime.Object) cluster.Getter { + t.Helper() + return cluster.NewKubeGetter(fake.NewSimpleClientset(objs...)) +} + +func mcpWithSources(t *testing.T, poolName, renderedName string, sourceNames ...string) *mcfgv1.MachineConfigPool { + t.Helper() + refs := make([]corev1.ObjectReference, 0, len(sourceNames)) + for _, name := range sourceNames { + refs = append(refs, corev1.ObjectReference{Kind: "MachineConfig", Name: name}) + } + cfg := mcfgv1.MachineConfigPoolStatusConfiguration{ + ObjectReference: corev1.ObjectReference{Name: renderedName}, + Source: refs, + } + return &mcfgv1.MachineConfigPool{ + ObjectMeta: metav1.ObjectMeta{Name: poolName}, + Spec: mcfgv1.MachineConfigPoolSpec{Configuration: cfg}, + Status: mcfgv1.MachineConfigPoolStatus{Configuration: cfg}, + } +} + +func mcWithFile(t *testing.T, name, role, path, contents string) *mcfgv1.MachineConfig { + t.Helper() + return mcWithFileBytes(t, name, role, path, []byte(contents)) +} + +func mcWithFileBytes(t *testing.T, name, role, path string, contents []byte) *mcfgv1.MachineConfig { + t.Helper() + if contents == nil { + contents = []byte{} + } + ign := ign3types.Config{ + Ignition: ign3types.Ignition{Version: ign3types.MaxVersion.String()}, + Storage: ign3types.Storage{ + Files: []ign3types.File{ctrlcommon.NewIgnFileBytes(path, contents)}, + }, + } + raw, err := json.Marshal(ign) + require.NoError(t, err) + return &mcfgv1.MachineConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + ctrlcommon.MachineConfigRoleLabel: role, + }, + }, + Spec: mcfgv1.MachineConfigSpec{ + Config: runtime.RawExtension{Raw: raw}, + }, + } +} diff --git a/devex/cmd/mcdiff/internal/attribution/attribute.go b/devex/cmd/mcdiff/internal/attribution/attribute.go new file mode 100644 index 0000000000..681588595c --- /dev/null +++ b/devex/cmd/mcdiff/internal/attribution/attribute.go @@ -0,0 +1,137 @@ +package attribution + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + + ign3types "github.com/coreos/ignition/v2/config/v3_5/types" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" +) + +// Writer is one MachineConfig that sets a given Ignition file path. +type Writer struct { + // MachineConfigName is metadata.name of the fragment that set the path. + MachineConfigName string + // Mode is the Ignition file mode, if set. + Mode *int + // ContentSHA256 is the hex SHA-256 of the decoded file contents. + ContentSHA256 string +} + +// Result is the merge-order attribution of a file path across source MachineConfigs. +type Result struct { + Path string + // Writers are fragments that set Path, in MergeMachineConfigs order. + Writers []Writer + // LastWriter is the fragment that wins the Ignition merge for Path. + // Nil if no source MachineConfig sets the path. + LastWriter *Writer +} + +// Attribute reports which source MachineConfigs supply path, using the same +// merge order as ctrlcommon.MergeMachineConfigs. Expected file bytes should +// still be read from the rendered MachineConfig; this only names writers. +func Attribute(path string, sources []*mcfgv1.MachineConfig) (*Result, error) { + if path == "" { + return nil, fmt.Errorf("path must not be empty") + } + + ordered, err := sortForMerge(sources) + if err != nil { + return nil, err + } + + out := &Result{Path: path} + for _, mc := range ordered { + writer, found, err := writerFromMachineConfig(mc, path) + if err != nil { + return nil, err + } + if !found { + continue + } + out.Writers = append(out.Writers, writer) + } + if len(out.Writers) > 0 { + last := out.Writers[len(out.Writers)-1] + out.LastWriter = &last + } + return out, nil +} + +// sortForMerge copies configs and orders them the way MergeMachineConfigs does: +// worker-role fragments by name, then all other fragments by name. +func sortForMerge(configs []*mcfgv1.MachineConfig) ([]*mcfgv1.MachineConfig, error) { + if len(configs) == 0 { + return nil, nil + } + + var workerConfigs, otherConfigs []*mcfgv1.MachineConfig + for _, config := range configs { + if config == nil { + return nil, fmt.Errorf("nil MachineConfig in source list") + } + if config.ObjectMeta.Labels == nil { + return nil, fmt.Errorf("cannot find label in MachineConfig %s", config.ObjectMeta.Name) + } + if config.ObjectMeta.Labels[ctrlcommon.MachineConfigRoleLabel] == ctrlcommon.MachineConfigPoolWorker { + workerConfigs = append(workerConfigs, config) + continue + } + otherConfigs = append(otherConfigs, config) + } + sort.SliceStable(workerConfigs, func(i, j int) bool { return workerConfigs[i].Name < workerConfigs[j].Name }) + sort.SliceStable(otherConfigs, func(i, j int) bool { return otherConfigs[i].Name < otherConfigs[j].Name }) + return append(workerConfigs, otherConfigs...), nil +} + +func writerFromMachineConfig(mc *mcfgv1.MachineConfig, path string) (Writer, bool, error) { + if len(mc.Spec.Config.Raw) == 0 { + return Writer{}, false, nil + } + + ign, err := ctrlcommon.ParseAndConvertConfig(mc.Spec.Config.Raw) + if err != nil { + return Writer{}, false, fmt.Errorf("failed to parse Ignition in MachineConfig %s: %w", mc.Name, err) + } + + file, found := fileByPath(ign, path) + if !found { + return Writer{}, false, nil + } + if len(file.Append) > 0 { + return Writer{}, false, fmt.Errorf("MachineConfig %s: file %q has an append section; append is not supported", mc.Name, path) + } + + contents, err := ctrlcommon.DecodeIgnitionFileContents(file.Contents.Source, file.Contents.Compression) + if err != nil { + return Writer{}, false, fmt.Errorf("couldn't decode file %q in MachineConfig %s: %w", path, mc.Name, err) + } + + sum := sha256.Sum256(contents) + return Writer{ + MachineConfigName: mc.Name, + Mode: copyMode(file.Mode), + ContentSHA256: hex.EncodeToString(sum[:]), + }, true, nil +} + +func fileByPath(ign ign3types.Config, path string) (ign3types.File, bool) { + for _, f := range ign.Storage.Files { + if f.Path == path { + return f, true + } + } + return ign3types.File{}, false +} + +func copyMode(mode *int) *int { + if mode == nil { + return nil + } + copied := *mode + return &copied +} diff --git a/devex/cmd/mcdiff/internal/attribution/attribute_test.go b/devex/cmd/mcdiff/internal/attribution/attribute_test.go new file mode 100644 index 0000000000..da9c33252b --- /dev/null +++ b/devex/cmd/mcdiff/internal/attribution/attribute_test.go @@ -0,0 +1,161 @@ +package attribution + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "testing" + + ign3types "github.com/coreos/ignition/v2/config/v3_5/types" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +const sshdPath = "/etc/ssh/sshd_config" + +func TestAttributeLastWriterWinsAmongWorkerFragments(t *testing.T) { + t.Parallel() + + base := mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, sshdPath, "PermitRootLogin no\n") + overlay := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "PermitRootLogin no\nUsePAM yes\n") + unrelated := mcWithFile(t, "01-worker-kubelet", ctrlcommon.MachineConfigPoolWorker, "/etc/kubernetes/kubelet.conf", "kubelet\n") + + // Reverse name order to prove we sort the way MergeMachineConfigs does. + got, err := Attribute(sshdPath, []*mcfgv1.MachineConfig{overlay, unrelated, base}) + require.NoError(t, err) + require.NotNil(t, got.LastWriter) + + assert.Equal(t, sshdPath, got.Path) + assert.Equal(t, []string{"00-worker", "99-worker-ssh"}, writerNames(got.Writers)) + assert.Equal(t, "99-worker-ssh", got.LastWriter.MachineConfigName) + assert.Equal(t, sha256Hex("PermitRootLogin no\nUsePAM yes\n"), got.LastWriter.ContentSHA256) + assert.Equal(t, sha256Hex("PermitRootLogin no\n"), got.Writers[0].ContentSHA256) +} + +func TestAttributeCustomPoolOverridesWorker(t *testing.T) { + t.Parallel() + + // Worker fragments are merged first (by name), then non-worker fragments. + // A custom-pool MC named 00-infra still wins over 99-worker-ssh. + worker := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-worker\n") + infra := mcWithFile(t, "00-infra", "infra", sshdPath, "from-infra\n") + + got, err := Attribute(sshdPath, []*mcfgv1.MachineConfig{infra, worker}) + require.NoError(t, err) + require.NotNil(t, got.LastWriter) + + assert.Equal(t, []string{"99-worker-ssh", "00-infra"}, writerNames(got.Writers)) + assert.Equal(t, "00-infra", got.LastWriter.MachineConfigName) + assert.Equal(t, sha256Hex("from-infra\n"), got.LastWriter.ContentSHA256) +} + +func TestAttributePathNotPresent(t *testing.T) { + t.Parallel() + + mc := mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, "/etc/hostname", "node\n") + got, err := Attribute(sshdPath, []*mcfgv1.MachineConfig{mc}) + require.NoError(t, err) + assert.Empty(t, got.Writers) + assert.Nil(t, got.LastWriter) +} + +func TestAttributeEmptySources(t *testing.T) { + t.Parallel() + + got, err := Attribute(sshdPath, nil) + require.NoError(t, err) + assert.Empty(t, got.Writers) + assert.Nil(t, got.LastWriter) +} + +func TestAttributeRejectsEmptyPath(t *testing.T) { + t.Parallel() + + _, err := Attribute("", []*mcfgv1.MachineConfig{}) + require.Error(t, err) +} + +func TestAttributeRejectsMissingRoleLabel(t *testing.T) { + t.Parallel() + + mc := mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, sshdPath, "x\n") + mc.Labels = nil + _, err := Attribute(sshdPath, []*mcfgv1.MachineConfig{mc}) + require.Error(t, err) + assert.Contains(t, err.Error(), "cannot find label") +} + +func TestAttributeSkipsEmptyIgnition(t *testing.T) { + t.Parallel() + + empty := &mcfgv1.MachineConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "00-worker-empty", + Labels: map[string]string{ctrlcommon.MachineConfigRoleLabel: ctrlcommon.MachineConfigPoolWorker}, + }, + } + overlay := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "only-overlay\n") + + got, err := Attribute(sshdPath, []*mcfgv1.MachineConfig{empty, overlay}) + require.NoError(t, err) + require.NotNil(t, got.LastWriter) + assert.Equal(t, []string{"99-worker-ssh"}, writerNames(got.Writers)) +} + +func TestSortForMergeDoesNotMutateInput(t *testing.T) { + t.Parallel() + + a := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "a\n") + b := mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, sshdPath, "b\n") + in := []*mcfgv1.MachineConfig{a, b} + + ordered, err := sortForMerge(in) + require.NoError(t, err) + require.Len(t, ordered, 2) + assert.Equal(t, "00-worker", ordered[0].Name) + assert.Equal(t, "99-worker-ssh", ordered[1].Name) + assert.Equal(t, "99-worker-ssh", in[0].Name) + assert.Equal(t, "00-worker", in[1].Name) +} + +func mcWithFile(t *testing.T, name, role, path, contents string) *mcfgv1.MachineConfig { + t.Helper() + + ign := ign3types.Config{ + Ignition: ign3types.Ignition{Version: ign3types.MaxVersion.String()}, + Storage: ign3types.Storage{ + Files: []ign3types.File{ctrlcommon.NewIgnFile(path, contents)}, + }, + } + raw, err := json.Marshal(ign) + require.NoError(t, err) + + return &mcfgv1.MachineConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + ctrlcommon.MachineConfigRoleLabel: role, + }, + }, + Spec: mcfgv1.MachineConfigSpec{ + Config: runtime.RawExtension{Raw: raw}, + }, + } +} + +func writerNames(writers []Writer) []string { + names := make([]string, 0, len(writers)) + for _, w := range writers { + names = append(names, w.MachineConfigName) + } + return names +} + +func sha256Hex(contents string) string { + sum := sha256.Sum256([]byte(contents)) + return hex.EncodeToString(sum[:]) +} diff --git a/devex/cmd/mcdiff/internal/cluster/errors.go b/devex/cmd/mcdiff/internal/cluster/errors.go new file mode 100644 index 0000000000..4c764e5c18 --- /dev/null +++ b/devex/cmd/mcdiff/internal/cluster/errors.go @@ -0,0 +1,35 @@ +package cluster + +import ( + "errors" + "fmt" +) + +var ( + // ErrPoolNotFound is returned when the MachineConfigPool does not exist. + ErrPoolNotFound = errors.New("machineconfigpool not found") + // ErrNoRenderedConfiguration is returned when the pool has no rendered MachineConfig name. + ErrNoRenderedConfiguration = errors.New("pool has no rendered configuration") + // ErrRenderedNotFound is returned when the named rendered MachineConfig does not exist. + ErrRenderedNotFound = errors.New("rendered machineconfig not found") + // ErrSourceUnavailable is returned when one or more source MachineConfigs cannot be retrieved. + // LoadPoolFile still returns expected content from the rendered MachineConfig when this is set + // on PoolFile.AttributionErr. + ErrSourceUnavailable = errors.New("source machineconfigs unavailable") +) + +func wrapPoolNotFound(poolName string, err error) error { + return fmt.Errorf("failed to get MachineConfigPool %q: %w: %w", poolName, ErrPoolNotFound, err) +} + +func wrapNoRendered(poolName string) error { + return fmt.Errorf("failed to resolve rendered MachineConfig for pool %q: %w", poolName, ErrNoRenderedConfiguration) +} + +func wrapRenderedNotFound(poolName, renderedName string, err error) error { + return fmt.Errorf("failed to resolve rendered MachineConfig %q for pool %q: %w: %w", renderedName, poolName, ErrRenderedNotFound, err) +} + +func wrapSourceUnavailable(poolName string, missing []string, err error) error { + return fmt.Errorf("source MachineConfigs %v for pool %q are unavailable: %w: %w", missing, poolName, ErrSourceUnavailable, err) +} diff --git a/devex/cmd/mcdiff/internal/cluster/getter.go b/devex/cmd/mcdiff/internal/cluster/getter.go new file mode 100644 index 0000000000..308151b9f9 --- /dev/null +++ b/devex/cmd/mcdiff/internal/cluster/getter.go @@ -0,0 +1,56 @@ +package cluster + +import ( + "context" + + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + mcfgclientset "github.com/openshift/client-go/machineconfiguration/clientset/versioned" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Getter loads MachineConfigPools and MachineConfigs. A kube client implements +// this; a must-gather loader can later implement the same methods without a +// live cluster. +type Getter interface { + GetMachineConfigPool(ctx context.Context, name string) (*mcfgv1.MachineConfigPool, error) + GetMachineConfig(ctx context.Context, name string) (*mcfgv1.MachineConfig, error) + ListMachineConfigPools(ctx context.Context) ([]*mcfgv1.MachineConfigPool, error) +} + +type kubeGetter struct { + client mcfgclientset.Interface +} + +// NewKubeGetter returns a Getter backed by the machineconfiguration clientset. +func NewKubeGetter(client mcfgclientset.Interface) Getter { + return &kubeGetter{client: client} +} + +func (k *kubeGetter) GetMachineConfigPool(ctx context.Context, name string) (*mcfgv1.MachineConfigPool, error) { + pool, err := k.client.MachineconfigurationV1().MachineConfigPools().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, wrapPoolNotFound(name, err) + } + return nil, err + } + return pool, nil +} + +func (k *kubeGetter) GetMachineConfig(ctx context.Context, name string) (*mcfgv1.MachineConfig, error) { + return k.client.MachineconfigurationV1().MachineConfigs().Get(ctx, name, metav1.GetOptions{}) +} + +func (k *kubeGetter) ListMachineConfigPools(ctx context.Context) ([]*mcfgv1.MachineConfigPool, error) { + list, err := k.client.MachineconfigurationV1().MachineConfigPools().List(ctx, metav1.ListOptions{}) + if err != nil { + return nil, err + } + out := make([]*mcfgv1.MachineConfigPool, 0, len(list.Items)) + for i := range list.Items { + p := list.Items[i] + out = append(out, &p) + } + return out, nil +} diff --git a/devex/cmd/mcdiff/internal/cluster/load.go b/devex/cmd/mcdiff/internal/cluster/load.go new file mode 100644 index 0000000000..31d05fcaa2 --- /dev/null +++ b/devex/cmd/mcdiff/internal/cluster/load.go @@ -0,0 +1,223 @@ +package cluster + +import ( + "context" + "errors" + "fmt" + + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/attribution" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/ignition" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" +) + +const ( + // ConfigurationCurrent is MCP status.configuration (applied to the pool). + ConfigurationCurrent = "current" + // ConfigurationTarget is MCP spec.configuration (desired/target). + ConfigurationTarget = "target" +) + +// ConfigurationOrigin records whether LoadPoolFile used status or spec. +type ConfigurationOrigin struct { + // Kind is ConfigurationCurrent or ConfigurationTarget. + Kind string + // Source is the MCP field path, e.g. "MCP status.configuration". + Source string +} + +// PoolFile is the rendered-MC view of one Ignition path for a pool, plus +// optional last-writer attribution from the pool's configuration.source list. +type PoolFile struct { + Pool *mcfgv1.MachineConfigPool + Rendered *mcfgv1.MachineConfig + Origin ConfigurationOrigin + Path string + // Expected is the decoded file from Rendered. Nil only when Found is false. + Expected []byte + // Found is true when Path exists on the rendered MachineConfig, including + // when Expected is empty. + Found bool + Mode *int + + Attribution *attribution.Result + AttributionErr error +} + +// WriterNames returns contributing MachineConfig names in merge order. +func (p *PoolFile) WriterNames() []string { + if p == nil || p.Attribution == nil { + return nil + } + names := make([]string, 0, len(p.Attribution.Writers)) + for _, w := range p.Attribution.Writers { + names = append(names, w.MachineConfigName) + } + return names +} + +// LastWriterName returns the last-writer MachineConfig name, or empty. +func (p *PoolFile) LastWriterName() string { + if p == nil || p.Attribution == nil || p.Attribution.LastWriter == nil { + return "" + } + return p.Attribution.LastWriter.MachineConfigName +} + +// RenderedPool is a pool's rendered MachineConfig plus the source fragments +// used for last-writer attribution. Expected bytes always come from Rendered. +type RenderedPool struct { + Pool *mcfgv1.MachineConfigPool + Rendered *mcfgv1.MachineConfig + Origin ConfigurationOrigin + // Sources are configuration.source MachineConfigs, excluding the rendered object. + // Nil when AttributionErr is set. + Sources []*mcfgv1.MachineConfig + AttributionErr error +} + +// LoadRenderedPool resolves poolName's rendered MachineConfig and loads the +// source fragments named in the same configuration object. +// +// Status.configuration is used when it has a name (applied/current). Otherwise +// spec.configuration is used (desired/target). Origin records which was chosen. +func LoadRenderedPool(ctx context.Context, g Getter, poolName string) (*RenderedPool, error) { + if g == nil { + return nil, fmt.Errorf("getter is nil") + } + if poolName == "" { + return nil, fmt.Errorf("pool name must not be empty") + } + + pool, err := g.GetMachineConfigPool(ctx, poolName) + if err != nil { + return nil, err + } + + renderedName, sourceRefs, err := renderedConfiguration(pool) + if err != nil { + return nil, wrapNoRendered(poolName) + } + + rendered, err := g.GetMachineConfig(ctx, renderedName) + if err != nil { + if apierrors.IsNotFound(err) || errors.Is(err, ErrRenderedNotFound) { + return nil, wrapRenderedNotFound(poolName, renderedName, err) + } + return nil, fmt.Errorf("failed to resolve rendered MachineConfig %q for pool %q: %w", renderedName, poolName, err) + } + + out := &RenderedPool{ + Pool: pool, + Rendered: rendered, + Origin: originFromPool(pool), + } + + sources, missing, getErr := loadSourceMachineConfigs(ctx, g, renderedName, sourceRefs) + if getErr != nil { + out.AttributionErr = wrapSourceUnavailable(poolName, missing, getErr) + return out, nil + } + out.Sources = sources + return out, nil +} + +// LoadPoolFile resolves poolName's rendered MachineConfig, decodes path from +// that object, and attributes the path across configuration.source. +// +// Status.configuration is used when it has a name (applied/current). Otherwise +// spec.configuration is used (desired/target). Origin records which was chosen. +// +// Expected bytes always come from the rendered MachineConfig, never from a +// client-side re-merge of source fragments. +func LoadPoolFile(ctx context.Context, g Getter, poolName, path string) (*PoolFile, error) { + if g == nil { + return nil, fmt.Errorf("getter is nil") + } + if poolName == "" { + return nil, fmt.Errorf("pool name must not be empty") + } + if path == "" { + return nil, fmt.Errorf("path must not be empty") + } + + rp, err := LoadRenderedPool(ctx, g, poolName) + if err != nil { + return nil, err + } + + extracted, err := ignition.ExtractFile(rp.Rendered, path) + if err != nil { + return nil, err + } + + out := &PoolFile{ + Pool: rp.Pool, + Rendered: rp.Rendered, + Origin: rp.Origin, + Path: path, + Expected: extracted.Contents, + Found: extracted.Found, + Mode: extracted.Mode, + } + + if rp.AttributionErr != nil { + out.AttributionErr = rp.AttributionErr + return out, nil + } + + attr, err := attribution.Attribute(path, rp.Sources) + if err != nil { + out.AttributionErr = fmt.Errorf("failed to attribute file %q for pool %q: %w", path, poolName, err) + return out, nil + } + out.Attribution = attr + return out, nil +} + +// renderedConfiguration returns the rendered MC name and the source refs that +// generated it. Status is the current applied configuration; spec is the +// targeted configuration and is used only when status has no name yet. +func renderedConfiguration(pool *mcfgv1.MachineConfigPool) (string, []corev1.ObjectReference, error) { + if pool.Status.Configuration.Name != "" { + return pool.Status.Configuration.Name, pool.Status.Configuration.Source, nil + } + if pool.Spec.Configuration.Name != "" { + return pool.Spec.Configuration.Name, pool.Spec.Configuration.Source, nil + } + return "", nil, ErrNoRenderedConfiguration +} + +func originFromPool(pool *mcfgv1.MachineConfigPool) ConfigurationOrigin { + if pool.Status.Configuration.Name != "" { + return ConfigurationOrigin{Kind: ConfigurationCurrent, Source: "MCP status.configuration"} + } + return ConfigurationOrigin{Kind: ConfigurationTarget, Source: "MCP spec.configuration"} +} + +func loadSourceMachineConfigs(ctx context.Context, g Getter, renderedName string, refs []corev1.ObjectReference) ([]*mcfgv1.MachineConfig, []string, error) { + var ( + sources []*mcfgv1.MachineConfig + missing []string + first error + ) + for _, ref := range refs { + if ref.Name == "" || ref.Name == renderedName { + continue + } + mc, err := g.GetMachineConfig(ctx, ref.Name) + if err != nil { + if first == nil { + first = err + } + missing = append(missing, ref.Name) + continue + } + sources = append(sources, mc) + } + if first != nil { + return nil, missing, first + } + return sources, nil, nil +} diff --git a/devex/cmd/mcdiff/internal/cluster/load_test.go b/devex/cmd/mcdiff/internal/cluster/load_test.go new file mode 100644 index 0000000000..98e500181c --- /dev/null +++ b/devex/cmd/mcdiff/internal/cluster/load_test.go @@ -0,0 +1,236 @@ +package cluster + +import ( + "context" + "encoding/json" + "errors" + "testing" + + ign3types "github.com/coreos/ignition/v2/config/v3_5/types" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/client-go/machineconfiguration/clientset/versioned/fake" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + sshdPath = "/etc/ssh/sshd_config" + renderedMC = "rendered-worker-abc" +) + +func TestLoadPoolFileWorkerPool(t *testing.T) { + t.Parallel() + + base := mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-00\n") + overlay := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-99\n") + // Would win a label-select re-merge; must be ignored because it is not in configuration.source. + extra := mcWithFile(t, "zz-worker-extra", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-extra\n") + // Authoritative expected bytes are on the rendered object, not a client-side merge. + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "canonical-from-render\n") + pool := mcpWithSources(t, "worker", renderedMC, "99-worker-ssh", "00-worker") + + got, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool, base, overlay, extra, rendered), "worker", sshdPath) + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, []byte("canonical-from-render\n"), got.Expected) + assert.Equal(t, []string{"00-worker", "99-worker-ssh"}, got.WriterNames()) + assert.Equal(t, "99-worker-ssh", got.LastWriterName()) + assert.NotContains(t, got.WriterNames(), "zz-worker-extra") + assert.NotContains(t, got.WriterNames(), renderedMC) + assert.Equal(t, ConfigurationCurrent, got.Origin.Kind) + assert.Equal(t, "MCP status.configuration", got.Origin.Source) +} + +func TestLoadPoolFileReversedSourceRefs(t *testing.T) { + t.Parallel() + + base := mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-00\n") + overlay := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-99\n") + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "canonical-from-render\n") + pool := mcpWithSources(t, "worker", renderedMC, "00-worker", "99-worker-ssh") + + got, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool, overlay, base, rendered), "worker", sshdPath) + require.NoError(t, err) + assert.Equal(t, []string{"00-worker", "99-worker-ssh"}, got.WriterNames()) + assert.Equal(t, "99-worker-ssh", got.LastWriterName()) +} + +func TestLoadPoolFileCustomPool(t *testing.T) { + t.Parallel() + + worker := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-worker\n") + infra := mcWithFile(t, "00-infra", "infra", sshdPath, "from-infra\n") + rendered := mcWithFile(t, "rendered-infra-abc", "infra", sshdPath, "from-infra\n") + pool := mcpWithSources(t, "infra", "rendered-infra-abc", "00-infra", "99-worker-ssh") + + got, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool, worker, infra, rendered), "infra", sshdPath) + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, []byte("from-infra\n"), got.Expected) + assert.Equal(t, []string{"99-worker-ssh", "00-infra"}, got.WriterNames()) + assert.Equal(t, "00-infra", got.LastWriterName()) +} + +func TestLoadPoolFileAbsent(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "present\n") + pool := mcpWithSources(t, "worker", renderedMC) + + got, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool, rendered), "worker", "/etc/example") + require.NoError(t, err) + assert.False(t, got.Found) + assert.Empty(t, got.Expected) +} + +func TestLoadPoolFileEmptyContents(t *testing.T) { + t.Parallel() + + rendered := mcWithFileBytes(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, "/etc/empty", nil) + pool := mcpWithSources(t, "worker", renderedMC) + + got, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool, rendered), "worker", "/etc/empty") + require.NoError(t, err) + assert.True(t, got.Found) + assert.Equal(t, []byte{}, got.Expected) +} + +func TestLoadPoolFileRenderedMissing(t *testing.T) { + t.Parallel() + + pool := mcpWithSources(t, "worker", renderedMC) + _, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool), "worker", sshdPath) + require.Error(t, err) + assert.ErrorIs(t, err, ErrRenderedNotFound) + assert.Contains(t, err.Error(), `failed to resolve rendered MachineConfig "rendered-worker-abc" for pool "worker"`) +} + +func TestLoadPoolFileNoRenderedConfiguration(t *testing.T) { + t.Parallel() + + pool := &mcfgv1.MachineConfigPool{ObjectMeta: metav1.ObjectMeta{Name: "worker"}} + _, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool), "worker", sshdPath) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoRenderedConfiguration) + assert.Contains(t, err.Error(), `failed to resolve rendered MachineConfig for pool "worker"`) +} + +func TestLoadPoolFileMissingSourceDoesNotDropExpected(t *testing.T) { + t.Parallel() + + overlay := mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-99\n") + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "canonical-from-render\n") + pool := mcpWithSources(t, "worker", renderedMC, "00-worker", "99-worker-ssh") + + got, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool, overlay, rendered), "worker", sshdPath) + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, []byte("canonical-from-render\n"), got.Expected) + assert.Nil(t, got.Attribution) + require.Error(t, got.AttributionErr) + assert.ErrorIs(t, got.AttributionErr, ErrSourceUnavailable) + assert.Contains(t, got.AttributionErr.Error(), "00-worker") +} + +func TestLoadPoolFilePoolNotFound(t *testing.T) { + t.Parallel() + + _, err := LoadPoolFile(context.Background(), newFakeGetter(t), "worker", sshdPath) + require.Error(t, err) + assert.ErrorIs(t, err, ErrPoolNotFound) +} + +func TestLoadPoolFilePrefersStatusConfiguration(t *testing.T) { + t.Parallel() + + statusRendered := mcWithFile(t, "rendered-status", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-status\n") + specRendered := mcWithFile(t, "rendered-spec", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-spec\n") + pool := mcpWithSources(t, "worker", "rendered-spec") + pool.Status.Configuration.Name = "rendered-status" + pool.Status.Configuration.Source = nil + + got, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool, statusRendered, specRendered), "worker", sshdPath) + require.NoError(t, err) + assert.Equal(t, []byte("from-status\n"), got.Expected) + assert.Equal(t, "rendered-status", got.Rendered.Name) + assert.Equal(t, ConfigurationCurrent, got.Origin.Kind) + assert.Equal(t, "MCP status.configuration", got.Origin.Source) +} + +func TestLoadPoolFileUsesSpecWhenStatusEmpty(t *testing.T) { + t.Parallel() + + rendered := mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-spec\n") + pool := mcpWithSources(t, "worker", renderedMC) + pool.Status.Configuration = mcfgv1.MachineConfigPoolStatusConfiguration{} + + got, err := LoadPoolFile(context.Background(), newFakeGetter(t, pool, rendered), "worker", sshdPath) + require.NoError(t, err) + assert.Equal(t, []byte("from-spec\n"), got.Expected) + assert.Equal(t, ConfigurationTarget, got.Origin.Kind) + assert.Equal(t, "MCP spec.configuration", got.Origin.Source) +} + +func newFakeGetter(t *testing.T, objs ...runtime.Object) Getter { + t.Helper() + return NewKubeGetter(fake.NewSimpleClientset(objs...)) +} + +func mcpWithSources(t *testing.T, poolName, renderedName string, sourceNames ...string) *mcfgv1.MachineConfigPool { + t.Helper() + refs := make([]corev1.ObjectReference, 0, len(sourceNames)) + for _, name := range sourceNames { + refs = append(refs, corev1.ObjectReference{Kind: "MachineConfig", Name: name}) + } + cfg := mcfgv1.MachineConfigPoolStatusConfiguration{ + ObjectReference: corev1.ObjectReference{Name: renderedName}, + Source: refs, + } + return &mcfgv1.MachineConfigPool{ + ObjectMeta: metav1.ObjectMeta{Name: poolName}, + Spec: mcfgv1.MachineConfigPoolSpec{Configuration: cfg}, + Status: mcfgv1.MachineConfigPoolStatus{Configuration: cfg}, + } +} + +func mcWithFile(t *testing.T, name, role, path, contents string) *mcfgv1.MachineConfig { + t.Helper() + return mcWithFileBytes(t, name, role, path, []byte(contents)) +} + +func mcWithFileBytes(t *testing.T, name, role, path string, contents []byte) *mcfgv1.MachineConfig { + t.Helper() + if contents == nil { + contents = []byte{} + } + ign := ign3types.Config{ + Ignition: ign3types.Ignition{Version: ign3types.MaxVersion.String()}, + Storage: ign3types.Storage{ + Files: []ign3types.File{ctrlcommon.NewIgnFileBytes(path, contents)}, + }, + } + raw, err := json.Marshal(ign) + require.NoError(t, err) + return &mcfgv1.MachineConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + ctrlcommon.MachineConfigRoleLabel: role, + }, + }, + Spec: mcfgv1.MachineConfigSpec{ + Config: runtime.RawExtension{Raw: raw}, + }, + } +} + +func TestRenderedConfigurationEmpty(t *testing.T) { + t.Parallel() + _, _, err := renderedConfiguration(&mcfgv1.MachineConfigPool{}) + assert.ErrorIs(t, err, ErrNoRenderedConfiguration) + assert.True(t, errors.Is(err, ErrNoRenderedConfiguration)) +} diff --git a/devex/cmd/mcdiff/internal/diff/diff.go b/devex/cmd/mcdiff/internal/diff/diff.go new file mode 100644 index 0000000000..83542fa602 --- /dev/null +++ b/devex/cmd/mcdiff/internal/diff/diff.go @@ -0,0 +1,146 @@ +package diff + +import ( + "bytes" + "fmt" + "strings" + + "github.com/pmezard/go-difflib/difflib" +) + +const ( + lineEndingOnly = "line endings differ; textual content is otherwise identical\n" + trailingNewlineOnly = "trailing newline differs; textual content is otherwise identical\n" + + // DefaultFileMode is the Ignition/MCD default when a file omits mode (0644). + DefaultFileMode = 0o644 +) + +// Result is a structured comparison of expected rendered bytes vs actual bytes. +// This is the engine later tasks will reuse for --node and must-gather. +type Result struct { + Match bool + ExpectedSize int + ActualSize int + UnifiedDiff string + ExpectedMode *int + ActualMode *int + // ModeMatch is true when modes are equal, when actual mode is unknown, or + // when Compare was called without mode information. + ModeMatch bool +} + +// Compare reports whether actual matches expected. +// +// Match is a raw byte comparison (the same standard the MCD uses on disk). +// UnifiedDiff is generated after normalizing CRLF/CR to LF so line-ending-only +// drift does not produce a noisy every-line diff. Trailing-newline-only drift +// (common on /etc/resolv.conf and /etc/chrony.conf) is reported as a one-line +// message instead of a full-file rewrite. Sizes always reflect the original +// byte lengths. +func Compare(expected, actual []byte, expectedName, actualName string) Result { + if expectedName == "" { + expectedName = "expected" + } + if actualName == "" { + actualName = "actual" + } + + out := Result{ + Match: bytes.Equal(expected, actual), + ExpectedSize: len(expected), + ActualSize: len(actual), + ModeMatch: true, + } + if out.Match { + return out + } + + expN := normalizeNewlines(expected) + actN := normalizeNewlines(actual) + if bytes.Equal(expN, actN) { + out.UnifiedDiff = lineEndingOnly + return out + } + if bytes.Equal(bytes.TrimRight(expN, "\n"), bytes.TrimRight(actN, "\n")) { + out.UnifiedDiff = trailingNewlineOnly + return out + } + + ud, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ + A: difflib.SplitLines(string(expN)), + B: difflib.SplitLines(string(actN)), + FromFile: expectedName, + ToFile: actualName, + Context: 3, + Eol: "\n", + }) + if err != nil { + out.UnifiedDiff = fmt.Sprintf("failed to generate unified diff: %v\n", err) + return out + } + out.UnifiedDiff = annotateMissingNewline(ud, expN, actN) + return out +} + +// WithModes records expected vs actual file modes on a content comparison. +// A nil actual mode is treated as unknown (ModeMatch stays true) so missing +// stat data does not invent a mismatch. A nil expected mode uses DefaultFileMode, +// matching the MCD on-disk check. +func WithModes(r Result, expected, actual *int) Result { + r.ExpectedMode = copyMode(expected) + r.ActualMode = copyMode(actual) + r.ModeMatch = ModesMatch(expected, actual) + return r +} + +// ModesMatch reports whether permission bits agree. Unknown actual mode matches. +func ModesMatch(expected, actual *int) bool { + if actual == nil { + return true + } + return perm(EffectiveMode(expected)) == perm(*actual) +} + +// EffectiveMode returns the mode the MCD would enforce: explicit Ignition mode, +// or DefaultFileMode when omitted. +func EffectiveMode(mode *int) int { + if mode == nil { + return DefaultFileMode + } + return *mode +} + +func perm(mode int) int { + return mode & 0o7777 +} + +func copyMode(mode *int) *int { + if mode == nil { + return nil + } + copied := *mode + return &copied +} + +func annotateMissingNewline(ud string, expected, actual []byte) string { + if ud == "" { + return ud + } + var b strings.Builder + b.WriteString(ud) + if !bytes.HasSuffix(expected, []byte("\n")) || !bytes.HasSuffix(actual, []byte("\n")) { + if !strings.HasSuffix(ud, "\n") { + b.WriteByte('\n') + } + b.WriteString("\\ No newline at end of file\n") + } + return b.String() +} + +func normalizeNewlines(b []byte) []byte { + s := string(b) + s = strings.ReplaceAll(s, "\r\n", "\n") + s = strings.ReplaceAll(s, "\r", "\n") + return []byte(s) +} diff --git a/devex/cmd/mcdiff/internal/diff/diff_test.go b/devex/cmd/mcdiff/internal/diff/diff_test.go new file mode 100644 index 0000000000..de2b809abd --- /dev/null +++ b/devex/cmd/mcdiff/internal/diff/diff_test.go @@ -0,0 +1,105 @@ +package diff + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCompareMatch(t *testing.T) { + t.Parallel() + + got := Compare([]byte("PermitRootLogin no\n"), []byte("PermitRootLogin no\n"), "expected", "actual") + assert.True(t, got.Match) + assert.Equal(t, 19, got.ExpectedSize) + assert.Equal(t, 19, got.ActualSize) + assert.Empty(t, got.UnifiedDiff) +} + +func TestCompareMismatchUnifiedDiff(t *testing.T) { + t.Parallel() + + got := Compare([]byte("PermitRootLogin no\n"), []byte("PermitRootLogin yes\n"), "/etc/ssh/sshd_config", "./sshd_config") + require.False(t, got.Match) + assert.Equal(t, 19, got.ExpectedSize) + assert.Equal(t, 20, got.ActualSize) + assert.Contains(t, got.UnifiedDiff, "--- /etc/ssh/sshd_config") + assert.Contains(t, got.UnifiedDiff, "+++ ./sshd_config") + assert.Contains(t, got.UnifiedDiff, "-PermitRootLogin no") + assert.Contains(t, got.UnifiedDiff, "+PermitRootLogin yes") +} + +func TestCompareSizeMismatch(t *testing.T) { + t.Parallel() + + got := Compare([]byte("abc"), []byte("abcdef"), "", "") + require.False(t, got.Match) + assert.Equal(t, 3, got.ExpectedSize) + assert.Equal(t, 6, got.ActualSize) + assert.NotEmpty(t, got.UnifiedDiff) +} + +func TestCompareLineEndingsOnly(t *testing.T) { + t.Parallel() + + got := Compare([]byte("a\nb\n"), []byte("a\r\nb\r\n"), "expected", "actual") + require.False(t, got.Match, "raw bytes differ when CRLF vs LF") + assert.Equal(t, 4, got.ExpectedSize) + assert.Equal(t, 6, got.ActualSize) + assert.Equal(t, lineEndingOnly, got.UnifiedDiff) + assert.NotContains(t, got.UnifiedDiff, "-a") +} + +func TestCompareTrailingNewlineOnly(t *testing.T) { + t.Parallel() + + got := Compare([]byte("server clock.redhat.com iburst\n"), []byte("server clock.redhat.com iburst"), "/etc/chrony.conf", "node:worker-0") + require.False(t, got.Match) + assert.Equal(t, 31, got.ExpectedSize) + assert.Equal(t, 30, got.ActualSize) + assert.Equal(t, trailingNewlineOnly, got.UnifiedDiff) + assert.NotContains(t, got.UnifiedDiff, "-server") + assert.NotContains(t, got.UnifiedDiff, "+server") +} + +func TestCompareResolvConfContentStillDiffs(t *testing.T) { + t.Parallel() + + got := Compare([]byte("nameserver 1.1.1.1\n"), []byte("nameserver 8.8.8.8\n"), "/etc/resolv.conf", "node:worker-0") + require.False(t, got.Match) + assert.Contains(t, got.UnifiedDiff, "-nameserver 1.1.1.1") + assert.Contains(t, got.UnifiedDiff, "+nameserver 8.8.8.8") +} + +func TestWithModesMismatch(t *testing.T) { + t.Parallel() + + expected := 0o644 + actual := 0o755 + got := WithModes(Compare([]byte("same\n"), []byte("same\n"), "expected", "actual"), &expected, &actual) + assert.True(t, got.Match) + assert.False(t, got.ModeMatch) + assert.Equal(t, 0o644, *got.ExpectedMode) + assert.Equal(t, 0o755, *got.ActualMode) +} + +func TestModesMatchDefaultWhenExpectedNil(t *testing.T) { + t.Parallel() + + actual644 := 0o644 + actual755 := 0o755 + assert.True(t, ModesMatch(nil, &actual644)) + assert.False(t, ModesMatch(nil, &actual755)) + assert.True(t, ModesMatch(&actual644, nil), "unknown actual mode is not a mismatch") +} + +func TestCompareEmptyMatch(t *testing.T) { + t.Parallel() + + got := Compare(nil, []byte{}, "", "") + assert.True(t, got.Match) + assert.Equal(t, 0, got.ExpectedSize) + assert.Equal(t, 0, got.ActualSize) + assert.Empty(t, got.UnifiedDiff) +} diff --git a/devex/cmd/mcdiff/internal/ignition/extract.go b/devex/cmd/mcdiff/internal/ignition/extract.go new file mode 100644 index 0000000000..222d2afe71 --- /dev/null +++ b/devex/cmd/mcdiff/internal/ignition/extract.go @@ -0,0 +1,134 @@ +package ignition + +import ( + "fmt" + + ign3types "github.com/coreos/ignition/v2/config/v3_5/types" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" +) + +// File is one Ignition storage file decoded from a MachineConfig. +type File struct { + Path string + Contents []byte + Mode *int + User ign3types.NodeUser + Group ign3types.NodeGroup + Overwrite *bool + // Found is true when the path is present in the MachineConfig, including + // when Contents is empty. + Found bool + // Err is set by ExtractAll when the path exists in Ignition but contents + // could not be decoded (for example an unsupported append section). + // ExtractFile returns that condition as a function error instead. + Err error +} + +// ExtractFile returns the Ignition file at path from mc. +// An empty file is Found with a zero-length Contents slice, not a miss. +func ExtractFile(mc *mcfgv1.MachineConfig, path string) (File, error) { + if mc == nil { + return File{}, fmt.Errorf("machineconfig is nil") + } + if path == "" { + return File{}, fmt.Errorf("path must not be empty") + } + if len(mc.Spec.Config.Raw) == 0 { + return File{Path: path}, nil + } + + ign, err := ctrlcommon.ParseAndConvertConfig(mc.Spec.Config.Raw) + if err != nil { + return File{}, fmt.Errorf("failed to parse Ignition in MachineConfig %s: %w", mc.Name, err) + } + + for _, f := range ign.Storage.Files { + if f.Path != path { + continue + } + extracted, err := fileFromIgnition(mc.Name, f) + if err != nil { + return File{}, err + } + extracted.Path = path + return extracted, nil + } + + return File{Path: path}, nil +} + +// ExtractAll returns every Ignition storage file in mc. Duplicate paths keep +// the first occurrence, matching ExtractFile. A decode or append failure on +// one path is recorded on that File and does not abort the rest. +func ExtractAll(mc *mcfgv1.MachineConfig) ([]File, error) { + if mc == nil { + return nil, fmt.Errorf("machineconfig is nil") + } + if len(mc.Spec.Config.Raw) == 0 { + return nil, nil + } + + ign, err := ctrlcommon.ParseAndConvertConfig(mc.Spec.Config.Raw) + if err != nil { + return nil, fmt.Errorf("failed to parse Ignition in MachineConfig %s: %w", mc.Name, err) + } + + seen := make(map[string]struct{}, len(ign.Storage.Files)) + out := make([]File, 0, len(ign.Storage.Files)) + for _, f := range ign.Storage.Files { + if _, ok := seen[f.Path]; ok { + continue + } + seen[f.Path] = struct{}{} + extracted, err := fileFromIgnition(mc.Name, f) + if err != nil { + out = append(out, File{Path: f.Path, Found: true, Err: err}) + continue + } + out = append(out, extracted) + } + return out, nil +} + +func fileFromIgnition(mcName string, f ign3types.File) (File, error) { + if len(f.Append) > 0 { + return File{}, fmt.Errorf("MachineConfig %s: file %q has an append section; append is not supported", mcName, f.Path) + } + // DecodeIgnitionFileContents uses dataurl.DecodeString, so both Ignition + // encodings used in MachineConfigs (and in KCS workarounds) are handled: + // data:text/plain;charset=utf-8;base64, + // data:, + contents, err := ctrlcommon.DecodeIgnitionFileContents(f.Contents.Source, f.Contents.Compression) + if err != nil { + return File{}, fmt.Errorf("couldn't decode file %q in MachineConfig %s: %w", f.Path, mcName, err) + } + if contents == nil { + contents = []byte{} + } + return File{ + Path: f.Path, + Contents: contents, + Mode: copyMode(f.Mode), + User: f.User, + Group: f.Group, + Overwrite: copyBool(f.Overwrite), + Found: true, + }, nil +} + +func copyMode(mode *int) *int { + if mode == nil { + return nil + } + copied := *mode + return &copied +} + +func copyBool(v *bool) *bool { + if v == nil { + return nil + } + copied := *v + return &copied +} diff --git a/devex/cmd/mcdiff/internal/ignition/extract_test.go b/devex/cmd/mcdiff/internal/ignition/extract_test.go new file mode 100644 index 0000000000..a872aa17c9 --- /dev/null +++ b/devex/cmd/mcdiff/internal/ignition/extract_test.go @@ -0,0 +1,185 @@ +package ignition + +import ( + "encoding/base64" + "encoding/json" + "testing" + + ign3types "github.com/coreos/ignition/v2/config/v3_5/types" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestExtractAllMultipleFiles(t *testing.T) { + t.Parallel() + + mc := mcWithFiles(t, "rendered-worker-abc", + fileSpec{"/etc/a", []byte("a\n")}, + fileSpec{"/etc/b", []byte("b\n")}, + fileSpec{"/etc/empty", nil}, + ) + got, err := ExtractAll(mc) + require.NoError(t, err) + require.Len(t, got, 3) + + byPath := map[string]File{} + for _, f := range got { + byPath[f.Path] = f + } + assert.Equal(t, []byte("a\n"), byPath["/etc/a"].Contents) + assert.Equal(t, []byte("b\n"), byPath["/etc/b"].Contents) + assert.True(t, byPath["/etc/empty"].Found) + assert.Equal(t, []byte{}, byPath["/etc/empty"].Contents) + assert.NoError(t, byPath["/etc/a"].Err) +} + +func TestExtractAllEmptyConfig(t *testing.T) { + t.Parallel() + + got, err := ExtractAll(&mcfgv1.MachineConfig{ObjectMeta: metav1.ObjectMeta{Name: "empty"}}) + require.NoError(t, err) + assert.Empty(t, got) +} + +func TestExtractAllNil(t *testing.T) { + t.Parallel() + _, err := ExtractAll(nil) + require.Error(t, err) +} + +func TestExtractFileBase64DataURL(t *testing.T) { + t.Parallel() + + want := []byte("pool 2.rhel.pool.ntp.org iburst\n") + src := "data:text/plain;charset=utf-8;base64," + base64.StdEncoding.EncodeToString(want) + mc := mcWithIgnSource(t, "/etc/chrony.conf", src) + + got, err := ExtractFile(mc, "/etc/chrony.conf") + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, want, got.Contents) +} + +func TestExtractFilePercentEncodedDataURL(t *testing.T) { + t.Parallel() + + want := []byte("pool 2.rhel.pool.ntp.org iburst\n") + src := "data:,pool%202.rhel.pool.ntp.org%20iburst%0A" + mc := mcWithIgnSource(t, "/etc/chrony.conf", src) + + got, err := ExtractFile(mc, "/etc/chrony.conf") + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, want, got.Contents) +} + +func TestExtractAllDecodesBothEncodings(t *testing.T) { + t.Parallel() + + chrony := []byte("pool 2.rhel.pool.ntp.org iburst\n") + resolv := []byte("nameserver 1.1.1.1\n") + mode := 0o644 + empty := "" + b64 := "data:text/plain;charset=utf-8;base64," + base64.StdEncoding.EncodeToString(chrony) + pct := "data:,nameserver%201.1.1.1%0A" + ign := ign3types.Config{ + Ignition: ign3types.Ignition{Version: ign3types.MaxVersion.String()}, + Storage: ign3types.Storage{ + Files: []ign3types.File{ + { + Node: ign3types.Node{Path: "/etc/chrony.conf"}, + FileEmbedded1: ign3types.FileEmbedded1{ + Mode: &mode, + Contents: ign3types.Resource{ + Source: &b64, + Compression: &empty, + }, + }, + }, + { + Node: ign3types.Node{Path: "/etc/resolv.conf"}, + FileEmbedded1: ign3types.FileEmbedded1{ + Mode: &mode, + Contents: ign3types.Resource{ + Source: &pct, + Compression: &empty, + }, + }, + }, + }, + }, + } + raw, err := json.Marshal(ign) + require.NoError(t, err) + mc := &mcfgv1.MachineConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "rendered-worker-abc"}, + Spec: mcfgv1.MachineConfigSpec{Config: runtime.RawExtension{Raw: raw}}, + } + + got, err := ExtractAll(mc) + require.NoError(t, err) + byPath := map[string]File{} + for _, f := range got { + byPath[f.Path] = f + } + assert.Equal(t, chrony, byPath["/etc/chrony.conf"].Contents) + assert.Equal(t, resolv, byPath["/etc/resolv.conf"].Contents) +} + +func mcWithIgnSource(t *testing.T, path, source string) *mcfgv1.MachineConfig { + t.Helper() + mode := 0o644 + empty := "" + ign := ign3types.Config{ + Ignition: ign3types.Ignition{Version: ign3types.MaxVersion.String()}, + Storage: ign3types.Storage{ + Files: []ign3types.File{{ + Node: ign3types.Node{Path: path}, + FileEmbedded1: ign3types.FileEmbedded1{ + Mode: &mode, + Contents: ign3types.Resource{ + Source: &source, + Compression: &empty, + }, + }, + }}, + }, + } + raw, err := json.Marshal(ign) + require.NoError(t, err) + return &mcfgv1.MachineConfig{ + ObjectMeta: metav1.ObjectMeta{Name: "rendered-worker-abc"}, + Spec: mcfgv1.MachineConfigSpec{Config: runtime.RawExtension{Raw: raw}}, + } +} + +type fileSpec struct { + path string + contents []byte +} + +func mcWithFiles(t *testing.T, name string, files ...fileSpec) *mcfgv1.MachineConfig { + t.Helper() + ignFiles := make([]ign3types.File, 0, len(files)) + for _, f := range files { + contents := f.contents + if contents == nil { + contents = []byte{} + } + ignFiles = append(ignFiles, ctrlcommon.NewIgnFileBytes(f.path, contents)) + } + ign := ign3types.Config{ + Ignition: ign3types.Ignition{Version: ign3types.MaxVersion.String()}, + Storage: ign3types.Storage{Files: ignFiles}, + } + raw, err := json.Marshal(ign) + require.NoError(t, err) + return &mcfgv1.MachineConfig{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: mcfgv1.MachineConfigSpec{Config: runtime.RawExtension{Raw: raw}}, + } +} diff --git a/devex/cmd/mcdiff/internal/mustgather/archive.go b/devex/cmd/mcdiff/internal/mustgather/archive.go new file mode 100644 index 0000000000..303f9b7129 --- /dev/null +++ b/devex/cmd/mcdiff/internal/mustgather/archive.go @@ -0,0 +1,126 @@ +package mustgather + +import ( + "fmt" + "os" + "path/filepath" + + "k8s.io/klog/v2" +) + +var scopedDirNames = []string{"cluster-scoped-resources", "cluster-scopes"} + +// MustGather is an unpacked oc adm must-gather tree. +type MustGather struct { + // Path is the path the user passed to --must-gather. + Path string + // Root is the directory that contains cluster-scoped-resources (possibly a nested image dir). + Root string +} + +// Open verifies dir looks like a must-gather tree and returns an archive handle. +func Open(dir string) (*MustGather, error) { + if dir == "" { + return nil, fmt.Errorf("must-gather path must not be empty") + } + st, err := os.Stat(dir) + if err != nil { + return nil, fmt.Errorf("failed to open must-gather %q: %w", dir, err) + } + if !st.IsDir() { + return nil, fmt.Errorf("%q is not a directory; extract the must-gather archive first", dir) + } + + root, err := resolveRoot(dir) + if err != nil { + return nil, err + } + klog.V(2).Infof("using must-gather root %s", root) + return &MustGather{Path: dir, Root: root}, nil +} + +// Getter returns a cluster.Getter backed by YAML/JSON manifests in the archive. +func (m *MustGather) Getter() *ClusterGetter { + return &ClusterGetter{mg: m} +} + +// NodeReader returns a node.Reader backed by host-file snapshots and on-disk MCD configs. +func (m *MustGather) NodeReader() *NodeReader { + return &NodeReader{mg: m} +} + +func resolveRoot(dir string) (string, error) { + if isGatherRoot(dir) { + return dir, nil + } + entries, err := os.ReadDir(dir) + if err != nil { + return "", fmt.Errorf("failed to read must-gather %q: %w", dir, err) + } + var found []string + for _, e := range entries { + if !e.IsDir() { + continue + } + candidate := filepath.Join(dir, e.Name()) + if isGatherRoot(candidate) { + found = append(found, candidate) + } + } + switch len(found) { + case 1: + return found[0], nil + case 0: + return "", fmt.Errorf("%q does not look like a must-gather (missing cluster-scoped-resources)", dir) + default: + // Several plugin images can land in one dest-dir; prefer one that has MCO CRs. + for _, c := range found { + if hasMCOResources(c) { + return c, nil + } + } + return found[0], nil + } +} + +func isGatherRoot(dir string) bool { + for _, name := range scopedDirNames { + st, err := os.Stat(filepath.Join(dir, name)) + if err == nil && st.IsDir() { + return true + } + } + return false +} + +func hasMCOResources(root string) bool { + for _, scoped := range scopedDirNames { + p := filepath.Join(root, scoped, "machineconfiguration.openshift.io") + st, err := os.Stat(p) + if err == nil && st.IsDir() { + return true + } + } + return false +} + +func (m *MustGather) scopedDirs() []string { + var dirs []string + for _, name := range scopedDirNames { + p := filepath.Join(m.Root, name) + if st, err := os.Stat(p); err == nil && st.IsDir() { + dirs = append(dirs, p) + } + } + return dirs +} + +func existingFile(candidates ...string) string { + for _, p := range candidates { + st, err := os.Stat(p) + if err == nil && st.Mode().IsRegular() { + return p + } + } + return "" +} diff --git a/devex/cmd/mcdiff/internal/mustgather/archive_test.go b/devex/cmd/mcdiff/internal/mustgather/archive_test.go new file mode 100644 index 0000000000..b50ac514ac --- /dev/null +++ b/devex/cmd/mcdiff/internal/mustgather/archive_test.go @@ -0,0 +1,269 @@ +package mustgather + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + ign3types "github.com/coreos/ignition/v2/config/v3_5/types" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/node" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" +) + +const ( + sshdPath = "/etc/ssh/sshd_config" + renderedMC = "rendered-worker-abc" +) + +func TestOpenRejectsMissingAndNonDir(t *testing.T) { + t.Parallel() + + _, err := Open(filepath.Join(t.TempDir(), "missing")) + require.Error(t, err) + + f := filepath.Join(t.TempDir(), "file") + require.NoError(t, os.WriteFile(f, []byte("x"), 0o600)) + _, err = Open(f) + require.Error(t, err) + assert.Contains(t, err.Error(), "not a directory") + + _, err = Open(t.TempDir()) + require.Error(t, err) + assert.Contains(t, err.Error(), "does not look like a must-gather") +} + +func TestOpenNestedImageDir(t *testing.T) { + t.Parallel() + + outer := t.TempDir() + inner := filepath.Join(outer, "quay-io-must-gather") + writePoolFixture(t, inner, "PermitRootLogin no\n", nil) + + mg, err := Open(outer) + require.NoError(t, err) + assert.Equal(t, outer, mg.Path) + assert.Equal(t, inner, mg.Root) +} + +func TestClusterGetterLoadPoolFile(t *testing.T) { + t.Parallel() + + root := writePoolFixture(t, t.TempDir(), "canonical-from-render\n", nil) + mg, err := Open(root) + require.NoError(t, err) + + got, err := cluster.LoadPoolFile(context.Background(), mg.Getter(), "worker", sshdPath) + require.NoError(t, err) + require.True(t, got.Found) + assert.Equal(t, []byte("canonical-from-render\n"), got.Expected) + assert.Equal(t, []string{"00-worker", "99-worker-ssh"}, got.WriterNames()) + assert.Equal(t, "99-worker-ssh", got.LastWriterName()) + assert.Equal(t, cluster.ConfigurationCurrent, got.Origin.Kind) +} + +func TestClusterGetterPoolNotFound(t *testing.T) { + t.Parallel() + + root := writePoolFixture(t, t.TempDir(), "x\n", nil) + mg, err := Open(root) + require.NoError(t, err) + + _, err = mg.Getter().GetMachineConfigPool(context.Background(), "infra") + require.Error(t, err) + assert.ErrorIs(t, err, cluster.ErrPoolNotFound) +} + +func TestClusterGetterRenderedNotFound(t *testing.T) { + t.Parallel() + + root := writePoolFixture(t, t.TempDir(), "x\n", nil) + require.NoError(t, os.Remove(filepath.Join(root, "cluster-scoped-resources/machineconfiguration.openshift.io/machineconfigs", renderedMC+".yaml"))) + + mg, err := Open(root) + require.NoError(t, err) + _, err = cluster.LoadPoolFile(context.Background(), mg.Getter(), "worker", sshdPath) + require.Error(t, err) + assert.ErrorIs(t, err, cluster.ErrRenderedNotFound) +} + +func TestClusterGetterJSON(t *testing.T) { + t.Parallel() + + root := t.TempDir() + writePoolFixture(t, root, "from-json\n", nil) + mcDir := filepath.Join(root, "cluster-scoped-resources/machineconfiguration.openshift.io/machineconfigs") + yamlPath := filepath.Join(mcDir, "00-worker.yaml") + data, err := os.ReadFile(yamlPath) + require.NoError(t, err) + var mc mcfgv1.MachineConfig + require.NoError(t, yaml.Unmarshal(data, &mc)) + js, err := json.Marshal(mc) + require.NoError(t, err) + require.NoError(t, os.Remove(yamlPath)) + require.NoError(t, os.WriteFile(filepath.Join(mcDir, "00-worker.json"), js, 0o600)) + + mg, err := Open(root) + require.NoError(t, err) + got, err := mg.Getter().GetMachineConfig(context.Background(), "00-worker") + require.NoError(t, err) + assert.Equal(t, "00-worker", got.Name) +} + +func TestClusterGetterMissingMachineConfigIsNotFound(t *testing.T) { + t.Parallel() + + root := writePoolFixture(t, t.TempDir(), "x\n", nil) + mg, err := Open(root) + require.NoError(t, err) + _, err = mg.Getter().GetMachineConfig(context.Background(), "does-not-exist") + require.Error(t, err) + assert.True(t, apierrors.IsNotFound(err) || err != nil) + assert.ErrorIs(t, err, cluster.ErrRenderedNotFound) +} + +func TestNodeReaderHostSnapshot(t *testing.T) { + t.Parallel() + + root := writePoolFixture(t, t.TempDir(), "expected\n", map[string]string{ + "worker-0": "actual-on-node\n", + }) + mg, err := Open(root) + require.NoError(t, err) + + content, mode, err := mg.NodeReader().ReadFile(context.Background(), "worker-0", sshdPath) + require.NoError(t, err) + assert.Equal(t, []byte("actual-on-node\n"), content) + require.NotNil(t, mode) +} + +func TestNodeReaderFromCurrentConfig(t *testing.T) { + t.Parallel() + + root := writePoolFixture(t, t.TempDir(), "expected\n", nil) + ondisk := filepath.Join(root, "machine_config_ondisk", "worker-1") + require.NoError(t, os.MkdirAll(ondisk, 0o755)) + writeYAML(t, filepath.Join(ondisk, "currentconfig"), mcWithFile(t, "current-worker-1", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-currentconfig\n")) + + mg, err := Open(root) + require.NoError(t, err) + content, _, err := mg.NodeReader().ReadFile(context.Background(), "worker-1", sshdPath) + require.NoError(t, err) + assert.Equal(t, []byte("from-currentconfig\n"), content) +} + +func TestNodeReaderMissingFile(t *testing.T) { + t.Parallel() + + root := writePoolFixture(t, t.TempDir(), "expected\n", map[string]string{"worker-0": "x\n"}) + mg, err := Open(root) + require.NoError(t, err) + _, _, err = mg.NodeReader().ReadFile(context.Background(), "worker-0", "/etc/missing") + require.Error(t, err) + assert.ErrorIs(t, err, node.ErrFileNotFound) +} + +func TestNodeReaderNodeNotFound(t *testing.T) { + t.Parallel() + + root := writePoolFixture(t, t.TempDir(), "expected\n", nil) + mg, err := Open(root) + require.NoError(t, err) + _, _, err = mg.NodeReader().ReadFile(context.Background(), "no-such-node", sshdPath) + require.Error(t, err) + assert.ErrorIs(t, err, node.ErrNodeNotFound) +} + +func TestLoadPoolFileUnmanagedPath(t *testing.T) { + t.Parallel() + + root := writePoolFixture(t, t.TempDir(), "present\n", nil) + mg, err := Open(root) + require.NoError(t, err) + got, err := cluster.LoadPoolFile(context.Background(), mg.Getter(), "worker", "/etc/example") + require.NoError(t, err) + assert.False(t, got.Found) +} + +func writePoolFixture(t *testing.T, root, renderedContents string, nodeFiles map[string]string) string { + t.Helper() + mcDir := filepath.Join(root, "cluster-scoped-resources", "machineconfiguration.openshift.io", "machineconfigs") + poolDir := filepath.Join(root, "cluster-scoped-resources", "machineconfiguration.openshift.io", "machineconfigpools") + nodeDir := filepath.Join(root, "cluster-scoped-resources", "core", "nodes") + require.NoError(t, os.MkdirAll(mcDir, 0o755)) + require.NoError(t, os.MkdirAll(poolDir, 0o755)) + require.NoError(t, os.MkdirAll(nodeDir, 0o755)) + + writeYAML(t, filepath.Join(mcDir, "00-worker.yaml"), mcWithFile(t, "00-worker", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-00\n")) + writeYAML(t, filepath.Join(mcDir, "99-worker-ssh.yaml"), mcWithFile(t, "99-worker-ssh", ctrlcommon.MachineConfigPoolWorker, sshdPath, "from-99\n")) + writeYAML(t, filepath.Join(mcDir, renderedMC+".yaml"), mcWithFile(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, sshdPath, renderedContents)) + writeYAML(t, filepath.Join(poolDir, "worker.yaml"), mcpWithSources(t, "worker", renderedMC, "99-worker-ssh", "00-worker")) + + for name, contents := range nodeFiles { + writeYAML(t, filepath.Join(nodeDir, name+".yaml"), &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: name}}) + host := filepath.Join(root, "nodes", name, "host", "etc", "ssh") + require.NoError(t, os.MkdirAll(host, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(host, "sshd_config"), []byte(contents), 0o644)) + } + return root +} + +func writeYAML(t *testing.T, path string, obj any) { + t.Helper() + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + data, err := yaml.Marshal(obj) + require.NoError(t, err) + require.NoError(t, os.WriteFile(path, data, 0o600)) +} + +func mcpWithSources(t *testing.T, poolName, renderedName string, sourceNames ...string) *mcfgv1.MachineConfigPool { + t.Helper() + refs := make([]corev1.ObjectReference, 0, len(sourceNames)) + for _, name := range sourceNames { + refs = append(refs, corev1.ObjectReference{Kind: "MachineConfig", Name: name}) + } + cfg := mcfgv1.MachineConfigPoolStatusConfiguration{ + ObjectReference: corev1.ObjectReference{Name: renderedName}, + Source: refs, + } + return &mcfgv1.MachineConfigPool{ + TypeMeta: metav1.TypeMeta{APIVersion: mcfgv1.GroupVersion.String(), Kind: "MachineConfigPool"}, + ObjectMeta: metav1.ObjectMeta{Name: poolName}, + Spec: mcfgv1.MachineConfigPoolSpec{Configuration: cfg}, + Status: mcfgv1.MachineConfigPoolStatus{Configuration: cfg}, + } +} + +func mcWithFile(t *testing.T, name, role, path, contents string) *mcfgv1.MachineConfig { + t.Helper() + ign := ign3types.Config{ + Ignition: ign3types.Ignition{Version: ign3types.MaxVersion.String()}, + Storage: ign3types.Storage{ + Files: []ign3types.File{ctrlcommon.NewIgnFileBytes(path, []byte(contents))}, + }, + } + raw, err := json.Marshal(ign) + require.NoError(t, err) + return &mcfgv1.MachineConfig{ + TypeMeta: metav1.TypeMeta{APIVersion: mcfgv1.GroupVersion.String(), Kind: "MachineConfig"}, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ + ctrlcommon.MachineConfigRoleLabel: role, + }, + }, + Spec: mcfgv1.MachineConfigSpec{ + Config: runtime.RawExtension{Raw: raw}, + }, + } +} diff --git a/devex/cmd/mcdiff/internal/mustgather/getter.go b/devex/cmd/mcdiff/internal/mustgather/getter.go new file mode 100644 index 0000000000..2b38f8828d --- /dev/null +++ b/devex/cmd/mcdiff/internal/mustgather/getter.go @@ -0,0 +1,139 @@ +package mustgather + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/klog/v2" + "sigs.k8s.io/yaml" +) + +// ClusterGetter loads MachineConfigPools and MachineConfigs from must-gather manifests. +type ClusterGetter struct { + mg *MustGather +} + +var _ cluster.Getter = (*ClusterGetter)(nil) + +func (g *ClusterGetter) GetMachineConfigPool(ctx context.Context, name string) (*mcfgv1.MachineConfigPool, error) { + _ = ctx + if g == nil || g.mg == nil { + return nil, fmt.Errorf("must-gather getter is not configured") + } + p, err := g.mg.findClusterObject("machineconfiguration.openshift.io", "machineconfigpools", name) + if err != nil { + return nil, fmt.Errorf("failed to get MachineConfigPool %q: %w: %w", name, cluster.ErrPoolNotFound, err) + } + var pool mcfgv1.MachineConfigPool + if err := decodeFile(p, &pool); err != nil { + return nil, fmt.Errorf("failed to decode MachineConfigPool %q from %s: %w", name, p, err) + } + if pool.Name == "" { + pool.Name = name + } + klog.V(4).Infof("loaded MachineConfigPool %s from %s", name, p) + return &pool, nil +} + +func (g *ClusterGetter) GetMachineConfig(ctx context.Context, name string) (*mcfgv1.MachineConfig, error) { + _ = ctx + if g == nil || g.mg == nil { + return nil, fmt.Errorf("must-gather getter is not configured") + } + p, err := g.mg.findClusterObject("machineconfiguration.openshift.io", "machineconfigs", name) + if err != nil { + return nil, fmt.Errorf("failed to get MachineConfig %q from must-gather: %w: %w", name, cluster.ErrRenderedNotFound, apierrors.NewNotFound(mcfgv1.Resource("machineconfigs"), name)) + } + var mc mcfgv1.MachineConfig + if err := decodeFile(p, &mc); err != nil { + return nil, fmt.Errorf("failed to decode MachineConfig %q from %s: %w", name, p, err) + } + if mc.Name == "" { + mc.Name = name + } + klog.V(4).Infof("loaded MachineConfig %s from %s", name, p) + return &mc, nil +} + +func (g *ClusterGetter) ListMachineConfigPools(ctx context.Context) ([]*mcfgv1.MachineConfigPool, error) { + _ = ctx + if g == nil || g.mg == nil { + return nil, fmt.Errorf("must-gather getter is not configured") + } + paths, err := g.mg.listClusterObjects("machineconfiguration.openshift.io", "machineconfigpools") + if err != nil { + return nil, fmt.Errorf("failed to list MachineConfigPools from must-gather: %w", err) + } + out := make([]*mcfgv1.MachineConfigPool, 0, len(paths)) + for _, p := range paths { + var pool mcfgv1.MachineConfigPool + if err := decodeFile(p, &pool); err != nil { + return nil, fmt.Errorf("failed to decode MachineConfigPool from %s: %w", p, err) + } + if pool.Name == "" { + pool.Name = strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)) + } + out = append(out, &pool) + } + return out, nil +} + +func (m *MustGather) findClusterObject(group, resource, name string) (string, error) { + var tried []string + for _, scoped := range m.scopedDirs() { + base := filepath.Join(scoped, group, resource) + for _, ext := range []string{".yaml", ".yml", ".json"} { + candidate := filepath.Join(base, name+ext) + tried = append(tried, candidate) + if existingFile(candidate) != "" { + return candidate, nil + } + } + } + return "", fmt.Errorf("not found (looked in %v)", tried) +} + +func (m *MustGather) listClusterObjects(group, resource string) ([]string, error) { + seen := map[string]struct{}{} + var paths []string + for _, scoped := range m.scopedDirs() { + dir := filepath.Join(scoped, group, resource) + entries, err := os.ReadDir(dir) + if err != nil { + if os.IsNotExist(err) { + continue + } + return nil, err + } + for _, e := range entries { + if e.IsDir() { + continue + } + ext := filepath.Ext(e.Name()) + if ext != ".yaml" && ext != ".yml" && ext != ".json" { + continue + } + name := strings.TrimSuffix(e.Name(), ext) + if _, ok := seen[name]; ok { + continue + } + seen[name] = struct{}{} + paths = append(paths, filepath.Join(dir, e.Name())) + } + } + return paths, nil +} + +func decodeFile(path string, into any) error { + data, err := os.ReadFile(path) + if err != nil { + return err + } + return yaml.Unmarshal(data, into) +} diff --git a/devex/cmd/mcdiff/internal/mustgather/node.go b/devex/cmd/mcdiff/internal/mustgather/node.go new file mode 100644 index 0000000000..ec553c848e --- /dev/null +++ b/devex/cmd/mcdiff/internal/mustgather/node.go @@ -0,0 +1,141 @@ +package mustgather + +import ( + "context" + "fmt" + "os" + "path" + "path/filepath" + "strings" + + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/ignition" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/node" + corev1 "k8s.io/api/core/v1" + "k8s.io/klog/v2" +) + +// NodeReader reads host files from a must-gather archive. +type NodeReader struct { + mg *MustGather +} + +var _ node.Reader = (*NodeReader)(nil) +var _ node.Getter = (*MustGather)(nil) + +// GetNode loads a Node object from the must-gather archive. Used to detect the +// node's MachineConfigPool from labels when --pool is not set. +func (m *MustGather) GetNode(ctx context.Context, name string) (*corev1.Node, error) { + _ = ctx + if m == nil { + return nil, fmt.Errorf("must-gather archive is not configured") + } + p, err := m.findClusterObject("core", "nodes", name) + if err != nil { + return nil, fmt.Errorf("failed to get node %q from must-gather: %w: %w", name, node.ErrNodeNotFound, err) + } + var n corev1.Node + if err := decodeFile(p, &n); err != nil { + return nil, fmt.Errorf("failed to decode node %q from %s: %w", name, p, err) + } + if n.Name == "" { + n.Name = name + } + klog.V(4).Infof("loaded Node %s from %s", name, p) + return &n, nil +} + +func (r *NodeReader) ReadFile(ctx context.Context, nodeName, filePath string) ([]byte, *int, error) { + _ = ctx + if r == nil || r.mg == nil { + return nil, nil, fmt.Errorf("must-gather node reader is not configured") + } + if nodeName == "" { + return nil, nil, fmt.Errorf("node name must not be empty") + } + if filePath == "" || !strings.HasPrefix(filePath, "/") { + return nil, nil, fmt.Errorf("path %q must be an absolute Unix path", filePath) + } + + if p := r.mg.hostSnapshot(nodeName, filePath); p != "" { + klog.V(2).Infof("reading %s for node %s from must-gather snapshot %s", filePath, nodeName, p) + return readHostSnapshot(p) + } + + if content, mode, ok, err := r.mg.fromCurrentConfig(nodeName, filePath); err != nil { + return nil, nil, err + } else if ok { + klog.V(2).Infof("reading %s for node %s from machine_config_ondisk currentconfig", filePath, nodeName) + return content, mode, nil + } + + if r.mg.nodePresent(nodeName) { + return nil, nil, fmt.Errorf("file %q is missing on node %q in must-gather: %w", filePath, nodeName, node.ErrFileNotFound) + } + return nil, nil, fmt.Errorf("node %q not found in must-gather: %w", nodeName, node.ErrNodeNotFound) +} + +func (m *MustGather) hostSnapshot(nodeName, filePath string) string { + rel := strings.TrimPrefix(path.Clean(filePath), "/") + return existingFile( + filepath.Join(m.Root, "nodes", nodeName, "host", rel), + filepath.Join(m.Root, "host_files", nodeName, rel), + filepath.Join(m.Root, "machine_config_ondisk", nodeName, "files", rel), + ) +} + +func (m *MustGather) nodePresent(nodeName string) bool { + if _, err := m.findClusterObject("core", "nodes", nodeName); err == nil { + return true + } + dirs := []string{ + filepath.Join(m.Root, "nodes", nodeName), + filepath.Join(m.Root, "host_files", nodeName), + filepath.Join(m.Root, "machine_config_ondisk", nodeName), + } + for _, d := range dirs { + if st, err := os.Stat(d); err == nil && st.IsDir() { + return true + } + } + return false +} + +func (m *MustGather) fromCurrentConfig(nodeName, filePath string) ([]byte, *int, bool, error) { + p := existingFile( + filepath.Join(m.Root, "machine_config_ondisk", nodeName, "currentconfig"), + filepath.Join(m.Root, "machine_config_ondisk", nodeName, "currentconfig.json"), + filepath.Join(m.Root, "machine_config_ondisk", nodeName, "currentconfig.yaml"), + ) + if p == "" { + return nil, nil, false, nil + } + var mc mcfgv1.MachineConfig + if err := decodeFile(p, &mc); err != nil { + return nil, nil, false, fmt.Errorf("failed to decode currentconfig for node %q (%s): %w", nodeName, p, err) + } + extracted, err := ignition.ExtractFile(&mc, filePath) + if err != nil { + return nil, nil, false, err + } + if !extracted.Found { + return nil, nil, false, nil + } + return extracted.Contents, extracted.Mode, true, nil +} + +func readHostSnapshot(p string) ([]byte, *int, error) { + data, err := os.ReadFile(p) + if err != nil { + return nil, nil, err + } + info, err := os.Stat(p) + if err != nil { + return data, nil, nil + } + mode := int(info.Mode().Perm()) + if data == nil { + data = []byte{} + } + return data, &mode, nil +} diff --git a/devex/cmd/mcdiff/internal/node/errors.go b/devex/cmd/mcdiff/internal/node/errors.go new file mode 100644 index 0000000000..5be9a5ca76 --- /dev/null +++ b/devex/cmd/mcdiff/internal/node/errors.go @@ -0,0 +1,40 @@ +package node + +import ( + "errors" + "fmt" +) + +var ( + // ErrNodeNotFound is returned when the named Node does not exist. + ErrNodeNotFound = errors.New("node not found") + // ErrFileNotFound is returned when the path does not exist on the node's host filesystem. + // Distinguishes a missing file from an empty file (empty file returns nil error and zero-length content). + ErrFileNotFound = errors.New("file not found on node") + // ErrPermissionDenied is returned when the host file cannot be read. + ErrPermissionDenied = errors.New("permission denied reading node file") + // ErrMCDUnavailable is returned when the machine-config-daemon pod cannot be used for exec. + ErrMCDUnavailable = errors.New("machine-config-daemon pod unavailable") +) + +func wrapNodeNotFound(nodeName string, err error) error { + return fmt.Errorf("failed to get node %q: %w: %w", nodeName, ErrNodeNotFound, err) +} + +func wrapFileNotFound(nodeName, path string) error { + return fmt.Errorf("file %q is missing on node %q: %w", path, nodeName, ErrFileNotFound) +} + +func wrapPermissionDenied(nodeName, path string, err error) error { + if err == nil { + return fmt.Errorf("permission denied reading %q on node %q: %w", path, nodeName, ErrPermissionDenied) + } + return fmt.Errorf("permission denied reading %q on node %q: %w: %w", path, nodeName, ErrPermissionDenied, err) +} + +func wrapMCDUnavailable(nodeName string, err error) error { + if err == nil { + return fmt.Errorf("machine-config-daemon on node %q is unavailable: %w", nodeName, ErrMCDUnavailable) + } + return fmt.Errorf("machine-config-daemon on node %q is unavailable: %w: %w", nodeName, ErrMCDUnavailable, err) +} diff --git a/devex/cmd/mcdiff/internal/node/getter.go b/devex/cmd/mcdiff/internal/node/getter.go new file mode 100644 index 0000000000..6ac6d7e31b --- /dev/null +++ b/devex/cmd/mcdiff/internal/node/getter.go @@ -0,0 +1,45 @@ +package node + +import ( + "context" + "fmt" + + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +var _ Getter = (*kubeNodeGetter)(nil) + +// Getter loads a Node object. Used to detect the node's MachineConfigPool +// from labels when --pool is not set. +type Getter interface { + GetNode(ctx context.Context, name string) (*corev1.Node, error) +} + +type kubeNodeGetter struct { + kube kubernetes.Interface +} + +// NewKubeNodeGetter returns a Getter backed by the kubernetes clientset. +func NewKubeNodeGetter(kube kubernetes.Interface) Getter { + return &kubeNodeGetter{kube: kube} +} + +func (g *kubeNodeGetter) GetNode(ctx context.Context, name string) (*corev1.Node, error) { + if g == nil || g.kube == nil { + return nil, fmt.Errorf("node getter is not configured") + } + if name == "" { + return nil, fmt.Errorf("node name must not be empty") + } + n, err := g.kube.CoreV1().Nodes().Get(ctx, name, metav1.GetOptions{}) + if err != nil { + if apierrors.IsNotFound(err) { + return nil, wrapNodeNotFound(name, err) + } + return nil, fmt.Errorf("failed to get node %q: %w", name, err) + } + return n, nil +} diff --git a/devex/cmd/mcdiff/internal/node/reader.go b/devex/cmd/mcdiff/internal/node/reader.go new file mode 100644 index 0000000000..d3c7fad3f6 --- /dev/null +++ b/devex/cmd/mcdiff/internal/node/reader.go @@ -0,0 +1,247 @@ +package node + +import ( + "context" + "fmt" + "path" + "strings" + "time" + + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/remotecommand" + "k8s.io/klog/v2" +) + +const ( + mcdContainer = "machine-config-daemon" + // hostRoot is where the MCD mounts the node's root filesystem. This is the + // same host tree oc debug node exposes at /host. + hostRoot = "/rootfs" + defaultTimeout = 45 * time.Second + mcdDaemonLabel = "k8s-app" + mcdDaemonValue = "machine-config-daemon" +) + +// Reader reads a file from a live node's host filesystem. +type Reader interface { + ReadFile(ctx context.Context, nodeName, path string) (content []byte, mode *int, err error) +} + +type commandExecutor interface { + Exec(ctx context.Context, namespace, pod, container string, command []string) (stdout, stderr []byte, err error) +} + +type kubeReader struct { + kube kubernetes.Interface + execer commandExecutor + timeout time.Duration +} + +// NewKubeReader returns a Reader that execs into the machine-config-daemon pod +// on the named node and reads from the host rootfs mount. +func NewKubeReader(kube kubernetes.Interface, config *rest.Config) Reader { + return &kubeReader{ + kube: kube, + execer: newSPDYExecutor(kube, config), + timeout: defaultTimeout, + } +} + +func (r *kubeReader) ReadFile(ctx context.Context, nodeName, filePath string) ([]byte, *int, error) { + if r == nil || r.kube == nil { + return nil, nil, fmt.Errorf("node reader is not configured") + } + if nodeName == "" { + return nil, nil, fmt.Errorf("node name must not be empty") + } + hostPath, err := hostFilePath(filePath) + if err != nil { + return nil, nil, err + } + + if _, ok := ctx.Deadline(); !ok && r.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, r.timeout) + defer cancel() + } + + if _, err := r.kube.CoreV1().Nodes().Get(ctx, nodeName, metav1.GetOptions{}); err != nil { + if apierrors.IsNotFound(err) { + return nil, nil, wrapNodeNotFound(nodeName, err) + } + return nil, nil, fmt.Errorf("failed to get node %q: %w", nodeName, err) + } + + pod, err := r.mcdPod(ctx, nodeName) + if err != nil { + return nil, nil, err + } + if pod.Status.Phase != corev1.PodRunning { + return nil, nil, wrapMCDUnavailable(nodeName, fmt.Errorf("pod %q is not running (phase %s)", pod.Name, pod.Status.Phase)) + } + + klog.V(2).Infof("reading %s on node %s via machine-config-daemon pod %s", filePath, nodeName, pod.Name) + + mode, err := r.statHostFile(ctx, pod.Name, nodeName, filePath, hostPath) + if err != nil { + return nil, nil, err + } + + stdout, stderr, err := r.execer.Exec(ctx, ctrlcommon.MCONamespace, pod.Name, mcdContainer, []string{"cat", hostPath}) + if err != nil { + return nil, mode, classifyExecError(nodeName, filePath, stderr, err) + } + if looksMissing(stderr) { + return nil, nil, wrapFileNotFound(nodeName, filePath) + } + if looksDenied(stderr) { + return nil, mode, wrapPermissionDenied(nodeName, filePath, nil) + } + if stdout == nil { + stdout = []byte{} + } + return stdout, mode, nil +} + +func (r *kubeReader) statHostFile(ctx context.Context, podName, nodeName, filePath, hostPath string) (*int, error) { + stdout, stderr, err := r.execer.Exec(ctx, ctrlcommon.MCONamespace, podName, mcdContainer, []string{"stat", "-c", "%a", hostPath}) + if err != nil { + return nil, classifyExecError(nodeName, filePath, stderr, err) + } + if looksMissing(stderr) || looksMissing(stdout) { + return nil, wrapFileNotFound(nodeName, filePath) + } + if looksDenied(stderr) { + return nil, wrapPermissionDenied(nodeName, filePath, nil) + } + mode, parseErr := parseOctalMode(strings.TrimSpace(string(stdout))) + if parseErr != nil { + klog.V(4).Infof("could not parse mode from stat on node %s path %s: %v", nodeName, filePath, parseErr) + return nil, nil + } + return &mode, nil +} + +func (r *kubeReader) mcdPod(ctx context.Context, nodeName string) (*corev1.Pod, error) { + list, err := r.kube.CoreV1().Pods(ctrlcommon.MCONamespace).List(ctx, metav1.ListOptions{ + LabelSelector: labels.SelectorFromSet(labels.Set{mcdDaemonLabel: mcdDaemonValue}).String(), + FieldSelector: fields.SelectorFromSet(fields.Set{"spec.nodeName": nodeName}).String(), + }) + if err != nil { + return nil, wrapMCDUnavailable(nodeName, err) + } + running := make([]corev1.Pod, 0, len(list.Items)) + for _, p := range list.Items { + if p.DeletionTimestamp != nil { + continue + } + running = append(running, p) + } + if len(running) == 0 { + return nil, wrapMCDUnavailable(nodeName, fmt.Errorf("no machine-config-daemon pod on node %s", nodeName)) + } + if len(running) > 1 { + return nil, wrapMCDUnavailable(nodeName, fmt.Errorf("found %d machine-config-daemon pods on node %s", len(running), nodeName)) + } + return &running[0], nil +} + +func hostFilePath(filePath string) (string, error) { + if filePath == "" { + return "", fmt.Errorf("path must not be empty") + } + if !strings.HasPrefix(filePath, "/") { + return "", fmt.Errorf("path %q must be an absolute Unix path", filePath) + } + if strings.Contains(filePath, "\x00") { + return "", fmt.Errorf("path %q is invalid", filePath) + } + cleaned := path.Clean(filePath) + if cleaned == "/" { + return "", fmt.Errorf("path %q is not a file", filePath) + } + return path.Join(hostRoot, cleaned), nil +} + +func parseOctalMode(s string) (int, error) { + if s == "" { + return 0, fmt.Errorf("empty mode") + } + var mode int + n, err := fmt.Sscanf(s, "%o", &mode) + if err != nil || n != 1 { + return 0, fmt.Errorf("invalid mode %q", s) + } + return mode, nil +} + +func classifyExecError(nodeName, filePath string, stderr []byte, err error) error { + msg := string(stderr) + if err != nil { + msg += err.Error() + } + if looksDenied([]byte(msg)) { + return wrapPermissionDenied(nodeName, filePath, err) + } + if looksMissing([]byte(msg)) { + return wrapFileNotFound(nodeName, filePath) + } + if err == context.DeadlineExceeded || strings.Contains(err.Error(), "deadline exceeded") { + return fmt.Errorf("timed out reading %q from node %q: %w", filePath, nodeName, err) + } + return fmt.Errorf("failed to read %q from node %q: %w", filePath, nodeName, err) +} + +func looksMissing(b []byte) bool { + return strings.Contains(strings.ToLower(string(b)), "no such file") +} + +func looksDenied(b []byte) bool { + return strings.Contains(strings.ToLower(string(b)), "permission denied") +} + +type spdyExecutor struct { + kube kubernetes.Interface + config *rest.Config +} + +func newSPDYExecutor(kube kubernetes.Interface, config *rest.Config) commandExecutor { + return &spdyExecutor{kube: kube, config: config} +} + +func (s *spdyExecutor) Exec(ctx context.Context, namespace, pod, container string, command []string) ([]byte, []byte, error) { + if s.config == nil { + return nil, nil, fmt.Errorf("rest config is nil") + } + req := s.kube.CoreV1().RESTClient().Post(). + Resource("pods"). + Name(pod). + Namespace(namespace). + SubResource("exec"). + VersionedParams(&corev1.PodExecOptions{ + Container: container, + Command: command, + Stdout: true, + Stderr: true, + }, scheme.ParameterCodec) + + executor, err := remotecommand.NewSPDYExecutor(s.config, "POST", req.URL()) + if err != nil { + return nil, nil, fmt.Errorf("failed to create exec executor: %w", err) + } + + var stdout, stderr strings.Builder + err = executor.StreamWithContext(ctx, remotecommand.StreamOptions{ + Stdout: &stdout, + Stderr: &stderr, + }) + return []byte(stdout.String()), []byte(stderr.String()), err +} diff --git a/devex/cmd/mcdiff/internal/node/reader_test.go b/devex/cmd/mcdiff/internal/node/reader_test.go new file mode 100644 index 0000000000..5d62327937 --- /dev/null +++ b/devex/cmd/mcdiff/internal/node/reader_test.go @@ -0,0 +1,166 @@ +package node + +import ( + "context" + "errors" + "fmt" + "testing" + "time" + + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/kubernetes/fake" +) + +func TestHostFilePath(t *testing.T) { + t.Parallel() + + got, err := hostFilePath("/etc/ssh/sshd_config") + require.NoError(t, err) + assert.Equal(t, "/rootfs/etc/ssh/sshd_config", got) + + _, err = hostFilePath("etc/ssh/sshd_config") + require.Error(t, err) + + _, err = hostFilePath("") + require.Error(t, err) +} + +func TestReadFileMatch(t *testing.T) { + t.Parallel() + + execer := &scriptedExec{statOut: []byte("644\n"), catOut: []byte("PermitRootLogin no\n")} + r := newTestReader(t, execer, testNode("worker-0"), testMCDPod("worker-0", corev1.PodRunning)) + + content, mode, err := r.ReadFile(context.Background(), "worker-0", "/etc/ssh/sshd_config") + require.NoError(t, err) + assert.Equal(t, []byte("PermitRootLogin no\n"), content) + require.NotNil(t, mode) + assert.Equal(t, 0o644, *mode) + require.Len(t, execer.cmds, 2) + assert.Equal(t, []string{"stat", "-c", "%a", "/rootfs/etc/ssh/sshd_config"}, execer.cmds[0]) + assert.Equal(t, []string{"cat", "/rootfs/etc/ssh/sshd_config"}, execer.cmds[1]) +} + +func TestReadFileEmpty(t *testing.T) { + t.Parallel() + + r := newTestReader(t, &scriptedExec{statOut: []byte("644\n"), catOut: []byte{}}, testNode("worker-0"), testMCDPod("worker-0", corev1.PodRunning)) + content, mode, err := r.ReadFile(context.Background(), "worker-0", "/etc/empty") + require.NoError(t, err) + assert.Equal(t, []byte{}, content) + require.NotNil(t, mode) +} + +func TestReadFileMissing(t *testing.T) { + t.Parallel() + + r := newTestReader(t, &scriptedExec{ + statErr: errors.New("stat: cannot statx '/rootfs/etc/missing': No such file or directory"), + statErrOut: []byte("stat: cannot statx '/rootfs/etc/missing': No such file or directory\n"), + }, testNode("worker-0"), testMCDPod("worker-0", corev1.PodRunning)) + + _, _, err := r.ReadFile(context.Background(), "worker-0", "/etc/missing") + require.Error(t, err) + assert.ErrorIs(t, err, ErrFileNotFound) +} + +func TestReadFilePermissionDenied(t *testing.T) { + t.Parallel() + + r := newTestReader(t, &scriptedExec{ + statErr: errors.New("exit 1"), + statErrOut: []byte("stat: cannot statx '/rootfs/etc/shadow': Permission denied\n"), + }, testNode("worker-0"), testMCDPod("worker-0", corev1.PodRunning)) + + _, _, err := r.ReadFile(context.Background(), "worker-0", "/etc/shadow") + require.Error(t, err) + assert.ErrorIs(t, err, ErrPermissionDenied) +} + +func TestReadFileNodeNotFound(t *testing.T) { + t.Parallel() + + r := newTestReader(t, &scriptedExec{}, testNode("other")) + _, _, err := r.ReadFile(context.Background(), "worker-0", "/etc/ssh/sshd_config") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNodeNotFound) +} + +func TestReadFileMCDMissing(t *testing.T) { + t.Parallel() + + r := newTestReader(t, &scriptedExec{}, testNode("worker-0")) + _, _, err := r.ReadFile(context.Background(), "worker-0", "/etc/ssh/sshd_config") + require.Error(t, err) + assert.ErrorIs(t, err, ErrMCDUnavailable) +} + +func TestReadFileMCDNotRunning(t *testing.T) { + t.Parallel() + + r := newTestReader(t, &scriptedExec{}, testNode("worker-0"), testMCDPod("worker-0", corev1.PodPending)) + _, _, err := r.ReadFile(context.Background(), "worker-0", "/etc/ssh/sshd_config") + require.Error(t, err) + assert.ErrorIs(t, err, ErrMCDUnavailable) + assert.Contains(t, err.Error(), "not running") +} + +func TestReadFileTimeout(t *testing.T) { + t.Parallel() + + r := newTestReader(t, &scriptedExec{statErr: context.DeadlineExceeded}, testNode("worker-0"), testMCDPod("worker-0", corev1.PodRunning)) + _, _, err := r.ReadFile(context.Background(), "worker-0", "/etc/ssh/sshd_config") + require.Error(t, err) + assert.Contains(t, err.Error(), "timed out") +} + +type scriptedExec struct { + statOut []byte + statErrOut []byte + statErr error + catOut []byte + catErrOut []byte + catErr error + cmds [][]string +} + +func (s *scriptedExec) Exec(_ context.Context, _, _, _ string, command []string) ([]byte, []byte, error) { + s.cmds = append(s.cmds, append([]string(nil), command...)) + if len(command) > 0 && command[0] == "stat" { + return s.statOut, s.statErrOut, s.statErr + } + return s.catOut, s.catErrOut, s.catErr +} + +func newTestReader(t *testing.T, execer commandExecutor, objs ...runtime.Object) *kubeReader { + t.Helper() + return &kubeReader{ + kube: fake.NewSimpleClientset(objs...), + execer: execer, + timeout: time.Second, + } +} + +func testNode(name string) *corev1.Node { + return &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: name}} +} + +func testMCDPod(nodeName string, phase corev1.PodPhase) *corev1.Pod { + return &corev1.Pod{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("machine-config-daemon-%s", nodeName), + Namespace: ctrlcommon.MCONamespace, + Labels: map[string]string{mcdDaemonLabel: mcdDaemonValue}, + }, + Spec: corev1.PodSpec{ + NodeName: nodeName, + Containers: []corev1.Container{{Name: mcdContainer}}, + }, + Status: corev1.PodStatus{Phase: phase}, + } +} diff --git a/devex/cmd/mcdiff/internal/report/report.go b/devex/cmd/mcdiff/internal/report/report.go new file mode 100644 index 0000000000..754e313e06 --- /dev/null +++ b/devex/cmd/mcdiff/internal/report/report.go @@ -0,0 +1,326 @@ +package report + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/diff" +) + +const ( + separator = "────────────────────────────────────────" + outputText = "text" + outputJSON = "json" +) + +// Options control how a PoolFile is printed. +type Options struct { + // ShowContent includes expected file bytes. Default is metadata only. + ShowContent bool + // Format is "text" (default) or "json". + Format string + // FromFile is the local path passed to --from-file. Empty means no comparison. + FromFile string + // Node is the node name passed to --node. Empty means no live-node comparison. + Node string + // Actual is the compared file bytes when FromFile or Node is set and the file exists. + Actual []byte + // ActualMissing is true when --node was set and the path does not exist on the node. + ActualMissing bool + // Diff is the expected-vs-actual comparison. Nil when there is no comparison + // (no --from-file/--node, unmanaged path, or node file missing). + Diff *diff.Result + // MustGather is the --must-gather path when operating offline. + MustGather string +} + +// Write prints pf according to opts. +func Write(w io.Writer, pf *cluster.PoolFile, opts Options) error { + if pf == nil { + return fmt.Errorf("pool file result is nil") + } + format := opts.Format + if format == "" { + format = outputText + } + switch format { + case outputText: + _, err := io.WriteString(w, formatText(pf, opts)) + return err + case outputJSON: + return json.NewEncoder(w).Encode(toJSON(pf, opts)) + default: + return fmt.Errorf("unknown output format %q (want %s or %s)", format, outputText, outputJSON) + } +} + +func formatText(pf *cluster.PoolFile, opts Options) string { + var b strings.Builder + fmt.Fprintf(&b, "MachineConfig File\n%s\n\n", separator) + fmt.Fprintf(&b, "Pool: %s\n", poolName(pf)) + fmt.Fprintf(&b, "Configuration: %s\n", originKind(pf)) + fmt.Fprintf(&b, "Source: %s\n", originSource(pf)) + if opts.MustGather != "" { + fmt.Fprintf(&b, "Archive: Must-Gather Archive (%s)\n", opts.MustGather) + } + fmt.Fprintf(&b, "Rendered MC: %s\n\n", renderedName(pf)) + fmt.Fprintf(&b, "File: %s\n", pf.Path) + + if !pf.Found { + fmt.Fprintf(&b, "Exists: no\n\n") + fmt.Fprintf(&b, "This path is not managed by the rendered MachineConfig.\n") + writeAttribution(&b, pf) + writeUnmanagedActual(&b, opts) + return b.String() + } + + fmt.Fprintf(&b, "Exists: yes\n") + fmt.Fprintf(&b, "Mode: %s\n", formatMode(pf.Mode)) + fmt.Fprintf(&b, "Expected size: %d bytes\n", len(pf.Expected)) + writeAttribution(&b, pf) + writeComparison(&b, opts) + + if compared(opts) && !opts.ShowContent { + return b.String() + } + if !opts.ShowContent { + fmt.Fprintf(&b, "\nExpected content: omitted (pass --show-content to print)\n") + return b.String() + } + + fmt.Fprintf(&b, "\nExpected content:\n%s\n", separator) + if len(pf.Expected) == 0 { + fmt.Fprintf(&b, "\n") + } else { + b.Write(pf.Expected) + if pf.Expected[len(pf.Expected)-1] != '\n' { + b.WriteByte('\n') + } + } + fmt.Fprintf(&b, "%s\n", separator) + return b.String() +} + +func compared(opts Options) bool { + return opts.FromFile != "" || opts.Node != "" +} + +func writeUnmanagedActual(b *strings.Builder, opts Options) { + if !compared(opts) { + return + } + fmt.Fprintf(b, "\n") + switch { + case opts.Node != "" && opts.ActualMissing: + fmt.Fprintf(b, "Node: %s\n", opts.Node) + fmt.Fprintf(b, "Node file: MISSING ON NODE\n") + case opts.Node != "": + fmt.Fprintf(b, "Node: %s\n", opts.Node) + fmt.Fprintf(b, "Node file: exists (%d bytes)\n", len(opts.Actual)) + default: + fmt.Fprintf(b, "Local file: %s (%d bytes)\n", opts.FromFile, len(opts.Actual)) + } + fmt.Fprintf(b, "No content comparison was performed because this path is not managed by the rendered MachineConfig.\n") +} + +func writeComparison(b *strings.Builder, opts Options) { + if !compared(opts) { + return + } + fmt.Fprintf(b, "\n") + if opts.Node != "" { + fmt.Fprintf(b, "Node: %s\n", opts.Node) + } + if opts.ActualMissing { + fmt.Fprintf(b, "Node file: MISSING ON NODE\n") + fmt.Fprintf(b, "\nFile exists in rendered MC, but is MISSING ON NODE %s.\n", opts.Node) + return + } + if opts.Diff == nil { + return + } + d := opts.Diff + fmt.Fprintf(b, "Comparison: %s\n", comparisonLabel(d)) + if opts.FromFile != "" { + fmt.Fprintf(b, "From file: %s\n", opts.FromFile) + } + fmt.Fprintf(b, "Expected size: %d bytes\n", d.ExpectedSize) + fmt.Fprintf(b, "Actual size: %d bytes\n", d.ActualSize) + if d.ExpectedSize != d.ActualSize { + fmt.Fprintf(b, "Size: expected %d bytes, got %d bytes\n", d.ExpectedSize, d.ActualSize) + } + writeModeDelta(b, d) + if d.Match || d.UnifiedDiff == "" { + return + } + fmt.Fprintf(b, "\nUnified diff:\n%s\n", separator) + b.WriteString(d.UnifiedDiff) + if !strings.HasSuffix(d.UnifiedDiff, "\n") { + b.WriteByte('\n') + } + fmt.Fprintf(b, "%s\n", separator) +} + +func writeAttribution(b *strings.Builder, pf *cluster.PoolFile) { + fmt.Fprintf(b, "\n") + if pf.AttributionErr != nil { + fmt.Fprintf(b, "Attribution: unavailable\n") + fmt.Fprintf(b, "Reason: %s\n", pf.AttributionErr.Error()) + return + } + writers := pf.WriterNames() + if len(writers) == 0 { + fmt.Fprintf(b, "Writers: (none)\n") + fmt.Fprintf(b, "Last writer: (none)\n") + return + } + fmt.Fprintf(b, "Writers:\n") + for _, name := range writers { + fmt.Fprintf(b, " %s\n", name) + } + fmt.Fprintf(b, "\nLast writer:\n %s\n", pf.LastWriterName()) +} + +func comparisonLabel(d *diff.Result) string { + switch { + case d.Match && d.ModeMatch: + return "MATCH" + case d.Match && !d.ModeMatch: + return "MODE MISMATCH" + case !d.Match && d.ModeMatch: + return "CONTENT MISMATCH" + default: + return "CONTENT AND MODE MISMATCH" + } +} + +func writeModeDelta(b *strings.Builder, d *diff.Result) { + if d == nil || d.ModeMatch { + return + } + fmt.Fprintf(b, "Mode: expected %s, actual %s\n", formatModeOctal(diff.EffectiveMode(d.ExpectedMode)), formatMode(d.ActualMode)) +} + +func formatModeOctal(mode int) string { + return fmt.Sprintf("%#o", mode) +} + +func formatMode(mode *int) string { + if mode == nil { + return "unspecified" + } + return fmt.Sprintf("%#o", *mode) +} + +func poolName(pf *cluster.PoolFile) string { + if pf.Pool == nil { + return "" + } + return pf.Pool.Name +} + +func renderedName(pf *cluster.PoolFile) string { + if pf.Rendered == nil { + return "" + } + return pf.Rendered.Name +} + +func originKind(pf *cluster.PoolFile) string { + if pf.Origin.Kind == "" { + return cluster.ConfigurationCurrent + } + return pf.Origin.Kind +} + +func originSource(pf *cluster.PoolFile) string { + if pf.Origin.Source == "" { + return "MCP status.configuration" + } + return pf.Origin.Source +} + +type fileJSON struct { + Pool string `json:"pool"` + Configuration string `json:"configuration"` + ConfigurationSource string `json:"configurationSource"` + RenderedMachineConfig string `json:"renderedMachineConfig"` + Path string `json:"path"` + Found bool `json:"found"` + Mode *int `json:"mode,omitempty"` + ExpectedSize int `json:"expectedSize"` + Writers []string `json:"writers"` + LastWriter string `json:"lastWriter"` + AttributionAvailable bool `json:"attributionAvailable"` + AttributionError string `json:"attributionError,omitempty"` + ExpectedContent string `json:"expectedContent,omitempty"` + FromFile string `json:"fromFile,omitempty"` + Node string `json:"node,omitempty"` + NodeFileFound *bool `json:"nodeFileFound,omitempty"` + Match *bool `json:"match,omitempty"` + ModeMatch *bool `json:"modeMatch,omitempty"` + ActualMode *int `json:"actualMode,omitempty"` + ActualSize *int `json:"actualSize,omitempty"` + Diff string `json:"diff,omitempty"` + MustGatherDir string `json:"mustGatherDir,omitempty"` +} + +func toJSON(pf *cluster.PoolFile, opts Options) fileJSON { + out := fileJSON{ + Pool: poolName(pf), + Configuration: originKind(pf), + ConfigurationSource: originSource(pf), + RenderedMachineConfig: renderedName(pf), + Path: pf.Path, + Found: pf.Found, + Mode: pf.Mode, + ExpectedSize: len(pf.Expected), + Writers: pf.WriterNames(), + LastWriter: pf.LastWriterName(), + AttributionAvailable: pf.Attribution != nil && pf.AttributionErr == nil, + MustGatherDir: opts.MustGather, + } + if out.Writers == nil { + out.Writers = []string{} + } + if pf.AttributionErr != nil { + out.AttributionError = pf.AttributionErr.Error() + } + if opts.ShowContent && pf.Found { + out.ExpectedContent = string(pf.Expected) + } + if opts.FromFile != "" { + out.FromFile = opts.FromFile + size := len(opts.Actual) + out.ActualSize = &size + attachDiffJSON(&out, opts.Diff) + } + if opts.Node != "" { + out.Node = opts.Node + found := !opts.ActualMissing + out.NodeFileFound = &found + if !opts.ActualMissing { + size := len(opts.Actual) + out.ActualSize = &size + } + attachDiffJSON(&out, opts.Diff) + } + return out +} + +func attachDiffJSON(out *fileJSON, d *diff.Result) { + if d == nil { + return + } + match := d.Match + out.Match = &match + out.Diff = d.UnifiedDiff + if d.ActualMode != nil || !d.ModeMatch { + modeMatch := d.ModeMatch + out.ModeMatch = &modeMatch + out.ActualMode = d.ActualMode + } +} diff --git a/devex/cmd/mcdiff/internal/report/report_test.go b/devex/cmd/mcdiff/internal/report/report_test.go new file mode 100644 index 0000000000..d64d84417a --- /dev/null +++ b/devex/cmd/mcdiff/internal/report/report_test.go @@ -0,0 +1,368 @@ +package report + +import ( + "bytes" + "encoding/json" + "errors" + "testing" + + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/attribution" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/diff" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestWriteManagedFileOmitsContentByDefault(t *testing.T) { + t.Parallel() + + mode := 0o644 + var buf bytes.Buffer + err := Write(&buf, managedPoolFile(mode, "PermitRootLogin no\n"), Options{}) + require.NoError(t, err) + out := buf.String() + + assert.Contains(t, out, "Pool: worker") + assert.Contains(t, out, "Configuration: current") + assert.Contains(t, out, "Source: MCP status.configuration") + assert.Contains(t, out, "Rendered MC: rendered-worker-abc") + assert.Contains(t, out, "File: /etc/ssh/sshd_config") + assert.Contains(t, out, "Exists: yes") + assert.Contains(t, out, "Mode: 0644") + assert.Contains(t, out, " 00-worker") + assert.Contains(t, out, " 99-worker-ssh") + assert.Contains(t, out, " 99-worker-ssh") + assert.Contains(t, out, "Last writer:") + assert.Contains(t, out, "omitted (pass --show-content to print)") + assert.NotContains(t, out, "PermitRootLogin no") +} + +func TestWriteShowContent(t *testing.T) { + t.Parallel() + + mode := 0o644 + var buf bytes.Buffer + err := Write(&buf, managedPoolFile(mode, "PermitRootLogin no\n"), Options{ShowContent: true}) + require.NoError(t, err) + assert.Contains(t, buf.String(), "PermitRootLogin no") +} + +func TestWriteMissingFile(t *testing.T) { + t.Parallel() + + pf := managedPoolFile(0o644, "present\n") + pf.Path = "/etc/example" + pf.Found = false + pf.Expected = nil + + var buf bytes.Buffer + require.NoError(t, Write(&buf, pf, Options{ShowContent: true})) + out := buf.String() + assert.Contains(t, out, "Exists: no") + assert.Contains(t, out, "This path is not managed by the rendered MachineConfig.") + assert.NotContains(t, out, "Expected content:") + assert.NotContains(t, out, "present") +} + +func TestWriteEmptyFile(t *testing.T) { + t.Parallel() + + mode := 0o644 + pf := managedPoolFile(mode, "") + pf.Expected = []byte{} + pf.Found = true + + var buf bytes.Buffer + require.NoError(t, Write(&buf, pf, Options{ShowContent: true})) + out := buf.String() + assert.Contains(t, out, "Exists: yes") + assert.Contains(t, out, "Expected size: 0 bytes") + assert.Contains(t, out, "") +} + +func TestWriteAttributionUnavailable(t *testing.T) { + t.Parallel() + + mode := 0o644 + pf := managedPoolFile(mode, "canonical\n") + pf.Attribution = nil + pf.AttributionErr = errors.New("source MachineConfig 99-worker-ssh could not be retrieved") + + var buf bytes.Buffer + require.NoError(t, Write(&buf, pf, Options{})) + out := buf.String() + assert.Contains(t, out, "Expected size: 10 bytes") + assert.Contains(t, out, "Attribution: unavailable") + assert.Contains(t, out, "99-worker-ssh could not be retrieved") +} + +func TestWriteJSONOmitsContent(t *testing.T) { + t.Parallel() + + mode := 0o644 + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(mode, "secret\n"), Options{Format: "json"})) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "worker", got["pool"]) + assert.Equal(t, "current", got["configuration"]) + assert.Equal(t, "MCP status.configuration", got["configurationSource"]) + assert.Equal(t, "rendered-worker-abc", got["renderedMachineConfig"]) + assert.Equal(t, "/etc/ssh/sshd_config", got["path"]) + assert.Equal(t, true, got["found"]) + assert.Equal(t, float64(420), got["mode"]) + assert.Equal(t, float64(7), got["expectedSize"]) + assert.Equal(t, []any{"00-worker", "99-worker-ssh"}, got["writers"]) + assert.Equal(t, "99-worker-ssh", got["lastWriter"]) + assert.Equal(t, true, got["attributionAvailable"]) + _, hasContent := got["expectedContent"] + assert.False(t, hasContent) +} + +func TestWriteJSONIncludesContentWhenRequested(t *testing.T) { + t.Parallel() + + mode := 0o644 + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(mode, "secret\n"), Options{Format: "json", ShowContent: true})) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "secret\n", got["expectedContent"]) +} + +func TestWriteJSONTargetOrigin(t *testing.T) { + t.Parallel() + + pf := managedPoolFile(0o644, "x\n") + pf.Origin = cluster.ConfigurationOrigin{Kind: cluster.ConfigurationTarget, Source: "MCP spec.configuration"} + + var buf bytes.Buffer + require.NoError(t, Write(&buf, pf, Options{Format: "json"})) + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "target", got["configuration"]) + assert.Equal(t, "MCP spec.configuration", got["configurationSource"]) +} + +func TestWriteFromFileMatch(t *testing.T) { + t.Parallel() + + contents := "PermitRootLogin no\n" + cmp := diff.Compare([]byte(contents), []byte(contents), "/etc/ssh/sshd_config", "./sshd_config") + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, contents), Options{ + FromFile: "./sshd_config", + Actual: []byte(contents), + Diff: &cmp, + })) + out := buf.String() + assert.Contains(t, out, "Comparison: MATCH") + assert.Contains(t, out, "From file: ./sshd_config") + assert.NotContains(t, out, "Unified diff:") + assert.NotContains(t, out, "omitted (pass --show-content to print)") +} + +func TestWriteFromFileMismatch(t *testing.T) { + t.Parallel() + + expected := "PermitRootLogin no\n" + actual := "PermitRootLogin yes\n" + cmp := diff.Compare([]byte(expected), []byte(actual), "/etc/ssh/sshd_config", "./sshd_config") + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, expected), Options{ + FromFile: "./sshd_config", + Actual: []byte(actual), + Diff: &cmp, + })) + out := buf.String() + assert.Contains(t, out, "Comparison: CONTENT MISMATCH") + assert.Contains(t, out, "expected 19 bytes, got 20 bytes") + assert.Contains(t, out, "Unified diff:") + assert.Contains(t, out, "-PermitRootLogin no") + assert.Contains(t, out, "+PermitRootLogin yes") +} + +func TestWriteFromFileUnmanaged(t *testing.T) { + t.Parallel() + + pf := managedPoolFile(0o644, "present\n") + pf.Path = "/etc/example" + pf.Found = false + pf.Expected = nil + + var buf bytes.Buffer + require.NoError(t, Write(&buf, pf, Options{ + FromFile: "./example", + Actual: []byte("local-only\n"), + })) + out := buf.String() + assert.Contains(t, out, "This path is not managed by the rendered MachineConfig.") + assert.Contains(t, out, "Local file: ./example (11 bytes)") + assert.Contains(t, out, "No content comparison was performed") + assert.NotContains(t, out, "Unified diff:") + assert.NotContains(t, out, "local-only") +} + +func TestWriteFromFileJSON(t *testing.T) { + t.Parallel() + + expected := "PermitRootLogin no\n" + actual := "PermitRootLogin yes\n" + cmp := diff.Compare([]byte(expected), []byte(actual), "/etc/ssh/sshd_config", "./sshd_config") + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, expected), Options{ + Format: "json", + FromFile: "./sshd_config", + Actual: []byte(actual), + Diff: &cmp, + })) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "./sshd_config", got["fromFile"]) + assert.Equal(t, false, got["match"]) + assert.Equal(t, float64(20), got["actualSize"]) + diffStr, ok := got["diff"].(string) + require.True(t, ok) + assert.Contains(t, diffStr, "-PermitRootLogin no") + _, hasContent := got["expectedContent"] + assert.False(t, hasContent) +} + +func TestWriteNodeMatch(t *testing.T) { + t.Parallel() + + contents := "PermitRootLogin no\n" + cmp := diff.Compare([]byte(contents), []byte(contents), "/etc/ssh/sshd_config", "node:worker-0") + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, contents), Options{ + Node: "worker-0", + Actual: []byte(contents), + Diff: &cmp, + })) + out := buf.String() + assert.Contains(t, out, "Node: worker-0") + assert.Contains(t, out, "Comparison: MATCH") + assert.NotContains(t, out, "Unified diff:") +} + +func TestWriteMustGatherArchive(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, "x\n"), Options{MustGather: "./must-gather-archive"})) + assert.Contains(t, buf.String(), "Archive: Must-Gather Archive (./must-gather-archive)") + assert.Contains(t, buf.String(), "Source: MCP status.configuration") +} + +func TestWriteMustGatherJSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, "x\n"), Options{Format: "json", MustGather: "./mg"})) + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "./mg", got["mustGatherDir"]) +} + +func TestWriteNodeMismatch(t *testing.T) { + t.Parallel() + + expected := "PermitRootLogin no\n" + actual := "PermitRootLogin yes\n" + cmp := diff.Compare([]byte(expected), []byte(actual), "/etc/ssh/sshd_config", "node:worker-0") + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, expected), Options{ + Node: "worker-0", + Actual: []byte(actual), + Diff: &cmp, + })) + out := buf.String() + assert.Contains(t, out, "Comparison: CONTENT MISMATCH") + assert.Contains(t, out, "Node: worker-0") + assert.Contains(t, out, "-PermitRootLogin no") +} + +func TestWriteNodeModeMismatch(t *testing.T) { + t.Parallel() + + contents := "PermitRootLogin no\n" + expectedMode := 0o644 + actualMode := 0o755 + cmp := diff.WithModes(diff.Compare([]byte(contents), []byte(contents), "/etc/ssh/sshd_config", "node:worker-0"), &expectedMode, &actualMode) + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, contents), Options{ + Node: "worker-0", + Actual: []byte(contents), + Diff: &cmp, + })) + out := buf.String() + assert.Contains(t, out, "Comparison: MODE MISMATCH") + assert.Contains(t, out, "Mode: expected 0644, actual 0755") + assert.NotContains(t, out, "Unified diff:") +} + +func TestWriteNodeMissing(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, "present\n"), Options{ + Node: "worker-0", + ActualMissing: true, + })) + out := buf.String() + assert.Contains(t, out, "File exists in rendered MC, but is MISSING ON NODE worker-0.") + assert.NotContains(t, out, "Unified diff:") +} + +func TestWriteNodeJSON(t *testing.T) { + t.Parallel() + + expected := "PermitRootLogin no\n" + actual := "PermitRootLogin yes\n" + cmp := diff.Compare([]byte(expected), []byte(actual), "/etc/ssh/sshd_config", "node:worker-0") + var buf bytes.Buffer + require.NoError(t, Write(&buf, managedPoolFile(0o644, expected), Options{ + Format: "json", + Node: "worker-0", + Actual: []byte(actual), + Diff: &cmp, + })) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "worker-0", got["node"]) + assert.Equal(t, false, got["match"]) + assert.Equal(t, true, got["nodeFileFound"]) + assert.Equal(t, float64(20), got["actualSize"]) + diffStr, ok := got["diff"].(string) + require.True(t, ok) + assert.Contains(t, diffStr, "+PermitRootLogin yes") +} + +func managedPoolFile(mode int, contents string) *cluster.PoolFile { + return &cluster.PoolFile{ + Pool: &mcfgv1.MachineConfigPool{ObjectMeta: metav1.ObjectMeta{Name: "worker"}}, + Rendered: &mcfgv1.MachineConfig{ObjectMeta: metav1.ObjectMeta{Name: "rendered-worker-abc"}}, + Origin: cluster.ConfigurationOrigin{ + Kind: cluster.ConfigurationCurrent, + Source: "MCP status.configuration", + }, + Path: "/etc/ssh/sshd_config", + Expected: []byte(contents), + Found: true, + Mode: &mode, + Attribution: &attribution.Result{ + Path: "/etc/ssh/sshd_config", + Writers: []attribution.Writer{ + {MachineConfigName: "00-worker"}, + {MachineConfigName: "99-worker-ssh"}, + }, + LastWriter: &attribution.Writer{MachineConfigName: "99-worker-ssh"}, + }, + } +} diff --git a/devex/cmd/mcdiff/internal/report/scan.go b/devex/cmd/mcdiff/internal/report/scan.go new file mode 100644 index 0000000000..a0c9ddcfc1 --- /dev/null +++ b/devex/cmd/mcdiff/internal/report/scan.go @@ -0,0 +1,237 @@ +package report + +import ( + "encoding/json" + "fmt" + "io" + "strings" + + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/diff" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/scanner" +) + +// ScanOptions control how a whole-node scan is printed. +type ScanOptions struct { + // Format is "text" (default) or "json". + Format string + // ShowDiffs includes unified diffs for mismatched files. + ShowDiffs bool + // MustGather is the --must-gather path when operating offline. + MustGather string +} + +// WriteScan prints a whole-node scan result. +func WriteScan(w io.Writer, result *scanner.Result, opts ScanOptions) error { + if result == nil { + return fmt.Errorf("scan result is nil") + } + format := opts.Format + if format == "" { + format = outputText + } + switch format { + case outputText: + _, err := io.WriteString(w, formatScanText(result, opts)) + return err + case outputJSON: + return json.NewEncoder(w).Encode(toScanJSON(result, opts)) + default: + return fmt.Errorf("unknown output format %q (want %s or %s)", format, outputText, outputJSON) + } +} + +func formatScanText(result *scanner.Result, opts ScanOptions) string { + var b strings.Builder + fmt.Fprintf(&b, "MachineConfig Node Scan\n%s\n\n", separator) + fmt.Fprintf(&b, "Node: %s\n", result.Node) + fmt.Fprintf(&b, "Pool: %s\n", result.Pool) + fmt.Fprintf(&b, "Rendered MC: %s\n", result.Rendered) + if opts.MustGather != "" { + fmt.Fprintf(&b, "Archive: Must-Gather Archive (%s)\n", opts.MustGather) + } + fmt.Fprintf(&b, "Scanned Files: %d\n", result.Scanned) + fmt.Fprintf(&b, "Matching: %d\n", result.Matching) + fmt.Fprintf(&b, "Mismatched: %d\n", result.Mismatched) + fmt.Fprintf(&b, "Missing: %d\n", result.Missing) + if result.Errors > 0 { + fmt.Fprintf(&b, "Unreadable: %d\n", result.Errors) + } + fmt.Fprintf(&b, "Status: %s\n", scanStatusLine(result)) + + writeFindingList(&b, "Mismatched Files", result.MismatchedFiles, true, opts.ShowDiffs) + writeFindingList(&b, "Missing Files", result.MissingFiles, false, false) + writeErrorList(&b, result.ErrorFiles) + return b.String() +} + +func scanStatusLine(result *scanner.Result) string { + switch result.Status() { + case "clean": + return "CLEAN" + case "error": + return fmt.Sprintf("ERRORS (%s)", fileCount(result.Errors, "unreadable")) + default: + var parts []string + if result.Mismatched > 0 { + parts = append(parts, fileCount(result.Mismatched, "modified")) + } + if result.Missing > 0 { + parts = append(parts, fileCount(result.Missing, "missing")) + } + if result.Errors > 0 { + parts = append(parts, fileCount(result.Errors, "unreadable")) + } + return "DRIFT DETECTED (" + strings.Join(parts, ", ") + ")" + } +} + +func fileCount(n int, adjective string) string { + if n == 1 { + return fmt.Sprintf("1 file %s", adjective) + } + return fmt.Sprintf("%d files %s", n, adjective) +} + +func writeFindingList(b *strings.Builder, title string, findings []scanner.Finding, withSizes, showDiffs bool) { + if len(findings) == 0 { + return + } + fmt.Fprintf(b, "\n%s:\n", title) + for i, f := range findings { + fmt.Fprintf(b, "%d. %s\n", i+1, f.Path) + if !withSizes { + fmt.Fprintf(b, " Status: MISSING ON NODE\n") + } + if withSizes { + fmt.Fprintf(b, " Expected: %d bytes | Actual: %d bytes\n", f.ExpectedSize, f.ActualSize) + if f.ModeMismatch { + fmt.Fprintf(b, " Mode: expected %s | actual %s\n", formatModeOctal(diff.EffectiveMode(f.ExpectedMode)), formatMode(f.ActualMode)) + } + } + last := f.LastWriter + if last == "" { + last = "(unknown)" + } + fmt.Fprintf(b, " Last Writer: %s\n", last) + if showDiffs && f.Diff != "" { + fmt.Fprintf(b, "\n Unified diff:\n") + for _, line := range strings.Split(strings.TrimSuffix(f.Diff, "\n"), "\n") { + fmt.Fprintf(b, " %s\n", line) + } + } + if i < len(findings)-1 { + fmt.Fprintf(b, "\n") + } + } +} + +func writeErrorList(b *strings.Builder, findings []scanner.Finding) { + if len(findings) == 0 { + return + } + fmt.Fprintf(b, "\nUnreadable Files:\n") + for i, f := range findings { + fmt.Fprintf(b, "%d. %s\n", i+1, f.Path) + last := f.LastWriter + if last == "" { + last = "(unknown)" + } + fmt.Fprintf(b, " Last Writer: %s\n", last) + fmt.Fprintf(b, " Error: %s\n", f.Error) + if i < len(findings)-1 { + fmt.Fprintf(b, "\n") + } + } +} + +type scanJSON struct { + Node string `json:"node"` + Pool string `json:"pool"` + RenderedMachineConfig string `json:"renderedMachineConfig"` + Configuration string `json:"configuration"` + ConfigurationSource string `json:"configurationSource"` + ScannedFiles int `json:"scannedFiles"` + Matching int `json:"matching"` + Mismatched int `json:"mismatched"` + Missing int `json:"missing"` + Unreadable int `json:"unreadable"` + Status string `json:"status"` + MismatchedFiles []findingJSON `json:"mismatchedFiles"` + MissingFiles []findingJSON `json:"missingFiles"` + UnreadableFiles []findingJSON `json:"unreadableFiles,omitempty"` + MustGatherDir string `json:"mustGatherDir,omitempty"` +} + +type findingJSON struct { + Path string `json:"path"` + ExpectedSize int `json:"expectedSize"` + ActualSize *int `json:"actualSize,omitempty"` + ExpectedMode *int `json:"expectedMode,omitempty"` + ActualMode *int `json:"actualMode,omitempty"` + ModeMismatch bool `json:"modeMismatch,omitempty"` + LastWriter string `json:"lastWriter"` + Diff string `json:"diff,omitempty"` + Error string `json:"error,omitempty"` +} + +func toScanJSON(result *scanner.Result, opts ScanOptions) scanJSON { + out := scanJSON{ + Node: result.Node, + Pool: result.Pool, + RenderedMachineConfig: result.Rendered, + Configuration: result.Origin.Kind, + ConfigurationSource: result.Origin.Source, + ScannedFiles: result.Scanned, + Matching: result.Matching, + Mismatched: result.Mismatched, + Missing: result.Missing, + Unreadable: result.Errors, + Status: result.Status(), + MismatchedFiles: findingJSONList(result.MismatchedFiles, true, opts.ShowDiffs), + MissingFiles: findingJSONList(result.MissingFiles, false, false), + MustGatherDir: opts.MustGather, + } + if out.Configuration == "" { + out.Configuration = "current" + } + if out.ConfigurationSource == "" { + out.ConfigurationSource = "MCP status.configuration" + } + if out.MismatchedFiles == nil { + out.MismatchedFiles = []findingJSON{} + } + if out.MissingFiles == nil { + out.MissingFiles = []findingJSON{} + } + if result.Errors > 0 { + out.UnreadableFiles = findingJSONList(result.ErrorFiles, false, false) + } + return out +} + +func findingJSONList(findings []scanner.Finding, withActual, showDiffs bool) []findingJSON { + if len(findings) == 0 { + return []findingJSON{} + } + out := make([]findingJSON, 0, len(findings)) + for _, f := range findings { + item := findingJSON{ + Path: f.Path, + ExpectedSize: f.ExpectedSize, + ExpectedMode: f.ExpectedMode, + LastWriter: f.LastWriter, + Error: f.Error, + ModeMismatch: f.ModeMismatch, + } + if withActual { + size := f.ActualSize + item.ActualSize = &size + item.ActualMode = f.ActualMode + } + if showDiffs { + item.Diff = f.Diff + } + out = append(out, item) + } + return out +} diff --git a/devex/cmd/mcdiff/internal/report/scan_test.go b/devex/cmd/mcdiff/internal/report/scan_test.go new file mode 100644 index 0000000000..6a88f3d30d --- /dev/null +++ b/devex/cmd/mcdiff/internal/report/scan_test.go @@ -0,0 +1,96 @@ +package report + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/scanner" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWriteScanClean(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := WriteScan(&buf, &scanner.Result{ + Node: "worker-0", + Pool: "worker", + Rendered: "rendered-worker-a1b2c3", + Origin: cluster.ConfigurationOrigin{Kind: cluster.ConfigurationCurrent, Source: "MCP status.configuration"}, + Scanned: 42, + Matching: 42, + }, ScanOptions{}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Node: worker-0") + assert.Contains(t, out, "Pool: worker") + assert.Contains(t, out, "Rendered MC: rendered-worker-a1b2c3") + assert.Contains(t, out, "Scanned Files: 42") + assert.Contains(t, out, "Status: CLEAN") + assert.NotContains(t, out, "Mismatched Files:") +} + +func TestWriteScanDrift(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := WriteScan(&buf, &scanner.Result{ + Node: "worker-0", + Pool: "worker", + Rendered: "rendered-worker-a1b2c3", + Scanned: 42, + Matching: 39, + Mismatched: 2, + Missing: 1, + MismatchedFiles: []scanner.Finding{ + {Path: "/etc/ssh/sshd_config", ExpectedSize: 3667, ActualSize: 3674, LastWriter: "99-worker-ssh", Diff: "-a\n+b\n"}, + {Path: "/etc/containers/registries.conf", ExpectedSize: 1200, ActualSize: 1250, LastWriter: "99-worker-container-registry"}, + }, + MissingFiles: []scanner.Finding{ + {Path: "/etc/motd", ExpectedSize: 12, LastWriter: "00-worker"}, + }, + }, ScanOptions{}) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Status: DRIFT DETECTED (2 files modified, 1 file missing)") + assert.Contains(t, out, "1. /etc/ssh/sshd_config") + assert.Contains(t, out, "Expected: 3667 bytes | Actual: 3674 bytes") + assert.Contains(t, out, "Last Writer: 99-worker-ssh") + assert.Contains(t, out, "1. /etc/motd") + assert.Contains(t, out, "Status: MISSING ON NODE") + assert.Contains(t, out, "Last Writer: 00-worker") + assert.NotContains(t, out, "Unified diff:") +} + +func TestWriteScanJSON(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + err := WriteScan(&buf, &scanner.Result{ + Node: "worker-0", + Pool: "worker", + Rendered: "rendered-worker-a1b2c3", + Scanned: 3, + Matching: 1, + Mismatched: 1, + Missing: 1, + MismatchedFiles: []scanner.Finding{ + {Path: "/etc/ssh/sshd_config", ExpectedSize: 10, ActualSize: 12, LastWriter: "99-worker-ssh", Diff: "secret-diff"}, + }, + MissingFiles: []scanner.Finding{ + {Path: "/etc/motd", ExpectedSize: 5, LastWriter: "00-worker"}, + }, + }, ScanOptions{Format: "json"}) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "drift", got["status"]) + assert.Equal(t, float64(3), got["scannedFiles"]) + item := got["mismatchedFiles"].([]any)[0].(map[string]any) + _, hasDiff := item["diff"] + assert.False(t, hasDiff) +} diff --git a/devex/cmd/mcdiff/internal/scanner/errors.go b/devex/cmd/mcdiff/internal/scanner/errors.go new file mode 100644 index 0000000000..b068608c2b --- /dev/null +++ b/devex/cmd/mcdiff/internal/scanner/errors.go @@ -0,0 +1,12 @@ +package scanner + +import "errors" + +var ( + // ErrNodeUnassigned is returned when the node's labels match no MachineConfigPool. + ErrNodeUnassigned = errors.New("node is not assigned to a machineconfigpool") + // ErrMultipleCustomPools is returned when the node matches more than one custom pool. + ErrMultipleCustomPools = errors.New("node belongs to multiple custom machineconfigpools") + // ErrWindowsNode is returned when the node is Windows and therefore not MCO-managed. + ErrWindowsNode = errors.New("node is a windows node") +) diff --git a/devex/cmd/mcdiff/internal/scanner/pool.go b/devex/cmd/mcdiff/internal/scanner/pool.go new file mode 100644 index 0000000000..2be4846d47 --- /dev/null +++ b/devex/cmd/mcdiff/internal/scanner/pool.go @@ -0,0 +1,87 @@ +package scanner + +import ( + "fmt" + + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/labels" + "k8s.io/klog/v2" +) + +const osLabel = "kubernetes.io/os" + +// ResolvePrimaryPool returns the MachineConfigPool a node targets, using the +// same rules as pkg/helpers.GetPrimaryPoolForNode: custom pool beats worker, +// master beats custom and worker, multiple custom pools are an error. +func ResolvePrimaryPool(n *corev1.Node, pools []*mcfgv1.MachineConfigPool) (*mcfgv1.MachineConfigPool, error) { + if n == nil { + return nil, fmt.Errorf("node is nil") + } + if isWindows(n) { + return nil, fmt.Errorf("node %q is a Windows node and is not managed by the Machine Config Operator; pass --pool to override: %w", n.Name, ErrWindowsNode) + } + + master, worker, custom, err := matchingPools(n, pools) + if err != nil { + return nil, err + } + if master == nil && worker == nil && len(custom) == 0 { + return nil, fmt.Errorf("node %q is not assigned to a MachineConfigPool; pass --pool: %w", n.Name, ErrNodeUnassigned) + } + + switch { + case len(custom) > 1: + return nil, fmt.Errorf("node %q belongs to %d custom MachineConfigPools; pass --pool to select one: %w", n.Name, len(custom), ErrMultipleCustomPools) + case len(custom) == 1: + if master != nil { + klog.V(2).Infof("node %s matches master and custom pool %s; defaulting to master", n.Name, custom[0].Name) + return master, nil + } + return custom[0], nil + case master != nil: + return master, nil + default: + return worker, nil + } +} + +func matchingPools(n *corev1.Node, pools []*mcfgv1.MachineConfigPool) (*mcfgv1.MachineConfigPool, *mcfgv1.MachineConfigPool, []*mcfgv1.MachineConfigPool, error) { + var matched []*mcfgv1.MachineConfigPool + for _, p := range pools { + if p == nil { + continue + } + selector, err := metav1.LabelSelectorAsSelector(p.Spec.NodeSelector) + if err != nil { + return nil, nil, nil, fmt.Errorf("invalid node selector on MachineConfigPool %s: %w", p.Name, err) + } + if selector.Empty() || !selector.Matches(labels.Set(n.Labels)) { + continue + } + matched = append(matched, p) + } + + var master, worker *mcfgv1.MachineConfigPool + var custom []*mcfgv1.MachineConfigPool + for _, pool := range matched { + switch pool.Name { + case ctrlcommon.MachineConfigPoolMaster: + master = pool + case ctrlcommon.MachineConfigPoolWorker: + worker = pool + default: + custom = append(custom, pool) + } + } + return master, worker, custom, nil +} + +func isWindows(n *corev1.Node) bool { + if value, ok := n.Labels[osLabel]; ok { + return value == "windows" + } + return false +} diff --git a/devex/cmd/mcdiff/internal/scanner/pool_test.go b/devex/cmd/mcdiff/internal/scanner/pool_test.go new file mode 100644 index 0000000000..0d61a54da3 --- /dev/null +++ b/devex/cmd/mcdiff/internal/scanner/pool_test.go @@ -0,0 +1,105 @@ +package scanner + +import ( + "testing" + + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestResolvePrimaryPoolWorker(t *testing.T) { + t.Parallel() + + worker := poolWithSelector("worker", map[string]string{"node-role.kubernetes.io/worker": ""}) + n := labeledNode("worker-0", map[string]string{"node-role.kubernetes.io/worker": ""}) + + got, err := ResolvePrimaryPool(n, []*mcfgv1.MachineConfigPool{worker}) + require.NoError(t, err) + assert.Equal(t, "worker", got.Name) +} + +func TestResolvePrimaryPoolCustomBeatsWorker(t *testing.T) { + t.Parallel() + + worker := poolWithSelector("worker", map[string]string{"node-role.kubernetes.io/worker": ""}) + infra := poolWithSelector("infra", map[string]string{"node-role.kubernetes.io/infra": ""}) + n := labeledNode("infra-0", map[string]string{ + "node-role.kubernetes.io/worker": "", + "node-role.kubernetes.io/infra": "", + }) + + got, err := ResolvePrimaryPool(n, []*mcfgv1.MachineConfigPool{worker, infra}) + require.NoError(t, err) + assert.Equal(t, "infra", got.Name) +} + +func TestResolvePrimaryPoolMasterBeatsWorker(t *testing.T) { + t.Parallel() + + master := poolWithSelector("master", map[string]string{"node-role.kubernetes.io/master": ""}) + worker := poolWithSelector("worker", map[string]string{"node-role.kubernetes.io/worker": ""}) + n := labeledNode("master-0", map[string]string{ + "node-role.kubernetes.io/master": "", + "node-role.kubernetes.io/worker": "", + }) + + got, err := ResolvePrimaryPool(n, []*mcfgv1.MachineConfigPool{master, worker}) + require.NoError(t, err) + assert.Equal(t, "master", got.Name) +} + +func TestResolvePrimaryPoolMultipleCustom(t *testing.T) { + t.Parallel() + + infra := poolWithSelector("infra", map[string]string{"node-role.kubernetes.io/infra": ""}) + edge := poolWithSelector("edge", map[string]string{"node-role.kubernetes.io/edge": ""}) + n := labeledNode("custom-0", map[string]string{ + "node-role.kubernetes.io/infra": "", + "node-role.kubernetes.io/edge": "", + }) + + _, err := ResolvePrimaryPool(n, []*mcfgv1.MachineConfigPool{infra, edge}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrMultipleCustomPools) +} + +func TestResolvePrimaryPoolUnassigned(t *testing.T) { + t.Parallel() + + worker := poolWithSelector("worker", map[string]string{"node-role.kubernetes.io/worker": ""}) + n := labeledNode("other-0", map[string]string{"foo": "bar"}) + + _, err := ResolvePrimaryPool(n, []*mcfgv1.MachineConfigPool{worker}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNodeUnassigned) +} + +func TestResolvePrimaryPoolWindows(t *testing.T) { + t.Parallel() + + worker := poolWithSelector("worker", map[string]string{"node-role.kubernetes.io/worker": ""}) + n := labeledNode("win-0", map[string]string{ + "node-role.kubernetes.io/worker": "", + "kubernetes.io/os": "windows", + }) + + _, err := ResolvePrimaryPool(n, []*mcfgv1.MachineConfigPool{worker}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrWindowsNode) +} + +func poolWithSelector(name string, matchLabels map[string]string) *mcfgv1.MachineConfigPool { + return &mcfgv1.MachineConfigPool{ + ObjectMeta: metav1.ObjectMeta{Name: name}, + Spec: mcfgv1.MachineConfigPoolSpec{ + NodeSelector: &metav1.LabelSelector{MatchLabels: matchLabels}, + }, + } +} + +func labeledNode(name string, labels map[string]string) *corev1.Node { + return &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: name, Labels: labels}} +} diff --git a/devex/cmd/mcdiff/internal/scanner/scan.go b/devex/cmd/mcdiff/internal/scanner/scan.go new file mode 100644 index 0000000000..9af7710788 --- /dev/null +++ b/devex/cmd/mcdiff/internal/scanner/scan.go @@ -0,0 +1,218 @@ +package scanner + +import ( + "context" + "errors" + "fmt" + "sort" + + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/attribution" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/diff" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/ignition" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/node" +) + +// Options control pool selection for a whole-node scan. +type Options struct { + // Pool overrides automatic pool detection from the node's labels. + Pool string +} + +// Result is the aggregate of comparing every Ignition file in the rendered +// MachineConfig against the node's on-disk copy. +type Result struct { + Node string + Pool string + Rendered string + Origin cluster.ConfigurationOrigin + + Scanned int + Matching int + Mismatched int + Missing int + Errors int + + MismatchedFiles []Finding + MissingFiles []Finding + ErrorFiles []Finding +} + +// Status is "clean" when every managed file matches, "drift" when any file +// mismatches or is missing, and "error" when the only problems are unreadable files. +func (r *Result) Status() string { + if r == nil { + return "clean" + } + if r.Mismatched > 0 || r.Missing > 0 { + return "drift" + } + if r.Errors > 0 { + return "error" + } + return "clean" +} + +// Finding is one managed path that did not match the rendered MachineConfig. +type Finding struct { + Path string + ExpectedSize int + ActualSize int + ExpectedMode *int + ActualMode *int + ModeMismatch bool + LastWriter string + Diff string + Error string +} + +// Scan enumerates every file in the node's rendered MachineConfig and compares +// each against the on-disk copy from reader. +func Scan(ctx context.Context, g cluster.Getter, nodes node.Getter, reader node.Reader, nodeName string, opts Options) (*Result, error) { + if g == nil { + return nil, fmt.Errorf("getter is nil") + } + if reader == nil { + return nil, fmt.Errorf("node reader is not configured") + } + if nodeName == "" { + return nil, fmt.Errorf("node name must not be empty") + } + + poolName, err := resolvePoolName(ctx, g, nodes, nodeName, opts.Pool) + if err != nil { + return nil, err + } + + rp, err := cluster.LoadRenderedPool(ctx, g, poolName) + if err != nil { + return nil, err + } + + files, err := ignition.ExtractAll(rp.Rendered) + if err != nil { + return nil, err + } + sort.Slice(files, func(i, j int) bool { return files[i].Path < files[j].Path }) + + out := &Result{ + Node: nodeName, + Pool: poolName, + Rendered: rp.Rendered.Name, + Origin: rp.Origin, + Scanned: len(files), + } + + for _, f := range files { + if err := ctx.Err(); err != nil { + return nil, err + } + kind, finding, err := scanFile(ctx, rp, reader, nodeName, f) + if err != nil { + return nil, err + } + switch kind { + case findingMatch: + out.Matching++ + case findingMismatch: + out.Mismatched++ + out.MismatchedFiles = append(out.MismatchedFiles, finding) + case findingMissing: + out.Missing++ + out.MissingFiles = append(out.MissingFiles, finding) + case findingError: + out.Errors++ + out.ErrorFiles = append(out.ErrorFiles, finding) + } + } + return out, nil +} + +type findingKind int + +const ( + findingMatch findingKind = iota + findingMismatch + findingMissing + findingError +) + +func scanFile(ctx context.Context, rp *cluster.RenderedPool, reader node.Reader, nodeName string, f ignition.File) (findingKind, Finding, error) { + lastWriter := lastWriterFor(rp, f.Path) + + if f.Err != nil { + return findingError, Finding{Path: f.Path, LastWriter: lastWriter, Error: f.Err.Error()}, nil + } + + actual, actualMode, err := reader.ReadFile(ctx, nodeName, f.Path) + if err != nil { + if errors.Is(err, node.ErrFileNotFound) { + return findingMissing, Finding{ + Path: f.Path, + ExpectedSize: len(f.Contents), + ExpectedMode: copyMode(f.Mode), + LastWriter: lastWriter, + }, nil + } + if errors.Is(err, node.ErrNodeNotFound) || errors.Is(err, node.ErrMCDUnavailable) { + return 0, Finding{}, fmt.Errorf("failed to read files from node %q: %w", nodeName, err) + } + return findingError, Finding{Path: f.Path, LastWriter: lastWriter, Error: err.Error()}, nil + } + + cmp := diff.WithModes(diff.Compare(f.Contents, actual, f.Path, "node:"+nodeName), f.Mode, actualMode) + if cmp.Match && cmp.ModeMatch { + return findingMatch, Finding{}, nil + } + return findingMismatch, Finding{ + Path: f.Path, + ExpectedSize: cmp.ExpectedSize, + ActualSize: cmp.ActualSize, + ExpectedMode: cmp.ExpectedMode, + ActualMode: cmp.ActualMode, + ModeMismatch: !cmp.ModeMatch, + LastWriter: lastWriter, + Diff: cmp.UnifiedDiff, + }, nil +} + +func copyMode(mode *int) *int { + if mode == nil { + return nil + } + copied := *mode + return &copied +} + +func lastWriterFor(rp *cluster.RenderedPool, path string) string { + if rp == nil || rp.AttributionErr != nil { + return "" + } + attr, err := attribution.Attribute(path, rp.Sources) + if err != nil || attr == nil || attr.LastWriter == nil { + return "" + } + return attr.LastWriter.MachineConfigName +} + +func resolvePoolName(ctx context.Context, g cluster.Getter, nodes node.Getter, nodeName, poolOverride string) (string, error) { + if poolOverride != "" { + return poolOverride, nil + } + if nodes == nil { + return "", fmt.Errorf("cannot detect MachineConfigPool for node %q; pass --pool", nodeName) + } + n, err := nodes.GetNode(ctx, nodeName) + if err != nil { + return "", err + } + pools, err := g.ListMachineConfigPools(ctx) + if err != nil { + return "", fmt.Errorf("failed to list MachineConfigPools: %w", err) + } + pool, err := ResolvePrimaryPool(n, pools) + if err != nil { + return "", err + } + return pool.Name, nil +} diff --git a/devex/cmd/mcdiff/internal/scanner/scan_test.go b/devex/cmd/mcdiff/internal/scanner/scan_test.go new file mode 100644 index 0000000000..3494167aaf --- /dev/null +++ b/devex/cmd/mcdiff/internal/scanner/scan_test.go @@ -0,0 +1,301 @@ +package scanner + +import ( + "context" + "encoding/json" + "fmt" + "testing" + + ign3types "github.com/coreos/ignition/v2/config/v3_5/types" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/client-go/machineconfiguration/clientset/versioned/fake" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/node" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +const ( + sshdPath = "/etc/ssh/sshd_config" + regPath = "/etc/containers/registries.conf" + motdPath = "/etc/motd" + chronyPath = "/etc/chrony.conf" + hostsPath = "/etc/hosts" + renderedMC = "rendered-worker-abc" +) + +func TestScanAllMatch(t *testing.T) { + t.Parallel() + + files := []pathContent{ + {sshdPath, "sshd\n"}, + {regPath, "registries\n"}, + {motdPath, "motd\n"}, + {chronyPath, "chrony\n"}, + {hostsPath, "hosts\n"}, + } + g, reader := setupScan(t, files, map[string][]byte{ + sshdPath: []byte("sshd\n"), + regPath: []byte("registries\n"), + motdPath: []byte("motd\n"), + chronyPath: []byte("chrony\n"), + hostsPath: []byte("hosts\n"), + }) + + got, err := Scan(context.Background(), g, nil, reader, "worker-0", Options{Pool: "worker"}) + require.NoError(t, err) + assert.Equal(t, 5, got.Scanned) + assert.Equal(t, 5, got.Matching) + assert.Equal(t, 0, got.Mismatched) + assert.Equal(t, 0, got.Missing) + assert.Equal(t, "clean", got.Status()) + assert.Equal(t, "worker", got.Pool) + assert.Equal(t, renderedMC, got.Rendered) + assert.Empty(t, got.MismatchedFiles) + assert.Empty(t, got.MissingFiles) +} + +func TestScanTwoOfFiveMismatch(t *testing.T) { + t.Parallel() + + files := []pathContent{ + {sshdPath, "sshd-expected\n"}, + {regPath, "reg-expected\n"}, + {motdPath, "motd\n"}, + {chronyPath, "chrony\n"}, + {hostsPath, "hosts\n"}, + } + g, reader := setupScan(t, files, map[string][]byte{ + sshdPath: []byte("sshd-actual-longer\n"), + regPath: []byte("reg-actual-xx\n"), + motdPath: []byte("motd\n"), + chronyPath: []byte("chrony\n"), + hostsPath: []byte("hosts\n"), + }) + + got, err := Scan(context.Background(), g, nil, reader, "worker-0", Options{Pool: "worker"}) + require.NoError(t, err) + assert.Equal(t, 5, got.Scanned) + assert.Equal(t, 3, got.Matching) + assert.Equal(t, 2, got.Mismatched) + assert.Equal(t, 0, got.Missing) + assert.Equal(t, "drift", got.Status()) + + require.Len(t, got.MismatchedFiles, 2) + assert.Equal(t, regPath, got.MismatchedFiles[0].Path) + assert.Equal(t, sshdPath, got.MismatchedFiles[1].Path) + assert.Equal(t, "99-worker-ssh", got.MismatchedFiles[1].LastWriter) + assert.NotEmpty(t, got.MismatchedFiles[0].Diff) + assert.NotEqual(t, got.MismatchedFiles[0].ExpectedSize, got.MismatchedFiles[0].ActualSize) +} + +func TestScanMissingFile(t *testing.T) { + t.Parallel() + + files := []pathContent{ + {sshdPath, "sshd\n"}, + {motdPath, "hello\n"}, + } + g, reader := setupScan(t, files, map[string][]byte{ + sshdPath: []byte("sshd\n"), + }) + + got, err := Scan(context.Background(), g, nil, reader, "worker-0", Options{Pool: "worker"}) + require.NoError(t, err) + assert.Equal(t, 2, got.Scanned) + assert.Equal(t, 1, got.Matching) + assert.Equal(t, 0, got.Mismatched) + assert.Equal(t, 1, got.Missing) + assert.Equal(t, "drift", got.Status()) + require.Len(t, got.MissingFiles, 1) + assert.Equal(t, motdPath, got.MissingFiles[0].Path) + assert.Equal(t, "00-worker", got.MissingFiles[0].LastWriter) + assert.Equal(t, len("hello\n"), got.MissingFiles[0].ExpectedSize) +} + +func TestScanModeMismatch(t *testing.T) { + t.Parallel() + + files := []pathContent{{chronyPath, "pool 2.rhel.pool.ntp.org iburst\n"}} + g, reader := setupScan(t, files, map[string][]byte{ + chronyPath: []byte("pool 2.rhel.pool.ntp.org iburst\n"), + }) + reader.modes = map[string]int{chronyPath: 0o755} + + got, err := Scan(context.Background(), g, nil, reader, "worker-0", Options{Pool: "worker"}) + require.NoError(t, err) + assert.Equal(t, 1, got.Scanned) + assert.Equal(t, 0, got.Matching) + assert.Equal(t, 1, got.Mismatched) + assert.Equal(t, "drift", got.Status()) + require.Len(t, got.MismatchedFiles, 1) + assert.True(t, got.MismatchedFiles[0].ModeMismatch) + require.NotNil(t, got.MismatchedFiles[0].ExpectedMode) + assert.Equal(t, 0o644, *got.MismatchedFiles[0].ExpectedMode) + require.NotNil(t, got.MismatchedFiles[0].ActualMode) + assert.Equal(t, 0o755, *got.MismatchedFiles[0].ActualMode) + assert.Empty(t, got.MismatchedFiles[0].Diff) +} + +func TestScanDetectsPoolFromNodeLabels(t *testing.T) { + t.Parallel() + + files := []pathContent{{sshdPath, "sshd\n"}} + g, reader := setupScan(t, files, map[string][]byte{sshdPath: []byte("sshd\n")}) + + n := &corev1.Node{ObjectMeta: metav1.ObjectMeta{ + Name: "worker-0", + Labels: map[string]string{"node-role.kubernetes.io/worker": ""}, + }} + got, err := Scan(context.Background(), g, staticNode{n: n}, reader, "worker-0", Options{}) + require.NoError(t, err) + assert.Equal(t, "worker", got.Pool) + assert.Equal(t, 1, got.Matching) +} + +func TestScanRequiresPoolWhenUnassigned(t *testing.T) { + t.Parallel() + + files := []pathContent{{sshdPath, "sshd\n"}} + g, reader := setupScan(t, files, map[string][]byte{sshdPath: []byte("sshd\n")}) + + n := &corev1.Node{ObjectMeta: metav1.ObjectMeta{Name: "worker-0", Labels: map[string]string{"foo": "bar"}}} + _, err := Scan(context.Background(), g, staticNode{n: n}, reader, "worker-0", Options{}) + require.Error(t, err) + assert.ErrorIs(t, err, ErrNodeUnassigned) +} + +func TestScanAbortsWhenNodeMissing(t *testing.T) { + t.Parallel() + + files := []pathContent{{sshdPath, "sshd\n"}} + g, _ := setupScan(t, files, nil) + reader := &mapReader{err: fmt.Errorf("gone: %w", node.ErrNodeNotFound)} + + _, err := Scan(context.Background(), g, nil, reader, "worker-0", Options{Pool: "worker"}) + require.Error(t, err) + assert.ErrorIs(t, err, node.ErrNodeNotFound) +} + +type pathContent struct { + path string + contents string +} + +func setupScan(t *testing.T, files []pathContent, onDisk map[string][]byte) (cluster.Getter, *mapReader) { + t.Helper() + rendered := mcWithFiles(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, files...) + sources := sourceConfigs(t, files) + pool := mcpWithSources(t, "worker", renderedMC, sourceNames(sources)...) + pool.Spec.NodeSelector = &metav1.LabelSelector{ + MatchLabels: map[string]string{"node-role.kubernetes.io/worker": ""}, + } + + objs := []runtime.Object{pool, rendered} + for _, s := range sources { + objs = append(objs, s) + } + return cluster.NewKubeGetter(fake.NewSimpleClientset(objs...)), &mapReader{files: onDisk} +} + +func sourceConfigs(t *testing.T, files []pathContent) []*mcfgv1.MachineConfig { + t.Helper() + var sources []*mcfgv1.MachineConfig + byWriter := map[string][]pathContent{} + for _, f := range files { + writer := "00-worker" + switch f.path { + case sshdPath: + writer = "99-worker-ssh" + case regPath: + writer = "99-worker-container-registry" + } + byWriter[writer] = append(byWriter[writer], f) + } + for name, list := range byWriter { + sources = append(sources, mcWithFiles(t, name, ctrlcommon.MachineConfigPoolWorker, list...)) + } + return sources +} + +func sourceNames(sources []*mcfgv1.MachineConfig) []string { + names := make([]string, 0, len(sources)) + for _, s := range sources { + names = append(names, s.Name) + } + return names +} + +func mcWithFiles(t *testing.T, name, role string, files ...pathContent) *mcfgv1.MachineConfig { + t.Helper() + ignFiles := make([]ign3types.File, 0, len(files)) + for _, f := range files { + ignFiles = append(ignFiles, ctrlcommon.NewIgnFileBytes(f.path, []byte(f.contents))) + } + ign := ign3types.Config{ + Ignition: ign3types.Ignition{Version: ign3types.MaxVersion.String()}, + Storage: ign3types.Storage{Files: ignFiles}, + } + raw, err := json.Marshal(ign) + require.NoError(t, err) + return &mcfgv1.MachineConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ctrlcommon.MachineConfigRoleLabel: role}, + }, + Spec: mcfgv1.MachineConfigSpec{Config: runtime.RawExtension{Raw: raw}}, + } +} + +func mcpWithSources(t *testing.T, poolName, renderedName string, sourceNames ...string) *mcfgv1.MachineConfigPool { + t.Helper() + refs := make([]corev1.ObjectReference, 0, len(sourceNames)) + for _, name := range sourceNames { + refs = append(refs, corev1.ObjectReference{Kind: "MachineConfig", Name: name}) + } + cfg := mcfgv1.MachineConfigPoolStatusConfiguration{ + ObjectReference: corev1.ObjectReference{Name: renderedName}, + Source: refs, + } + return &mcfgv1.MachineConfigPool{ + ObjectMeta: metav1.ObjectMeta{Name: poolName}, + Spec: mcfgv1.MachineConfigPoolSpec{Configuration: cfg}, + Status: mcfgv1.MachineConfigPoolStatus{Configuration: cfg}, + } +} + +type mapReader struct { + files map[string][]byte + modes map[string]int + err error +} + +func (m *mapReader) ReadFile(_ context.Context, nodeName, path string) ([]byte, *int, error) { + if m.err != nil { + return nil, nil, m.err + } + b, ok := m.files[path] + if !ok { + return nil, nil, fmt.Errorf("file %q is missing on node %q: %w", path, nodeName, node.ErrFileNotFound) + } + if m.modes != nil { + if mode, ok := m.modes[path]; ok { + copied := mode + return b, &copied, nil + } + } + return b, nil, nil +} + +type staticNode struct { + n *corev1.Node + err error +} + +func (s staticNode) GetNode(context.Context, string) (*corev1.Node, error) { + return s.n, s.err +} diff --git a/devex/cmd/mcdiff/main.go b/devex/cmd/mcdiff/main.go index 03119b25bc..21cf3c60d2 100644 --- a/devex/cmd/mcdiff/main.go +++ b/devex/cmd/mcdiff/main.go @@ -11,8 +11,23 @@ import ( var ( rootCmd = &cobra.Command{ Use: "mcdiff", - Short: "Diffs MachineConfigs", - Long: "", + Short: "Explains MachineConfig files: expected content, last writer, and diffs", + Long: `mcdiff inspects files managed by the Machine Config Operator. + +The file subcommand answers what the rendered MachineConfig says a path should +contain, which MachineConfig last wrote it, and optionally how that differs +from a local file, a live node, or a must-gather archive. + +The node subcommand scans every file in a node's rendered MachineConfig against +the host filesystem, for the case where a node is degraded and the drifted +path is unknown. + +The diff subcommand diffs two MachineConfig objects with dyff. + +Shell completions: + source <(mcdiff completion bash) + source <(mcdiff completion zsh) + mcdiff completion fish | source`, } ) @@ -21,5 +36,6 @@ func init() { } func main() { + rootCmd.InitDefaultCompletionCmd() os.Exit(cli.Run(rootCmd)) } diff --git a/devex/cmd/mcdiff/main_test.go b/devex/cmd/mcdiff/main_test.go new file mode 100644 index 0000000000..788e693529 --- /dev/null +++ b/devex/cmd/mcdiff/main_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "bytes" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMain(m *testing.M) { + rootCmd.InitDefaultCompletionCmd() + os.Exit(m.Run()) +} + +func TestCompletionCommandRegistered(t *testing.T) { + names := map[string]bool{} + for _, c := range rootCmd.Commands() { + names[c.Name()] = true + } + assert.True(t, names["completion"], "expected cobra completion command") + assert.True(t, names["file"]) + assert.True(t, names["diff"]) + assert.True(t, names["node"]) +} + +func TestCompletionBashAndZsh(t *testing.T) { + var bash bytes.Buffer + require.NoError(t, rootCmd.GenBashCompletion(&bash)) + assert.Contains(t, bash.String(), "mcdiff") + + var zsh bytes.Buffer + require.NoError(t, rootCmd.GenZshCompletion(&zsh)) + assert.Contains(t, zsh.String(), "mcdiff") +} diff --git a/devex/cmd/mcdiff/node.go b/devex/cmd/mcdiff/node.go new file mode 100644 index 0000000000..1afbbbaf4f --- /dev/null +++ b/devex/cmd/mcdiff/node.go @@ -0,0 +1,192 @@ +package main + +import ( + "context" + "fmt" + "io" + "os" + + mcfgclientset "github.com/openshift/client-go/machineconfiguration/clientset/versioned" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/mustgather" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/node" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/report" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/scanner" + "github.com/spf13/cobra" + "k8s.io/cli-runtime/pkg/genericclioptions" + "k8s.io/client-go/kubernetes" +) + +type nodeOptions struct { + pool string + showDiffs bool + output string + mustGather string + configFlags *genericclioptions.ConfigFlags + // getter, if set, is used instead of building a kube client. Tests inject this. + getter cluster.Getter + // nodeReader, if set, is used instead of a live MCD exec reader. Tests inject this. + nodeReader node.Reader + // nodeGetter, if set, is used instead of a live Node Get. Tests inject this. + nodeGetter node.Getter + out io.Writer +} + +func newNodeCommand() *cobra.Command { + o := &nodeOptions{ + configFlags: genericclioptions.NewConfigFlags(true), + out: os.Stdout, + } + cmd := &cobra.Command{ + Use: "node NODE", + Short: "Scan every file in a node's rendered MachineConfig against the host filesystem", + Long: `Scan all Ignition files managed by a node's rendered MachineConfig. + +This answers which files drifted when a node is degraded and the mismatched +path is unknown. Each managed path is compared against the on-disk copy on the +node (via the machine-config-daemon pod host rootfs, equivalent to +oc debug node/ -- cat /host/). + +The node's MachineConfigPool is detected from node labels the same way the +Machine Config Operator assigns pools. Pass --pool when the node is unassigned +or matches more than one custom pool. + +With --must-gather, read MachineConfigs and optional node snapshots from an +unpacked oc adm must-gather directory instead of a live cluster. No kubeconfig +is required. Standard must-gather archives do not snapshot the entire host +/etc tree; files without a snapshot are reported as missing. + +A missing host file is reported as MISSING ON NODE and does not fail the scan +(the same "could not stat file" case from MachineConfigDaemon degraded events). +Mode drift is reported alongside content and size deltas. + +By default the report is a summary with size deltas. Pass --show-diffs to +include a unified diff for every mismatched file. + +Exit 0 means the scan completed, including CLEAN, DRIFT DETECTED, MISSING ON NODE, +and unreadable files. Non-zero means the tool could not resolve the pool, could +not read the rendered MachineConfig, or could not reach the node.`, + Example: ` # Scan a live node (pool detected from node labels) + mcdiff node worker-0 + + # Override pool detection + mcdiff node worker-0 --pool worker + + # Include unified diffs for mismatched files + mcdiff node worker-0 --show-diffs + + # Offline whole-node scan from a must-gather + mcdiff node worker-0 --must-gather ./must-gather.local --pool worker + + # JSON summary + mcdiff node worker-0 -o json`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + SilenceErrors: true, + RunE: func(cmd *cobra.Command, args []string) error { + if o.out == nil || o.out == os.Stdout { + o.out = cmd.OutOrStdout() + } + return o.run(cmd.Context(), args[0]) + }, + } + cmd.Flags().StringVar(&o.pool, "pool", "", "MachineConfigPool name (detected from node labels when omitted)") + cmd.Flags().BoolVar(&o.showDiffs, "show-diffs", false, "Include unified diffs for every mismatched file") + cmd.Flags().StringVar(&o.mustGather, "must-gather", "", "Unpacked must-gather directory (offline; skips kubeconfig)") + cmd.Flags().StringVarP(&o.output, "output", "o", "text", "Output format: text or json") + _ = cmd.MarkFlagDirname("must-gather") + o.configFlags.AddFlags(cmd.Flags()) + return cmd +} + +func (o *nodeOptions) run(ctx context.Context, nodeName string) error { + g := o.getter + nr := o.nodeReader + ng := o.nodeGetter + if o.mustGather != "" { + mg, err := mustgather.Open(o.mustGather) + if err != nil { + return err + } + if g == nil { + g = mg.Getter() + } + if nr == nil { + nr = mg.NodeReader() + } + if ng == nil { + ng = mg + } + } else if g == nil || nr == nil || (o.pool == "" && ng == nil) { + clients, err := liveClientsFromFlags(o.configFlags) + if err != nil { + return err + } + if g == nil { + g = clients.getter + } + if nr == nil { + nr = clients.reader + } + if ng == nil { + ng = clients.nodes + } + } + return runNode(ctx, g, ng, nr, nodeScanArgs{ + node: nodeName, + pool: o.pool, + output: o.output, + showDiffs: o.showDiffs, + mustGather: o.mustGather, + }, o.out) +} + +type liveClients struct { + getter cluster.Getter + reader node.Reader + nodes node.Getter +} + +func liveClientsFromFlags(flags *genericclioptions.ConfigFlags) (*liveClients, error) { + restConfig, err := flags.ToRESTConfig() + if err != nil { + return nil, fmt.Errorf("failed to load kubeconfig: %w", err) + } + mcfg, err := mcfgclientset.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create machineconfiguration client: %w", err) + } + kube, err := kubernetes.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create kubernetes client: %w", err) + } + return &liveClients{ + getter: cluster.NewKubeGetter(mcfg), + reader: node.NewKubeReader(kube, restConfig), + nodes: node.NewKubeNodeGetter(kube), + }, nil +} + +type nodeScanArgs struct { + node string + pool string + output string + showDiffs bool + mustGather string +} + +func runNode(ctx context.Context, g cluster.Getter, nodes node.Getter, reader node.Reader, args nodeScanArgs, w io.Writer) error { + result, err := scanner.Scan(ctx, g, nodes, reader, args.node, scanner.Options{Pool: args.pool}) + if err != nil { + return err + } + return report.WriteScan(w, result, report.ScanOptions{ + Format: args.output, + ShowDiffs: args.showDiffs, + MustGather: args.mustGather, + }) +} + +func init() { + rootCmd.AddCommand(newNodeCommand()) +} diff --git a/devex/cmd/mcdiff/node_test.go b/devex/cmd/mcdiff/node_test.go new file mode 100644 index 0000000000..fde195b12e --- /dev/null +++ b/devex/cmd/mcdiff/node_test.go @@ -0,0 +1,246 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "testing" + + ign3types "github.com/coreos/ignition/v2/config/v3_5/types" + mcfgv1 "github.com/openshift/api/machineconfiguration/v1" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/cluster" + "github.com/openshift/machine-config-operator/devex/cmd/mcdiff/internal/node" + ctrlcommon "github.com/openshift/machine-config-operator/pkg/controller/common" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" +) + +func TestRunNodeAllMatch(t *testing.T) { + t.Parallel() + + g, reader := nodeScanFixture(t, map[string]string{ + sshdPath: "sshd\n", + motdPath: "hello\n", + }, map[string][]byte{ + sshdPath: []byte("sshd\n"), + motdPath: []byte("hello\n"), + }) + + var buf bytes.Buffer + err := runNode(context.Background(), g, nil, reader, nodeScanArgs{node: "worker-0", pool: "worker", output: "text"}, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Node: worker-0") + assert.Contains(t, out, "Pool: worker") + assert.Contains(t, out, "Rendered MC: rendered-worker-abc") + assert.Contains(t, out, "Scanned Files: 2") + assert.Contains(t, out, "Status: CLEAN") + assert.NotContains(t, out, "Mismatched Files:") + assert.NotContains(t, out, "sshd\n") +} + +func TestRunNodeMismatchesAndMissing(t *testing.T) { + t.Parallel() + + g, reader := nodeScanFixture(t, map[string]string{ + sshdPath: "PermitRootLogin no\n", + regPath: "unqualified-search-registries = []\n", + motdPath: "hello\n", + }, map[string][]byte{ + sshdPath: []byte("PermitRootLogin yes\n"), + regPath: []byte("unqualified-search-registries = ['example.com']\n"), + }) + + var buf bytes.Buffer + err := runNode(context.Background(), g, nil, reader, nodeScanArgs{node: "worker-0", pool: "worker", output: "text"}, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Status: DRIFT DETECTED (2 files modified, 1 file missing)") + assert.Contains(t, out, "Mismatched Files:") + assert.Contains(t, out, "/etc/ssh/sshd_config") + assert.Contains(t, out, "/etc/containers/registries.conf") + assert.Contains(t, out, "Last Writer: 99-worker-ssh") + assert.Contains(t, out, "Missing Files:") + assert.Contains(t, out, "/etc/motd") + assert.NotContains(t, out, "Unified diff:") + assert.NotContains(t, out, "PermitRootLogin yes") +} + +func TestRunNodeShowDiffs(t *testing.T) { + t.Parallel() + + g, reader := nodeScanFixture(t, map[string]string{sshdPath: "PermitRootLogin no\n"}, map[string][]byte{sshdPath: []byte("PermitRootLogin yes\n")}) + var buf bytes.Buffer + err := runNode(context.Background(), g, nil, reader, nodeScanArgs{node: "worker-0", pool: "worker", output: "text", showDiffs: true}, &buf) + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Unified diff:") + assert.Contains(t, out, "-PermitRootLogin no") + assert.Contains(t, out, "+PermitRootLogin yes") +} + +func TestRunNodeJSON(t *testing.T) { + t.Parallel() + + g, reader := nodeScanFixture(t, map[string]string{ + sshdPath: "sshd-expected\n", + motdPath: "hello\n", + }, map[string][]byte{ + sshdPath: []byte("sshd-actual-xx\n"), + }) + + var buf bytes.Buffer + err := runNode(context.Background(), g, nil, reader, nodeScanArgs{node: "worker-0", pool: "worker", output: "json"}, &buf) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + assert.Equal(t, "worker-0", got["node"]) + assert.Equal(t, "worker", got["pool"]) + assert.Equal(t, renderedMC, got["renderedMachineConfig"]) + assert.Equal(t, float64(2), got["scannedFiles"]) + assert.Equal(t, float64(0), got["matching"]) + assert.Equal(t, float64(1), got["mismatched"]) + assert.Equal(t, float64(1), got["missing"]) + assert.Equal(t, "drift", got["status"]) + assert.Equal(t, "current", got["configuration"]) + + mismatched, ok := got["mismatchedFiles"].([]any) + require.True(t, ok) + require.Len(t, mismatched, 1) + item := mismatched[0].(map[string]any) + assert.Equal(t, sshdPath, item["path"]) + assert.Equal(t, "99-worker-ssh", item["lastWriter"]) + assert.NotNil(t, item["actualSize"]) + _, hasDiff := item["diff"] + assert.False(t, hasDiff, "unified diffs are omitted from JSON unless --show-diffs") + + missing, ok := got["missingFiles"].([]any) + require.True(t, ok) + require.Len(t, missing, 1) + miss := missing[0].(map[string]any) + assert.Equal(t, motdPath, miss["path"]) + assert.Equal(t, "00-worker", miss["lastWriter"]) + _, hasActual := miss["actualSize"] + assert.False(t, hasActual) +} + +func TestRunNodeJSONShowDiffs(t *testing.T) { + t.Parallel() + + g, reader := nodeScanFixture(t, map[string]string{sshdPath: "no\n"}, map[string][]byte{sshdPath: []byte("yes\n")}) + var buf bytes.Buffer + err := runNode(context.Background(), g, nil, reader, nodeScanArgs{node: "worker-0", pool: "worker", output: "json", showDiffs: true}, &buf) + require.NoError(t, err) + + var got map[string]any + require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) + item := got["mismatchedFiles"].([]any)[0].(map[string]any) + diffStr, ok := item["diff"].(string) + require.True(t, ok) + assert.Contains(t, diffStr, "-no") + assert.Contains(t, diffStr, "+yes") +} + +func TestRunNodeMustGather(t *testing.T) { + t.Parallel() + + dir := writeMustGatherFixture(t, "PermitRootLogin no\n", map[string]string{"worker-0": "PermitRootLogin yes\n"}) + var buf bytes.Buffer + o := &nodeOptions{pool: "worker", mustGather: dir, output: "text", out: &buf} + err := o.run(context.Background(), "worker-0") + require.NoError(t, err) + out := buf.String() + assert.Contains(t, out, "Archive: Must-Gather Archive ("+dir+")") + assert.Contains(t, out, "DRIFT DETECTED") + assert.Contains(t, out, sshdPath) +} + +func TestNodeCommandHelp(t *testing.T) { + t.Parallel() + + cmd := newNodeCommand() + var buf bytes.Buffer + cmd.SetOut(&buf) + cmd.SetErr(&buf) + cmd.SetArgs([]string{"--help"}) + require.NoError(t, cmd.Execute()) + out := buf.String() + assert.Contains(t, out, "mcdiff node worker-0") + assert.Contains(t, out, "--pool") + assert.Contains(t, out, "--show-diffs") + assert.Contains(t, out, "--must-gather") + assert.Contains(t, out, "-o json") +} + +const ( + motdPath = "/etc/motd" + regPath = "/etc/containers/registries.conf" +) + +func nodeScanFixture(t *testing.T, expected map[string]string, actual map[string][]byte) (cluster.Getter, node.Reader) { + t.Helper() + var files []ignFile + for path, contents := range expected { + files = append(files, ignFile{path, contents}) + } + rendered := mcWithFiles(t, renderedMC, ctrlcommon.MachineConfigPoolWorker, files...) + var sources []runtime.Object + var sourceNames []string + for path, contents := range expected { + name := "00-worker" + switch path { + case sshdPath: + name = "99-worker-ssh" + case regPath: + name = "99-worker-container-registry" + } + sources = append(sources, mcWithFile(t, name, ctrlcommon.MachineConfigPoolWorker, path, contents)) + sourceNames = append(sourceNames, name) + } + pool := mcpWithSources(t, "worker", renderedMC, sourceNames...) + objs := []runtime.Object{pool, rendered} + objs = append(objs, sources...) + return newFakeGetter(t, objs...), &pathReader{files: actual} +} + +type ignFile struct { + path string + contents string +} + +func mcWithFiles(t *testing.T, name, role string, files ...ignFile) *mcfgv1.MachineConfig { + t.Helper() + ignFiles := make([]ign3types.File, 0, len(files)) + for _, f := range files { + ignFiles = append(ignFiles, ctrlcommon.NewIgnFileBytes(f.path, []byte(f.contents))) + } + ign := ign3types.Config{ + Ignition: ign3types.Ignition{Version: ign3types.MaxVersion.String()}, + Storage: ign3types.Storage{Files: ignFiles}, + } + raw, err := json.Marshal(ign) + require.NoError(t, err) + return &mcfgv1.MachineConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Labels: map[string]string{ctrlcommon.MachineConfigRoleLabel: role}, + }, + Spec: mcfgv1.MachineConfigSpec{Config: runtime.RawExtension{Raw: raw}}, + } +} + +type pathReader struct { + files map[string][]byte +} + +func (p *pathReader) ReadFile(_ context.Context, nodeName, path string) ([]byte, *int, error) { + b, ok := p.files[path] + if !ok { + return nil, nil, fmt.Errorf("file %q is missing on node %q: %w", path, nodeName, node.ErrFileNotFound) + } + return b, nil, nil +} diff --git a/go.mod b/go.mod index 050bd22b1b..fbb8494e17 100644 --- a/go.mod +++ b/go.mod @@ -46,6 +46,7 @@ require ( github.com/openshift/imagebuilder v1.2.21 github.com/openshift/library-go v0.0.0-20260720123941-85336565c3c7 github.com/openshift/runtime-utils v0.0.0-20230921210328-7bdb5b9c177b + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 github.com/prometheus/client_golang v1.23.2 github.com/rs/zerolog v1.34.0 github.com/spf13/cobra v1.10.2 @@ -61,6 +62,7 @@ require ( k8s.io/api v0.36.2 k8s.io/apiextensions-apiserver v0.36.2 k8s.io/apimachinery v0.36.2 + k8s.io/cli-runtime v0.36.2 k8s.io/client-go v0.36.2 k8s.io/code-generator v0.36.2 k8s.io/component-base v0.36.2 @@ -232,7 +234,6 @@ require ( gopkg.in/DATA-DOG/go-sqlmock.v1 v1.3.0 // indirect gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - k8s.io/cli-runtime v0.36.2 // indirect k8s.io/cloud-provider v0.0.0 // indirect k8s.io/component-helpers v0.36.2 // indirect k8s.io/controller-manager v0.32.1 // indirect @@ -361,7 +362,6 @@ require ( github.com/opencontainers/runtime-spec v1.3.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect github.com/pkg/errors v0.9.1 - github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/polyfloyd/go-errorlint v1.7.0 // indirect github.com/proglottis/gpgme v0.1.4 // indirect github.com/prometheus/client_model v0.6.2 // indirect