From c6bfc38fcf865fcd040ad05dcd27e9b679f142a9 Mon Sep 17 00:00:00 2001 From: Adam Wolfe Gordon Date: Thu, 16 Apr 2026 16:33:44 -0600 Subject: [PATCH 01/23] Create initial CLI repository structure This commit adds build infrastructure for the standalone CLI repository, with a stub cmd/crossplane. We'll add the existing crank code from crossplane in a subsequent commit. Signed-off-by: Adam Wolfe Gordon --- .gitignore | 47 +++++++ .golangci.yml | 295 +++++++++++++++++++++++++++++++++++++++++ cmd/crossplane/main.go | 23 ++++ flake.lock | 100 ++++++++++++++ flake.nix | 207 +++++++++++++++++++++++++++++ generate.go | 24 ++++ go.mod | 3 + gomod2nix.toml | 3 + nix.sh | 124 +++++++++++++++++ nix/apps.nix | 195 +++++++++++++++++++++++++++ nix/build.nix | 143 ++++++++++++++++++++ nix/checks.nix | 158 ++++++++++++++++++++++ 12 files changed, 1322 insertions(+) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 cmd/crossplane/main.go create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 generate.go create mode 100644 go.mod create mode 100644 gomod2nix.toml create mode 100755 nix.sh create mode 100644 nix/apps.nix create mode 100644 nix/build.nix create mode 100644 nix/checks.nix diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ebb4d21 --- /dev/null +++ b/.gitignore @@ -0,0 +1,47 @@ +/.cache +/.work +/.hack +/_output +/config/ +/config +cover.out +/vendor +/.vendor-new +.DS_Store +Zone.Identifier +.tmp-earthly-out/ +build/ + +# Nix build output symlinks +result +result-* + +# Exclude the ko data dir +kodata/ + +# gitlab example +# exclude files generate by running the example +external-dns-*.tgz +gitlab-*.tgz +gitlab-gcp.yaml +gitlab/ + +# ignore vscode files (debug config etc...) +/.vscode +/.idea +/.devcontainer + +# Crossplane packages +*.xpkg + +# go build output (from go build ./cmd/crossplane etc) +/crossplane + +# ignore AI tools settings/config +/.claude +/.kiro +CLAUDE.md +AGENTS.md + +# Ignore local dev environment variables +.env diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..b6b6868 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,295 @@ +version: "2" + +output: + formats: + text: + path: stderr + +linters: + default: all + disable: + # These are linters we'd like to enable, but that will be labor intensive to + # make existing code compliant. + - wrapcheck + - varnamelen + - testpackage + - paralleltest + - nilnil + - funcorder + + # Below are linters that lint for things we don't value. Each entry below + # this line must have a comment explaining the rationale. + + # These linters add whitespace in an attempt to make code more readable. + # This isn't a widely accepted Go best practice, and would be laborious to + # apply to existing code. + - wsl + - wsl_v5 + - nlreturn + + # Warns about uses of fmt.Sprintf that are less performant than alternatives + # such as string concatenation. We value readability more than performance + # unless performance is measured to be an issue. + - perfsprint + + # This linter: + # + # 1. Requires errors.Is/errors.As to test equality. + # 2. Requires all errors be wrapped with fmt.Errorf specifically. + # 3. Disallows errors.New inline - requires package level errors. + # + # 1 is covered by other linters. 2 is covered by wrapcheck, which can also + # handle our use of crossplane-runtime's errors package. 3 is more strict + # than we need. Not every error needs to be tested for equality. + - err113 + + # These linters duplicate gocognit, but calculate complexity differently. + - gocyclo + - cyclop + - nestif + - funlen + - maintidx + + # Enforces max line length. It's not idiomatic to enforce a strict limit on + # line length in Go. We'd prefer to lint for things that often cause long + # lines, like functions with too many parameters or long parameter names + # that duplicate their types. + - lll + + # Warns about struct instantiations that don't specify every field. Could be + # useful in theory to catch fields that are accidentally omitted. Seems like + # it would have many more false positives than useful catches, though. + - exhaustruct + + # Warns about TODO comments. The rationale being they should be issues + # instead. We're okay with using TODO to track minor cleanups for next time + # we touch a particular file. + - godox + + # Warns about duplicated code blocks within the same file. Could be useful + # to prompt folks to think about whether code should be broken out into a + # function, but generally we're less worried about DRY and fine with a + # little copying. We don't want to give folks the impression that we require + # every duplicated code block to be factored out into a function. + - dupl + + # Warns about returning interfaces rather than concrete types. We do think + # it's best to avoid returning interfaces where possible. However, at the + # time of writing enabling this linter would only catch the (many) cases + # where we must return an interface. + - ireturn + + # Warns about returning named variables. We do think it's best to avoid + # returning named variables where possible. However, at the time of writing + # enabling this linter would only catch the (many) cases where returning + # named variables is useful to document what the variables are. For example + # we believe it makes sense to return (ready bool) rather than just (bool) + # to communicate what the bool means. + - nonamedreturns + + # Warns about using magic numbers. We do think it's best to avoid magic + # numbers, but we should not be strict about it. + - mnd + + # Warns about if err := Foo(); err != nil style error checks. Seems to go + # against idiomatic Go programming, which encourages this approach - e.g. + # to scope errors. + - noinlineerr + settings: + depguard: + rules: + no_third_party_test_libraries: + list-mode: lax + files: + - $test + deny: + - pkg: github.com/stretchr/testify + desc: See https://go.dev/wiki/TestComments#assert-libraries + - pkg: github.com/onsi/ginkgo + desc: See https://go.dev/wiki/TestComments#assert-libraries + - pkg: github.com/onsi/gomega + desc: See https://go.dev/wiki/TestComments#assert-libraries + dupl: + threshold: 100 + errcheck: + check-type-assertions: false + check-blank: false + goconst: + min-len: 3 + min-occurrences: 5 + gocritic: + enabled-tags: + - performance + settings: + captLocal: + paramsOnly: true + rangeValCopy: + sizeThreshold: 32 + gomoddirectives: + # Allow replace for the local ./apis module. + replace-local: true + govet: + disable: + - shadow + interfacebloat: + max: 5 + lll: + tab-width: 1 + nakedret: + max-func-lines: 30 + nolintlint: + require-explanation: true + require-specific: true + prealloc: + simple: true + range-loops: true + for-loops: false + tagliatelle: + case: + rules: + json: goCamel + unparam: + check-exported: false + unused: + exported-fields-are-used: true + exclusions: + generated: lax + rules: + - linters: + - containedctx + - errcheck + - forcetypeassert + - gochecknoglobals + - gochecknoinits + - gocognit + - gosec + - noctx + - scopelint + - unparam + - embeddedstructfieldcheck + path: _test(ing)?\.go + + - linters: + - gocritic + path: _test\.go + text: (unnamedResult|exitAfterDefer) + + # It's idiomatic to register Kubernetes types with a package scoped + # SchemeBuilder using an init function. + - linters: + - gochecknoglobals + - gochecknoinits + path: apis/ + + # The omitzero check warns that omitempty has no effect on struct fields + # in Go's encoding/json, which is true. However, kubebuilder uses + # omitempty to determine whether fields are optional in CRD schemas. + # Removing omitempty would incorrectly mark fields like status as required. + - linters: + - modernize + text: 'omitzero:' + + # These are performance optimisations rather than style issues per se. + # They warn when function arguments or range values copy a lot of memory + # rather than using a pointer. + - linters: + - gocritic + text: '(hugeParam|rangeValCopy):' + + # This "TestMain should call os.Exit to set exit code" warning is not clever + # enough to notice that we call a helper method that calls os.Exit. + - linters: + - staticcheck + text: 'SA3000:' + + # This is a "potential hardcoded credentials" warning. It's triggered by + # any variable with 'secret' in the same, and thus hits a lot of false + # positives in Kubernetes land where a Secret is an object type. + - linters: + - gosec + text: 'G101:' + + # This is an 'errors unhandled' warning that duplicates errcheck. + - linters: + - gosec + text: 'G104:' + + # This is about implicit memory aliasing in a range loop. + # This is a false positive with Go v1.22 and above. + - linters: + - gosec + text: 'G601:' + + # Some k8s dependencies do not have JSON tags on all fields in structs. + - linters: + - musttag + path: k8s.io/ + + # Various fields related to native patch and transform Composition are + # deprecated, but we can't drop support from Crossplane 1.x. We ignore the + # warnings globally instead of suppressing them with comments everywhere. + - linters: + - staticcheck + text: 'SA1019: .+ is deprecated: Use Composition Functions instead.' + + # Various fields related to package dependencies are deprecated in favor + # of specifying apiVersion and kind explicitly. External package authors + # should use the new format, but Crossplane must support the old format + # for backward compatibility. + - linters: + - staticcheck + text: 'SA1019: .+ is deprecated: Specify an apiVersion' + + # controller-runtime has marked the events API we use as deprecated, and + # it will be a big lift to move to the new API. We'll ignore the warnings + # about this globally until we have time to introduce the new API + # everywhere. + - linters: + - staticcheck + text: 'SA1019: .+ is deprecated: this uses the old events API and will be removed in a future release. Please use GetEventRecorder instead.' + + # We include a 'common' package under crank, which revive doesn't like. + - linters: + - revive + path: 'cmd/crank/common/*' + text: 'var-naming: avoid meaningless package names' + + paths: + - zz_generated\..+\.go$ + - .+\.pb.go$ + - third_party$ + - builtin$ + - examples$ + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 + new: false + +formatters: + enable: + - gci + - gofmt + - gofumpt + - goimports + settings: + gci: + sections: + - standard + - default + - prefix(github.com/crossplane/crossplane-runtime) + - prefix(github.com/crossplane/crossplane) + - prefix(github.com/crossplane/cli) + - blank + - dot + custom-order: true + gofmt: + simplify: true + exclusions: + generated: lax + paths: + - zz_generated\..+\.go$ + - .+\.pb.go$ + - third_party$ + - builtin$ + - examples$ diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go new file mode 100644 index 0000000..f21a7ef --- /dev/null +++ b/cmd/crossplane/main.go @@ -0,0 +1,23 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import "fmt" + +func main() { + fmt.Println("this is a stub.") +} diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..283dbd0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,100 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "gomod2nix": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1770313987, + "narHash": "sha256-81QP51jUEgp2sOkGEXZ2TDsfbls98/A9aQTu7PAyP30=", + "owner": "nix-community", + "repo": "gomod2nix", + "rev": "75c2866d585a75a1b30c634dbd7c2dcce5a6c3a7", + "type": "github" + }, + "original": { + "owner": "nix-community", + "repo": "gomod2nix", + "rev": "75c2866d585a75a1b30c634dbd7c2dcce5a6c3a7", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1776221942, + "narHash": "sha256-FbQAeVNi7G4v3QCSThrSAAvzQTmrmyDLiHNPvTF2qFM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "1766437c5509f444c1b15331e82b8b6a9b967000", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-25.11", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-unstable": { + "locked": { + "lastModified": 1776255774, + "narHash": "sha256-psVTpH6PK3q1htMJpmdz1hLF5pQgEshu7gQWgKO6t6Y=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "566acc07c54dc807f91625bb286cb9b321b5f42a", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "gomod2nix": "gomod2nix", + "nixpkgs": "nixpkgs", + "nixpkgs-unstable": "nixpkgs-unstable" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..7b28be7 --- /dev/null +++ b/flake.nix @@ -0,0 +1,207 @@ +# New to Nix? Start here: +# Language basics: https://nix.dev/tutorials/nix-language +# Flakes intro: https://zero-to-nix.com/concepts/flakes +{ + description = "Crossplane CLI"; + + inputs = { + nixpkgs.url = "github:NixOS/nixpkgs/nixos-25.11"; + nixpkgs-unstable.url = "github:NixOS/nixpkgs/nixpkgs-unstable"; + + # TODO(negz): Unpin once https://github.com/nix-community/gomod2nix/pull/231 is released. + gomod2nix = { + url = "github:nix-community/gomod2nix/75c2866d585a75a1b30c634dbd7c2dcce5a6c3a7"; + inputs.nixpkgs.follows = "nixpkgs"; + }; + }; + + outputs = + { + self, + nixpkgs, + nixpkgs-unstable, + gomod2nix, + }: + let + # Set by CI to override the auto-generated dev version. + buildVersion = null; + + # Platforms we build Go binaries for. + goPlatforms = [ + { + os = "linux"; + arch = "amd64"; + } + { + os = "linux"; + arch = "arm64"; + } + { + os = "linux"; + arch = "arm"; + } + { + os = "linux"; + arch = "ppc64le"; + } + { + os = "darwin"; + arch = "arm64"; + } + { + os = "darwin"; + arch = "amd64"; + } + { + os = "windows"; + arch = "amd64"; + } + ]; + + # Systems where Nix runs (dev machines, CI). + supportedSystems = [ + "x86_64-linux" + "aarch64-linux" + "x86_64-darwin" + "aarch64-darwin" + ]; + + # Semantic version for binaries. Uses buildVersion if set by CI, otherwise + # generates a dev version from git metadata. (self ? shortRev tests if + # the attribute exists - clean commits have shortRev, uncommitted changes + # have dirtyShortRev.) + version = + if buildVersion != null then + buildVersion + else if self ? shortRev then + "v0.0.0-${builtins.toString self.lastModified}-${self.shortRev}" + else + "v0.0.0-${builtins.toString self.lastModified}-${self.dirtyShortRev}"; + + # Helpers for per-system outputs. + forAllSystems = f: nixpkgs.lib.genAttrs supportedSystems (system: forSystem system f); + forSystem = + system: f: + f { + inherit system; + pkgs = import nixpkgs { + inherit system; + overlays = [ + gomod2nix.overlays.default + (_final: _prev: { + # We use the go toolchain from unstable because it's been + # updated to fix some CVEs. However, we explicitly use this only + # in our build targets rather than replace Go in the global + # overlay so that we can still use pre-built binaries for + # Go-based tools from nixpkgs. + go-unstable = nixpkgs-unstable.legacyPackages.${system}.go_1_25; + }) + ]; + }; + }; + + in + { + # Build outputs (nix build). + packages = forAllSystems ( + { pkgs, ... }: + let + build = import ./nix/build.nix { inherit pkgs self; }; + in + { + default = build.release { + inherit + version + goPlatforms + ; + }; + } + ); + + # CI checks (nix flake check). + checks = forAllSystems ( + { pkgs, ... }: + let + checks = import ./nix/checks.nix { inherit pkgs self; }; + in + { + test = checks.test { inherit version; }; + generate = checks.generate { inherit version; }; + go-lint = checks.goLint { inherit version; }; + shell-lint = checks.shellLint { }; + nix-lint = checks.nixLint { }; + } + ); + + # Development commands (nix run .#). + apps = forAllSystems ( + { pkgs, ... }: + let + build = import ./nix/build.nix { inherit pkgs self; }; + apps = import ./nix/apps.nix { inherit pkgs; }; + in + { + test = apps.test { }; + lint = apps.lint { }; + generate = apps.generate { }; + tidy = apps.tidy { }; + push-artifacts = apps.pushArtifacts { + inherit version; + release = build.release { + inherit version goPlatforms; + }; + }; + promote-artifacts = apps.promoteArtifacts { }; + } + ); + + # Development shell (nix develop). + devShells = forAllSystems ( + { pkgs, ... }: + { + default = pkgs.mkShell { + buildInputs = [ + pkgs.coreutils + pkgs.gnused + pkgs.ncurses + pkgs.go-unstable + pkgs.golangci-lint + pkgs.kubectl + pkgs.kind + pkgs.docker-client + pkgs.gotestsum + pkgs.awscli2 + pkgs.gomod2nix + + # Code generation + pkgs.buf + pkgs.goverter + pkgs.protoc-gen-go + pkgs.protoc-gen-go-grpc + pkgs.kubernetes-controller-tools + + # Nix + pkgs.nixfmt-rfc-style + ]; + + shellHook = '' + export PS1='\[\033[38;2;243;128;123m\][cros\[\033[38;2;255;205;60m\]spla\[\033[38;2;53;208;186m\]ne]\[\033[0m\] \w \$ ' + + source <(kubectl completion bash 2>/dev/null) + source <(kind completion bash 2>/dev/null) + + alias k=kubectl + + echo "Crossplane development shell ($(go version | cut -d' ' -f3))" + echo "" + echo " nix run .#test nix run .#generate" + echo " nix run .#lint nix run .#tidy" + echo "" + echo " nix build nix flake check" + echo "" + ''; + }; + } + ); + }; +} diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..0ae502d --- /dev/null +++ b/generate.go @@ -0,0 +1,24 @@ +//go:build generate +// +build generate + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generation tools (controller-gen, goverter, buf, etc.) must be in your +// $PATH. Use './nix.sh develop' or './nix.sh run .#generate' to ensure they +// are. + +package generate diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..4d7692c --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/crossplane/cli/v2 + +go 1.25.9 diff --git a/gomod2nix.toml b/gomod2nix.toml new file mode 100644 index 0000000..43cd4cf --- /dev/null +++ b/gomod2nix.toml @@ -0,0 +1,3 @@ +schema = 3 + +[mod] diff --git a/nix.sh b/nix.sh new file mode 100755 index 0000000..af43fad --- /dev/null +++ b/nix.sh @@ -0,0 +1,124 @@ +#!/usr/bin/env bash +# nix.sh - Run Nix commands via Docker without installing Nix locally. +# +# Usage: ./nix.sh +# +# Run './nix.sh flake show' for available apps and packages, or see flake.nix. +# Examples: ./nix.sh run .#test, ./nix.sh build, ./nix.sh develop +# +# The first run downloads dependencies into /nix/store (cached in a Docker +# volume). Subsequent runs reuse the cache. To reset: docker volume rm crossplane-cli-nix + +set -e + +# When NIX_SH_CONTAINER is set, we're running inside the Docker container. +# This script re-executes itself inside the container to avoid sh -c quoting. + +if [ "${NIX_SH_CONTAINER:-}" = "1" ]; then + # Install tools this entrypoint script needs. It needs Docker to setup + # Docker in Docker for E2E tests, and rsync to copy build the build result + # (cp doesn't work well on MacOS volumes). Installed packages persist across + # runs thanks to the crossplane-cli-nix volume. + if ! command -v docker &>/dev/null || ! command -v rsync &>/dev/null; then + nix-env -iA nixpkgs.docker nixpkgs.rsync + fi + + # Start the Docker daemon, storing its data in the crossplane-cli-nix volume + # for persistence across container runs. This gives us a consistent Docker + # environment with cached images (e.g., kind node images). + dockerd --data-root=/nix/docker >/tmp/dockerd.log 2>&1 & + + attempts=0 + until docker info >/dev/null 2>&1; do + sleep 1 + attempts=$((attempts + 1)) + if [ ${attempts} -gt 30 ]; then + echo "Docker failed to start. Logs:" + cat /tmp/dockerd.log + exit 1 + fi + done + + # The container runs as root, but the bind-mounted /crossplane-cli is owned + # by the host user. Git refuses to operate in directories owned by other + # users. + git config --global --add safe.directory /crossplane-cli + + # Record the current time. After nix runs, we'll find files newer than this + # marker and chown them to the host user. + marker=$(mktemp) + + # If result (i.e. the build output) is a directory, remove it so nix build can + # create its symlink. We only remove directories, not symlinks (which might be + # from a host Nix install). + if [ -d result ] && [ ! -L result ]; then + rm -rf result + fi + + nix "${@}" + + # Nix build makes result/ a symlink to a directory in the Nix store. That + # directory only exists inside the container, but it creates the symlink in + # /crossplane-cli, which is shared with the host. We use this rsync trick to + # make result/ a directory of regular files. + if [ -L result ] && readlink result | grep -q '^/nix/store/' && [ -e result ]; then + rsync -rL --chmod=u+w result/ result.tmp + rm result + mv result.tmp result + fi + + # Fix ownership of any files nix created or modified. The container runs as + # root, so without this, generated files would be root-owned on the host. + # Using -newer is surgical - we only chown files touched during this run. + find /crossplane-cli -newer "${marker}" -exec chown "${HOST_UID}:${HOST_GID}" {} + 2>/dev/null || true + rm -f "${marker}" + + exit 0 +fi + +# When running on the host, launch a Docker container and re-execute this +# script inside it. + +# Nix configuration, equivalent to /etc/nix/nix.conf. +NIX_CONFIG=" +# Flakes are Nix's modern project format - a flake.nix file plus a flake.lock +# that pins all dependencies. This is still marked 'experimental' but is stable +# and widely used. +experimental-features = nix-command flakes + +# Build multiple derivations in parallel. A derivation is Nix's build unit, +# like a Makefile target. 'auto' uses one job per CPU core. +max-jobs = auto + +# Sandbox builds to prevent access to undeclared dependencies. Requires --privileged. +sandbox = true + +# Cachix is a binary cache service. Our GitHub Actions CI pushes there, so if CI +# has recently built the commit you're on Nix will download stuff instead of +# rebuilding it locally. +extra-substituters = https://crossplane-cli.cachix.org +extra-trusted-public-keys = crossplane-cli.cachix.org-1:NJluVUN9TX0rY/zAxHYaT19Y5ik4ELH4uFuxje+62d4= +" + +# Only allocate a TTY if stdout is a terminal. TTY mode corrupts binary +# output. The -i flag keeps stdin open for interactive commands like 'nix +# develop'. +INTERACTIVE_FLAGS="" +if [ -t 1 ]; then + INTERACTIVE_FLAGS="-it" +fi + +# Run with --privileged for Docker-in-Docker (required for kind clusters). +docker run --rm --privileged --cgroupns=host ${INTERACTIVE_FLAGS} \ + -v "$(pwd):/crossplane-cli" \ + -v "crossplane-cli-nix:/nix" \ + -w /crossplane-cli \ + -e "NIX_SH_CONTAINER=1" \ + -e "NIX_CONFIG=${NIX_CONFIG}" \ + -e "GOMODCACHE=/nix/go-mod-cache" \ + -e "GOCACHE=/nix/go-build-cache" \ + -e "HOST_UID=$(id -u)" \ + -e "HOST_GID=$(id -g)" \ + -e "TERM=${TERM:-xterm}" \ + nixos/nix \ + /crossplane-cli/nix.sh "${@}" diff --git a/nix/apps.nix b/nix/apps.nix new file mode 100644 index 0000000..43e307d --- /dev/null +++ b/nix/apps.nix @@ -0,0 +1,195 @@ +# Interactive development commands for Crossplane CLI. +# +# Apps run outside the Nix sandbox with full filesystem and network access. +# They're designed for local development where Go modules are already available. +# +# All apps are builder functions that take an attrset of arguments and return a +# complete app definition ({ type, meta.description, program }). Most use +# writeShellApplication to create the program. The text block is preprocessed: +# +# ${somePkg}/bin/foo -> /nix/store/.../bin/foo (Nix store path) +# ''${SOME_VAR} -> ${SOME_VAR} (shell variable, escaped) +# +# Each app declares its tool dependencies via runtimeInputs, with inheritPath +# set to false. This ensures apps only use explicitly declared tools. +{ pkgs }: +{ + # Run Go unit tests. + test = _: { + type = "app"; + meta.description = "Run unit tests"; + program = pkgs.lib.getExe ( + pkgs.writeShellApplication { + name = "crossplane-cli-test"; + runtimeInputs = [ pkgs.go-unstable ]; + inheritPath = false; + text = '' + export CGO_ENABLED=0 + go test -covermode=count ./... "$@" + ''; + } + ); + }; + + # Run linters with auto-fix. Formats code first, then reports remaining issues. + lint = _: { + type = "app"; + meta.description = "Format code and run linters"; + program = pkgs.lib.getExe ( + pkgs.writeShellApplication { + name = "crossplane-cli-lint"; + runtimeInputs = [ + pkgs.findutils + pkgs.go-unstable + pkgs.golangci-lint + pkgs.statix + pkgs.deadnix + pkgs.nixfmt-rfc-style + pkgs.shellcheck + pkgs.gnupatch + pkgs.shfmt + ]; + inheritPath = false; + text = '' + export CGO_ENABLED=0 + export GOLANGCI_LINT_CACHE="''${XDG_CACHE_HOME:-$HOME/.cache}/golangci-lint" + + echo "Formatting and linting Nix..." + statix fix . + deadnix --edit flake.nix nix/*.nix + nixfmt flake.nix nix/*.nix + + echo "Formatting and linting shell..." + find . -name '*.sh' -type f | while read -r script; do + shellcheck --format=diff "$script" | patch -p1 || true + shfmt -w "$script" + done + find . -name '*.sh' -type f -exec shellcheck {} + + + echo "Formatting and linting Go..." + golangci-lint run --fix "$@" + ''; + } + ); + }; + + # Run code generation. + generate = _: { + type = "app"; + meta.description = "Run code generation"; + program = pkgs.lib.getExe ( + pkgs.writeShellApplication { + name = "crossplane-cli-generate"; + runtimeInputs = [ + pkgs.coreutils + pkgs.gnused + pkgs.go-unstable + + # Code generation + pkgs.buf + pkgs.goverter + pkgs.protoc-gen-go + pkgs.protoc-gen-go-grpc + pkgs.kubernetes-controller-tools + ]; + inheritPath = false; + text = '' + export CGO_ENABLED=0 + + echo "Running go generate..." + go generate -tags generate . + + echo "Done" + ''; + } + ); + }; + + # Run go mod tidy and regenerate gomod2nix.toml. + tidy = _: { + type = "app"; + meta.description = "Run go mod tidy and regenerate gomod2nix.toml"; + program = pkgs.lib.getExe ( + pkgs.writeShellApplication { + name = "crossplane-cli-tidy"; + runtimeInputs = [ + pkgs.go-unstable + pkgs.gomod2nix + ]; + inheritPath = false; + text = '' + export CGO_ENABLED=0 + + echo "Running go mod tidy..." + go mod tidy + echo "Regenerating gomod2nix.toml..." + gomod2nix generate --with-deps + + echo "Done" + ''; + } + ); + }; + + # Push build artifacts to S3. + pushArtifacts = + { release, version }: + { + type = "app"; + meta.description = "Push build artifacts to S3"; + program = pkgs.lib.getExe ( + pkgs.writeShellApplication { + name = "crossplane-cli-push-artifacts"; + runtimeInputs = [ pkgs.awscli2 ]; + inheritPath = false; + text = '' + BRANCH="''${1:?Usage: nix run .#push-artifacts -- }" + + echo "Pushing artifacts to s3://crossplane-cli-releases/build/''${BRANCH}/${version}..." + aws s3 sync --delete --only-show-errors \ + ${release} \ + "s3://crossplane-cli-releases/build/''${BRANCH}/${version}" + echo "Done" + ''; + } + ); + }; + + # Promote build artifacts to a release channel. + promoteArtifacts = _: { + type = "app"; + meta.description = "Promote build artifacts to a release channel"; + program = pkgs.lib.getExe ( + pkgs.writeShellApplication { + name = "crossplane-cli-promote-artifacts"; + runtimeInputs = [ + pkgs.coreutils + pkgs.awscli2 + ]; + inheritPath = false; + text = '' + BRANCH="''${1:?Usage: nix run .#promote-artifacts -- [--prerelease]}" + VERSION="''${2:?Usage: nix run .#promote-artifacts -- [--prerelease]}" + CHANNEL="''${3:?Usage: nix run .#promote-artifacts -- [--prerelease]}" + PRERELEASE="''${4:-}" + + BUILD_PATH="s3://crossplane-cli-releases/build/''${BRANCH}/''${VERSION}" + CHANNEL_PATH="s3://crossplane-cli-releases/''${CHANNEL}" + + WORKDIR=$(mktemp -d) + trap 'rm -rf "$WORKDIR"' EXIT + + echo "Promoting artifacts from ''${BUILD_PATH} to ''${CHANNEL}..." + + aws s3 sync --delete --only-show-errors "''${BUILD_PATH}" "''${CHANNEL_PATH}/''${VERSION}" + + if [ "''${PRERELEASE}" != "--prerelease" ]; then + aws s3 sync --delete --only-show-errors "''${BUILD_PATH}" "''${CHANNEL_PATH}/current" + fi + + echo "Done" + ''; + } + ); + }; +} diff --git a/nix/build.nix b/nix/build.nix new file mode 100644 index 0000000..0e6c9e9 --- /dev/null +++ b/nix/build.nix @@ -0,0 +1,143 @@ +# Build functions for Crossplane CLI. +# +# All functions are builders that take an attrset of arguments. +# This makes dependencies explicit and keeps flake.nix as a clean manifest. +# +# Key primitives used here: +# pkgs.buildGoApplication - gomod2nix's Go builder (https://github.com/nix-community/gomod2nix) +# pkgs.runCommand - Run a shell script, capture output directory as $out +{ pkgs, self }: +let + # Build a Go binary for a specific platform. + goBinary = + { + version, + pname, + subPackage, + platform, + }: + let + ext = if platform.os == "windows" then ".exe" else ""; + in + pkgs.buildGoApplication { + pname = "${pname}-${platform.os}-${platform.arch}"; + inherit version; + src = self; + pwd = self; + modules = "${self}/gomod2nix.toml"; + subPackages = [ subPackage ]; + + # Cross-compile by merging GOOS/GOARCH into Go's attrset (// merges attrsets). + go = pkgs.go-unstable // { + GOOS = platform.os; + GOARCH = platform.arch; + }; + + CGO_ENABLED = "0"; + doCheck = false; + + preBuild = '' + ldflags="-s -w -X=github.com/crossplane/crossplane-runtime/v2/pkg/version.version=${version}" + ''; + + postInstall = '' + if [ -d $out/bin/${platform.os}_${platform.arch} ]; then + mv $out/bin/${platform.os}_${platform.arch}/* $out/bin/ + rmdir $out/bin/${platform.os}_${platform.arch} + fi + cd $out/bin + sha256sum ${pname}${ext} | head -c 64 > ${pname}${ext}.sha256 + ''; + + meta = { + description = "Crossplane - The cloud native control plane framework"; + homepage = "https://crossplane.io"; + license = pkgs.lib.licenses.asl20; + mainProgram = pname; + }; + }; + + # Build tarball with checksums. + bundle = + { + version, + drv, + platform, + }: + let + ext = if platform.os == "windows" then ".exe" else ""; + in + pkgs.runCommand "crossplane-bundle-${platform.os}-${platform.arch}-${version}" + { + nativeBuildInputs = [ + pkgs.gnutar + pkgs.gzip + ]; + } + '' + mkdir -p $out + cp ${drv}/bin/crossplane${ext} . + cp ${drv}/bin/crossplane${ext}.sha256 . + chmod 755 crossplane${ext} + chmod 644 crossplane${ext}.sha256 + tar -czvf $out/crossplane-cli.tar.gz crossplane${ext} crossplane${ext}.sha256 + cd $out + sha256sum crossplane-cli.tar.gz | head -c 64 > crossplane-cli.tar.gz.sha256 + ''; + +in +{ + # Full release package with all artifacts. + release = + { + version, + goPlatforms, + }: + let + + bins = builtins.listToAttrs ( + map (p: { + name = "${p.os}-${p.arch}"; + value = goBinary { + inherit version; + pname = "crossplane"; + subPackage = "cmd/crossplane"; + platform = p; + }; + }) goPlatforms + ); + + bundles = builtins.listToAttrs ( + map (p: { + name = "${p.os}-${p.arch}"; + value = bundle { + inherit version; + drv = bins."${p.os}-${p.arch}"; + platform = p; + }; + }) goPlatforms + ); + in + pkgs.runCommand "crossplane-release-${version}" { } '' + mkdir -p $out/bin $out/bundle + + ${pkgs.lib.concatMapStrings (p: '' + mkdir -p $out/bin/${p.os}_${p.arch} + cp ${bins."${p.os}-${p.arch}"}/bin/* $out/bin/${p.os}_${p.arch}/ + ${ + let + ext = if p.os == "windows" then ".exe" else ""; + in + '' + chmod 755 $out/bin/${p.os}_${p.arch}/crossplane${ext} + chmod 644 $out/bin/${p.os}_${p.arch}/crossplane${ext}.sha256 + '' + } + '') goPlatforms} + + ${pkgs.lib.concatMapStrings (p: '' + mkdir -p $out/bundle/${p.os}_${p.arch} + cp ${bundles."${p.os}-${p.arch}"}/* $out/bundle/${p.os}_${p.arch}/ + '') goPlatforms} + ''; +} diff --git a/nix/checks.nix b/nix/checks.nix new file mode 100644 index 0000000..2a4f09e --- /dev/null +++ b/nix/checks.nix @@ -0,0 +1,158 @@ +# CI check builders for Crossplane CLI. +# +# Checks run inside the Nix sandbox without network or filesystem access. This +# makes them fully reproducible but means Go modules must come from gomod2nix. +# +# Most checks use buildGoApplication, which sets up the Go environment with +# modules from gomod2nix.toml. This is different from apps, which run outside +# the sandbox and can access Go modules normally. +# +# All checks are builder functions that take an attrset of arguments and return +# a derivation. The actual check definitions live in flake.nix. +{ pkgs, self }: +{ + # Run Go unit tests with coverage + test = + { version }: + pkgs.buildGoApplication { + pname = "crossplane-cli-test"; + inherit version; + src = self; + pwd = self; + modules = ../gomod2nix.toml; + go = pkgs.go-unstable; + + CGO_ENABLED = "0"; + + dontBuild = true; + + checkPhase = '' + runHook preCheck + export HOME=$TMPDIR + go test -covermode=count -coverprofile=coverage.txt ./... + runHook postCheck + ''; + + installPhase = '' + mkdir -p $out + cp coverage.txt $out/ + ''; + }; + + # Run golangci-lint (without --fix, since source is read-only) + goLint = + { version }: + pkgs.buildGoApplication { + pname = "crossplane-cli-go-lint"; + inherit version; + src = self; + pwd = self; + modules = ../gomod2nix.toml; + go = pkgs.go-unstable; + + CGO_ENABLED = "0"; + + nativeBuildInputs = [ pkgs.golangci-lint ]; + + dontBuild = true; + + checkPhase = '' + runHook preCheck + export HOME=$TMPDIR + export GOLANGCI_LINT_CACHE=$TMPDIR/.cache/golangci-lint + golangci-lint run + runHook postCheck + ''; + + installPhase = '' + mkdir -p $out + touch $out/.lint-passed + ''; + }; + + # Verify generated code matches committed code + generate = + { version }: + pkgs.buildGoApplication { + pname = "crossplane-cli-generate-check"; + inherit version; + src = self; + pwd = self; + modules = ../gomod2nix.toml; + go = pkgs.go-unstable; + + CGO_ENABLED = "0"; + + nativeBuildInputs = [ + pkgs.buf + pkgs.goverter + pkgs.protoc-gen-go + pkgs.protoc-gen-go-grpc + pkgs.kubernetes-controller-tools + ]; + + dontBuild = true; + + checkPhase = '' + runHook preCheck + export HOME=$TMPDIR + + echo "Running go generate..." + go generate -tags generate . + + # TODO(adamwg): Uncomment this once we have generated code. + # echo "Comparing against committed source..." + # if ! diff -rq proto ${self}/proto > /dev/null 2>&1; then + # echo "ERROR: Generated code is out of date. Run 'nix run .#generate' and commit." + # exit 1 + # fi + + runHook postCheck + ''; + + installPhase = '' + mkdir -p $out + touch $out/.generate-passed + ''; + }; + + # Run shell linters (shellcheck, shfmt) + shellLint = + _: + pkgs.runCommand "crossplane-cli-shell-lint" + { + nativeBuildInputs = [ + pkgs.findutils + pkgs.shellcheck + pkgs.shfmt + ]; + } + '' + cd ${self} + find . -name '*.sh' -type f | while read -r script; do + shellcheck "$script" + shfmt -d "$script" + done + mkdir -p $out + touch $out/.shell-lint-passed + ''; + + # Run Nix linters (statix, deadnix, nixfmt) + nixLint = + _: + pkgs.runCommand "crossplane-cli-nix-lint" + { + nativeBuildInputs = [ + pkgs.statix + pkgs.deadnix + pkgs.nixfmt-rfc-style + ]; + } + '' + statix check ${self} + deadnix --fail ${self}/flake.nix ${self}/nix + nixfmt --check ${self}/flake.nix ${self}/nix/*.nix + mkdir -p $out + touch $out/.nix-lint-passed + ''; +} From 489d25a62af0af8d5ba8d77c42d17c77839fab7b Mon Sep 17 00:00:00 2001 From: Adam Wolfe Gordon Date: Fri, 17 Apr 2026 14:58:23 -0600 Subject: [PATCH 02/23] Add render protos and code generation Signed-off-by: Adam Wolfe Gordon --- buf.gen.yaml | 8 + buf.yaml | 14 + generate.go | 5 + go.mod | 2 + go.sum | 4 + gomod2nix.toml | 4 + nix/checks.nix | 11 +- proto/render/v1alpha1/render.pb.go | 1246 ++++++++++++++++++++++++++++ proto/render/v1alpha1/render.proto | 212 +++++ 9 files changed, 1500 insertions(+), 6 deletions(-) create mode 100644 buf.gen.yaml create mode 100644 buf.yaml create mode 100644 go.sum create mode 100644 proto/render/v1alpha1/render.pb.go create mode 100644 proto/render/v1alpha1/render.proto diff --git a/buf.gen.yaml b/buf.gen.yaml new file mode 100644 index 0000000..c5ca49b --- /dev/null +++ b/buf.gen.yaml @@ -0,0 +1,8 @@ +version: v2 +plugins: + - local: protoc-gen-go + out: . + opt: paths=source_relative + - local: protoc-gen-go-grpc + out: . + opt: paths=source_relative diff --git a/buf.yaml b/buf.yaml new file mode 100644 index 0000000..d6f40bb --- /dev/null +++ b/buf.yaml @@ -0,0 +1,14 @@ +version: v2 +name: buf.build/crossplane/cli +lint: + use: + - STANDARD + except: + - FIELD_NOT_REQUIRED + - PACKAGE_NO_IMPORT_CYCLE +breaking: + use: + - WIRE_JSON # Includes JSON breaking changes, same as crossplane-runtime + except: + - EXTENSION_NO_DELETE + - FIELD_SAME_DEFAULT diff --git a/generate.go b/generate.go index 0ae502d..2e0fe6c 100644 --- a/generate.go +++ b/generate.go @@ -21,4 +21,9 @@ limitations under the License. // $PATH. Use './nix.sh develop' or './nix.sh run .#generate' to ensure they // are. +// Generate gRPC types and stubs. See buf.gen.yaml for buf's configuration. +// The protoc-gen-go and protoc-gen-go-grpc plugins must be in $PATH. +// Note that the vendor dir does temporarily exist during a Nix build. +//go:generate buf generate --exclude-path vendor + package generate diff --git a/go.mod b/go.mod index 4d7692c..e4304f1 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/crossplane/cli/v2 go 1.25.9 + +require google.golang.org/protobuf v1.36.11 diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..296be18 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= diff --git a/gomod2nix.toml b/gomod2nix.toml index 43cd4cf..384520b 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -1,3 +1,7 @@ schema = 3 +cachePackages = ["google.golang.org/protobuf/encoding/protojson", "google.golang.org/protobuf/encoding/prototext", "google.golang.org/protobuf/encoding/protowire", "google.golang.org/protobuf/proto", "google.golang.org/protobuf/reflect/protoreflect", "google.golang.org/protobuf/reflect/protoregistry", "google.golang.org/protobuf/runtime/protoiface", "google.golang.org/protobuf/runtime/protoimpl", "google.golang.org/protobuf/types/known/structpb", "google.golang.org/protobuf/types/known/timestamppb"] [mod] + [mod."google.golang.org/protobuf"] + version = "v1.36.11" + hash = "sha256-7W+6jntfI/awWL3JP6yQedxqP5S9o3XvPgJ2XxxsIeE=" diff --git a/nix/checks.nix b/nix/checks.nix index 2a4f09e..a5f8a8e 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -100,12 +100,11 @@ echo "Running go generate..." go generate -tags generate . - # TODO(adamwg): Uncomment this once we have generated code. - # echo "Comparing against committed source..." - # if ! diff -rq proto ${self}/proto > /dev/null 2>&1; then - # echo "ERROR: Generated code is out of date. Run 'nix run .#generate' and commit." - # exit 1 - # fi + echo "Comparing against committed source..." + if ! diff -rq proto ${self}/proto > /dev/null 2>&1; then + echo "ERROR: Generated code is out of date. Run 'nix run .#generate' and commit." + exit 1 + fi runHook postCheck ''; diff --git a/proto/render/v1alpha1/render.pb.go b/proto/render/v1alpha1/render.pb.go new file mode 100644 index 0000000..7879cbc --- /dev/null +++ b/proto/render/v1alpha1/render.pb.go @@ -0,0 +1,1246 @@ +// +//Copyright 2026 The Crossplane Authors. +// +//Licensed under the Apache License, Version 2.0 (the "License"); +//you may not use this file except in compliance with the License. +//You may obtain a copy of the License at +// +//http://www.apache.org/licenses/LICENSE-2.0 +// +//Unless required by applicable law or agreed to in writing, software +//distributed under the License is distributed on an "AS IS" BASIS, +//WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +//See the License for the specific language governing permissions and +//limitations under the License. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.10 +// protoc (unknown) +// source: proto/render/v1alpha1/render.proto + +// This package defines the render protocol. The render engine runs one real +// Reconcile() call against a fake in-memory client, backed by the real +// Crossplane reconciler. It accepts a RenderRequest on stdin and writes a +// RenderResponse to stdout. + +//buf:lint:ignore PACKAGE_DIRECTORY_MATCH + +package v1alpha1 + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + structpb "google.golang.org/protobuf/types/known/structpb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// A RenderRequest asks the render engine to render a resource. +type RenderRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Metadata pertaining to the render request. + Meta *RequestMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + // The resource to render. Exactly one must be set. + // + // Types that are valid to be assigned to Input: + // + // *RenderRequest_Composite + // *RenderRequest_Operation + // *RenderRequest_CronOperation + // *RenderRequest_WatchOperation + Input isRenderRequest_Input `protobuf_oneof:"input"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderRequest) Reset() { + *x = RenderRequest{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderRequest) ProtoMessage() {} + +func (x *RenderRequest) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenderRequest.ProtoReflect.Descriptor instead. +func (*RenderRequest) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{0} +} + +func (x *RenderRequest) GetMeta() *RequestMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *RenderRequest) GetInput() isRenderRequest_Input { + if x != nil { + return x.Input + } + return nil +} + +func (x *RenderRequest) GetComposite() *CompositeInput { + if x != nil { + if x, ok := x.Input.(*RenderRequest_Composite); ok { + return x.Composite + } + } + return nil +} + +func (x *RenderRequest) GetOperation() *OperationInput { + if x != nil { + if x, ok := x.Input.(*RenderRequest_Operation); ok { + return x.Operation + } + } + return nil +} + +func (x *RenderRequest) GetCronOperation() *CronOperationInput { + if x != nil { + if x, ok := x.Input.(*RenderRequest_CronOperation); ok { + return x.CronOperation + } + } + return nil +} + +func (x *RenderRequest) GetWatchOperation() *WatchOperationInput { + if x != nil { + if x, ok := x.Input.(*RenderRequest_WatchOperation); ok { + return x.WatchOperation + } + } + return nil +} + +type isRenderRequest_Input interface { + isRenderRequest_Input() +} + +type RenderRequest_Composite struct { + Composite *CompositeInput `protobuf:"bytes,2,opt,name=composite,proto3,oneof"` +} + +type RenderRequest_Operation struct { + Operation *OperationInput `protobuf:"bytes,3,opt,name=operation,proto3,oneof"` +} + +type RenderRequest_CronOperation struct { + CronOperation *CronOperationInput `protobuf:"bytes,4,opt,name=cron_operation,json=cronOperation,proto3,oneof"` +} + +type RenderRequest_WatchOperation struct { + WatchOperation *WatchOperationInput `protobuf:"bytes,5,opt,name=watch_operation,json=watchOperation,proto3,oneof"` +} + +func (*RenderRequest_Composite) isRenderRequest_Input() {} + +func (*RenderRequest_Operation) isRenderRequest_Input() {} + +func (*RenderRequest_CronOperation) isRenderRequest_Input() {} + +func (*RenderRequest_WatchOperation) isRenderRequest_Input() {} + +// A RenderResponse is the result of rendering a resource. +type RenderResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Metadata pertaining to the render response. + Meta *ResponseMeta `protobuf:"bytes,1,opt,name=meta,proto3" json:"meta,omitempty"` + // The render result. The variant matches the input variant. + // + // Types that are valid to be assigned to Output: + // + // *RenderResponse_Composite + // *RenderResponse_Operation + // *RenderResponse_CronOperation + // *RenderResponse_WatchOperation + Output isRenderResponse_Output `protobuf_oneof:"output"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RenderResponse) Reset() { + *x = RenderResponse{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RenderResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RenderResponse) ProtoMessage() {} + +func (x *RenderResponse) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RenderResponse.ProtoReflect.Descriptor instead. +func (*RenderResponse) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{1} +} + +func (x *RenderResponse) GetMeta() *ResponseMeta { + if x != nil { + return x.Meta + } + return nil +} + +func (x *RenderResponse) GetOutput() isRenderResponse_Output { + if x != nil { + return x.Output + } + return nil +} + +func (x *RenderResponse) GetComposite() *CompositeOutput { + if x != nil { + if x, ok := x.Output.(*RenderResponse_Composite); ok { + return x.Composite + } + } + return nil +} + +func (x *RenderResponse) GetOperation() *OperationOutput { + if x != nil { + if x, ok := x.Output.(*RenderResponse_Operation); ok { + return x.Operation + } + } + return nil +} + +func (x *RenderResponse) GetCronOperation() *CronOperationOutput { + if x != nil { + if x, ok := x.Output.(*RenderResponse_CronOperation); ok { + return x.CronOperation + } + } + return nil +} + +func (x *RenderResponse) GetWatchOperation() *WatchOperationOutput { + if x != nil { + if x, ok := x.Output.(*RenderResponse_WatchOperation); ok { + return x.WatchOperation + } + } + return nil +} + +type isRenderResponse_Output interface { + isRenderResponse_Output() +} + +type RenderResponse_Composite struct { + Composite *CompositeOutput `protobuf:"bytes,2,opt,name=composite,proto3,oneof"` +} + +type RenderResponse_Operation struct { + Operation *OperationOutput `protobuf:"bytes,3,opt,name=operation,proto3,oneof"` +} + +type RenderResponse_CronOperation struct { + CronOperation *CronOperationOutput `protobuf:"bytes,4,opt,name=cron_operation,json=cronOperation,proto3,oneof"` +} + +type RenderResponse_WatchOperation struct { + WatchOperation *WatchOperationOutput `protobuf:"bytes,5,opt,name=watch_operation,json=watchOperation,proto3,oneof"` +} + +func (*RenderResponse_Composite) isRenderResponse_Output() {} + +func (*RenderResponse_Operation) isRenderResponse_Output() {} + +func (*RenderResponse_CronOperation) isRenderResponse_Output() {} + +func (*RenderResponse_WatchOperation) isRenderResponse_Output() {} + +// RequestMeta contains metadata pertaining to a RenderRequest. +type RequestMeta struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RequestMeta) Reset() { + *x = RequestMeta{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RequestMeta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RequestMeta) ProtoMessage() {} + +func (x *RequestMeta) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RequestMeta.ProtoReflect.Descriptor instead. +func (*RequestMeta) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{2} +} + +// ResponseMeta contains metadata pertaining to a RenderResponse. +type ResponseMeta struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResponseMeta) Reset() { + *x = ResponseMeta{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResponseMeta) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResponseMeta) ProtoMessage() {} + +func (x *ResponseMeta) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResponseMeta.ProtoReflect.Descriptor instead. +func (*ResponseMeta) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{3} +} + +// A FunctionInput identifies a running composition function by name and gRPC +// address. The caller is responsible for starting function runtimes and +// providing their addresses. +type FunctionInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Name of the function, matching the pipeline step's functionRef.name. + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // gRPC address of the running function (e.g. "localhost:9443"). + Address string `protobuf:"bytes,2,opt,name=address,proto3" json:"address,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *FunctionInput) Reset() { + *x = FunctionInput{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *FunctionInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*FunctionInput) ProtoMessage() {} + +func (x *FunctionInput) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use FunctionInput.ProtoReflect.Descriptor instead. +func (*FunctionInput) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{4} +} + +func (x *FunctionInput) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *FunctionInput) GetAddress() string { + if x != nil { + return x.Address + } + return "" +} + +// An Event represents a Kubernetes event the reconciler would emit. +type Event struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Type is Normal or Warning. + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + // Reason is the short, machine-readable reason for the event. + Reason string `protobuf:"bytes,2,opt,name=reason,proto3" json:"reason,omitempty"` + // Message is the human-readable description of the event. + Message string `protobuf:"bytes,3,opt,name=message,proto3" json:"message,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *Event) Reset() { + *x = Event{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *Event) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Event) ProtoMessage() {} + +func (x *Event) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Event.ProtoReflect.Descriptor instead. +func (*Event) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{5} +} + +func (x *Event) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *Event) GetReason() string { + if x != nil { + return x.Reason + } + return "" +} + +func (x *Event) GetMessage() string { + if x != nil { + return x.Message + } + return "" +} + +// A CompositeInput contains all inputs needed to render a composite resource +// (XR) using the real XR reconciler. +type CompositeInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The composite resource (XR) to reconcile. + CompositeResource *structpb.Struct `protobuf:"bytes,1,opt,name=composite_resource,json=compositeResource,proto3" json:"composite_resource,omitempty"` + // The Composition to use. + Composition *structpb.Struct `protobuf:"bytes,2,opt,name=composition,proto3" json:"composition,omitempty"` + // Functions to call in the pipeline. + Functions []*FunctionInput `protobuf:"bytes,3,rep,name=functions,proto3" json:"functions,omitempty"` + // Existing composed resources from a previous reconcile. Optional. + ObservedResources []*structpb.Struct `protobuf:"bytes,4,rep,name=observed_resources,json=observedResources,proto3" json:"observed_resources,omitempty"` + // Resources available for functions that request them via the Requirements + // protocol. Optional. + RequiredResources []*structpb.Struct `protobuf:"bytes,5,rep,name=required_resources,json=requiredResources,proto3" json:"required_resources,omitempty"` + // Kubernetes Secrets for function credentials. Optional. + Credentials []*structpb.Struct `protobuf:"bytes,6,rep,name=credentials,proto3" json:"credentials,omitempty"` + // OpenAPI v3 documents providing schemas for resource kinds. Functions can + // request schemas via the Requirements protocol. Each entry is a full OpenAPI + // v3 document as JSON. Optional. + RequiredSchemas []*structpb.Struct `protobuf:"bytes,7,rep,name=required_schemas,json=requiredSchemas,proto3" json:"required_schemas,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompositeInput) Reset() { + *x = CompositeInput{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompositeInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompositeInput) ProtoMessage() {} + +func (x *CompositeInput) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CompositeInput.ProtoReflect.Descriptor instead. +func (*CompositeInput) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{6} +} + +func (x *CompositeInput) GetCompositeResource() *structpb.Struct { + if x != nil { + return x.CompositeResource + } + return nil +} + +func (x *CompositeInput) GetComposition() *structpb.Struct { + if x != nil { + return x.Composition + } + return nil +} + +func (x *CompositeInput) GetFunctions() []*FunctionInput { + if x != nil { + return x.Functions + } + return nil +} + +func (x *CompositeInput) GetObservedResources() []*structpb.Struct { + if x != nil { + return x.ObservedResources + } + return nil +} + +func (x *CompositeInput) GetRequiredResources() []*structpb.Struct { + if x != nil { + return x.RequiredResources + } + return nil +} + +func (x *CompositeInput) GetCredentials() []*structpb.Struct { + if x != nil { + return x.Credentials + } + return nil +} + +func (x *CompositeInput) GetRequiredSchemas() []*structpb.Struct { + if x != nil { + return x.RequiredSchemas + } + return nil +} + +// A CompositeOutput contains the results of rendering a composite resource. +type CompositeOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The XR with desired status and conditions set by the reconciler. + CompositeResource *structpb.Struct `protobuf:"bytes,1,opt,name=composite_resource,json=compositeResource,proto3" json:"composite_resource,omitempty"` + // Composed resources the reconciler would apply via server-side apply. + ComposedResources []*structpb.Struct `protobuf:"bytes,2,rep,name=composed_resources,json=composedResources,proto3" json:"composed_resources,omitempty"` + // Composed resources the reconciler would garbage collect. + DeletedResources []*structpb.Struct `protobuf:"bytes,3,rep,name=deleted_resources,json=deletedResources,proto3" json:"deleted_resources,omitempty"` + // Events the reconciler would emit. + Events []*Event `protobuf:"bytes,4,rep,name=events,proto3" json:"events,omitempty"` + // Required resources that were requested by the function pipeline. The + // structs are fnv1.ResourceSelector messages. + RequiredResources []*structpb.Struct `protobuf:"bytes,5,rep,name=required_resources,json=requiredResources,proto3" json:"required_resources,omitempty"` + // Required schemas that were requested by the function pipeline. The structs + // are fnv1.SchemaSelector messages. + RequiredSchemas []*structpb.Struct `protobuf:"bytes,6,rep,name=required_schemas,json=requiredSchemas,proto3" json:"required_schemas,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CompositeOutput) Reset() { + *x = CompositeOutput{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CompositeOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CompositeOutput) ProtoMessage() {} + +func (x *CompositeOutput) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CompositeOutput.ProtoReflect.Descriptor instead. +func (*CompositeOutput) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{7} +} + +func (x *CompositeOutput) GetCompositeResource() *structpb.Struct { + if x != nil { + return x.CompositeResource + } + return nil +} + +func (x *CompositeOutput) GetComposedResources() []*structpb.Struct { + if x != nil { + return x.ComposedResources + } + return nil +} + +func (x *CompositeOutput) GetDeletedResources() []*structpb.Struct { + if x != nil { + return x.DeletedResources + } + return nil +} + +func (x *CompositeOutput) GetEvents() []*Event { + if x != nil { + return x.Events + } + return nil +} + +func (x *CompositeOutput) GetRequiredResources() []*structpb.Struct { + if x != nil { + return x.RequiredResources + } + return nil +} + +func (x *CompositeOutput) GetRequiredSchemas() []*structpb.Struct { + if x != nil { + return x.RequiredSchemas + } + return nil +} + +// An OperationInput contains all inputs needed to render an Operation using the +// real Operation reconciler. +type OperationInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The Operation to reconcile. + Operation *structpb.Struct `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"` + // Functions to call in the pipeline. + Functions []*FunctionInput `protobuf:"bytes,2,rep,name=functions,proto3" json:"functions,omitempty"` + // Resources available for functions that request them via the Requirements + // protocol. Optional. + RequiredResources []*structpb.Struct `protobuf:"bytes,3,rep,name=required_resources,json=requiredResources,proto3" json:"required_resources,omitempty"` + // Kubernetes Secrets for function credentials. Optional. + Credentials []*structpb.Struct `protobuf:"bytes,4,rep,name=credentials,proto3" json:"credentials,omitempty"` + // OpenAPI v3 documents providing schemas for resource kinds. Functions can + // request schemas via the Requirements protocol. Each entry is a full OpenAPI + // v3 document as JSON. Optional. + RequiredSchemas []*structpb.Struct `protobuf:"bytes,5,rep,name=required_schemas,json=requiredSchemas,proto3" json:"required_schemas,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OperationInput) Reset() { + *x = OperationInput{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OperationInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OperationInput) ProtoMessage() {} + +func (x *OperationInput) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OperationInput.ProtoReflect.Descriptor instead. +func (*OperationInput) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{8} +} + +func (x *OperationInput) GetOperation() *structpb.Struct { + if x != nil { + return x.Operation + } + return nil +} + +func (x *OperationInput) GetFunctions() []*FunctionInput { + if x != nil { + return x.Functions + } + return nil +} + +func (x *OperationInput) GetRequiredResources() []*structpb.Struct { + if x != nil { + return x.RequiredResources + } + return nil +} + +func (x *OperationInput) GetCredentials() []*structpb.Struct { + if x != nil { + return x.Credentials + } + return nil +} + +func (x *OperationInput) GetRequiredSchemas() []*structpb.Struct { + if x != nil { + return x.RequiredSchemas + } + return nil +} + +// An OperationOutput contains the results of rendering an Operation. +type OperationOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The Operation with status set by the reconciler. + Operation *structpb.Struct `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"` + // Resources the Operation would apply via server-side apply. + AppliedResources []*structpb.Struct `protobuf:"bytes,2,rep,name=applied_resources,json=appliedResources,proto3" json:"applied_resources,omitempty"` + // Events the reconciler would emit. + Events []*Event `protobuf:"bytes,3,rep,name=events,proto3" json:"events,omitempty"` + // Required resources that were requested by the function pipeline. The + // structs are fnv1.ResourceSelector messages. + RequiredResources []*structpb.Struct `protobuf:"bytes,4,rep,name=required_resources,json=requiredResources,proto3" json:"required_resources,omitempty"` + // Required schemas that were requested by the function pipeline. The structs + // are fnv1.SchemaSelector messages. + RequiredSchemas []*structpb.Struct `protobuf:"bytes,5,rep,name=required_schemas,json=requiredSchemas,proto3" json:"required_schemas,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *OperationOutput) Reset() { + *x = OperationOutput{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *OperationOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OperationOutput) ProtoMessage() {} + +func (x *OperationOutput) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OperationOutput.ProtoReflect.Descriptor instead. +func (*OperationOutput) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{9} +} + +func (x *OperationOutput) GetOperation() *structpb.Struct { + if x != nil { + return x.Operation + } + return nil +} + +func (x *OperationOutput) GetAppliedResources() []*structpb.Struct { + if x != nil { + return x.AppliedResources + } + return nil +} + +func (x *OperationOutput) GetEvents() []*Event { + if x != nil { + return x.Events + } + return nil +} + +func (x *OperationOutput) GetRequiredResources() []*structpb.Struct { + if x != nil { + return x.RequiredResources + } + return nil +} + +func (x *OperationOutput) GetRequiredSchemas() []*structpb.Struct { + if x != nil { + return x.RequiredSchemas + } + return nil +} + +// A CronOperationInput contains all inputs needed to produce the Operation a +// CronOperation would create. +type CronOperationInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The CronOperation to render. + CronOperation *structpb.Struct `protobuf:"bytes,1,opt,name=cron_operation,json=cronOperation,proto3" json:"cron_operation,omitempty"` + // The scheduled time. Defaults to now if not set. + ScheduledTime *timestamppb.Timestamp `protobuf:"bytes,2,opt,name=scheduled_time,json=scheduledTime,proto3,oneof" json:"scheduled_time,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CronOperationInput) Reset() { + *x = CronOperationInput{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CronOperationInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CronOperationInput) ProtoMessage() {} + +func (x *CronOperationInput) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CronOperationInput.ProtoReflect.Descriptor instead. +func (*CronOperationInput) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{10} +} + +func (x *CronOperationInput) GetCronOperation() *structpb.Struct { + if x != nil { + return x.CronOperation + } + return nil +} + +func (x *CronOperationInput) GetScheduledTime() *timestamppb.Timestamp { + if x != nil { + return x.ScheduledTime + } + return nil +} + +// A CronOperationOutput contains the Operation a CronOperation would create. +type CronOperationOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The Operation the CronOperation would create. + Operation *structpb.Struct `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *CronOperationOutput) Reset() { + *x = CronOperationOutput{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *CronOperationOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CronOperationOutput) ProtoMessage() {} + +func (x *CronOperationOutput) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CronOperationOutput.ProtoReflect.Descriptor instead. +func (*CronOperationOutput) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{11} +} + +func (x *CronOperationOutput) GetOperation() *structpb.Struct { + if x != nil { + return x.Operation + } + return nil +} + +// A WatchOperationInput contains all inputs needed to produce the Operation a +// WatchOperation would create in response to a watched resource change. +type WatchOperationInput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The WatchOperation to render. + WatchOperation *structpb.Struct `protobuf:"bytes,1,opt,name=watch_operation,json=watchOperation,proto3" json:"watch_operation,omitempty"` + // The resource whose change triggered the WatchOperation. + WatchedResource *structpb.Struct `protobuf:"bytes,2,opt,name=watched_resource,json=watchedResource,proto3" json:"watched_resource,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchOperationInput) Reset() { + *x = WatchOperationInput{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchOperationInput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchOperationInput) ProtoMessage() {} + +func (x *WatchOperationInput) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[12] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchOperationInput.ProtoReflect.Descriptor instead. +func (*WatchOperationInput) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{12} +} + +func (x *WatchOperationInput) GetWatchOperation() *structpb.Struct { + if x != nil { + return x.WatchOperation + } + return nil +} + +func (x *WatchOperationInput) GetWatchedResource() *structpb.Struct { + if x != nil { + return x.WatchedResource + } + return nil +} + +// A WatchOperationOutput contains the Operation a WatchOperation would create. +type WatchOperationOutput struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The Operation the WatchOperation would create. + Operation *structpb.Struct `protobuf:"bytes,1,opt,name=operation,proto3" json:"operation,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *WatchOperationOutput) Reset() { + *x = WatchOperationOutput{} + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *WatchOperationOutput) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*WatchOperationOutput) ProtoMessage() {} + +func (x *WatchOperationOutput) ProtoReflect() protoreflect.Message { + mi := &file_proto_render_v1alpha1_render_proto_msgTypes[13] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use WatchOperationOutput.ProtoReflect.Descriptor instead. +func (*WatchOperationOutput) Descriptor() ([]byte, []int) { + return file_proto_render_v1alpha1_render_proto_rawDescGZIP(), []int{13} +} + +func (x *WatchOperationOutput) GetOperation() *structpb.Struct { + if x != nil { + return x.Operation + } + return nil +} + +var File_proto_render_v1alpha1_render_proto protoreflect.FileDescriptor + +const file_proto_render_v1alpha1_render_proto_rawDesc = "" + + "\n" + + "\"proto/render/v1alpha1/render.proto\x12\x1acrossplane.render.v1alpha1\x1a\x1cgoogle/protobuf/struct.proto\x1a\x1fgoogle/protobuf/timestamp.proto\"\xa2\x03\n" + + "\rRenderRequest\x12;\n" + + "\x04meta\x18\x01 \x01(\v2'.crossplane.render.v1alpha1.RequestMetaR\x04meta\x12J\n" + + "\tcomposite\x18\x02 \x01(\v2*.crossplane.render.v1alpha1.CompositeInputH\x00R\tcomposite\x12J\n" + + "\toperation\x18\x03 \x01(\v2*.crossplane.render.v1alpha1.OperationInputH\x00R\toperation\x12W\n" + + "\x0ecron_operation\x18\x04 \x01(\v2..crossplane.render.v1alpha1.CronOperationInputH\x00R\rcronOperation\x12Z\n" + + "\x0fwatch_operation\x18\x05 \x01(\v2/.crossplane.render.v1alpha1.WatchOperationInputH\x00R\x0ewatchOperationB\a\n" + + "\x05input\"\xa9\x03\n" + + "\x0eRenderResponse\x12<\n" + + "\x04meta\x18\x01 \x01(\v2(.crossplane.render.v1alpha1.ResponseMetaR\x04meta\x12K\n" + + "\tcomposite\x18\x02 \x01(\v2+.crossplane.render.v1alpha1.CompositeOutputH\x00R\tcomposite\x12K\n" + + "\toperation\x18\x03 \x01(\v2+.crossplane.render.v1alpha1.OperationOutputH\x00R\toperation\x12X\n" + + "\x0ecron_operation\x18\x04 \x01(\v2/.crossplane.render.v1alpha1.CronOperationOutputH\x00R\rcronOperation\x12[\n" + + "\x0fwatch_operation\x18\x05 \x01(\v20.crossplane.render.v1alpha1.WatchOperationOutputH\x00R\x0ewatchOperationB\b\n" + + "\x06output\"\r\n" + + "\vRequestMeta\"\x0e\n" + + "\fResponseMeta\"=\n" + + "\rFunctionInput\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x18\n" + + "\aaddress\x18\x02 \x01(\tR\aaddress\"M\n" + + "\x05Event\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12\x16\n" + + "\x06reason\x18\x02 \x01(\tR\x06reason\x12\x18\n" + + "\amessage\x18\x03 \x01(\tR\amessage\"\xeb\x03\n" + + "\x0eCompositeInput\x12F\n" + + "\x12composite_resource\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x11compositeResource\x129\n" + + "\vcomposition\x18\x02 \x01(\v2\x17.google.protobuf.StructR\vcomposition\x12G\n" + + "\tfunctions\x18\x03 \x03(\v2).crossplane.render.v1alpha1.FunctionInputR\tfunctions\x12F\n" + + "\x12observed_resources\x18\x04 \x03(\v2\x17.google.protobuf.StructR\x11observedResources\x12F\n" + + "\x12required_resources\x18\x05 \x03(\v2\x17.google.protobuf.StructR\x11requiredResources\x129\n" + + "\vcredentials\x18\x06 \x03(\v2\x17.google.protobuf.StructR\vcredentials\x12B\n" + + "\x10required_schemas\x18\a \x03(\v2\x17.google.protobuf.StructR\x0frequiredSchemas\"\xae\x03\n" + + "\x0fCompositeOutput\x12F\n" + + "\x12composite_resource\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x11compositeResource\x12F\n" + + "\x12composed_resources\x18\x02 \x03(\v2\x17.google.protobuf.StructR\x11composedResources\x12D\n" + + "\x11deleted_resources\x18\x03 \x03(\v2\x17.google.protobuf.StructR\x10deletedResources\x129\n" + + "\x06events\x18\x04 \x03(\v2!.crossplane.render.v1alpha1.EventR\x06events\x12F\n" + + "\x12required_resources\x18\x05 \x03(\v2\x17.google.protobuf.StructR\x11requiredResources\x12B\n" + + "\x10required_schemas\x18\x06 \x03(\v2\x17.google.protobuf.StructR\x0frequiredSchemas\"\xd7\x02\n" + + "\x0eOperationInput\x125\n" + + "\toperation\x18\x01 \x01(\v2\x17.google.protobuf.StructR\toperation\x12G\n" + + "\tfunctions\x18\x02 \x03(\v2).crossplane.render.v1alpha1.FunctionInputR\tfunctions\x12F\n" + + "\x12required_resources\x18\x03 \x03(\v2\x17.google.protobuf.StructR\x11requiredResources\x129\n" + + "\vcredentials\x18\x04 \x03(\v2\x17.google.protobuf.StructR\vcredentials\x12B\n" + + "\x10required_schemas\x18\x05 \x03(\v2\x17.google.protobuf.StructR\x0frequiredSchemas\"\xd5\x02\n" + + "\x0fOperationOutput\x125\n" + + "\toperation\x18\x01 \x01(\v2\x17.google.protobuf.StructR\toperation\x12D\n" + + "\x11applied_resources\x18\x02 \x03(\v2\x17.google.protobuf.StructR\x10appliedResources\x129\n" + + "\x06events\x18\x03 \x03(\v2!.crossplane.render.v1alpha1.EventR\x06events\x12F\n" + + "\x12required_resources\x18\x04 \x03(\v2\x17.google.protobuf.StructR\x11requiredResources\x12B\n" + + "\x10required_schemas\x18\x05 \x03(\v2\x17.google.protobuf.StructR\x0frequiredSchemas\"\xaf\x01\n" + + "\x12CronOperationInput\x12>\n" + + "\x0ecron_operation\x18\x01 \x01(\v2\x17.google.protobuf.StructR\rcronOperation\x12F\n" + + "\x0escheduled_time\x18\x02 \x01(\v2\x1a.google.protobuf.TimestampH\x00R\rscheduledTime\x88\x01\x01B\x11\n" + + "\x0f_scheduled_time\"L\n" + + "\x13CronOperationOutput\x125\n" + + "\toperation\x18\x01 \x01(\v2\x17.google.protobuf.StructR\toperation\"\x9b\x01\n" + + "\x13WatchOperationInput\x12@\n" + + "\x0fwatch_operation\x18\x01 \x01(\v2\x17.google.protobuf.StructR\x0ewatchOperation\x12B\n" + + "\x10watched_resource\x18\x02 \x01(\v2\x17.google.protobuf.StructR\x0fwatchedResource\"M\n" + + "\x14WatchOperationOutput\x125\n" + + "\toperation\x18\x01 \x01(\v2\x17.google.protobuf.StructR\toperationB4Z2github.com/crossplane/cli/v2/proto/render/v1alpha1b\x06proto3" + +var ( + file_proto_render_v1alpha1_render_proto_rawDescOnce sync.Once + file_proto_render_v1alpha1_render_proto_rawDescData []byte +) + +func file_proto_render_v1alpha1_render_proto_rawDescGZIP() []byte { + file_proto_render_v1alpha1_render_proto_rawDescOnce.Do(func() { + file_proto_render_v1alpha1_render_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_render_v1alpha1_render_proto_rawDesc), len(file_proto_render_v1alpha1_render_proto_rawDesc))) + }) + return file_proto_render_v1alpha1_render_proto_rawDescData +} + +var file_proto_render_v1alpha1_render_proto_msgTypes = make([]protoimpl.MessageInfo, 14) +var file_proto_render_v1alpha1_render_proto_goTypes = []any{ + (*RenderRequest)(nil), // 0: crossplane.render.v1alpha1.RenderRequest + (*RenderResponse)(nil), // 1: crossplane.render.v1alpha1.RenderResponse + (*RequestMeta)(nil), // 2: crossplane.render.v1alpha1.RequestMeta + (*ResponseMeta)(nil), // 3: crossplane.render.v1alpha1.ResponseMeta + (*FunctionInput)(nil), // 4: crossplane.render.v1alpha1.FunctionInput + (*Event)(nil), // 5: crossplane.render.v1alpha1.Event + (*CompositeInput)(nil), // 6: crossplane.render.v1alpha1.CompositeInput + (*CompositeOutput)(nil), // 7: crossplane.render.v1alpha1.CompositeOutput + (*OperationInput)(nil), // 8: crossplane.render.v1alpha1.OperationInput + (*OperationOutput)(nil), // 9: crossplane.render.v1alpha1.OperationOutput + (*CronOperationInput)(nil), // 10: crossplane.render.v1alpha1.CronOperationInput + (*CronOperationOutput)(nil), // 11: crossplane.render.v1alpha1.CronOperationOutput + (*WatchOperationInput)(nil), // 12: crossplane.render.v1alpha1.WatchOperationInput + (*WatchOperationOutput)(nil), // 13: crossplane.render.v1alpha1.WatchOperationOutput + (*structpb.Struct)(nil), // 14: google.protobuf.Struct + (*timestamppb.Timestamp)(nil), // 15: google.protobuf.Timestamp +} +var file_proto_render_v1alpha1_render_proto_depIdxs = []int32{ + 2, // 0: crossplane.render.v1alpha1.RenderRequest.meta:type_name -> crossplane.render.v1alpha1.RequestMeta + 6, // 1: crossplane.render.v1alpha1.RenderRequest.composite:type_name -> crossplane.render.v1alpha1.CompositeInput + 8, // 2: crossplane.render.v1alpha1.RenderRequest.operation:type_name -> crossplane.render.v1alpha1.OperationInput + 10, // 3: crossplane.render.v1alpha1.RenderRequest.cron_operation:type_name -> crossplane.render.v1alpha1.CronOperationInput + 12, // 4: crossplane.render.v1alpha1.RenderRequest.watch_operation:type_name -> crossplane.render.v1alpha1.WatchOperationInput + 3, // 5: crossplane.render.v1alpha1.RenderResponse.meta:type_name -> crossplane.render.v1alpha1.ResponseMeta + 7, // 6: crossplane.render.v1alpha1.RenderResponse.composite:type_name -> crossplane.render.v1alpha1.CompositeOutput + 9, // 7: crossplane.render.v1alpha1.RenderResponse.operation:type_name -> crossplane.render.v1alpha1.OperationOutput + 11, // 8: crossplane.render.v1alpha1.RenderResponse.cron_operation:type_name -> crossplane.render.v1alpha1.CronOperationOutput + 13, // 9: crossplane.render.v1alpha1.RenderResponse.watch_operation:type_name -> crossplane.render.v1alpha1.WatchOperationOutput + 14, // 10: crossplane.render.v1alpha1.CompositeInput.composite_resource:type_name -> google.protobuf.Struct + 14, // 11: crossplane.render.v1alpha1.CompositeInput.composition:type_name -> google.protobuf.Struct + 4, // 12: crossplane.render.v1alpha1.CompositeInput.functions:type_name -> crossplane.render.v1alpha1.FunctionInput + 14, // 13: crossplane.render.v1alpha1.CompositeInput.observed_resources:type_name -> google.protobuf.Struct + 14, // 14: crossplane.render.v1alpha1.CompositeInput.required_resources:type_name -> google.protobuf.Struct + 14, // 15: crossplane.render.v1alpha1.CompositeInput.credentials:type_name -> google.protobuf.Struct + 14, // 16: crossplane.render.v1alpha1.CompositeInput.required_schemas:type_name -> google.protobuf.Struct + 14, // 17: crossplane.render.v1alpha1.CompositeOutput.composite_resource:type_name -> google.protobuf.Struct + 14, // 18: crossplane.render.v1alpha1.CompositeOutput.composed_resources:type_name -> google.protobuf.Struct + 14, // 19: crossplane.render.v1alpha1.CompositeOutput.deleted_resources:type_name -> google.protobuf.Struct + 5, // 20: crossplane.render.v1alpha1.CompositeOutput.events:type_name -> crossplane.render.v1alpha1.Event + 14, // 21: crossplane.render.v1alpha1.CompositeOutput.required_resources:type_name -> google.protobuf.Struct + 14, // 22: crossplane.render.v1alpha1.CompositeOutput.required_schemas:type_name -> google.protobuf.Struct + 14, // 23: crossplane.render.v1alpha1.OperationInput.operation:type_name -> google.protobuf.Struct + 4, // 24: crossplane.render.v1alpha1.OperationInput.functions:type_name -> crossplane.render.v1alpha1.FunctionInput + 14, // 25: crossplane.render.v1alpha1.OperationInput.required_resources:type_name -> google.protobuf.Struct + 14, // 26: crossplane.render.v1alpha1.OperationInput.credentials:type_name -> google.protobuf.Struct + 14, // 27: crossplane.render.v1alpha1.OperationInput.required_schemas:type_name -> google.protobuf.Struct + 14, // 28: crossplane.render.v1alpha1.OperationOutput.operation:type_name -> google.protobuf.Struct + 14, // 29: crossplane.render.v1alpha1.OperationOutput.applied_resources:type_name -> google.protobuf.Struct + 5, // 30: crossplane.render.v1alpha1.OperationOutput.events:type_name -> crossplane.render.v1alpha1.Event + 14, // 31: crossplane.render.v1alpha1.OperationOutput.required_resources:type_name -> google.protobuf.Struct + 14, // 32: crossplane.render.v1alpha1.OperationOutput.required_schemas:type_name -> google.protobuf.Struct + 14, // 33: crossplane.render.v1alpha1.CronOperationInput.cron_operation:type_name -> google.protobuf.Struct + 15, // 34: crossplane.render.v1alpha1.CronOperationInput.scheduled_time:type_name -> google.protobuf.Timestamp + 14, // 35: crossplane.render.v1alpha1.CronOperationOutput.operation:type_name -> google.protobuf.Struct + 14, // 36: crossplane.render.v1alpha1.WatchOperationInput.watch_operation:type_name -> google.protobuf.Struct + 14, // 37: crossplane.render.v1alpha1.WatchOperationInput.watched_resource:type_name -> google.protobuf.Struct + 14, // 38: crossplane.render.v1alpha1.WatchOperationOutput.operation:type_name -> google.protobuf.Struct + 39, // [39:39] is the sub-list for method output_type + 39, // [39:39] is the sub-list for method input_type + 39, // [39:39] is the sub-list for extension type_name + 39, // [39:39] is the sub-list for extension extendee + 0, // [0:39] is the sub-list for field type_name +} + +func init() { file_proto_render_v1alpha1_render_proto_init() } +func file_proto_render_v1alpha1_render_proto_init() { + if File_proto_render_v1alpha1_render_proto != nil { + return + } + file_proto_render_v1alpha1_render_proto_msgTypes[0].OneofWrappers = []any{ + (*RenderRequest_Composite)(nil), + (*RenderRequest_Operation)(nil), + (*RenderRequest_CronOperation)(nil), + (*RenderRequest_WatchOperation)(nil), + } + file_proto_render_v1alpha1_render_proto_msgTypes[1].OneofWrappers = []any{ + (*RenderResponse_Composite)(nil), + (*RenderResponse_Operation)(nil), + (*RenderResponse_CronOperation)(nil), + (*RenderResponse_WatchOperation)(nil), + } + file_proto_render_v1alpha1_render_proto_msgTypes[10].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_render_v1alpha1_render_proto_rawDesc), len(file_proto_render_v1alpha1_render_proto_rawDesc)), + NumEnums: 0, + NumMessages: 14, + NumExtensions: 0, + NumServices: 0, + }, + GoTypes: file_proto_render_v1alpha1_render_proto_goTypes, + DependencyIndexes: file_proto_render_v1alpha1_render_proto_depIdxs, + MessageInfos: file_proto_render_v1alpha1_render_proto_msgTypes, + }.Build() + File_proto_render_v1alpha1_render_proto = out.File + file_proto_render_v1alpha1_render_proto_goTypes = nil + file_proto_render_v1alpha1_render_proto_depIdxs = nil +} diff --git a/proto/render/v1alpha1/render.proto b/proto/render/v1alpha1/render.proto new file mode 100644 index 0000000..e791bd1 --- /dev/null +++ b/proto/render/v1alpha1/render.proto @@ -0,0 +1,212 @@ +/* + Copyright 2026 The Crossplane Authors. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +syntax = "proto3"; + +// This package defines the render protocol. The render engine runs one real +// Reconcile() call against a fake in-memory client, backed by the real +// Crossplane reconciler. It accepts a RenderRequest on stdin and writes a +// RenderResponse to stdout. + +//buf:lint:ignore PACKAGE_DIRECTORY_MATCH +package crossplane.render.v1alpha1; + +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option go_package = "github.com/crossplane/cli/v2/proto/render/v1alpha1"; + +// A RenderRequest asks the render engine to render a resource. +message RenderRequest { + // Metadata pertaining to the render request. + RequestMeta meta = 1; + + // The resource to render. Exactly one must be set. + oneof input { + CompositeInput composite = 2; + OperationInput operation = 3; + CronOperationInput cron_operation = 4; + WatchOperationInput watch_operation = 5; + } +} + +// A RenderResponse is the result of rendering a resource. +message RenderResponse { + // Metadata pertaining to the render response. + ResponseMeta meta = 1; + + // The render result. The variant matches the input variant. + oneof output { + CompositeOutput composite = 2; + OperationOutput operation = 3; + CronOperationOutput cron_operation = 4; + WatchOperationOutput watch_operation = 5; + } +} + +// RequestMeta contains metadata pertaining to a RenderRequest. +message RequestMeta {} + +// ResponseMeta contains metadata pertaining to a RenderResponse. +message ResponseMeta {} + +// A FunctionInput identifies a running composition function by name and gRPC +// address. The caller is responsible for starting function runtimes and +// providing their addresses. +message FunctionInput { + // Name of the function, matching the pipeline step's functionRef.name. + string name = 1; + + // gRPC address of the running function (e.g. "localhost:9443"). + string address = 2; +} + +// An Event represents a Kubernetes event the reconciler would emit. +message Event { + // Type is Normal or Warning. + string type = 1; + + // Reason is the short, machine-readable reason for the event. + string reason = 2; + + // Message is the human-readable description of the event. + string message = 3; +} + +// A CompositeInput contains all inputs needed to render a composite resource +// (XR) using the real XR reconciler. +message CompositeInput { + // The composite resource (XR) to reconcile. + google.protobuf.Struct composite_resource = 1; + + // The Composition to use. + google.protobuf.Struct composition = 2; + + // Functions to call in the pipeline. + repeated FunctionInput functions = 3; + + // Existing composed resources from a previous reconcile. Optional. + repeated google.protobuf.Struct observed_resources = 4; + + // Resources available for functions that request them via the Requirements + // protocol. Optional. + repeated google.protobuf.Struct required_resources = 5; + + // Kubernetes Secrets for function credentials. Optional. + repeated google.protobuf.Struct credentials = 6; + + // OpenAPI v3 documents providing schemas for resource kinds. Functions can + // request schemas via the Requirements protocol. Each entry is a full OpenAPI + // v3 document as JSON. Optional. + repeated google.protobuf.Struct required_schemas = 7; +} + +// A CompositeOutput contains the results of rendering a composite resource. +message CompositeOutput { + // The XR with desired status and conditions set by the reconciler. + google.protobuf.Struct composite_resource = 1; + + // Composed resources the reconciler would apply via server-side apply. + repeated google.protobuf.Struct composed_resources = 2; + + // Composed resources the reconciler would garbage collect. + repeated google.protobuf.Struct deleted_resources = 3; + + // Events the reconciler would emit. + repeated Event events = 4; + + // Required resources that were requested by the function pipeline. The + // structs are fnv1.ResourceSelector messages. + repeated google.protobuf.Struct required_resources = 5; + + // Required schemas that were requested by the function pipeline. The structs + // are fnv1.SchemaSelector messages. + repeated google.protobuf.Struct required_schemas = 6; +} + +// An OperationInput contains all inputs needed to render an Operation using the +// real Operation reconciler. +message OperationInput { + // The Operation to reconcile. + google.protobuf.Struct operation = 1; + + // Functions to call in the pipeline. + repeated FunctionInput functions = 2; + + // Resources available for functions that request them via the Requirements + // protocol. Optional. + repeated google.protobuf.Struct required_resources = 3; + + // Kubernetes Secrets for function credentials. Optional. + repeated google.protobuf.Struct credentials = 4; + + // OpenAPI v3 documents providing schemas for resource kinds. Functions can + // request schemas via the Requirements protocol. Each entry is a full OpenAPI + // v3 document as JSON. Optional. + repeated google.protobuf.Struct required_schemas = 5; +} + +// An OperationOutput contains the results of rendering an Operation. +message OperationOutput { + // The Operation with status set by the reconciler. + google.protobuf.Struct operation = 1; + + // Resources the Operation would apply via server-side apply. + repeated google.protobuf.Struct applied_resources = 2; + + // Events the reconciler would emit. + repeated Event events = 3; + + // Required resources that were requested by the function pipeline. The + // structs are fnv1.ResourceSelector messages. + repeated google.protobuf.Struct required_resources = 4; + + // Required schemas that were requested by the function pipeline. The structs + // are fnv1.SchemaSelector messages. + repeated google.protobuf.Struct required_schemas = 5; +} + +// A CronOperationInput contains all inputs needed to produce the Operation a +// CronOperation would create. +message CronOperationInput { + // The CronOperation to render. + google.protobuf.Struct cron_operation = 1; + + // The scheduled time. Defaults to now if not set. + optional google.protobuf.Timestamp scheduled_time = 2; +} + +// A CronOperationOutput contains the Operation a CronOperation would create. +message CronOperationOutput { + // The Operation the CronOperation would create. + google.protobuf.Struct operation = 1; +} + +// A WatchOperationInput contains all inputs needed to produce the Operation a +// WatchOperation would create in response to a watched resource change. +message WatchOperationInput { + // The WatchOperation to render. + google.protobuf.Struct watch_operation = 1; + + // The resource whose change triggered the WatchOperation. + google.protobuf.Struct watched_resource = 2; +} + +// A WatchOperationOutput contains the Operation a WatchOperation would create. +message WatchOperationOutput { + // The Operation the WatchOperation would create. + google.protobuf.Struct operation = 1; +} From 84b5b2f083ab369ccbab4ea6b06911d11436acc4 Mon Sep 17 00:00:00 2001 From: Adam Wolfe Gordon Date: Tue, 5 May 2026 09:59:11 -0600 Subject: [PATCH 03/23] ci: Add GitHub Actions configuration This is all copied from crossplane/crossplane and updated to remove the parts we don't need (e.g., pushing container images). Signed-off-by: Adam Wolfe Gordon --- .github/ISSUE_TEMPLATE/bug_report.md | 43 +++++ .github/ISSUE_TEMPLATE/config.yml | 10 + .github/ISSUE_TEMPLATE/feature_request.md | 26 +++ .github/PULL_REQUEST_TEMPLATE.md | 32 ++++ .github/renovate-entrypoint.sh | 35 ++++ .github/renovate.json5 | 147 +++++++++++++++ .github/workflows/backport.yml | 38 ++++ .github/workflows/ci.yml | 218 ++++++++++++++++++++++ .github/workflows/commands.yml | 62 ++++++ .github/workflows/pr.yml | 22 +++ .github/workflows/promote.yml | 103 ++++++++++ .github/workflows/renovate.yml | 54 ++++++ .github/workflows/stale.yml | 49 +++++ .github/workflows/tag.yml | 31 +++ 14 files changed, 870 insertions(+) create mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.md create mode 100644 .github/PULL_REQUEST_TEMPLATE.md create mode 100755 .github/renovate-entrypoint.sh create mode 100644 .github/renovate.json5 create mode 100644 .github/workflows/backport.yml create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/commands.yml create mode 100644 .github/workflows/pr.yml create mode 100644 .github/workflows/promote.yml create mode 100644 .github/workflows/renovate.yml create mode 100644 .github/workflows/stale.yml create mode 100644 .github/workflows/tag.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..e762ca3 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,43 @@ +--- +name: Bug Report +about: Help us diagnose and fix bugs in Crossplane +labels: bug +--- + + +### What happened? + + + +### How can we reproduce it? + + + +### What environment did it happen in? + +* Crossplane CLI version: +* Platform (e.g., linux/amd64): +* Crossplane version (if applicable): + + diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..1b7476f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,10 @@ +contact_links: +- name: Support Request + url: https://github.com/crossplane/crossplane/discussions + about: Ask the Crossplane community for support. +- name: Documentation Issues + url: https://github.com/crossplane/docs/issues + about: Report a documentation bug, or suggest an improvement. +- name: Request a New Extension + url: https://github.com/crossplane/crossplane/discussions/5194 + about: Request a new Crossplane provider, function, etc. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..312679a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: Feature Request +about: Help us make Crossplane more useful +labels: enhancement +--- + + +### What problem are you facing? + + + +### How could Crossplane help solve your problem? + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..3c4d85b --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,32 @@ + + +### Description of your changes + + + +Fixes # + +I have: + +- [ ] Read and followed Crossplane's [contribution process]. +- [ ] Run `./nix.sh flake check` to ensure this PR is ready for review. +- [ ] Added or updated unit tests. +- [ ] Linked a PR or a [docs tracking issue] to [document this change]. +- [ ] Added `backport release-x.y` labels to auto-backport this PR. + +Need help with this checklist? See the [cheat sheet]. + +[contribution process]: https://github.com/crossplane/crossplane/tree/main/contributing +[docs tracking issue]: https://github.com/crossplane/docs/issues/new +[document this change]: https://docs.crossplane.io/contribute/contribute +[cheat sheet]: https://github.com/crossplane/crossplane/tree/main/contributing#checklist-cheat-sheet diff --git a/.github/renovate-entrypoint.sh b/.github/renovate-entrypoint.sh new file mode 100755 index 0000000..a9f48b7 --- /dev/null +++ b/.github/renovate-entrypoint.sh @@ -0,0 +1,35 @@ +#!/bin/bash + +set -e + +# Install Earthly (for release branches) +echo "Installing Earthly..." +curl -fsSLo /usr/local/bin/earthly https://github.com/earthly/earthly/releases/latest/download/earthly-linux-amd64 +chmod +x /usr/local/bin/earthly +/usr/local/bin/earthly bootstrap + +# Install Nix (for main branch) +echo "Installing Nix..." +apt-get update && apt-get install -y nix-bin + +# Configure Nix +mkdir -p /etc/nix +cat >/etc/nix/nix.conf <<'EOF' +# Enable flakes and the nix command (e.g. nix run, nix build). +experimental-features = nix-command flakes + +# Run builds as the calling user, not dedicated nixbld users. This avoids +# needing to create the nixbld group and users in this ephemeral container. +build-users-group = + +# Build derivations in parallel, one per CPU core. +max-jobs = auto + +# Use the Crossplane Cachix cache to download pre-built binaries from CI. +extra-substituters = https://crossplane.cachix.org +extra-trusted-public-keys = crossplane.cachix.org-1:NJluVUN9TX0rY/zAxHYaT19Y5ik4ELH4uFuxje+62d4= +EOF + +echo "Nix $(nix --version) installed successfully" + +renovate diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000..ae7ab12 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,147 @@ +{ + $schema: 'https://docs.renovatebot.com/renovate-schema.json', + extends: [ + 'config:recommended', + 'helpers:pinGitHubActionDigests', + ':semanticCommits', + ], + // We only want renovate to rebase PRs when they have conflicts, default + // "auto" mode is not required. + rebaseWhen: 'conflicted', + // The maximum number of PRs to be created in parallel + prConcurrentLimit: 5, + // The branches renovate should target + // PLEASE UPDATE THIS WHEN RELEASING. + baseBranches: [ + 'main', + '/^release-.*/', + ], + ignorePaths: [], + postUpdateOptions: [ + 'gomodTidy', + ], + // All PRs should have a label + labels: [ + 'automated', + ], + // PackageRules disabled below should be enabled in case of vulnerabilities + vulnerabilityAlerts: { + enabled: true, + }, + osvVulnerabilityAlerts: true, + // Renovate evaluates all packageRules in order, so low priority rules should + // be at the beginning, high priority at the end + packageRules: [ + { + description: 'Ignore non-security related updates to release branches', + matchBaseBranches: [ + '/^release-.*/', + ], + enabled: false, + }, + { + description: 'Still update Docker images on release branches though', + matchDatasources: [ + 'docker', + ], + matchBaseBranches: [ + '/^release-.*/', + ], + enabled: true, + }, + { + description: 'Only get Docker image updates every 2 weeks to reduce noise', + matchDatasources: [ + 'docker', + ], + schedule: [ + 'every 2 week on monday', + ], + enabled: true, + }, + { + description: 'Ignore k8s.io/client-go older versions, they switched to semantic version and old tags are still available in the repo', + matchDatasources: [ + 'go', + ], + matchDepNames: [ + 'k8s.io/client-go', + ], + allowedVersions: '<1.0', + }, + { + description: 'Ignore k8s dependencies, should be updated on crossplane-runtime', + matchDatasources: [ + 'go', + ], + enabled: false, + matchPackageNames: [ + 'k8s.io{/,}**', + 'sigs.k8s.io{/,}**', + ], + }, + { + description: 'Only get dependency digest updates every month to reduce noise, except crossplane-runtime', + matchDatasources: [ + 'go', + ], + matchUpdateTypes: [ + 'digest', + ], + extends: [ + 'schedule:monthly', + ], + matchPackageNames: [ + '!github.com/crossplane/crossplane-runtime/v2', + ], + }, + { + description: "Ignore oss-fuzz, it's not using tags, we'll stick to master", + matchDepTypes: [ + 'action', + ], + matchDepNames: [ + 'google/oss-fuzz', + ], + enabled: false, + }, + { + description: 'Group all go version updates', + matchDatasources: [ + 'golang-version', + ], + groupName: 'golang version', + }, + { + description: 'Regenerate gomod2nix.toml and generated code after upgrading go dependencies', + matchDatasources: [ + 'go', + ], + postUpgradeTasks: { + commands: [ + 'nix run .#tidy', + 'nix run .#generate', + ], + fileFilters: [ + '**/*', + ], + executionMode: 'update', + }, + }, + { + description: 'Lint code after upgrading golangci-lint', + matchDepNames: [ + 'golangci/golangci-lint', + ], + postUpgradeTasks: { + commands: [ + 'nix run .#lint', + ], + fileFilters: [ + '**/*', + ], + executionMode: 'update', + }, + }, + ], +} diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml new file mode 100644 index 0000000..d71a6ed --- /dev/null +++ b/.github/workflows/backport.yml @@ -0,0 +1,38 @@ +name: Backport + +on: + # NOTE(negz): This is a risky target, but we run this action only when and if + # a PR is closed, then filter down to specifically merged PRs. We also don't + # invoke any scripts, etc from within the repo. I believe the fact that we'll + # be able to review PRs before this runs makes this fairly safe. + # https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ + pull_request_target: + types: [closed] + # See also commands.yml for the /backport triggered variant of this workflow. + +permissions: + contents: read + +jobs: + # NOTE(negz): I tested many backport GitHub actions before landing on this + # one. Many do not support merge commits, or do not support pull requests with + # more than one commit. This one does. It also handily links backport PRs with + # new PRs, and provides commentary and instructions when it can't backport. + # Note that PRs _must_ be labelled before they're merged to trigger a backport. + open-pr: + permissions: + contents: write # for korthout/backport-action to create branch + pull-requests: write # for korthout/backport-action to create PR to backport + runs-on: ubuntu-24.04 + if: github.event.pull_request.merged + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Open Backport PR + uses: korthout/backport-action@4aaf0e03a94ff0a619c9a511b61aeb42adea5b02 # v4.2.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + github_workspace: ${{ github.workspace }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..ad02adb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,218 @@ +name: CI + +on: + push: + branches: + - main + - release-* + pull_request: {} + workflow_dispatch: {} + +permissions: + contents: read + +env: + # We can't run a step 'if secrets.FOO != ""' but we can run a step + # 'if env.FOO' != ""', so we copy secrets to env vars for conditional checks. + AWS_USR: ${{ secrets.AWS_USR }} + +jobs: + check-diff: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Nix + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31 + + - name: Setup Cachix + uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: crossplane + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Verify Generated Code + run: nix build .#checks.x86_64-linux.generate --print-build-logs + + lint: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Nix + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31 + + - name: Setup Cachix + uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: crossplane + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Lint + run: nix build .#checks.x86_64-linux.go-lint --print-build-logs + + codeql: + runs-on: ubuntu-24.04 + permissions: + contents: read + security-events: write + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Nix + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31 + + - name: Setup Cachix + uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: crossplane + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Setup Nix Environment + uses: nicknovitski/nix-develop@9be7cfb4b10451d3390a75dc18ad0465bed4932a # v1 + + - name: Initialize CodeQL + uses: github/codeql-action/init@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 + with: + languages: go + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@45cbd0c69e560cd9e7cd7f8c32362050c9b7ded2 # v4 + + unit-tests: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Nix + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31 + + - name: Setup Cachix + uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: crossplane + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Run Unit Tests + run: nix build .#checks.x86_64-linux.test --print-build-logs + + - name: Publish Unit Test Coverage + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 + with: + flags: unittests + file: result/coverage.txt + token: ${{ secrets.CODECOV_TOKEN }} + + # Build all artifacts + build-artifacts: + permissions: + contents: read + runs-on: ubuntu-24.04 + + steps: + - name: Cleanup Disk + uses: jlumbroso/free-disk-space@54081f138730dfa15788a46383842cd2f914a1be # v1.3.1 + with: + android: true + dotnet: true + haskell: true + tool-cache: true + swap-storage: false + large-packages: false + docker-images: false + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Install Nix + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31 + + - name: Setup Cachix + uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: crossplane + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + # Set buildVersion in flake.nix. The version is an input to the build. + # Pure (sandboxed, reproducible) Nix build inputs can only come from git + # tracked files, so we set it in flake.nix before building. + - name: Set Version + run: | + VERSION=$(git describe --dirty --always --tags | sed 's/-/./2g') + echo "VERSION=$VERSION" >> "$GITHUB_ENV" + sed -i "s|buildVersion = null;|buildVersion = \"$VERSION\";|" flake.nix + + - name: Build Artifacts + run: nix build --option warn-dirty false --print-build-logs + + - name: Upload Artifacts + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: output + path: result/** + + - name: Push Artifacts to S3 + if: (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/heads/release-')) && env.AWS_USR != '' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_USR }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_PSW }} + AWS_DEFAULT_REGION: us-east-1 + run: nix run --option warn-dirty false .#push-artifacts -- "${GITHUB_REF##*/}" + + - name: Promote Artifacts to Master Channel + if: github.ref == 'refs/heads/main' && env.AWS_USR != '' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_USR }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_PSW }} + AWS_DEFAULT_REGION: us-east-1 + run: nix run --option warn-dirty false .#promote-artifacts -- main "$VERSION" master + + # Fuzz testing (unchanged from original) + fuzz-test: + runs-on: ubuntu-24.04 + + steps: + - name: Build Fuzzers + id: build + uses: google/oss-fuzz/infra/cifuzz/actions/build_fuzzers@master + with: + oss-fuzz-project-name: "crossplane-cli" + language: go + + - name: Run Fuzzers + uses: google/oss-fuzz/infra/cifuzz/actions/run_fuzzers@master + with: + oss-fuzz-project-name: "crossplane-cli" + fuzz-seconds: 300 + language: go + + - name: Upload Crash + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + if: failure() && steps.build.outcome == 'success' + with: + name: artifacts + path: ./out/artifacts + + # Protobuf schema linting (unchanged from original) + protobuf-schemas: + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Lint and Push Protocol Buffers + uses: bufbuild/buf-action@8f4a1456a0ab6a1eb80ba68e53832e6fcfacc16c # v1 + with: + token: ${{ secrets.BUF_TOKEN }} + pr_comment: false diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml new file mode 100644 index 0000000..9845dfe --- /dev/null +++ b/.github/workflows/commands.yml @@ -0,0 +1,62 @@ +name: Comment Commands + +on: issue_comment + +permissions: + contents: read + +jobs: + # NOTE(negz): See also backport.yml, which is the variant that triggers on PR + # merge rather than on comment. + backport: + permissions: + contents: write + pull-requests: write + runs-on: ubuntu-24.04 + if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/backport') + steps: + - name: Extract Command + id: command + uses: xt0rted/slash-command-action@bf51f8f5f4ea3d58abc7eca58f77104182b23e88 # v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + command: backport + reaction: "true" + reaction-type: "eyes" + allow-edits: "false" + permission-level: write + + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + fetch-depth: 0 + + - name: Open Backport PR + uses: korthout/backport-action@4aaf0e03a94ff0a619c9a511b61aeb42adea5b02 # v4.2.0 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + github_workspace: ${{ github.workspace }} + + fresh: + permissions: + issues: write + pull-requests: write + runs-on: ubuntu-24.04 + if: startsWith(github.event.comment.body, '/fresh') && github.event.comment.author_association != 'NONE' + + steps: + - name: Extract Command + id: command + uses: xt0rted/slash-command-action@bf51f8f5f4ea3d58abc7eca58f77104182b23e88 # v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + command: fresh + reaction: "true" + reaction-type: "eyes" + allow-edits: "false" + permission-level: read + - name: Handle Command + uses: actions-ecosystem/action-remove-labels@2ce5d41b4b6aa8503e285553f75ed56e0a40bae0 # v1 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + labels: stale diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml new file mode 100644 index 0000000..a9f54fc --- /dev/null +++ b/.github/workflows/pr.yml @@ -0,0 +1,22 @@ +name: PR + +on: + pull_request: + types: [opened, edited, synchronize] + +permissions: + contents: read + +jobs: + checklist-completed: + permissions: + pull-requests: read # for reading PR checklist + if: github.actor != 'crossplane-renovate[bot]' + runs-on: ubuntu-24.04 + steps: + - uses: mheap/require-checklist-action@46d2ca1a0f90144bd081fd13a80b1dc581759365 # v2 + with: + # The checklist must _exist_ and be filled out. + requireChecklist: true + # Only check the PR description, ignore checklists in comments. + skipComments: true diff --git a/.github/workflows/promote.yml b/.github/workflows/promote.yml new file mode 100644 index 0000000..485cc46 --- /dev/null +++ b/.github/workflows/promote.yml @@ -0,0 +1,103 @@ +name: Promote + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g. v1.18.0)' + required: true + channel: + description: 'Release channel' + required: true + default: 'stable' + pre-release: + type: boolean + description: 'This is a pre-release (will not update current pointer)' + required: true + default: false + +permissions: + contents: read + +env: + DOCKER_USR: ${{ secrets.DOCKER_USR }} + AWS_USR: ${{ secrets.AWS_USR }} + UPBOUND_MARKETPLACE_PUSH_ROBOT_USR: ${{ secrets.UPBOUND_MARKETPLACE_PUSH_ROBOT_USR }} + +jobs: + promote: + permissions: + contents: read + packages: write # for pushing to ghcr.io + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Install Nix + uses: cachix/install-nix-action@4e002c8ec80594ecd40e759629461e26c8abed15 # v31 + + - name: Setup Cachix + uses: cachix/cachix-action@3ba601ff5bbb07c7220846facfa2cd81eeee15a1 # v16 + with: + name: crossplane + authToken: ${{ secrets.CACHIX_AUTH_TOKEN }} + + - name: Login to DockerHub + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + if: env.DOCKER_USR != '' + with: + username: ${{ secrets.DOCKER_USR }} + password: ${{ secrets.DOCKER_PSW }} + + - name: Login to Upbound + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + if: env.UPBOUND_MARKETPLACE_PUSH_ROBOT_USR != '' + with: + registry: xpkg.upbound.io + username: ${{ secrets.UPBOUND_MARKETPLACE_PUSH_ROBOT_USR }} + password: ${{ secrets.UPBOUND_MARKETPLACE_PUSH_ROBOT_PSW }} + + - name: Login to GitHub Container Registry + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Promote Images to DockerHub + if: env.DOCKER_USR != '' + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + run: nix run .#promote-images -- crossplane/crossplane "$VERSION" "$CHANNEL" + + - name: Promote Images to Upbound + if: env.UPBOUND_MARKETPLACE_PUSH_ROBOT_USR != '' + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + run: nix run .#promote-images -- xpkg.upbound.io/crossplane/crossplane "$VERSION" "$CHANNEL" + + - name: Promote Images to GitHub Container Registry + env: + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + run: nix run .#promote-images -- ghcr.io/crossplane/crossplane "$VERSION" "$CHANNEL" + + - name: Promote Artifacts to S3 + if: env.AWS_USR != '' + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_USR }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_PSW }} + AWS_DEFAULT_REGION: us-east-1 + VERSION: ${{ inputs.version }} + CHANNEL: ${{ inputs.channel }} + PRE_RELEASE: ${{ inputs.pre-release }} + run: | + PRERELEASE_FLAG="" + if [ "$PRE_RELEASE" = "true" ]; then + PRERELEASE_FLAG="--prerelease" + fi + nix run .#promote-artifacts -- "${GITHUB_REF##*/}" "$VERSION" "$CHANNEL" $PRERELEASE_FLAG diff --git a/.github/workflows/renovate.yml b/.github/workflows/renovate.yml new file mode 100644 index 0000000..3b56487 --- /dev/null +++ b/.github/workflows/renovate.yml @@ -0,0 +1,54 @@ +name: Renovate +on: + # Allows manual/automated trigger for debugging purposes + workflow_dispatch: + inputs: + logLevel: + description: "Renovate's log level" + required: true + default: "info" + type: string + schedule: + - cron: '0 8 * * *' + +permissions: + contents: read + +env: + LOG_LEVEL: "info" + +jobs: + renovate: + runs-on: ubuntu-latest + if: | + !github.event.repository.fork && + !github.event.pull_request.head.repo.fork + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # Don't waste time starting Renovate if JSON is invalid + - name: Validate Renovate JSON + run: npx --yes --package renovate -- renovate-config-validator + + - name: Get token + id: get-github-app-token + uses: actions/create-github-app-token@29824e69f54612133e76f7eaac726eef6c875baf # v2 + with: + app-id: ${{ secrets.RENOVATE_GITHUB_APP_ID }} + private-key: ${{ secrets.RENOVATE_GITHUB_APP_PRIVATE_KEY }} + + - name: Self-hosted Renovate + uses: renovatebot/github-action@e23f4d9675532445118c886434f5a34292b630b4 # v46.0.2 + env: + RENOVATE_REPOSITORIES: ${{ github.repository }} + # Use GitHub API to create commits + RENOVATE_PLATFORM_COMMIT: "true" + LOG_LEVEL: ${{ github.event.inputs.logLevel || env.LOG_LEVEL }} + RENOVATE_ALLOWED_COMMANDS: '["^nix .+"]' + with: + configurationFile: .github/renovate.json5 + token: '${{ steps.get-github-app-token.outputs.token }}' + mount-docker-socket: true + docker-user: root + docker-cmd-file: .github/renovate-entrypoint.sh diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml new file mode 100644 index 0000000..e4cf493 --- /dev/null +++ b/.github/workflows/stale.yml @@ -0,0 +1,49 @@ +name: Stale Issues and PRs +on: + schedule: + # Process new stale issues once a day. Folks can /fresh for a fast un-stale + # per the commands workflow. Run at 1:15 mostly as a somewhat unique time to + # help correlate any issues with this workflow. + - cron: '15 1 * * *' + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + stale: + permissions: + issues: write # for labeling/commenting on issues + pull-requests: write # for labeling/commenting on PRs + runs-on: ubuntu-24.04 + steps: + - uses: actions/stale@997185467fa4f803885201cee163a9f38240193d # v10 + with: + # This action uses ~2 operations per stale issue per run to determine + # whether it's still stale. It also uses 2-3 operations to mark an issue + # stale or not. During steady state (no issues to mark stale, check, or + # close) we seem to use less than 10 operations with ~150 issues and PRs + # open. + # + # Our hourly rate-limit budget for all workflows that use GITHUB_TOKEN + # is 1,000 requests per the below docs. + # https://docs.github.com/en/rest/overview/resources-in-the-rest-api#requests-from-github-actions + operations-per-run: 100 + days-before-stale: 90 + days-before-close: 14 + stale-issue-label: stale + exempt-issue-labels: exempt-from-stale + stale-issue-message: > + Crossplane does not currently have enough maintainers to address every + issue and pull request. This issue has been automatically marked as + `stale` because it has had no activity in the last 90 days. It will be + closed in 14 days if no further activity occurs. Leaving a comment + **starting with** `/fresh` will mark this issue as not stale. + stale-pr-label: stale + exempt-pr-labels: exempt-from-stale + stale-pr-message: + Crossplane does not currently have enough maintainers to address every + issue and pull request. This pull request has been automatically + marked as `stale` because it has had no activity in the last 90 days. + It will be closed in 14 days if no further activity occurs. + Adding a comment **starting with** `/fresh` will mark this PR as not stale. diff --git a/.github/workflows/tag.yml b/.github/workflows/tag.yml new file mode 100644 index 0000000..91052e8 --- /dev/null +++ b/.github/workflows/tag.yml @@ -0,0 +1,31 @@ +name: Tag + +on: + workflow_dispatch: + inputs: + version: + description: 'Release version (e.g. v0.1.0)' + required: true + message: + description: 'Tag message' + required: true + +permissions: + contents: read + +jobs: + create-tag: + permissions: + contents: write # for creating git tags + runs-on: ubuntu-24.04 + + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Create Tag + uses: negz/create-tag@39bae1e0932567a58c20dea5a1a0d18358503320 # v1 + with: + version: ${{ github.event.inputs.version }} + message: ${{ github.event.inputs.message }} + token: ${{ secrets.GITHUB_TOKEN }} From 86dd11e7be0dd0d7f40c292a8b1e24bb89772729 Mon Sep 17 00:00:00 2001 From: Adam Wolfe Gordon Date: Tue, 5 May 2026 09:32:07 -0600 Subject: [PATCH 04/23] Import the crank CLI from crossplane/crossplane This commit contains the current `cmd/crank` from c/c and the supporting `internal/docker` package, with imports updated as necessary. Signed-off-by: Adam Wolfe Gordon --- cmd/crossplane/alpha/alpha.go | 36 + cmd/crossplane/alpha/render/cmd.go | 41 + cmd/crossplane/alpha/render/op/cmd.go | 370 ++++ cmd/crossplane/alpha/render/op/cmd_test.go | 385 ++++ cmd/crossplane/alpha/render/op/load.go | 114 ++ cmd/crossplane/alpha/render/op/load_test.go | 389 ++++ .../render/op/testdata/cmd/functions.yaml | 8 + .../op/testdata/cmd/operation-not-op.yaml | 6 + .../render/op/testdata/cmd/operation.yaml | 10 + .../cmd/output/include-full-operation.yaml | 12 + .../cmd/output/include-function-results.yaml | 18 + .../op/testdata/cmd/output/success.yaml | 19 + .../render/op/testdata/cmd/watched-multi.yaml | 11 + cmd/crossplane/alpha/render/xr/cmd.go | 99 + cmd/crossplane/beta/beta.go | 42 + .../convert/compositionenvironment/cmd.go | 134 ++ .../compositionenvironment/converter.go | 111 ++ .../compositionenvironment/converter_test.go | 229 +++ cmd/crossplane/beta/convert/convert.go | 43 + cmd/crossplane/beta/convert/io/io.go | 79 + cmd/crossplane/beta/top/top.go | 304 +++ cmd/crossplane/beta/top/top_test.go | 373 ++++ .../beta/trace/internal/printer/default.go | 433 +++++ .../trace/internal/printer/default_test.go | 263 +++ .../beta/trace/internal/printer/dot.go | 210 ++ .../beta/trace/internal/printer/dot_test.go | 188 ++ .../beta/trace/internal/printer/json.go | 65 + .../beta/trace/internal/printer/json_test.go | 682 +++++++ .../beta/trace/internal/printer/printer.go | 73 + .../trace/internal/printer/printer_test.go | 293 +++ .../beta/trace/internal/printer/yaml.go | 65 + .../beta/trace/internal/printer/yaml_test.go | 472 +++++ cmd/crossplane/beta/trace/trace.go | 341 ++++ cmd/crossplane/beta/trace/trace_test.go | 105 + cmd/crossplane/beta/trace/watch.go | 229 +++ cmd/crossplane/beta/validate/cache.go | 231 +++ cmd/crossplane/beta/validate/cache_test.go | 343 ++++ cmd/crossplane/beta/validate/cmd.go | 153 ++ cmd/crossplane/beta/validate/image.go | 387 ++++ cmd/crossplane/beta/validate/image_test.go | 223 +++ cmd/crossplane/beta/validate/manager.go | 365 ++++ cmd/crossplane/beta/validate/manager_test.go | 517 +++++ .../provider-nop@v0.2.0/package.yaml | 349 ++++ .../provider-dummy@v1.0.0/crd.yaml | 4 + .../folder/nested-folder/resource-a.yaml | 9 + .../validate/testdata/folder/resource-b.yaml | 9 + .../beta/validate/testdata/resources.yaml | 18 + .../beta/validate/unknown_fields.go | 43 + cmd/crossplane/beta/validate/validate.go | 237 +++ cmd/crossplane/beta/validate/validate_test.go | 1712 +++++++++++++++++ cmd/crossplane/common/crd/crd.go | 70 + cmd/crossplane/common/load/loader.go | 311 +++ cmd/crossplane/common/load/loader_test.go | 503 +++++ .../folder/nested-folder/resource-a.yaml | 9 + .../load/testdata/folder/resource-b.yaml | 9 + .../common/load/testdata/resources.yaml | 18 + cmd/crossplane/common/load/testutils/mocks.go | 15 + .../common/loggerwriter/logger_writer.go | 38 + cmd/crossplane/common/package.go | 2 + cmd/crossplane/common/resource/client.go | 72 + cmd/crossplane/common/resource/resource.go | 59 + cmd/crossplane/common/resource/xpkg/client.go | 340 ++++ .../common/resource/xpkg/client_test.go | 421 ++++ cmd/crossplane/common/resource/xpkg/xpkg.go | 71 + .../common/resource/xpkg/xpkg_test.go | 254 +++ cmd/crossplane/common/resource/xrm/client.go | 182 ++ .../common/resource/xrm/client_test.go | 327 ++++ cmd/crossplane/common/resource/xrm/loader.go | 155 ++ .../common/resource/xrm/loader_test.go | 194 ++ cmd/crossplane/completion/completion.go | 277 +++ cmd/crossplane/internal/client.go | 62 + cmd/crossplane/main.go | 79 +- cmd/crossplane/render/cmd.go | 452 +++++ cmd/crossplane/render/cmd_test.go | 500 +++++ cmd/crossplane/render/context.go | 56 + cmd/crossplane/render/contextfn/context.go | 141 ++ .../render/contextfn/context_test.go | 155 ++ cmd/crossplane/render/contextfn/listener.go | 151 ++ .../render/contextfn/listener_test.go | 87 + cmd/crossplane/render/contextfn/wire.go | 86 + cmd/crossplane/render/convert.go | 323 ++++ cmd/crossplane/render/engine.go | 78 + cmd/crossplane/render/engine_docker.go | 143 ++ cmd/crossplane/render/engine_local.go | 73 + cmd/crossplane/render/engine_mock.go | 63 + cmd/crossplane/render/load.go | 279 +++ cmd/crossplane/render/load_test.go | 590 ++++++ cmd/crossplane/render/network.go | 59 + cmd/crossplane/render/render.go | 217 +++ cmd/crossplane/render/render_test.go | 241 +++ cmd/crossplane/render/runtime.go | 92 + cmd/crossplane/render/runtime_development.go | 65 + cmd/crossplane/render/runtime_docker.go | 512 +++++ cmd/crossplane/render/runtime_docker_test.go | 249 +++ cmd/crossplane/render/schemas.go | 72 + cmd/crossplane/render/schemas_test.go | 140 ++ .../cmd/composition-label-mismatch.yaml | 15 + .../cmd/composition-not-pipeline.yaml | 9 + .../render/testdata/cmd/composition.yaml | 13 + .../render/testdata/cmd/functions.yaml | 8 + .../testdata/cmd/output/include-full-xr.yaml | 10 + .../cmd/output/include-function-results.yaml | 14 + .../render/testdata/cmd/output/success.yaml | 17 + .../render/testdata/cmd/xr-extra-spec.yaml | 7 + .../render/testdata/cmd/xr-with-selector.yaml | 10 + .../testdata/cmd/xr-wrong-apiversion.yaml | 6 + .../render/testdata/cmd/xr-wrong-kind.yaml | 6 + cmd/crossplane/render/testdata/cmd/xr.yaml | 6 + .../render/testdata/composition.yaml | 14 + .../render/testdata/extra-resources.yaml | 20 + cmd/crossplane/render/testdata/functions.yaml | 30 + cmd/crossplane/render/testdata/observed.yaml | 18 + cmd/crossplane/render/testdata/xr.yaml | 6 + cmd/crossplane/render/testdata/xrd.yaml | 26 + cmd/crossplane/render/xrd.go | 43 + cmd/crossplane/render/xrd_test.go | 251 +++ cmd/crossplane/version/fetch.go | 90 + cmd/crossplane/version/version.go | 61 + cmd/crossplane/xpkg/batch.go | 570 ++++++ cmd/crossplane/xpkg/build.go | 231 +++ cmd/crossplane/xpkg/extract.go | 229 +++ cmd/crossplane/xpkg/extract_test.go | 137 ++ cmd/crossplane/xpkg/init.go | 307 +++ cmd/crossplane/xpkg/init_test.go | 84 + cmd/crossplane/xpkg/install.go | 236 +++ cmd/crossplane/xpkg/push.go | 249 +++ cmd/crossplane/xpkg/service_metadata.go | 114 ++ cmd/crossplane/xpkg/service_metadata_test.go | 315 +++ cmd/crossplane/xpkg/template_funcs.go | 76 + cmd/crossplane/xpkg/template_funcs_test.go | 112 ++ cmd/crossplane/xpkg/testdata/NOTES.txt | 2 + cmd/crossplane/xpkg/update.go | 135 ++ cmd/crossplane/xpkg/xpkg.go | 47 + go.mod | 252 ++- go.sum | 1186 ++++++++++++ gomod2nix.toml | 737 ++++++- internal/docker/docker.go | 477 +++++ 137 files changed, 25315 insertions(+), 5 deletions(-) create mode 100644 cmd/crossplane/alpha/alpha.go create mode 100644 cmd/crossplane/alpha/render/cmd.go create mode 100644 cmd/crossplane/alpha/render/op/cmd.go create mode 100644 cmd/crossplane/alpha/render/op/cmd_test.go create mode 100644 cmd/crossplane/alpha/render/op/load.go create mode 100644 cmd/crossplane/alpha/render/op/load_test.go create mode 100644 cmd/crossplane/alpha/render/op/testdata/cmd/functions.yaml create mode 100644 cmd/crossplane/alpha/render/op/testdata/cmd/operation-not-op.yaml create mode 100644 cmd/crossplane/alpha/render/op/testdata/cmd/operation.yaml create mode 100644 cmd/crossplane/alpha/render/op/testdata/cmd/output/include-full-operation.yaml create mode 100644 cmd/crossplane/alpha/render/op/testdata/cmd/output/include-function-results.yaml create mode 100644 cmd/crossplane/alpha/render/op/testdata/cmd/output/success.yaml create mode 100644 cmd/crossplane/alpha/render/op/testdata/cmd/watched-multi.yaml create mode 100644 cmd/crossplane/alpha/render/xr/cmd.go create mode 100644 cmd/crossplane/beta/beta.go create mode 100644 cmd/crossplane/beta/convert/compositionenvironment/cmd.go create mode 100644 cmd/crossplane/beta/convert/compositionenvironment/converter.go create mode 100644 cmd/crossplane/beta/convert/compositionenvironment/converter_test.go create mode 100644 cmd/crossplane/beta/convert/convert.go create mode 100644 cmd/crossplane/beta/convert/io/io.go create mode 100644 cmd/crossplane/beta/top/top.go create mode 100644 cmd/crossplane/beta/top/top_test.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/default.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/default_test.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/dot.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/dot_test.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/json.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/json_test.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/printer.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/printer_test.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/yaml.go create mode 100644 cmd/crossplane/beta/trace/internal/printer/yaml_test.go create mode 100644 cmd/crossplane/beta/trace/trace.go create mode 100644 cmd/crossplane/beta/trace/trace_test.go create mode 100644 cmd/crossplane/beta/trace/watch.go create mode 100644 cmd/crossplane/beta/validate/cache.go create mode 100644 cmd/crossplane/beta/validate/cache_test.go create mode 100644 cmd/crossplane/beta/validate/cmd.go create mode 100644 cmd/crossplane/beta/validate/image.go create mode 100644 cmd/crossplane/beta/validate/image_test.go create mode 100644 cmd/crossplane/beta/validate/manager.go create mode 100644 cmd/crossplane/beta/validate/manager_test.go create mode 100644 cmd/crossplane/beta/validate/testdata/cache/xpkg.crossplane.io/crossplane-contrib/provider-nop@v0.2.0/package.yaml create mode 100644 cmd/crossplane/beta/validate/testdata/crds/xpkg.crossplane.io/provider-dummy@v1.0.0/crd.yaml create mode 100644 cmd/crossplane/beta/validate/testdata/folder/nested-folder/resource-a.yaml create mode 100644 cmd/crossplane/beta/validate/testdata/folder/resource-b.yaml create mode 100644 cmd/crossplane/beta/validate/testdata/resources.yaml create mode 100644 cmd/crossplane/beta/validate/unknown_fields.go create mode 100644 cmd/crossplane/beta/validate/validate.go create mode 100644 cmd/crossplane/beta/validate/validate_test.go create mode 100644 cmd/crossplane/common/crd/crd.go create mode 100644 cmd/crossplane/common/load/loader.go create mode 100644 cmd/crossplane/common/load/loader_test.go create mode 100644 cmd/crossplane/common/load/testdata/folder/nested-folder/resource-a.yaml create mode 100644 cmd/crossplane/common/load/testdata/folder/resource-b.yaml create mode 100644 cmd/crossplane/common/load/testdata/resources.yaml create mode 100644 cmd/crossplane/common/load/testutils/mocks.go create mode 100644 cmd/crossplane/common/loggerwriter/logger_writer.go create mode 100644 cmd/crossplane/common/package.go create mode 100644 cmd/crossplane/common/resource/client.go create mode 100644 cmd/crossplane/common/resource/resource.go create mode 100644 cmd/crossplane/common/resource/xpkg/client.go create mode 100644 cmd/crossplane/common/resource/xpkg/client_test.go create mode 100644 cmd/crossplane/common/resource/xpkg/xpkg.go create mode 100644 cmd/crossplane/common/resource/xpkg/xpkg_test.go create mode 100644 cmd/crossplane/common/resource/xrm/client.go create mode 100644 cmd/crossplane/common/resource/xrm/client_test.go create mode 100644 cmd/crossplane/common/resource/xrm/loader.go create mode 100644 cmd/crossplane/common/resource/xrm/loader_test.go create mode 100644 cmd/crossplane/completion/completion.go create mode 100644 cmd/crossplane/internal/client.go create mode 100644 cmd/crossplane/render/cmd.go create mode 100644 cmd/crossplane/render/cmd_test.go create mode 100644 cmd/crossplane/render/context.go create mode 100644 cmd/crossplane/render/contextfn/context.go create mode 100644 cmd/crossplane/render/contextfn/context_test.go create mode 100644 cmd/crossplane/render/contextfn/listener.go create mode 100644 cmd/crossplane/render/contextfn/listener_test.go create mode 100644 cmd/crossplane/render/contextfn/wire.go create mode 100644 cmd/crossplane/render/convert.go create mode 100644 cmd/crossplane/render/engine.go create mode 100644 cmd/crossplane/render/engine_docker.go create mode 100644 cmd/crossplane/render/engine_local.go create mode 100644 cmd/crossplane/render/engine_mock.go create mode 100644 cmd/crossplane/render/load.go create mode 100644 cmd/crossplane/render/load_test.go create mode 100644 cmd/crossplane/render/network.go create mode 100644 cmd/crossplane/render/render.go create mode 100644 cmd/crossplane/render/render_test.go create mode 100644 cmd/crossplane/render/runtime.go create mode 100644 cmd/crossplane/render/runtime_development.go create mode 100644 cmd/crossplane/render/runtime_docker.go create mode 100644 cmd/crossplane/render/runtime_docker_test.go create mode 100644 cmd/crossplane/render/schemas.go create mode 100644 cmd/crossplane/render/schemas_test.go create mode 100644 cmd/crossplane/render/testdata/cmd/composition-label-mismatch.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/composition-not-pipeline.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/composition.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/functions.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/output/include-full-xr.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/output/include-function-results.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/output/success.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/xr-extra-spec.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/xr-with-selector.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/xr-wrong-apiversion.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/xr-wrong-kind.yaml create mode 100644 cmd/crossplane/render/testdata/cmd/xr.yaml create mode 100644 cmd/crossplane/render/testdata/composition.yaml create mode 100644 cmd/crossplane/render/testdata/extra-resources.yaml create mode 100644 cmd/crossplane/render/testdata/functions.yaml create mode 100644 cmd/crossplane/render/testdata/observed.yaml create mode 100644 cmd/crossplane/render/testdata/xr.yaml create mode 100644 cmd/crossplane/render/testdata/xrd.yaml create mode 100644 cmd/crossplane/render/xrd.go create mode 100644 cmd/crossplane/render/xrd_test.go create mode 100644 cmd/crossplane/version/fetch.go create mode 100644 cmd/crossplane/version/version.go create mode 100644 cmd/crossplane/xpkg/batch.go create mode 100644 cmd/crossplane/xpkg/build.go create mode 100644 cmd/crossplane/xpkg/extract.go create mode 100644 cmd/crossplane/xpkg/extract_test.go create mode 100644 cmd/crossplane/xpkg/init.go create mode 100644 cmd/crossplane/xpkg/init_test.go create mode 100644 cmd/crossplane/xpkg/install.go create mode 100644 cmd/crossplane/xpkg/push.go create mode 100644 cmd/crossplane/xpkg/service_metadata.go create mode 100644 cmd/crossplane/xpkg/service_metadata_test.go create mode 100644 cmd/crossplane/xpkg/template_funcs.go create mode 100644 cmd/crossplane/xpkg/template_funcs_test.go create mode 100644 cmd/crossplane/xpkg/testdata/NOTES.txt create mode 100644 cmd/crossplane/xpkg/update.go create mode 100644 cmd/crossplane/xpkg/xpkg.go create mode 100644 internal/docker/docker.go diff --git a/cmd/crossplane/alpha/alpha.go b/cmd/crossplane/alpha/alpha.go new file mode 100644 index 0000000..c319693 --- /dev/null +++ b/cmd/crossplane/alpha/alpha.go @@ -0,0 +1,36 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package alpha contains alpha Crossplane CLI subcommands. +// These commands are experimental, and may be changed or removed in a future +// release. +package alpha + +import ( + "github.com/crossplane/cli/v2/cmd/crossplane/alpha/render" +) + +// Cmd contains alpha commands. +type Cmd struct { + // Subcommands and flags will appear in the CLI help output in the same + // order they're specified here. Keep them in alphabetical order. + Render render.Cmd `cmd:"" help:"Render resources."` +} + +// Help output for crossplane alpha. +func (c *Cmd) Help() string { + return "WARNING: These commands are experimental and may be changed or removed in a future release." +} diff --git a/cmd/crossplane/alpha/render/cmd.go b/cmd/crossplane/alpha/render/cmd.go new file mode 100644 index 0000000..65b3cb8 --- /dev/null +++ b/cmd/crossplane/alpha/render/cmd.go @@ -0,0 +1,41 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package render implements alpha rendering commands. +package render + +import ( + "github.com/crossplane/cli/v2/cmd/crossplane/alpha/render/op" + "github.com/crossplane/cli/v2/cmd/crossplane/alpha/render/xr" +) + +// Cmd contains alpha render subcommands. +type Cmd struct { + // Subcommands and flags will appear in the CLI help output in the same + // order they're specified here. Keep them in alphabetical order. + Op op.Cmd `cmd:"" help:"Render an operation."` + XR xr.Cmd `cmd:"" help:"Render a composite resource (XR)."` +} + +// Help output for crossplane alpha render. +func (c *Cmd) Help() string { + return ` +Render Crossplane resources locally using functions. + +These commands show you what resources Crossplane would create or mutate by +running function pipelines locally, without talking to a Crossplane control plane. +` +} diff --git a/cmd/crossplane/alpha/render/op/cmd.go b/cmd/crossplane/alpha/render/op/cmd.go new file mode 100644 index 0000000..8837285 --- /dev/null +++ b/cmd/crossplane/alpha/render/op/cmd.go @@ -0,0 +1,370 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package op implements operation rendering using operation functions. +package op + +import ( + "context" + "fmt" + "time" + + "github.com/alecthomas/kong" + "github.com/spf13/afero" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + kjson "k8s.io/apimachinery/pkg/runtime/serializer/json" + "k8s.io/kube-openapi/pkg/spec3" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + opsv1alpha1 "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + "github.com/crossplane/cli/v2/cmd/crossplane/render/contextfn" +) + +// Cmd arguments and flags for alpha render op subcommand. +type Cmd struct { + render.EngineFlags `prefix:""` + + // Arguments. + Operation string `arg:"" help:"A YAML file specifying the Operation to render." predictor:"yaml_file" type:"existingfile"` + Functions string `arg:"" help:"A YAML file or directory of YAML files specifying the operation functions to use to render the Operation." predictor:"yaml_file_or_directory" type:"path"` + + // Flags. Keep them in alphabetical order. + ContextFiles map[string]string `help:"Comma-separated context key-value pairs to pass to the function pipeline. Values must be files containing JSON." mapsep:"" predictor:"file"` + ContextValues map[string]string `help:"Comma-separated context key-value pairs to pass to the function pipeline. Values must be JSON. Keys take precedence over --context-files." mapsep:""` + FunctionCredentials string `help:"A YAML file or directory of YAML files specifying credentials to use for functions." placeholder:"PATH" predictor:"yaml_file_or_directory" type:"path"` + FunctionAnnotations []string `help:"Override function annotations for all functions. Can be repeated." placeholder:"KEY=VALUE" short:"a"` + IncludeContext bool `help:"Include the context in the rendered output as a resource of kind: Context." short:"c"` + IncludeFullOperation bool `help:"Include a direct copy of the input Operation's spec and metadata fields in the rendered output." short:"o"` + IncludeFunctionResults bool `help:"Include informational and warning messages from functions in the rendered output as resources of kind: Result." short:"r"` + RequiredResources string `help:"A YAML file or directory of YAML files specifying required resources to pass to the function pipeline." placeholder:"PATH" predictor:"yaml_file_or_directory" short:"e" type:"path"` + RequiredSchemas string `help:"A directory of JSON files specifying OpenAPI schemas to pass to the function pipeline." placeholder:"DIR" predictor:"directory" type:"path"` + WatchedResource string `help:"A YAML file specifying the watched resource for WatchOperation rendering. The resource is also added to required resources." placeholder:"PATH" predictor:"yaml_file" short:"w" type:"existingfile"` + + Timeout time.Duration `default:"1m" help:"How long to run before timing out."` + + fs afero.Fs + + // newEngine constructs the render Engine. + newEngine func(*render.EngineFlags, logging.Logger) render.Engine +} + +// Help prints out the help for the alpha render op command. +func (c *Cmd) Help() string { + return ` +This command shows you what resources an Operation would create or mutate by +printing them to stdout. It runs the Crossplane render engine to produce +high-fidelity output that matches what the real reconciler would produce. + +For Operations, it runs the operation function pipeline and shows what +resources the operation would mutate. + +Functions are pulled and run using Docker by default. You can add +the following annotations to each function to change how they're run: + + render.crossplane.io/runtime: "Development" + + Connect to a function that is already running, instead of using Docker. This + is useful to develop and debug new functions. The function must be listening + at localhost:9443 and running with the --insecure flag. + + render.crossplane.io/runtime-development-target: "dns:///example.org:7443" + + Connect to a function running somewhere other than localhost:9443. The + target uses gRPC target syntax. + + render.crossplane.io/runtime-docker-cleanup: "Orphan" + + Don't stop the function's Docker container after rendering. + + render.crossplane.io/runtime-docker-name: "" + + create a container with that name and also reuse it as long as it is running or can be restarted. + + render.crossplane.io/runtime-docker-pull-policy: "Always" + + Always pull the function's package, even if it already exists locally. + Other supported values are Never, or IfNotPresent. + +Use the standard DOCKER_HOST, DOCKER_API_VERSION, DOCKER_CERT_PATH, and +DOCKER_TLS_VERIFY environment variables to configure how this command connects +to the Docker daemon. + +Examples: + + # Render an Operation. + crossplane alpha render op operation.yaml functions.yaml + + # Pin the Crossplane version used for rendering. + crossplane alpha render op operation.yaml functions.yaml \ + --crossplane-version=v2.2.1 + + # Use a local crossplane binary instead of Docker. + crossplane alpha render op operation.yaml functions.yaml \ + --crossplane-binary=/usr/local/bin/crossplane + + # Pass context values to the function pipeline. + crossplane alpha render op operation.yaml functions.yaml \ + --context-values=apiextensions.crossplane.io/environment='{"key": "value"}' + + # Pass required resources functions can request. + crossplane alpha render op operation.yaml functions.yaml \ + --required-resources=required-resources.yaml + + # Pass OpenAPI schemas for functions that need them. + crossplane alpha render op operation.yaml functions.yaml \ + --required-schemas=schemas/ + + # Render a WatchOperation with a watched resource. + crossplane alpha render op watchoperation.yaml functions.yaml \ + --watched-resource=watched-configmap.yaml + + # Pass credentials to functions that need them. + crossplane alpha render op operation.yaml functions.yaml \ + --function-credentials=credentials.yaml + + # Include function results and context in output. + crossplane alpha render op operation.yaml functions.yaml -r -c + + # Include the full Operation with original spec and metadata. + crossplane alpha render op operation.yaml functions.yaml -o + + # Override function annotations for remote Docker daemon. + crossplane alpha render op operation.yaml functions.yaml \ + -a render.crossplane.io/runtime-docker-publish-address=0.0.0.0 \ + -a render.crossplane.io/runtime-docker-target=192.168.1.100 + + # Use development runtime with custom target. + crossplane alpha render op operation.yaml functions.yaml \ + -a render.crossplane.io/runtime=Development \ + -a render.crossplane.io/runtime-development-target=localhost:9444 +` +} + +// AfterApply implements kong.AfterApply. +func (c *Cmd) AfterApply() error { + c.fs = afero.NewOsFs() + c.newEngine = render.NewEngineFromFlags + + return nil +} + +// Run alpha render op. +func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit // Orchestration is inherently complex. + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + + // Load operation (extracts Operation template from CronOperation/WatchOperation) + op, err := LoadOperation(c.fs, c.Operation) + if err != nil { + return err + } + + // Load required resources + rrs := []unstructured.Unstructured{} + if c.RequiredResources != "" { + rrs, err = render.LoadRequiredResources(c.fs, c.RequiredResources) + if err != nil { + return errors.Wrapf(err, "cannot load required resources from %q", c.RequiredResources) + } + } + + // Load required schemas + rsc := []spec3.OpenAPI{} + if c.RequiredSchemas != "" { + rsc, err = render.LoadRequiredSchemas(c.fs, c.RequiredSchemas) + if err != nil { + return errors.Wrapf(err, "cannot load required schemas from %q", c.RequiredSchemas) + } + } + + // Handle watched resource for WatchOperation rendering + if c.WatchedResource != "" { + watched, err := render.LoadRequiredResources(c.fs, c.WatchedResource) + if err != nil { + return errors.Wrapf(err, "cannot load watched resource from %q", c.WatchedResource) + } + + if len(watched) != 1 { + return errors.Errorf("--watched-resource must contain exactly one resource, got %d", len(watched)) + } + + // Inject selector into all pipeline steps (replicates WatchOperation controller behavior) + InjectWatchedResource(op, &watched[0]) + + // Add to required resources so it can be fetched by functions + rrs = append(rrs, watched[0]) + } + + // Load functions + fns, err := render.LoadFunctions(c.fs, c.Functions) + if err != nil { + return err + } + + // Apply global annotation overrides to each function + if err := render.OverrideFunctionAnnotations(fns, c.FunctionAnnotations); err != nil { + return errors.Wrap(err, "cannot apply function annotation overrides") + } + + // Load function credentials + fcreds := []corev1.Secret{} + if c.FunctionCredentials != "" { + fcreds, err = render.LoadCredentials(c.fs, c.FunctionCredentials) + if err != nil { + return errors.Wrapf(err, "cannot load function credentials from %q", c.FunctionCredentials) + } + } + + engine := c.newEngine(&c.EngineFlags, log) + + seedCtx := len(c.ContextValues) > 0 || len(c.ContextFiles) > 0 + captureCtx := c.IncludeContext + + var ctxHandle *contextfn.Handle + if seedCtx || captureCtx { + if err := engine.CheckContextSupport(); err != nil { + return err + } + + raw, err := render.BuildContextData(c.fs, c.ContextFiles, c.ContextValues) + if err != nil { + return errors.Wrap(err, "cannot build context data") + } + + parsed, err := render.ParseContextData(raw) + if err != nil { + return errors.Wrap(err, "cannot parse context data") + } + + ctxHandle, err = contextfn.Start(ctx, log, parsed) + if err != nil { + return errors.Wrap(err, "cannot start context function") + } + defer ctxHandle.Stop() + + fns = append(fns, ctxHandle.Function()) + if seedCtx { + op.Spec.Pipeline = append([]opsv1alpha1.PipelineStep{ctxHandle.OperationSeedStep()}, op.Spec.Pipeline...) + } + if captureCtx { + op.Spec.Pipeline = append(op.Spec.Pipeline, ctxHandle.OperationCaptureStep()) + } + } + + cleanup, err := engine.Setup(ctx, fns) + if err != nil { + return err + } + defer cleanup() + + // Start function runtimes to get their addresses. + fnAddrs, err := render.StartFunctionRuntimes(ctx, log, fns) + if err != nil { + return errors.Wrap(err, "cannot start function runtimes") + } + defer render.StopFunctionRuntimes(log, fnAddrs) + + addrs := fnAddrs.Addresses() + if ctxHandle != nil { + addrs[contextfn.FunctionName] = ctxHandle.Target + } + + // Build and execute the render request. + in := render.OperationInputs{ + Operation: op, + FunctionAddrs: addrs, + RequiredResources: rrs, + RequiredSchemas: rsc, + FunctionCredentials: fcreds, + } + req, err := render.BuildOperationRequest(in) + if err != nil { + return errors.Wrap(err, "cannot build render request") + } + + rsp, err := engine.Render(ctx, req) + if err != nil { + return errors.Wrap(err, "cannot render operation") + } + + operationOut := rsp.GetOperation() + if operationOut == nil { + return errors.New("render response does not contain an operation output") + } + + out, err := render.ParseOperationResponse(operationOut) + if err != nil { + return errors.Wrap(err, "cannot parse render response") + } + + if captureCtx && ctxHandle != nil { + if s := ctxHandle.Captured(); s != nil { + out.Context = &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "render.crossplane.io/v1beta1", + "kind": "Context", + "fields": s.AsMap(), + }} + } + } + + // Output results + s := kjson.NewSerializerWithOptions(kjson.DefaultMetaFactory, nil, nil, kjson.SerializerOptions{Yaml: true}) + + // Only include spec when IncludeFullOperation flag is set + if c.IncludeFullOperation && out.Operation != nil { + out.Operation.Spec = *op.Spec.DeepCopy() + } + + // Always output the Operation (with metadata and status, optionally with spec) + if out.Operation != nil { + _, _ = fmt.Fprintln(k.Stdout, "---") + if err := s.Encode(out.Operation, k.Stdout); err != nil { + return errors.Wrapf(err, "cannot marshal operation %q to YAML", op.GetName()) + } + } + + // Output applied resources + for _, res := range out.AppliedResources { + _, _ = fmt.Fprintln(k.Stdout, "---") + if err := s.Encode(&res, k.Stdout); err != nil { + return errors.Wrap(err, "cannot marshal applied resource to YAML") + } + } + + // Output results if requested + if c.IncludeFunctionResults { + for _, res := range out.Results { + _, _ = fmt.Fprintln(k.Stdout, "---") + if err := s.Encode(&res, k.Stdout); err != nil { + return errors.Wrap(err, "cannot marshal result to YAML") + } + } + } + + if c.IncludeContext && out.Context != nil { + _, _ = fmt.Fprintln(k.Stdout, "---") + if err := s.Encode(out.Context, k.Stdout); err != nil { + return errors.Wrap(err, "cannot marshal context to YAML") + } + } + + return nil +} diff --git a/cmd/crossplane/alpha/render/op/cmd_test.go b/cmd/crossplane/alpha/render/op/cmd_test.go new file mode 100644 index 0000000..880286d --- /dev/null +++ b/cmd/crossplane/alpha/render/op/cmd_test.go @@ -0,0 +1,385 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package op + +import ( + "bytes" + "context" + "io" + "testing" + "testing/fstest" + "time" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" + + _ "embed" +) + +//go:embed testdata/cmd/operation.yaml +var operationYAML string + +//go:embed testdata/cmd/operation-not-op.yaml +var operationNotOpYAML string + +//go:embed testdata/cmd/functions.yaml +var functionsYAML string + +//go:embed testdata/cmd/watched-multi.yaml +var watchedMultiYAML string + +//go:embed testdata/cmd/output/success.yaml +var successOutput string + +//go:embed testdata/cmd/output/include-function-results.yaml +var includeFunctionResultsOutput string + +//go:embed testdata/cmd/output/include-full-operation.yaml +var includeFullOperationOutput string + +func newEngineFunc(engine render.Engine) func(*render.EngineFlags, logging.Logger) render.Engine { + return func(*render.EngineFlags, logging.Logger) render.Engine { + return engine + } +} + +// newTestFS builds an in-memory filesystem seeded with the default happy-path +// fixtures. Entries in extra are overlaid on top; an entry with an empty value +// removes the file from the FS. +func newTestFS(extra map[string]string) afero.Fs { + files := map[string]*fstest.MapFile{ + "operation.yaml": {Data: []byte(operationYAML)}, + "functions.yaml": {Data: []byte(functionsYAML)}, + } + for k, v := range extra { + if v == "" { + delete(files, k) + continue + } + files[k] = &fstest.MapFile{Data: []byte(v)} + } + return afero.FromIOFS{FS: fstest.MapFS(files)} +} + +func mustNewStruct(t *testing.T, data map[string]any) *structpb.Struct { + t.Helper() + s, err := structpb.NewStruct(data) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + return s +} + +func TestCmdRun(t *testing.T) { + type args struct { + cmd Cmd + } + type want struct { + err error + stdout string + } + cases := map[string]struct { + reason string + args args + want want + }{ + "Success": { + reason: "Happy path: load fixtures, render an Operation, and emit YAML for the operation and one applied resource.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&render.MockEngine{ + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Operation{ + Operation: &renderv1alpha1.OperationOutput{ + Operation: req.GetOperation().GetOperation(), + AppliedResources: []*structpb.Struct{ + mustNewStruct(t, map[string]any{ + "apiVersion": "example.org/v1alpha1", + "kind": "AppliedResource", + "metadata": map[string]any{ + "name": "applied-foo", + }, + "spec": map[string]any{"coolField": "applied!"}, + }), + }, + }, + }, + }, nil + }, + }), + }, + }, + want: want{ + stdout: successOutput, + }, + }, + "LoadOperationError": { + reason: "Missing operation file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + Operation: "missing.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadOperationWrongKind": { + reason: "An input that is not an Operation/CronOperation/WatchOperation should error.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(map[string]string{"operation.yaml": operationNotOpYAML}), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadRequiredResourcesError": { + reason: "Missing required resources file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + RequiredResources: "missing.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadRequiredSchemasError": { + reason: "Missing required schemas directory should return a wrapped load error.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + RequiredSchemas: "missing", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadWatchedResourceError": { + reason: "Missing watched resource file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + WatchedResource: "missing.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "WatchedResourceNotExactlyOne": { + reason: "The watched resource file must contain exactly one resource.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + WatchedResource: "watched.yaml", + Timeout: time.Minute, + fs: newTestFS(map[string]string{"watched.yaml": watchedMultiYAML}), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadFunctionsError": { + reason: "Missing functions file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "missing.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "InvalidAnnotationOverride": { + reason: "Function annotation overrides must be in key=value form.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + FunctionAnnotations: []string{"not-a-key-value"}, + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadFunctionCredentialsError": { + reason: "Missing function credentials file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + FunctionCredentials: "missing.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "EngineSetupError": { + reason: "Engine.Setup failures should propagate.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&render.MockEngine{ + MockSetup: func(_ context.Context, _ []pkgv1.Function) (func(), error) { + return func() {}, errors.New("setup blew up") + }, + }), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "EngineRenderError": { + reason: "Engine.Render failures should be wrapped.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&render.MockEngine{ + MockRender: func(_ context.Context, _ *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return nil, errors.New("render blew up") + }, + }), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "RenderResponseMissingOperation": { + reason: "A RenderResponse without an operation output should error.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&render.MockEngine{ + MockRender: func(_ context.Context, _ *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{}, nil + }, + }), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "IncludeFunctionResults": { + reason: "When --include-function-results is set, Result documents should appear in stdout.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + IncludeFunctionResults: true, + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&render.MockEngine{ + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Operation{ + Operation: &renderv1alpha1.OperationOutput{ + Operation: req.GetOperation().GetOperation(), + Events: []*renderv1alpha1.Event{{ + Type: "Normal", + Reason: "Hello", + Message: "function says hi", + }}, + }, + }, + }, nil + }, + }), + }, + }, + want: want{ + stdout: includeFunctionResultsOutput, + }, + }, + "IncludeFullOperation": { + reason: "With --include-full-operation, the rendered Operation includes the original spec.", + args: args{ + cmd: Cmd{ + Operation: "operation.yaml", + Functions: "functions.yaml", + IncludeFullOperation: true, + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&render.MockEngine{ + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Operation{ + Operation: &renderv1alpha1.OperationOutput{ + Operation: req.GetOperation().GetOperation(), + }, + }, + }, nil + }, + }), + }, + }, + want: want{ + stdout: includeFullOperationOutput, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + buf := &bytes.Buffer{} + kctx := &kong.Context{Kong: &kong.Kong{Stdout: buf, Stderr: io.Discard}} + + err := tc.args.cmd.Run(kctx, logging.NewNopLogger()) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nRun(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.stdout, buf.String()); diff != "" { + t.Errorf("\n%s\nRun(...): -want stdout +got stdout:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/alpha/render/op/load.go b/cmd/crossplane/alpha/render/op/load.go new file mode 100644 index 0000000..eb0979b --- /dev/null +++ b/cmd/crossplane/alpha/render/op/load.go @@ -0,0 +1,114 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package op + +import ( + "github.com/spf13/afero" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" + "k8s.io/utils/ptr" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + opsv1alpha1 "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1" +) + +// LoadOperation loads an Operation from a YAML file. If the file contains a +// CronOperation or WatchOperation, the Operation template is extracted. +func LoadOperation(fs afero.Fs, path string) (*opsv1alpha1.Operation, error) { + data, err := afero.ReadFile(fs, path) + if err != nil { + return nil, errors.Wrapf(err, "cannot read operation file %q", path) + } + + // Peek at the GVK to determine which type to unmarshal into. + var meta metav1.TypeMeta + if err := yaml.Unmarshal(data, &meta); err != nil { + return nil, errors.Wrapf(err, "cannot unmarshal type metadata from %q", path) + } + + // TODO(adamwg): Use `crossplane internal render` to translate + // {Cron,Watch}Operations into Operations, for better fidelity to the real + // Crossplane controllers. + switch gvk := meta.GroupVersionKind(); gvk { + case opsv1alpha1.OperationGroupVersionKind: + op := &opsv1alpha1.Operation{} + if err := yaml.Unmarshal(data, op); err != nil { + return nil, errors.Wrapf(err, "cannot unmarshal Operation from %q", path) + } + return op, nil + + case opsv1alpha1.CronOperationGroupVersionKind: + cop := &opsv1alpha1.CronOperation{} + if err := yaml.Unmarshal(data, cop); err != nil { + return nil, errors.Wrapf(err, "cannot unmarshal CronOperation from %q", path) + } + // Use the template's metadata (labels, annotations) like the real + // controller does. The real controller always overwrites the name with + // a generated one; we use the parent's name for simplicity. + op := &opsv1alpha1.Operation{ + TypeMeta: metav1.TypeMeta{APIVersion: meta.APIVersion, Kind: opsv1alpha1.OperationKind}, + ObjectMeta: cop.Spec.OperationTemplate.ObjectMeta, + Spec: cop.Spec.OperationTemplate.Spec, + } + op.SetName(cop.GetName()) + return op, nil + + case opsv1alpha1.WatchOperationGroupVersionKind: + wop := &opsv1alpha1.WatchOperation{} + if err := yaml.Unmarshal(data, wop); err != nil { + return nil, errors.Wrapf(err, "cannot unmarshal WatchOperation from %q", path) + } + // Use the template's metadata (labels, annotations) like the real + // controller does. The real controller always overwrites the name with + // a generated one; we use the parent's name for simplicity. + op := &opsv1alpha1.Operation{ + TypeMeta: metav1.TypeMeta{APIVersion: meta.APIVersion, Kind: opsv1alpha1.OperationKind}, + ObjectMeta: wop.Spec.OperationTemplate.ObjectMeta, + Spec: *wop.Spec.OperationTemplate.Spec.DeepCopy(), + } + op.SetName(wop.GetName()) + return op, nil + + default: + return nil, errors.Errorf("not an operation type: %s/%s", gvk.Kind, meta.GetObjectKind().GroupVersionKind().GroupVersion()) + } +} + +// InjectWatchedResource adds a RequiredResourceSelector for the watched resource +// to every pipeline step. This replicates what the WatchOperation controller does +// when creating an Operation from a WatchOperation. +func InjectWatchedResource(op *opsv1alpha1.Operation, watched *unstructured.Unstructured) { + sel := opsv1alpha1.RequiredResourceSelector{ + RequirementName: opsv1alpha1.RequirementNameWatchedResource, + APIVersion: watched.GetAPIVersion(), + Kind: watched.GetKind(), + Name: ptr.To(watched.GetName()), + } + if watched.GetNamespace() != "" { + sel.Namespace = ptr.To(watched.GetNamespace()) + } + + for i := range op.Spec.Pipeline { + step := &op.Spec.Pipeline[i] + if step.Requirements == nil { + step.Requirements = &opsv1alpha1.FunctionRequirements{} + } + step.Requirements.RequiredResources = append(step.Requirements.RequiredResources, sel) + } +} diff --git a/cmd/crossplane/alpha/render/op/load_test.go b/cmd/crossplane/alpha/render/op/load_test.go new file mode 100644 index 0000000..6a64fa3 --- /dev/null +++ b/cmd/crossplane/alpha/render/op/load_test.go @@ -0,0 +1,389 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package op + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/utils/ptr" + + opsv1alpha1 "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1" +) + +func TestLoadOperation(t *testing.T) { + type args struct { + fs afero.Fs + path string + } + type want struct { + op *opsv1alpha1.Operation + err error + } + + invalidYAML := "invalid: yaml: content: [" + + notAnOperationYAML := `apiVersion: v1 +kind: ConfigMap +metadata: + name: test-data +data: + foo: bar` + + cronOperationYAML := `apiVersion: ops.crossplane.io/v1alpha1 +kind: CronOperation +metadata: + name: test-operation +spec: + schedule: "*/5 * * * *" + operationTemplate: + spec: + mode: Pipeline + pipeline: + - step: test-step + functionRef: + name: test-function` + + watchOperationYAML := `apiVersion: ops.crossplane.io/v1alpha1 +kind: WatchOperation +metadata: + name: test-operation +spec: + watch: + apiVersion: v1 + kind: Secret + matchLabels: + foo: bar + operationTemplate: + spec: + mode: Pipeline + pipeline: + - step: test-step + functionRef: + name: test-function` + + wrongVersionYAML := `apiVersion: ops.crossplane.io/v1beta1 +kind: Operation +metadata: + name: test-op +spec: + mode: Pipeline + pipeline: + - step: test-step + functionRef: + name: test-function` + + validOperationYAML := `apiVersion: ops.crossplane.io/v1alpha1 +kind: Operation +metadata: + name: test-operation +spec: + mode: Pipeline + pipeline: + - step: test-step + functionRef: + name: test-function` + + validOperation := &opsv1alpha1.Operation{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "ops.crossplane.io/v1alpha1", + Kind: "Operation", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test-operation", + }, + Spec: opsv1alpha1.OperationSpec{ + Mode: opsv1alpha1.OperationModePipeline, + Pipeline: []opsv1alpha1.PipelineStep{ + { + Step: "test-step", + FunctionRef: opsv1alpha1.FunctionReference{ + Name: "test-function", + }, + }, + }, + }, + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "FileNotFound": { + reason: "Should return an error if the operation file doesn't exist", + args: args{ + fs: afero.NewMemMapFs(), + path: "nonexistent.yaml", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "InvalidYAML": { + reason: "Should return an error if the file contains invalid YAML", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "invalid.yaml", []byte(invalidYAML), 0o644) + return fs + }(), + path: "invalid.yaml", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "WrongKind": { + reason: "Should return an error if the resource is not an Operation", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "notop.yaml", []byte(notAnOperationYAML), 0o644) + return fs + }(), + path: "notop.yaml", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "WrongAPIVersion": { + reason: "Should return an error if the API version is not supported", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "wrongversion.yaml", []byte(wrongVersionYAML), 0o644) + return fs + }(), + path: "wrongversion.yaml", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "ValidOperation": { + reason: "Should successfully load a valid Operation", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "operation.yaml", []byte(validOperationYAML), 0o644) + return fs + }(), + path: "operation.yaml", + }, + want: want{ + op: validOperation, + }, + }, + "ValidCronOperation": { + reason: "Should successfully load a valid Operation from a CronOperation", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "cronoperation.yaml", []byte(cronOperationYAML), 0o644) + return fs + }(), + path: "cronoperation.yaml", + }, + want: want{ + op: validOperation, + }, + }, + "ValidWatchOperation": { + reason: "Should successfully load a valid Operation from a WatchOperation without injecting watched resource", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "watchoperation.yaml", []byte(watchOperationYAML), 0o644) + return fs + }(), + path: "watchoperation.yaml", + }, + want: want{ + op: validOperation, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := LoadOperation(tc.args.fs, tc.args.path) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nLoadOperation(...): -want error, +got error:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.op, got); diff != "" { + t.Errorf("\n%s\nLoadOperation(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestInjectWatchedResource(t *testing.T) { + type args struct { + op *opsv1alpha1.Operation + watched *unstructured.Unstructured + } + + sn := "cool-secret" + sns := "default" + + cases := map[string]struct { + reason string + args args + want *opsv1alpha1.Operation + }{ + "InjectIntoAllSteps": { + reason: "Should inject the watched resource selector into all pipeline steps", + args: args{ + op: &opsv1alpha1.Operation{ + Spec: opsv1alpha1.OperationSpec{ + Mode: opsv1alpha1.OperationModePipeline, + Pipeline: []opsv1alpha1.PipelineStep{ + { + Step: "step-one", + FunctionRef: opsv1alpha1.FunctionReference{ + Name: "fn-one", + }, + }, + { + Step: "step-two", + FunctionRef: opsv1alpha1.FunctionReference{ + Name: "fn-two", + }, + }, + }, + }, + }, + watched: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Secret", + "metadata": map[string]any{ + "name": "cool-secret", + "namespace": "default", + }, + }, + }, + }, + want: &opsv1alpha1.Operation{ + Spec: opsv1alpha1.OperationSpec{ + Mode: opsv1alpha1.OperationModePipeline, + Pipeline: []opsv1alpha1.PipelineStep{ + { + Step: "step-one", + FunctionRef: opsv1alpha1.FunctionReference{ + Name: "fn-one", + }, + Requirements: &opsv1alpha1.FunctionRequirements{ + RequiredResources: []opsv1alpha1.RequiredResourceSelector{ + { + RequirementName: opsv1alpha1.RequirementNameWatchedResource, + APIVersion: "v1", + Kind: "Secret", + Name: &sn, + Namespace: &sns, + }, + }, + }, + }, + { + Step: "step-two", + FunctionRef: opsv1alpha1.FunctionReference{ + Name: "fn-two", + }, + Requirements: &opsv1alpha1.FunctionRequirements{ + RequiredResources: []opsv1alpha1.RequiredResourceSelector{ + { + RequirementName: opsv1alpha1.RequirementNameWatchedResource, + APIVersion: "v1", + Kind: "Secret", + Name: &sn, + Namespace: &sns, + }, + }, + }, + }, + }, + }, + }, + }, + "ClusterScopedResource": { + reason: "Should not set namespace for cluster-scoped resources", + args: args{ + op: &opsv1alpha1.Operation{ + Spec: opsv1alpha1.OperationSpec{ + Mode: opsv1alpha1.OperationModePipeline, + Pipeline: []opsv1alpha1.PipelineStep{ + { + Step: "test-step", + FunctionRef: opsv1alpha1.FunctionReference{ + Name: "test-fn", + }, + }, + }, + }, + }, + watched: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Node", + "metadata": map[string]any{ + "name": "my-node", + }, + }, + }, + }, + want: &opsv1alpha1.Operation{ + Spec: opsv1alpha1.OperationSpec{ + Mode: opsv1alpha1.OperationModePipeline, + Pipeline: []opsv1alpha1.PipelineStep{ + { + Step: "test-step", + FunctionRef: opsv1alpha1.FunctionReference{ + Name: "test-fn", + }, + Requirements: &opsv1alpha1.FunctionRequirements{ + RequiredResources: []opsv1alpha1.RequiredResourceSelector{ + { + RequirementName: opsv1alpha1.RequirementNameWatchedResource, + APIVersion: "v1", + Kind: "Node", + Name: ptr.To("my-node"), + }, + }, + }, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + InjectWatchedResource(tc.args.op, tc.args.watched) + if diff := cmp.Diff(tc.want, tc.args.op); diff != "" { + t.Errorf("\n%s\nInjectWatchedResource(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/functions.yaml b/cmd/crossplane/alpha/render/op/testdata/cmd/functions.yaml new file mode 100644 index 0000000..f4df506 --- /dev/null +++ b/cmd/crossplane/alpha/render/op/testdata/cmd/functions.yaml @@ -0,0 +1,8 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Function +metadata: + name: function-dummy + annotations: + render.crossplane.io/runtime: InProcess +spec: + package: xpkg.crossplane.io/example/function-dummy:v0.0.0 diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/operation-not-op.yaml b/cmd/crossplane/alpha/render/op/testdata/cmd/operation-not-op.yaml new file mode 100644 index 0000000..7c282fd --- /dev/null +++ b/cmd/crossplane/alpha/render/op/testdata/cmd/operation-not-op.yaml @@ -0,0 +1,6 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: not-an-operation +data: + foo: bar diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/operation.yaml b/cmd/crossplane/alpha/render/op/testdata/cmd/operation.yaml new file mode 100644 index 0000000..87ae93b --- /dev/null +++ b/cmd/crossplane/alpha/render/op/testdata/cmd/operation.yaml @@ -0,0 +1,10 @@ +apiVersion: ops.crossplane.io/v1alpha1 +kind: Operation +metadata: + name: test-op +spec: + mode: Pipeline + pipeline: + - step: be-a-dummy + functionRef: + name: function-dummy diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/output/include-full-operation.yaml b/cmd/crossplane/alpha/render/op/testdata/cmd/output/include-full-operation.yaml new file mode 100644 index 0000000..b642b5f --- /dev/null +++ b/cmd/crossplane/alpha/render/op/testdata/cmd/output/include-full-operation.yaml @@ -0,0 +1,12 @@ +--- +apiVersion: ops.crossplane.io/v1alpha1 +kind: Operation +metadata: + name: test-op +spec: + mode: Pipeline + pipeline: + - functionRef: + name: function-dummy + step: be-a-dummy +status: {} diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/output/include-function-results.yaml b/cmd/crossplane/alpha/render/op/testdata/cmd/output/include-function-results.yaml new file mode 100644 index 0000000..97224fd --- /dev/null +++ b/cmd/crossplane/alpha/render/op/testdata/cmd/output/include-function-results.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: ops.crossplane.io/v1alpha1 +kind: Operation +metadata: + name: test-op +spec: + mode: Pipeline + pipeline: + - functionRef: + name: function-dummy + step: be-a-dummy +status: {} +--- +apiVersion: render.crossplane.io/v1beta1 +kind: Result +message: function says hi +reason: Hello +severity: Normal diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/output/success.yaml b/cmd/crossplane/alpha/render/op/testdata/cmd/output/success.yaml new file mode 100644 index 0000000..306686d --- /dev/null +++ b/cmd/crossplane/alpha/render/op/testdata/cmd/output/success.yaml @@ -0,0 +1,19 @@ +--- +apiVersion: ops.crossplane.io/v1alpha1 +kind: Operation +metadata: + name: test-op +spec: + mode: Pipeline + pipeline: + - functionRef: + name: function-dummy + step: be-a-dummy +status: {} +--- +apiVersion: example.org/v1alpha1 +kind: AppliedResource +metadata: + name: applied-foo +spec: + coolField: applied! diff --git a/cmd/crossplane/alpha/render/op/testdata/cmd/watched-multi.yaml b/cmd/crossplane/alpha/render/op/testdata/cmd/watched-multi.yaml new file mode 100644 index 0000000..947c6ca --- /dev/null +++ b/cmd/crossplane/alpha/render/op/testdata/cmd/watched-multi.yaml @@ -0,0 +1,11 @@ +apiVersion: v1 +kind: Secret +metadata: + name: watched-one + namespace: default +--- +apiVersion: v1 +kind: Secret +metadata: + name: watched-two + namespace: default diff --git a/cmd/crossplane/alpha/render/xr/cmd.go b/cmd/crossplane/alpha/render/xr/cmd.go new file mode 100644 index 0000000..ef8e951 --- /dev/null +++ b/cmd/crossplane/alpha/render/xr/cmd.go @@ -0,0 +1,99 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package xr implements XR rendering by delegating to the existing render command. +package xr + +import ( + "github.com/alecthomas/kong" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/cmd/crossplane/render" +) + +// Cmd renders a composite resource (XR) by delegating to the existing render command. +type Cmd struct { + render.Cmd +} + +// Help prints out the help for the alpha render xr command. +func (c *Cmd) Help() string { + return ` +This command renders a composite resource (XR) by delegating to the main +crossplane render command. It supports all the same functionality. + +For composite resources (XRs), it requires a Composition in Pipeline mode and +renders the XR using composition functions. + +Functions are pulled and run using Docker by default. You can add +the following annotations to each Function to change how they're run: + + render.crossplane.io/runtime: "Development" + + Connect to a Function that is already running, instead of using Docker. This + is useful to develop and debug new Functions. The Function must be listening + at localhost:9443 and running with the --insecure flag. + + render.crossplane.io/runtime-development-target: "dns:///example.org:7443" + + Connect to a Function running somewhere other than localhost:9443. The + target uses gRPC target syntax. + + render.crossplane.io/runtime-docker-cleanup: "Orphan" + + Don't stop the Function's Docker container after rendering. + + render.crossplane.io/runtime-docker-name: "" + + create a container with that name and also reuse it as long as it is running or can be restarted. + + render.crossplane.io/runtime-docker-pull-policy: "Always" + + Always pull the Function's package, even if it already exists locally. + Other supported values are Never, or IfNotPresent. + +Use the standard DOCKER_HOST, DOCKER_API_VERSION, DOCKER_CERT_PATH, and +DOCKER_TLS_VERIFY environment variables to configure how this command connects +to the Docker daemon. + +Examples: + + # Render a composite resource. + crossplane alpha render xr xr.yaml composition.yaml functions.yaml + + # Simulate updating an XR that already exists. + crossplane alpha render xr xr.yaml composition.yaml functions.yaml \ + --observed-resources=existing-observed-resources.yaml + + # Pass context values to the Function pipeline. + crossplane alpha render xr xr.yaml composition.yaml functions.yaml \ + --context-values=apiextensions.crossplane.io/environment='{"key": "value"}' + + # Pass required resources Functions can request. + crossplane alpha render xr xr.yaml composition.yaml functions.yaml \ + --required-resources=required-resources.yaml + + # Pass credentials to Functions that need them. + crossplane alpha render xr xr.yaml composition.yaml functions.yaml \ + --function-credentials=credentials.yaml +` +} + +// Run delegates to the existing render command. +func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { + return c.Cmd.Run(k, log) +} diff --git a/cmd/crossplane/beta/beta.go b/cmd/crossplane/beta/beta.go new file mode 100644 index 0000000..ecbea9a --- /dev/null +++ b/cmd/crossplane/beta/beta.go @@ -0,0 +1,42 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package beta contains beta Crossplane CLI subcommands. +// These commands are experimental, and may be changed or removed in a future +// release. +package beta + +import ( + "github.com/crossplane/cli/v2/cmd/crossplane/beta/convert" + "github.com/crossplane/cli/v2/cmd/crossplane/beta/top" + "github.com/crossplane/cli/v2/cmd/crossplane/beta/trace" + "github.com/crossplane/cli/v2/cmd/crossplane/beta/validate" +) + +// Cmd contains beta commands. +type Cmd struct { + // Subcommands and flags will appear in the CLI help output in the same + // order they're specified here. Keep them in alphabetical order. + Convert convert.Cmd `cmd:"" help:"Convert a Crossplane resource to a newer version or kind."` + Top top.Cmd `cmd:"" help:"Display resource (CPU/memory) usage by Crossplane related pods."` + Trace trace.Cmd `cmd:"" help:"Trace a Crossplane resource to get a detailed output of its relationships, helpful for troubleshooting."` + Validate validate.Cmd `cmd:"" help:"Validate Crossplane resources."` +} + +// Help output for crossplane beta. +func (c *Cmd) Help() string { + return "WARNING: These commands may be changed or removed in a future release." +} diff --git a/cmd/crossplane/beta/convert/compositionenvironment/cmd.go b/cmd/crossplane/beta/convert/compositionenvironment/cmd.go new file mode 100644 index 0000000..46343f5 --- /dev/null +++ b/cmd/crossplane/beta/convert/compositionenvironment/cmd.go @@ -0,0 +1,134 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package compositionenvironment is a package for converting Pipeline Compositions using native Composition Environment +// capabilities to use function-environment-configs. +package compositionenvironment + +import ( + "bufio" + "fmt" + "os" + + "github.com/alecthomas/kong" + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + commonIO "github.com/crossplane/cli/v2/cmd/crossplane/beta/convert/io" +) + +// Cmd arguments and flags for converting a Composition to use function-environment-configs. +type Cmd struct { + // Arguments. + InputFile string `arg:"" default:"-" help:"The Composition file to be converted. If not specified or '-', stdin will be used." optional:"" predictor:"file" type:"path"` + + // Flags. + OutputFile string `help:"The file to write the generated Composition to. If not specified, stdout will be used." placeholder:"PATH" predictor:"file" short:"o" type:"path"` + + FunctionEnvironmentConfigRef string `default:"function-environment-configs" help:"Name of the existing function-environment-configs Function, to be used to reference it." name:"function-environment-configs-ref"` + + fs afero.Fs +} + +// Help returns help message for the migrate composition-environment command. +func (c *Cmd) Help() string { + return ` +This command converts a Crossplane Composition to use function-environment-configs, if needed. + +It adds a function pipeline step using crossplane-contrib/function-environment-configs, if needed. +By default it'll reference the function as function-environment-configs, but it can be overridden +with the -f flag. + +Examples: + + # Convert an existing Composition (Pipeline mode) leveraging native + # Composition Environment to use function-environment-configs. + crossplane beta convert composition-environment composition.yaml -o composition-environment.yaml + + # Use a different functionRef and output to stdout. + crossplane beta convert composition-environment composition.yaml --function-environment-configs-ref local-function-environment-configs + + # Stdin to stdout. + cat composition.yaml | ./crossplane beta convert composition-environment + +` +} + +// AfterApply implements kong.AfterApply. +func (c *Cmd) AfterApply() error { + c.fs = afero.NewOsFs() + return nil +} + +// Run converts a classic Composition to a function pipeline Composition. +func (c *Cmd) Run(k *kong.Context) error { + data, err := commonIO.Read(c.fs, c.InputFile) + if err != nil { + return err + } + + u := &unstructured.Unstructured{} + + if err := yaml.Unmarshal(data, u); err != nil { + return errors.Wrap(err, "Unmarshalling Error") + } + + out, err := ConvertToFunctionEnvironmentConfigs(u, c.FunctionEnvironmentConfigRef) + if err != nil { + return errors.Wrap(err, "Error generating new Composition") + } + + if out == nil { + _, err = fmt.Fprintf(k.Stderr, "No changes needed.\n") + return errors.Wrap(err, "unable to write to stderr") + } + + b, err := yaml.Marshal(out) + if err != nil { + return errors.Wrap(err, "Unable to marshal back to yaml") + } + + output := k.Stdout + + if outputFileName := c.OutputFile; outputFileName != "" { + f, err := c.fs.OpenFile(outputFileName, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return errors.Wrap(err, "Unable to open output file") + } + + defer func() { _ = f.Close() }() + + output = f + } + + outputW := bufio.NewWriter(output) + if _, err := outputW.WriteString("---\n"); err != nil { + return errors.Wrap(err, "Writing YAML file header") + } + + if _, err := outputW.Write(b); err != nil { + return errors.Wrap(err, "Writing YAML file content") + } + + if err := outputW.Flush(); err != nil { + return errors.Wrap(err, "Flushing output") + } + + return nil +} diff --git a/cmd/crossplane/beta/convert/compositionenvironment/converter.go b/cmd/crossplane/beta/convert/compositionenvironment/converter.go new file mode 100644 index 0000000..ed95a26 --- /dev/null +++ b/cmd/crossplane/beta/convert/compositionenvironment/converter.go @@ -0,0 +1,111 @@ +package compositionenvironment + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" + + v1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" +) + +// ConvertToFunctionEnvironmentConfigs converts a Composition to use function-environment-configs. +func ConvertToFunctionEnvironmentConfigs(in *unstructured.Unstructured, functionName string) (*unstructured.Unstructured, error) { + if in == nil { + return nil, errors.New("input is nil") + } + + gvk := in.GetObjectKind().GroupVersionKind() + + if gvk.Empty() { + return nil, errors.New("GroupVersionKind is empty") + } + + if gvk.Group != v1.Group { + return nil, errors.Errorf("GroupVersionKind Group is not %s", v1.Group) + } + + if gvk.Kind != v1.CompositionKind { + return nil, errors.Errorf("GroupVersionKind Kind is not %s", v1.CompositionKind) + } + + out, err := fieldpath.PaveObject(in) + if err != nil { + return nil, err + } + + if mode, err := out.GetString("spec.mode"); fieldpath.IsNotFound(err) || mode != string(v1.CompositionModePipeline) { + return nil, errors.New("Composition is using Resources mode, run pipeline-composition command instead") + } + + // Prepare function-environment-configs input + inputPaved := fieldpath.Pave(map[string]any{ + "apiVersion": "environmentconfigs.fn.crossplane.io/v1beta1", + "kind": "Input", + }) + + var modified bool + + // Copy spec.environment.defaultData to function-environment-configs, if any + if dd, err := out.GetValue("spec.environment.defaultData"); err == nil { + if err := inputPaved.SetValue("spec.defaultData", dd); err != nil { + return nil, errors.Wrap(err, "failed to set defaultData") + } + + modified = true + } + + // Copy spec.environment.environmentConfigs to function-environment-configs, if any + if ec, err := out.GetValue("spec.environment.environmentConfigs"); err == nil { + if err := inputPaved.SetValue("spec.environmentConfigs", ec); err != nil { + return nil, errors.Wrap(err, "failed to set environmentConfigs") + } + + modified = true + } + + // Copy spec.environment.policy.resolution to function-environment-configs, if any + if resolutionPolicy, err := out.GetString("spec.environment.policy.resolution"); err == nil { + if err := inputPaved.SetValue("spec.policy.resolution", resolutionPolicy); err != nil { + return nil, errors.Wrap(err, "failed to set policy.resolution") + } + + modified = true + } + + if !modified { + // Nothing to do + return nil, nil + } + + // Nothing else should be left, we can delete the environment field + if err := out.DeleteField("spec.environment"); err != nil { + return nil, errors.Wrap(err, "failed to delete environment") + } + + // Add function-environment-configs to the pipeline + var pipeline []map[string]any + if err := out.GetValueInto("spec.pipeline", &pipeline); err != nil { + return nil, errors.Wrap(err, "failed to get pipeline") + } + + if functionName == "" { + functionName = "function-environment-configs" + } + + pipeline = append([]map[string]any{ + { + "step": "environment-configs", + "functionRef": map[string]any{ + "name": functionName, + }, + "input": inputPaved.UnstructuredContent(), + }, + }, pipeline...) + + if err := out.SetValue("spec.pipeline", pipeline); err != nil { + return nil, errors.Wrap(err, "failed to set pipeline") + } + + return &unstructured.Unstructured{Object: out.UnstructuredContent()}, nil +} diff --git a/cmd/crossplane/beta/convert/compositionenvironment/converter_test.go b/cmd/crossplane/beta/convert/compositionenvironment/converter_test.go new file mode 100644 index 0000000..5a9a57e --- /dev/null +++ b/cmd/crossplane/beta/convert/compositionenvironment/converter_test.go @@ -0,0 +1,229 @@ +package compositionenvironment + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/yaml" +) + +func TestConvertToFunctionEnvironmentConfigs(t *testing.T) { + type args struct { + in *unstructured.Unstructured + functionName string + } + + type want struct { + out *unstructured.Unstructured + err error + } + + tests := map[string]struct { + reason string + args args + want want + }{ + "Success": { + reason: "Should successfully convert a Composition to use function-environment-configs.", + args: args{ + in: fromYAML(t, ` +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: foo +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + environment: + policy: + resolution: Required + resolve: Always + defaultData: + foo: + bar: baz + key: value + environmentConfigs: + - type: Reference + ref: + name: example-config + pipeline: + - step: patch-and-transform + functionRef: + name: function-patch-and-transform + input: + apiVersion: pt.fn.crossplane.io/v1beta1 + kind: Resources + environment: + patches: + - type: ToCompositeFieldPath + fromFieldPath: "someFieldInTheEnvironment" + toFieldPath: "status.someFieldFromTheEnvironment" + resources: + - name: bucket + base: + apiVersion: s3.aws.crossplane.io/v1beta1 + kind: Bucket + spec: + forProvider: + region: us-east-2 + patches: + - type: FromEnvironmentFieldPath + fromFieldPath: "someFieldInTheEnvironment" + toFieldPath: "spec.forProvider.someFieldFromTheEnvironment" + - type: ToEnvironmentFieldPath + fromFieldPath: "status.someOtherFieldInTheResource" + toFieldPath: "someOtherFieldInTheEnvironment"`), + }, + want: want{ + out: fromYAML(t, ` +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: foo +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: environment-configs + functionRef: + name: function-environment-configs + input: + apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 + kind: Input + spec: + policy: + resolution: Required + defaultData: + foo: + bar: baz + key: value + environmentConfigs: + - type: Reference + ref: + name: example-config + - step: patch-and-transform + functionRef: + name: function-patch-and-transform + input: + apiVersion: pt.fn.crossplane.io/v1beta1 + kind: Resources + environment: + patches: + - type: ToCompositeFieldPath + fromFieldPath: "someFieldInTheEnvironment" + toFieldPath: "status.someFieldFromTheEnvironment" + resources: + - name: bucket + base: + apiVersion: s3.aws.crossplane.io/v1beta1 + kind: Bucket + spec: + forProvider: + region: us-east-2 + patches: + - type: FromEnvironmentFieldPath + fromFieldPath: "someFieldInTheEnvironment" + toFieldPath: "spec.forProvider.someFieldFromTheEnvironment" + - type: ToEnvironmentFieldPath + fromFieldPath: "status.someOtherFieldInTheResource" + toFieldPath: "someOtherFieldInTheEnvironment" +`), + }, + }, + "SuccessWithNoEnvironment": { + reason: "Should do nothing if the Composition has no environment defined.", + args: args{ + in: fromYAML(t, ` +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: foo +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Pipeline + pipeline: + - step: patch-and-transform + functionRef: + name: function-patch-and-transform + input: + apiVersion: pt.fn.crossplane.io/v1beta1 + kind: Resources + environment: + patches: + - type: ToCompositeFieldPath + fromFieldPath: "someFieldInTheEnvironment" + toFieldPath: "status.someFieldFromTheEnvironment" + resources: + - name: bucket + base: + apiVersion: s3.aws.crossplane.io/v1beta1 + kind: Bucket + spec: + forProvider: + region: us-east-2 + patches: + - type: FromEnvironmentFieldPath + fromFieldPath: "someFieldInTheEnvironment" + toFieldPath: "spec.forProvider.someFieldFromTheEnvironment" + - type: ToEnvironmentFieldPath + fromFieldPath: "status.someOtherFieldInTheResource" + toFieldPath: "someOtherFieldInTheEnvironment"`), + }, + want: want{ + out: nil, + }, + }, + "FailWithResources": { + reason: "Should refuse to convert a Composition that still uses Resources mode.", + args: args{ + in: fromYAML(t, ` +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: foo +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1 + kind: XR + mode: Resources +`), + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got, err := ConvertToFunctionEnvironmentConfigs(tt.args.in, tt.args.functionName) + if diff := cmp.Diff(tt.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("ConvertToFunctionEnvironmentConfigs() %s error -want, +got:\n%s", tt.reason, diff) + } + + if diff := cmp.Diff(tt.want.out, got); diff != "" { + t.Errorf("ConvertToFunctionEnvironmentConfigs() %s -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func fromYAML(t *testing.T, in string) *unstructured.Unstructured { + t.Helper() + + obj := make(map[string]any) + + err := yaml.Unmarshal([]byte(in), &obj) + if err != nil { + t.Fatalf("fromYAML: %s", err) + } + + return &unstructured.Unstructured{Object: obj} +} diff --git a/cmd/crossplane/beta/convert/convert.go b/cmd/crossplane/beta/convert/convert.go new file mode 100644 index 0000000..b57a769 --- /dev/null +++ b/cmd/crossplane/beta/convert/convert.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package convert contains Crossplane CLI subcommands for migrating Crossplane +// resources to newer versions or kinds. +package convert + +import ( + "github.com/crossplane/cli/v2/cmd/crossplane/beta/convert/compositionenvironment" +) + +// Cmd converts a Crossplane resource to a newer version or a different kind. +type Cmd struct { + CompositionEnvironment compositionenvironment.Cmd `cmd:"" help:"Convert a Pipeline Composition to use function-environment-configs."` +} + +// Help returns help message for the migrate command. +func (c *Cmd) Help() string { + return ` +This command converts a Crossplane resource to a newer version or a different kind. + +Currently supported conversions: + * native Composition Environment -> function-environment-configs + +Examples: + # Convert an existing Composition to use function-environment-configs instead of native Composition Environment, + # requires the composition to be in Pipeline mode already. + crossplane beta convert composition-environment composition.yaml -o composition-environment.yaml +` +} diff --git a/cmd/crossplane/beta/convert/io/io.go b/cmd/crossplane/beta/convert/io/io.go new file mode 100644 index 0000000..27f89dd --- /dev/null +++ b/cmd/crossplane/beta/convert/io/io.go @@ -0,0 +1,79 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package io is a package for reading and writing files for the migration +// command. Possibly this should be moved as a general package for the cli. +package io + +import ( + "io" + "os" + + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/serializer/json" + "k8s.io/client-go/kubernetes/scheme" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// Read reads the input from the given file or stdin if no file is given. +func Read(fs afero.Fs, inputFile string) ([]byte, error) { + var ( + data []byte + err error + ) + + if inputFile != "-" { + data, err = afero.ReadFile(fs, inputFile) + } else { + data, err = io.ReadAll(os.Stdin) + } + + if err != nil { + return nil, errors.Wrap(err, "Unable to read inputFile") + } + + return data, nil +} + +// WriteObjectYAML writes the given object to the given file or stdout if no +// file is given. The output format is YAML. +func WriteObjectYAML(fs afero.Fs, outputFile string, o runtime.Object) error { + s := json.NewSerializerWithOptions(json.DefaultMetaFactory, scheme.Scheme, scheme.Scheme, json.SerializerOptions{Yaml: true}) + + var output io.Writer + + if outputFile != "" { + f, err := fs.OpenFile(outputFile, os.O_CREATE|os.O_WRONLY, 0o644) + if err != nil { + return errors.Wrap(err, "Unable to open output file") + } + + defer func() { _ = f.Close() }() + + output = f + } else { + output = os.Stdout + } + + err := s.Encode(o, output) + if err != nil { + return errors.Wrap(err, "Unable to encode output") + } + + return nil +} diff --git a/cmd/crossplane/beta/top/top.go b/cmd/crossplane/beta/top/top.go new file mode 100644 index 0000000..4f46736 --- /dev/null +++ b/cmd/crossplane/beta/top/top.go @@ -0,0 +1,304 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package top contains the top command. +package top + +import ( + "context" + "fmt" + "io" + "sort" + "strings" + + "github.com/alecthomas/kong" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/cli-runtime/pkg/printers" + "k8s.io/client-go/kubernetes" + "k8s.io/metrics/pkg/client/clientset/versioned" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" +) + +const ( + errKubeConfig = "failed to get kubeconfig" + errCreateK8sClientset = "could not create the clientset for Kubernetes" + errCreateMetricsClientset = "could not create the clientset for Metrics" + errFetchAllPods = "could not fetch pods" + errGetPodMetrics = "error getting metrics for pod" + errPrintingPodsTable = "error creating pods table" + errAddingPodMetrics = "error adding metrics to pod, check if metrics-server is running or wait until metrics are available for the pod" + errWriteHeader = "cannot write header" + errWriteRow = "cannot write row" +) + +// Cmd represents the top command. +type Cmd struct { + Summary bool `help:"Adds summary header for all Crossplane pods." name:"summary" short:"s"` + Namespace string `default:"crossplane-system" help:"Show pods from a specific namespace, defaults to crossplane-system." name:"namespace" predictor:"namespace" short:"n"` +} + +// Help returns help instructions for the top command. +func (c *Cmd) Help() string { + return ` +This command returns current resources utilization (CPU and Memory) by Crossplane pods. + +Similar to kubectl top pods, it requires Metrics Server to be correctly configured and working on the server. + +Examples: + # Show resources utilization for all Crossplane pods in the default 'crossplane-system' namespace in a tabular format. + crossplane beta top + + # Show resources utilization for all Crossplane pods in a specified namespace in a tabular format. + crossplane beta top -n + + # Add summary of resources utilization for all Crossplane pods in the default 'crossplane-system' on top of the results. + crossplane beta top -s +` +} + +type topMetrics struct { + PodType string + PodName string + PodNamespace string + CPUUsage resource.Quantity + MemoryUsage resource.Quantity +} + +type defaultPrinterRow struct { + podType string + namespace string + name string + cpu string + memory string +} + +func (r *defaultPrinterRow) String() string { + return strings.Join([]string{ + r.podType, + r.namespace, + r.name, + r.cpu, + r.memory, + }, "\t") +} + +// Run runs the top command. +func (c *Cmd) Run(k *kong.Context, logger logging.Logger) error { + logger = logger.WithValues("cmd", "top") + + logger.Debug("Tabwriter header created") + + // Build the config from the kubeconfig path + config, err := ctrl.GetConfig() + if err != nil { + return errors.Wrap(err, errKubeConfig) + } + + logger.Debug("Found kubeconfig") + + // Create the clientset for Kubernetes + k8sClientset, err := kubernetes.NewForConfig(config) + if err != nil { + return errors.Wrap(err, errCreateK8sClientset) + } + + logger.Debug("Created clientset for Kubernetes") + + // Create the clientset for Metrics + metricsClientset, err := versioned.NewForConfig(config) + if err != nil { + return errors.Wrap(err, errCreateMetricsClientset) + } + + logger.Debug("Created clientset for Metrics") + + ctx := context.Background() + + pods, err := k8sClientset.CoreV1().Pods(c.Namespace).List(ctx, metav1.ListOptions{}) + if err != nil { + return errors.Wrap(err, errFetchAllPods) + } + + crossplanePods := getCrossplanePods(pods.Items) + logger.Debug("Fetched all Crossplane pods", "pods", crossplanePods, "namespace", c.Namespace) + + if len(crossplanePods) == 0 { + _, _ = fmt.Fprintln(k.Stdout, "No Crossplane pods found in the namespace", c.Namespace) + return nil + } + + for i, pod := range crossplanePods { + podMetrics, err := metricsClientset.MetricsV1beta1().PodMetricses(pod.PodNamespace).Get(ctx, pod.PodName, metav1.GetOptions{}) + if err != nil { + return errors.Wrap(err, errAddingPodMetrics) + } + + for _, container := range podMetrics.Containers { + if cpu := container.Usage.Cpu(); cpu != nil { + crossplanePods[i].CPUUsage.Add(*cpu) + } + + if memory := container.Usage.Memory(); memory != nil { + crossplanePods[i].MemoryUsage.Add(*memory) + } + } + } + + logger.Debug("Added metrics to Crossplane pods") + + sort.Slice(crossplanePods, func(i, j int) bool { + if crossplanePods[i].PodType == crossplanePods[j].PodType { + return crossplanePods[i].PodName < crossplanePods[j].PodName + } + + return crossplanePods[i].PodType < crossplanePods[j].PodType + }) + + if c.Summary { + printPodsSummary(k.Stdout, crossplanePods) + logger.Debug("Printed pods summary") + + _, _ = fmt.Fprintln(k.Stdout) + } + + if err := printPodsTable(k.Stdout, crossplanePods); err != nil { + return errors.Wrap(err, errPrintingPodsTable) + } + + logger.Debug("Printed pods as table") + + return nil +} + +func printPodsTable(w io.Writer, crossplanePods []topMetrics) error { + tw := printers.GetNewTabWriter(w) + // Building header + headers := defaultPrinterRow{ + podType: "TYPE", + namespace: "NAMESPACE", + name: "NAME", + cpu: "CPU(cores)", + memory: "MEMORY", + } + + _, err := fmt.Fprintln(tw, headers.String()) + if err != nil { + return errors.Wrap(err, errWriteHeader) + } + + // Building rows for each pod + for _, pod := range crossplanePods { + row := defaultPrinterRow{ + podType: pod.PodType, + namespace: pod.PodNamespace, + name: pod.PodName, + // NOTE(phisco): inspired by https://github.com/kubernetes/kubectl/blob/97bd96adbceb24fd598bdc698da8794cb0b88e3b/pkg/metricsutil/metrics_printer.go#L209C6-L209C30 + cpu: fmt.Sprintf("%vm", pod.CPUUsage.MilliValue()), + memory: fmt.Sprintf("%vMi", pod.MemoryUsage.Value()/(1024*1024)), + } + + _, err := fmt.Fprintln(tw, row.String()) + if err != nil { + return errors.Wrap(err, errWriteRow) + } + } + + return tw.Flush() +} + +func printPodsSummary(w io.Writer, pods []topMetrics) { + categoryCounts := make(map[string]int) + + var totalMemoryUsage, totalCPUUsage resource.Quantity + + for _, pod := range pods { + // Increment the count for this pod's category + categoryCounts[pod.PodType]++ + + // Aggregate CPU and Memory usage + totalCPUUsage.Add(pod.CPUUsage) + totalMemoryUsage.Add(pod.MemoryUsage) + } + + // Print summary directly to the provided writer + _, _ = fmt.Fprintf(w, "Nr of Crossplane pods: %d\n", len(pods)) + // Sort categories alphabetically to ensure consistent output + categories := make([]string, 0, len(categoryCounts)) + for category := range categoryCounts { + categories = append(categories, category) + } + + sort.Strings(categories) + + for _, category := range categories { + _, _ = fmt.Fprintf(w, "%s: %d\n", capitalizeFirst(category), categoryCounts[category]) + } + + _, _ = fmt.Fprintf(w, "Memory: %s\n", fmt.Sprintf("%vMi", totalMemoryUsage.Value()/(1024*1024))) + _, _ = fmt.Fprintf(w, "CPU(cores): %s\n", fmt.Sprintf("%vm", totalCPUUsage.MilliValue())) +} + +func getCrossplanePods(pods []v1.Pod) []topMetrics { + metricsList := make([]topMetrics, 0) + + for _, pod := range pods { + labels := pod.GetLabels() + + var podType string + + isCrossplanePod := false + + for labelKey, labelValue := range labels { + switch { + case strings.HasPrefix(labelKey, "pkg.crossplane.io/"): + podType = strings.SplitN(labelKey, "/", 2)[1] + if podType != "revision" { + isCrossplanePod = true + } + case labelKey == "app.kubernetes.io/part-of" && labelValue == "crossplane": + podType = "crossplane" + isCrossplanePod = true + } + + if isCrossplanePod { + break + } + } + + if isCrossplanePod { + metricsList = append(metricsList, topMetrics{ + PodType: podType, + PodName: pod.Name, + PodNamespace: pod.Namespace, + }) + } + } + + return metricsList +} + +func capitalizeFirst(s string) string { + if s == "" { + return "" + } + + return strings.ToUpper(s[:1]) + s[1:] +} diff --git a/cmd/crossplane/beta/top/top_test.go b/cmd/crossplane/beta/top/top_test.go new file mode 100644 index 0000000..9ac8ca1 --- /dev/null +++ b/cmd/crossplane/beta/top/top_test.go @@ -0,0 +1,373 @@ +package top + +import ( + "bytes" + "fmt" + "io" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + v1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +type errorWriter struct{} + +func (w *errorWriter) Write(_ []byte) (n int, err error) { + return 0, fmt.Errorf("write error") +} + +func TestGetCrossplanePods(t *testing.T) { + type want struct { + topMetrics []topMetrics + err error + } + + tests := map[string]struct { + reason string + metrics []corev1.Pod + want want + }{ + "NoPodsFound": { + reason: "Should return empty topMetrics slice when no pods are found", + metrics: []corev1.Pod{}, + want: want{ + topMetrics: []topMetrics{}, + err: nil, + }, + }, + "FunctionPod": { + reason: "Should return function pod", + metrics: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-12345abcd-xyzwv", + Namespace: "crossplane-system", + Labels: map[string]string{ + v1.LabelFunction: "function-go-templating", + }, + }, + }, + }, + want: want{ + topMetrics: []topMetrics{ + { + PodType: "function", + PodName: "function-12345abcd-xyzwv", + PodNamespace: "crossplane-system", + }, + }, + err: nil, + }, + }, + "CrossplanePod": { + reason: "Should return crossplane pod", + metrics: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "crossplane-75575fcf5d-fzwgq", + Namespace: "crossplane-system", + Labels: map[string]string{ + "app.kubernetes.io/part-of": "crossplane", + }, + }, + }, + }, + want: want{ + topMetrics: []topMetrics{ + { + PodType: "crossplane", + PodName: "crossplane-75575fcf5d-fzwgq", + PodNamespace: "crossplane-system", + }, + }, + err: nil, + }, + }, + "MultipleDiferentPods": { + reason: "Should return multiple different pods types", + metrics: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "function-go-templating-213wer", + Namespace: "crossplane-system", + Labels: map[string]string{ + v1.LabelFunction: "function-go-templating", + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "provider-azure-storage", + Namespace: "crossplane-system", + Labels: map[string]string{ + v1.LabelProvider: "provider-azure-storage", + }, + }, + }, + }, + want: want{ + topMetrics: []topMetrics{ + { + PodType: "function", + PodName: "function-go-templating-213wer", + PodNamespace: "crossplane-system", + }, + { + PodType: "provider", + PodName: "provider-azure-storage", + PodNamespace: "crossplane-system", + }, + }, + }, + }, + "NewPodType": { + reason: "Should return new pod type 'extension'", + metrics: []corev1.Pod{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "extension-some-feature-12345", + Namespace: "crossplane-system", + Labels: map[string]string{ + "pkg.crossplane.io/extension": "new-crossplane-extension", + }, + }, + }, + }, + want: want{ + topMetrics: []topMetrics{ + { + PodType: "extension", + PodName: "extension-some-feature-12345", + PodNamespace: "crossplane-system", + }, + }, + err: nil, + }, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := getCrossplanePods(tt.metrics) + + if diff := cmp.Diff(tt.want.topMetrics, got); diff != "" { + t.Errorf("Cmd.getResourceAndName() resource = %v, want %v", got, tt.want.topMetrics) + } + }) + } +} + +func TestPrintPodsTable(t *testing.T) { + type want struct { + results string + err error + } + + tests := map[string]struct { + reason string + crossplanePods []topMetrics + writer io.Writer + want want + }{ + "NoPodsFound": { + reason: "Should return header when no pods are found", + crossplanePods: []topMetrics{}, + writer: &bytes.Buffer{}, + want: want{ + results: ` +TYPE NAMESPACE NAME CPU(cores) MEMORY +`, + err: nil, + }, + }, + "SinglePod": { + reason: "Should return single pod", + crossplanePods: []topMetrics{ + { + PodType: "crossplane", + PodName: "crossplane-123", + PodNamespace: "crossplane-system", + CPUUsage: resource.MustParse("100m"), + MemoryUsage: resource.MustParse("512Mi"), + }, + }, + writer: &bytes.Buffer{}, + want: want{ + results: ` +TYPE NAMESPACE NAME CPU(cores) MEMORY +crossplane crossplane-system crossplane-123 100m 512Mi +`, + err: nil, + }, + }, + "MultiplePods": { + reason: "Should return multiple pods", + crossplanePods: []topMetrics{ + { + PodType: "crossplane", + PodName: "crossplane-123", + PodNamespace: "crossplane-system", + CPUUsage: resource.MustParse("100m"), + MemoryUsage: resource.MustParse("512Mi"), + }, + { + PodType: "function", + PodName: "function-123", + PodNamespace: "crossplane-system", + CPUUsage: resource.MustParse("200m"), + MemoryUsage: resource.MustParse("1024Mi"), + }, + }, + writer: &bytes.Buffer{}, + want: want{ + results: ` +TYPE NAMESPACE NAME CPU(cores) MEMORY +crossplane crossplane-system crossplane-123 100m 512Mi +function crossplane-system function-123 200m 1024Mi +`, + err: nil, + }, + }, + "WriterError": { + reason: "Should return error when writer fails", + crossplanePods: []topMetrics{ + { + PodType: "crossplane", + PodName: "crossplane-123", + PodNamespace: "crossplane-system", + CPUUsage: resource.MustParse("100m"), + MemoryUsage: resource.MustParse("512Mi"), + }, + }, + writer: &errorWriter{}, + want: want{ + results: "", + err: cmpopts.AnyError, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + w := tt.writer + + err := printPodsTable(w, tt.crossplanePods) + if diff := cmp.Diff(tt.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nprintPodsTable() error: -want,+got:\n%s", tt.reason, diff) + } + + if buf, ok := w.(*bytes.Buffer); ok { + if diff := cmp.Diff(strings.TrimSpace(tt.want.results), strings.TrimSpace(buf.String())); diff != "" { + t.Errorf("%s\nprintPodsTable(): -want, +got:\n%s", tt.reason, diff) + } + } + }) + } +} + +func TestPrintPodsSummary(t *testing.T) { + type want struct { + results string + } + + tests := map[string]struct { + reason string + crossplanePods []topMetrics + want want + }{ + "PrintSummary": { + reason: "Should return summary", + crossplanePods: []topMetrics{ + { + PodType: "crossplane", + PodName: "crossplane-123", + PodNamespace: "crossplane-system", + CPUUsage: resource.MustParse("100"), + MemoryUsage: resource.MustParse("512Mi"), + }, + { + PodType: "function", + PodName: "function-123", + PodNamespace: "crossplane-system", + CPUUsage: resource.MustParse("200"), + MemoryUsage: resource.MustParse("1024Mi"), + }, + { + PodType: "crossplane", + PodName: "crossplane-124", + PodNamespace: "crossplane-system", + CPUUsage: resource.MustParse("200"), + MemoryUsage: resource.MustParse("512Mi"), + }, + { + PodType: "function", + PodName: "function-124", + PodNamespace: "crossplane-system", + CPUUsage: resource.MustParse("400"), + MemoryUsage: resource.MustParse("1024Mi"), + }, + }, + want: want{ + results: ` +Nr of Crossplane pods: 4 +Crossplane: 2 +Function: 2 +Memory: 3072Mi +CPU(cores): 900000m + `, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + b := &bytes.Buffer{} + printPodsSummary(b, tt.crossplanePods) + + if diff := cmp.Diff(strings.TrimSpace(tt.want.results), strings.TrimSpace(b.String())); diff != "" { + t.Errorf("%s\nprintPodsSummary(): -want, +got:\n%s", tt.reason, diff) + } + }) + } +} + +func TestCapitalizeFirst(t *testing.T) { + tests := map[string]struct { + input string + want string + }{ + "EmptyString": { + input: "", + want: "", + }, + "AlreadyCapitalized": { + input: "Crossplane", + want: "Crossplane", + }, + "Lowercase": { + input: "crossplane", + want: "Crossplane", + }, + "MultipleWords": { + input: "crossplane rocks", + want: "Crossplane rocks", + }, + "NonAlphaCharacters": { + input: "123crossplane", + want: "123crossplane", + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + got := capitalizeFirst(tt.input) + if diff := cmp.Diff(tt.want, got); diff != "" { + t.Errorf("CapitalizeFirst() = %v, want %v; diff %s", got, tt.want, diff) + } + }) + } +} diff --git a/cmd/crossplane/beta/trace/internal/printer/default.go b/cmd/crossplane/beta/trace/internal/printer/default.go new file mode 100644 index 0000000..a3406cc --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/default.go @@ -0,0 +1,433 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + "strings" + "text/tabwriter" + + gcrname "github.com/google/go-containerregistry/pkg/name" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" + "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xpkg" +) + +const ( + errWriteHeader = "cannot write header" + errWriteRow = "cannot write row" + errFlushTabWriter = "cannot flush tab writer" + + tabwriterMinWidth = 6 + tabwriterWidth = 4 + tabwriterPadding = 3 + tabwriterPadChar = ' ' +) + +// DefaultPrinter defines the DefaultPrinter configuration. +type DefaultPrinter struct { + wide bool +} + +var _ Printer = &DefaultPrinter{} + +type defaultPrinterRow struct { + wide bool + + // wide only fields + resourceName string + + name string + synced string + ready string + status string +} + +func (r *defaultPrinterRow) String() string { + cols := []string{ + r.name, + } + if r.wide { + cols = append(cols, r.resourceName) + } + + cols = append(cols, + r.synced, + r.ready, + r.status, + ) + + return strings.Join(cols, "\t") +} + +type defaultPkgPrinterRow struct { + wide bool + // wide only fields + // NOTE(phisco): just package is a reserved word + packageImg string + + name string + version string + installed string + healthy string + state string + status string +} + +func (r *defaultPkgPrinterRow) String() string { + cols := []string{ + r.name, + } + if r.wide { + cols = append(cols, r.packageImg) + } + + cols = append(cols, + r.version, + r.installed, + r.healthy, + r.state, + r.status, + ) + + return strings.Join(cols, "\t") + "\t" +} + +func getHeaders(gk schema.GroupKind, wide bool) (headers fmt.Stringer, isPackageOrPackageRevision bool) { + if xpkg.IsPackageType(gk) || xpkg.IsPackageRevisionType(gk) { + return &defaultPkgPrinterRow{ + wide: wide, + + name: "NAME", + packageImg: "PACKAGE", + version: "VERSION", + installed: "INSTALLED", + healthy: "HEALTHY", + state: "STATE", + status: "STATUS", + }, true + } + + return &defaultPrinterRow{ + wide: wide, + name: "NAME", + resourceName: "RESOURCE", + synced: "SYNCED", + ready: "READY", + status: "STATUS", + }, false +} + +func getNewTabWriter(output io.Writer) *tabwriter.Writer { + return tabwriter.NewWriter(output, tabwriterMinWidth, tabwriterWidth, tabwriterPadding, tabwriterPadChar, 0) +} + +// Print implements the Printer interface by prints the resource tree in a +// human-readable format. +func (p *DefaultPrinter) Print(w io.Writer, root *resource.Resource) error { + tw := getNewTabWriter(w) + + headers, isPackageOrRevision := getHeaders(root.Unstructured.GroupVersionKind().GroupKind(), p.wide) + + if _, err := fmt.Fprintln(tw, headers.String()); err != nil { + return errors.Wrap(err, errWriteHeader) + } + + err := p.printResourceTree(tw, root, isPackageOrRevision) + if err != nil { + return errors.Wrap(err, "cannot print resource tree") + } + + if err := tw.Flush(); err != nil { + return errors.Wrap(err, errFlushTabWriter) + } + + return nil +} + +// PrintList implements the Printer interface by prints the resource tree of a list of resources in a +// human-readable format. +func (p *DefaultPrinter) PrintList(w io.Writer, roots *resource.ResourceList) error { + tw := getNewTabWriter(w) + + if roots == nil || len(roots.Items) == 0 { + return errors.New("cannot print resource tree: resource list is empty") + } + + firstResource := roots.Items[0] + + headers, isPackageOrRevision := getHeaders(firstResource.Unstructured.GroupVersionKind().GroupKind(), p.wide) + + if _, err := fmt.Fprintln(tw, headers.String()); err != nil { + return errors.Wrap(err, errWriteHeader) + } + + // Print each resource in the list + for _, r := range roots.Items { + if err := p.printResourceTree(tw, r, isPackageOrRevision); err != nil { + return errors.Wrap(err, "cannot print resource tree") + } + } + + if err := tw.Flush(); err != nil { + return errors.Wrap(err, errFlushTabWriter) + } + + return nil +} + +func (p *DefaultPrinter) printResourceTree(tw *tabwriter.Writer, root *resource.Resource, isPackageOrRevision bool) error { + type queueItem struct { + resource *resource.Resource + depth int + isLast bool + prefix string + } + + // Initialize LIFO queue with root element to traverse the tree depth-first, + // enqueuing children in reverse order so that they are dequeued in the + // right order w.r.t. the way they are defined by the resources. + queue := []*queueItem{{resource: root}} + + for len(queue) > 0 { + var item *queueItem + + l := len(queue) + item, queue = queue[l-1], queue[:l-1] // Pop the last element + + // Build the name of the current node, prepending the required prefix to + // show the tree structure + name := strings.Builder{} + + childPrefix := item.prefix // Inherited prefix for all the children of the current node + switch { + case item.depth == 0: + // We don't need a prefix for the root, nor a custom + // prefix for its children + case item.isLast: + name.WriteString(item.prefix + "└─ ") + + childPrefix += " " + default: + name.WriteString(item.prefix + "├─ ") + + childPrefix += "│ " + } + + name.WriteString(fmt.Sprintf("%s/%s", item.resource.Unstructured.GetKind(), item.resource.Unstructured.GetName())) + + // Append the namespace if it's not empty + if item.resource.Unstructured.GetNamespace() != "" { + name.WriteString(fmt.Sprintf(" (%s)", item.resource.Unstructured.GetNamespace())) + } + + var row fmt.Stringer + if isPackageOrRevision { + row = getPkgResourceStatus(item.resource, name.String(), p.wide) + } else { + row = getResourceStatus(item.resource, name.String(), p.wide) + } + + if _, err := fmt.Fprintln(tw, row.String()); err != nil { + return errors.Wrap(err, errWriteRow) + } + + // Enqueue the children of the current node in reverse order to ensure + // that they are dequeued from the LIFO queue in the same order w.r.t. + // the way they are defined by the resources. + for idx := len(item.resource.Children) - 1; idx >= 0; idx-- { + isLast := idx == len(item.resource.Children)-1 + queue = append(queue, &queueItem{resource: item.resource.Children[idx], depth: item.depth + 1, isLast: isLast, prefix: childPrefix}) + } + } + return nil +} + +// getResourceStatus returns a string that represents an entire row of status +// information for the resource. +func getResourceStatus(r *resource.Resource, name string, wide bool) fmt.Stringer { + readyCond := r.GetCondition(xpv2.TypeReady) + syncedCond := r.GetCondition(xpv2.TypeSynced) + + var status, m string + + switch { + case r.Unstructured.GetDeletionTimestamp() != nil: + // Report the status as deleted if the resource is being deleted + status = "Deleting" + case r.Error != nil: + // if there is an error we want to show it + status = "Error" + m = r.Error.Error() + case readyCond.Status == corev1.ConditionTrue && syncedCond.Status == corev1.ConditionTrue: + // if both are true we want to show the ready reason only + status = string(readyCond.Reason) + + // The following cases are for when one of the conditions is not true (false or unknown), + // prioritizing synced over readiness in case of issues. + case syncedCond.Status != corev1.ConditionTrue && + (syncedCond.Reason != "" || syncedCond.Message != ""): + status = string(syncedCond.Reason) + m = syncedCond.Message + case readyCond.Status != corev1.ConditionTrue && + (readyCond.Reason != "" || readyCond.Message != ""): + status = string(readyCond.Reason) + m = readyCond.Message + + default: + // both are unknown or unset, let's try showing the ready reason, probably empty + status = string(readyCond.Reason) + m = readyCond.Message + } + + // Crop the message to the last 64 characters if it's too long and we are + // not in wide mode + if !wide && len(m) > 64 { + m = "..." + m[len(m)-64:] + } + + // Append the message to the status if it's not empty + if m != "" { + status = fmt.Sprintf("%s: %s", status, m) + } + + return &defaultPrinterRow{ + wide: wide, + name: name, + resourceName: r.Unstructured.GetAnnotations()[xcrd.AnnotationKeyCompositionResourceName], + ready: mapEmptyStatusToDash(readyCond.Status), + synced: mapEmptyStatusToDash(syncedCond.Status), + status: status, + } +} + +func getPkgResourceStatus(r *resource.Resource, name string, wide bool) fmt.Stringer { + var ( + err error + packageImg, state, status, m string + ) + + healthyCond := r.GetCondition(pkgv1.TypeHealthy) + installedCond := r.GetCondition(pkgv1.TypeInstalled) + + gk := r.Unstructured.GroupVersionKind().GroupKind() + switch { + case r.Error != nil: + // If there is an error we want to show it, regardless of what type this + // resource is and what conditions it has. + status = "Error" + m = r.Error.Error() + case xpkg.IsPackageType(gk): + switch { + case healthyCond.Status == corev1.ConditionTrue && installedCond.Status == corev1.ConditionTrue: + // If both are true we want to show the healthy reason only + status = string(healthyCond.Reason) + + // The following cases are for when one of the conditions is not true (false or unknown), + // prioritizing installed over healthy in case of issues. + case installedCond.Status != corev1.ConditionTrue && + (installedCond.Reason != "" || installedCond.Message != ""): + status = string(installedCond.Reason) + m = installedCond.Message + case healthyCond.Status != corev1.ConditionTrue && + (healthyCond.Reason != "" || healthyCond.Message != ""): + status = string(healthyCond.Reason) + m = healthyCond.Message + default: + // both are unknown or unset, let's try showing the installed reason + status = string(installedCond.Reason) + m = installedCond.Message + } + + if packageImg, err = fieldpath.Pave(r.Unstructured.Object).GetString("spec.package"); err != nil { + state = err.Error() + } + case xpkg.IsPackageRevisionType(gk): + // package revisions only have the healthy condition, so use that + status = string(healthyCond.Reason) + m = healthyCond.Message + + // Get the state (active vs. inactive) of this package revision. + var err error + + state, err = fieldpath.Pave(r.Unstructured.Object).GetString("spec.desiredState") + if err != nil { + state = err.Error() + } + // Get the image used. + if packageImg, err = fieldpath.Pave(r.Unstructured.Object).GetString("spec.image"); err != nil { + state = err.Error() + } + case xpkg.IsPackageRuntimeConfigType(gk): + // nothing to do here + default: + status = "Unknown package type" + } + + // Crop the message to the last 64 characters if it's too long and we are + // not in wide mode + if !wide && len(m) > 64 { + m = "..." + m[len(m)-64:] + } + + // Append the message to the status if it's not empty + if m != "" { + status = fmt.Sprintf("%s: %s", status, m) + } + + // Parse the image reference extracting the tag, we'll leave it empty if we + // couldn't parse it and leave the whole thing as package instead. + var packageImgTag string + if tag, err := gcrname.NewTag(packageImg, gcrname.StrictValidation); err == nil { + packageImgTag = tag.TagStr() + + packageImg = tag.RepositoryStr() + if tag.RegistryStr() != "" { + packageImg = fmt.Sprintf("%s/%s", tag.RegistryStr(), packageImg) + } + } + + return &defaultPkgPrinterRow{ + wide: wide, + + name: name, + packageImg: packageImg, + version: packageImgTag, + installed: mapEmptyStatusToDash(installedCond.Status), + healthy: mapEmptyStatusToDash(healthyCond.Status), + state: mapEmptyStatusToDash(corev1.ConditionStatus(state)), + status: status, + } +} + +func mapEmptyStatusToDash(s corev1.ConditionStatus) string { + if s == "" { + return "-" + } + + return string(s) +} diff --git a/cmd/crossplane/beta/trace/internal/printer/default_test.go b/cmd/crossplane/beta/trace/internal/printer/default_test.go new file mode 100644 index 0000000..667f9c3 --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/default_test.go @@ -0,0 +1,263 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +func TestDefaultPrinterPrint(t *testing.T) { + type args struct { + resource *resource.Resource + wide bool + } + + type want struct { + output string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + // Test valid resource + "ResourceWithChildren": { + reason: "Should print a complex Resource with children.", + args: args{ + resource: GetComplexResource(), + wide: false, + }, + want: want{ + // Note: Use spaces instead of tabs for indentation + //nolint:dupword // False positive for 'True True' + output: ` +NAME SYNCED READY STATUS +ObjectStorage/test-resource (default) True True +└─ XObjectStorage/test-resource-hash True True + ├─ Bucket/test-resource-bucket-hash True True + │ ├─ User/test-resource-child-1-bucket-hash True False SomethingWrongHappened: ...rure magna. Non cillum id nulla. Anim culpa do duis consectetur. + │ ├─ User/test-resource-child-mid-bucket-hash False True CantSync: Sync error with bucket child mid + │ └─ User/test-resource-child-2-bucket-hash True False SomethingWrongHappened: Error with bucket child 2 + │ └─ User/test-resource-child-2-1-bucket-hash True - + └─ User/test-resource-user-hash Unknown True +`, + err: nil, + }, + }, + "ResourceWithChildrenWide": { + reason: "Should print a complex Resource with children even in wide.", + args: args{ + resource: GetComplexResource(), + wide: true, + }, + want: want{ + // Note: Use spaces instead of tabs for indentation + //nolint:dupword // False positive for 'True True' + output: ` +NAME RESOURCE SYNCED READY STATUS +ObjectStorage/test-resource (default) True True +└─ XObjectStorage/test-resource-hash True True + ├─ Bucket/test-resource-bucket-hash one True True + │ ├─ User/test-resource-child-1-bucket-hash two True False SomethingWrongHappened: Error with bucket child 1: Sint eu mollit tempor ad minim do commodo irure. Magna labore irure magna. Non cillum id nulla. Anim culpa do duis consectetur. + │ ├─ User/test-resource-child-mid-bucket-hash three False True CantSync: Sync error with bucket child mid + │ └─ User/test-resource-child-2-bucket-hash four True False SomethingWrongHappened: Error with bucket child 2 + │ └─ User/test-resource-child-2-1-bucket-hash True - + └─ User/test-resource-user-hash Unknown True +`, + err: nil, + }, + }, + "PackageWithChildren": { + reason: "Should print a complex Package with children.", + args: args{ + resource: GetComplexPackage(), + }, + want: want{ + // Note: Use spaces instead of tabs for indentation + //nolint:dupword // False positive for 'True True' + output: ` +NAME VERSION INSTALLED HEALTHY STATE STATUS +Configuration/platform-ref-aws v0.9.0 True True - HealthyPackageRevision +├─ ConfigurationRevision/platform-ref-aws-9ad7b5db2899 v0.9.0 True True Active HealthyPackageRevision +└─ Configuration/crossplane-configuration-aws-network v0.7.0 True True - HealthyPackageRevision + ├─ ConfigurationRevision/crossplane-configuration-aws-network-97be9100cfe1 v0.7.0 True True Active HealthyPackageRevision + └─ Provider/crossplane-provider-aws-ec2 v0.47.0 True Unknown - UnknownPackageRevisionHealth: ...-helm xpkg.crossplane.io/crossplane-contrib/provider-kubernetes] + ├─ ProviderRevision/crossplane-provider-aws-ec2-9ad7b5db2899 v0.47.0 True False Active UnhealthyPackageRevision: ...ider package deployment has no condition of type "Available" yet + └─ Provider/crossplane-provider-aws-something v0.47.0 True - - ActivePackageRevision +`, + err: nil, + }, + }, + "PackageWithChildrenWide": { + reason: "Should print a complex Package with children.", + args: args{ + resource: GetComplexPackage(), + wide: true, + }, + want: want{ + // Note: Use spaces instead of tabs for indentation + //nolint:dupword // False positive for 'True True' + output: ` +NAME PACKAGE VERSION INSTALLED HEALTHY STATE STATUS +Configuration/platform-ref-aws xpkg.crossplane.io/crossplane/platform-ref-aws v0.9.0 True True - HealthyPackageRevision +├─ ConfigurationRevision/platform-ref-aws-9ad7b5db2899 xpkg.crossplane.io/crossplane/platform-ref-aws v0.9.0 True True Active HealthyPackageRevision +└─ Configuration/crossplane-configuration-aws-network xpkg.crossplane.io/crossplane/configuration-aws-network v0.7.0 True True - HealthyPackageRevision + ├─ ConfigurationRevision/crossplane-configuration-aws-network-97be9100cfe1 xpkg.crossplane.io/crossplane/configuration-aws-network v0.7.0 True True Active HealthyPackageRevision + └─ Provider/crossplane-provider-aws-ec2 xpkg.crossplane.io/crossplane/provider-aws-ec2 v0.47.0 True Unknown - UnknownPackageRevisionHealth: cannot resolve package dependencies: incompatible dependencies: [xpkg.crossplane.io/crossplane-contrib/provider-helm xpkg.crossplane.io/crossplane-contrib/provider-kubernetes] + ├─ ProviderRevision/crossplane-provider-aws-ec2-9ad7b5db2899 xpkg.crossplane.io/crossplane/provider-aws-ec2 v0.47.0 True False Active UnhealthyPackageRevision: post establish runtime hook failed for package: provider package deployment has no condition of type "Available" yet + └─ Provider/crossplane-provider-aws-something xpkg.crossplane.io/crossplane/provider-aws-something v0.47.0 True - - ActivePackageRevision +`, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p := DefaultPrinter{ + wide: tc.args.wide, + } + + var buf bytes.Buffer + + err := p.Print(&buf, tc.args.resource) + got := buf.String() + + // Check error + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff) + } + // Check table + if diff := cmp.Diff(strings.TrimSpace(tc.want.output), strings.TrimSpace(got)); diff != "" { + t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestDefaultPrinterPrintList(t *testing.T) { + type args struct { + resourceList *resource.ResourceList + wide bool + } + + type want struct { + output string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + // Test valid resource + "ResourceListWithChildren": { + reason: "Should print the resource list with children.", + args: args{ + resourceList: &resource.ResourceList{ + Items: []*resource.Resource{ + GetComplexResource(), + GetSimpleResource(), + }, + }, + wide: false, + }, + want: want{ + // Note: Use spaces instead of tabs for indentation + //nolint:dupword // False positive for 'True True' + output: ` +NAME SYNCED READY STATUS +ObjectStorage/test-resource (default) True True +└─ XObjectStorage/test-resource-hash True True + ├─ Bucket/test-resource-bucket-hash True True + │ ├─ User/test-resource-child-1-bucket-hash True False SomethingWrongHappened: ...rure magna. Non cillum id nulla. Anim culpa do duis consectetur. + │ ├─ User/test-resource-child-mid-bucket-hash False True CantSync: Sync error with bucket child mid + │ └─ User/test-resource-child-2-bucket-hash True False SomethingWrongHappened: Error with bucket child 2 + │ └─ User/test-resource-child-2-1-bucket-hash True - + └─ User/test-resource-user-hash Unknown True +SimpleResource/simple-resource (default) True True +└─ XSimpleResource/simple-resource-hash True True + └─ Something/simple-resource-something-hash True True + +`, + err: nil, + }, + }, + "ResourceListWithChildrenWide": { + reason: "Should print the resource list with children even in wide.", + args: args{ + resourceList: &resource.ResourceList{ + Items: []*resource.Resource{ + GetComplexResource(), + GetSimpleResource(), + }, + }, + wide: true, + }, + want: want{ + // Note: Use spaces instead of tabs for indentation + //nolint:dupword // False positive for 'True True' + output: ` +NAME RESOURCE SYNCED READY STATUS +ObjectStorage/test-resource (default) True True +└─ XObjectStorage/test-resource-hash True True + ├─ Bucket/test-resource-bucket-hash one True True + │ ├─ User/test-resource-child-1-bucket-hash two True False SomethingWrongHappened: Error with bucket child 1: Sint eu mollit tempor ad minim do commodo irure. Magna labore irure magna. Non cillum id nulla. Anim culpa do duis consectetur. + │ ├─ User/test-resource-child-mid-bucket-hash three False True CantSync: Sync error with bucket child mid + │ └─ User/test-resource-child-2-bucket-hash four True False SomethingWrongHappened: Error with bucket child 2 + │ └─ User/test-resource-child-2-1-bucket-hash True - + └─ User/test-resource-user-hash Unknown True +SimpleResource/simple-resource (default) True True +└─ XSimpleResource/simple-resource-hash True True + └─ Something/simple-resource-something-hash something True True +`, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p := DefaultPrinter{ + wide: tc.args.wide, + } + var buf bytes.Buffer + err := p.PrintList(&buf, tc.args.resourceList) + got := buf.String() + + // Check error + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff) + } + // Check table + if diff := cmp.Diff(strings.TrimSpace(tc.want.output), strings.TrimSpace(got)); diff != "" { + t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/beta/trace/internal/printer/dot.go b/cmd/crossplane/beta/trace/internal/printer/dot.go new file mode 100644 index 0000000..c3357ad --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/dot.go @@ -0,0 +1,210 @@ +package printer + +import ( + "fmt" + "io" + "strings" + + "github.com/emicklei/dot" + "github.com/pkg/errors" + + "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + v1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xpkg" +) + +// DotPrinter defines the DotPrinter configuration. +type DotPrinter struct{} + +var _ Printer = &DotPrinter{} + +type dotLabel struct { + namespace string + apiVersion string + name string + ready string + synced string + error string +} + +type queueItem struct { + resource *resource.Resource + parent *dot.Node +} + +func (r *dotLabel) String() string { + out := []string{ + "Name: " + r.name, + "ApiVersion: " + r.apiVersion, + } + if r.namespace != "" { + out = append(out, + "Namespace: "+r.namespace) + } + + out = append(out, + "Ready: "+r.ready, + "Synced: "+r.synced, + ) + if r.error != "" { + out = append(out, + "Error: "+r.error, + ) + } + + return strings.Join(out, "\n") + "\n" +} + +type dotPackageLabel struct { + apiVersion string + name string + pkg string + installed string + healthy string + state string + error string +} + +func (r *dotPackageLabel) String() string { + out := []string{ + "Name: " + r.name, + "ApiVersion: " + r.apiVersion, + "Package: " + r.pkg, + } + if r.installed != "" { + out = append(out, + "Installed: "+r.installed) + } + + out = append(out, + "Healthy: "+r.healthy, + ) + if r.state != "" { + out = append(out, + "State: "+r.state, + ) + } + + if r.error != "" { + out = append(out, + "Error: "+r.error, + ) + } + + return strings.Join(out, "\n") + "\n" +} + +// Print gets all the nodes and then return the graph as a dot format string to the Writer. +func (p *DotPrinter) Print(w io.Writer, root *resource.Resource) error { + g := dot.NewGraph(dot.Undirected) + queue := []*queueItem{{root, nil}} + + printGraphQueue(g, queue) + + dotString := g.String() + if dotString == "" { + return errors.New("graph is empty") + } + g.Write(w) + + return nil +} + +// PrintList gets all the nodes and then return the graph as a dot format string to the Writer. +func (p *DotPrinter) PrintList(w io.Writer, roots *resource.ResourceList) error { + if roots == nil || len(roots.Items) == 0 { + return errors.New("resource list is empty") + } + + g := dot.NewGraph(dot.Undirected) + queue := make([]*queueItem, 0, len(roots.Items)) + + // Initialize the queue with all items in the resource list + for _, r := range roots.Items { + queue = append(queue, &queueItem{r, nil}) + } + + printGraphQueue(g, queue) + + dotString := g.String() + if dotString == "" { + return errors.New("graph is empty") + } + + g.Write(w) + + return nil +} + +func printGraphQueue(g *dot.Graph, queue []*queueItem) { + var id int + + for len(queue) > 0 { + // Dequeue the first element from the start + item := queue[0] + queue = queue[1:] + + node := g.Node(fmt.Sprintf("%d", id)) + id++ + + if item.parent != nil { + g.Edge(*item.parent, node) + } + + var label fmt.Stringer + + gk := item.resource.Unstructured.GroupVersionKind().GroupKind() + switch { + case xpkg.IsPackageType(gk): + pkg, err := fieldpath.Pave(item.resource.Unstructured.Object).GetString("spec.package") + + l := &dotPackageLabel{ + apiVersion: item.resource.Unstructured.GroupVersionKind().GroupVersion().String(), + name: item.resource.Unstructured.GetName(), + pkg: pkg, + installed: string(item.resource.GetCondition(v1.TypeInstalled).Status), + healthy: string(item.resource.GetCondition(v1.TypeHealthy).Status), + } + if err != nil { + l.error = err.Error() + } + + label = l + case xpkg.IsPackageRevisionType(gk): + pkg, err := fieldpath.Pave(item.resource.Unstructured.Object).GetString("spec.image") + + l := &dotPackageLabel{ + apiVersion: item.resource.Unstructured.GroupVersionKind().GroupVersion().String(), + name: item.resource.Unstructured.GetName(), + pkg: pkg, + healthy: string(item.resource.GetCondition(v1.TypeHealthy).Status), + state: string(item.resource.GetCondition(v1.TypeHealthy).Reason), + } + if err != nil { + l.error = err.Error() + } + + label = l + default: + label = &dotLabel{ + namespace: item.resource.Unstructured.GetNamespace(), + apiVersion: item.resource.Unstructured.GetObjectKind().GroupVersionKind().GroupVersion().String(), + name: fmt.Sprintf("%s/%s", item.resource.Unstructured.GetKind(), item.resource.Unstructured.GetName()), + ready: string(item.resource.GetCondition(xpv2.TypeReady).Status), + synced: string(item.resource.GetCondition(xpv2.TypeSynced).Status), + } + } + + node.Label(label.String()) + node.Attr("penwidth", "2") + + // Push the children to the stack, increasing the depth + for _, child := range item.resource.Children { + queue = append(queue, &queueItem{child, &node}) + } + } +} diff --git a/cmd/crossplane/beta/trace/internal/printer/dot_test.go b/cmd/crossplane/beta/trace/internal/printer/dot_test.go new file mode 100644 index 0000000..74478d5 --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/dot_test.go @@ -0,0 +1,188 @@ +package printer + +import ( + "bytes" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +// Define a test for PrintDotGraph. +func TestPrintDotGraphPrint(t *testing.T) { + type args struct { + resource *resource.Resource + } + + type want struct { + dotString string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + // Test valid resource + "ResourceWithChildren": { + reason: "Should print a complex Resource with children.", + args: args{ + resource: GetComplexResource(), + }, + want: want{ + dotString: `graph { + + n1[label="Name: ObjectStorage/test-resource\nApiVersion: test.cloud/v1alpha1\nNamespace: default\nReady: True\nSynced: True\n",penwidth="2"]; + n2[label="Name: XObjectStorage/test-resource-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: True\n",penwidth="2"]; + n3[label="Name: Bucket/test-resource-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: True\n",penwidth="2"]; + n4[label="Name: User/test-resource-user-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: Unknown\n",penwidth="2"]; + n5[label="Name: User/test-resource-child-1-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: False\nSynced: True\n",penwidth="2"]; + n6[label="Name: User/test-resource-child-mid-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: False\n",penwidth="2"]; + n7[label="Name: User/test-resource-child-2-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: False\nSynced: True\n",penwidth="2"]; + n8[label="Name: User/test-resource-child-2-1-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: \nSynced: True\n",penwidth="2"]; + n1--n2; + n2--n3; + n2--n4; + n3--n5; + n3--n6; + n3--n7; + n7--n8; + +} +`, + err: nil, + }, + }, + "PackageResourceWithChildren": { + reason: "Should print a complex Package with children.", + args: args{ + resource: GetComplexPackage(), + }, + want: want{ + dotString: `graph { + + n1[label="Name: platform-ref-aws\nApiVersion: pkg.crossplane.io/v1\nPackage: xpkg.crossplane.io/crossplane/platform-ref-aws:v0.9.0\nInstalled: True\nHealthy: True\n",penwidth="2"]; + n2[label="Name: platform-ref-aws-9ad7b5db2899\nApiVersion: pkg.crossplane.io/v1\nPackage: xpkg.crossplane.io/crossplane/platform-ref-aws:v0.9.0\nHealthy: True\nState: HealthyPackageRevision\n",penwidth="2"]; + n3[label="Name: crossplane-configuration-aws-network\nApiVersion: pkg.crossplane.io/v1\nPackage: xpkg.crossplane.io/crossplane/configuration-aws-network:v0.7.0\nInstalled: True\nHealthy: True\n",penwidth="2"]; + n4[label="Name: crossplane-configuration-aws-network-97be9100cfe1\nApiVersion: pkg.crossplane.io/v1\nPackage: xpkg.crossplane.io/crossplane/configuration-aws-network:v0.7.0\nHealthy: True\nState: HealthyPackageRevision\n",penwidth="2"]; + n5[label="Name: crossplane-provider-aws-ec2\nApiVersion: pkg.crossplane.io/v1\nPackage: xpkg.crossplane.io/crossplane/provider-aws-ec2:v0.47.0\nInstalled: True\nHealthy: Unknown\n",penwidth="2"]; + n6[label="Name: crossplane-provider-aws-ec2-9ad7b5db2899\nApiVersion: pkg.crossplane.io/v1\nPackage: xpkg.crossplane.io/crossplane/provider-aws-ec2:v0.47.0\nHealthy: False\nState: UnhealthyPackageRevision\n",penwidth="2"]; + n7[label="Name: crossplane-provider-aws-something\nApiVersion: pkg.crossplane.io/v1\nPackage: xpkg.crossplane.io/crossplane/provider-aws-something:v0.47.0\nInstalled: True\nHealthy: \n",penwidth="2"]; + n1--n2; + n1--n3; + n3--n4; + n3--n5; + n5--n6; + n5--n7; + +} +`, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + // Create a GraphPrinter + p := &DotPrinter{} + + var buf bytes.Buffer + + err := p.Print(&buf, tc.args.resource) + got := buf.String() + + // Check error + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\ndotPrinter.Print(): -want, +got:\n%s", tc.reason, diff) + } + + // Check if dotString is correct + if diff := cmp.Diff(tc.want.dotString, got); diff != "" { + t.Errorf("%s\nDotPrinter.Print(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestPrintDotGraphPrintList(t *testing.T) { + type args struct { + resourceList *resource.ResourceList + } + + type want struct { + dotString string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + // Test valid resource + "MultipleResourceWithChildren": { + reason: "Should print multiple resources with children.", + args: args{ + resourceList: &resource.ResourceList{ + Items: []*resource.Resource{ + GetComplexResource(), + GetSimpleResource(), + }, + }, + }, + want: want{ + dotString: `graph { + + n1[label="Name: ObjectStorage/test-resource\nApiVersion: test.cloud/v1alpha1\nNamespace: default\nReady: True\nSynced: True\n",penwidth="2"]; + n2[label="Name: SimpleResource/simple-resource\nApiVersion: test.cloud/v1alpha1\nNamespace: default\nReady: True\nSynced: True\n",penwidth="2"]; + n11[label="Name: User/test-resource-child-2-1-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: \nSynced: True\n",penwidth="2"]; + n3[label="Name: XObjectStorage/test-resource-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: True\n",penwidth="2"]; + n4[label="Name: XSimpleResource/simple-resource-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: True\n",penwidth="2"]; + n5[label="Name: Bucket/test-resource-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: True\n",penwidth="2"]; + n6[label="Name: User/test-resource-user-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: Unknown\n",penwidth="2"]; + n7[label="Name: Something/simple-resource-something-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: True\n",penwidth="2"]; + n8[label="Name: User/test-resource-child-1-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: False\nSynced: True\n",penwidth="2"]; + n9[label="Name: User/test-resource-child-mid-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: True\nSynced: False\n",penwidth="2"]; + n10[label="Name: User/test-resource-child-2-bucket-hash\nApiVersion: test.cloud/v1alpha1\nReady: False\nSynced: True\n",penwidth="2"]; + n1--n3; + n2--n4; + n3--n5; + n3--n6; + n4--n7; + n5--n8; + n5--n9; + n5--n10; + n10--n11; + +} +`, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + // Create a GraphPrinter + p := &DotPrinter{} + var buf bytes.Buffer + err := p.PrintList(&buf, tc.args.resourceList) + got := buf.String() + + // Check error + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\ndotPrinter.Print(): -want, +got:\n%s", tc.reason, diff) + } + + // Check if dotString is correct + if diff := cmp.Diff(tc.want.dotString, got); diff != "" { + t.Errorf("%s\nDotPrinter.Print(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/beta/trace/internal/printer/json.go b/cmd/crossplane/beta/trace/internal/printer/json.go new file mode 100644 index 0000000..939436c --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/json.go @@ -0,0 +1,65 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/pkg/errors" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +const ( + errCannotMarshalJSON = "cannot marshal resource graph as JSON" +) + +// JSONPrinter is a printer that prints the resource graph as JSON. +type JSONPrinter struct{} + +var _ Printer = &JSONPrinter{} + +// Print implements the Printer interface. +func (p *JSONPrinter) Print(w io.Writer, root *resource.Resource) error { + out, err := json.MarshalIndent(root, "", " ") + if err != nil { + return errors.Wrap(err, errCannotMarshalJSON) + } + + _, err = fmt.Fprintln(w, string(out)) + + return err +} + +// PrintList implements the Printer interface. +func (p *JSONPrinter) PrintList(w io.Writer, roots *resource.ResourceList) error { + if roots == nil { + roots = &resource.ResourceList{} + } + if roots.Items == nil { + roots.Items = []*resource.Resource{} + } + + out, err := json.MarshalIndent(roots, "", " ") + if err != nil { + return errors.Wrap(err, errCannotMarshalJSON) + } + _, err = fmt.Fprintln(w, string(out)) + return err +} diff --git a/cmd/crossplane/beta/trace/internal/printer/json_test.go b/cmd/crossplane/beta/trace/internal/printer/json_test.go new file mode 100644 index 0000000..9ceccbb --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/json_test.go @@ -0,0 +1,682 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +func TestJSONPrinterPrint(t *testing.T) { + type args struct { + resource *resource.Resource + } + + type want struct { + output string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + // Test valid resource + "ComplexResourceWithChildren": { + reason: "Should print a complex Resource with children.", + args: args{ + resource: GetComplexResource(), + }, + want: want{ + // Note: Use spaces instead of tabs for intendation + output: ` +{ + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "ObjectStorage", + "metadata": { + "name": "test-resource", + "namespace": "default" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "XObjectStorage", + "metadata": { + "name": "test-resource-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "Bucket", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "one" + }, + "name": "test-resource-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "two" + }, + "name": "test-resource-child-1-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "message": "Error with bucket child 1: Sint eu mollit tempor ad minim do commodo irure. Magna labore irure magna. Non cillum id nulla. Anim culpa do duis consectetur.", + "reason": "SomethingWrongHappened", + "status": "False", + "type": "Ready" + } + ] + } + } + }, + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "three" + }, + "name": "test-resource-child-mid-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "message": "Sync error with bucket child mid", + "reason": "CantSync", + "status": "False", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "AllGood", + "status": "True", + "type": "Ready" + } + ] + } + } + }, + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "four" + }, + "name": "test-resource-child-2-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "message": "Error with bucket child 2", + "reason": "SomethingWrongHappened", + "status": "False", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "" + }, + "name": "test-resource-child-2-1-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + } + ] + } + } + } + ] + } + ] + }, + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "name": "test-resource-user-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "Unknown", + "type": "Synced" + } + ] + } + } + } + ] + } + ] +} +`, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p := JSONPrinter{} + + var buf bytes.Buffer + + err := p.Print(&buf, tc.args.resource) + gotJSON := buf.String() + + // Check error + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff) + } + // Unmarshal expected and actual output to compare them as maps + // instead of strings, to avoid order dependent failures + var output, got map[string]any + if err := json.Unmarshal([]byte(tc.want.output), &output); err != nil { + t.Errorf("JSONPrinter.Print() error unmarshalling expected output: %s", err) + } + + if err := json.Unmarshal([]byte(gotJSON), &got); err != nil { + t.Errorf("JSONPrinter.Print() error unmarshalling actual output: %s", err) + } + // Check table + if diff := cmp.Diff(output, got); diff != "" { + t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestJSONPrinterPrintList(t *testing.T) { + type args struct { + resourceList *resource.ResourceList + } + + type want struct { + output string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + // Test valid resource + "ComplexResourceWithChildren": { + reason: "Should print multiple resources with children.", + args: args{ + resourceList: &resource.ResourceList{ + Items: []*resource.Resource{ + GetComplexResource(), + GetSimpleResource(), + }, + }, + }, + want: want{ + // Note: Use spaces instead of tabs for intendation + output: ` +{ + "items": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "ObjectStorage", + "metadata": { + "name": "test-resource", + "namespace": "default" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "XObjectStorage", + "metadata": { + "name": "test-resource-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "Bucket", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "one" + }, + "name": "test-resource-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "two" + }, + "name": "test-resource-child-1-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "message": "Error with bucket child 1: Sint eu mollit tempor ad minim do commodo irure. Magna labore irure magna. Non cillum id nulla. Anim culpa do duis consectetur.", + "reason": "SomethingWrongHappened", + "status": "False", + "type": "Ready" + } + ] + } + } + }, + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "three" + }, + "name": "test-resource-child-mid-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "message": "Sync error with bucket child mid", + "reason": "CantSync", + "status": "False", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "AllGood", + "status": "True", + "type": "Ready" + } + ] + } + } + }, + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "four" + }, + "name": "test-resource-child-2-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "message": "Error with bucket child 2", + "reason": "SomethingWrongHappened", + "status": "False", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "" + }, + "name": "test-resource-child-2-1-bucket-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + } + ] + } + } + } + ] + } + ] + }, + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "User", + "metadata": { + "name": "test-resource-user-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "Unknown", + "type": "Synced" + } + ] + } + } + } + ] + } + ] + }, + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "SimpleResource", + "metadata": { + "name": "simple-resource", + "namespace": "default" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "XSimpleResource", + "metadata": { + "name": "simple-resource-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + } + ] + } + }, + "children": [ + { + "object": { + "apiVersion": "test.cloud/v1alpha1", + "kind": "Something", + "metadata": { + "annotations": { + "crossplane.io/composition-resource-name": "something" + }, + "name": "simple-resource-something-hash" + }, + "status": { + "conditions": [ + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Synced" + }, + { + "lastTransitionTime": null, + "reason": "", + "status": "True", + "type": "Ready" + } + ] + } + } + } + ] + } + ] + } + ] +} +`, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p := JSONPrinter{} + var buf bytes.Buffer + err := p.PrintList(&buf, tc.args.resourceList) + gotJSON := buf.String() + + // Check error + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff) + } + // Unmarshal expected and actual output to compare them as maps + // instead of strings, to avoid order dependent failures + var output, got map[string]any + if err := json.Unmarshal([]byte(tc.want.output), &output); err != nil { + t.Errorf("JSONPrinter.Print() error unmarshalling expected output: %s", err) + } + if err := json.Unmarshal([]byte(gotJSON), &got); err != nil { + t.Errorf("JSONPrinter.Print() error unmarshalling actual output: %s", err) + } + // Check table + if diff := cmp.Diff(output, got); diff != "" { + t.Errorf("%s\nCliTableAddResource(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/beta/trace/internal/printer/printer.go b/cmd/crossplane/beta/trace/internal/printer/printer.go new file mode 100644 index 0000000..2b41bd8 --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/printer.go @@ -0,0 +1,73 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package printer contains the definition of the Printer interface and the +// implementation of all the available printers implementing it. +package printer + +import ( + "io" + + "github.com/pkg/errors" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +const ( + errFmtUnknownPrinterType = "unknown printer output type: %s" +) + +// Type represents the type of printer. +type Type string + +// Implemented PrinterTypes. +const ( + TypeDefault Type = "default" + TypeWide Type = "wide" + TypeJSON Type = "json" + TypeDot Type = "dot" + TypeYAML Type = "yaml" +) + +// Printer implements the interface which is used by all printers in this package. +type Printer interface { + Print(w io.Writer, r *resource.Resource) error + PrintList(w io.Writer, r *resource.ResourceList) error +} + +// New creates a new printer based on the specified type. +func New(typeStr string) (Printer, error) { + var p Printer + + switch Type(typeStr) { + case TypeDefault: + p = &DefaultPrinter{} + case TypeWide: + p = &DefaultPrinter{ + wide: true, + } + case TypeJSON: + p = &JSONPrinter{} + case TypeDot: + p = &DotPrinter{} + case TypeYAML: + p = &YAMLPrinter{} + default: + return nil, errors.Errorf(errFmtUnknownPrinterType, typeStr) + } + + return p, nil +} diff --git a/cmd/crossplane/beta/trace/internal/printer/printer_test.go b/cmd/crossplane/beta/trace/internal/printer/printer_test.go new file mode 100644 index 0000000..60ecc74 --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/printer_test.go @@ -0,0 +1,293 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" + "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + v1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +// DummyManifestOpt can be passed to customize a dummy manifest. +type DummyManifestOpt func(*unstructured.Unstructured) + +// DummyManifest returns an unstructured that has basic fields set to be used by +// other tests, can be customized with DummyManifestOpt. +func DummyManifest(kind, name string, opts ...DummyManifestOpt) unstructured.Unstructured { + m := unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.cloud/v1alpha1", + "kind": kind, + "metadata": map[string]any{ + "name": name, + }, + }, + } + + for _, opt := range opts { + opt(&m) + } + + return m +} + +// WithAPIVersion sets the APIVersion of the manifest. +func WithAPIVersion(apiVersion string) DummyManifestOpt { + return func(m *unstructured.Unstructured) { + m.SetAPIVersion(apiVersion) + } +} + +// WithNamespace sets the Namespace of the manifest. +func WithNamespace(namespace string) DummyManifestOpt { + return func(m *unstructured.Unstructured) { + m.SetNamespace(namespace) + } +} + +// WithConditions sets the given conditions on the manifest. +func WithConditions(conds ...xpv2.Condition) DummyManifestOpt { + return func(m *unstructured.Unstructured) { + fieldpath.Pave(m.Object).SetValue("status.conditions", conds) + } +} + +func WithCompositionResourceName(n string) DummyManifestOpt { + return func(m *unstructured.Unstructured) { + meta.AddAnnotations(m, map[string]string{xcrd.AnnotationKeyCompositionResourceName: n}) + } +} + +// WithImage sets the image of the manifest. +func WithImage(image string) DummyManifestOpt { + return func(m *unstructured.Unstructured) { + fieldpath.Pave(m.Object).SetValue("spec.image", image) + } +} + +// WithPackage sets the package of the manifest. +func WithPackage(pkg string) DummyManifestOpt { + return func(m *unstructured.Unstructured) { + fieldpath.Pave(m.Object).SetValue("spec.package", pkg) + } +} + +// WithDesiredState sets the desired state of the manifest. +func WithDesiredState(state v1.PackageRevisionDesiredState) DummyManifestOpt { + return func(m *unstructured.Unstructured) { + fieldpath.Pave(m.Object).SetValue("spec.desiredState", state) + } +} + +// DummyNamespacedResource returns an unstructured that has basic fields set to be used by other tests. +func DummyNamespacedResource(kind, name, namespace string, conds ...xpv2.Condition) unstructured.Unstructured { + return DummyManifest(kind, name, WithConditions(conds...), WithNamespace(namespace)) +} + +func DummyClusterScopedResource(kind, name string, conds ...xpv2.Condition) unstructured.Unstructured { + return DummyManifest(kind, name, WithConditions(conds...)) +} + +func DummyComposedResource(kind, name, resourceName string, conds ...xpv2.Condition) unstructured.Unstructured { + return DummyManifest(kind, name, WithConditions(conds...), WithCompositionResourceName(resourceName)) +} + +// DummyPackage returns an unstructured that has basic fields set to be used by other tests. +func DummyPackage(gvk schema.GroupVersionKind, name string, opts ...DummyManifestOpt) unstructured.Unstructured { + return DummyManifest(gvk.Kind, name, append([]DummyManifestOpt{WithAPIVersion(gvk.GroupVersion().String())}, opts...)...) +} + +// GetComplexResource returns a complex resource with children. +func GetComplexResource() *resource.Resource { + return &resource.Resource{ + Unstructured: DummyNamespacedResource("ObjectStorage", "test-resource", "default", xpv2.Condition{ + Type: "Synced", + Status: "True", + }, xpv2.Condition{ + Type: "Ready", + Status: "True", + }), + Children: []*resource.Resource{ + { + Unstructured: DummyClusterScopedResource("XObjectStorage", "test-resource-hash", xpv2.Condition{ + Type: "Synced", + Status: "True", + }, xpv2.Condition{ + Type: "Ready", + Status: "True", + }), + Children: []*resource.Resource{ + { + Unstructured: DummyComposedResource("Bucket", "test-resource-bucket-hash", "one", xpv2.Condition{ + Type: "Synced", + Status: "True", + }, xpv2.Condition{ + Type: "Ready", + Status: "True", + }), + Children: []*resource.Resource{ + { + Unstructured: DummyComposedResource("User", "test-resource-child-1-bucket-hash", "two", xpv2.Condition{ + Type: "Synced", + Status: "True", + }, xpv2.Condition{ + Type: "Ready", + Status: "False", + Reason: "SomethingWrongHappened", + Message: "Error with bucket child 1: Sint eu mollit tempor ad minim do commodo irure. Magna labore irure magna. Non cillum id nulla. Anim culpa do duis consectetur.", + }), + }, + { + Unstructured: DummyComposedResource("User", "test-resource-child-mid-bucket-hash", "three", xpv2.Condition{ + Type: "Synced", + Status: "False", + Reason: "CantSync", + Message: "Sync error with bucket child mid", + }, xpv2.Condition{ + Type: "Ready", + Status: "True", + Reason: "AllGood", + }), + }, + { + Unstructured: DummyComposedResource("User", "test-resource-child-2-bucket-hash", "four", xpv2.Condition{ + Type: "Synced", + Status: "True", + }, xpv2.Condition{ + Type: "Ready", + Reason: "SomethingWrongHappened", + Status: "False", + Message: "Error with bucket child 2", + }), + Children: []*resource.Resource{ + { + Unstructured: DummyComposedResource("User", "test-resource-child-2-1-bucket-hash", "", xpv2.Condition{ + Type: "Synced", + Status: "True", + }), + }, + }, + }, + }, + }, + { + Unstructured: DummyClusterScopedResource("User", "test-resource-user-hash", xpv2.Condition{ + Type: "Ready", + Status: "True", + }, xpv2.Condition{ + Type: "Synced", + Status: "Unknown", + }), + }, + }, + }, + }, + } +} + +// GetSimpleResource returns a simple resource with children. +func GetSimpleResource() *resource.Resource { + return &resource.Resource{ + Unstructured: DummyNamespacedResource("SimpleResource", "simple-resource", "default", xpv2.Condition{ + Type: "Synced", + Status: "True", + }, xpv2.Condition{ + Type: "Ready", + Status: "True", + }), + Children: []*resource.Resource{ + { + Unstructured: DummyClusterScopedResource("XSimpleResource", "simple-resource-hash", xpv2.Condition{ + Type: "Synced", + Status: "True", + }, xpv2.Condition{ + Type: "Ready", + Status: "True", + }), + Children: []*resource.Resource{ + { + Unstructured: DummyComposedResource("Something", "simple-resource-something-hash", "something", xpv2.Condition{ + Type: "Synced", + Status: "True", + }, xpv2.Condition{ + Type: "Ready", + Status: "True", + }), + }, + }, + }, + }, + } +} + +// GetComplexPackage returns a complex package with children. +func GetComplexPackage() *resource.Resource { + return &resource.Resource{ + Unstructured: DummyPackage(v1.ConfigurationGroupVersionKind, "platform-ref-aws", + WithConditions(v1.Active(), v1.Healthy()), + WithPackage("xpkg.crossplane.io/crossplane/platform-ref-aws:v0.9.0")), + Children: []*resource.Resource{ + { + Unstructured: DummyPackage(v1.ConfigurationRevisionGroupVersionKind, "platform-ref-aws-9ad7b5db2899", + WithConditions(v1.Active(), v1.Healthy()), + WithImage("xpkg.crossplane.io/crossplane/platform-ref-aws:v0.9.0"), + WithDesiredState(v1.PackageRevisionActive)), + }, + { + Unstructured: DummyPackage(v1.ConfigurationGroupVersionKind, "crossplane-configuration-aws-network", + WithConditions(v1.Active(), v1.Healthy()), + WithPackage("xpkg.crossplane.io/crossplane/configuration-aws-network:v0.7.0")), + Children: []*resource.Resource{ + { + Unstructured: DummyPackage(v1.ConfigurationRevisionGroupVersionKind, "crossplane-configuration-aws-network-97be9100cfe1", + WithConditions(v1.Active(), v1.Healthy()), + WithImage("xpkg.crossplane.io/crossplane/configuration-aws-network:v0.7.0"), + WithDesiredState(v1.PackageRevisionActive)), + }, + { + Unstructured: DummyPackage(v1.ProviderGroupVersionKind, "crossplane-provider-aws-ec2", + WithConditions(v1.Active(), v1.UnknownHealth().WithMessage("cannot resolve package dependencies: incompatible dependencies: [xpkg.crossplane.io/crossplane-contrib/provider-helm xpkg.crossplane.io/crossplane-contrib/provider-kubernetes]")), + WithPackage("xpkg.crossplane.io/crossplane/provider-aws-ec2:v0.47.0"), + ), + Children: []*resource.Resource{ + { + Unstructured: DummyPackage(v1.ProviderRevisionGroupVersionKind, "crossplane-provider-aws-ec2-9ad7b5db2899", + WithConditions(v1.Active(), v1.Unhealthy().WithMessage("post establish runtime hook failed for package: provider package deployment has no condition of type \"Available\" yet")), + WithImage("xpkg.crossplane.io/crossplane/provider-aws-ec2:v0.47.0"), + WithDesiredState(v1.PackageRevisionActive)), + }, + { + Unstructured: DummyPackage(v1.ProviderGroupVersionKind, "crossplane-provider-aws-something", + WithConditions(v1.Active()), // Missing healthy condition on purpose. + WithPackage("xpkg.crossplane.io/crossplane/provider-aws-something:v0.47.0"), + ), + }, + }, + }, + }, + }, + }, + } +} diff --git a/cmd/crossplane/beta/trace/internal/printer/yaml.go b/cmd/crossplane/beta/trace/internal/printer/yaml.go new file mode 100644 index 0000000..d98589f --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/yaml.go @@ -0,0 +1,65 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "fmt" + "io" + + "github.com/pkg/errors" + "sigs.k8s.io/yaml" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +const ( + errCannotMarshalYAML = "cannot marshal resource graph as YAML" +) + +// YAMLPrinter is a printer that prints the resource graph as YAML. +type YAMLPrinter struct{} + +var _ Printer = &YAMLPrinter{} + +// Print implements the Printer interface. +func (p *YAMLPrinter) Print(w io.Writer, root *resource.Resource) error { + out, err := yaml.Marshal(root) + if err != nil { + return errors.Wrap(err, errCannotMarshalYAML) + } + + _, err = fmt.Fprintln(w, string(out)) + + return err +} + +// PrintList implements the Printer interface. +func (p *YAMLPrinter) PrintList(w io.Writer, roots *resource.ResourceList) error { + if roots == nil { + roots = &resource.ResourceList{} + } + if roots.Items == nil { + roots.Items = []*resource.Resource{} + } + + out, err := yaml.Marshal(roots) + if err != nil { + return errors.Wrap(err, errCannotMarshalYAML) + } + _, err = fmt.Fprintln(w, string(out)) + return err +} diff --git a/cmd/crossplane/beta/trace/internal/printer/yaml_test.go b/cmd/crossplane/beta/trace/internal/printer/yaml_test.go new file mode 100644 index 0000000..58454fe --- /dev/null +++ b/cmd/crossplane/beta/trace/internal/printer/yaml_test.go @@ -0,0 +1,472 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package printer + +import ( + "bytes" + "testing" + + "github.com/google/go-cmp/cmp" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +func TestYAMLPrinterPrint(t *testing.T) { + type args struct { + resource *resource.Resource + } + + type want struct { + output string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + // Test valid resource + "ComplexResourceWithChildren": { + reason: "Should print a complex Resource with children.", + args: args{ + resource: GetComplexResource(), + }, + want: want{ + // Note: Use spaces instead of tabs for intendation + output: ` +object: + apiVersion: test.cloud/v1alpha1 + kind: ObjectStorage + metadata: + name: test-resource + namespace: default + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready +children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: XObjectStorage + metadata: + name: test-resource-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready + children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: Bucket + metadata: + annotations: + crossplane.io/composition-resource-name: one + name: test-resource-bucket-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready + children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + annotations: + crossplane.io/composition-resource-name: two + name: test-resource-child-1-bucket-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + message: 'Error with bucket child 1: Sint eu mollit tempor ad minim do commodo irure. Magna labore irure magna. Non cillum id nulla. Anim culpa do duis consectetur.' + reason: SomethingWrongHappened + status: "False" + type: Ready + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + annotations: + crossplane.io/composition-resource-name: three + name: test-resource-child-mid-bucket-hash + status: + conditions: + - lastTransitionTime: null + message: Sync error with bucket child mid + reason: CantSync + status: "False" + type: Synced + - lastTransitionTime: null + reason: AllGood + status: "True" + type: Ready + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + annotations: + crossplane.io/composition-resource-name: four + name: test-resource-child-2-bucket-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + message: Error with bucket child 2 + reason: SomethingWrongHappened + status: "False" + type: Ready + children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + annotations: + crossplane.io/composition-resource-name: "" + name: test-resource-child-2-1-bucket-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + name: test-resource-user-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready + - lastTransitionTime: null + reason: "" + status: Unknown + type: Synced +`, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p := YAMLPrinter{} + + var buf bytes.Buffer + + err := p.Print(&buf, tc.args.resource) + gotYAML := buf.String() + + // Check error + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nYAMLPrinter.Print(): -want, +got:\n%s", tc.reason, diff) + } + // Unmarshal expected and actual output to compare them as maps + // instead of strings, to avoid order dependent failures + var output, got map[string]any + if err := yaml.Unmarshal([]byte(tc.want.output), &output); err != nil { + t.Errorf("YAMLPrinter.Print() error unmarshalling expected output: %s", err) + } + + if err := yaml.Unmarshal([]byte(gotYAML), &got); err != nil { + t.Errorf("YAMLPrinter.Print() error unmarshalling actual output: %s", err) + } + // Check table + if diff := cmp.Diff(output, got); diff != "" { + t.Errorf("%s\nYAMLPrinter.Print(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestYAMLPrinterPrintList(t *testing.T) { + type args struct { + resourceList *resource.ResourceList + } + + type want struct { + output string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + // Test valid resource + "ComplexResourceWithChildren": { + reason: "Should print multiple resources with children.", + args: args{ + resourceList: &resource.ResourceList{ + Items: []*resource.Resource{ + GetComplexResource(), + GetSimpleResource(), + }, + }, + }, + want: want{ + // Note: Use spaces instead of tabs for intendation + output: ` +items: + - object: + apiVersion: test.cloud/v1alpha1 + kind: ObjectStorage + metadata: + name: test-resource + namespace: default + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready + children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: XObjectStorage + metadata: + name: test-resource-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready + children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: Bucket + metadata: + annotations: + crossplane.io/composition-resource-name: one + name: test-resource-bucket-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready + children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + annotations: + crossplane.io/composition-resource-name: two + name: test-resource-child-1-bucket-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + message: 'Error with bucket child 1: Sint eu mollit tempor ad minim do commodo irure. Magna labore irure magna. Non cillum id nulla. Anim culpa do duis consectetur.' + reason: SomethingWrongHappened + status: "False" + type: Ready + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + annotations: + crossplane.io/composition-resource-name: three + name: test-resource-child-mid-bucket-hash + status: + conditions: + - lastTransitionTime: null + message: Sync error with bucket child mid + reason: CantSync + status: "False" + type: Synced + - lastTransitionTime: null + reason: AllGood + status: "True" + type: Ready + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + annotations: + crossplane.io/composition-resource-name: four + name: test-resource-child-2-bucket-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + message: Error with bucket child 2 + reason: SomethingWrongHappened + status: "False" + type: Ready + children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + annotations: + crossplane.io/composition-resource-name: "" + name: test-resource-child-2-1-bucket-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - object: + apiVersion: test.cloud/v1alpha1 + kind: User + metadata: + name: test-resource-user-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready + - lastTransitionTime: null + reason: "" + status: Unknown + type: Synced + - object: + apiVersion: test.cloud/v1alpha1 + kind: SimpleResource + metadata: + name: simple-resource + namespace: default + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready + children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: XSimpleResource + metadata: + name: simple-resource-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready + children: + - object: + apiVersion: test.cloud/v1alpha1 + kind: Something + metadata: + annotations: + crossplane.io/composition-resource-name: something + name: simple-resource-something-hash + status: + conditions: + - lastTransitionTime: null + reason: "" + status: "True" + type: Synced + - lastTransitionTime: null + reason: "" + status: "True" + type: Ready +`, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + p := YAMLPrinter{} + var buf bytes.Buffer + err := p.PrintList(&buf, tc.args.resourceList) + gotYAML := buf.String() + + // Check error + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nYAMLPrinter.PrintList(): -want, +got:\n%s", tc.reason, diff) + } + // Unmarshal expected and actual output to compare them as maps + // instead of strings, to avoid order dependent failures + var output, got map[string]any + if err := yaml.Unmarshal([]byte(tc.want.output), &output); err != nil { + t.Errorf("YAMLPrinter.PrintList() error unmarshalling expected output: %s", err) + } + if err := yaml.Unmarshal([]byte(gotYAML), &got); err != nil { + t.Errorf("YAMLPrinter.PrintList() error unmarshalling actual output: %s", err) + } + // Check table + if diff := cmp.Diff(output, got); diff != "" { + t.Errorf("%s\nYAMLPrinter.PrintList(): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/beta/trace/trace.go b/cmd/crossplane/beta/trace/trace.go new file mode 100644 index 0000000..1c58c5b --- /dev/null +++ b/cmd/crossplane/beta/trace/trace.go @@ -0,0 +1,341 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package trace contains the trace command. +package trace + +import ( + "context" + "strings" + + "github.com/alecthomas/kong" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/kubernetes/scheme" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/crossplane/apis/v2/pkg" + + "github.com/crossplane/cli/v2/cmd/crossplane/beta/trace/internal/printer" + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xpkg" + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource/xrm" + "github.com/crossplane/cli/v2/cmd/crossplane/internal" +) + +const ( + errGetResource = "cannot get requested resource" + errFmtGetResource = "cannot get requested resource: kind=%s name=%s namespace=%s (verify context, namespace, and that the resource exists)" + errFmtGetResourceTree = "cannot get resource tree: kind=%s name=%s namespace=%s (verify context, namespace, and that the resource exists)" + errCliOutput = "cannot print output" + errKubeConfig = "failed to get kubeconfig" + errKubeNamespace = "failed to get namespace from kubeconfig" + errInitKubeClient = "cannot init kubeclient" + errGetDiscoveryClient = "cannot get discovery client" + errGetMapping = "cannot get mapping for resource" + errInitPrinter = "cannot init new printer" + errNameDoubled = "name provided twice, must be provided separately 'TYPE[.VERSION][.GROUP] [NAME]' or in the 'TYPE[.VERSION][.GROUP][/NAME]' format" + errInvalidResource = "invalid resource, must be provided in the 'TYPE[.VERSION][.GROUP][/NAME]' format" + errInvalidResourceAndName = "invalid resource and name" +) + +// Cmd builds the trace tree for a Crossplane resource. +type Cmd struct { + Resource string `arg:"" help:"Kind of the Crossplane resource, accepts the 'TYPE[.VERSION][.GROUP][/NAME]' format." predictor:"k8s_resource"` + Name string `arg:"" help:"Name of the Crossplane resource, can be passed as part of the resource too." optional:"" predictor:"k8s_resource_name"` + + // TODO(phisco): add support for all the usual kubectl flags; configFlags := genericclioptions.NewConfigFlags(true).AddFlags(...) + Context string `default:"" help:"Kubernetes context." name:"context" predictor:"context" short:"c"` + Namespace string `default:"" help:"Namespace of the resource." name:"namespace" predictor:"namespace" short:"n"` + Output string `default:"default" enum:"default,wide,json,dot,yaml" help:"Output format. One of: default, wide, json, dot, yaml." name:"output" short:"o"` + ShowConnectionSecrets bool `help:"Show connection secrets in the output." name:"show-connection-secrets" short:"s"` + ShowPackageDependencies string `default:"unique" enum:"unique,all,none" help:"Show package dependencies in the output. One of: unique, all, none." name:"show-package-dependencies"` + ShowPackageRevisions string `default:"active" enum:"active,all,none" help:"Show package revisions in the output. One of: active, all, none." name:"show-package-revisions"` + ShowPackageRuntimeConfigs bool `default:"false" help:"Show package runtime configs in the output." name:"show-package-runtime-configs"` + Concurrency int `default:"5" help:"load concurrency" name:"concurrency"` + Watch bool `default:"false" help:"Watch for changes until the resource is deleted" name:"watch" short:"w"` +} + +// Help returns help message for the trace command. +func (c *Cmd) Help() string { + return ` +This command trace a Crossplane resource (Claim, Composite, or Managed Resource) +to get a detailed output of its relationships, helpful for troubleshooting. + +If needed the resource kind can be also specified further, +'TYPE[.VERSION][.GROUP]', e.g. mykind.example.org or +mykind.v1alpha1.example.org. + +Examples: + # Trace a MyKind resource (mykinds.example.org/v1alpha1) named 'my-res' in the namespace 'my-ns' + crossplane beta trace mykind my-res -n my-ns + + # Trace all MyKind resources (mykinds.example.org/v1alpha1) in the namespace 'my-ns' + crossplane beta trace mykind -n my-ns + + # Output wide format, showing full errors and condition messages, and other useful info + # depending on the target type, e.g. composed resources names for composite resources or image used for packages + crossplane beta trace mykind my-res -n my-ns -o wide + + # Show connection secrets in the output + crossplane beta trace mykind my-res -n my-ns --show-connection-secrets + + # Output a graph in dot format and pipe to dot to generate a png + crossplane beta trace mykind my-res -n my-ns -o dot | dot -Tpng -o output.png + + # Output all retrieved resources to json and pipe to jq to have it coloured + crossplane beta trace mykind my-res -n my-ns -o json | jq + + # Output debug logs to stderr while redirecting a dot formatted graph to dot + crossplane beta trace mykind my-res -n my-ns -o dot --verbose | dot -Tpng -o output.png + + # Watch a resource continuously until it is deleted + crossplane beta trace mykind my-res -n my-ns --watch +` +} + +func (c *Cmd) setupKubeClient(logger logging.Logger) (clientcmd.ClientConfig, client.WithWatch, meta.RESTMapper, error) { + clientconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{CurrentContext: c.Context}, + ) + + kubeconfig, err := clientconfig.ClientConfig() + if err != nil { + return nil, nil, nil, errors.Wrap(err, errKubeConfig) + } + + // NOTE(phisco): We used to get them set as part of + // https://github.com/kubernetes-sigs/controller-runtime/blob/2e9781e9fc6054387cf0901c70db56f0b0a63083/pkg/client/config/config.go#L96, + // this new approach doesn't set them, so we need to set them here to avoid + // being utterly slow. + // TODO(phisco): make this configurable. + if kubeconfig.QPS == 0 { + kubeconfig.QPS = 20 + } + + if kubeconfig.Burst == 0 { + kubeconfig.Burst = 30 + } + + logger.Debug("Found kubeconfig") + + cl, err := client.NewWithWatch(kubeconfig, client.Options{ + Scheme: scheme.Scheme, + }) + if err != nil { + return nil, nil, nil, errors.Wrap(err, errInitKubeClient) + } + + // add package scheme + _ = pkg.AddToScheme(cl.Scheme()) + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(kubeconfig) + if err != nil { + return nil, nil, nil, errors.Wrap(err, errGetDiscoveryClient) + } + // TODO(phisco): properly handle flags and switch to file backed cache + // (restmapper.NewDeferredDiscoveryRESTMapper), as cli-runtime + // pkg/resource Builder does. + d := memory.NewMemCacheClient(discoveryClient) + rmapper := restmapper.NewShortcutExpander(restmapper.NewDeferredDiscoveryRESTMapper(d), d, nil) + + return clientconfig, cl, rmapper, nil +} + +// Run runs the trace command. +func (c *Cmd) Run(k *kong.Context, logger logging.Logger) error { + ctx := context.Background() + logger = logger.WithValues("Resource", c.Resource, "Name", c.Name) + + // Init new printer + p, err := printer.New(c.Output) + if err != nil { + return errors.Wrap(err, errInitPrinter) + } + + logger.Debug("Built printer", "output", c.Output) + + clientconfig, client, rmapper, err := c.setupKubeClient(logger) + if err != nil { + return err + } + + res, name, err := c.getResourceAndName() + if err != nil { + return errors.Wrap(err, errInvalidResourceAndName) + } + + mapping, err := internal.MappingFor(rmapper, res) + if err != nil { + return errors.Wrap(err, errGetMapping) + } + + // Get Resource object. Contains k8s resource and all its children, also as Resource. + rootRef := &v1.ObjectReference{ + Kind: mapping.GroupVersionKind.Kind, + APIVersion: mapping.GroupVersionKind.GroupVersion().String(), + Name: name, + } + if mapping.Scope.Name() == meta.RESTScopeNameNamespace { + namespace := c.Namespace + if namespace == "" { + namespace, _, err = clientconfig.Namespace() + if err != nil { + return errors.Wrap(err, errKubeNamespace) + } + } + + logger.Debug("Requested resource is namespaced", "namespace", namespace) + rootRef.Namespace = namespace + } + + // If no name is provided, we should print a list of resources. + shouldPrintAsList := name == "" + + logger.Debug("Getting resource tree", "rootRef", rootRef.String()) + var resourceList *resource.ResourceList + if shouldPrintAsList { + // If no name is provided, we list all resources of the kind. + logger.Debug("No name provided, listing all resources of the kind") + resourceList = resource.ListResources(ctx, client, rootRef) + } else { + // If a name is provided, we get the specific resource. + logger.Debug("Name provided, getting specific resource", "name", name) + res := resource.GetResource(ctx, client, rootRef) + resourceList = &resource.ResourceList{ + Items: []*resource.Resource{res}, + Error: res.Error, + } + } + + // We should just surface any error getting the root resource immediately. + nameDisplay := name + if nameDisplay == "" { + nameDisplay = "" + } + if err := resourceList.Error; err != nil { + return errors.Wrapf(err, errFmtGetResource, mapping.GroupVersionKind.Kind, nameDisplay, rootRef.Namespace) + } + + for i := range resourceList.Items { + root := resourceList.Items[i] + itemKind, itemName, itemNamespace := root.Unstructured.GetKind(), root.Unstructured.GetName(), root.Unstructured.GetNamespace() + root, err = c.getResourceTree(ctx, root, mapping, client, logger) + if err != nil { + logger.Debug(errGetResource, "error", err) + return errors.Wrapf(err, errFmtGetResourceTree, itemKind, itemName, itemNamespace) + } + + logger.Debug("Got resource tree", "root", root) + + resourceList.Items[i] = root + } + + // Watch mode for a single resource + if c.Watch && !shouldPrintAsList && len(resourceList.Items) > 0 { + root := resourceList.Items[0] + return c.watchResourceTree(ctx, k, logger, client, root, mapping, p) + } + + if shouldPrintAsList { + // Print list of resources + err = p.PrintList(k.Stdout, resourceList) + if err != nil { + return errors.Wrap(err, errCliOutput) + } + // Warn if watch mode was requested with multiple resources + if c.Watch { + if _, err := k.Stdout.Write([]byte("error: you may only watch a single resource at a time\n")); err != nil { + return errors.Wrap(err, errCliOutput) + } + } + return nil + } + + // Print a single resource + err = p.Print(k.Stdout, resourceList.Items[0]) + if err != nil { + return errors.Wrap(err, errCliOutput) + } + + return nil +} + +func (c *Cmd) getResourceAndName() (string, string, error) { + // If no resource was provided, error out (should never happen as it's + // required by Kong) + if c.Resource == "" { + return "", "", errors.New(errInvalidResource) + } + + // Split the resource into its components + splittedResource := strings.Split(c.Resource, "/") + length := len(splittedResource) + + if length == 1 { + // Resource has only kind and the name is separately provided + return splittedResource[0], c.Name, nil + } + + if length == 2 { + // If a name is separately provided, error out + if c.Name != "" { + return "", "", errors.New(errNameDoubled) + } + + // Resource includes both kind and name + return splittedResource[0], splittedResource[1], nil + } + + // Handle the case when resource format is invalid + return "", "", errors.New(errInvalidResource) +} + +func (c *Cmd) getResourceTree(ctx context.Context, root *resource.Resource, mapping *meta.RESTMapping, client client.Client, logger logging.Logger) (*resource.Resource, error) { + var treeClient resource.TreeClient + var err error + switch { + case xpkg.IsPackageType(mapping.GroupVersionKind.GroupKind()): + logger.Debug("Requested resource is a Package") + treeClient, err = xpkg.NewClient(client, + xpkg.WithDependencyOutput(xpkg.DependencyOutput(c.ShowPackageDependencies)), + xpkg.WithPackageRuntimeConfigs(c.ShowPackageRuntimeConfigs), + xpkg.WithRevisionOutput(xpkg.RevisionOutput(c.ShowPackageRevisions))) + if err != nil { + return nil, errors.Wrap(err, errInitKubeClient) + } + default: + logger.Debug("Requested resource is not a package, assumed to be an XR, XRC or MR") + treeClient, err = xrm.NewClient(client, + xrm.WithConnectionSecrets(c.ShowConnectionSecrets), + xrm.WithConcurrency(c.Concurrency), + ) + if err != nil { + return nil, errors.Wrap(err, errInitKubeClient) + } + } + logger.Debug("Built client") + + return treeClient.GetResourceTree(ctx, root) +} diff --git a/cmd/crossplane/beta/trace/trace_test.go b/cmd/crossplane/beta/trace/trace_test.go new file mode 100644 index 0000000..4143a59 --- /dev/null +++ b/cmd/crossplane/beta/trace/trace_test.go @@ -0,0 +1,105 @@ +package trace + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" +) + +func TestCmd_getResourceAndName(t *testing.T) { + type args struct { + Resource string + Name string + } + + type want struct { + resource string + name string + err error + } + + tests := map[string]struct { + reason string + fields args + want want + }{ + "Splitted": { + reason: "Should return the resource and name if both are provided", + fields: args{ + Resource: "resource", + Name: "name", + }, + want: want{ + resource: "resource", + name: "name", + err: nil, + }, + }, + "Empty": { + // should never happen, resource is required by kong + reason: "Should return an error if no resource is provided", + fields: args{ + Resource: "", + Name: "", + }, + want: want{ + err: errors.New(errInvalidResource), + }, + }, + "Combined": { + reason: "Should return the resource and name if both are provided combined as resource", + fields: args{ + Resource: "resource/name", + Name: "", + }, + want: want{ + resource: "resource", + name: "name", + }, + }, + "MoreSlashes": { + reason: "Should return an error if the resource contains more than one slashes", + fields: args{ + Resource: "resource/name/other", + Name: "", + }, + want: want{ + err: errors.New(errInvalidResource), + }, + }, + "BothAndCombined": { + reason: "Should return an error if a name is provided both in the resource and separately", + fields: args{ + Resource: "resource/name", + Name: "name", + }, + want: want{ + err: errors.New(errNameDoubled), + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := &Cmd{ + Resource: tt.fields.Resource, + Name: tt.fields.Name, + } + + gotResource, gotName, err := c.getResourceAndName() + if diff := cmp.Diff(tt.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("Cmd.getResourceAndName() error = %v, wantErr %v", err, tt.want.err) + } + + if diff := cmp.Diff(tt.want.resource, gotResource); diff != "" { + t.Errorf("Cmd.getResourceAndName() resource = %v, want %v", gotResource, tt.want.resource) + } + + if diff := cmp.Diff(tt.want.name, gotName); diff != "" { + t.Errorf("Cmd.getResourceAndName() name = %v, want %v", gotName, tt.want.name) + } + }) + } +} diff --git a/cmd/crossplane/beta/trace/watch.go b/cmd/crossplane/beta/trace/watch.go new file mode 100644 index 0000000..6ec641c --- /dev/null +++ b/cmd/crossplane/beta/trace/watch.go @@ -0,0 +1,229 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package trace + +import ( + "bytes" + "context" + "fmt" + "time" + + "github.com/alecthomas/kong" + tea "github.com/charmbracelet/bubbletea" + v1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/fields" + "k8s.io/apimachinery/pkg/watch" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/cmd/crossplane/beta/trace/internal/printer" + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +// Bubble Tea messages for watch mode. +type treeUpdateMsg struct { + rendered string +} + +type treeErrMsg struct { + err error +} + +type treeQuitMsg struct{} + +// Bubble Tea model for watch mode. +type treeModel struct { + rendered string + err error +} + +func (m treeModel) Init() tea.Cmd { + return nil +} + +func (m treeModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case treeUpdateMsg: + m.rendered = msg.rendered + return m, nil + + case treeErrMsg: + m.err = msg.err + return m, tea.Quit + + case treeQuitMsg: + return m, tea.Quit + + case tea.KeyMsg: + // Allow user to quit with q, esc, or ctrl+c + switch msg.String() { + case "q", "esc", "ctrl+c": + return m, tea.Quit + } + } + + return m, nil +} + +func (m treeModel) View() string { + if m.err != nil { + return fmt.Sprintf("error: %v\n", m.err) + } + return m.rendered +} + +// renderTreeToString runs the printer into a string buffer. +func renderTreeToString(p printer.Printer, tree *resource.Resource) (string, error) { + var buf bytes.Buffer + if err := p.Print(&buf, tree); err != nil { + return "", err + } + return buf.String(), nil +} + +// watchResourceTree watches the resource tree until it's deleted. +func (c *Cmd) watchResourceTree(ctx context.Context, k *kong.Context, logger logging.Logger, kClient client.Client, root *resource.Resource, mapping *meta.RESTMapping, p printer.Printer) error { + // Create a watch for the specific resource + opts := &client.ListOptions{ + Namespace: root.Unstructured.GetNamespace(), + FieldSelector: fields.OneTermEqualSelector("metadata.name", root.Unstructured.GetName()), + } + + // Use a typed object for watching + obj := root.Unstructured.DeepCopy() + + // Start the Kubernetes watch + watchClient, ok := kClient.(client.WithWatch) + if !ok { + return errors.New("client does not support watch") + } + w, err := watchClient.Watch(ctx, obj, opts) + if err != nil { + return errors.Wrap(err, "cannot start watch") + } + defer w.Stop() + + // Create Bubble Tea program + prog := tea.NewProgram( + treeModel{}, + tea.WithOutput(k.Stdout), + ) + + // Start producer loop in background + go c.watchProducer(ctx, logger, kClient, root, mapping, p, prog, w) + + // Run Bubble Tea (blocks until quit) + _, err = prog.Run() + return err +} + +// watchProducer runs the watch loop and sends updates to Bubble Tea. +func (c *Cmd) watchProducer(ctx context.Context, logger logging.Logger, kClient client.Client, root *resource.Resource, mapping *meta.RESTMapping, p printer.Printer, prog *tea.Program, w watch.Interface) { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + // Cache last rendered string to avoid sending duplicates + var last string + + // Helper to fetch and render the resource tree, then send to Bubble Tea + renderAndSend := func() error { + current := resource.GetResource(ctx, kClient, &v1.ObjectReference{ + Kind: root.Unstructured.GetKind(), + APIVersion: root.Unstructured.GetAPIVersion(), + Name: root.Unstructured.GetName(), + Namespace: root.Unstructured.GetNamespace(), + }) + + if current.Error != nil { + if apierrors.IsNotFound(current.Error) { + logger.Debug("Resource deleted, stopping watch") + prog.Send(treeQuitMsg{}) + return nil + } + return current.Error + } + + tree, err := c.getResourceTree(ctx, current, mapping, kClient, logger) + if err != nil { + return err + } + + logger.Debug("Got resource tree", "root", tree) + + rendered, err := renderTreeToString(p, tree) + if err != nil { + return err + } + + // Only send if changed + if rendered != last { + last = rendered + prog.Send(treeUpdateMsg{rendered: rendered}) + } + + return nil + } + + // Initial render + if err := renderAndSend(); err != nil { + c.handleProducerError(prog, err) + return + } + + // Watch loop + for { + select { + case evt, ok := <-w.ResultChan(): + if !ok { + prog.Send(treeQuitMsg{}) + return + } + if evt.Type == watch.Deleted { + prog.Send(treeQuitMsg{}) + return + } + if err := renderAndSend(); err != nil { + c.handleProducerError(prog, err) + return + } + + case <-ticker.C: + // Periodically refresh to catch child resource changes + if err := renderAndSend(); err != nil { + c.handleProducerError(prog, err) + return + } + + case <-ctx.Done(): + prog.Send(treeQuitMsg{}) + return + } + } +} + +// handleProducerError handles errors from the watch producer. +func (c *Cmd) handleProducerError(prog *tea.Program, err error) { + if apierrors.IsNotFound(err) { + prog.Send(treeQuitMsg{}) + return + } + prog.Send(treeErrMsg{err: errors.Wrap(err, errGetResource)}) +} diff --git a/cmd/crossplane/beta/validate/cache.go b/cmd/crossplane/beta/validate/cache.go new file mode 100644 index 0000000..4572f92 --- /dev/null +++ b/cmd/crossplane/beta/validate/cache.go @@ -0,0 +1,231 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + iofs "io/fs" + "os" + "path" + "path/filepath" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/load" +) + +// Cache defines an interface for caching schemas. +type Cache interface { + Store(schemas [][]byte, path string) error + Flush() error + Init() error + Load(image string) ([]*unstructured.Unstructured, error) + Exists(image string) (string, error) +} + +// LocalCache implements the Cache interface. +type LocalCache struct { + fs afero.Fs + cacheDir string +} + +// Store stores the schemas in the directory. +func (c *LocalCache) Store(schemas [][]byte, path string) error { + path = c.getCachePath(path) + + if err := c.fs.MkdirAll(path, os.ModePerm); err != nil { + return errors.Wrapf(err, "cannot create directory %s", path) + } + + file, err := c.fs.Create(filepath.Join(path, packageFileName)) + if err != nil { + return errors.Wrapf(err, "cannot create file") + } + + for _, s := range schemas { + _, err := file.Write(s) + if err != nil { + return errors.Wrapf(err, "cannot write to file") + } + + _, err = file.WriteString("---\n") + if err != nil { + return errors.Wrapf(err, "cannot write to file") + } + } + + return nil +} + +// Init creates the cache directory if it doesn't exist. +func (c *LocalCache) Init() error { + if _, err := c.fs.Stat(c.cacheDir); os.IsNotExist(err) { + if err := c.fs.MkdirAll(c.cacheDir, os.ModePerm); err != nil { + return errors.Wrapf(err, "cannot create cache directory %s", c.cacheDir) + } + } else if err != nil { + return errors.Wrapf(err, "cannot stat cache directory %s", c.cacheDir) + } + + return nil +} + +// Flush removes the cache directory. +func (c *LocalCache) Flush() error { + return c.fs.RemoveAll(c.cacheDir) +} + +// Load loads schemas from the cache directory. +// image should be a validated image name with the format: /:. +// can be a constraint, in which case the latest version of the schema that satisfies this constraint +// is loaded from the cache. +func (c *LocalCache) Load(image string) ([]*unstructured.Unstructured, error) { + cacheImagePath := c.getCachePath(image) + imageBase, imageTag := separateImageTag(image) + + if isRangedConstraint(imageTag) { + var err error + cacheImagePath, err = c.findLatestCachedVersionForConstraint(image) + if err != nil { + return nil, errors.Wrapf(err, + "failed to scan cache for entries that matches image %s with the constraint %s", + imageBase, imageTag) + } + + if cacheImagePath == "" { + return []*unstructured.Unstructured{}, nil + } + } + + loader, err := load.NewLoader(cacheImagePath) + if err != nil { + return nil, errors.Wrapf(err, "cannot create loader from %s", cacheImagePath) + } + + schemas, err := loader.Load() + if err != nil { + return nil, errors.Wrapf(err, "cannot load schemas from %s", cacheImagePath) + } + + return schemas, nil +} + +// Exists checks if the cache contains the image and returns the path if it doesn't exist. +// If the image contains a semantic version constraint, the returned cache-path will include it on cache miss, +// as it can not be resolved by the cache. +func (c *LocalCache) Exists(image string) (string, error) { + path := c.getCachePath(image) + + _, imageTag := separateImageTag(image) + + // if the image-tag is a ranged constraint we need to try to find the latest cached version that satisfies that constraint + if isRangedConstraint(imageTag) { + v, err := c.findLatestCachedVersionForConstraint(image) + if err != nil { + return "", errors.Wrapf(err, "failed to scan cache for constraint") + } + + if v == "" { + // valid version for constraint not found + return path, nil + } + + // valid version for constraint was found + return "", nil + } + + _, err := os.Stat(path) + if err != nil && os.IsNotExist(err) { + return path, nil + } else if err != nil { + return "", errors.Wrapf(err, "cannot stat file %s", path) + } + + return "", nil +} + +// getCachePath transforms an image name to a validate folder path that store schemas. +func (c *LocalCache) getCachePath(image string) string { + cacheImagePath := strings.ReplaceAll(image, ":", "@") + return filepath.Join(c.cacheDir, cacheImagePath) +} + +// isRangedConstraint checks if a string is a semantic version constraint, but not an exact version. +func isRangedConstraint(tag string) bool { + if _, err := semver.NewVersion(tag); err == nil { + return false + } + _, err := semver.NewConstraint(tag) + return err == nil +} + +// findLatestCachedVersionForConstraint returns the cache-path for the latest tag that matches the image version constraint. +// On cache miss, an empty string is returned. +// The image must be a valid image name with the format: /:. +func (c *LocalCache) findLatestCachedVersionForConstraint(image string) (string, error) { + imageBase, imageTag := separateImageTag(image) + + constraint, err := semver.NewConstraint(imageTag) + if err != nil { + return "", errors.Wrapf(err, "%s is not a valid constraint", imageTag) + } + + cachePath := c.getCachePath(image) + + // search cache-directory for tags + cacheDir := filepath.Dir(cachePath) + + dirEntries, err := afero.ReadDir(c.fs, cacheDir) + if err != nil { + if errors.Is(err, iofs.ErrNotExist) { + // the directory wont exist if it hasnt been cached yet, in which case its a normal cache miss + return "", nil + } + + return "", err + } + + tags := []string{} + for _, entry := range dirEntries { + if entry.IsDir() && strings.HasPrefix(entry.Name(), path.Base(imageBase)) { + i := strings.Index(entry.Name(), "@") + if i == -1 { + return "", errors.Errorf("the cache entry '%s' does not contain a tag", entry.Name()) + } + + tags = append(tags, entry.Name()[i+1:]) + } + } + + vs := convertToSemver(tags) + + sort.Sort(sort.Reverse(semver.Collection(vs))) + + for _, v := range vs { + if constraint.Check(v) { + // return the cache-path with the latest valid semantic version + return strings.ReplaceAll(cachePath, imageTag, v.Original()), nil + } + } + + return "", nil +} diff --git a/cmd/crossplane/beta/validate/cache_test.go b/cmd/crossplane/beta/validate/cache_test.go new file mode 100644 index 0000000..bfc791f --- /dev/null +++ b/cmd/crossplane/beta/validate/cache_test.go @@ -0,0 +1,343 @@ +package validate + +import ( + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var ( + fs = afero.NewMemMapFs() + osFs = afero.NewOsFs() +) + +func TestLocalCacheExists(t *testing.T) { + type args struct { + image string + } + + type want struct { + path string + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Exists": { + reason: "Exists should return an empty path with no error if it exists", + args: args{ + image: "xpkg.crossplane.io/crossplane-contrib/provider-nop:v0.2.0", + }, + want: want{ + path: "", + err: nil, + }, + }, + "DoesNotExist": { + reason: "Exists should return the path with no error", + args: args{ + image: "xpkg.crossplane.io/crossplane-contrib/provider-nop:v0.2.1", + }, + want: want{ + path: "testdata/cache/xpkg.crossplane.io/crossplane-contrib/provider-nop@v0.2.1", + err: nil, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &LocalCache{ + fs: fs, + cacheDir: "testdata/cache", + } + + got, err := c.Exists(tc.args.image) + if diff := cmp.Diff(tc.want.path, got); diff != "" { + t.Errorf("%s\nExists(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nExists(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestLocalCacheFlush(t *testing.T) { + cases := map[string]struct { + reason string + wantErr error + }{ + "Flush": { + reason: "Flush should flush the cache", + wantErr: nil, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &LocalCache{ + fs: fs, + cacheDir: "testdata/cache", + } + + err := c.Flush() + if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nFlush(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestLocalCacheInit(t *testing.T) { + type args struct { + cacheDir string + fs afero.Fs + } + + cases := map[string]struct { + reason string + args args + wantErr error + }{ + "Success": { + reason: "Init should initialize the cache", + args: args{ + cacheDir: "testdata/cache", + fs: fs, + }, + wantErr: nil, + }, + "Error": { + reason: "Init should return an error if it cannot create the cache directory", + args: args{ + cacheDir: "/", + fs: osFs, + }, + wantErr: nil, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &LocalCache{ + fs: tc.args.fs, + cacheDir: tc.args.cacheDir, + } + + err := c.Init() + if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("Init() error = %v, wantErr %v", err, tc.wantErr) + } + + if tc.wantErr == nil { + info, err := fs.Stat(c.cacheDir) + if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("Init() could not stat cache directory: %v", err) + } + + if !info.IsDir() { + t.Errorf("Init() cache directory is not a directory") + } + } + }) + } +} + +func TestLocalCacheLoad(t *testing.T) { + type args struct { + cacheDir string + image string + } + + type want struct { + schemas []*unstructured.Unstructured + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Load": { + reason: "Load should load the schemas from the cache", + args: args{ + cacheDir: "./testdata/crds", + image: "xpkg.crossplane.io/provider-dummy:v1.0.0", + }, + want: want{ + schemas: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1beta1", + "kind": "CustomResourceDefinition", + "metadata": map[string]any{ + "name": "test", + }, + }, + }, + }, + err: nil, + }, + }, + "LoadNonExisting": { + reason: "Load should return an error if the package does not exist", + args: args{cacheDir: "./testdata/non-existing"}, + want: want{ + schemas: nil, + err: cmpopts.AnyError, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &LocalCache{ + fs: fs, + cacheDir: tc.args.cacheDir, + } + + got, err := c.Load(tc.args.image) + if diff := cmp.Diff(tc.want.schemas, got); diff != "" { + t.Errorf("%s\nLoad(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nLoad(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestLocalCacheStore(t *testing.T) { + type args struct { + schemas [][]byte + path string + fs afero.Fs + } + + type want struct { + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Store": { + reason: "Store should store the schemas in the cache", + args: args{ + schemas: [][]byte{ + []byte("apiVersion: apiextensions.k8s.io/v1beta1\nkind: CustomResourceDefinition\nmetadata:\n name: test\n"), + }, + path: "testdata/cache/xpkg.crossplane.io/crossplane-contrib/dummy@v0.2.0", + fs: fs, + }, + want: want{ + err: nil, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &LocalCache{ + fs: tc.args.fs, + } + + err := c.Store(tc.args.schemas, tc.args.path) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nStore(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if tc.want.err == nil { + fPath := filepath.Join(tc.args.path, packageFileName) + + info, err := fs.Stat(fPath) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nStore(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if info.IsDir() { + t.Errorf("%s\nStore(...): -want file, +got directory", tc.reason) + } + } + }) + } +} + +func TestIsRangedConstraint(t *testing.T) { + type args struct { + in string + } + + type want struct { + result bool + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "ValidConstraint": { + reason: "A valid constraint should return true", + args: args{ + in: ">v2.0.0", + }, + want: want{ + result: true, + }, + }, + "ValidRangedConstraint": { + reason: "A valid ranged constraint should return true", + args: args{ + in: ">v2.0.0, /:. +// where can be a version constraint. +// if is already an exact version, the same image string is returned back. +func findImageTagForVersionConstraint(image string) (string, error) { + imageBase, imageTag := separateImageTag(image) + + // Check if the tag is a constraint or already a valid semantic version + isConstraint := true + isExactVersion := true + + c, err := semver.NewConstraint(imageTag) + if err != nil { + isConstraint = false + } + + _, err = semver.NewVersion(imageTag) + if err != nil { + isExactVersion = false + } + + // Return original image if no constraint was detected or the tag is already an exact version + if !isConstraint || isExactVersion { + return image, nil + } + + // Fetch all image tags + tags, err := crane.ListTags(imageBase) + if err != nil { + return "", errors.Wrapf(err, "cannot fetch tags for the image %s", imageBase) + } + + vs := convertToSemver(tags) + + // Sort all versions and find the last version complient with the constraint + sort.Sort(sort.Reverse(semver.Collection(vs))) + + var addVer string + + for _, v := range vs { + if c.Check(v) { + addVer = v.Original() + + break + } + } + + if addVer == "" { + return "", errors.Errorf("cannot find any tag complient with the constraint %s", imageTag) + } + + // Compose new complete image string if any complient version was found + image = fmt.Sprintf("%s:%s", imageBase, addVer) + + return image, nil +} + +func extractPackageContent(layer conregv1.Layer) ([][]byte, []byte, error) { + rc, err := layer.Uncompressed() + if err != nil { + return nil, nil, errors.Wrapf(err, "cannot get uncompressed layer") + } + + objs, err := load.YamlStream(rc) + if err != nil { + return nil, nil, errors.Wrapf(err, "cannot read from layer") + } + + // we need the meta object for identifying the dependencies + var metaObj []byte + if len(objs) > 0 { + metaObj = objs[0] + } + + // the first line of the layer is not part of the meta object, so we need to remove it + metaStr := string(metaObj) + metaLines := strings.Split(metaStr, "\n") + metaStr = strings.Join(metaLines[1:], "\n") + + // the last obj is not yaml, so we need to remove it + return objs[1 : len(objs)-1], []byte(metaStr), nil +} + +func extractPackageCRDs(layers []conregv1.Layer) ([][]byte, error) { + // Create a temporary directory to extract the files + tmpDir, err := os.MkdirTemp("", "image-extract") + if err != nil { + return nil, errors.Wrapf(err, "failed to create temporary directory") + } + + defer func() { + if err := os.RemoveAll(tmpDir); err != nil { + log.Printf("Failed to remove temporary directory: %v", err) + } + }() + + for _, layer := range layers { + if err := extractLayer(layer, tmpDir); err != nil { + return nil, errors.Wrapf(err, "failed to extract layer") + } + } + + // Search for .yaml files in the "crds" directory + var yamlFiles [][]byte + + err = filepath.Walk(tmpDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Check if the file is in the "crds" directory and has a .yaml extension + if strings.Contains(path, "/crds/") && strings.HasSuffix(info.Name(), ".yaml") { + content, err := os.ReadFile(filepath.Clean(path)) + if err != nil { + return errors.Wrapf(err, "failed to read file: %s", path) + } + + yamlFiles = append(yamlFiles, content) + } + + return nil + }) + if err != nil { + return nil, errors.Wrapf(err, "failed to walk through extracted files") + } + + return yamlFiles, nil +} + +// extractLayer extracts the contents of a layer to the specified directory. +func extractLayer(layer conregv1.Layer, destDir string) error { //nolint:gocognit // no extra func + r, err := layer.Uncompressed() + if err != nil { + return err + } + + defer func() { + if err := r.Close(); err != nil { + log.Printf("Failed to close reader: %v", err) + } + }() + + tr := tar.NewReader(r) + + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + break // End of tar archive + } + + if err != nil { + return err + } + + // Resolve the target path + target := filepath.Join(destDir, filepath.Clean(hdr.Name)) + + targetPath, err := filepath.Abs(target) + if err != nil { + return errors.Wrap(err, "failed to get absolute path") + } + + // Skip entries that are the same as the destination directory or just "./" + if targetPath == filepath.Clean(destDir) || hdr.Name == "./" { + continue + } + + // Ensure the target path is within the destination directory + if !strings.HasPrefix(targetPath, filepath.Clean(destDir)+string(os.PathSeparator)) { + return errors.Errorf("invalid file path: %s", targetPath) + } + + // Create the file or directory + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(targetPath, 0o750); err != nil { + return errors.Wrapf(err, "cannot create directory: %s", targetPath) + } + case tar.TypeReg: + dir := filepath.Dir(targetPath) + if err := os.MkdirAll(dir, 0o750); err != nil { + return errors.Wrapf(err, "cannot create directory: %s", dir) + } + + file, err := os.Create(filepath.Clean(targetPath)) + if err != nil { + return errors.Wrapf(err, "cannot create file: %s", targetPath) + } + + defer func() { + if err := file.Close(); err != nil { + log.Printf("Failed to close file: %v", err) + } + }() + + // Limit the decompression size to avoid DoS attacks + limitedReader := io.LimitReader(tr, maxDecompressedSize) + if _, err := io.Copy(file, limitedReader); err != nil { + return errors.Wrapf(err, "cannot decompress file: %s", targetPath) + } + } + } + + return nil +} + +// prepareImageReference prepares the image reference by stripping the digest or resolving the tag if necessary. +func prepareImageReference(image string) (string, error) { + if strings.Contains(image, "@") { + return image, nil + } + + if strings.Contains(image, ":") { + return findImageTagForVersionConstraint(image) + } + + return image, nil +} diff --git a/cmd/crossplane/beta/validate/image_test.go b/cmd/crossplane/beta/validate/image_test.go new file mode 100644 index 0000000..c91f2c4 --- /dev/null +++ b/cmd/crossplane/beta/validate/image_test.go @@ -0,0 +1,223 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" +) + +func TestFindImageTagForVersionConstraint(t *testing.T) { + repoName := "ubuntu" + responseTags := []byte(`{"tags":["1.2.3","4.5.6"]}`) + cases := map[string]struct { + responseBody []byte + host string + constraint string + expectedImage string + expectError bool + }{ + "NoConstraint": { + responseBody: responseTags, + constraint: "1.2.3", + expectedImage: "ubuntu:1.2.3", + }, + "Constraint": { + responseBody: responseTags, + constraint: ">=1.2.3", + expectedImage: "ubuntu:4.5.6", + }, + "ConstraintV": { + responseBody: responseTags, + constraint: ">=v1.2.3", + expectedImage: "ubuntu:4.5.6", + }, + "ConstraintPreRelease": { + responseBody: responseTags, + constraint: ">v4.5.6-rc.0.100.g658deda0.dirty", + expectedImage: "ubuntu:4.5.6", + }, + "CannotFetchTags": { + responseBody: responseTags, + host: "wrong.host", + constraint: ">=4.5.6", + expectError: true, + }, + "NoMatchingTag": { + responseBody: responseTags, + constraint: ">4.5.6", + expectError: true, + }, + "RangedConstraint": { + responseBody: responseTags, + constraint: ">=v2.0.0 =v2.0.0, 0 { + if _, err := regv1.NewHash(dep.Version); err == nil { + // digest + image = fmt.Sprintf(refFmt, image, dep.Version) + } else { + // tag + image = fmt.Sprintf(imageFmt, image, dep.Version) + } + + m.deps[image] = true + + if _, ok := m.confs[image]; !ok && dep.Configuration != nil { + deepConfs[image] = nil + m.confs[image] = nil + } + } + } + } + + return m.addDependencies(deepConfs) +} + +func (m *Manager) cacheDependencies() error { + if err := m.cache.Init(); err != nil { + return errors.Wrapf(err, "cannot initialize cache directory") + } + + for image := range m.deps { + path, err := m.cache.Exists(image) // returns the path if the image is not cached + if err != nil { + return errors.Wrapf(err, "cannot check if cache exists for %s", image) + } + + // cache hit, skip unless update-cache option is enabled + if path == "" && !m.updateCache { + continue + } + + msg := "schemas does not exist, downloading: " + if path == "" { + msg = "updating cached schemas: " + } + if _, err := fmt.Fprintln(m.writer, msg, image); err != nil { + return errors.Wrapf(err, errWriteOutput) + } + + var schemas [][]byte + // handling for packages + resolvedImage, layer, err := m.fetcher.FetchBaseLayer(image) + switch { + case IsErrBaseLayerNotFound(err): + // We fall back to fetching the image if the base layer is not found + var layers []regv1.Layer + var err error + resolvedImage, layers, err = m.fetcher.FetchImage(image) + if err != nil { + return errors.Wrapf(err, "cannot extract crds") + } + + schemas, err = extractPackageCRDs(layers) + if err != nil { + return errors.Wrapf(err, "cannot find crds") + } + case err != nil: + return errors.Wrapf(err, "cannot download package %s", image) + default: + schemas, _, err = extractPackageContent(*layer) + if err != nil { + return errors.Wrapf(err, "cannot extract package file and meta") + } + } + + if err := m.cache.Store(schemas, resolvedImage); err != nil { + return errors.Wrapf(err, "cannot store base layer") + } + } + + return nil +} + +func (m *Manager) loadDependencies() ([]*unstructured.Unstructured, error) { + schemas := make([]*unstructured.Unstructured, 0) + + for dep := range m.deps { + cachedSchema, err := m.cache.Load(dep) + if err != nil { + return nil, errors.Wrapf(err, "cannot load cache for %s", dep) + } + + schemas = append(schemas, cachedSchema...) + } + + return schemas, nil +} diff --git a/cmd/crossplane/beta/validate/manager_test.go b/cmd/crossplane/beta/validate/manager_test.go new file mode 100644 index 0000000..f69187b --- /dev/null +++ b/cmd/crossplane/beta/validate/manager_test.go @@ -0,0 +1,517 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "bytes" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + conregv1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/static" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/test" +) + +var ( + // config-pkg:v1.3.0. + configPkg = []byte(`apiVersion: meta.pkg.crossplane.io/v1alpha1 +kind: Configuration +metadata: + name: config-pkg +spec: + dependsOn: + - provider: provider-dep-1 + version: "v1.3.0" +--- + +`) + + // provider-dep-1:v1.3.0. + providerYaml = []byte(`apiVersion: meta.pkg.crossplane.io/v1 +kind: Provider +metadata: + name: provider-dep-1 +--- + +`) + + // provider-test:v1.3.0. + provider2Yaml = []byte(`apiVersion: meta.pkg.crossplane.io/v1 +kind: Provider +metadata: + name: provider-test +--- + +`) + + // function-dep-1:v1.3.0. + funcYaml = []byte(`apiVersion: meta.pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-dep-1 +--- + +`) + + // function-test:v1.3.0. + func2Yaml = []byte(`apiVersion: meta.pkg.crossplane.io/v1beta1 +kind: Function +metadata: + name: function-test +--- + +`) + + // config-dep-1:v1.3.0. + configDep1Yaml = []byte(`apiVersion: meta.pkg.crossplane.io/v1alpha1 +kind: Configuration +metadata: + name: config-dep-1 +spec: + dependsOn: + - configuration: config-dep-2 + version: "v1.3.0" +--- + +`) + + // config-dep-2:v1.3.0. + configDep2Yaml = []byte(`apiVersion: meta.pkg.crossplane.io/v1alpha1 +kind: Configuration +metadata: + name: config-dep-2 +spec: + dependsOn: + - provider: provider-dep-1 + version: "v1.3.0" + - function: function-dep-1 + version: "v1.3.0" +--- + +`) +) + +func TestConfigurationTypeSupport(t *testing.T) { + confpkg := static.NewLayer(configPkg, types.OCILayer) + pd := static.NewLayer(providerYaml, types.OCILayer) + p2 := static.NewLayer(provider2Yaml, types.OCILayer) + fd := static.NewLayer(funcYaml, types.OCILayer) + f2 := static.NewLayer(func2Yaml, types.OCILayer) + + fetchMockFunc := func(image string) (string, *conregv1.Layer, error) { + switch image { + case "config-pkg:v1.3.0": + return "config-pkg:v1.3.0", &confpkg, nil + case "provider-dep-1:v1.3.0": + return "provider-dep-1-:v1.3.0", &pd, nil + case "provider-test:v1.3.0": + return "provider-test:v1.3.0", &p2, nil + case "function-dep-1:v1.3.0": + return "function-dep-1:v1.3.0", &fd, nil + case "function-test:v1.3.0": + return "function-test:v1.3.0", &f2, nil + default: + return "", nil, fmt.Errorf("unknown image: %s", image) + } + } + + type args struct { + extensions []*unstructured.Unstructured + fetchMock func(image string) (string, *conregv1.Layer, error) + } + + type want struct { + err error + confs int + deps int + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "SuccessfulConfigPkg": { + // config-pkg + // └─►provider-dep-1 + reason: "All dependencies should be successfully added from Configuration.pkg", + args: args{ + extensions: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "pkg.crossplane.io/v1alpha1", + "kind": "Configuration", + "metadata": map[string]any{ + "name": "config-pkg", + }, + "spec": map[string]any{ + "package": "config-pkg:v1.3.0", + }, + }, + }, + }, + fetchMock: fetchMockFunc, + }, + want: want{ + err: nil, + confs: 1, // Configuration.pkg from remote + deps: 2, // 1 provider, 1 Configuration.pkg dependency + }, + }, + "SuccessfulConfigMeta": { + // config-meta + // └─►function-dep-1 + reason: "All dependencies should be successfully added from Configuration.meta", + args: args{ + extensions: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "meta.pkg.crossplane.io/v1alpha1", + "kind": "Configuration", + "metadata": map[string]any{ + "name": "config-meta", + }, + "spec": map[string]any{ + "dependsOn": []map[string]any{ + { + "function": "function-dep-1", + "version": "v1.3.0", + }, + }, + }, + }, + }, + }, + fetchMock: fetchMockFunc, + }, + want: want{ + err: nil, + confs: 1, // Configuration.meta + deps: 1, // Not adding Configuration.meta itself to not send it to cacheDependencies() for download + }, + }, + "SuccessfulConfigMetaAndPkg": { + // config-meta + // └─►function-dep-1 + // config-pkg + // └─►provider-dep-1 + reason: "All dependencies should be successfully added from both Configuration.meta and Configuration.pkg", + args: args{ + extensions: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "meta.pkg.crossplane.io/v1alpha1", + "kind": "Configuration", + "metadata": map[string]any{ + "name": "config-meta", + }, + "spec": map[string]any{ + "dependsOn": []map[string]any{ + { + "function": "function-dep-1", + "version": "v1.3.0", + }, + { + "function": "function-test", + "version": "v1.3.0", + }, + { + "function": "provider-test", + "version": "v1.3.0", + }, + }, + }, + }, + }, + { + Object: map[string]any{ + "apiVersion": "pkg.crossplane.io/v1alpha1", + "kind": "Configuration", + "metadata": map[string]any{ + "name": "config-pkg", + }, + "spec": map[string]any{ + "package": "config-pkg:v1.3.0", + }, + }, + }, + }, + fetchMock: fetchMockFunc, + }, + want: want{ + err: nil, + confs: 2, // Configuration.meta and Configuration.pkg + deps: 5, // 1 Configuration.pkg, 2 provider, 2 function + }, + }, + "FunctionPkg": { + // function-test + reason: "Function pkg added", + args: args{ + extensions: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "pkg.crossplane.io/v1", + "kind": "Function", + "metadata": map[string]any{ + "name": "function-test", + }, + "spec": map[string]any{ + "package": "function-test:v1.3.0", + }, + }, + }, + }, + fetchMock: fetchMockFunc, + }, + want: want{ + err: nil, + confs: 0, + deps: 1, // Function.pkg from remote + }, + }, + "MultipleFunctionPkg": { + // function-test + reason: "Function pkg added", + args: args{ + extensions: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "pkg.crossplane.io/v1", + "kind": "Function", + "metadata": map[string]any{ + "name": "function-test", + }, + "spec": map[string]any{ + "package": "function-test:v1.3.0", + }, + }, + }, + { + Object: map[string]any{ + "apiVersion": "pkg.crossplane.io/v1", + "kind": "Function", + "metadata": map[string]any{ + "name": "function-dep-1", + }, + "spec": map[string]any{ + "package": "function-dep-1:v1.3.0", + }, + }, + }, + }, + fetchMock: fetchMockFunc, + }, + want: want{ + err: nil, + confs: 0, + deps: 2, // 2 Function.pkg from remote + }, + }, + } + for name, tc := range cases { + fs := afero.NewMemMapFs() + w := &bytes.Buffer{} + + m := NewManager("", fs, w) + + t.Run(name, func(t *testing.T) { + m.fetcher = &MockFetcher{tc.args.fetchMock, nil} + + err := m.PrepExtensions(tc.args.extensions) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nPrepExtensions(...): -want error, +got error:\n%s", tc.reason, diff) + } + + err = m.addDependencies(m.confs) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\naddDependencies(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.confs, len(m.confs)); diff != "" { + t.Errorf("\n%s\naddDependencies(...): -want confs, +got confs:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.deps, len(m.deps)); diff != "" { + t.Errorf("\n%s\naddDependencies(...): -want deps, +got deps:\n%s", tc.reason, diff) + } + }) + } +} + +func TestAddDependencies(t *testing.T) { + cd1 := static.NewLayer(configDep1Yaml, types.OCILayer) + cd2 := static.NewLayer(configDep2Yaml, types.OCILayer) + pd1 := static.NewLayer(providerYaml, types.OCILayer) + fd1 := static.NewLayer(funcYaml, types.OCILayer) + crossplaneLayer := static.NewLayer([]byte("crossplane content"), types.OCILayer) + + fetchMockFunc := func(image string) (string, *conregv1.Layer, error) { + switch image { + case "config-dep-1:v1.3.0": + return image, &cd1, nil + case "config-dep-2:v1.3.0": + return image, &cd2, nil + case "provider-dep-1:v1.3.0": + return image, &pd1, nil + case "function-dep-1:v1.3.0": + return image, &fd1, nil + case "xpkg.crossplane.io/crossplane/crossplane:v1.16.0": + return "xpkg.crossplane.io/crossplane/crossplane:v1.16.0", &crossplaneLayer, nil + default: + return "", nil, fmt.Errorf("unknown image: %s", image) + } + } + + fetchImageMockFunc := func(image string) (string, []conregv1.Layer, error) { + switch image { + case "xpkg.crossplane.io/crossplane/crossplane:v1.16.0": + return "xpkg.crossplane.io/crossplane/crossplane:v1.16.0", []conregv1.Layer{crossplaneLayer}, nil + default: + return "", nil, fmt.Errorf("unknown image: %s", image) + } + } + + type args struct { + extensions []*unstructured.Unstructured + fetcher ImageFetcher + crossplaneImage string + } + + type want struct { + confs int + deps int + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "SuccessfulDependenciesAdditionWithCrossplaneImage": { + // config-dep-1 + // └─►config-dep-2 + // ├─►provider-dep-1 + // └─►function-dep-1 + // └─►crossplaneImage (xpkg.crossplane.io/crossplane/crossplane:v1.16.0) + reason: "All dependencies including the crossplane image should be successfully fetched and added", + args: args{ + fetcher: &MockFetcher{ + fetchBaseLayer: fetchMockFunc, + fetchImage: fetchImageMockFunc, + }, + extensions: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "pkg.crossplane.io/v1alpha1", + "kind": "Configuration", + "metadata": map[string]any{ + "name": "config-dep-1", + }, + "spec": map[string]any{ + "package": "config-dep-1:v1.3.0", + }, + }, + }, + }, + crossplaneImage: "xpkg.crossplane.io/crossplane/crossplane:v1.16.0", + }, + want: want{ + confs: 2, // 1 Base configuration (config-dep-1), 1 child configuration (config-dep-2) + deps: 5, // 2 configurations (config-dep-1, config-dep-2), 1 provider (provider-dep-1), 1 function (function-dep-1), 1 crossplaneImage + err: nil, + }, + }, + "SuccessfulDependenciesAdditionWithoutCrossplaneImage": { + // config-dep-1 + // └─►config-dep-2 + // ├─►provider-dep-1 + // └─►function-dep-1 + reason: "All dependencies should be successfully fetched and added without specifying a crossplane image", + args: args{ + fetcher: &MockFetcher{fetchMockFunc, nil}, + extensions: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "pkg.crossplane.io/v1alpha1", + "kind": "Configuration", + "metadata": map[string]any{ + "name": "config-dep-1", + }, + "spec": map[string]any{ + "package": "config-dep-1:v1.3.0", + }, + }, + }, + }, + crossplaneImage: "", + }, + want: want{ + confs: 2, // 1 Base configuration (config-dep-1), 1 child configuration (config-dep-2) + deps: 4, // 2 configurations (config-dep-1, config-dep-2), 1 provider (provider-dep-1), 1 function (function-dep-1) + err: nil, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + w := &bytes.Buffer{} + + m := NewManager("", fs, w, WithCrossplaneImage(tc.args.crossplaneImage)) + _ = m.PrepExtensions(tc.args.extensions) + + m.fetcher = tc.args.fetcher + + err := m.addDependencies(m.confs) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\naddDependencies(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.confs, len(m.confs)); diff != "" { + t.Errorf("\n%s\naddDependencies(...): -want confs, +got confs:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.deps, len(m.deps)); diff != "" { + t.Errorf("\n%s\naddDependencies(...): -want deps, +got deps:\n%s", tc.reason, diff) + } + }) + } +} + +type MockFetcher struct { + fetchBaseLayer func(image string) (string, *conregv1.Layer, error) + fetchImage func(image string) (string, []conregv1.Layer, error) +} + +func (m *MockFetcher) FetchBaseLayer(image string) (string, *conregv1.Layer, error) { + return m.fetchBaseLayer(image) +} + +func (m *MockFetcher) FetchImage(image string) (string, []conregv1.Layer, error) { + if m.fetchImage != nil { + return m.fetchImage(image) + } + + return "", nil, nil // or a sensible default/mock behavior +} diff --git a/cmd/crossplane/beta/validate/testdata/cache/xpkg.crossplane.io/crossplane-contrib/provider-nop@v0.2.0/package.yaml b/cmd/crossplane/beta/validate/testdata/cache/xpkg.crossplane.io/crossplane-contrib/provider-nop@v0.2.0/package.yaml new file mode 100644 index 0000000..ca688bf --- /dev/null +++ b/cmd/crossplane/beta/validate/testdata/cache/xpkg.crossplane.io/crossplane-contrib/provider-nop@v0.2.0/package.yaml @@ -0,0 +1,349 @@ +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.11.1 + creationTimestamp: null + name: nopresources.nop.crossplane.io +spec: + group: nop.crossplane.io + names: + categories: + - crossplane + - managed + - nop + kind: NopResource + listKind: NopResourceList + plural: nopresources + singular: nopresource + scope: Cluster + versions: + - additionalPrinterColumns: + - jsonPath: .status.conditions[?(@.type=='Ready')].status + name: READY + type: string + - jsonPath: .status.conditions[?(@.type=='Synced')].status + name: SYNCED + type: string + - jsonPath: .metadata.creationTimestamp + name: AGE + type: date + name: v1alpha1 + schema: + openAPIV3Schema: + description: A NopResource is an example API type. + properties: + apiVersion: + description: 'APIVersion defines the versioned schema of this representation + of an object. Servers should convert recognized schemas to the latest + internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' + type: string + kind: + description: 'Kind is a string value representing the REST resource this + object represents. Servers may infer this from the endpoint the client + submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' + type: string + metadata: + type: object + spec: + description: A NopResourceSpec defines the desired state of a NopResource. + properties: + deletionPolicy: + default: Delete + description: DeletionPolicy specifies what will happen to the underlying + external when this managed resource is deleted - either "Delete" + or "Orphan" the external resource. + enum: + - Orphan + - Delete + type: string + forProvider: + description: NopResourceParameters are the configurable fields of + a NopResource. + properties: + conditionAfter: + description: 'ConditionAfter can be used to set status conditions + after a specified time. By default a NopResource will only have + a status condition of Type: Synced. It will never have a status + condition of Type: Ready unless one is configured here.' + items: + description: ResourceConditionAfter specifies a condition of + a NopResource that should be set after a certain duration. + properties: + conditionReason: + description: ConditionReason to set - e.g. Available. + type: string + conditionStatus: + description: ConditionStatus to set - e.g. True. + type: string + conditionType: + description: ConditionType to set - e.g. Ready. + type: string + time: + description: Time is the duration after which the condition + should be set. + type: string + required: + - conditionStatus + - conditionType + - time + type: object + type: array + connectionDetails: + description: ConnectionDetails that this NopResource should emit + on each reconcile. + items: + description: ResourceConnectionDetail specifies a connection + detail a NopResource should emit. + properties: + name: + description: Name of the connection detail. + type: string + value: + description: Value of the connection detail. + type: string + required: + - name + - value + type: object + type: array + fields: + description: Fields is an arbitrary object you can patch to and + from. It has no schema, is not validated, and is not used by + the NopResource controller. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + providerConfigRef: + default: + name: default + description: ProviderConfigReference specifies how the provider that + will be used to create, observe, update, and delete this managed + resource should be configured. + properties: + name: + description: Name of the referenced object. + type: string + policy: + description: Policies for referencing. + properties: + resolution: + default: Required + description: Resolution specifies whether resolution of this + reference is required. The default is 'Required', which + means the reconcile will fail if the reference cannot be + resolved. 'Optional' means this reference will be a no-op + if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: Resolve specifies when this reference should + be resolved. The default is 'IfNotPresent', which will attempt + to resolve the reference only when the corresponding field + is not present. Use 'Always' to resolve the reference on + every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + required: + - name + type: object + providerRef: + description: 'ProviderReference specifies the provider that will be + used to create, observe, update, and delete this managed resource. + Deprecated: Please use ProviderConfigReference, i.e. `providerConfigRef`' + properties: + name: + description: Name of the referenced object. + type: string + policy: + description: Policies for referencing. + properties: + resolution: + default: Required + description: Resolution specifies whether resolution of this + reference is required. The default is 'Required', which + means the reconcile will fail if the reference cannot be + resolved. 'Optional' means this reference will be a no-op + if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: Resolve specifies when this reference should + be resolved. The default is 'IfNotPresent', which will attempt + to resolve the reference only when the corresponding field + is not present. Use 'Always' to resolve the reference on + every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + required: + - name + type: object + publishConnectionDetailsTo: + description: PublishConnectionDetailsTo specifies the connection secret + config which contains a name, metadata and a reference to secret + store config to which any connection details for this managed resource + should be written. Connection details frequently include the endpoint, + username, and password required to connect to the managed resource. + properties: + configRef: + default: + name: default + description: SecretStoreConfigRef specifies which secret store + config should be used for this ConnectionSecret. + properties: + name: + description: Name of the referenced object. + type: string + policy: + description: Policies for referencing. + properties: + resolution: + default: Required + description: Resolution specifies whether resolution of + this reference is required. The default is 'Required', + which means the reconcile will fail if the reference + cannot be resolved. 'Optional' means this reference + will be a no-op if it cannot be resolved. + enum: + - Required + - Optional + type: string + resolve: + description: Resolve specifies when this reference should + be resolved. The default is 'IfNotPresent', which will + attempt to resolve the reference only when the corresponding + field is not present. Use 'Always' to resolve the reference + on every reconcile. + enum: + - Always + - IfNotPresent + type: string + type: object + required: + - name + type: object + metadata: + description: Metadata is the metadata for connection secret. + properties: + annotations: + additionalProperties: + type: string + description: Annotations are the annotations to be added to + connection secret. - For Kubernetes secrets, this will be + used as "metadata.annotations". - It is up to Secret Store + implementation for others store types. + type: object + labels: + additionalProperties: + type: string + description: Labels are the labels/tags to be added to connection + secret. - For Kubernetes secrets, this will be used as "metadata.labels". + - It is up to Secret Store implementation for others store + types. + type: object + type: + description: Type is the SecretType for the connection secret. + - Only valid for Kubernetes Secret Stores. + type: string + type: object + name: + description: Name is the name of the connection secret. + type: string + required: + - name + type: object + writeConnectionSecretToRef: + description: WriteConnectionSecretToReference specifies the namespace + and name of a Secret to which any connection details for this managed + resource should be written. Connection details frequently include + the endpoint, username, and password required to connect to the + managed resource. This field is planned to be replaced in a future + release in favor of PublishConnectionDetailsTo. Currently, both + could be set independently and connection details would be published + to both without affecting each other. + properties: + name: + description: Name of the secret. + type: string + namespace: + description: Namespace of the secret. + type: string + required: + - name + - namespace + type: object + required: + - forProvider + type: object + status: + description: A NopResourceStatus represents the observed state of a NopResource. + properties: + atProvider: + description: NopResourceObservation are the observable fields of a + NopResource. + properties: + fields: + description: Fields is an arbitrary object you can patch to and + from. It has no schema, is not validated, and is not used by + the NopResource controller. + type: object + x-kubernetes-preserve-unknown-fields: true + type: object + conditions: + description: Conditions of the resource. + items: + description: A Condition that may apply to a resource. + properties: + lastTransitionTime: + description: LastTransitionTime is the last time this condition + transitioned from one status to another. + format: date-time + type: string + message: + description: A Message containing details about this condition's + last transition from one status to another, if any. + type: string + reason: + description: A Reason for this condition's last transition from + one status to another. + type: string + status: + description: Status of this condition; is it currently True, + False, or Unknown? + type: string + type: + description: Type of this condition. At most one of each condition + type may apply to a resource at any point in time. + type: string + required: + - lastTransitionTime + - reason + - status + - type + type: object + type: array + type: object + required: + - spec + type: object + served: true + storage: true + subresources: + status: {} +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: null +--- diff --git a/cmd/crossplane/beta/validate/testdata/crds/xpkg.crossplane.io/provider-dummy@v1.0.0/crd.yaml b/cmd/crossplane/beta/validate/testdata/crds/xpkg.crossplane.io/provider-dummy@v1.0.0/crd.yaml new file mode 100644 index 0000000..6439ff6 --- /dev/null +++ b/cmd/crossplane/beta/validate/testdata/crds/xpkg.crossplane.io/provider-dummy@v1.0.0/crd.yaml @@ -0,0 +1,4 @@ +apiVersion: apiextensions.k8s.io/v1beta1 +kind: CustomResourceDefinition +metadata: + name: test \ No newline at end of file diff --git a/cmd/crossplane/beta/validate/testdata/folder/nested-folder/resource-a.yaml b/cmd/crossplane/beta/validate/testdata/folder/nested-folder/resource-a.yaml new file mode 100644 index 0000000..d03f240 --- /dev/null +++ b/cmd/crossplane/beta/validate/testdata/folder/nested-folder/resource-a.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-validate-a + annotations: + crossplane.io/composition-resource-name: resource-a +spec: + coolField: "I'm cool!" diff --git a/cmd/crossplane/beta/validate/testdata/folder/resource-b.yaml b/cmd/crossplane/beta/validate/testdata/folder/resource-b.yaml new file mode 100644 index 0000000..dfacc4a --- /dev/null +++ b/cmd/crossplane/beta/validate/testdata/folder/resource-b.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-validate-b + annotations: + crossplane.io/composition-resource-name: resource-b +spec: + coolerField: "I'm cooler!" \ No newline at end of file diff --git a/cmd/crossplane/beta/validate/testdata/resources.yaml b/cmd/crossplane/beta/validate/testdata/resources.yaml new file mode 100644 index 0000000..bd542c4 --- /dev/null +++ b/cmd/crossplane/beta/validate/testdata/resources.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-validate-a + annotations: + crossplane.io/composition-resource-name: resource-a +spec: + coolField: "I'm cool!" +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-validate-b + annotations: + crossplane.io/composition-resource-name: resource-b +spec: + coolerField: "I'm cooler!" \ No newline at end of file diff --git a/cmd/crossplane/beta/validate/unknown_fields.go b/cmd/crossplane/beta/validate/unknown_fields.go new file mode 100644 index 0000000..5f3a258 --- /dev/null +++ b/cmd/crossplane/beta/validate/unknown_fields.go @@ -0,0 +1,43 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "fmt" + "strings" + + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +// validateUnknownFields Validates the resource's unknown fields against the given schema and returns a list of errors. +func validateUnknownFields(mr map[string]any, sch *schema.Structural) field.ErrorList { + opts := schema.UnknownFieldPathOptions{ + TrackUnknownFieldPaths: true, // to get the list of pruned unknown fields + } + errs := field.ErrorList{} + + uf := pruning.PruneWithOptions(mr, sch, true, opts) + for _, f := range uf { + strPath := strings.Split(f, ".") + child := strPath[len(strPath)-1] + errs = append(errs, field.Invalid(field.NewPath(f), child, fmt.Sprintf("unknown field: \"%s\"", child))) + } + + return errs +} diff --git a/cmd/crossplane/beta/validate/validate.go b/cmd/crossplane/beta/validate/validate.go new file mode 100644 index 0000000..0a2b14a --- /dev/null +++ b/cmd/crossplane/beta/validate/validate.go @@ -0,0 +1,237 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "context" + "fmt" + "io" + + ext "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel" + structuraldefaulting "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting" + "k8s.io/apiextensions-apiserver/pkg/apiserver/validation" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + runtimeschema "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + celconfig "k8s.io/apiserver/pkg/apis/cel" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" +) + +const ( + errWriteOutput = "cannot write output" +) + +func newValidatorsAndStructurals(crds []*extv1.CustomResourceDefinition) (map[runtimeschema.GroupVersionKind][]*validation.SchemaValidator, map[runtimeschema.GroupVersionKind]*schema.Structural, error) { + validators := map[runtimeschema.GroupVersionKind][]*validation.SchemaValidator{} + structurals := map[runtimeschema.GroupVersionKind]*schema.Structural{} + + for i := range crds { + internal := &ext.CustomResourceDefinition{} + if err := extv1.Convert_v1_CustomResourceDefinition_To_apiextensions_CustomResourceDefinition(crds[i], internal, nil); err != nil { + return nil, nil, err + } + + // Top-level and per-version schemas are mutually exclusive. + for _, ver := range internal.Spec.Versions { + var ( + sv validation.SchemaValidator + err error + ) + + gvk := runtimeschema.GroupVersionKind{ + Group: internal.Spec.Group, + Version: ver.Name, + Kind: internal.Spec.Names.Kind, + } + + var s *ext.JSONSchemaProps + + switch { + case internal.Spec.Validation != nil: + s = internal.Spec.Validation.OpenAPIV3Schema + case ver.Schema != nil && ver.Schema.OpenAPIV3Schema != nil: + s = ver.Schema.OpenAPIV3Schema + default: + // TODO log a warning here, it should never happen + continue + } + + sv, _, err = validation.NewSchemaValidator(s) + if err != nil { + return nil, nil, err + } + + validators[gvk] = append(validators[gvk], &sv) + + structural, err := schema.NewStructural(s) + if err != nil { + return nil, nil, err + } + + structurals[gvk] = structural + } + } + + return validators, structurals, nil +} + +// SchemaValidation validates the resources against the given CRDs. +func SchemaValidation(ctx context.Context, resources []*unstructured.Unstructured, crds []*extv1.CustomResourceDefinition, errorOnMissingSchemas bool, skipSuccessLogs bool, w io.Writer) error { //nolint:gocognit // printing the output increases the cyclomatic complexity a little bit + schemaValidators, structurals, err := newValidatorsAndStructurals(crds) + if err != nil { + return errors.Wrap(err, "cannot create schema validators") + } + + failure, missingSchemas := 0, 0 + + for _, r := range resources { + gvk := r.GetObjectKind().GroupVersionKind() + sv, ok := schemaValidators[gvk] + s := structurals[gvk] // if we have a schema validator, we should also have a structural + + if !ok { + missingSchemas++ + + if _, err := fmt.Fprintf(w, "[!] could not find CRD/XRD for: %s\n", r.GroupVersionKind().String()); err != nil { + return errors.Wrap(err, errWriteOutput) + } + + continue + } + + if err := applyDefaults(r, gvk, crds); err != nil { + if _, err := fmt.Fprintf(w, "[!] failed to apply defaults for %s, %s: %v\n", r.GroupVersionKind().String(), getResourceName(r), err); err != nil { + return errors.Wrap(err, errWriteOutput) + } + } + + rf := 0 + + re := field.ErrorList{} + for _, v := range sv { + re = append(re, validation.ValidateCustomResource(nil, r, *v)...) + + re = append(re, validateUnknownFields(r.UnstructuredContent(), s)...) + for _, e := range re { + rf++ + + if _, err := fmt.Fprintf(w, "[x] schema validation error %s, %s : %s\n", r.GroupVersionKind().String(), getResourceName(r), e.Error()); err != nil { + return errors.Wrap(err, errWriteOutput) + } + } + + celValidator := cel.NewValidator(s, true, celconfig.PerCallLimit) + + re, _ = celValidator.Validate(ctx, nil, s, r.Object, nil, celconfig.PerCallLimit) + for _, e := range re { + rf++ + + if _, err := fmt.Fprintf(w, "[x] CEL validation error %s, %s : %s\n", r.GroupVersionKind().String(), getResourceName(r), e.Error()); err != nil { + return errors.Wrap(err, errWriteOutput) + } + } + + if rf == 0 { + if !skipSuccessLogs { + if _, err := fmt.Fprintf(w, "[✓] %s, %s validated successfully\n", r.GroupVersionKind().String(), getResourceName(r)); err != nil { + return errors.Wrap(err, errWriteOutput) + } + } + } else { + failure++ + } + } + } + + if _, err := fmt.Fprintf(w, "Total %d resources: %d missing schemas, %d success cases, %d failure cases\n", len(resources), missingSchemas, len(resources)-failure-missingSchemas, failure); err != nil { + return errors.Wrap(err, errWriteOutput) + } + + if failure > 0 { + return errors.New("could not validate all resources") + } + + if errorOnMissingSchemas && missingSchemas > 0 { + return errors.New("could not validate all resources, schema(s) missing") + } + + return nil +} + +func getResourceName(r *unstructured.Unstructured) string { + if r.GetName() != "" { + return r.GetName() + } + + // fallback to composition resource name + return r.GetAnnotations()[xcrd.AnnotationKeyCompositionResourceName] +} + +// applyDefaults applies default values from the CRD schema to the unstructured resource. +func applyDefaults(resource *unstructured.Unstructured, gvk runtimeschema.GroupVersionKind, crds []*extv1.CustomResourceDefinition) error { + var matchingCRD *extv1.CustomResourceDefinition + + for _, crd := range crds { + if crd.Spec.Group == gvk.Group && crd.Spec.Names.Kind == gvk.Kind { + matchingCRD = crd + break + } + } + + if matchingCRD == nil { + // no CRD found for applying defaults, skip defaulting + return nil + } + + var schemaProps *extv1.JSONSchemaProps + + for _, v := range matchingCRD.Spec.Versions { + if v.Name == gvk.Version { + if v.Schema != nil && v.Schema.OpenAPIV3Schema != nil { + schemaProps = v.Schema.OpenAPIV3Schema + } + + break + } + } + + if schemaProps == nil { + return fmt.Errorf("no schema found for version %s in CRD %s", gvk.Version, matchingCRD.Name) + } + + var apiExtSchema ext.JSONSchemaProps + + err := extv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(schemaProps, &apiExtSchema, nil) + if err != nil { + return fmt.Errorf("failed to convert schema: %w", err) + } + + structural, err := schema.NewStructural(&apiExtSchema) + if err != nil { + return fmt.Errorf("failed to create structural schema: %w", err) + } + + obj := resource.UnstructuredContent() + structuraldefaulting.Default(obj, structural) + + return nil +} diff --git a/cmd/crossplane/beta/validate/validate_test.go b/cmd/crossplane/beta/validate/validate_test.go new file mode 100644 index 0000000..9e6d0e1 --- /dev/null +++ b/cmd/crossplane/beta/validate/validate_test.go @@ -0,0 +1,1712 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package validate + +import ( + "bytes" + "context" + "testing" + + "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + runtimeschema "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/validation/field" + "k8s.io/utils/ptr" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" +) + +var ( + testCRD = &extv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "test.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Test", + ListKind: "TestList", + Plural: "tests", + Singular: "test", + }, + Scope: "Cluster", + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + Storage: true, + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + }, + Required: []string{ + "replicas", + }, + }, + }, + }, + }, + }, + }, + }, + } + testCRDWithCEL = &extv1.CustomResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.k8s.io/v1", + Kind: "CustomResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "test.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Test", + ListKind: "TestList", + Plural: "tests", + Singular: "test", + }, + Scope: "Cluster", + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + Storage: true, + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + XValidations: extv1.ValidationRules{ + extv1.ValidationRule{ + Rule: "self.minReplicas <= self.replicas && self.replicas <= self.maxReplicas", + Message: "replicas should be in between minReplicas and maxReplicas", + }, + }, + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + "minReplicas": { + Type: "integer", + }, + "maxReplicas": { + Type: "integer", + }, + }, + Required: []string{ + "replicas", + "minReplicas", + "maxReplicas", + }, + }, + }, + }, + }, + }, + }, + }, + } +) + +func TestConvertToCRDs(t *testing.T) { + type args struct { + schemas []*unstructured.Unstructured + } + + type want struct { + crd []*extv1.CustomResourceDefinition + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "UnstructuredCRD": { + reason: "Should convert an unstructured CRD to a CustomResourceDefinition", + args: args{ + schemas: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "CustomResourceDefinition", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "group": "test.org", + "names": map[string]any{ + "kind": "Test", + "listKind": "TestList", + "plural": "tests", + "singular": "test", + }, + "scope": "Cluster", + "versions": []any{ + map[string]any{ + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": map[string]any{ + "openAPIV3Schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "spec": map[string]any{ + "type": "object", + "properties": map[string]any{ + "replicas": map[string]any{ + "type": "integer", + }, + }, + "required": []any{ + "replicas", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + crd: []*extv1.CustomResourceDefinition{ + testCRD, + }, + }, + }, + "UnstructuredXRD": { + reason: "Should convert an unstructured XRD to a CustomResourceDefinition", + args: args{ + schemas: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "apiextensions.crossplane.io/v1alpha1", + "kind": "CompositeResourceDefinition", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "group": "test.org", + "names": map[string]any{ + "kind": "Test", + "listKind": "TestList", + "plural": "tests", + "singular": "test", + }, + "versions": []any{ + map[string]any{ + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": map[string]any{ + "openAPIV3Schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "spec": map[string]any{ + "type": "object", + "properties": map[string]any{ + "replicas": map[string]any{ + "type": "integer", + }, + }, + }, + "status": map[string]any{ + "type": "object", + "properties": map[string]any{ + "replicas": map[string]any{ + "type": "integer", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + crd: []*extv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apiextensions.crossplane.io/v1", + Kind: "CompositeResourceDefinition", + Name: "test", + Controller: ptr.To[bool](true), + BlockOwnerDeletion: ptr.To[bool](true), + }, + }, + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "test.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Test", + ListKind: "TestList", + Plural: "tests", + Singular: "test", + Categories: []string{ + "composite", + }, + }, + Scope: "Cluster", + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + Storage: false, + Subresources: &extv1.CustomResourceSubresources{ + Status: &extv1.CustomResourceSubresourceStatus{}, + }, + AdditionalPrinterColumns: []extv1.CustomResourceColumnDefinition{ + { + Name: "SYNCED", + Type: "string", + JSONPath: ".status.conditions[?(@.type=='Synced')].status", + }, + { + Name: "READY", + Type: "string", + JSONPath: ".status.conditions[?(@.type=='Ready')].status", + }, + { + Name: "COMPOSITION", + Type: "string", + JSONPath: ".spec.compositionRef.name", + }, + { + Name: "COMPOSITIONREVISION", + Type: "string", + JSONPath: ".spec.compositionRevisionRef.name", + Priority: 1, + }, + { + Name: "AGE", + Type: "date", + JSONPath: ".metadata.creationTimestamp", + }, + }, + + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Required: []string{ + "spec", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": { + Type: "string", + }, + "kind": { + Type: "string", + }, + "metadata": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + MaxLength: ptr.To[int64](63), + }, + }, + }, + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + "claimRef": { + Type: "object", + Required: []string{ + "apiVersion", "kind", "namespace", "name", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": { + Type: "string", + }, + "kind": { + Type: "string", + }, + "name": { + Type: "string", + }, + "namespace": { + Type: "string", + }, + }, + }, + "compositionRef": { + Type: "object", + Required: []string{ + "name", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + }, + }, + }, + "compositionRevisionRef": { + Type: "object", + Required: []string{ + "name", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + }, + }, + }, + "compositionRevisionSelector": { + Type: "object", + Required: []string{ + "matchLabels", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "matchLabels": { + Type: "object", + AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &extv1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + }, + }, + "compositionSelector": { + Type: "object", + Required: []string{"matchLabels"}, + Properties: map[string]extv1.JSONSchemaProps{ + "matchLabels": { + Type: "object", + AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &extv1.JSONSchemaProps{Type: "string"}, + }, + }, + }, + }, + "compositionUpdatePolicy": { + Type: "string", + Enum: []extv1.JSON{ + {Raw: []byte(`"Automatic"`)}, + {Raw: []byte(`"Manual"`)}, + }, + }, + "resourceRefs": { + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": {Type: "string"}, + "name": {Type: "string"}, + "namespace": {Type: "string"}, + "kind": {Type: "string"}, + }, + Required: []string{"apiVersion", "kind"}, + }, + }, + XListType: ptr.To("atomic"), + }, + "writeConnectionSecretToRef": { + Type: "object", + Required: []string{"name", "namespace"}, + Properties: map[string]extv1.JSONSchemaProps{ + "name": {Type: "string"}, + "namespace": {Type: "string"}, + }, + }, + }, + }, + "status": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + "conditions": { + Description: "Conditions of the resource.", + Type: "array", + XListType: ptr.To("map"), + XListMapKeys: []string{"type"}, + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "object", + Required: []string{"lastTransitionTime", "reason", "status", "type"}, + Properties: map[string]extv1.JSONSchemaProps{ + "lastTransitionTime": {Type: "string", Format: "date-time"}, + "message": {Type: "string"}, + "reason": {Type: "string"}, + "status": {Type: "string"}, + "type": {Type: "string"}, + "observedGeneration": {Type: "integer", Format: "int64"}, + }, + }, + }, + }, + "claimConditionTypes": { + Type: "array", + XListType: ptr.To("set"), + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "connectionDetails": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "lastPublishedTime": {Type: "string", Format: "date-time"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "UnstructuredXRDWithClaim": { + reason: "Should convert an unstructured XRD to a CustomResourceDefinition", + args: args{ + schemas: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "apiextensions.crossplane.io/v1alpha1", + "kind": "CompositeResourceDefinition", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "claimNames": map[string]any{ + "kind": "TestClaim", + "plural": "testclaims", + }, + "group": "test.org", + "names": map[string]any{ + "kind": "Test", + "listKind": "TestList", + "plural": "tests", + "singular": "test", + }, + "versions": []any{ + map[string]any{ + "name": "v1alpha1", + "served": true, + "storage": true, + "schema": map[string]any{ + "openAPIV3Schema": map[string]any{ + "type": "object", + "properties": map[string]any{ + "spec": map[string]any{ + "type": "object", + "properties": map[string]any{ + "replicas": map[string]any{ + "type": "integer", + }, + }, + }, + "status": map[string]any{ + "type": "object", + "properties": map[string]any{ + "replicas": map[string]any{ + "type": "integer", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + crd: []*extv1.CustomResourceDefinition{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "test", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apiextensions.crossplane.io/v1", + Kind: "CompositeResourceDefinition", + Name: "test", + Controller: ptr.To[bool](true), + BlockOwnerDeletion: ptr.To[bool](true), + }, + }, + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "test.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Test", + ListKind: "TestList", + Plural: "tests", + Singular: "test", + Categories: []string{ + "composite", + }, + }, + Scope: "Cluster", + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + Storage: false, + Subresources: &extv1.CustomResourceSubresources{ + Status: &extv1.CustomResourceSubresourceStatus{}, + }, + AdditionalPrinterColumns: []extv1.CustomResourceColumnDefinition{ + { + Name: "SYNCED", + Type: "string", + JSONPath: ".status.conditions[?(@.type=='Synced')].status", + }, + { + Name: "READY", + Type: "string", + JSONPath: ".status.conditions[?(@.type=='Ready')].status", + }, + { + Name: "COMPOSITION", + Type: "string", + JSONPath: ".spec.compositionRef.name", + }, + { + Name: "COMPOSITIONREVISION", + Type: "string", + JSONPath: ".spec.compositionRevisionRef.name", + Priority: 1, + }, + { + Name: "AGE", + Type: "date", + JSONPath: ".metadata.creationTimestamp", + }, + }, + + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Required: []string{ + "spec", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": { + Type: "string", + }, + "kind": { + Type: "string", + }, + "metadata": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + MaxLength: ptr.To[int64](63), + }, + }, + }, + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + "claimRef": { + Type: "object", + Required: []string{ + "apiVersion", "kind", "namespace", "name", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": { + Type: "string", + }, + "kind": { + Type: "string", + }, + "name": { + Type: "string", + }, + "namespace": { + Type: "string", + }, + }, + }, + "compositionRef": { + Type: "object", + Required: []string{ + "name", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + }, + }, + }, + "compositionRevisionRef": { + Type: "object", + Required: []string{ + "name", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + }, + }, + }, + "compositionRevisionSelector": { + Type: "object", + Required: []string{ + "matchLabels", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "matchLabels": { + Type: "object", + AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &extv1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + }, + }, + "compositionSelector": { + Type: "object", + Required: []string{"matchLabels"}, + Properties: map[string]extv1.JSONSchemaProps{ + "matchLabels": { + Type: "object", + AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &extv1.JSONSchemaProps{Type: "string"}, + }, + }, + }, + }, + "compositionUpdatePolicy": { + Type: "string", + Enum: []extv1.JSON{ + {Raw: []byte(`"Automatic"`)}, + {Raw: []byte(`"Manual"`)}, + }, + }, + "resourceRefs": { + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": {Type: "string"}, + "name": {Type: "string"}, + "namespace": {Type: "string"}, + "kind": {Type: "string"}, + }, + Required: []string{"apiVersion", "kind"}, + }, + }, + XListType: ptr.To("atomic"), + }, + "writeConnectionSecretToRef": { + Type: "object", + Required: []string{"name", "namespace"}, + Properties: map[string]extv1.JSONSchemaProps{ + "name": {Type: "string"}, + "namespace": {Type: "string"}, + }, + }, + }, + }, + "status": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + "conditions": { + Description: "Conditions of the resource.", + Type: "array", + XListType: ptr.To("map"), + XListMapKeys: []string{"type"}, + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "object", + Required: []string{"lastTransitionTime", "reason", "status", "type"}, + Properties: map[string]extv1.JSONSchemaProps{ + "lastTransitionTime": {Type: "string", Format: "date-time"}, + "message": {Type: "string"}, + "reason": {Type: "string"}, + "status": {Type: "string"}, + "type": {Type: "string"}, + "observedGeneration": {Type: "integer", Format: "int64"}, + }, + }, + }, + }, + "claimConditionTypes": { + Type: "array", + XListType: ptr.To("set"), + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "connectionDetails": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "lastPublishedTime": {Type: "string", Format: "date-time"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "testclaims.test.org", + OwnerReferences: []metav1.OwnerReference{ + { + APIVersion: "apiextensions.crossplane.io/v1", + Kind: "CompositeResourceDefinition", + Name: "test", + Controller: ptr.To[bool](true), + BlockOwnerDeletion: ptr.To[bool](true), + }, + }, + }, + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "test.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "TestClaim", + Plural: "testclaims", + Categories: []string{ + "claim", + }, + }, + Scope: "Namespaced", + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Served: true, + Storage: false, + Subresources: &extv1.CustomResourceSubresources{ + Status: &extv1.CustomResourceSubresourceStatus{}, + }, + AdditionalPrinterColumns: []extv1.CustomResourceColumnDefinition{ + { + Name: "SYNCED", + Type: "string", + JSONPath: ".status.conditions[?(@.type=='Synced')].status", + }, + { + Name: "READY", + Type: "string", + JSONPath: ".status.conditions[?(@.type=='Ready')].status", + }, + { + Name: "CONNECTION-SECRET", + Type: "string", + JSONPath: ".spec.writeConnectionSecretToRef.name", + }, + { + Name: "AGE", + Type: "date", + JSONPath: ".metadata.creationTimestamp", + }, + }, + + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Required: []string{ + "spec", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": { + Type: "string", + }, + "kind": { + Type: "string", + }, + "metadata": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + MaxLength: ptr.To[int64](63), + }, + }, + }, + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + "compositeDeletePolicy": { + Type: "string", + Enum: []extv1.JSON{ + {Raw: []byte(`"Background"`)}, + {Raw: []byte(`"Foreground"`)}, + }, + }, + "compositionRef": { + Type: "object", + Required: []string{ + "name", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + }, + }, + }, + "compositionRevisionRef": { + Type: "object", + Required: []string{ + "name", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + }, + }, + }, + "compositionRevisionSelector": { + Type: "object", + Required: []string{ + "matchLabels", + }, + Properties: map[string]extv1.JSONSchemaProps{ + "matchLabels": { + Type: "object", + AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &extv1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + }, + }, + "compositionSelector": { + Type: "object", + Required: []string{"matchLabels"}, + Properties: map[string]extv1.JSONSchemaProps{ + "matchLabels": { + Type: "object", + AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &extv1.JSONSchemaProps{Type: "string"}, + }, + }, + }, + }, + "compositionUpdatePolicy": { + Type: "string", + Enum: []extv1.JSON{ + {Raw: []byte(`"Automatic"`)}, + {Raw: []byte(`"Manual"`)}, + }, + }, + "resourceRef": { + Type: "object", + Required: []string{"apiVersion", "kind", "name"}, + Properties: map[string]extv1.JSONSchemaProps{ + "apiVersion": {Type: "string"}, + "name": {Type: "string"}, + "kind": {Type: "string"}, + }, + }, + "writeConnectionSecretToRef": { + Type: "object", + Required: []string{"name"}, + Properties: map[string]extv1.JSONSchemaProps{ + "name": {Type: "string"}, + }, + }, + }, + }, + "status": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + "conditions": { + Description: "Conditions of the resource.", + Type: "array", + XListType: ptr.To("map"), + XListMapKeys: []string{"type"}, + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "object", + Required: []string{"lastTransitionTime", "reason", "status", "type"}, + Properties: map[string]extv1.JSONSchemaProps{ + "lastTransitionTime": {Type: "string", Format: "date-time"}, + "message": {Type: "string"}, + "reason": {Type: "string"}, + "status": {Type: "string"}, + "type": {Type: "string"}, + "observedGeneration": {Type: "integer", Format: "int64"}, + }, + }, + }, + }, + "claimConditionTypes": { + Type: "array", + XListType: ptr.To("set"), + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "connectionDetails": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "lastPublishedTime": {Type: "string", Format: "date-time"}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + "WrongKind": { + reason: "Should skip an unstructured object that is not a CRD or XRD", + args: args{ + schemas: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "apiextensions.k8s.io/v1", + "kind": "WrongKind", + "metadata": map[string]any{ + "name": "test", + }, + }, + }, + }, + }, + want: want{ + crd: []*extv1.CustomResourceDefinition{}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + w := &bytes.Buffer{} + m := NewManager("", nil, w) + err := m.PrepExtensions(tc.args.schemas) + + if diff := cmp.Diff(tc.want.crd, m.crds); diff != "" { + t.Errorf("%s\nconvertToCRDs(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\nconvertToCRDs(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestValidateResources(t *testing.T) { + type args struct { + resources []*unstructured.Unstructured + crds []*extv1.CustomResourceDefinition + errorOnMissingSchemas bool + } + + type want struct { + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Valid": { + reason: "Should not return an error if the resources are valid", + args: args{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "replicas": 1, + }, + }, + }, + }, + crds: []*extv1.CustomResourceDefinition{ + testCRD, + }, + }, + }, + "ValidWithCEL": { + reason: "Should not return an error if the resources are valid", + args: args{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "replicas": 5, + "minReplicas": 3, + "maxReplicas": 10, + }, + }, + }, + }, + crds: []*extv1.CustomResourceDefinition{ + testCRDWithCEL, + }, + }, + }, + "ValidWithMissingSchemasEnabled": { + reason: "Should not return an error if the resources are valid and schemas are not missing", + args: args{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "replicas": 1, + }, + }, + }, + }, + crds: []*extv1.CustomResourceDefinition{ + testCRD, + }, + errorOnMissingSchemas: true, + }, + }, + "ErrorOnMissingSchemas": { + reason: "Should return an error if schemas are missing", + args: args{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "replicas": 1, + }, + }, + }, + }, + crds: []*extv1.CustomResourceDefinition{}, + errorOnMissingSchemas: true, + }, + want: want{ + err: errors.New("could not validate all resources, schema(s) missing"), + }, + }, + "Invalid": { + reason: "Should return an error if the resources are invalid", + args: args{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "replicas": "non-integer", + }, + }, + }, + }, + crds: []*extv1.CustomResourceDefinition{ + testCRD, + }, + }, + want: want{ + err: errors.New("could not validate all resources"), + }, + }, + "InvalidWithCEL": { + reason: "Should not return an error if the resources are valid", + args: args{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "replicas": 50, + "minReplicas": 3, + "maxReplicas": 10, + }, + }, + }, + }, + crds: []*extv1.CustomResourceDefinition{ + testCRDWithCEL, + }, + }, + want: want{ + err: errors.New("could not validate all resources"), + }, + }, + "MissingCRD": { + reason: "Should not return an error if the CRD/XRD is missing", + args: args{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "metadata": map[string]any{ + "name": "test", + }, + "spec": map[string]any{ + "replicas": 1, + }, + }, + }, + }, + crds: []*extv1.CustomResourceDefinition{}, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + w := &bytes.Buffer{} + + got := SchemaValidation(context.Background(), tc.args.resources, tc.args.crds, tc.args.errorOnMissingSchemas, false, w) + if diff := cmp.Diff(tc.want.err, got, test.EquateErrors()); diff != "" { + t.Errorf("%s\nvalidateResources(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestValidateUnknownFields(t *testing.T) { + type args struct { + mr map[string]any + sch *schema.Structural + } + + type want struct { + errs field.ErrorList + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "UnknownFieldPresent": { + reason: "Should detect unknown fields in the resource and return an error", + args: args{ + mr: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "metadata": map[string]any{ + "name": "test-instance", + }, + "spec": map[string]any{ + "replicas": 3, + "unknownField": "should fail", // This field is not defined in the CRD schema + }, + }, + sch: &schema.Structural{ + Properties: map[string]schema.Structural{ + "spec": { + Properties: map[string]schema.Structural{ + "replicas": { + Generic: schema.Generic{Type: "integer"}, + }, + }, + }, + }, + }, + }, + want: want{ + errs: field.ErrorList{ + field.Invalid(field.NewPath("spec.unknownField"), "unknownField", `unknown field: "unknownField"`), + }, + }, + }, + "UnknownFieldNotPresent": { + reason: "Should not return an error when no unknown fields are present", + args: args{ + mr: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "metadata": map[string]any{ + "name": "test-instance", + }, + "spec": map[string]any{ + "replicas": 3, // No unknown fields + }, + }, + sch: &schema.Structural{ + Properties: map[string]schema.Structural{ + "spec": { + Properties: map[string]schema.Structural{ + "replicas": { + Generic: schema.Generic{Type: "integer"}, + }, + }, + }, + }, + }, + }, + want: want{ + errs: field.ErrorList{}, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + errs := validateUnknownFields(tc.args.mr, tc.args.sch) + if diff := cmp.Diff(tc.want.errs, errs, test.EquateErrors()); diff != "" { + t.Errorf("%s\nvalidateUnknownFields(...): -want errs, +got errs:\n%s", tc.reason, diff) + } + }) + } +} + +func TestApplyDefaults(t *testing.T) { + type args struct { + resource *unstructured.Unstructured + gvk runtimeschema.GroupVersionKind + crds []*extv1.CustomResourceDefinition + } + + type want struct { + resource *unstructured.Unstructured + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "NoCRDFound": { + reason: "Should return nil when no matching CRD is found (skip defaulting)", + args: args{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "replicas": 3, + }, + }, + }, + gvk: runtimeschema.GroupVersionKind{ + Group: "test.org", + Version: "v1alpha1", + Kind: "Test", + }, + crds: []*extv1.CustomResourceDefinition{}, + }, + want: want{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "replicas": 3, + }, + }, + }, + err: nil, + }, + }, + "ApplySimpleDefault": { + reason: "Should apply default value to missing property", + args: args{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "replicas": 3, + }, + }, + }, + gvk: runtimeschema.GroupVersionKind{ + Group: "test.org", + Version: "v1alpha1", + Kind: "Test", + }, + crds: []*extv1.CustomResourceDefinition{ + { + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "test.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Test", + }, + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + "deletionPolicy": { + Type: "string", + Default: &extv1.JSON{Raw: []byte(`"Delete"`)}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "replicas": 3, + "deletionPolicy": "Delete", + }, + }, + }, + err: nil, + }, + }, + "DoNotOverrideExisting": { + reason: "Should not override existing values with defaults", + args: args{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "replicas": 3, + "deletionPolicy": "Retain", + }, + }, + }, + gvk: runtimeschema.GroupVersionKind{ + Group: "test.org", + Version: "v1alpha1", + Kind: "Test", + }, + crds: []*extv1.CustomResourceDefinition{ + { + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "test.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Test", + }, + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "replicas": { + Type: "integer", + }, + "deletionPolicy": { + Type: "string", + Default: &extv1.JSON{Raw: []byte(`"Delete"`)}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "replicas": 3, + "deletionPolicy": "Retain", + }, + }, + }, + err: nil, + }, + }, + "NestedDefaults": { + reason: "Should apply defaults to nested objects", + args: args{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "us-east-1", + }, + }, + }, + }, + gvk: runtimeschema.GroupVersionKind{ + Group: "test.org", + Version: "v1alpha1", + Kind: "Test", + }, + crds: []*extv1.CustomResourceDefinition{ + { + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "test.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Test", + }, + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "forProvider": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "region": { + Type: "string", + }, + "instanceType": { + Type: "string", + Default: &extv1.JSON{Raw: []byte(`"t3.micro"`)}, + }, + }, + }, + "deletionPolicy": { + Type: "string", + Default: &extv1.JSON{Raw: []byte(`"Delete"`)}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "forProvider": map[string]any{ + "region": "us-east-1", + "instanceType": "t3.micro", + }, + "deletionPolicy": "Delete", + }, + }, + }, + err: nil, + }, + }, + "ComplexDefaults": { + reason: "Should apply complex default values (objects, arrays)", + args: args{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "name": "test", + }, + }, + }, + gvk: runtimeschema.GroupVersionKind{ + Group: "test.org", + Version: "v1alpha1", + Kind: "Test", + }, + crds: []*extv1.CustomResourceDefinition{ + { + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "test.org", + Names: extv1.CustomResourceDefinitionNames{ + Kind: "Test", + }, + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1alpha1", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "name": { + Type: "string", + }, + "metadata": { + Type: "object", + Default: &extv1.JSON{Raw: []byte(`{"labels":{"app":"default-app"}}`)}, + }, + "tags": { + Type: "array", + Default: &extv1.JSON{Raw: []byte(`["default","tag"]`)}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: want{ + resource: &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "test.org/v1alpha1", + "kind": "Test", + "spec": map[string]any{ + "name": "test", + "metadata": map[string]any{ + "labels": map[string]any{ + "app": "default-app", + }, + }, + "tags": []any{"default", "tag"}, + }, + }, + }, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := applyDefaults(tc.args.resource, tc.args.gvk, tc.args.crds) + if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { + t.Errorf("%s\napplyDefaults(...): -want err, +got err:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.resource, tc.args.resource); diff != "" { + t.Errorf("%s\napplyDefaults(...): -want resource, +got resource:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/common/crd/crd.go b/cmd/crossplane/common/crd/crd.go new file mode 100644 index 0000000..9ed9dc2 --- /dev/null +++ b/cmd/crossplane/common/crd/crd.go @@ -0,0 +1,70 @@ +// Package crd provides functions and helpers shared between CLI utilities, available for import by external tools interacting with Crossplane. +package crd + +import ( + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + // xcrd is the reason this has to live in util and not downstream in contrib; the internal reference here. + // TODO: can we expose it? should we? + "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" + + v1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" +) + +// ConvertToCRDs Helper function to convert XRDs/CRDs to CRDs. +func ConvertToCRDs(extensions []*un.Unstructured) ([]*extv1.CustomResourceDefinition, error) { + crds := make([]*extv1.CustomResourceDefinition, 0) + + for _, e := range extensions { + switch e.GroupVersionKind().GroupKind() { + case schema.GroupKind{Group: "apiextensions.k8s.io", Kind: "CustomResourceDefinition"}: + crd := &extv1.CustomResourceDefinition{} + bytes, err := e.MarshalJSON() + if err != nil { + return nil, errors.Wrap(err, "cannot marshal CRD to JSON") + } + + if err := yaml.Unmarshal(bytes, crd); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal CRD YAML") + } + + crds = append(crds, crd) + + case schema.GroupKind{Group: "apiextensions.crossplane.io", Kind: "CompositeResourceDefinition"}: + xrd := &v1.CompositeResourceDefinition{} + bytes, err := e.MarshalJSON() + if err != nil { + return nil, errors.Wrap(err, "cannot marshal XRD to JSON") + } + + if err := yaml.Unmarshal(bytes, xrd); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal XRD YAML") + } + + crd, err := xcrd.ForCompositeResource(xrd) + if err != nil { + return nil, errors.Wrapf(err, "cannot derive composite CRD from XRD %q", xrd.GetName()) + } + crds = append(crds, crd) + + if xrd.Spec.ClaimNames != nil { + claimCrd, err := xcrd.ForCompositeResourceClaim(xrd) + if err != nil { + return nil, errors.Wrapf(err, "cannot derive claim CRD from XRD %q", xrd.GetName()) + } + + crds = append(crds, claimCrd) + } + + default: + // Process other package types as needed; for now, nothing to do. + continue + } + } + + return crds, nil +} diff --git a/cmd/crossplane/common/load/loader.go b/cmd/crossplane/common/load/loader.go new file mode 100644 index 0000000..2e6a67d --- /dev/null +++ b/cmd/crossplane/common/load/loader.go @@ -0,0 +1,311 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package load provides functionality to load Kubernetes manifests from various sources +package load + +import ( + "bufio" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + v1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" +) + +// Loader interface defines the contract for different input sources. +type Loader interface { + Load() ([]*unstructured.Unstructured, error) +} + +// NewLoader returns a Loader based on the input source. +func NewLoader(input string) (Loader, error) { + sources := strings.Split(input, ",") + + if len(sources) == 1 { + return newLoader(sources[0]) + } + + loaders := make([]Loader, 0, len(sources)) + + for _, source := range sources { + loader, err := newLoader(source) + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("cannot create loader for %q", source)) + } + + loaders = append(loaders, loader) + } + + return &MultiLoader{loaders: loaders}, nil +} + +func newLoader(input string) (Loader, error) { + if input == "-" { + return &StdinLoader{}, nil + } + + fi, err := os.Stat(input) + if err != nil { + return nil, errors.Wrap(err, "cannot stat input source") + } + + if fi.IsDir() { + return &FolderLoader{path: input}, nil + } + + return &FileLoader{path: input}, nil +} + +// MultiLoader implements the Loader interface for reading from multiple other loaders. +type MultiLoader struct { + loaders []Loader +} + +// Load reads and merges the content from the loaders. +func (m *MultiLoader) Load() ([]*unstructured.Unstructured, error) { + var manifests []*unstructured.Unstructured + + for i, loader := range m.loaders { + output, err := loader.Load() + if err != nil { + return nil, errors.Wrap(err, fmt.Sprintf("cannot load source at position %d", i)) + } + + manifests = append(manifests, output...) + } + + return manifests, nil +} + +// StdinLoader implements the Loader interface for reading from stdin. +type StdinLoader struct{} + +// Load reads the contents from stdin. +func (s *StdinLoader) Load() ([]*unstructured.Unstructured, error) { + stream, err := YamlStream(os.Stdin) + if err != nil { + return nil, errors.Wrap(err, "cannot load YAML stream from stdin") + } + + return streamToUnstructured(stream) +} + +// FileLoader implements the Loader interface for reading from a file and converting input to unstructured objects. +type FileLoader struct { + path string +} + +// Load reads the contents from a file. +func (f *FileLoader) Load() ([]*unstructured.Unstructured, error) { + stream, err := readFile(f.path) + if err != nil { + return nil, errors.Wrap(err, "cannot read file") + } + + return streamToUnstructured(stream) +} + +// FolderLoader implements the Loader interface for reading from a folder. +type FolderLoader struct { + path string +} + +// Load reads the contents from all files in a folder. +func (f *FolderLoader) Load() ([]*unstructured.Unstructured, error) { + var stream [][]byte + + err := filepath.Walk(f.path, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if isYamlFile(info) { + s, err := readFile(path) + if err != nil { + return err + } + + stream = append(stream, s...) + } + + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "cannot read folder") + } + + return streamToUnstructured(stream) +} + +func isYamlFile(info os.FileInfo) bool { + return !info.IsDir() && (filepath.Ext(info.Name()) == ".yaml" || filepath.Ext(info.Name()) == ".yml") +} + +func readFile(path string) ([][]byte, error) { + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return nil, errors.Wrap(err, "cannot open file") + } + defer f.Close() //nolint:errcheck // Only open for reading. + + return YamlStream(f) +} + +// YamlStream loads a yaml stream from a reader into a 2d byte slice. +func YamlStream(r io.Reader) ([][]byte, error) { + stream := make([][]byte, 0) + + yr := yaml.NewYAMLReader(bufio.NewReader(r)) + + for { + bytes, err := yr.Read() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return nil, errors.Wrap(err, "cannot parse YAML stream") + } + + if len(bytes) == 0 { + continue + } + + stream = append(stream, bytes) + } + + return stream, nil +} + +func streamToUnstructured(stream [][]byte) ([]*unstructured.Unstructured, error) { + manifests := make([]*unstructured.Unstructured, 0, len(stream)) + + for _, y := range stream { + u := &unstructured.Unstructured{} + if err := yaml.Unmarshal(y, u); err != nil { + return nil, errors.Wrap(err, "cannot parse YAML manifest") + } + // extract pipeline input resources + if u.GetObjectKind().GroupVersionKind() == v1.CompositionGroupVersionKind { + // Convert the unstructured resource to a Composition + var comp v1.Composition + + err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.Object, &comp) + if err != nil { + return nil, errors.Wrap(err, "failed to convert unstructured to Composition") + } + // Iterate over each step in the pipeline + for _, step := range comp.Spec.Pipeline { + // Create a new resource based on the input (we can use it for validation) + if step.Input != nil && step.Input.Raw != nil { + var inputMap map[string]any + + err := json.Unmarshal(step.Input.Raw, &inputMap) + if err != nil { + return nil, errors.Wrap(err, "failed to unmarshal raw input") + } + + newInputResource := &unstructured.Unstructured{ + Object: inputMap, + } + // Add the input as new manifest to the manifests slice that we can validate + manifests = append(manifests, newInputResource) + } + } + } + + manifests = append(manifests, u) + } + + return manifests, nil +} + +// CompositeLoader acts as a composition of multiple loaders +// to handle loading resources from various sources at once. +type CompositeLoader struct { + loaders []Loader +} + +// NewCompositeLoader creates a new composite loader based on the specified sources. +// Sources can be files, directories, or "-" for stdin. +// If sources is empty, stdin is used by default. +func NewCompositeLoader(sources []string) (Loader, error) { + if len(sources) == 0 { + // In unit tests, this will cause an error when Load() is called + // which is the expected behavior for NoSources test case + return &CompositeLoader{loaders: []Loader{}}, nil + } + + // Create loaders for each source + loaders := make([]Loader, 0, len(sources)) + + // Check for duplicate stdin markers to avoid reading stdin multiple times + stdinUsed := false + + for _, source := range sources { + if source == "-" { + if stdinUsed { + // Skip duplicate stdin markers - only use stdin once + continue + } + stdinUsed = true + } + + loader, err := NewLoader(source) + if err != nil { + return nil, errors.Wrapf(err, "cannot create loader for %q", source) + } + loaders = append(loaders, loader) + } + + return &CompositeLoader{loaders: loaders}, nil +} + +// Load implements the Loader interface by loading from all contained loaders +// and combining the results. +func (c *CompositeLoader) Load() ([]*unstructured.Unstructured, error) { + if len(c.loaders) == 0 { + return nil, errors.New("no loaders configured") + } + + // Combine results from all loaders + var allResources []*unstructured.Unstructured + + for _, loader := range c.loaders { + resources, err := loader.Load() + if err != nil { + return nil, errors.Wrap(err, "cannot load resources from loader") + } + allResources = append(allResources, resources...) + } + + // Check if we found any resources + if len(allResources) == 0 { + return nil, errors.New("no resources found from any source") + } + + return allResources, nil +} diff --git a/cmd/crossplane/common/load/loader_test.go b/cmd/crossplane/common/load/loader_test.go new file mode 100644 index 0000000..acb1793 --- /dev/null +++ b/cmd/crossplane/common/load/loader_test.go @@ -0,0 +1,503 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package load + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +var ( + coolResource = map[string]any{ + "apiVersion": "example.org/v1alpha1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "resource-a", + }, + "name": "test-validate-a", + }, + "spec": map[string]any{ + "coolField": "I'm cool!", + }, + } + coolerResource = map[string]any{ + "apiVersion": "example.org/v1alpha1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "resource-b", + }, + "name": "test-validate-b", + }, + "spec": map[string]any{ + "coolerField": "I'm cooler!", + }, + } +) + +func TestNewLoader(t *testing.T) { + type args struct { + input string + } + + type want struct { + loader Loader + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "SucessWithStdin": { + reason: "Successfully create loader from stdin", + args: args{ + input: "-", + }, + want: want{ + loader: &StdinLoader{}, + }, + }, + "SucessWithFile": { + reason: "Successfully create loader from file", + args: args{ + input: "testdata/resources.yaml", + }, + want: want{ + loader: &FileLoader{path: "testdata/resources.yaml"}, + }, + }, + "SucessWithDirectory": { + reason: "Successfully create loader from directory", + args: args{ + input: "testdata/folder", + }, + want: want{ + loader: &FolderLoader{path: "testdata/folder"}, + }, + }, + "SucessWithMultiple": { + reason: "Successfully create loader from multiple sources", + args: args{ + input: "testdata/resources.yaml,testdata/folder", + }, + want: want{ + loader: &MultiLoader{loaders: []Loader{ + &FileLoader{path: "testdata/resources.yaml"}, + &FolderLoader{path: "testdata/folder"}, + }}, + }, + }, + "ErrorWithFile": { + reason: "Error creating loader from file that does not exist", + args: args{ + input: "testdata/does-not-exist.yaml", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "ErrorWithFolder": { + reason: "Error creating loader from folder that does not exist", + args: args{ + input: "testdata/does-not-exist", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "ErrorWithMultiple": { + reason: "Error creating loader from multiple sources that does not exist", + args: args{ + input: "testdata/does-not-exist.yaml,testdata/does-not-exist", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := NewLoader(tc.args.input) + if diff := cmp.Diff( + tc.want.loader, + got, + cmpopts.IgnoreUnexported(FileLoader{}, FolderLoader{}, MultiLoader{}), + ); diff != "" { + t.Errorf("%s\nLoad(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nLoad(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestMultiLoaderLoad(t *testing.T) { + type args struct { + loaders []Loader + } + + type want struct { + resources []*unstructured.Unstructured + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Success": { + reason: "Successfully load resources from file and folder loaders", + args: args{ + loaders: []Loader{ + &FileLoader{ + path: "testdata/resources.yaml", + }, + &FolderLoader{ + path: "testdata/folder", + }, + }, + }, + want: want{ + resources: []*unstructured.Unstructured{ + { + Object: coolResource, + }, + { + Object: coolerResource, + }, + { + Object: coolResource, + }, + { + Object: coolerResource, + }, + }, + }, + }, + "Error": { + reason: "Error loading resources from invalid loader", + args: args{ + []Loader{ + &FileLoader{ + path: "testdata/does-not-exist.yaml", + }, + }, + }, + want: want{ + resources: nil, + err: cmpopts.AnyError, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := &MultiLoader{ + loaders: tc.args.loaders, + } + + got, err := f.Load() + if diff := cmp.Diff(tc.want.resources, got); diff != "" { + t.Errorf("%s\nLoad(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nLoad(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestFileLoaderLoad(t *testing.T) { + type args struct { + Path string + } + + type want struct { + resources []*unstructured.Unstructured + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Success": { + reason: "Successfully load resources from file", + args: args{ + Path: "testdata/resources.yaml", + }, + want: want{ + resources: []*unstructured.Unstructured{ + { + Object: coolResource, + }, + { + Object: coolerResource, + }, + }, + }, + }, + "Error": { + reason: "Error loading resources from file", + args: args{ + Path: "testdata/does-not-exist.yaml", + }, + want: want{ + resources: nil, + err: cmpopts.AnyError, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := &FileLoader{ + path: tc.args.Path, + } + + got, err := f.Load() + if diff := cmp.Diff(tc.want.resources, got); diff != "" { + t.Errorf("%s\nLoad(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nLoad(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestFolderLoaderLoad(t *testing.T) { + type args struct { + Path string + } + + type want struct { + resources []*unstructured.Unstructured + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Success": { + reason: "Successfully load resources from folder", + args: args{ + Path: "testdata/folder", + }, + want: want{ + resources: []*unstructured.Unstructured{ + { + Object: coolResource, + }, + { + Object: coolerResource, + }, + }, + }, + }, + "Error": { + reason: "Error loading resources from folder", + args: args{ + Path: "testdata/does-not-exist", + }, + want: want{ + resources: nil, + err: cmpopts.AnyError, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := &FolderLoader{ + path: tc.args.Path, + } + + got, err := f.Load() + if diff := cmp.Diff(tc.want.resources, got); diff != "" { + t.Errorf("%s\nLoad(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nLoad(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} + +func TestStreamToUnstructured(t *testing.T) { + type args struct { + stream [][]byte + } + + type want struct { + resources []*unstructured.Unstructured + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Success": { + reason: "Successfully parse stream to unstructured resources", + args: args{ + stream: [][]byte{ + []byte("apiVersion: v1\nkind: Pod\nmetadata:\n name: test"), + }, + }, + want: want{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "v1", + "kind": "Pod", + "metadata": map[string]any{ + "name": "test", + }, + }, + }, + }, + }, + }, + "Error": { + reason: "Error parsing stream to unstructured resources", + args: args{ + stream: [][]byte{ + []byte("this is not a yaml"), + }, + }, + want: want{ + resources: nil, + err: cmpopts.AnyError, + }, + }, + "CompositionWithPipelineResources": { + reason: "Successfully parse Composition with pipeline input resources to unstructured resources", + args: args{ + stream: [][]byte{ + []byte(` +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: example-composition +spec: + compositeTypeRef: + apiVersion: example.crossplane.io/v1alpha1 + kind: ExampleComposite + pipeline: + - step: patch-and-transform + functionRef: + name: example-function + input: + apiVersion: pt.fn.crossplane.io/v1beta1 + kind: Resources + resources: + - name: instanceNodeRole + base: + apiVersion: iam.aws.crossplane.io/v1beta1 + kind: Role + spec: {} +`), + }, + }, + want: want{ + resources: []*unstructured.Unstructured{ + { + Object: map[string]any{ + "apiVersion": "pt.fn.crossplane.io/v1beta1", + "kind": "Resources", + "resources": []any{ + map[string]any{ + "name": "instanceNodeRole", + "base": map[string]any{ + "apiVersion": "iam.aws.crossplane.io/v1beta1", + "kind": "Role", + "spec": map[string]any{}, + }, + }, + }, + }, + }, + { + Object: map[string]any{ + "apiVersion": "apiextensions.crossplane.io/v1", + "kind": "Composition", + "metadata": map[string]any{ + "name": "example-composition", + }, + "spec": map[string]any{ + "compositeTypeRef": map[string]any{ + "apiVersion": "example.crossplane.io/v1alpha1", + "kind": "ExampleComposite", + }, + "pipeline": []any{ + map[string]any{ + "step": "patch-and-transform", + "functionRef": map[string]any{ + "name": "example-function", + }, + "input": map[string]any{ + "apiVersion": "pt.fn.crossplane.io/v1beta1", + "kind": "Resources", + "resources": []any{ + map[string]any{ + "name": "instanceNodeRole", + "base": map[string]any{ + "apiVersion": "iam.aws.crossplane.io/v1beta1", + "kind": "Role", + "spec": map[string]any{}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + err: nil, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := streamToUnstructured(tc.args.stream) + if diff := cmp.Diff(tc.want.resources, got); diff != "" { + t.Errorf("%s\nstreamToUnstructured(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("%s\nstreamToUnstructured(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/common/load/testdata/folder/nested-folder/resource-a.yaml b/cmd/crossplane/common/load/testdata/folder/nested-folder/resource-a.yaml new file mode 100644 index 0000000..d03f240 --- /dev/null +++ b/cmd/crossplane/common/load/testdata/folder/nested-folder/resource-a.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-validate-a + annotations: + crossplane.io/composition-resource-name: resource-a +spec: + coolField: "I'm cool!" diff --git a/cmd/crossplane/common/load/testdata/folder/resource-b.yaml b/cmd/crossplane/common/load/testdata/folder/resource-b.yaml new file mode 100644 index 0000000..dfacc4a --- /dev/null +++ b/cmd/crossplane/common/load/testdata/folder/resource-b.yaml @@ -0,0 +1,9 @@ +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-validate-b + annotations: + crossplane.io/composition-resource-name: resource-b +spec: + coolerField: "I'm cooler!" \ No newline at end of file diff --git a/cmd/crossplane/common/load/testdata/resources.yaml b/cmd/crossplane/common/load/testdata/resources.yaml new file mode 100644 index 0000000..bd542c4 --- /dev/null +++ b/cmd/crossplane/common/load/testdata/resources.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-validate-a + annotations: + crossplane.io/composition-resource-name: resource-a +spec: + coolField: "I'm cool!" +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-validate-b + annotations: + crossplane.io/composition-resource-name: resource-b +spec: + coolerField: "I'm cooler!" \ No newline at end of file diff --git a/cmd/crossplane/common/load/testutils/mocks.go b/cmd/crossplane/common/load/testutils/mocks.go new file mode 100644 index 0000000..3772657 --- /dev/null +++ b/cmd/crossplane/common/load/testutils/mocks.go @@ -0,0 +1,15 @@ +// Package testutils is for test utilities. +package testutils + +import un "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + +// MockLoader represents a mock Loader for testing. +type MockLoader struct { + Resources []*un.Unstructured + Err error +} + +// Load returns the resources and/or error on this mock. +func (m *MockLoader) Load() ([]*un.Unstructured, error) { + return m.Resources, m.Err +} diff --git a/cmd/crossplane/common/loggerwriter/logger_writer.go b/cmd/crossplane/common/loggerwriter/logger_writer.go new file mode 100644 index 0000000..63f2cd9 --- /dev/null +++ b/cmd/crossplane/common/loggerwriter/logger_writer.go @@ -0,0 +1,38 @@ +// Package loggerwriter provides an io.Writer implementation that writes to a crossplane logger. +package loggerwriter + +import ( + "strings" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" +) + +// LoggerWriter is an io.Writer implementation that writes to a logger. +type LoggerWriter struct { + logger logging.Logger + level int +} + +// NewLoggerWriter creates a new LoggerWriter that sends output to the given logger. +func NewLoggerWriter(logger logging.Logger) *LoggerWriter { + return &LoggerWriter{ + logger: logger, + level: 0, // Info level by default + } +} + +// Write implements io.Writer.Write by sending the data to the logger. +func (w *LoggerWriter) Write(p []byte) (n int, err error) { + // Convert to string and trim trailing newlines + message := strings.TrimSuffix(string(p), "\n") + if message != "" { + w.logger.Debug(message) + } + return len(p), nil +} + +// WithLevel allows setting a specific logging level. +func (w *LoggerWriter) WithLevel(level int) *LoggerWriter { + w.level = level + return w +} diff --git a/cmd/crossplane/common/package.go b/cmd/crossplane/common/package.go new file mode 100644 index 0000000..d4a0cba --- /dev/null +++ b/cmd/crossplane/common/package.go @@ -0,0 +1,2 @@ +// Package common provides functions and helpers shared between CLI utilities, available for import by external tools interacting with Crossplane. +package common //nolint:revive // TODO(adamwg): Move these packages into pkg/ or internal/. diff --git a/cmd/crossplane/common/resource/client.go b/cmd/crossplane/common/resource/client.go new file mode 100644 index 0000000..75fe447 --- /dev/null +++ b/cmd/crossplane/common/resource/client.go @@ -0,0 +1,72 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package resource + +import ( + "context" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "sigs.k8s.io/controller-runtime/pkg/client" + + xpmeta "github.com/crossplane/crossplane-runtime/v2/pkg/meta" +) + +// TreeClient is the interface to get a Resource with all its children. +type TreeClient interface { + GetResourceTree(ctx context.Context, root *Resource) (*Resource, error) +} + +// GetResource returns the requested Resource, setting any error as Resource.Error. +func GetResource(ctx context.Context, client client.Client, ref *v1.ObjectReference) *Resource { + result := unstructured.Unstructured{} + result.SetGroupVersionKind(ref.GroupVersionKind()) + + err := client.Get(ctx, xpmeta.NamespacedNameOf(ref), &result) + if err != nil { + // If the resource is not found, we still want to return a Resource + // object with the name and namespace set, so that the caller can + // still use it. + result.SetName(ref.Name) + result.SetNamespace(ref.Namespace) + } + + return &Resource{Unstructured: result, Error: err} +} + +// ListResources returns requested Resources matching the references, setting any error as Resource.Error. +func ListResources(ctx context.Context, c client.Client, ref *v1.ObjectReference) *ResourceList { + result := unstructured.UnstructuredList{} + result.SetGroupVersionKind(ref.GroupVersionKind()) + + var listOptions []client.ListOption + if ref.Namespace != "" { + listOptions = append(listOptions, client.InNamespace(ref.Namespace)) + } + err := c.List(ctx, &result, listOptions...) + resources := make([]*Resource, 0, len(result.Items)) + for i := range result.Items { + resources = append(resources, &Resource{ + Unstructured: result.Items[i], + }) + } + + return &ResourceList{ + Items: resources, + Error: err, + } +} diff --git a/cmd/crossplane/common/resource/resource.go b/cmd/crossplane/common/resource/resource.go new file mode 100644 index 0000000..f959cb0 --- /dev/null +++ b/cmd/crossplane/common/resource/resource.go @@ -0,0 +1,59 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package resource contains the definition of the Resource used by all trace +// printers, and the client used to get a Resource and its children. +package resource + +import ( + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" +) + +// Resource struct represents a kubernetes resource. +type Resource struct { + Unstructured unstructured.Unstructured `json:"object"` + Error error `json:"error,omitempty"` + Children []*Resource `json:"children,omitempty"` +} + +// ResourceList struct represents a list of kubernetes resources. +// revive:disable-next-line:exported For consistency with Resource. +type ResourceList struct { + Items []*Resource `json:"items"` + Error error `json:"error,omitempty"` +} + +// GetCondition of this resource. +func (r *Resource) GetCondition(ct xpv2.ConditionType) xpv2.Condition { + conditioned := xpv2.ConditionedStatus{} + // The path is directly `status` because conditions are inline. + if err := fieldpath.Pave(r.Unstructured.Object).GetValueInto("status", &conditioned); err != nil { + return xpv2.Condition{} + } + // We didn't use xpv1.CondidionedStatus.GetCondition because that's defaulting the + // status to unknown if the condition is not found at all. + for _, c := range conditioned.Conditions { + if c.Type == ct { + return c + } + } + + return xpv2.Condition{} +} diff --git a/cmd/crossplane/common/resource/xpkg/client.go b/cmd/crossplane/common/resource/xpkg/client.go new file mode 100644 index 0000000..f806bdc --- /dev/null +++ b/cmd/crossplane/common/resource/xpkg/client.go @@ -0,0 +1,340 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "slices" + + pkgname "github.com/google/go-containerregistry/pkg/name" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/types" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath" + xpresource "github.com/crossplane/crossplane-runtime/v2/pkg/resource" + xpunstructured "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +// Client to get a Package with all its dependencies. +type Client struct { + dependencyOutput DependencyOutput + revisionOutput RevisionOutput + includePackageRuntimeConfig bool + + client client.Client +} + +// ClientOption is a functional option for a Client. +type ClientOption func(*Client) + +// WithDependencyOutput is a functional option that configures how the client should output dependencies. +func WithDependencyOutput(do DependencyOutput) ClientOption { + return func(c *Client) { + c.dependencyOutput = do + } +} + +// WithRevisionOutput is a functional option that configures how the client should output revisions. +func WithRevisionOutput(ro RevisionOutput) ClientOption { + return func(c *Client) { + c.revisionOutput = ro + } +} + +// WithPackageRuntimeConfigs is a functional option that configures if the client +// should include the package runtime config as a child. +func WithPackageRuntimeConfigs(v bool) ClientOption { + return func(c *Client) { + c.includePackageRuntimeConfig = v + } +} + +// NewClient returns a new Client. +func NewClient(in client.Client, opts ...ClientOption) (*Client, error) { + uClient := xpunstructured.NewClient(in) + + c := &Client{ + client: uClient, + } + + for _, o := range opts { + o(c) + } + + return c, nil +} + +// GetResourceTree returns the requested package Resource and all its children. +func (kc *Client) GetResourceTree(ctx context.Context, root *resource.Resource) (*resource.Resource, error) { + var err error + + if !IsPackageType(root.Unstructured.GroupVersionKind().GroupKind()) { + return nil, errors.Errorf("resource %s is not a package", root.Unstructured.GetName()) + } + + // the root is a package type, get the lock file now + lock := &pkgv1beta1.Lock{} + if err := kc.client.Get(ctx, types.NamespacedName{Name: "lock"}, lock); err != nil { + return nil, err + } + + // Set up a FIFO queue to traverse the resource tree breadth first. + queue := []*resource.Resource{root} + + uniqueDeps := map[string]struct{}{} + + for len(queue) > 0 { + // Pop the first element from the queue. + res := queue[0] + queue = queue[1:] + + if !IsPackageType(res.Unstructured.GroupVersionKind().GroupKind()) { + return nil, errors.Errorf("resource %s is not a package: %s", res.Unstructured.GetName(), res.Unstructured.GroupVersionKind().GroupKind()) + } + + // Set the package runtime config as a child if we want to show it + kc.setPackageRuntimeConfigChild(ctx, res) + + // Set the revisions for the current package and add them as children + if err := kc.setChildrenRevisions(ctx, res); err != nil { + return nil, errors.Wrapf(err, "failed to set package revision children for package %s", res.Unstructured.GetName()) + } + + refs := make([]v1.ObjectReference, 0) + + if kc.dependencyOutput != DependencyOutputNone { + refs, err = kc.getPackageDeps(ctx, res, lock, uniqueDeps) + if err != nil { + return nil, errors.Wrapf(err, "failed to get dependencies for package %s", res.Unstructured.GetName()) + } + } + + for i := range refs { + child := resource.GetResource(ctx, kc.client, &refs[i]) + + res.Children = append(res.Children, child) + queue = append(queue, child) + } + } + + return root, nil +} + +func (kc *Client) setPackageRuntimeConfigChild(ctx context.Context, res *resource.Resource) { + if !kc.includePackageRuntimeConfig { + return + } + + runtimeConfigRef := pkgv1.RuntimeConfigReference{} + if err := fieldpath.Pave(res.Unstructured.Object).GetValueInto("spec.runtimeConfigRef", &runtimeConfigRef); err == nil { + res.Children = append(res.Children, resource.GetResource(ctx, kc.client, &v1.ObjectReference{ + APIVersion: *runtimeConfigRef.APIVersion, + Kind: *runtimeConfigRef.Kind, + Name: runtimeConfigRef.Name, + })) + } +} + +func (kc *Client) setChildrenRevisions(ctx context.Context, res *resource.Resource) (err error) { + // Nothing to do if we don't want to show revisions + if kc.revisionOutput == RevisionOutputNone { + return nil + } + + revisions, err := kc.getRevisions(ctx, res) + if err != nil { + return errors.Wrapf(err, "failed to get revisions for package %s", res.Unstructured.GetName()) + } + + // add the current revision as a child of the current node if the revision output says we should + for _, r := range revisions { + state, _ := fieldpath.Pave(r.Unstructured.Object).GetString("spec.desiredState") + switch pkgv1.PackageRevisionDesiredState(state) { + case pkgv1.PackageRevisionActive: + res.Children = append(res.Children, r) + case pkgv1.PackageRevisionInactive: + if kc.revisionOutput == RevisionOutputAll { + res.Children = append(res.Children, r) + } + } + } + + return nil +} + +// getRevisions gets the revisions for the given package. +func (kc *Client) getRevisions(ctx context.Context, xpkg *resource.Resource) ([]*resource.Resource, error) { + revisions := &unstructured.UnstructuredList{} + + switch gvk := xpkg.Unstructured.GroupVersionKind(); gvk.GroupKind() { + case pkgv1.ProviderGroupVersionKind.GroupKind(): + revisions.SetGroupVersionKind(pkgv1.ProviderRevisionGroupVersionKind) + case pkgv1.ConfigurationGroupVersionKind.GroupKind(): + revisions.SetGroupVersionKind(pkgv1.ConfigurationRevisionGroupVersionKind) + case pkgv1.FunctionGroupVersionKind.GroupKind(): + revisions.SetGroupVersionKind(pkgv1.FunctionRevisionGroupVersionKind) + default: + // If we didn't match any of the know types, we try to guess + revisions.SetGroupVersionKind(gvk.GroupVersion().WithKind(gvk.Kind + "RevisionList")) + } + + if err := kc.client.List(ctx, revisions, client.MatchingLabels(map[string]string{pkgv1.LabelParentPackage: xpkg.Unstructured.GetName()})); xpresource.IgnoreNotFound(err) != nil { + return nil, err + } + // Sort the revisions by creation timestamp to have a stable output + slices.SortFunc(revisions.Items, func(i, j unstructured.Unstructured) int { + return i.GetCreationTimestamp().Compare(j.GetCreationTimestamp().Time) + }) + + resources := make([]*resource.Resource, 0, len(revisions.Items)) + for i := range revisions.Items { + resources = append(resources, &resource.Resource{Unstructured: revisions.Items[i]}) + } + + return resources, nil +} + +// getDependencyRef returns the dependency reference for the given package, +// based on the lock file. +func (kc *Client) getDependencyRef(ctx context.Context, d pkgv1beta1.Dependency, pkgs []pkgv1beta1.LockPackage) (*v1.ObjectReference, error) { + // if we don't find a package to match the current dependency, which + // can happen during initial installation when dependencies are + // being discovered and fetched. We'd still like to show something + // though, so try to make the package name pretty + name := xpkg.ToDNSLabel(d.Package) + if pkgref, err := pkgname.ParseReference(d.Package); err == nil { + name = xpkg.ToDNSLabel(pkgref.Context().RepositoryStr()) + } + + // NOTE: everything in the lock file is basically a package revision + // - pkgrev A + // - dependency: pkgrev B + // - dependency: pkgrev C + // - pkgrev B + // - pkgrev C + + rev := &unstructured.Unstructured{} + + var pkgKind string + + switch { + case d.APIVersion != nil && d.Kind != nil: + rev.SetAPIVersion(*d.APIVersion) + rev.SetKind(*d.Kind + "Revision") + pkgKind = *d.Kind + case ptr.Deref(d.Type, "") == pkgv1beta1.ConfigurationPackageType: + rev.SetAPIVersion(pkgv1.ConfigurationRevisionGroupVersionKind.GroupVersion().String()) + rev.SetKind(pkgv1.ConfigurationRevisionKind) + pkgKind = pkgv1.ConfigurationKind + case ptr.Deref(d.Type, "") == pkgv1beta1.ProviderPackageType: + rev.SetAPIVersion(pkgv1.ProviderRevisionGroupVersionKind.GroupVersion().String()) + rev.SetKind(pkgv1.ProviderRevisionKind) + pkgKind = pkgv1.ProviderKind + case ptr.Deref(d.Type, "") == pkgv1beta1.FunctionPackageType: + rev.SetAPIVersion(pkgv1.FunctionRevisionGroupVersionKind.GroupVersion().String()) + rev.SetKind(pkgv1.FunctionRevisionKind) + pkgKind = pkgv1.FunctionKind + default: + return nil, errors.Errorf("cannot determine dependency type - you must specify either a valid type, or an explicit apiVersion and kind") + } + + for _, p := range pkgs { + if p.Source != d.Package { + continue + } + + // current package source matches the package of the dependency, let's get the full object + if err := kc.client.Get(ctx, types.NamespacedName{Name: p.Name}, rev); xpresource.IgnoreNotFound(err) != nil { + return nil, err + } + + // look for the owner of this package revision, that's its parent package + for _, or := range rev.GetOwnerReferences() { + if or.Kind == pkgKind && or.Controller != nil && *or.Controller { + name = or.Name + break + } + } + + break + } + + return &v1.ObjectReference{ + APIVersion: rev.GetAPIVersion(), + Kind: pkgKind, + Name: name, + }, nil +} + +// getPackageDeps returns the dependencies for the given package resource. +func (kc *Client) getPackageDeps(ctx context.Context, node *resource.Resource, lock *pkgv1beta1.Lock, uniqueDeps map[string]struct{}) ([]v1.ObjectReference, error) { + cr, _ := fieldpath.Pave(node.Unstructured.Object).GetString("status.currentRevision") + if cr == "" { + // we don't have a current package revision, so just return empty deps + return nil, nil + } + + // find the lock file entry for the current revision + var lp *pkgv1beta1.LockPackage + + for i := range lock.Packages { + if lock.Packages[i].Name == cr { + lp = &lock.Packages[i] + break + } + } + + if lp == nil { + // the current revision for this package isn't in the lock file yet, + // so just return empty deps + return nil, nil + } + + // iterate over all dependencies of the package to get full references to them + depRefs := make([]v1.ObjectReference, 0) + + for _, d := range lp.Dependencies { + if kc.dependencyOutput == DependencyOutputUnique { + if _, ok := uniqueDeps[d.Package]; ok { + // we are supposed to only show unique dependencies, and we've seen this one already in the tree, skip it + continue + } + } + + dep, err := kc.getDependencyRef(ctx, d, lock.Packages) + if err != nil { + return nil, errors.Wrapf(err, "failed to get dependency ref %s", d.Package) + } + + depRefs = append(depRefs, *dep) + + // track this dependency in the unique dependency map + uniqueDeps[d.Package] = struct{}{} + } + + return depRefs, nil +} diff --git a/cmd/crossplane/common/resource/xpkg/client_test.go b/cmd/crossplane/common/resource/xpkg/client_test.go new file mode 100644 index 0000000..fa3b1fb --- /dev/null +++ b/cmd/crossplane/common/resource/xpkg/client_test.go @@ -0,0 +1,421 @@ +package xpkg + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + v1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + xpv1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + xpkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +// TODO add more cases, fake client +// Consider testing getPackageDeps instead to cover more. +func TestGetDependencyRef(t *testing.T) { + type args struct { + d v1beta1.Dependency + pkgs []v1beta1.LockPackage + } + + type want struct { + ref *v1.ObjectReference + err error + } + + cases := map[string]struct { + reason string + + client client.Client + args args + want want + }{ + "PkgNotInLock": { + reason: "Should return the provider ref for a provider dependency, even when the dep is not found.", + client: &test.MockClient{}, + args: args{ + d: v1beta1.Dependency{ + Type: ptr.To(v1beta1.ProviderPackageType), + Package: "example.com/provider-1:v1.0.0", + }, + pkgs: []v1beta1.LockPackage{ + *buildLockPkg("configuration-1", + withDependencies(newDependency("provider-2"), newDependency("provider-1")), + withSource("example.com/configuration-1:v1.0.0")), + *buildLockPkg("function-1", + withDependencies(newDependency("provider-3"), newDependency("provider-4")), + withSource("example.com/function-1:v1.0.0")), + }, + }, + want: want{ + ref: &v1.ObjectReference{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Provider", + Name: "provider-1", + }, + }, + }, + "PKGInLock": { + reason: "Should return the provider ref for a provider dependency.", + client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + obj.SetName("provider-1") + obj.SetOwnerReferences([]xpv1.OwnerReference{ + { + APIVersion: "pkg.crossplane.io/v1", + Kind: "Provider", + Name: "my-awesome-provider", + Controller: ptr.To(true), + }, + }) + return nil + }), + }, + args: args{ + d: v1beta1.Dependency{ + Type: ptr.To(v1beta1.ProviderPackageType), + Package: "example.com/provider-1:v1.0.0", + }, + pkgs: []v1beta1.LockPackage{ + *buildLockPkg("provider-3", + withDependencies(newDependency("provider-2"), newDependency("provider-1")), + withSource("example.com/provider-1:v1.0.0")), + *buildLockPkg("function-1", + withDependencies(newDependency("provider-3"), newDependency("provider-4")), + withSource("example.com/function-1:v1.0.0")), + }, + }, + want: want{ + ref: &v1.ObjectReference{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Provider", + Name: "my-awesome-provider", + }, + }, + }, + "PKGTypeWrong": { + reason: "Should return an error for a provider dependency when the package type is wrong.", + client: test.NewMockClient(), + args: args{ + d: v1beta1.Dependency{ + Type: ptr.To(v1beta1.PackageType("wrong")), + Package: "example.com/provider-1:v1.0.0", + }, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "ErrorGettingPKGRevision": { + reason: "Should return an error for a provider dependency when the package revision cannot be retrieved.", + client: &test.MockClient{ + MockGet: test.NewMockGetFn(errors.New("boom")), + }, + args: args{ + d: v1beta1.Dependency{ + Type: ptr.To(v1beta1.ConfigurationPackageType), + Package: "example.com/configuration-1:v1.0.0", + }, + pkgs: []v1beta1.LockPackage{ + *buildLockPkg("configuration-1", + withDependencies(newDependency("provider-2"), newDependency("provider-1")), + withSource("example.com/configuration-1:v1.0.0")), + *buildLockPkg("function-1", + withDependencies(newDependency("provider-3"), newDependency("provider-4")), + withSource("example.com/function-1:v1.0.0")), + }, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "PKGRevisionNotFound": { + reason: "Should return no error for a provider dependency when the package revision is not found.", + client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(_ client.Object) error { + return kerrors.NewNotFound(schema.GroupResource{}, "whatever") + }), + }, + args: args{ + d: v1beta1.Dependency{ + Type: ptr.To(v1beta1.FunctionPackageType), + Package: "example.com/function-1:v1.0.0", + }, + pkgs: []v1beta1.LockPackage{ + *buildLockPkg("configuration-1", + withDependencies(newDependency("provider-2"), newDependency("provider-1")), + withSource("example.com/configuration-1:v1.0.0")), + *buildLockPkg("function-1", + withDependencies(newDependency("provider-3"), newDependency("provider-4")), + withSource("example.com/function-1:v1.0.0")), + }, + }, + want: want{ + err: nil, + ref: &v1.ObjectReference{ + APIVersion: "pkg.crossplane.io/v1", + Kind: "Function", + Name: "function-1", + }, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + kc := &Client{ + client: tc.client, + } + + got, err := kc.getDependencyRef(context.Background(), tc.args.d, tc.args.pkgs) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("getDependencyRef(...) error = %v, wantErr %v", err, tc.want.err) + } + + if diff := cmp.Diff(tc.want.ref, got); diff != "" { + t.Errorf("\n%s\ngetDependencyRef(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestGetPackageDeps(t *testing.T) { + type args struct { + client client.Client + dependencyOutput DependencyOutput + + node *resource.Resource + lock *v1beta1.Lock + uniqueDeps map[string]struct{} + } + + type want struct { + deps []v1.ObjectReference + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "NoCurrentRevision": { + reason: "Should return no error when the current revision cannot be retrieved.", + args: args{ + client: &test.MockClient{}, + node: &resource.Resource{ + Unstructured: unstructured.Unstructured{ + Object: nil, + }, + }, + }, + want: want{ + err: nil, + deps: nil, + }, + }, + "NotInLockYet": { + reason: "Should return no error when the current revision is not in the lock yet.", + args: args{ + client: &test.MockClient{}, + node: &resource.Resource{ + Unstructured: unstructured.Unstructured{ + Object: map[string]any{ + "status": map[string]any{ + "currentRevision": "provider-revision-1", + }, + }, + }, + }, + lock: buildLock("lock-1"), + }, + want: want{ + err: nil, + deps: nil, + }, + }, + "WantUniqueAndPresent": { + reason: "Should return no error when the unique dependencies are already present.", + args: args{ + client: &test.MockClient{}, + dependencyOutput: DependencyOutputUnique, + node: &resource.Resource{ + Unstructured: unstructured.Unstructured{ + Object: map[string]any{ + "status": map[string]any{ + "currentRevision": "provider-revision-1", + }, + }, + }, + }, + lock: buildLock("lock-1", withLockPackages([]v1beta1.LockPackage{ + *buildLockPkg("provider-revision-1", + withDependencies(newDependency("provider-2"), newDependency("provider-1")), + withSource("example.com/provider-1:v1.0.0")), + *buildLockPkg("provider-2", + withDependencies(newDependency("provider-3"), newDependency("provider-4")), + withSource("example.com/provider-2:v1.0.0")), + }...)), + uniqueDeps: map[string]struct{}{ + "provider-1": {}, + "provider-2": {}, + }, + }, + want: want{ + err: nil, + deps: nil, + }, + }, + "WantUniqueNotPresent": { + reason: "Should return the right dependencies when the unique dependencies are not already present.", + args: args{ + client: &test.MockClient{ + MockGet: test.NewMockGetFn(nil, func(obj client.Object) error { + u, ok := obj.(*unstructured.Unstructured) + if ok && u.GetKind() == "ProviderRevision" { + u.SetOwnerReferences([]xpv1.OwnerReference{ + { + APIVersion: xpkgv1.ProviderGroupVersionKind.GroupVersion().String(), + Kind: xpkgv1.ProviderKind, + Name: "my-awesome-provider", + Controller: ptr.To(true), + }, + }) + } + return nil + }), + }, + dependencyOutput: DependencyOutputUnique, + node: &resource.Resource{ + Unstructured: unstructured.Unstructured{ + Object: map[string]any{ + "status": map[string]any{ + "currentRevision": "provider-revision-0", + }, + }, + }, + }, + lock: buildLock("lock-1", withLockPackages([]v1beta1.LockPackage{ + *buildLockPkg("provider-revision-0", + withDependencies(newDependency("example.com/provider-1:v1.0.0", withPackageType(v1beta1.ProviderPackageType))), + withSource("example.com/provider-0:v1.0.0")), + *buildLockPkg("provider-revision-1", + withDependencies(newDependency("example.com/provider-2:v1.0.0", withPackageType(v1beta1.ProviderPackageType)), newDependency("example.com/provider-3:v1.0.0", withPackageType(v1beta1.ProviderPackageType))), + withSource("example.com/provider-1:v1.0.0")), + *buildLockPkg("provider-2", + withDependencies(newDependency("example.com/provider-3:v1.0.0"), newDependency("example.com/provider-4:v1.0.0")), + withSource("example.com/provider-2:v1.0.0")), + *buildLockPkg("provider-3", + withDependencies(newDependency("example.com/provider-4:v1.0.0"), newDependency("example.com/provider-5:v1.0.0")), + withSource("example.com/provider-3:v1.0.0")), + }...)), + uniqueDeps: map[string]struct{}{}, + }, + want: want{ + err: nil, + deps: []v1.ObjectReference{ + { + APIVersion: xpkgv1.ProviderGroupVersionKind.GroupVersion().String(), + Kind: xpkgv1.ProviderKind, + Name: "my-awesome-provider", + }, + }, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + kc := &Client{ + client: tc.args.client, + dependencyOutput: tc.args.dependencyOutput, + } + + got, err := kc.getPackageDeps(context.Background(), tc.args.node, tc.args.lock, tc.args.uniqueDeps) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("getPackageDeps(...) error = %v, wantErr %v", err, tc.want.err) + } + + if diff := cmp.Diff(tc.want.deps, got, cmpopts.SortSlices(func(r1, r2 v1.ObjectReference) bool { + return strings.Compare(r1.String(), r2.String()) < 0 + }), cmpopts.EquateEmpty()); diff != "" { + t.Errorf("\n%s\ngetPackageDeps(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} + +type lockOpt func(c *v1beta1.Lock) + +func buildLock(name string, opts ...lockOpt) *v1beta1.Lock { + l := &v1beta1.Lock{} + l.SetName(name) + + for _, f := range opts { + f(l) + } + + return l +} + +func withLockPackages(pkgs ...v1beta1.LockPackage) lockOpt { + return func(l *v1beta1.Lock) { + l.Packages = pkgs + } +} + +type lockPkgOpt func(c *v1beta1.LockPackage) + +func buildLockPkg(name string, opts ...lockPkgOpt) *v1beta1.LockPackage { + p := &v1beta1.LockPackage{} + + p.Name = name + for _, f := range opts { + f(p) + } + + return p +} + +func withDependencies(deps ...v1beta1.Dependency) lockPkgOpt { + return func(p *v1beta1.LockPackage) { + p.Dependencies = deps + } +} + +func withSource(source string) lockPkgOpt { + return func(p *v1beta1.LockPackage) { + p.Source = source + } +} + +type dependencyOpts func(d *v1beta1.Dependency) + +func withPackageType(pkgType v1beta1.PackageType) dependencyOpts { + return func(d *v1beta1.Dependency) { + d.Type = ptr.To(pkgType) + } +} + +func newDependency(pkg string, opts ...dependencyOpts) v1beta1.Dependency { + d := v1beta1.Dependency{ + Package: pkg, + } + for _, f := range opts { + f(&d) + } + + return d +} diff --git a/cmd/crossplane/common/resource/xpkg/xpkg.go b/cmd/crossplane/common/resource/xpkg/xpkg.go new file mode 100644 index 0000000..6196b97 --- /dev/null +++ b/cmd/crossplane/common/resource/xpkg/xpkg.go @@ -0,0 +1,71 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package xpkg contains the client to get a Crossplane package with all its +// dependencies as a tree of Resource. +package xpkg + +import ( + "k8s.io/apimachinery/pkg/runtime/schema" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" +) + +// DependencyOutput defines the output of the dependency tree. +type DependencyOutput string + +const ( + // DependencyOutputUnique outputs only unique dependencies. + DependencyOutputUnique DependencyOutput = "unique" + // DependencyOutputAll outputs all dependencies. + DependencyOutputAll DependencyOutput = "all" + // DependencyOutputNone outputs no dependencies. + DependencyOutputNone DependencyOutput = "none" +) + +// RevisionOutput defines the output of the revision tree. +type RevisionOutput string + +const ( + // RevisionOutputActive outputs only active revisions. + RevisionOutputActive RevisionOutput = "active" + // RevisionOutputAll outputs all revisions. + RevisionOutputAll RevisionOutput = "all" + // RevisionOutputNone outputs no revisions. + RevisionOutputNone RevisionOutput = "none" +) + +// IsPackageType returns true if the GroupKind is a Crossplane package type. +func IsPackageType(gk schema.GroupKind) bool { + return gk == pkgv1.ProviderGroupVersionKind.GroupKind() || + gk == pkgv1.ConfigurationGroupVersionKind.GroupKind() || + gk == pkgv1.FunctionGroupVersionKind.GroupKind() +} + +// IsPackageRevisionType returns true if the GroupKind is a Crossplane package +// revision type. +func IsPackageRevisionType(gk schema.GroupKind) bool { + return gk == pkgv1.ConfigurationRevisionGroupVersionKind.GroupKind() || + gk == pkgv1.ProviderRevisionGroupVersionKind.GroupKind() || + gk == pkgv1.FunctionRevisionGroupVersionKind.GroupKind() +} + +// IsPackageRuntimeConfigType returns true if the GroupKind is a Crossplane runtime +// config type. +func IsPackageRuntimeConfigType(gk schema.GroupKind) bool { + return gk == pkgv1beta1.DeploymentRuntimeConfigGroupVersionKind.GroupKind() +} diff --git a/cmd/crossplane/common/resource/xpkg/xpkg_test.go b/cmd/crossplane/common/resource/xpkg/xpkg_test.go new file mode 100644 index 0000000..08a0a3d --- /dev/null +++ b/cmd/crossplane/common/resource/xpkg/xpkg_test.go @@ -0,0 +1,254 @@ +/* +Copyright 2024 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "testing" + + "k8s.io/apimachinery/pkg/runtime/schema" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" +) + +func TestIsPackageType(t *testing.T) { + type args struct { + gk schema.GroupKind + } + + type want struct { + ok bool + } + + tests := map[string]struct { + reason string + args args + want want + }{ + "V1ProviderOK": { + reason: "Should return true for a v1 Provider", + args: args{ + gk: pkgv1.ProviderGroupVersionKind.GroupKind(), + }, + want: want{ + ok: true, + }, + }, + "V1ConfigurationOK": { + reason: "Should return true for a v1 Configuration", + args: args{ + gk: pkgv1.ConfigurationGroupVersionKind.GroupKind(), + }, + want: want{ + ok: true, + }, + }, + "V1beta1FunctionOK": { + reason: "Should return true for a v1beta1 Function", + args: args{ + gk: pkgv1.FunctionGroupVersionKind.GroupKind(), + }, + want: want{ + ok: true, + }, + }, + "V1ProviderRevisionKO": { + reason: "Should return false for a v1 ProviderRevision", + args: args{ + gk: pkgv1.ProviderRevisionGroupVersionKind.GroupKind(), + }, + want: want{ + ok: false, + }, + }, + "V1ConfigurationRevisionKO": { + reason: "Should return false for a v1 ConfigurationRevision", + args: args{ + gk: pkgv1.ConfigurationRevisionGroupVersionKind.GroupKind(), + }, + want: want{ + ok: false, + }, + }, + "V1beta1FunctionRevisionKO": { + reason: "Should return false for a v1beta1 FunctionRevision", + args: args{ + gk: pkgv1.FunctionRevisionGroupVersionKind.GroupKind(), + }, + want: want{ + ok: false, + }, + }, + "ElseKO": { + reason: "Should return false for a random GK", + args: args{ + gk: schema.GroupKind{ + Group: "foo", + Kind: "bar", + }, + }, + want: want{ + ok: false, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := IsPackageType(tc.args.gk) + if got != tc.want.ok { + t.Errorf("%s\nIsPackageType() = %v, want %v", tc.reason, got, tc.want.ok) + } + }) + } +} + +func TestIsPackageRevisionType(t *testing.T) { + type args struct { + gk schema.GroupKind + } + + type want struct { + ok bool + } + + tests := map[string]struct { + reason string + args args + want want + }{ + "V1ProviderKO": { + reason: "Should return false for a v1 Provider", + args: args{ + gk: pkgv1.ProviderGroupVersionKind.GroupKind(), + }, + want: want{ + ok: false, + }, + }, + "V1ConfigurationKO": { + reason: "Should return false for a v1 Configuration", + args: args{ + gk: pkgv1.ConfigurationGroupVersionKind.GroupKind(), + }, + want: want{ + ok: false, + }, + }, + "V1beta1FunctionKO": { + reason: "Should return false for a v1beta1 Function", + args: args{ + gk: pkgv1.FunctionGroupVersionKind.GroupKind(), + }, + want: want{ + ok: false, + }, + }, + "V1ProviderRevisionOK": { + reason: "Should return true for a v1 ProviderRevision", + args: args{ + gk: pkgv1.ProviderRevisionGroupVersionKind.GroupKind(), + }, + want: want{ + ok: true, + }, + }, + "V1ConfigurationRevisionOK": { + reason: "Should return true for a v1 ConfigurationRevision", + args: args{ + gk: pkgv1.ConfigurationRevisionGroupVersionKind.GroupKind(), + }, + want: want{ + ok: true, + }, + }, + "V1beta1FunctionRevisionOK": { + reason: "Should return true for a v1beta1 FunctionRevision", + args: args{ + gk: pkgv1.FunctionRevisionGroupVersionKind.GroupKind(), + }, + want: want{ + ok: true, + }, + }, + "ElseKO": { + reason: "Should return false for a random GK", + args: args{ + gk: schema.GroupKind{ + Group: "foo", + Kind: "bar", + }, + }, + want: want{ + ok: false, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := IsPackageRevisionType(tc.args.gk) + if got != tc.want.ok { + t.Errorf("%s\nIsPackageRevisionType() = %v, want %v", tc.reason, got, tc.want.ok) + } + }) + } +} + +func TestIsPackageRuntimeConfigType(t *testing.T) { + type args struct { + gk schema.GroupKind + } + + type want struct { + ok bool + } + + tests := map[string]struct { + reason string + args args + want want + }{ + "V1Beta1DeploymentRuntimeConfigOK": { + reason: "Should return true for a v1beta1 DeploymentRuntimeConfig", + args: args{ + gk: pkgv1beta1.DeploymentRuntimeConfigGroupVersionKind.GroupKind(), + }, + want: want{ + ok: true, + }, + }, + "ElseKO": { + reason: "Should return false for a random GK", + args: args{ + gk: schema.GroupKind{ + Group: "foo", + Kind: "bar", + }, + }, + want: want{ + ok: false, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + got := IsPackageRuntimeConfigType(tc.args.gk) + if got != tc.want.ok { + t.Errorf("%s\nIsPackageRuntimeConfigType() = %v, want %v", tc.reason, got, tc.want.ok) + } + }) + } +} diff --git a/cmd/crossplane/common/resource/xrm/client.go b/cmd/crossplane/common/resource/xrm/client.go new file mode 100644 index 0000000..f1d854f --- /dev/null +++ b/cmd/crossplane/common/resource/xrm/client.go @@ -0,0 +1,182 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package xrm contains the client to get a Crossplane resource with all its +// children as a tree of Resource. +package xrm + +import ( + "context" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + "sigs.k8s.io/controller-runtime/pkg/client" + + xpunstructured "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/claim" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + "github.com/crossplane/crossplane/apis/v2/apiextensions/v1alpha1" + "github.com/crossplane/crossplane/apis/v2/apiextensions/v1beta1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +// defaultConcurrency is the concurrency using which the resource tree if loaded when not explicitly specified. +const defaultConcurrency = 5 + +// Client to get a Resource with all its children. +type Client struct { + getConnectionSecrets bool + + client client.Client + concurrency int +} + +// ResourceClientOption is a functional option for a Client. +type ResourceClientOption func(*Client) + +// WithConnectionSecrets is a functional option that sets the client to get secrets to the desired value. +func WithConnectionSecrets(v bool) ResourceClientOption { + return func(c *Client) { + c.getConnectionSecrets = v + } +} + +// WithConcurrency is a functional option that sets the concurrency for the resource load. +func WithConcurrency(n int) ResourceClientOption { + return func(c *Client) { + c.concurrency = n + } +} + +// NewClient returns a new Client. +func NewClient(in client.Client, opts ...ResourceClientOption) (*Client, error) { + uClient := xpunstructured.NewClient(in) + + c := &Client{ + client: uClient, + concurrency: defaultConcurrency, + } + + for _, o := range opts { + o(c) + } + + return c, nil +} + +// GetResourceTree returns the requested Crossplane Resource and all its children. +func (kc *Client) GetResourceTree(ctx context.Context, root *resource.Resource) (*resource.Resource, error) { + q := newLoader(root, kc, defaultChannelCapacity) + q.load(ctx, kc.concurrency) + + return root, nil +} + +// loadResource returns the resource for the specified object reference. +func (kc *Client) loadResource(ctx context.Context, ref *v1.ObjectReference) *resource.Resource { + return resource.GetResource(ctx, kc.client, ref) +} + +// getResourceChildrenRefs returns the references to the children for the given +// Resource, assuming it's a Crossplane resource, XR or XRC. +func (kc *Client) getResourceChildrenRefs(r *resource.Resource) []v1.ObjectReference { + return getResourceChildrenRefs(r, kc.getConnectionSecrets) +} + +// getResourceChildrenRefs returns the references to the children for the given +// Resource, assuming it's a Crossplane resource, XR or XRC. +func getResourceChildrenRefs(r *resource.Resource, getConnectionSecrets bool) []v1.ObjectReference { + obj := r.Unstructured + + switch obj.GroupVersionKind().GroupKind() { + case schema.GroupKind{Group: "", Kind: "Secret"}, + v1alpha1.UsageGroupVersionKind.GroupKind(), + v1beta1.EnvironmentConfigGroupVersionKind.GroupKind(): + // nothing to do here, it's a resource we know not to have any reference + return nil + } + + // collect object references for the + var refs []v1.ObjectReference + + // treat it like a claim and look for a XR ref + cm := claim.Unstructured{Unstructured: obj} + if ref := cm.GetResourceReference(); ref != nil { + // it is in fact a claim, grab the ref to its XR + refs = append(refs, v1.ObjectReference{ + APIVersion: ref.APIVersion, + Kind: ref.Kind, + Name: ref.Name, + Namespace: ptr.Deref(ref.Namespace, ""), + }) + + if getConnectionSecrets { + // grab any connection secret from the claim if it has one + if cmSecretRef := cm.GetWriteConnectionSecretToReference(); cmSecretRef != nil { + ref := v1.ObjectReference{ + APIVersion: "v1", + Kind: "Secret", + Name: cmSecretRef.Name, + Namespace: cm.GetNamespace(), + } + refs = append(refs, ref) + } + } + + // we're done, the only ref a claim would have is to its XR + return refs + } + + // treat it like a modern XR then grab all the references (this will no-op + // if it's not a modern XR). We don't try to get connection secrets here + // because modern XRs don't support them. + xr := composite.Unstructured{Schema: composite.SchemaModern, Unstructured: obj} + refs = append(refs, xr.GetResourceReferences()...) + + // treat it like a legacy XR then grab all the references (this will no-op + // if it's not a legacy XR), and any potential connection secret (only + // legacy XRs have connection secrets). + xr = composite.Unstructured{Schema: composite.SchemaLegacy, Unstructured: obj} + refs = append(refs, xr.GetResourceReferences()...) + + if getConnectionSecrets { + if xrSecretRef := xr.GetWriteConnectionSecretToReference(); xrSecretRef != nil { + ref := v1.ObjectReference{ + APIVersion: "v1", + Kind: "Secret", + Name: xrSecretRef.Name, + Namespace: xrSecretRef.Namespace, + } + refs = append(refs, ref) + } + } + + if ns := obj.GetNamespace(); ns != "" { + // the XR is namespaced, so it's references will not explicitly declare + // their namespaces (they are implicit). We need to infer it from the XR so + // we have a complete reference to return to the caller. + for i := range refs { + if refs[i].Namespace == "" { + refs[i].Namespace = ns + } + } + } + + return refs +} diff --git a/cmd/crossplane/common/resource/xrm/client_test.go b/cmd/crossplane/common/resource/xrm/client_test.go new file mode 100644 index 0000000..3b661d7 --- /dev/null +++ b/cmd/crossplane/common/resource/xrm/client_test.go @@ -0,0 +1,327 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xrm + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/claim" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/reference" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +type xrcOpt func(c *claim.Unstructured) + +func withXRCRef(ref *reference.Composite) xrcOpt { + return func(c *claim.Unstructured) { + c.SetResourceReference(ref) + } +} + +func withXRCSecretRef(ref *xpv2.LocalSecretReference) xrcOpt { + return func(c *claim.Unstructured) { + c.SetWriteConnectionSecretToReference(ref) + } +} + +func buildXRC(namespace string, name string, opts ...xrcOpt) *unstructured.Unstructured { + c := claim.New() + c.SetName(name) + c.SetNamespace(namespace) + + for _, f := range opts { + f(c) + } + + return &c.Unstructured +} + +type xrOpt func(c *composite.Unstructured) + +func withXRRefs(refs ...v1.ObjectReference) xrOpt { + return func(c *composite.Unstructured) { + c.SetResourceReferences(refs) + } +} + +func withNamespace(namespace string) xrOpt { + return func(c *composite.Unstructured) { + c.SetNamespace(namespace) + } +} + +func buildXR(name string, schema composite.Schema, opts ...xrOpt) *unstructured.Unstructured { + c := composite.New(composite.WithSchema(schema)) + c.SetName(name) + + for _, f := range opts { + f(c) + } + + return &c.Unstructured +} + +func TestGetResourceChildrenRefs(t *testing.T) { + type args struct { + resource *resource.Resource + witSecrets bool + } + + type want struct { + refs []v1.ObjectReference + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "XRWithChildrenNamespaced": { + reason: "Should return the list of children refs (with namespaces added) for a namespaced XR.", + args: args{ + resource: &resource.Resource{ + Unstructured: *buildXR("root-xr", composite.SchemaModern, withNamespace("cool-ns"), withXRRefs(v1.ObjectReference{ + APIVersion: "v1", + Kind: "Deployment", + Name: "cool-deployment", + }, v1.ObjectReference{ + APIVersion: "v1", + Kind: "Secret", + Name: "cool-secret", + }, v1.ObjectReference{ + APIVersion: "example.com/v1", + Kind: "CoolComposite", + Name: "cool-xr", + }, + )), + }, + }, + want: want{ + refs: []v1.ObjectReference{ + { + APIVersion: "v1", + Kind: "Deployment", + Name: "cool-deployment", + Namespace: "cool-ns", + }, + { + APIVersion: "v1", + Kind: "Secret", + Name: "cool-secret", + Namespace: "cool-ns", + }, + { + APIVersion: "example.com/v1", + Kind: "CoolComposite", + Name: "cool-xr", + Namespace: "cool-ns", + }, + }, + }, + }, + "XRWithChildrenCluster": { + reason: "Should return the list of children refs for a cluster scoped XR, with namespaces where needed.", + args: args{ + resource: &resource.Resource{ + Unstructured: *buildXR("root-xr", composite.SchemaModern, withXRRefs(v1.ObjectReference{ + APIVersion: "v1", + Kind: "Deployment", + Name: "cool-deployment", + Namespace: "cool-ns", + }, v1.ObjectReference{ + APIVersion: "v1", + Kind: "Secret", + Name: "cool-secret", + Namespace: "cool-ns", + }, v1.ObjectReference{ + APIVersion: "example.com/v1", + Kind: "ClusterCoolComposite", + Name: "cool-xr", + }, + )), + }, + }, + want: want{ + refs: []v1.ObjectReference{ + { + APIVersion: "v1", + Kind: "Deployment", + Name: "cool-deployment", + Namespace: "cool-ns", + }, + { + APIVersion: "v1", + Kind: "Secret", + Name: "cool-secret", + Namespace: "cool-ns", + }, + { + APIVersion: "example.com/v1", + Kind: "ClusterCoolComposite", + Name: "cool-xr", + }, + }, + }, + }, + "LegacyXRCWithChildrenXR": { + reason: "Should return the XR child for an XRC.", + args: args{ + resource: &resource.Resource{ + Unstructured: *buildXRC("ns-1", "xrc", withXRCRef(&reference.Composite{ + APIVersion: "example.com/v1", + Kind: "XR", + Name: "xr-1", + })), + }, + }, + want: want{ + refs: []v1.ObjectReference{ + { + APIVersion: "example.com/v1", + Kind: "XR", + Name: "xr-1", + }, + }, + }, + }, + "LegacyXRWithChildren": { + reason: "Should return the list of children refs for an XR.", + args: args{ + resource: &resource.Resource{ + Unstructured: *buildXR("root-xr", composite.SchemaLegacy, withXRRefs(v1.ObjectReference{ + APIVersion: "example.com/v1", + Kind: "MR", + Name: "mr-1", + }, v1.ObjectReference{ + APIVersion: "example2.com/v1", + Kind: "MR", + Name: "mr-2", + }, v1.ObjectReference{ + APIVersion: "example2.com/v1", + Kind: "XR", + Name: "xr-1", + }, v1.ObjectReference{ + APIVersion: "example2.com/v1", + Kind: "XRC", + Name: "xrc-1", + Namespace: "ns-1", + }, + )), + }, + }, + want: want{ + refs: []v1.ObjectReference{ + { + APIVersion: "example.com/v1", + Kind: "MR", + Name: "mr-1", + }, + { + APIVersion: "example2.com/v1", + Kind: "MR", + Name: "mr-2", + }, + { + APIVersion: "example2.com/v1", + Kind: "XR", + Name: "xr-1", + }, + { + APIVersion: "example2.com/v1", + Kind: "XRC", + Name: "xrc-1", + Namespace: "ns-1", + }, + }, + }, + }, + "LegacyXRCWithChildrenXRandConnectionSecretEnabled": { + reason: "Should return the XR child, but no writeConnectionSecret ref for an XRC.", + args: args{ + witSecrets: true, + resource: &resource.Resource{ + Unstructured: *buildXRC("ns-1", "xrc", withXRCSecretRef(&xpv2.LocalSecretReference{ + Name: "secret-1", + }), withXRCRef(&reference.Composite{ + APIVersion: "example.com/v1", + Kind: "XR", + Name: "xr-1", + })), + }, + }, + want: want{ + refs: []v1.ObjectReference{ + { + APIVersion: "v1", + Kind: "Secret", + Namespace: "ns-1", + Name: "secret-1", + }, + { + APIVersion: "example.com/v1", + Kind: "XR", + Name: "xr-1", + }, + }, + }, + }, + "LegacyXRCWithChildrenXRandConnectionSecretDisabled": { + reason: "Should return the XR child, but no writeConnectionSecret, ref for an XRC.", + args: args{ + witSecrets: false, + resource: &resource.Resource{ + Unstructured: *buildXRC("ns-1", "xrc", withXRCSecretRef(&xpv2.LocalSecretReference{ + Name: "secret-1", + }), withXRCRef(&reference.Composite{ + APIVersion: "example.com/v1", + Kind: "XR", + Name: "xr-1", + })), + }, + }, + want: want{ + refs: []v1.ObjectReference{ + { + APIVersion: "example.com/v1", + Kind: "XR", + Name: "xr-1", + }, + }, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got := getResourceChildrenRefs(tc.args.resource, tc.args.witSecrets) + if diff := cmp.Diff(tc.want.refs, got, cmpopts.SortSlices(func(r1, r2 v1.ObjectReference) bool { + return strings.Compare(r1.String(), r2.String()) < 0 + })); diff != "" { + t.Errorf("\n%s\ngetResourceChildrenRefs(...): -want, +got:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/common/resource/xrm/loader.go b/cmd/crossplane/common/resource/xrm/loader.go new file mode 100644 index 0000000..3dcb035 --- /dev/null +++ b/cmd/crossplane/common/resource/xrm/loader.go @@ -0,0 +1,155 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xrm + +import ( + "context" + "sort" + "sync" + + v1 "k8s.io/api/core/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +// defaultChannelCapacity is the buffer size of the processing channel, should be a high value +// so that there is no blocking. Correctness of processing does not depend on the channel capacity. +const defaultChannelCapacity = 1000 + +// workItem maintains the relationship of a resource to be loaded with its parent +// such that the resource that is loaded can be added as a child. +type workItem struct { + parent *resource.Resource + child v1.ObjectReference +} + +// resourceLoader is a delegate that loads resources and returns child resource refs. +type resourceLoader interface { + loadResource(ctx context.Context, ref *v1.ObjectReference) *resource.Resource + getResourceChildrenRefs(r *resource.Resource) []v1.ObjectReference +} + +// loader loads resources concurrently. +type loader struct { + root *resource.Resource // the root resource for which the tree is loaded + rl resourceLoader // the resource loader + resourceLock sync.Mutex // lock when updating the children of any resource + processing sync.WaitGroup // "counter" to track requests in flight + ch chan workItem // processing channel + done chan struct{} // done channel, signaled when all resources are loaded +} + +// newLoader creates a loader for the root resource. +func newLoader(root *resource.Resource, rl resourceLoader, channelCapacity int) *loader { + l := &loader{ + rl: rl, + ch: make(chan workItem, channelCapacity), + done: make(chan struct{}), + root: root, + } + + return l +} + +// load loads the full resource tree in a concurrent manner. +func (l *loader) load(ctx context.Context, concurrency int) { + // make sure counters are incremented for root child refs before starting concurrent processing + refs := l.rl.getResourceChildrenRefs(l.root) + l.addRefs(l.root, refs) + + // signal the done channel after all items are processed + go func() { + l.processing.Wait() + close(l.done) + }() + + if concurrency < 1 { + concurrency = defaultConcurrency + } + + var wg sync.WaitGroup + for range concurrency { + // spin up a worker that processes items from the channel until the done channel is signaled. + wg.Go(func() { + for { + select { + case <-l.done: + return + case item := <-l.ch: + l.processItem(ctx, item) + } + } + }) + } + + wg.Wait() + // order of children loaded for resources is not deterministic because of concurrent processing. + // Sort children explicitly to make this so. + sortRefs(l.root) +} + +func sortRefs(root *resource.Resource) { + for _, child := range root.Children { + sortRefs(child) + } + // this duplicates the sorting logic from internal/controller/apiextensions/composite/composition_functions.go + sort.Slice(root.Children, func(i, j int) bool { + l := root.Children[i].Unstructured + r := root.Children[j].Unstructured + + return l.GetAPIVersion()+l.GetKind()+l.GetName() < r.GetAPIVersion()+r.GetKind()+r.GetName() + }) +} + +// addRefs adds work items to the queue. +func (l *loader) addRefs(parent *resource.Resource, refs []v1.ObjectReference) { + // only perform work and spin up a goroutine if references are present. + if len(refs) == 0 { + return + } + // ensure counters are updated synchronously + l.processing.Add(len(refs)) + // free up the current processing routine even if the channel would block. + go func() { + for _, ref := range refs { + l.ch <- workItem{ + parent: parent, + child: ref, + } + } + }() +} + +// processItem processes a single work item in the queue and decrements the in-process counter +// after adding child references. +func (l *loader) processItem(ctx context.Context, item workItem) { + defer l.processing.Done() + + res := l.rl.loadResource(ctx, &item.child) + refs := l.rl.getResourceChildrenRefs(res) + l.updateChild(item, res) + l.addRefs(res, refs) +} + +// updateChild adds the supplied child resource to its parent. +func (l *loader) updateChild(item workItem, res *resource.Resource) { + l.resourceLock.Lock() + + item.parent.Children = append(item.parent.Children, res) + + l.resourceLock.Unlock() +} diff --git a/cmd/crossplane/common/resource/xrm/loader_test.go b/cmd/crossplane/common/resource/xrm/loader_test.go new file mode 100644 index 0000000..551811c --- /dev/null +++ b/cmd/crossplane/common/resource/xrm/loader_test.go @@ -0,0 +1,194 @@ +package xrm + +import ( + "context" + "fmt" + "math/rand" + "sync" + "testing" + + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + + "github.com/crossplane/cli/v2/cmd/crossplane/common/resource" +) + +// simpleGenerator generates a tree of resources for a specific depth and the number of children to +// create at any level. +type simpleGenerator struct { + childDepth int + numItems int + l sync.Mutex // lock for accessing the depth map + depthMap map[string]int // tracks resource names and their depth so that we can stop when the desired depth is reached. +} + +func newSimpleGenerator(childDepth, numItems int) *simpleGenerator { + return &simpleGenerator{ + childDepth: childDepth, + numItems: numItems, + depthMap: map[string]int{}, + } +} + +func (d *simpleGenerator) createResource(apiVersion, kind, name string) *resource.Resource { + obj := map[string]any{ + "apiVersion": apiVersion, + "kind": kind, + "metadata": map[string]any{ + "name": name, + }, + } + + return &resource.Resource{Unstructured: unstructured.Unstructured{Object: obj}} +} + +func (d *simpleGenerator) trackResourceDepth(name string, depth int) { + d.l.Lock() + defer d.l.Unlock() + + d.depthMap[name] = depth +} + +func (d *simpleGenerator) createRefAtDepth(depth int) v1.ObjectReference { + prefix := "comp-res" + if depth == d.childDepth { + prefix = "managed-res" + } + + name := fmt.Sprintf("%s-%d-%d", prefix, rand.Int(), depth) + d.trackResourceDepth(name, depth) + + return v1.ObjectReference{ + Kind: fmt.Sprintf("Depth%d", depth), + Name: name, + APIVersion: "example.com/v1", + } +} + +func (d *simpleGenerator) createResourceFromRef(ref *v1.ObjectReference) *resource.Resource { + return d.createResource(ref.APIVersion, ref.Kind, ref.Name) +} + +func (d *simpleGenerator) loadResource(_ context.Context, ref *v1.ObjectReference) *resource.Resource { + return d.createResourceFromRef(ref) +} + +func (d *simpleGenerator) depthFromResource(res *resource.Resource) int { + d.l.Lock() + defer d.l.Unlock() + + return d.depthMap[res.Unstructured.GetName()] +} + +func (d *simpleGenerator) getResourceChildrenRefs(r *resource.Resource) []v1.ObjectReference { + depth := d.depthFromResource(r) + if depth == d.childDepth { + return nil + } + + ret := make([]v1.ObjectReference, 0, d.numItems) + for range d.numItems { + ret = append(ret, d.createRefAtDepth(depth+1)) + } + + return ret +} + +var _ resourceLoader = &simpleGenerator{} + +func countItems(root *resource.Resource) int { + ret := 1 + for _, child := range root.Children { + ret += countItems(child) + } + + return ret +} + +func TestLoader(t *testing.T) { + type want struct { + expectedResources int + } + + type args struct { + childDepth int + numItems int + channelCapacity int + concurrency int + } + + tests := map[string]struct { + reason string + args args + want want + }{ + "Basic": { + reason: "simple test with default concurrency", + args: args{ + childDepth: 3, + numItems: 3, + }, + want: want{ + expectedResources: 1 + 3 + 9 + 27, + }, + }, + "BlockingBuffer": { + reason: "in-process resources greater than channel buffer, causing blocking", + args: args{ + channelCapacity: 1, + concurrency: 1, + childDepth: 3, + numItems: 10, + }, + want: want{ + expectedResources: 1 + 10 + 100 + 1000, + }, + }, + "NoRootChildren": { + reason: "top-level resource has no children", + args: args{ + childDepth: 0, + numItems: 0, + }, + want: want{ + expectedResources: 1, + }, + }, + "BadConcurrency": { + reason: "invalid concurrency is adjusted to be valid", + args: args{ + concurrency: -1, + childDepth: 3, + numItems: 3, + }, + want: want{ + expectedResources: 1 + 3 + 9 + 27, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + channelCapacity := defaultChannelCapacity + if test.args.channelCapacity > 0 { + channelCapacity = test.args.channelCapacity + } + + concurrency := defaultConcurrency + if test.args.concurrency != 0 { + concurrency = test.args.concurrency + } + + sg := newSimpleGenerator(test.args.childDepth, test.args.numItems) + rootRef := sg.createRefAtDepth(0) + root := sg.createResourceFromRef(&rootRef) + l := newLoader(root, sg, channelCapacity) + l.load(context.Background(), concurrency) + + n := countItems(root) + if test.want.expectedResources != n { + t.Errorf("resource count mismatch: want %d, got %d", test.want.expectedResources, n) + } + }) + } +} diff --git a/cmd/crossplane/completion/completion.go b/cmd/crossplane/completion/completion.go new file mode 100644 index 0000000..d87ac4d --- /dev/null +++ b/cmd/crossplane/completion/completion.go @@ -0,0 +1,277 @@ +// Package completion contains Crossplane CLI completions. +package completion + +import ( + "context" + "fmt" + "strings" + + "github.com/posener/complete" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/kubernetes" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" + controllerClient "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/cli/v2/cmd/crossplane/internal" +) + +// Predictors returns all supported predictors. +func Predictors() map[string]complete.Predictor { + yamlPredictor := complete.PredictOr( + complete.PredictFiles("*.yml"), + complete.PredictFiles("*.yaml"), + ) + + return map[string]complete.Predictor{ + "file": complete.PredictFiles("*"), + "xpkg_file": complete.PredictFiles("*.xpkg"), + "yaml_file": yamlPredictor, + "directory": complete.PredictDirs("*"), + "file_or_directory": complete.PredictOr(complete.PredictFiles("*"), complete.PredictDirs("*")), + "yaml_file_or_directory": complete.PredictOr(yamlPredictor, complete.PredictDirs("*")), + "namespace": namespacePredictor(), + "context": contextPredictor(), + "k8s_resource": kubernetesResourcePredictor(), + "k8s_resource_name": kubernetesResourceNamePredictor(), + } +} + +// kubernetesResourcePredictor returns a predictor that suggests Kubernetes resources based on +// the current context and namespace or the context and namespace specified in the command line arguments. +// It uses the Kubernetes client to retrieve the available resources and filters them based on the +// last completed argument. +func kubernetesResourcePredictor() complete.PredictFunc { + return func(a complete.Args) []string { + _, kubeconfig, _, err := kubernetesClient(parseConfigOverride(a)) + if err != nil { + return nil + } + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(kubeconfig) + if err != nil { + return nil + } + + resources, err := discoveryClient.ServerPreferredResources() + if err != nil { + return nil + } + + if len(resources) == 0 { + return nil + } + + var predictions []string + + for _, apiResources := range resources { + for _, res := range apiResources.APIResources { + var resourceName string + + // Write the resource name in a normalized format .. + // or . if the group is empty. + // If the version is empty, just use the name. + switch { + case res.Group != "" && res.Version != "": + resourceName = fmt.Sprintf("%s.%s.%s", res.Name, res.Version, res.Group) + case res.Version != "": + resourceName = fmt.Sprintf("%s.%s", res.Name, res.Version) + default: + resourceName = res.Name + } + + // This way we can filter the resources by the last completed argument of any valid format + // by just checking the prefix. + if strings.HasPrefix(resourceName, a.Last) { + predictions = append(predictions, resourceName) + } + } + } + + return predictions + } +} + +// kubernetesResourceNamePredictor returns a predictor that suggests Kubernetes resource names based on +// the current context and namespace or the context and namespace specified in the command line arguments. +// It uses the Kubernetes client to retrieve the available resources and filters them based on the +// last completed argument. +func kubernetesResourceNamePredictor() complete.PredictFunc { + return func(a complete.Args) []string { + client, kubeconfig, clientconfig, err := kubernetesClient(parseConfigOverride(a)) + if err != nil { + return nil + } + + discoveryClient, err := discovery.NewDiscoveryClientForConfig(kubeconfig) + if err != nil { + return nil + } + + d := memory.NewMemCacheClient(discoveryClient) + // If the previously completed argument (resource name) was used by its short form, we need to + // get the full resource name to be able to list the resources. + rmapper := restmapper.NewShortcutExpander(restmapper.NewDeferredDiscoveryRESTMapper(d), d, nil) + + mapping, err := internal.MappingFor(rmapper, a.LastCompleted) + if err != nil { + return nil + } + + u := &unstructured.UnstructuredList{} + u.SetGroupVersionKind(schema.GroupVersionKind{ + Kind: mapping.GroupVersionKind.Kind, + Group: mapping.GroupVersionKind.Group, + Version: mapping.GroupVersionKind.Version, + }) + + // Limit the search results to the current context and namespace or the context and namespace specified in the command line arguments. + // If no namespace is specified, it will try to use the current context namespace. + // If that fails, it will use the default namespace. + namespace := parseNamespaceOverride(a) + if namespace == "" { + namespace, _, err = clientconfig.Namespace() + if err != nil || namespace == "" { + namespace = metav1.NamespaceDefault + } + } + + err = client.List(context.Background(), u, controllerClient.InNamespace(namespace)) + if err != nil { + return nil + } + + // Find predictions by filtering the resource names that start with the currently completed argument. + var predictions []string + + for _, res := range u.Items { + if strings.HasPrefix(res.GetName(), a.Last) { + predictions = append(predictions, res.GetName()) + } + } + + return predictions + } +} + +// contextPredictor returns a predictor that suggests Kubernetes contexts from the KUBECONFIG. +func contextPredictor() complete.PredictFunc { + return func(a complete.Args) []string { + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}, + ) + + kubeConfig, err := clientConfig.RawConfig() + if err != nil { + return nil + } + + var predictions []string + + for name := range kubeConfig.Contexts { + if strings.HasPrefix(name, a.Last) { + predictions = append(predictions, name) + } + } + + return predictions + } +} + +// namespacePredictor returns a predictor that suggests Kubernetes namespaces from the current context. +// It uses the Kubernetes client to retrieve the available namespaces and filters them based on the +// last completed argument. +func namespacePredictor() complete.PredictFunc { + return func(a complete.Args) []string { + client, err := kubernetesClientset() + if err != nil { + return nil + } + + namespaceList, err := client.CoreV1().Namespaces().List(context.Background(), metav1.ListOptions{}) + if err != nil { + return nil + } + + var predictions []string + + for _, ns := range namespaceList.Items { + if strings.HasPrefix(ns.GetName(), a.Last) { + predictions = append(predictions, ns.GetName()) + } + } + + return predictions + } +} + +// kubernetesClientset returns a Kubernetes clientset using the default kubeconfig. +func kubernetesClientset() (*kubernetes.Clientset, error) { + clientConfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + &clientcmd.ConfigOverrides{}, + ) + + kubeConfig, err := clientConfig.ClientConfig() + if err != nil { + return nil, err + } + + return kubernetes.NewForConfig(kubeConfig) +} + +// kubernetesClient returns a Kubernetes client and a rest.Config using the provided config overrides. +func kubernetesClient(configOverrides *clientcmd.ConfigOverrides) (controllerClient.Client, *rest.Config, clientcmd.ClientConfig, error) { + clientconfig := clientcmd.NewNonInteractiveDeferredLoadingClientConfig( + clientcmd.NewDefaultClientConfigLoadingRules(), + configOverrides, + ) + + kubeconfig, err := clientconfig.ClientConfig() + if err != nil { + return nil, nil, nil, err + } + + client, err := controllerClient.New(rest.CopyConfig(kubeconfig), controllerClient.Options{}) + if err != nil { + return nil, nil, nil, err + } + + return client, rest.CopyConfig(kubeconfig), clientconfig, nil +} + +// parseConfigOverride parses ConfigOverrides for the k8s client from the completed command line arguments. +func parseConfigOverride(a complete.Args) *clientcmd.ConfigOverrides { + context := "" + + for i, arg := range a.All { + if (arg == "--context" || arg == "-c") && i < len(a.All) { + context = a.All[i+1] + break + } + } + + return &clientcmd.ConfigOverrides{ + CurrentContext: context, + } +} + +// parseNamespaceOverride parses the namespace override from the completed command line arguments. +func parseNamespaceOverride(a complete.Args) string { + namespace := "" + + for i, arg := range a.All { + if (arg == "--namespace" || arg == "-n") && i < len(a.All) { + namespace = a.All[i+1] + break + } + } + + return namespace +} diff --git a/cmd/crossplane/internal/client.go b/cmd/crossplane/internal/client.go new file mode 100644 index 0000000..b1339d1 --- /dev/null +++ b/cmd/crossplane/internal/client.go @@ -0,0 +1,62 @@ +// Package internal contains internally used logic for Crossplane CLI. +package internal + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/apimachinery/pkg/runtime/schema" +) + +const ( + errFmtResourceTypeNotFound = "the server doesn't have a resource type %q" +) + +// MappingFor returns the RESTMapping for the given resource or kind argument. +// Copied over from cli-runtime pkg/resource Builder, +// https://github.com/kubernetes/cli-runtime/blob/9a91d944dd43186c52e0162e12b151b0e460354a/pkg/resource/builder.go#L768 +func MappingFor(rmapper meta.RESTMapper, resourceOrKindArg string) (*meta.RESTMapping, error) { + // TODO(phisco): actually use the Builder. + fullySpecifiedGVR, groupResource := schema.ParseResourceArg(resourceOrKindArg) + + gvk := schema.GroupVersionKind{} + if fullySpecifiedGVR != nil { + gvk, _ = rmapper.KindFor(*fullySpecifiedGVR) + } + + if gvk.Empty() { + gvk, _ = rmapper.KindFor(groupResource.WithVersion("")) + } + + if !gvk.Empty() { + return rmapper.RESTMapping(gvk.GroupKind(), gvk.Version) + } + + fullySpecifiedGVK, groupKind := schema.ParseKindArg(resourceOrKindArg) + if fullySpecifiedGVK == nil { + gvk := groupKind.WithVersion("") + fullySpecifiedGVK = &gvk + } + + if !fullySpecifiedGVK.Empty() { + if mapping, err := rmapper.RESTMapping(fullySpecifiedGVK.GroupKind(), fullySpecifiedGVK.Version); err == nil { + return mapping, nil + } + } + + mapping, err := rmapper.RESTMapping(groupKind, gvk.Version) + if err != nil { + // if we error out here, it is because we could not match a resource or a kind + // for the given argument. To maintain consistency with previous behavior, + // announce that a resource type could not be found. + // if the error is _not_ a *meta.NoKindMatchError, then we had trouble doing discovery, + // so we should return the original error since it may help a user diagnose what is actually wrong + if meta.IsNoMatchError(err) { + return nil, fmt.Errorf(errFmtResourceTypeNotFound, groupResource.Resource) + } + + return nil, err + } + + return mapping, nil +} diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index f21a7ef..4333e90 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -1,5 +1,5 @@ /* -Copyright 2026 The Crossplane Authors. +Copyright 2020 The Crossplane Authors. Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -14,10 +14,83 @@ See the License for the specific language governing permissions and limitations under the License. */ +// Package main implements Crossplane's crank CLI - aka crossplane CLI. package main -import "fmt" +import ( + "os" + + "github.com/alecthomas/kong" + "github.com/willabides/kongplete" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/cmd/crossplane/alpha" + "github.com/crossplane/cli/v2/cmd/crossplane/beta" + "github.com/crossplane/cli/v2/cmd/crossplane/completion" + "github.com/crossplane/cli/v2/cmd/crossplane/render" + "github.com/crossplane/cli/v2/cmd/crossplane/version" + "github.com/crossplane/cli/v2/cmd/crossplane/xpkg" +) + +var _ = kong.Must(&cli{}) + +type ( + verboseFlag bool +) + +func (v verboseFlag) BeforeApply(ctx *kong.Context) error { //nolint:unparam // BeforeApply requires this signature. + logger := logging.NewLogrLogger(zap.New(zap.UseDevMode(true))) + ctx.BindTo(logger, (*logging.Logger)(nil)) + + return nil +} + +// The top-level crossplane CLI. +type cli struct { + // Subcommands and flags will appear in the CLI help output in the same + // order they're specified here. Keep them in alphabetical order. + + // Subcommands. + XPKG xpkg.Cmd `cmd:"" help:"Manage Crossplane packages."` + Render render.Cmd `cmd:"" help:"Render a composite resource (XR)."` + + // The alpha and beta subcommands are intentionally in a separate block. We + // want them to appear after all other subcommands. + Alpha alpha.Cmd `cmd:"" help:"Alpha commands."` + Beta beta.Cmd `cmd:"" help:"Beta commands."` + Version version.Cmd `cmd:"" help:"Print the client and server version information for the current context."` + + // Flags. + Verbose verboseFlag `help:"Print verbose logging statements." name:"verbose"` + + // Completion + Completions kongplete.InstallCompletions `cmd:"" help:"Get shell (bash/zsh/fish) completions. You can source this command to get completions for the login shell. Example: 'source <(crossplane completions)'"` +} func main() { - fmt.Println("this is a stub.") + logger := logging.NewNopLogger() + parser := kong.Must(&cli{}, + kong.Name("crossplane"), + kong.Description("A command line tool for interacting with Crossplane."), + // Binding a variable to kong context makes it available to all commands + // at runtime. + kong.BindTo(logger, (*logging.Logger)(nil)), + kong.ConfigureHelp(kong.HelpOptions{ + FlagsLast: true, + Compact: true, + WrapUpperBound: 80, + }), + kong.UsageOnError()) + + kongplete.Complete(parser, + kongplete.WithPredictors(completion.Predictors()), + ) + + ctx, err := parser.Parse(os.Args[1:]) + parser.FatalIfErrorf(err) + + err = ctx.Run() + ctx.FatalIfErrorf(err) } diff --git a/cmd/crossplane/render/cmd.go b/cmd/crossplane/render/cmd.go new file mode 100644 index 0000000..9895cb0 --- /dev/null +++ b/cmd/crossplane/render/cmd.go @@ -0,0 +1,452 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package render implements composition rendering using composition functions. +package render + +import ( + "context" + "fmt" + "strings" + "time" + + "dario.cat/mergo" + "github.com/alecthomas/kong" + "github.com/spf13/afero" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/serializer/json" + "k8s.io/kube-openapi/pkg/spec3" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/cmd/crossplane/render/contextfn" +) + +// Cmd arguments and flags for render subcommand. +type Cmd struct { + EngineFlags `prefix:""` + + // Arguments. + CompositeResource string `arg:"" help:"A YAML file specifying the composite resource (XR) to render." predictor:"yaml_file" type:"existingfile"` + Composition string `arg:"" help:"A YAML file specifying the Composition to use to render the XR. Must be mode: Pipeline." predictor:"yaml_file" type:"existingfile"` + Functions string `arg:"" help:"A YAML file or directory of YAML files specifying the Composition Functions to use to render the XR." predictor:"yaml_file_or_directory" type:"path"` + + // Flags. Keep them in alphabetical order. + ContextFiles map[string]string `help:"Comma-separated context key-value pairs to pass to the Function pipeline. Values must be files containing JSON/YAML." mapsep:"" predictor:"file"` + ContextValues map[string]string `help:"Comma-separated context key-value pairs to pass to the Function pipeline. Values must be JSON/YAML. Keys take precedence over --context-files." mapsep:""` + IncludeFunctionResults bool `help:"Include informational and warning messages from Functions in the rendered output as resources of kind: Result." short:"r"` + IncludeFullXR bool `help:"Include a direct copy of the input XR's spec and metadata fields in the rendered output." short:"x"` + ObservedResources string `help:"A YAML file or directory of YAML files specifying the observed state of composed resources." placeholder:"PATH" predictor:"yaml_file_or_directory" short:"o" type:"path"` + ExtraResources string `help:"A YAML file or directory of YAML files specifying required resources (deprecated, use --required-resources)." placeholder:"PATH" predictor:"yaml_file_or_directory" type:"path"` + RequiredResources string `help:"A YAML file or directory of YAML files specifying required resources to pass to the Function pipeline." placeholder:"PATH" predictor:"yaml_file_or_directory" short:"e" type:"path"` + RequiredSchemas string `help:"A directory of JSON files specifying OpenAPI v3 schemas (from kubectl get --raw /openapi/v3/)." placeholder:"DIR" predictor:"directory" short:"s" type:"path"` + IncludeContext bool `help:"Include the context in the rendered output as a resource of kind: Context." short:"c"` + FunctionCredentials string `help:"A YAML file or directory of YAML files specifying credentials to use for Functions to render the XR." placeholder:"PATH" predictor:"yaml_file_or_directory" type:"path"` + FunctionAnnotations []string `help:"Override function annotations for all functions. Can be repeated." placeholder:"KEY=VALUE" short:"a"` + + Timeout time.Duration `default:"1m" help:"How long to run before timing out."` + XRD string `help:"A YAML file specifying the CompositeResourceDefinition (XRD) that defines the XR's schema and properties." optional:"" placeholder:"PATH" type:"existingfile"` + + fs afero.Fs + + // newEngine constructs the render Engine. + newEngine func(*EngineFlags, logging.Logger) Engine +} + +// Help prints out the help for the render command. +func (c *Cmd) Help() string { + return ` +This command shows you what composed resources Crossplane would create by +printing them to stdout. It also prints any changes that would be made to the +status of the XR. It runs the Crossplane render engine (either in a Docker +container or via a local binary) to produce high-fidelity output that matches +what the real reconciler would produce. + +Composition Functions are pulled and run using Docker by default. You can add +the following annotations to each Function to change how they're run: + + render.crossplane.io/runtime: "Development" + + Connect to a Function that is already running, instead of using Docker. This + is useful to develop and debug new Functions. The Function must be listening + at localhost:9443 and running with the --insecure flag. + + render.crossplane.io/runtime-development-target: "dns:///example.org:7443" + + Connect to a Function running somewhere other than localhost:9443. The + target uses gRPC target syntax (e.g., dns:///example.org:7443 or simply example.org:7443). + + render.crossplane.io/runtime-docker-cleanup: "Orphan" + + Don't stop the Function's Docker container after rendering. + + render.crossplane.io/runtime-docker-name: "" + + create a container with that name and also reuse it as long as it is running or can be restarted. + + render.crossplane.io/runtime-docker-pull-policy: "Always" + + Always pull the Function's package, even if it already exists locally. + Other supported values are Never, or IfNotPresent. + + render.crossplane.io/runtime-docker-publish-address: "0.0.0.0" + + Host address that Docker should publish the Function's container port to. + Defaults to 127.0.0.1 (localhost only). Use 0.0.0.0 to publish to all host + network interfaces, enabling access from remote machines. + + render.crossplane.io/runtime-docker-target: "docker-host" + + Address that the render CLI should use to connect to the Function's Docker + container. If not specified, uses the publish address. + +Use the standard DOCKER_HOST, DOCKER_API_VERSION, DOCKER_CERT_PATH, and +DOCKER_TLS_VERIFY environment variables to configure how this command connects +to the Docker daemon. + +Examples: + + # Simulate creating a new XR. + crossplane render xr.yaml composition.yaml functions.yaml + + # Simulate updating an XR that already exists. + crossplane render xr.yaml composition.yaml functions.yaml \ + --observed-resources=existing-observed-resources.yaml + + # Pin the Crossplane version used for rendering. + crossplane render xr.yaml composition.yaml functions.yaml \ + --crossplane-version=v2.3.0 + + # Use a local crossplane binary instead of Docker. + crossplane render xr.yaml composition.yaml functions.yaml \ + --crossplane-binary=/usr/local/bin/crossplane + + # Pass context values to the Function pipeline. + crossplane render xr.yaml composition.yaml functions.yaml \ + --context-values=apiextensions.crossplane.io/environment='{"key": "value"}' + + # Pass required resources Functions in the pipeline can request. + crossplane render xr.yaml composition.yaml functions.yaml \ + --required-resources=required-resources.yaml + + # Pass OpenAPI schemas for Functions that need them. + crossplane render xr.yaml composition.yaml functions.yaml \ + --required-schemas=schemas/ + + # Pass credentials to Functions in the pipeline that need them. + crossplane render xr.yaml composition.yaml functions.yaml \ + --function-credentials=credentials.yaml + + # Override function annotations for a remote Docker daemon. + DOCKER_HOST=tcp://192.168.1.100:2376 crossplane render xr.yaml composition.yaml functions.yaml \ + -a render.crossplane.io/runtime-docker-publish-address=0.0.0.0 \ + -a render.crossplane.io/runtime-docker-target=192.168.1.100 + + # Force all functions to use development runtime. + crossplane render xr.yaml composition.yaml functions.yaml \ + -a render.crossplane.io/runtime=Development \ + -a render.crossplane.io/runtime-development-target=localhost:9444 +` +} + +// AfterApply implements kong.AfterApply. +func (c *Cmd) AfterApply() error { + c.fs = afero.NewOsFs() + c.newEngine = NewEngineFromFlags + + return nil +} + +// Run render. +func (c *Cmd) Run(k *kong.Context, log logging.Logger) error { //nolint:gocognit // Orchestration is inherently complex. + xr, err := LoadCompositeResource(c.fs, c.CompositeResource) + if err != nil { + return errors.Wrapf(err, "cannot load composite resource from %q", c.CompositeResource) + } + + comp, err := LoadComposition(c.fs, c.Composition) + if err != nil { + return errors.Wrapf(err, "cannot load Composition from %q", c.Composition) + } + + // Validate that Composition's compositeTypeRef matches the XR's GroupVersionKind. + xrGVK := xr.GetObjectKind().GroupVersionKind() + compRef := comp.Spec.CompositeTypeRef + + if compRef.Kind != xrGVK.Kind { + return errors.Errorf("composition's compositeTypeRef.kind (%s) does not match XR's kind (%s)", compRef.Kind, xrGVK.Kind) + } + + if compRef.APIVersion != xrGVK.GroupVersion().String() { + return errors.Errorf("composition's compositeTypeRef.apiVersion (%s) does not match XR's apiVersion (%s)", compRef.APIVersion, xrGVK.GroupVersion().String()) + } + + // check if XR's matchLabels have corresponding label at composition + xrSelector := xr.GetCompositionSelector() + if xrSelector != nil { + for key, value := range xrSelector.MatchLabels { + compValue, exists := comp.Labels[key] + if !exists { + return fmt.Errorf("composition %q is missing required label %q", comp.GetName(), key) + } + + if compValue != value { + return fmt.Errorf("composition %q has incorrect value for label %q: want %q, got %q", + comp.GetName(), key, value, compValue) + } + } + } + + if comp.Spec.Mode != apiextensionsv1.CompositionModePipeline { + return errors.Errorf("render only supports Composition Function pipelines: Composition %q must use spec.mode: Pipeline", comp.GetName()) + } + + fns, err := LoadFunctions(c.fs, c.Functions) + if err != nil { + return errors.Wrapf(err, "cannot load functions from %q", c.Functions) + } + + // Apply global annotation overrides to each function + if err := OverrideFunctionAnnotations(fns, c.FunctionAnnotations); err != nil { + return errors.Wrap(err, "cannot apply function annotation overrides") + } + + if c.XRD != "" { + xrd, err := LoadXRD(c.fs, c.XRD) + if err != nil { + return errors.Wrapf(err, "cannot load XRD from %q", c.XRD) + } + + crd, err := xcrd.ForCompositeResource(xrd) + if err != nil { + return errors.Wrapf(err, "cannot derive composite CRD from XRD %q", xrd.GetName()) + } + + if err := DefaultValues(xr.UnstructuredContent(), xr.GetAPIVersion(), *crd); err != nil { + return errors.Wrapf(err, "cannot default values for XR %q", xr.GetName()) + } + } + + fcreds := []corev1.Secret{} + if c.FunctionCredentials != "" { + fcreds, err = LoadCredentials(c.fs, c.FunctionCredentials) + if err != nil { + return errors.Wrapf(err, "cannot load secrets from %q", c.FunctionCredentials) + } + } + + ors := []composed.Unstructured{} + if c.ObservedResources != "" { + ors, err = LoadObservedResources(c.fs, c.ObservedResources) + if err != nil { + return errors.Wrapf(err, "cannot load observed composed resources from %q", c.ObservedResources) + } + } + + ers := []unstructured.Unstructured{} + if c.ExtraResources != "" { + ers, err = LoadRequiredResources(c.fs, c.ExtraResources) + if err != nil { + return errors.Wrapf(err, "cannot load extra resources from %q", c.ExtraResources) + } + } + + rrs := []unstructured.Unstructured{} + if c.RequiredResources != "" { + rrs, err = LoadRequiredResources(c.fs, c.RequiredResources) + if err != nil { + return errors.Wrapf(err, "cannot load required resources from %q", c.RequiredResources) + } + } + + // Merge extra resources into required resources. + rrs = append(rrs, ers...) + + // Load required schemas + rsc := []spec3.OpenAPI{} + if c.RequiredSchemas != "" { + rsc, err = LoadRequiredSchemas(c.fs, c.RequiredSchemas) + if err != nil { + return errors.Wrapf(err, "cannot load required schemas from %q", c.RequiredSchemas) + } + } + + ctx, cancel := context.WithTimeout(context.Background(), c.Timeout) + defer cancel() + + engine := c.newEngine(&c.EngineFlags, log) + + seedCtx := len(c.ContextValues) > 0 || len(c.ContextFiles) > 0 + captureCtx := c.IncludeContext + + var ctxHandle *contextfn.Handle + if seedCtx || captureCtx { + if err := engine.CheckContextSupport(); err != nil { + return err + } + + raw, err := BuildContextData(c.fs, c.ContextFiles, c.ContextValues) + if err != nil { + return errors.Wrap(err, "cannot build context data") + } + + parsed, err := ParseContextData(raw) + if err != nil { + return errors.Wrap(err, "cannot parse context data") + } + + ctxHandle, err = contextfn.Start(ctx, log, parsed) + if err != nil { + return errors.Wrap(err, "cannot start context function") + } + defer ctxHandle.Stop() + + fns = append(fns, ctxHandle.Function()) + if seedCtx { + comp.Spec.Pipeline = append([]apiextensionsv1.PipelineStep{ctxHandle.CompositeSeedStep()}, comp.Spec.Pipeline...) + } + if captureCtx { + comp.Spec.Pipeline = append(comp.Spec.Pipeline, ctxHandle.CompositeCaptureStep()) + } + } + + cleanup, err := engine.Setup(ctx, fns) + if err != nil { + return err + } + defer cleanup() + + // Start function runtimes to get their addresses. + fnAddrs, err := StartFunctionRuntimes(ctx, log, fns) + if err != nil { + return errors.Wrap(err, "cannot start function runtimes") + } + defer StopFunctionRuntimes(log, fnAddrs) + + addrs := fnAddrs.Addresses() + if ctxHandle != nil { + addrs[contextfn.FunctionName] = ctxHandle.Target + } + + // Build and execute the render request. + in := CompositionInputs{ + CompositeResource: xr, + Composition: comp, + FunctionAddrs: addrs, + ObservedResources: ors, + RequiredResources: rrs, + RequiredSchemas: rsc, + FunctionCredentials: fcreds, + } + req, err := BuildCompositeRequest(in) + if err != nil { + return errors.Wrap(err, "cannot build render request") + } + + rsp, err := engine.Render(ctx, req) + if err != nil { + return errors.Wrap(err, "cannot render composite resource") + } + + compositeOut := rsp.GetComposite() + if compositeOut == nil { + return errors.New("render response does not contain a composite output") + } + + out, err := ParseCompositeResponse(compositeOut) + if err != nil { + return errors.Wrap(err, "cannot parse render response") + } + + if captureCtx && ctxHandle != nil { + if s := ctxHandle.Captured(); s != nil { + out.Context = &unstructured.Unstructured{Object: map[string]any{ + "apiVersion": "render.crossplane.io/v1beta1", + "kind": "Context", + "fields": s.AsMap(), + }} + } + } + + s := json.NewSerializerWithOptions(json.DefaultMetaFactory, nil, nil, json.SerializerOptions{Yaml: true}) + + if c.IncludeFullXR { + // Make our best effort to merge the composition pipeline's changes into + // the original XR. Note that this may not be 100% accurate, since we + // don't know how the apiserver would merge lists. + updatedXR := xr.DeepCopy() + if err := mergo.Merge(&updatedXR.Object, out.CompositeResource.Object, mergo.WithOverride); err != nil { + return errors.Wrap(err, "cannot merge updated XR") + } + out.CompositeResource = updatedXR + } + + _, _ = fmt.Fprintln(k.Stdout, "---") + if err := s.Encode(out.CompositeResource, k.Stdout); err != nil { + return errors.Wrapf(err, "cannot marshal composite resource %q to YAML", xr.GetName()) + } + + for i := range out.ComposedResources { + _, _ = fmt.Fprintln(k.Stdout, "---") + if err := s.Encode(&out.ComposedResources[i], k.Stdout); err != nil { + return errors.Wrapf(err, "cannot marshal composed resource %q to YAML", out.ComposedResources[i].GetAnnotations()[AnnotationKeyCompositionResourceName]) + } + } + + if c.IncludeFunctionResults { + for i := range out.Results { + _, _ = fmt.Fprintln(k.Stdout, "---") + if err := s.Encode(&out.Results[i], k.Stdout); err != nil { + return errors.Wrap(err, "cannot marshal result to YAML") + } + } + } + + if c.IncludeContext && out.Context != nil { + _, _ = fmt.Fprintln(k.Stdout, "---") + if err := s.Encode(out.Context, k.Stdout); err != nil { + return errors.Wrap(err, "cannot marshal context to YAML") + } + } + + return nil +} + +// OverrideFunctionAnnotations applies annotation overrides from flags to +// functions. +func OverrideFunctionAnnotations(fns []pkgv1.Function, annotations []string) error { + for i := range fns { + if fns[i].Annotations == nil { + fns[i].Annotations = make(map[string]string) + } + for _, annotation := range annotations { + parts := strings.SplitN(annotation, "=", 2) + if len(parts) != 2 { + return errors.Errorf("invalid function annotation format %q, expected key=value", annotation) + } + key, value := parts[0], parts[1] + fns[i].Annotations[key] = value // Flags override existing annotations + } + } + return nil +} diff --git a/cmd/crossplane/render/cmd_test.go b/cmd/crossplane/render/cmd_test.go new file mode 100644 index 0000000..6f4b277 --- /dev/null +++ b/cmd/crossplane/render/cmd_test.go @@ -0,0 +1,500 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "bytes" + "context" + "io" + "testing" + "testing/fstest" + "time" + + "github.com/alecthomas/kong" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" + "google.golang.org/protobuf/types/known/structpb" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" + + _ "embed" +) + +//go:embed testdata/cmd/xr.yaml +var xrYAML string + +//go:embed testdata/cmd/xr-extra-spec.yaml +var xrWithExtraSpecYAML string + +//go:embed testdata/cmd/xr-wrong-kind.yaml +var xrWrongKindYAML string + +//go:embed testdata/cmd/xr-wrong-apiversion.yaml +var xrWrongAPIVersionYAML string + +//go:embed testdata/cmd/xr-with-selector.yaml +var xrWithSelectorYAML string + +//go:embed testdata/cmd/composition.yaml +var compositionYAML string + +//go:embed testdata/cmd/composition-label-mismatch.yaml +var compositionLabelMismatchYAML string + +//go:embed testdata/cmd/composition-not-pipeline.yaml +var compositionNotPipelineYAML string + +//go:embed testdata/cmd/functions.yaml +var functionsYAML string + +//go:embed testdata/cmd/output/success.yaml +var successOutput string + +//go:embed testdata/cmd/output/include-function-results.yaml +var includeFunctionResultsOutput string + +//go:embed testdata/cmd/output/include-full-xr.yaml +var includeFullXROutput string + +func newEngineFunc(engine Engine) func(*EngineFlags, logging.Logger) Engine { + return func(*EngineFlags, logging.Logger) Engine { + return engine + } +} + +// newTestFS builds an in-memory filesystem seeded with the default happy-path +// fixtures. Entries in extra are overlaid on top; an entry with an empty value +// removes the file from the FS. +func newTestFS(extra map[string]string) afero.Fs { + files := map[string]*fstest.MapFile{ + "xr.yaml": {Data: []byte(xrYAML)}, + "composition.yaml": {Data: []byte(compositionYAML)}, + "functions.yaml": {Data: []byte(functionsYAML)}, + } + for k, v := range extra { + if v == "" { + delete(files, k) + continue + } + files[k] = &fstest.MapFile{Data: []byte(v)} + } + return afero.FromIOFS{FS: fstest.MapFS(files)} +} + +func mustNewStruct(t *testing.T, data map[string]any) *structpb.Struct { + t.Helper() + s, err := structpb.NewStruct(data) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + return s +} + +func fillResourceRefs(t *testing.T, xr *structpb.Struct) *structpb.Struct { + t.Helper() + + xr.Fields["spec"] = structpb.NewStructValue(mustNewStruct(t, map[string]any{ + "crossplane": map[string]any{ + "resourceRefs": "these are the resource refs", + }, + })) + + return xr +} + +func TestCmdRun(t *testing.T) { + type args struct { + cmd Cmd + } + type want struct { + err error + stdout string + } + cases := map[string]struct { + reason string + args args + want want + }{ + "Success": { + reason: "Happy path: load fixtures, render, and emit YAML for the composite and one composed resource.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&MockEngine{ + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: fillResourceRefs(t, req.GetComposite().GetCompositeResource()), + ComposedResources: []*structpb.Struct{ + mustNewStruct(t, map[string]any{ + "apiVersion": "example.org/v1alpha1", + "kind": "ComposedResource", + "metadata": map[string]any{ + "name": "composed-foo", + "annotations": map[string]any{ + "crossplane.io/composition-resource-name": "composed-foo", + }, + }, + "spec": map[string]any{"coolField": "composed!"}, + }), + }, + }, + }, + }, nil + }, + }), + }, + }, + want: want{ + stdout: successOutput, + }, + }, + "LoadCompositeResourceError": { + reason: "Missing XR file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + CompositeResource: "missing.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "LoadCompositionError": { + reason: "Missing Composition file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "missing.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "CompositeTypeRefKindMismatch": { + reason: "XR kind must match Composition's compositeTypeRef.kind.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(map[string]string{"xr.yaml": xrWrongKindYAML}), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "CompositeTypeRefAPIVersionMismatch": { + reason: "XR apiVersion must match Composition's compositeTypeRef.apiVersion.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(map[string]string{"xr.yaml": xrWrongAPIVersionYAML}), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "MatchLabelMissing": { + reason: "Composition must carry every label declared in the XR's compositionSelector.matchLabels.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(map[string]string{"xr.yaml": xrWithSelectorYAML}), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "MatchLabelMismatch": { + reason: "Composition's label value must equal the XR's matchLabels value.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(map[string]string{ + "xr.yaml": xrWithSelectorYAML, + "composition.yaml": compositionLabelMismatchYAML, + }), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "NotPipelineMode": { + reason: "render only supports Composition function pipelines.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(map[string]string{"composition.yaml": compositionNotPipelineYAML}), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadFunctionsError": { + reason: "Missing functions file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "missing.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "InvalidAnnotationOverride": { + reason: "Function annotation overrides must be in key=value form.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + FunctionAnnotations: []string{"not-a-key-value"}, + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadXRDError": { + reason: "Missing XRD file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + XRD: "missing.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadFunctionCredentialsError": { + reason: "Missing function credentials file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + FunctionCredentials: "missing.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadObservedResourcesError": { + reason: "Missing observed resources file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + ObservedResources: "missing.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadRequiredResourcesError": { + reason: "Missing required resources file should return a wrapped load error.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + RequiredResources: "missing.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "LoadRequiredSchemasError": { + reason: "Missing required schemas directory should return a wrapped load error.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + RequiredSchemas: "missing", + Timeout: time.Minute, + fs: newTestFS(nil), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "EngineSetupError": { + reason: "Engine.Setup failures should propagate.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&MockEngine{ + MockSetup: func(_ context.Context, _ []pkgv1.Function) (func(), error) { + return func() {}, errors.New("setup blew up") + }, + }), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "EngineRenderError": { + reason: "Engine.Render failures should be wrapped.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&MockEngine{ + MockRender: func(_ context.Context, _ *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return nil, errors.New("render blew up") + }, + }), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "RenderResponseMissingComposite": { + reason: "A RenderResponse without a composite output should error.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&MockEngine{ + MockRender: func(_ context.Context, _ *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{}, nil + }, + }), + }, + }, + want: want{err: cmpopts.AnyError}, + }, + "IncludeFunctionResults": { + reason: "When --include-function-results is set, Result documents should appear in stdout.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + IncludeFunctionResults: true, + Timeout: time.Minute, + fs: newTestFS(nil), + newEngine: newEngineFunc(&MockEngine{ + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: fillResourceRefs(t, req.GetComposite().GetCompositeResource()), + Events: []*renderv1alpha1.Event{{ + Type: "Normal", + Reason: "Hello", + Message: "function says hi", + }}, + }, + }, + }, nil + }, + }), + }, + }, + want: want{ + stdout: includeFunctionResultsOutput, + }, + }, + "IncludeFullXR": { + reason: "With --include-full-xr, the rendered XR is merged into the input XR so the input's spec.fromXR survives alongside any updated fields.", + args: args{ + cmd: Cmd{ + CompositeResource: "xr.yaml", + Composition: "composition.yaml", + Functions: "functions.yaml", + IncludeFullXR: true, + Timeout: time.Minute, + fs: newTestFS(map[string]string{"xr.yaml": xrWithExtraSpecYAML}), + newEngine: newEngineFunc(&MockEngine{ + MockRender: func(_ context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: fillResourceRefs(t, req.GetComposite().GetCompositeResource()), + }, + }, + }, nil + }, + }), + }, + }, + want: want{ + stdout: includeFullXROutput, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + buf := &bytes.Buffer{} + kctx := &kong.Context{Kong: &kong.Kong{Stdout: buf, Stderr: io.Discard}} + + err := tc.args.cmd.Run(kctx, logging.NewNopLogger()) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nRun(...): -want error, +got error:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.stdout, buf.String()); diff != "" { + t.Errorf("\n%s\nRun(...): -want stdout +got stdout:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/render/context.go b/cmd/crossplane/render/context.go new file mode 100644 index 0000000..2dc6ab6 --- /dev/null +++ b/cmd/crossplane/render/context.go @@ -0,0 +1,56 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// BuildContextData merges --context-files and --context-values into a single +// map. Files are read first; values take precedence when a key appears in +// both. +func BuildContextData(fs afero.Fs, ctxFiles, ctxValues map[string]string) (map[string][]byte, error) { + out := map[string][]byte{} + for k, filename := range ctxFiles { + v, err := afero.ReadFile(fs, filename) + if err != nil { + return nil, errors.Wrapf(err, "cannot read context value for key %q", k) + } + out[k] = v + } + for k, v := range ctxValues { + out[k] = []byte(v) + } + return out, nil +} + +// ParseContextData parses each raw context value as YAML/JSON and returns a +// map suitable for seeding the pipeline context. +func ParseContextData(raw map[string][]byte) (map[string]any, error) { + out := map[string]any{} + for k, v := range raw { + var parsed any + if err := yaml.Unmarshal(v, &parsed); err != nil { + return nil, errors.Wrapf(err, "cannot unmarshal context value for key %q", k) + } + out[k] = parsed + } + return out, nil +} diff --git a/cmd/crossplane/render/contextfn/context.go b/cmd/crossplane/render/contextfn/context.go new file mode 100644 index 0000000..1706bcf --- /dev/null +++ b/cmd/crossplane/render/contextfn/context.go @@ -0,0 +1,141 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package contextfn implements an in-process composition function that the +// CLI hosts to inject and capture pipeline context for `crossplane render`. +// It replaces the previous function-go-templating based approach so the CLI +// no longer depends on an external function image for context handling. +package contextfn + +import ( + "context" + "encoding/json" + "maps" + "sync" + + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + + fnv1 "github.com/crossplane/crossplane/v2/proto/fn/v1" +) + +// FunctionName is the pkgv1.Function.Name used for the in-process context +// function. It is exported because callers must key the FunctionInput +// address map with it. +const FunctionName = "crossplane-render-context" + +const ( + stepSeed = "crossplane-render-inject-context" + stepCapture = "crossplane-render-extract-context" + + modeSeed = "Seed" + modeCapture = "Capture" +) + +// input is the pipeline step input for the context function. The only field +// it carries is the mode: because the function runs in-process with the CLI, +// context data does not need to round-trip through the wire. +type input struct { + Mode string `json:"mode"` +} + +// server implements the v1 FunctionRunnerService in-process. It holds the +// CLI-parsed context data and records the end-of-pipeline context. +type server struct { + fnv1.UnimplementedFunctionRunnerServiceServer + + contextData map[string]any + + mu sync.Mutex + captured *structpb.Struct +} + +func newServer(contextData map[string]any) *server { + return &server{contextData: contextData} +} + +// capturedContext returns the context observed by the most recent capture +// invocation, or nil if capture has not run. +func (s *server) capturedContext() *structpb.Struct { + s.mu.Lock() + defer s.mu.Unlock() + return s.captured +} + +// RunFunction handles both seed and capture modes based on the input. +// Exported because it implements fnv1.FunctionRunnerServiceServer. +func (s *server) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) { + in := input{} + if raw := req.GetInput(); raw != nil { + b, err := raw.MarshalJSON() + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "cannot marshal input: %v", err) + } + if err := json.Unmarshal(b, &in); err != nil { + return nil, status.Errorf(codes.InvalidArgument, "cannot unmarshal input: %v", err) + } + } + + switch in.Mode { + case modeSeed: + return s.seed(req) + case modeCapture: + return s.capture(req) + default: + return nil, status.Errorf(codes.InvalidArgument, "unsupported mode %q", in.Mode) + } +} + +func (s *server) seed(req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) { + merged := map[string]any{} + if c := req.GetContext(); c != nil { + merged = c.AsMap() + } + maps.Copy(merged, s.contextData) + + sp, err := structpb.NewStruct(merged) + if err != nil { + return nil, status.Errorf(codes.Internal, "cannot build context struct: %v", err) + } + + return &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: req.GetMeta().GetTag()}, + Desired: req.GetDesired(), + Context: sp, + }, nil +} + +func (s *server) capture(req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) { + var captured *structpb.Struct + if c := req.GetContext(); c != nil { + clone, ok := proto.Clone(c).(*structpb.Struct) + if !ok { + return nil, status.Errorf(codes.Internal, "unexpected type from proto.Clone") + } + captured = clone + } + + s.mu.Lock() + s.captured = captured + s.mu.Unlock() + + return &fnv1.RunFunctionResponse{ + Meta: &fnv1.ResponseMeta{Tag: req.GetMeta().GetTag()}, + Desired: req.GetDesired(), + }, nil +} diff --git a/cmd/crossplane/render/contextfn/context_test.go b/cmd/crossplane/render/contextfn/context_test.go new file mode 100644 index 0000000..a762f2c --- /dev/null +++ b/cmd/crossplane/render/contextfn/context_test.go @@ -0,0 +1,155 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package contextfn + +import ( + "context" + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/testing/protocmp" + "google.golang.org/protobuf/types/known/structpb" + + fnv1 "github.com/crossplane/crossplane/v2/proto/fn/v1" +) + +func mustStruct(t *testing.T, m map[string]any) *structpb.Struct { + t.Helper() + s, err := structpb.NewStruct(m) + if err != nil { + t.Fatalf("structpb.NewStruct: %v", err) + } + return s +} + +func inputStruct(t *testing.T, mode string) *structpb.Struct { + t.Helper() + b, err := json.Marshal(input{Mode: mode}) + if err != nil { + t.Fatalf("marshal input: %v", err) + } + s := &structpb.Struct{} + if err := s.UnmarshalJSON(b); err != nil { + t.Fatalf("unmarshal input struct: %v", err) + } + return s +} + +func TestSeed(t *testing.T) { + cases := map[string]struct { + contextData map[string]any + reqContext map[string]any + want map[string]any + }{ + "EmptyIncomingContext": { + contextData: map[string]any{"k1": "v1", "nested": map[string]any{"a": 1.0}}, + reqContext: nil, + want: map[string]any{"k1": "v1", "nested": map[string]any{"a": 1.0}}, + }, + "OverlaysExistingContext": { + contextData: map[string]any{"override": "new"}, + reqContext: map[string]any{"keep": "yes", "override": "old"}, + want: map[string]any{"keep": "yes", "override": "new"}, + }, + "NilContextData": { + contextData: nil, + reqContext: map[string]any{"keep": "yes"}, + want: map[string]any{"keep": "yes"}, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + s := newServer(tc.contextData) + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "tag"}, + Input: inputStruct(t, modeSeed), + } + if tc.reqContext != nil { + req.Context = mustStruct(t, tc.reqContext) + } + + rsp, err := s.RunFunction(context.Background(), req) + if err != nil { + t.Fatalf("RunFunction: %v", err) + } + if diff := cmp.Diff(mustStruct(t, tc.want), rsp.GetContext(), protocmp.Transform()); diff != "" { + t.Errorf("context (-want +got):\n%s", diff) + } + if rsp.GetMeta().GetTag() != "tag" { + t.Errorf("meta.tag: want %q, got %q", "tag", rsp.GetMeta().GetTag()) + } + }) + } +} + +func TestCapture(t *testing.T) { + incoming := map[string]any{"foo": "bar", "n": 42.0} + + s := newServer(nil) + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "tag"}, + Input: inputStruct(t, modeCapture), + Context: mustStruct(t, incoming), + } + + rsp, err := s.RunFunction(context.Background(), req) + if err != nil { + t.Fatalf("RunFunction: %v", err) + } + + if rsp.GetContext() != nil { + t.Errorf("capture should not forward context, got %v", rsp.GetContext()) + } + if diff := cmp.Diff(mustStruct(t, incoming), s.capturedContext(), protocmp.Transform()); diff != "" { + t.Errorf("captured (-want +got):\n%s", diff) + } +} + +func TestCaptureNilContext(t *testing.T) { + s := newServer(nil) + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "tag"}, + Input: inputStruct(t, modeCapture), + } + + if _, err := s.RunFunction(context.Background(), req); err != nil { + t.Fatalf("RunFunction: %v", err) + } + if s.capturedContext() != nil { + t.Errorf("captured: want nil, got %v", s.capturedContext()) + } +} + +func TestUnknownMode(t *testing.T) { + s := newServer(nil) + req := &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "tag"}, + Input: inputStruct(t, "bogus"), + } + + _, err := s.RunFunction(context.Background(), req) + if err == nil { + t.Fatal("want error for unknown mode") + } + if got := status.Code(err); got != codes.InvalidArgument { + t.Errorf("status code: want %v, got %v", codes.InvalidArgument, got) + } +} diff --git a/cmd/crossplane/render/contextfn/listener.go b/cmd/crossplane/render/contextfn/listener.go new file mode 100644 index 0000000..3a7c81f --- /dev/null +++ b/cmd/crossplane/render/contextfn/listener.go @@ -0,0 +1,151 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package contextfn + +import ( + "context" + "encoding/json" + "net" + "os" + "path/filepath" + "sync" + "time" + + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/types/known/structpb" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + fnv1 "github.com/crossplane/crossplane/v2/proto/fn/v1" +) + +// Handle is the owner of a running in-process context function. +type Handle struct { + // Target is the gRPC target that dials the function. Set this as the + // FunctionInput address passed to the render engine. + Target string + + srv *grpc.Server + fn *server + socketPath string + dir string + stop sync.Once + log logging.Logger + seedInput *runtime.RawExtension + captureInput *runtime.RawExtension +} + +// Captured returns the context observed by the capture step, or nil if +// capture did not run. +func (h *Handle) Captured() *structpb.Struct { + return h.fn.capturedContext() +} + +// Start starts an in-process gRPC server that implements the composition +// function RunFunction RPC for context seeding and capture. The server +// listens on a unix-domain socket inside a fresh temp directory. Callers +// must call Handle.Stop when done. +func Start(ctx context.Context, log logging.Logger, contextData map[string]any) (*Handle, error) { + si, err := json.Marshal(input{Mode: modeSeed}) + if err != nil { + return nil, errors.Wrap(err, "cannot create seed context function input") + } + ci, err := json.Marshal(input{Mode: modeCapture}) + if err != nil { + return nil, errors.Wrap(err, "cannot create capture context function input") + } + + dir, err := os.MkdirTemp("", "render-ctx-*") + if err != nil { + return nil, errors.Wrap(err, "cannot create temp dir for context function socket") + } + + cleanup := func() { + _ = os.RemoveAll(dir) + } + + sockPath := filepath.Join(dir, "s") + var lc net.ListenConfig + lis, err := lc.Listen(ctx, "unix", sockPath) + if err != nil { + cleanup() + return nil, errors.Wrapf(err, "cannot listen on %q", sockPath) + } + + cleanup = func() { + _ = lis.Close() + _ = os.RemoveAll(dir) + } + + // In order for processes in Docker containers to connect to the socket, the + // socket must be world-writeable and its containing directory must be + // world-readable. + if err := os.Chmod(dir, 0o755); err != nil { //nolint:gosec // Necessary. + cleanup() + return nil, errors.Wrapf(err, "cannot make socket directory world-readable") + } + if err := os.Chmod(sockPath, 0o777); err != nil { //nolint:gosec // Necessary. + cleanup() + return nil, errors.Wrapf(err, "cannot make socket file writeable") + } + + srv := grpc.NewServer(grpc.Creds(insecure.NewCredentials())) + fn := newServer(contextData) + fnv1.RegisterFunctionRunnerServiceServer(srv, fn) + + h := &Handle{ + Target: "unix://" + sockPath, + srv: srv, + fn: fn, + socketPath: sockPath, + dir: dir, + log: log, + seedInput: &runtime.RawExtension{Raw: si}, + captureInput: &runtime.RawExtension{Raw: ci}, + } + + go func() { + if err := srv.Serve(lis); err != nil { + log.Debug("Context function gRPC server stopped", "error", err) + } + }() + + return h, nil +} + +// Stop gracefully stops the function server and removes the socket directory. +// Safe to call multiple times. +func (h *Handle) Stop() { + h.stop.Do(func() { + done := make(chan struct{}) + go func() { + h.srv.GracefulStop() + close(done) + }() + select { + case <-done: + case <-time.After(5 * time.Second): + h.srv.Stop() + } + if err := os.RemoveAll(h.dir); err != nil { + h.log.Debug("Cannot remove context function socket directory", "dir", h.dir, "error", err) + } + }) +} diff --git a/cmd/crossplane/render/contextfn/listener_test.go b/cmd/crossplane/render/contextfn/listener_test.go new file mode 100644 index 0000000..a72e3f3 --- /dev/null +++ b/cmd/crossplane/render/contextfn/listener_test.go @@ -0,0 +1,87 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package contextfn + +import ( + "context" + "os" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/protobuf/testing/protocmp" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + fnv1 "github.com/crossplane/crossplane/v2/proto/fn/v1" +) + +func TestListenerRoundTrip(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + data := map[string]any{"k": "v"} + h, err := Start(ctx, logging.NewNopLogger(), data) + if err != nil { + t.Fatalf("start: %v", err) + } + defer h.Stop() + + conn, err := grpc.NewClient(h.Target, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + t.Fatalf("grpc.NewClient: %v", err) + } + defer conn.Close() + + client := fnv1.NewFunctionRunnerServiceClient(conn) + rsp, err := client.RunFunction(ctx, &fnv1.RunFunctionRequest{ + Meta: &fnv1.RequestMeta{Tag: "tag"}, + Input: inputStruct(t, modeSeed), + }) + if err != nil { + t.Fatalf("RunFunction: %v", err) + } + + if diff := cmp.Diff(mustStruct(t, data), rsp.GetContext(), protocmp.Transform()); diff != "" { + t.Errorf("context (-want +got):\n%s", diff) + } +} + +func TestStopRemovesSocket(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + h, err := Start(ctx, logging.NewNopLogger(), nil) + if err != nil { + t.Fatalf("start: %v", err) + } + + if _, err := os.Stat(h.socketPath); err != nil { + t.Fatalf("socket should exist: %v", err) + } + + h.Stop() + + if _, err := os.Stat(h.socketPath); !os.IsNotExist(err) { + t.Errorf("socket should not exist after Stop, got err=%v", err) + } + + // Second Stop is a no-op. + h.Stop() +} diff --git a/cmd/crossplane/render/contextfn/wire.go b/cmd/crossplane/render/contextfn/wire.go new file mode 100644 index 0000000..21dc90b --- /dev/null +++ b/cmd/crossplane/render/contextfn/wire.go @@ -0,0 +1,86 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package contextfn + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + opsv1alpha1 "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +// Mirror the render package's runtime-selection annotations. Duplicated here +// rather than importing cmd/crank/render to avoid an import cycle. +const ( + annotationKeyRuntime = "render.crossplane.io/runtime" + annotationValueRuntimeInProcess = "InProcess" +) + +// Function returns the Function definition the caller must add to the +// render engine's functions list so the in-process context function is +// known by name. +func (h *Handle) Function() pkgv1.Function { + return pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Name: FunctionName, + Annotations: map[string]string{annotationKeyRuntime: annotationValueRuntimeInProcess}, + }, + } +} + +// CompositeSeedStep returns a Composition pipeline step that seeds the +// pipeline context from the handle's context data. Prepend it to a +// Composition pipeline. +func (h *Handle) CompositeSeedStep() apiextensionsv1.PipelineStep { + return apiextensionsv1.PipelineStep{ + Step: stepSeed, + FunctionRef: apiextensionsv1.FunctionReference{Name: FunctionName}, + Input: h.seedInput, + } +} + +// CompositeCaptureStep returns a Composition pipeline step that captures +// the end-of-pipeline context into the handle. Append it to a Composition +// pipeline. +func (h *Handle) CompositeCaptureStep() apiextensionsv1.PipelineStep { + return apiextensionsv1.PipelineStep{ + Step: stepCapture, + FunctionRef: apiextensionsv1.FunctionReference{Name: FunctionName}, + Input: h.captureInput, + } +} + +// OperationSeedStep is the Operation pipeline equivalent of +// CompositeSeedStep. +func (h *Handle) OperationSeedStep() opsv1alpha1.PipelineStep { + return opsv1alpha1.PipelineStep{ + Step: stepSeed, + FunctionRef: opsv1alpha1.FunctionReference{Name: FunctionName}, + Input: h.seedInput, + } +} + +// OperationCaptureStep is the Operation pipeline equivalent of +// CompositeCaptureStep. +func (h *Handle) OperationCaptureStep() opsv1alpha1.PipelineStep { + return opsv1alpha1.PipelineStep{ + Step: stepCapture, + FunctionRef: opsv1alpha1.FunctionReference{Name: FunctionName}, + Input: h.captureInput, + } +} diff --git a/cmd/crossplane/render/convert.go b/cmd/crossplane/render/convert.go new file mode 100644 index 0000000..b26a6c0 --- /dev/null +++ b/cmd/crossplane/render/convert.go @@ -0,0 +1,323 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "github.com/crossplane/function-sdk-go/resource" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" + corev1 "k8s.io/api/core/v1" + kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/kube-openapi/pkg/spec3" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + ucomposite "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + opsv1alpha1 "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1" + fnv1 "github.com/crossplane/crossplane/v2/proto/fn/v1" + + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +// BuildCompositeRequest builds a RenderRequest for a composite resource from +// the supplied inputs and function addresses. +func BuildCompositeRequest(in CompositionInputs) (*renderv1alpha1.RenderRequest, error) { + xrStruct, err := resource.AsStruct(in.CompositeResource) + if err != nil { + return nil, errors.Wrap(err, "cannot convert composite resource to protobuf") + } + + compStruct, err := asStructFromTyped(in.Composition) + if err != nil { + return nil, errors.Wrap(err, "cannot convert Composition to protobuf") + } + + fnInputs := make([]*renderv1alpha1.FunctionInput, 0, len(in.FunctionAddrs)) + for name, addr := range in.FunctionAddrs { + fnInputs = append(fnInputs, &renderv1alpha1.FunctionInput{ + Name: name, + Address: addr, + }) + } + + observedStructs, err := composedToStructs(in.ObservedResources) + if err != nil { + return nil, errors.Wrap(err, "cannot convert observed resources to protobuf") + } + + requiredStructs, err := unstructuredToStructs(in.RequiredResources) + if err != nil { + return nil, errors.Wrap(err, "cannot convert required resources to protobuf") + } + + credStructs, err := secretsToStructs(in.FunctionCredentials) + if err != nil { + return nil, errors.Wrap(err, "cannot convert credentials to protobuf") + } + + schemaStructs, err := schemasToStructs(in.RequiredSchemas) + if err != nil { + return nil, errors.Wrap(err, "cannot convert required schemas to protobuf") + } + + return &renderv1alpha1.RenderRequest{ + Meta: &renderv1alpha1.RequestMeta{}, + Input: &renderv1alpha1.RenderRequest_Composite{ + Composite: &renderv1alpha1.CompositeInput{ + CompositeResource: xrStruct, + Composition: compStruct, + Functions: fnInputs, + ObservedResources: observedStructs, + RequiredResources: requiredStructs, + RequiredSchemas: schemaStructs, + Credentials: credStructs, + }, + }, + }, nil +} + +// ParseCompositeResponse converts a CompositeOutput into Outputs for the CLI. +func ParseCompositeResponse(out *renderv1alpha1.CompositeOutput) (CompositionOutputs, error) { + xr := ucomposite.New() + if s := out.GetCompositeResource(); s != nil { + if err := resource.AsObject(s, xr); err != nil { + return CompositionOutputs{}, errors.Wrap(err, "cannot convert composite resource from protobuf") + } + } + + cds := make([]composed.Unstructured, 0, len(out.GetComposedResources())) + for _, s := range out.GetComposedResources() { + cd := composed.New() + if err := resource.AsObject(s, cd); err != nil { + return CompositionOutputs{}, errors.Wrap(err, "cannot convert composed resource from protobuf") + } + cds = append(cds, *cd) + } + + results := make([]kunstructured.Unstructured, 0, len(out.GetEvents())) + for _, ev := range out.GetEvents() { + results = append(results, kunstructured.Unstructured{Object: map[string]any{ + "apiVersion": "render.crossplane.io/v1beta1", + "kind": "Result", + "severity": ev.GetType(), + "reason": ev.GetReason(), + "message": ev.GetMessage(), + }}) + } + + rrs := make([]*fnv1.ResourceSelector, len(out.GetRequiredResources())) + for i, s := range out.GetRequiredResources() { + rs := &fnv1.ResourceSelector{} + if err := messageFromStruct(rs, s); err != nil { + return CompositionOutputs{}, errors.Wrap(err, "cannot convert resource selectors from structs") + } + rrs[i] = rs + } + + rss := make([]*fnv1.SchemaSelector, len(out.GetRequiredSchemas())) + for i, s := range out.GetRequiredSchemas() { + ss := &fnv1.SchemaSelector{} + if err := messageFromStruct(ss, s); err != nil { + return CompositionOutputs{}, errors.Wrap(err, "cannot convert schema selectors from structs") + } + rss[i] = ss + } + + return CompositionOutputs{ + CompositeResource: xr, + ComposedResources: cds, + Results: results, + RequiredResources: rrs, + RequiredSchemas: rss, + }, nil +} + +func messageFromStruct(m proto.Message, s *structpb.Struct) error { + bs, err := s.MarshalJSON() + if err != nil { + return errors.Wrap(err, "cannot marshal struct to json") + } + return errors.Wrap(protojson.Unmarshal(bs, m), "cannot unmarshal message from json") +} + +// BuildOperationRequest builds a RenderRequest for an Operation from the +// supplied inputs and function addresses. +func BuildOperationRequest(in OperationInputs) (*renderv1alpha1.RenderRequest, error) { + opStruct, err := asStructFromTyped(in.Operation) + if err != nil { + return nil, errors.Wrap(err, "cannot convert Operation to protobuf") + } + + fnInputs := make([]*renderv1alpha1.FunctionInput, 0, len(in.FunctionAddrs)) + for name, addr := range in.FunctionAddrs { + fnInputs = append(fnInputs, &renderv1alpha1.FunctionInput{ + Name: name, + Address: addr, + }) + } + + requiredStructs, err := unstructuredToStructs(in.RequiredResources) + if err != nil { + return nil, errors.Wrap(err, "cannot convert required resources to protobuf") + } + + schemaStructs, err := schemasToStructs(in.RequiredSchemas) + if err != nil { + return nil, errors.Wrap(err, "cannot convert required schemas to protobuf") + } + + credStructs, err := secretsToStructs(in.FunctionCredentials) + if err != nil { + return nil, errors.Wrap(err, "cannot convert credentials to protobuf") + } + + return &renderv1alpha1.RenderRequest{ + Meta: &renderv1alpha1.RequestMeta{}, + Input: &renderv1alpha1.RenderRequest_Operation{ + Operation: &renderv1alpha1.OperationInput{ + Operation: opStruct, + Functions: fnInputs, + RequiredResources: requiredStructs, + RequiredSchemas: schemaStructs, + Credentials: credStructs, + }, + }, + }, nil +} + +// ParseOperationResponse converts an OperationOutput into unstructured types +// for the CLI. +func ParseOperationResponse(out *renderv1alpha1.OperationOutput) (OperationOutputs, error) { + var op *opsv1alpha1.Operation + if s := out.GetOperation(); s != nil { + op = &opsv1alpha1.Operation{} + if err := resource.AsObject(s, op); err != nil { + return OperationOutputs{}, errors.Wrap(err, "cannot convert Operation from protobuf") + } + } + + applied := make([]kunstructured.Unstructured, 0, len(out.GetAppliedResources())) + for _, s := range out.GetAppliedResources() { + u := &kunstructured.Unstructured{} + if err := resource.AsObject(s, u); err != nil { + return OperationOutputs{}, errors.Wrap(err, "cannot convert applied resource from protobuf") + } + applied = append(applied, *u) + } + + results := make([]kunstructured.Unstructured, 0, len(out.GetEvents())) + for _, ev := range out.GetEvents() { + results = append(results, kunstructured.Unstructured{Object: map[string]any{ + "apiVersion": "render.crossplane.io/v1beta1", + "kind": "Result", + "severity": ev.GetType(), + "reason": ev.GetReason(), + "message": ev.GetMessage(), + }}) + } + + rrs := make([]*fnv1.ResourceSelector, len(out.GetRequiredResources())) + for i, s := range out.GetRequiredResources() { + rs := &fnv1.ResourceSelector{} + if err := messageFromStruct(rs, s); err != nil { + return OperationOutputs{}, errors.Wrap(err, "cannot convert resource selectors from structs") + } + rrs[i] = rs + } + + rss := make([]*fnv1.SchemaSelector, len(out.GetRequiredSchemas())) + for i, s := range out.GetRequiredSchemas() { + ss := &fnv1.SchemaSelector{} + if err := messageFromStruct(ss, s); err != nil { + return OperationOutputs{}, errors.Wrap(err, "cannot convert schema selectors from structs") + } + rss[i] = ss + } + + return OperationOutputs{ + Operation: op, + AppliedResources: applied, + Results: results, + RequiredResources: rrs, + RequiredSchemas: rss, + }, nil +} + +func asStructFromTyped(o runtime.Object) (*structpb.Struct, error) { + data, err := runtime.DefaultUnstructuredConverter.ToUnstructured(o) + if err != nil { + return nil, errors.Wrap(err, "cannot convert typed object to unstructured") + } + u := &kunstructured.Unstructured{Object: data} + return resource.AsStruct(u) +} + +func composedToStructs(resources []composed.Unstructured) ([]*structpb.Struct, error) { + out := make([]*structpb.Struct, 0, len(resources)) + for i := range resources { + s, err := resource.AsStruct(&resources[i]) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, nil +} + +func unstructuredToStructs(resources []kunstructured.Unstructured) ([]*structpb.Struct, error) { + out := make([]*structpb.Struct, 0, len(resources)) + for i := range resources { + s, err := resource.AsStruct(&resources[i]) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, nil +} + +func schemasToStructs(schemas []spec3.OpenAPI) ([]*structpb.Struct, error) { + out := make([]*structpb.Struct, len(schemas)) + for i, schema := range schemas { + bs, err := schema.MarshalJSON() + if err != nil { + return nil, err + } + + out[i] = &structpb.Struct{} + if err := out[i].UnmarshalJSON(bs); err != nil { + return nil, err + } + } + + return out, nil +} + +func secretsToStructs(secrets []corev1.Secret) ([]*structpb.Struct, error) { + out := make([]*structpb.Struct, 0, len(secrets)) + for i := range secrets { + s, err := asStructFromTyped(&secrets[i]) + if err != nil { + return nil, err + } + out = append(out, s) + } + return out, nil +} diff --git a/cmd/crossplane/render/engine.go b/cmd/crossplane/render/engine.go new file mode 100644 index 0000000..e7dcbea --- /dev/null +++ b/cmd/crossplane/render/engine.go @@ -0,0 +1,78 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + "fmt" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/version" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +// DefaultCrossplaneImage is the default Crossplane image used for rendering. +const DefaultCrossplaneImage = "xpkg.crossplane.io/crossplane/crossplane" + +// An Engine executes a crossplane internal render request and returns the +// response. +type Engine interface { + // CheckContextSupport validates whether context injection and collection + // works with this engine in the current runtime environment. + CheckContextSupport() error + + // Setup performs engine-specific pre-render preparation, such as + // creating Docker networks and annotating functions so their containers + // can reach the render engine. It may mutate fns. The returned cleanup + // function must be called when rendering is done. + Setup(ctx context.Context, fns []pkgv1.Function) (cleanup func(), err error) + + // Render executes the render request and returns the response. + Render(ctx context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) +} + +// EngineFlags contains flags for configuring the render engine. It is embedded +// by render command structs to provide shared engine configuration. +type EngineFlags struct { + CrossplaneVersion string `help:"Version of the Crossplane image to use for rendering (e.g. v2.2.1). Defaults to the current CLI version." placeholder:"VERSION" xor:"crossplane-selector"` + CrossplaneImage string `help:"Override the full Crossplane Docker image reference for rendering." placeholder:"IMAGE" xor:"crossplane-selector"` + CrossplaneBinary string `help:"Path to a local crossplane binary to use instead of Docker." placeholder:"PATH" type:"existingfile" xor:"crossplane-selector"` +} + +// NewEngineFromFlags creates an Engine from the flag configuration. If a binary +// path is set, it returns a local engine. Otherwise it returns a Docker engine +// using the resolved image reference. +func NewEngineFromFlags(f *EngineFlags, log logging.Logger) Engine { + if f.CrossplaneBinary != "" { + return &localRenderEngine{BinaryPath: f.CrossplaneBinary} + } + + img := f.CrossplaneImage + + if img == "" { + tag := f.CrossplaneVersion + if tag == "" { + tag = version.New().GetVersionString() + } + img = fmt.Sprintf("%s:%s", DefaultCrossplaneImage, tag) + } + + return &dockerRenderEngine{image: img, log: log} +} diff --git a/cmd/crossplane/render/engine_docker.go b/cmd/crossplane/render/engine_docker.go new file mode 100644 index 0000000..0b4e3b1 --- /dev/null +++ b/cmd/crossplane/render/engine_docker.go @@ -0,0 +1,143 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + + "google.golang.org/protobuf/proto" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/internal/docker" + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +// dockerRenderEngine executes crossplane internal render in a Docker container. +type dockerRenderEngine struct { + // image is the Crossplane Docker image reference. + image string + // network is the Docker network to connect the container to. When set, + // the container joins this network so it can reach function containers. + network string + + log logging.Logger +} + +func (e *dockerRenderEngine) CheckContextSupport() error { + if runtime.GOOS == "windows" { + return errors.New("context handling via --context-values/--context-files/--include-context is not supported on Windows") + } + if host := os.Getenv("DOCKER_HOST"); host != "" && !strings.HasPrefix(host, "unix://") { + return errors.New("context handling via --context-values/--context-files/--include-context requires a local Docker daemon or Crossplane controller binary") + } + + return nil +} + +// Setup creates a temporary Docker network, records its name so the render +// container joins it, and annotates the supplied functions so their +// containers also join it. The returned cleanup function removes the +// network. +func (e *dockerRenderEngine) Setup(ctx context.Context, fns []pkgv1.Function) (func(), error) { + networkID, networkName, err := createRenderNetwork(ctx) + if err != nil { + return func() {}, errors.Wrap(err, "cannot create Docker network for rendering") + } + + e.network = networkName + injectNetworkAnnotation(fns, networkName) + + cleanup := func() { //nolint:contextcheck // Detached context for cleanup. + _ = removeRenderNetwork(context.Background(), networkID) + } + + return cleanup, nil +} + +// Render marshals the request, runs it through a Docker container, and returns +// the response. +func (e *dockerRenderEngine) Render(ctx context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + // Update any localhost function addresses if needed. + if cinput := req.GetComposite(); cinput != nil { + cinput.Functions = RewriteAddressesForDocker(cinput.GetFunctions()) + } + if oinput := req.GetOperation(); oinput != nil { + oinput.Functions = RewriteAddressesForDocker(oinput.GetFunctions()) + } + + data, err := proto.Marshal(req) + if err != nil { + return nil, errors.Wrap(err, "cannot marshal render request") + } + + opts := []docker.RunContainerOption{ + docker.RunWithCommand([]string{"internal", "render"}), + docker.RunWithStdin(data), + // Let the container access any functions running in development mode on + // the host. + docker.RunWithExtraHosts([]string{"host.docker.internal:host-gateway"}), + } + if e.network != "" { + opts = append(opts, docker.RunWithNetworkName(e.network)) + } + + // Bind-mount the directory of every unix-socket function target into the + // render container at the same path so unix:// targets are reachable. + for _, fn := range getFunctionInputs(req) { + addr := fn.GetAddress() + if !strings.HasPrefix(addr, "unix://") { + continue + } + dir := filepath.Dir(strings.TrimPrefix(addr, "unix://")) + opts = append(opts, docker.RunWithBindMount(dir, dir)) + } + + e.log.Debug("Running crossplane internal render in Docker", "image", e.image, "network", e.network) + + stdout, _, err := docker.RunContainer(ctx, e.image, opts...) + if err != nil { + return nil, errors.Wrap(err, "cannot run crossplane internal render in Docker") + } + + rsp := &renderv1alpha1.RenderResponse{} + if err := proto.Unmarshal(stdout, rsp); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal render response") + } + + return rsp, nil +} + +// getFunctionInputs returns the FunctionInput list regardless of which oneof +// variant the RenderRequest carries. +func getFunctionInputs(req *renderv1alpha1.RenderRequest) []*renderv1alpha1.FunctionInput { + switch in := req.GetInput().(type) { + case *renderv1alpha1.RenderRequest_Composite: + return in.Composite.GetFunctions() + case *renderv1alpha1.RenderRequest_Operation: + return in.Operation.GetFunctions() + default: + return nil + } +} diff --git a/cmd/crossplane/render/engine_local.go b/cmd/crossplane/render/engine_local.go new file mode 100644 index 0000000..514220a --- /dev/null +++ b/cmd/crossplane/render/engine_local.go @@ -0,0 +1,73 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "bytes" + "context" + "os" + "os/exec" + + "google.golang.org/protobuf/proto" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +// localRenderEngine executes a local crossplane binary for rendering. +type localRenderEngine struct { + // BinaryPath is the path to the crossplane binary. + BinaryPath string +} + +func (e *localRenderEngine) CheckContextSupport() error { + return nil +} + +// Setup is a no-op for the local engine. Function containers publish ports to +// localhost, so there's nothing extra to configure. +func (e *localRenderEngine) Setup(_ context.Context, _ []pkgv1.Function) (func(), error) { + return func() {}, nil +} + +// Render marshals the request, runs it through a local binary, and returns +// the response. +func (e *localRenderEngine) Render(ctx context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + data, err := proto.Marshal(req) + if err != nil { + return nil, errors.Wrap(err, "cannot marshal render request") + } + + cmd := exec.CommandContext(ctx, e.BinaryPath, "internal", "render") //nolint:gosec // The binary path is user-supplied via CLI flag. + cmd.Stdin = bytes.NewReader(data) + cmd.Stderr = os.Stderr + + out, err := cmd.Output() + if err != nil { + return nil, errors.Wrap(err, "cannot run crossplane internal render") + } + + rsp := &renderv1alpha1.RenderResponse{} + if err := proto.Unmarshal(out, rsp); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal render response") + } + + return rsp, nil +} diff --git a/cmd/crossplane/render/engine_mock.go b/cmd/crossplane/render/engine_mock.go new file mode 100644 index 0000000..b3c81b2 --- /dev/null +++ b/cmd/crossplane/render/engine_mock.go @@ -0,0 +1,63 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +// MockEngine is a function-field mock of the Engine interface. +type MockEngine struct { + MockCheckContextSupport func() error + MockSetup func(ctx context.Context, fns []pkgv1.Function) (func(), error) + MockRender func(ctx context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) +} + +// CheckContextSupport calls MockCheckContextSupport. If unset, it returns nil. +func (m *MockEngine) CheckContextSupport() error { + if m.MockCheckContextSupport == nil { + return nil + } + return m.MockCheckContextSupport() +} + +// Setup calls MockSetup. If unset, it returns a no-op cleanup and no error. +func (m *MockEngine) Setup(ctx context.Context, fns []pkgv1.Function) (func(), error) { + if m.MockSetup == nil { + return func() {}, nil + } + return m.MockSetup(ctx, fns) +} + +// Render calls MockRender. If unset, it returns a RenderResponse echoing the +// request's composite resource as the output. +func (m *MockEngine) Render(ctx context.Context, req *renderv1alpha1.RenderRequest) (*renderv1alpha1.RenderResponse, error) { + if m.MockRender == nil { + return &renderv1alpha1.RenderResponse{ + Output: &renderv1alpha1.RenderResponse_Composite{ + Composite: &renderv1alpha1.CompositeOutput{ + CompositeResource: req.GetComposite().GetCompositeResource(), + }, + }, + }, nil + } + return m.MockRender(ctx, req) +} diff --git a/cmd/crossplane/render/load.go b/cmd/crossplane/render/load.go new file mode 100644 index 0000000..fa5971b --- /dev/null +++ b/cmd/crossplane/render/load.go @@ -0,0 +1,279 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "bufio" + "io" + "path/filepath" + + "github.com/spf13/afero" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/util/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" +) + +// LoadCompositeResource from a YAML manifest. +func LoadCompositeResource(fs afero.Fs, file string) (*composite.Unstructured, error) { + y, err := afero.ReadFile(fs, file) + if err != nil { + return nil, errors.Wrap(err, "cannot read composite resource file") + } + + xr := composite.New() + + return xr, errors.Wrap(yaml.Unmarshal(y, xr), "cannot unmarshal composite resource YAML") +} + +// TODO(negz): What if we load a YAML stream of Compositions? We could then +// render out nested XRs too. What would that look like in our output? How would +// we match XRs to Compositions (e.g. selectors, refs etc) + +// LoadComposition form a YAML manifest. +func LoadComposition(fs afero.Fs, file string) (*apiextensionsv1.Composition, error) { + y, err := afero.ReadFile(fs, file) + if err != nil { + return nil, errors.Wrap(err, "cannot read composition file") + } + + comp := &apiextensionsv1.Composition{} + if err := yaml.Unmarshal(y, comp); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal composition resource YAML") + } + + switch gvk := comp.GroupVersionKind(); gvk { + case apiextensionsv1.CompositionGroupVersionKind: + return comp, nil + default: + return nil, errors.Errorf("not a composition: %s/%s", gvk.Kind, comp.GetName()) + } +} + +// LoadXRD from a YAML manifest. +func LoadXRD(fs afero.Fs, file string) (*apiextensionsv1.CompositeResourceDefinition, error) { + y, err := afero.ReadFile(fs, file) + if err != nil { + return nil, errors.Wrap(err, "cannot read XRD file") + } + + xrd := &apiextensionsv1.CompositeResourceDefinition{} + + return xrd, errors.Wrap(yaml.Unmarshal(y, xrd), "cannot unmarshal XRD YAML") +} + +// TODO(negz): Support optionally loading functions and observed resources from +// a directory of manifests instead of a single stream. + +// LoadFunctions from a stream of YAML manifests. +func LoadFunctions(filesys afero.Fs, file string) ([]pkgv1.Function, error) { + stream, err := LoadYAMLStream(filesys, file) + if err != nil { + return nil, errors.Wrap(err, "cannot load YAML stream from file") + } + + // TODO(negz): This needs to support v1beta1 functions, too. + functions := make([]pkgv1.Function, 0, len(stream)) + for _, y := range stream { + f := &pkgv1.Function{} + if err := yaml.Unmarshal(y, f); err != nil { + return nil, errors.Wrap(err, "cannot parse YAML Function manifest") + } + + switch gvk := f.GroupVersionKind(); gvk { + case pkgv1.FunctionGroupVersionKind, pkgv1beta1.FunctionGroupVersionKind: + functions = append(functions, *f) + default: + return nil, errors.Errorf("not a function: %s/%s", gvk.Kind, f.GetName()) + } + } + + return functions, nil +} + +// LoadCredentials from a stream of YAML manifests. +func LoadCredentials(fs afero.Fs, file string) ([]corev1.Secret, error) { + stream, err := LoadYAMLStream(fs, file) + if err != nil { + return nil, errors.Wrap(err, "cannot load YAML stream from file") + } + + secrets := make([]corev1.Secret, 0, len(stream)) + for _, y := range stream { + s := &corev1.Secret{} + if err := yaml.Unmarshal(y, s); err != nil { + return nil, errors.Wrap(err, "cannot parse YAML secret manifest") + } + + secrets = append(secrets, *s) + } + + return secrets, nil +} + +// LoadRequiredResources from a stream of YAML manifests. +func LoadRequiredResources(fs afero.Fs, file string) ([]unstructured.Unstructured, error) { + stream, err := LoadYAMLStream(fs, file) + if err != nil { + return nil, errors.Wrap(err, "cannot load YAML stream from file") + } + + resources := make([]unstructured.Unstructured, 0, len(stream)) + for _, y := range stream { + r := &unstructured.Unstructured{} + if err := yaml.Unmarshal(y, r); err != nil { + return nil, errors.Wrap(err, "cannot parse YAML resource manifest") + } + + resources = append(resources, *r) + } + + return resources, nil +} + +// LoadObservedResources from a stream of YAML manifests. +func LoadObservedResources(fs afero.Fs, file string) ([]composed.Unstructured, error) { + stream, err := LoadYAMLStream(fs, file) + if err != nil { + return nil, errors.Wrap(err, "cannot load YAML stream from file") + } + + observed := make([]composed.Unstructured, 0, len(stream)) + for _, y := range stream { + cd := composed.New() + if err := yaml.Unmarshal(y, cd); err != nil { + return nil, errors.Wrap(err, "cannot parse YAML composed resource manifest") + } + + observed = append(observed, *cd) + } + + return observed, nil +} + +// LoadYAMLStream from the supplied file or directory. Returns an array of byte +// arrays, where each byte array is expected to be a YAML manifest. +func LoadYAMLStream(filesys afero.Fs, fileOrDir string) ([][]byte, error) { + var files []string + + f, err := filesys.Open(fileOrDir) + if err != nil { + return nil, errors.Wrap(err, "cannot open file") + } + + info, err := f.Stat() + if err != nil { + return nil, errors.Wrap(err, "cannot stat file") + } + + if !info.IsDir() { + files = append(files, fileOrDir) + } else { + yamls, err := getYAMLFiles(filesys, fileOrDir) + if err != nil { + return nil, errors.Wrap(err, "cannot get YAML files") + } + + files = append(files, yamls...) + if len(files) == 0 { + return nil, errors.Errorf("no YAML files found in %q (.yaml or .yml)", fileOrDir) + } + } + + out := make([][]byte, 0) + + for i := range files { + o, err := LoadYAMLStreamFromFile(filesys, files[i]) + if err != nil { + return nil, errors.Wrap(err, "cannot load YAML stream from file") + } + + out = append(out, o...) + } + + return out, nil +} + +// getYAMLFiles returns a list of YAML files from the supplied directory, sorted +// by file name, ignoring any subdirectory. +func getYAMLFiles(fs afero.Fs, dir string) (files []string, err error) { + // We don't care about nested directories, so we decided to go with a plain + // ReadDir, instead of a Walk. + // + // Previously we used Glob, but the pattern doesn't support the + // `.{yaml,yml}` syntax, so we would have had to run it twice, merge the + // results and sort them again. This just felt easier to switch to afero.Walk if + // we ever decided to support subdirectories. + entries, err := afero.ReadDir(fs, dir) + if err != nil { + return nil, errors.Wrap(err, "cannot read directory") + } + + for _, entry := range entries { + if entry.IsDir() { + // We don't care about nested directories. + continue + } + + switch filepath.Ext(entry.Name()) { + case ".yaml", ".yml": + files = append(files, filepath.Join(dir, entry.Name())) + } + } + + return files, nil +} + +// LoadYAMLStreamFromFile from the supplied file. Returns an array of byte +// arrays, where each byte array is expected to be a YAML manifest. +func LoadYAMLStreamFromFile(fs afero.Fs, file string) ([][]byte, error) { + out := make([][]byte, 0) + + f, err := fs.Open(file) + if err != nil { + return nil, errors.Wrap(err, "cannot open file") + } + defer f.Close() //nolint:errcheck // Only open for reading. + + yr := yaml.NewYAMLReader(bufio.NewReader(f)) + + for { + bytes, err := yr.Read() + if errors.Is(err, io.EOF) { + break + } + + if err != nil { + return nil, errors.Wrap(err, "cannot parse YAML stream") + } + + if len(bytes) == 0 { + continue + } + + out = append(out, bytes) + } + + return out, nil +} diff --git a/cmd/crossplane/render/load_test.go b/cmd/crossplane/render/load_test.go new file mode 100644 index 0000000..9c226de --- /dev/null +++ b/cmd/crossplane/render/load_test.go @@ -0,0 +1,590 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "embed" + "encoding/json" + "testing" + "testing/fstest" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" + v1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + runtime "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +//go:embed testdata +var testdatafs embed.FS + +func TestLoadCompositeResource(t *testing.T) { + fs := afero.FromIOFS{FS: testdatafs} + + type want struct { + xr *composite.Unstructured + err error + } + + cases := map[string]struct { + file string + want want + }{ + "Success": { + file: "testdata/xr.yaml", + want: want{ + xr: &composite.Unstructured{ + Unstructured: unstructured.Unstructured{ + Object: MustLoadJSON(`{ + "apiVersion": "nop.example.org/v1alpha1", + "kind": "XNopResource", + "metadata": { + "name": "test-render" + }, + "spec": { + "coolField": "I'm cool!" + } + }`), + }, + }, + }, + }, + "NoSuchFile": { + file: "testdata/nonexist.yaml", + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + xr, err := LoadCompositeResource(fs, tc.file) + + if diff := cmp.Diff(tc.want.xr, xr, test.EquateConditions()); diff != "" { + t.Errorf("LoadCompositeResource(..), -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("LoadCompositeResource(..), -want, +got:\n%s", diff) + } + }) + } +} + +func TestLoadXRD(t *testing.T) { + fs := afero.FromIOFS{FS: testdatafs} + + type want struct { + xrd *apiextensionsv1.CompositeResourceDefinition + err error + } + + cases := map[string]struct { + file string + want want + }{ + "Success": { + file: "testdata/xrd.yaml", + want: want{ + xrd: &apiextensionsv1.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + Kind: apiextensionsv1.CompositeResourceDefinitionKind, + APIVersion: apiextensionsv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: "xnopresources.nop.example.org"}, + Spec: apiextensionsv1.CompositeResourceDefinitionSpec{ + Group: "nop.example.org", + Names: v1.CustomResourceDefinitionNames{ + Kind: "XNopResource", + Plural: "xnopresources", + Singular: "xnopresource", + ShortNames: []string{"xnr"}, + }, + Versions: []apiextensionsv1.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Schema: &apiextensionsv1.CompositeResourceValidation{ + OpenAPIV3Schema: runtime.RawExtension{ + Raw: []byte(`{"description":"A test resource","properties":{"spec":{"properties":{"coolField":{"type":"string"}},"type":"object"}},"type":"object"}`), + }, + }, + }, + }, + }, + }, + }, + }, + "NoSuchFile": { + file: "testdata/nonexist.yaml", + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + xrd, err := LoadXRD(fs, tc.file) + + if diff := cmp.Diff(tc.want.xrd, xrd, test.EquateConditions()); diff != "" { + t.Errorf("LoadXRD(..), -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("LoadXRD(..), -want, +got:\n%s", diff) + } + }) + } +} + +func TestLoadComposition(t *testing.T) { + fs := afero.FromIOFS{FS: testdatafs} + + type want struct { + comp *apiextensionsv1.Composition + err error + } + + cases := map[string]struct { + file string + want want + }{ + "Success": { + file: "testdata/composition.yaml", + want: want{ + comp: &apiextensionsv1.Composition{ + TypeMeta: metav1.TypeMeta{ + Kind: apiextensionsv1.CompositionKind, + APIVersion: apiextensionsv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{Name: "xnopresources.nop.example.org"}, + Spec: apiextensionsv1.CompositionSpec{ + CompositeTypeRef: apiextensionsv1.TypeReference{ + APIVersion: "nop.example.org/v1alpha1", + Kind: "XNopResource", + }, + Mode: apiextensionsv1.CompositionModePipeline, + Pipeline: []apiextensionsv1.PipelineStep{{ + Step: "be-a-dummy", + FunctionRef: apiextensionsv1.FunctionReference{Name: "function-dummy"}, + }}, + }, + }, + }, + }, + "NoSuchFile": { + file: "testdata/nonexist.yaml", + want: want{ + err: cmpopts.AnyError, + }, + }, + "NotAComposition": { + file: "testdata/xr.yaml", + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + xr, err := LoadComposition(fs, tc.file) + + if diff := cmp.Diff(tc.want.comp, xr, test.EquateConditions()); diff != "" { + t.Errorf("LoadComposition(..), -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("LoadComposition(..), -want, +got:\n%s", diff) + } + }) + } +} + +func TestLoadFunctions(t *testing.T) { + fs := afero.FromIOFS{FS: testdatafs} + + type want struct { + fns []pkgv1.Function + err error + } + + cases := map[string]struct { + file string + want want + }{ + "Success": { + file: "testdata/functions.yaml", + want: want{ + fns: []pkgv1.Function{ + { + TypeMeta: metav1.TypeMeta{ + Kind: pkgv1.FunctionKind, + APIVersion: pkgv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-auto-ready", + Annotations: map[string]string{ + AnnotationKeyRuntime: string(AnnotationValueRuntimeDocker), + AnnotationKeyRuntimeDockerCleanup: string(AnnotationValueRuntimeDockerCleanupOrphan), + }, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.1.2", + }, + }, + }, + { + TypeMeta: metav1.TypeMeta{ + Kind: pkgv1.FunctionKind, + APIVersion: pkgv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-dummy", + Annotations: map[string]string{ + AnnotationKeyRuntime: string(AnnotationValueRuntimeDevelopment), + AnnotationKeyRuntimeDevelopmentTarget: "localhost:9444", + }, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1", + }, + }, + }, + { + TypeMeta: metav1.TypeMeta{ + Kind: pkgv1.FunctionKind, + APIVersion: pkgv1.SchemeGroupVersion.String(), + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "function-auto-ready", + Annotations: map[string]string{ + AnnotationKeyRuntime: string(AnnotationValueRuntimeDocker), + AnnotationKeyRuntimeDockerCleanup: string(AnnotationValueRuntimeDockerCleanupOrphan), + AnnotationKeyRuntimeNamedContainer: "function-auto-ready", + }, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.1.2", + }, + }, + }, + }, + }, + }, + "NoSuchFile": { + file: "testdata/nonexist.yaml", + want: want{ + err: cmpopts.AnyError, + }, + }, + "NotAFunction": { + file: "testdata/xr.yaml", + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + xr, err := LoadFunctions(fs, tc.file) + + if diff := cmp.Diff(tc.want.fns, xr, test.EquateConditions()); diff != "" { + t.Errorf("LoadFunctions(..), -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("LoadFunctions(..), -want, +got:\n%s", diff) + } + }) + } +} + +func TestLoadObservedResources(t *testing.T) { + fs := afero.FromIOFS{FS: testdatafs} + + type want struct { + ors []composed.Unstructured + err error + } + + cases := map[string]struct { + file string + want want + }{ + "Success": { + file: "testdata/observed.yaml", + want: want{ + ors: []composed.Unstructured{ + { + Unstructured: unstructured.Unstructured{Object: MustLoadJSON(`{ + "apiVersion": "example.org/v1alpha1", + "kind": "ComposedResource", + "metadata": { + "name": "test-render-a", + "annotations": { + "crossplane.io/composition-resource-name": "resource-a" + } + }, + "spec": { + "coolField": "I'm cool!" + } + }`)}, + }, + { + Unstructured: unstructured.Unstructured{Object: MustLoadJSON(`{ + "apiVersion": "example.org/v1alpha1", + "kind": "ComposedResource", + "metadata": { + "name": "test-render-b", + "annotations": { + "crossplane.io/composition-resource-name": "resource-b" + } + }, + "spec": { + "coolerField": "I'm cooler!" + } + }`)}, + }, + }, + }, + }, + "NoSuchFile": { + file: "testdata/nonexist.yaml", + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + xr, err := LoadObservedResources(fs, tc.file) + + if diff := cmp.Diff(tc.want.ors, xr, test.EquateConditions()); diff != "" { + t.Errorf("LoadObservedResources(..), -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("LoadObservedResources(..), -want, +got:\n%s", diff) + } + }) + } +} + +func TestLoadRequiredResources(t *testing.T) { + fs := afero.FromIOFS{FS: testdatafs} + + type args struct { + file string + fs afero.Fs + } + + type want struct { + out []unstructured.Unstructured + err error + } + + cases := map[string]struct { + args args + want want + }{ + "Success": { + args: args{ + file: "testdata/extra-resources.yaml", + fs: fs, + }, + want: want{ + out: []unstructured.Unstructured{ + { + Object: MustLoadJSON(`{ + "apiVersion": "example.org/v1alpha1", + "kind": "RequiredResourceA", + "metadata": { + "name": "test-extra-a", + "annotations": { + "some-annotation": "some-value" + } + }, + "spec": { + "coolField": "I'm cool!" + } + }`), + }, + { + Object: MustLoadJSON(`{ + "apiVersion": "example.org/v1", + "kind": "RequiredResourceB", + "metadata": { + "name": "test-extra-b", + "annotations": { + "some-other-annotation": "some-other-value" + }, + "labels": { + "some-label": "another-value" + } + }, + "spec": { + "coolerField": "I'm cooler!" + } + }`), + }, + }, + }, + }, + "NoSuchFile": { + args: args{ + file: "testdata/nonexist.yaml", + fs: fs, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f, err := LoadRequiredResources(tc.args.fs, tc.args.file) + + if diff := cmp.Diff(tc.want.out, f, test.EquateConditions()); diff != "" { + t.Errorf("LoadRequiredResources(..), -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("LoadRequiredResources(..), -want, +got:\n%s", diff) + } + }) + } +} + +func TestLoadYAMLStream(t *testing.T) { + type args struct { + file string + fs afero.Fs + } + + type want struct { + out [][]byte + err error + } + + cases := map[string]struct { + args args + want want + }{ + "Success": { + args: args{ + file: "testdata/observed.yaml", + fs: afero.FromIOFS{ + FS: fstest.MapFS{ + "testdata/observed.yaml": &fstest.MapFile{ + Data: []byte(`--- +test: "test" +--- +test: "test2" +`), + }, + }, + }, + }, + want: want{ + out: [][]byte{ + []byte("---\ntest: \"test\"\n"), + []byte("test: \"test2\"\n"), + }, + }, + }, + "NoSuchFile": { + args: args{ + file: "testdata/nonexist.yaml", + fs: afero.FromIOFS{FS: fstest.MapFS{}}, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "SuccessWithSubdirectory": { + args: args{ + file: "testdata", + fs: afero.FromIOFS{FS: fstest.MapFS{ + "testdata/file-1.yaml": &fstest.MapFile{ + Data: []byte(`--- +test: "file-1" +`), + }, + "testdata/file-2.yaml": &fstest.MapFile{ + Data: []byte(`--- +test: "file-2" +`), + }, + "testdata/file-3.txt": &fstest.MapFile{ + Data: []byte(`THIS SHOULD NOT BE LOADED`), + }, + "testdata/subdir/file-4.yaml": &fstest.MapFile{ + Data: []byte(`THIS IS IN A SUBDIRECTORY AND SHOULD NOT BE LOADED`), + }, + }}, + }, + want: want{ + out: [][]byte{ + []byte("---\ntest: \"file-1\"\n"), + []byte("---\ntest: \"file-2\"\n"), + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f, err := LoadYAMLStream(tc.args.fs, tc.args.file) + + if diff := cmp.Diff(tc.want.out, f, cmpopts.AcyclicTransformer("string", func(in []byte) string { + return string(in) + })); diff != "" { + t.Errorf("LoadYAMLStreamFromFile(..), -want, +got:\n%s", diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("LoadYAMLStreamFromFile(..), -want, +got:\n%s", diff) + } + }) + } +} + +func MustLoadJSON(j string) map[string]any { + out := make(map[string]any) + if err := json.Unmarshal([]byte(j), &out); err != nil { + panic(err) + } + + return out +} diff --git a/cmd/crossplane/render/network.go b/cmd/crossplane/render/network.go new file mode 100644 index 0000000..1772f6e --- /dev/null +++ b/cmd/crossplane/render/network.go @@ -0,0 +1,59 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + "fmt" + + "github.com/docker/docker/api/types/network" + "k8s.io/apimachinery/pkg/util/rand" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/internal/docker" +) + +// createRenderNetwork creates a temporary Docker bridge network for render. +// Function containers and the Crossplane render container join this network so +// they can reach each other. Returns the network ID and name. +func createRenderNetwork(ctx context.Context) (string, string, error) { + cli, err := docker.NewClient() + if err != nil { + return "", "", errors.Wrap(err, "cannot create Docker client") + } + + name := fmt.Sprintf("crossplane-render-%s", rand.String(8)) + + resp, err := cli.NetworkCreate(ctx, name, network.CreateOptions{ + Driver: "bridge", + }) + if err != nil { + return "", "", errors.Wrapf(err, "cannot create Docker network %q", name) + } + + return resp.ID, name, nil +} + +// removeRenderNetwork removes a temporary Docker network. +func removeRenderNetwork(ctx context.Context, networkID string) error { + cli, err := docker.NewClient() + if err != nil { + return errors.Wrap(err, "cannot create Docker client") + } + return errors.Wrap(cli.NetworkRemove(ctx, networkID), "cannot remove Docker network") +} diff --git a/cmd/crossplane/render/render.go b/cmd/crossplane/render/render.go new file mode 100644 index 0000000..63da1a3 --- /dev/null +++ b/cmd/crossplane/render/render.go @@ -0,0 +1,217 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + "strings" + "time" + + corev1 "k8s.io/api/core/v1" + kunstructured "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/kube-openapi/pkg/spec3" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/meta" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + ucomposite "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + + apiextensionsv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + opsv1alpha1 "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + fnv1 "github.com/crossplane/crossplane/v2/proto/fn/v1" + + renderv1alpha1 "github.com/crossplane/cli/v2/proto/render/v1alpha1" +) + +// Annotations added to composed resources. +const ( + AnnotationKeyCompositionResourceName = "crossplane.io/composition-resource-name" + AnnotationKeyCompositeName = "crossplane.io/composite" + AnnotationKeyClaimNamespace = "crossplane.io/claim-namespace" + AnnotationKeyClaimName = "crossplane.io/claim-name" +) + +// CompositionInputs contains all inputs to the render process. +type CompositionInputs struct { + CompositeResource *ucomposite.Unstructured + Composition *apiextensionsv1.Composition + FunctionAddrs map[string]string + FunctionCredentials []corev1.Secret + ObservedResources []composed.Unstructured + RequiredResources []kunstructured.Unstructured + RequiredSchemas []spec3.OpenAPI +} + +// CompositionOutputs contains all outputs from the render process. +type CompositionOutputs struct { + CompositeResource *ucomposite.Unstructured + ComposedResources []composed.Unstructured + Results []kunstructured.Unstructured + Context *kunstructured.Unstructured + RequiredResources []*fnv1.ResourceSelector + RequiredSchemas []*fnv1.SchemaSelector +} + +// OperationInputs contains all inputs to the render process for an operation. +type OperationInputs struct { + Operation *opsv1alpha1.Operation + FunctionAddrs map[string]string + FunctionCredentials []corev1.Secret + RequiredResources []kunstructured.Unstructured + RequiredSchemas []spec3.OpenAPI +} + +// OperationOutputs contains all outputs from the render process. +type OperationOutputs struct { + Operation *opsv1alpha1.Operation + AppliedResources []kunstructured.Unstructured + Results []kunstructured.Unstructured + Context *kunstructured.Unstructured + RequiredResources []*fnv1.ResourceSelector + RequiredSchemas []*fnv1.SchemaSelector +} + +// FunctionAddresses maps function names to their gRPC target addresses. +type FunctionAddresses struct { + addrs map[string]string + contexts map[string]RuntimeContext +} + +// Addresses returns the function name to gRPC address map. +func (fa *FunctionAddresses) Addresses() map[string]string { + return fa.addrs +} + +// Stop all function runtimes. +func (fa *FunctionAddresses) Stop(ctx context.Context) error { + for name, rctx := range fa.contexts { + if err := rctx.Stop(ctx); err != nil { + return errors.Wrapf(err, "cannot stop function %q runtime (target %q)", name, rctx.Target) + } + } + return nil +} + +// StartFunctionRuntimes starts the runtime for each function and returns their +// gRPC addresses. The caller must call Stop on the returned FunctionAddresses +// when done. +func StartFunctionRuntimes(ctx context.Context, log logging.Logger, fns []pkgv1.Function) (*FunctionAddresses, error) { + addrs := make(map[string]string, len(fns)) + contexts := make(map[string]RuntimeContext, len(fns)) + + for _, fn := range fns { + rt, err := GetRuntime(fn, log) + if err != nil { + return nil, errors.Wrapf(err, "cannot get runtime for Function %q", fn.GetName()) + } + + rctx, err := rt.Start(ctx) + if err != nil { + return nil, errors.Wrapf(err, "cannot start Function %q", fn.GetName()) + } + + addrs[fn.GetName()] = rctx.Target + contexts[fn.GetName()] = rctx + } + + return &FunctionAddresses{addrs: addrs, contexts: contexts}, nil +} + +// RewriteAddressesForDocker rewrites function addresses so they are reachable +// from inside a Docker container. Addresses targeting localhost or 127.0.0.1 +// are rewritten to host.docker.internal. +func RewriteAddressesForDocker(fns []*renderv1alpha1.FunctionInput) []*renderv1alpha1.FunctionInput { + for _, fn := range fns { + fn.Address = strings.Replace(fn.GetAddress(), "localhost:", "host.docker.internal:", 1) + fn.Address = strings.Replace(fn.GetAddress(), "127.0.0.1:", "host.docker.internal:", 1) + } + return fns +} + +// GetSecret retrieves the secret with the specified name and namespace from the provided list of secrets. +func GetSecret(name string, nameSpace string, secrets []corev1.Secret) (*corev1.Secret, error) { + for _, s := range secrets { + if s.GetName() == name && s.GetNamespace() == nameSpace { + return &s, nil + } + } + + return nil, errors.Errorf("secret %q not found", name) +} + +// SetComposedResourceMetadata sets standard, required composed resource +// metadata. It mirrors the behavior of RenderComposedResourceMetadata in +// Crossplane's composition controller. +func SetComposedResourceMetadata(cd resource.Object, xr resource.LegacyComposite, name string) error { + namePrefix := xr.GetLabels()[AnnotationKeyCompositeName] + if namePrefix == "" { + namePrefix = xr.GetName() + } + + if cd.GetName() == "" && cd.GetGenerateName() == "" { + cd.SetGenerateName(namePrefix + "-") + } + + if xr.GetNamespace() != "" { + cd.SetNamespace(xr.GetNamespace()) + } + + meta.AddAnnotations(cd, map[string]string{AnnotationKeyCompositionResourceName: name}) + meta.AddLabels(cd, map[string]string{AnnotationKeyCompositeName: namePrefix}) + + if xr.GetLabels()[AnnotationKeyClaimName] != "" && xr.GetLabels()[AnnotationKeyClaimNamespace] != "" { + meta.AddLabels(cd, map[string]string{ + AnnotationKeyClaimNamespace: xr.GetLabels()[AnnotationKeyClaimNamespace], + AnnotationKeyClaimName: xr.GetLabels()[AnnotationKeyClaimName], + }) + } else if ref := xr.GetClaimReference(); ref != nil { + meta.AddLabels(cd, map[string]string{ + AnnotationKeyClaimNamespace: ref.Namespace, + AnnotationKeyClaimName: ref.Name, + }) + } + + or := meta.AsController(meta.TypedReferenceTo(xr, xr.GetObjectKind().GroupVersionKind())) + + return errors.Wrapf(meta.AddControllerReference(cd, or), "cannot set composite resource %q as controller ref of composed resource", xr.GetName()) +} + +// injectNetworkAnnotation sets the Docker network annotation on all functions +// so their containers join the specified network. +func injectNetworkAnnotation(fns []pkgv1.Function, networkName string) { + for i := range fns { + if fns[i].Annotations == nil { + fns[i].Annotations = make(map[string]string) + } + fns[i].Annotations[AnnotationKeyRuntimeDockerNetwork] = networkName + } +} + +// StopFunctionRuntimes stops all function runtimes with a timeout. +func StopFunctionRuntimes(log logging.Logger, fa *FunctionAddresses) { + if fa == nil { + return + } + stopCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := fa.Stop(stopCtx); err != nil { + log.Info("Error stopping function runtimes", "error", err) + } +} diff --git a/cmd/crossplane/render/render_test.go b/cmd/crossplane/render/render_test.go new file mode 100644 index 0000000..fd7e47c --- /dev/null +++ b/cmd/crossplane/render/render_test.go @@ -0,0 +1,241 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/types/known/structpb" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed" + ucomposite "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/reference" +) + +func TestGetSecret(t *testing.T) { + secrets := []corev1.Secret{ + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret1", + Namespace: "namespace1", + }, + }, + { + ObjectMeta: metav1.ObjectMeta{ + Name: "secret2", + Namespace: "namespace2", + }, + }, + } + + tests := map[string]struct { + name string + namespace string + secrets []corev1.Secret + wantErr bool + }{ + "SecretFound": { + name: "secret1", + namespace: "namespace1", + secrets: secrets, + wantErr: false, + }, + "SecretNotFound": { + name: "secret3", + namespace: "namespace3", + secrets: secrets, + wantErr: true, + }, + "SecretWrongNamespace": { + name: "secret1", + namespace: "namespace2", + secrets: secrets, + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + _, err := GetSecret(tc.name, tc.namespace, tc.secrets) + if (err != nil) != tc.wantErr { + t.Errorf("GetSecret() error = %v, wantErr %v", err, tc.wantErr) + } + }) + } +} + +func TestSetComposedResourceMetadata(t *testing.T) { + type args struct { + cd *composed.Unstructured + xr *ucomposite.Unstructured + name string + } + type want struct { + generateName string + compositeLabel string + claimName string + claimNamespace string + } + + tests := map[string]struct { + reason string + args args + want want + }{ + "RootXRUsesOwnName": { + reason: "A root XR without composite label should use its own name", + args: args{ + cd: composed.New(), + xr: func() *ucomposite.Unstructured { + xr := ucomposite.New(ucomposite.WithSchema(ucomposite.SchemaLegacy)) + xr.SetName("root-xr") + return xr + }(), + name: "resource-a", + }, + want: want{ + generateName: "root-xr-", + compositeLabel: "root-xr", + }, + }, + "NestedXRPropagatesRootLabel": { + reason: "A nested XR with composite label should propagate the root's name", + args: args{ + cd: composed.New(), + xr: func() *ucomposite.Unstructured { + xr := ucomposite.New(ucomposite.WithSchema(ucomposite.SchemaLegacy)) + xr.SetName("root-xr-child") + xr.SetLabels(map[string]string{ + AnnotationKeyCompositeName: "root-xr", + }) + return xr + }(), + name: "resource-a", + }, + want: want{ + generateName: "root-xr-", + compositeLabel: "root-xr", + }, + }, + "NestedXRPropagatesClaimLabels": { + reason: "A nested XR with claim labels should propagate them to composed resources", + args: args{ + cd: composed.New(), + xr: func() *ucomposite.Unstructured { + xr := ucomposite.New(ucomposite.WithSchema(ucomposite.SchemaLegacy)) + xr.SetName("root-xr-child") + xr.SetLabels(map[string]string{ + AnnotationKeyCompositeName: "root-xr", + AnnotationKeyClaimName: "my-claim", + AnnotationKeyClaimNamespace: "claim-ns", + }) + return xr + }(), + name: "resource-a", + }, + want: want{ + generateName: "root-xr-", + compositeLabel: "root-xr", + claimName: "my-claim", + claimNamespace: "claim-ns", + }, + }, + "RootXRWithClaimReference": { + reason: "A root XR with ClaimReference but no claim labels should use ClaimReference for claim labels", + args: args{ + cd: composed.New(), + xr: func() *ucomposite.Unstructured { + xr := ucomposite.New(ucomposite.WithSchema(ucomposite.SchemaLegacy)) + xr.SetName("root-xr") + xr.SetClaimReference(&reference.Claim{ + Name: "my-claim", + Namespace: "claim-ns", + }) + return xr + }(), + name: "resource-a", + }, + want: want{ + generateName: "root-xr-", + compositeLabel: "root-xr", + claimName: "my-claim", + claimNamespace: "claim-ns", + }, + }, + "XRWithClaimLabelsButNoCompositeLabel": { + reason: "An XR with claim labels but no composite label should fall back to XR name and still propagate claim labels", + args: args{ + cd: composed.New(), + xr: func() *ucomposite.Unstructured { + xr := ucomposite.New(ucomposite.WithSchema(ucomposite.SchemaLegacy)) + xr.SetName("root-xr") + xr.SetLabels(map[string]string{ + AnnotationKeyClaimName: "my-claim", + AnnotationKeyClaimNamespace: "claim-ns", + }) + return xr + }(), + name: "resource-a", + }, + want: want{ + generateName: "root-xr-", + compositeLabel: "root-xr", + claimName: "my-claim", + claimNamespace: "claim-ns", + }, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + err := SetComposedResourceMetadata(tc.args.cd, tc.args.xr, tc.args.name) + if err != nil { + t.Fatalf("SetComposedResourceMetadata() error = %v", err) + } + + if diff := cmp.Diff(tc.want.generateName, tc.args.cd.GetGenerateName()); diff != "" { + t.Errorf("%s\nSetComposedResourceMetadata() generateName: -want, +got:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.compositeLabel, tc.args.cd.GetLabels()[AnnotationKeyCompositeName]); diff != "" { + t.Errorf("%s\nSetComposedResourceMetadata() compositeLabel: -want, +got:\n%s", tc.reason, diff) + } + if tc.want.claimName != "" { + if diff := cmp.Diff(tc.want.claimName, tc.args.cd.GetLabels()[AnnotationKeyClaimName]); diff != "" { + t.Errorf("%s\nSetComposedResourceMetadata() claimName: -want, +got:\n%s", tc.reason, diff) + } + } + if tc.want.claimNamespace != "" { + if diff := cmp.Diff(tc.want.claimNamespace, tc.args.cd.GetLabels()[AnnotationKeyClaimNamespace]); diff != "" { + t.Errorf("%s\nSetComposedResourceMetadata() claimNamespace: -want, +got:\n%s", tc.reason, diff) + } + } + }) + } +} + +func MustStructJSON(j string) *structpb.Struct { + s := &structpb.Struct{} + if err := protojson.Unmarshal([]byte(j), s); err != nil { + panic(err) + } + + return s +} diff --git a/cmd/crossplane/render/runtime.go b/cmd/crossplane/render/runtime.go new file mode 100644 index 0000000..45f9cd4 --- /dev/null +++ b/cmd/crossplane/render/runtime.go @@ -0,0 +1,92 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +// AnnotationKeyRuntime can be added to a Function to control what runtime is +// used to run it locally. +const AnnotationKeyRuntime = "render.crossplane.io/runtime" + +// RuntimeType is a type of Function runtime. +type RuntimeType string + +// Supported runtimes. +const ( + // AnnotationValueRuntimeDocker uses a Docker daemon to run a Function. It + // uses the standard DOCKER_ environment variables to determine how to + // connect to the daemon. + AnnotationValueRuntimeDocker RuntimeType = "Docker" + + // AnnotationValueRuntimeDevelopment expects you to deploy a Function + // locally. This is mostly useful when developing a Function. The Function + // must be running with the --insecure flag, i.e. without transport security. + AnnotationValueRuntimeDevelopment RuntimeType = "Development" + + // AnnotationValueRuntimeInProcess indicates that the Function is hosted + // in-process by the CLI itself. The CLI owns the listener lifecycle and + // injects the real target post-hoc; the runtime's Start is a no-op. + AnnotationValueRuntimeInProcess RuntimeType = "InProcess" + + AnnotationValueRuntimeDefault = AnnotationValueRuntimeDocker +) + +// RuntimeInProcess is a no-op runtime for functions the CLI hosts in-process. +// The real target is injected by the caller after StartFunctionRuntimes +// returns. +type RuntimeInProcess struct{} + +// Start does nothing and returns an empty target. +func (RuntimeInProcess) Start(_ context.Context) (RuntimeContext, error) { + return RuntimeContext{Stop: func(_ context.Context) error { return nil }}, nil +} + +// A Runtime runs a Function. +type Runtime interface { + // Start the Function. + Start(ctx context.Context) (RuntimeContext, error) +} + +// RuntimeContext contains context on how a Function is being run. +type RuntimeContext struct { + // Target for RunFunctionRequest gRPCs. + Target string + + // Stop the running Function. + Stop func(context.Context) error +} + +// GetRuntime for the supplied Function, per its annotations. +func GetRuntime(fn pkgv1.Function, log logging.Logger) (Runtime, error) { + switch r := RuntimeType(fn.GetAnnotations()[AnnotationKeyRuntime]); r { + case AnnotationValueRuntimeDocker, "": + return GetRuntimeDocker(fn, log) + case AnnotationValueRuntimeDevelopment: + return GetRuntimeDevelopment(fn, log), nil + case AnnotationValueRuntimeInProcess: + return RuntimeInProcess{}, nil + default: + return nil, errors.Errorf("unsupported %q annotation value %q (unknown runtime)", AnnotationKeyRuntime, r) + } +} diff --git a/cmd/crossplane/render/runtime_development.go b/cmd/crossplane/render/runtime_development.go new file mode 100644 index 0000000..54c823b --- /dev/null +++ b/cmd/crossplane/render/runtime_development.go @@ -0,0 +1,65 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +// Annotations that can be used to configure the Development runtime. +const ( + // AnnotationKeyRuntimeDevelopmentTarget can be used to configure the gRPC + // target where the Function is listening. The default is localhost:9443. + AnnotationKeyRuntimeDevelopmentTarget = "render.crossplane.io/runtime-development-target" +) + +// RuntimeDevelopment is largely a no-op. It expects you to run the Function +// manually. This is useful for developing Functions. +type RuntimeDevelopment struct { + // Target is the gRPC target for the running function, for example + // localhost:9443. + Target string + + // Function is the name of the function to be run. + Function string + + // log is the logger for this runtime. + log logging.Logger +} + +// GetRuntimeDevelopment extracts RuntimeDevelopment configuration from the +// supplied Function. +func GetRuntimeDevelopment(fn pkgv1.Function, log logging.Logger) *RuntimeDevelopment { + r := &RuntimeDevelopment{Target: "localhost:9443", Function: fn.GetName(), log: log} + if t := fn.GetAnnotations()[AnnotationKeyRuntimeDevelopmentTarget]; t != "" { + r.Target = t + } + + return r +} + +var _ Runtime = &RuntimeDevelopment{} + +// Start does nothing. It returns a Stop function that also does nothing. +func (r *RuntimeDevelopment) Start(_ context.Context) (RuntimeContext, error) { + r.log.Debug("Starting development runtime. Remember to run the function manually.", "function", r.Function, "target", r.Target) + return RuntimeContext{Target: r.Target, Stop: func(_ context.Context) error { return nil }}, nil +} diff --git a/cmd/crossplane/render/runtime_docker.go b/cmd/crossplane/render/runtime_docker.go new file mode 100644 index 0000000..c8616a3 --- /dev/null +++ b/cmd/crossplane/render/runtime_docker.go @@ -0,0 +1,512 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + "encoding/base64" + "fmt" + "io" + "net" + "strings" + + "github.com/containerd/errdefs" + "github.com/docker/docker/api/types/container" + typesimage "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/go-connections/nat" + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +// FunctionPort is the port that Composition Functions listen on inside their container. +const FunctionPort = 9443 + +// Annotations that can be used to configure the Docker runtime. +const ( + // AnnotationKeyRuntimeDockerCleanup configures how a Function's Docker + // container should be cleaned up once rendering is done. + AnnotationKeyRuntimeDockerCleanup = "render.crossplane.io/runtime-docker-cleanup" + + // AnnotationKeyRuntimeDockerImage overrides the Docker image that will be + // used to run the Function. By default render assumes the Function package + // (i.e. spec.package) can be used to run the Function. + AnnotationKeyRuntimeDockerImage = "render.crossplane.io/runtime-docker-image" + + // AnnotationKeyRuntimeNamedContainer sets the Docker container name that will + // be used for the container. it will also reuse the same container as long as + // it is available and also try to restart if it is not running. + AnnotationKeyRuntimeNamedContainer = "render.crossplane.io/runtime-docker-name" + + // AnnotationKeyRuntimeEnvironmentVariables sets the environment variables + // that will be used for the container. This is helpful to control kpm registry + // access to use a different registry. + // It is a comma separated string of key=value pairs e.g. "key1=value1,key2=value2". + AnnotationKeyRuntimeEnvironmentVariables = "render.crossplane.io/runtime-docker-env" + + // AnnotationKeyRuntimeDockerPublishAddress configures the host address that + // Docker should publish (bind) the Function's container port to. Defaults to 127.0.0.1. + // Use 0.0.0.0 to publish to all host interfaces for remote Docker access. + AnnotationKeyRuntimeDockerPublishAddress = "render.crossplane.io/runtime-docker-publish-address" + + // AnnotationKeyRuntimeDockerTarget configures the address that the render + // CLI should use to connect to the Function's Docker container. + AnnotationKeyRuntimeDockerTarget = "render.crossplane.io/runtime-docker-target" + + // AnnotationKeyRuntimeDockerNetwork specifies which Docker network the + // Function container should be connected to. When set, the container is + // reached via its Docker hostname on port 9443 rather than via host port + // bindings. This is useful when the render process itself runs inside a + // Docker container (e.g. a GitHub Actions container job) and function + // containers must be on the same network to be reachable. + AnnotationKeyRuntimeDockerNetwork = "render.crossplane.io/runtime-docker-network" +) + +// DockerCleanup specifies what Docker should do with a Function container after +// it has been run. +type DockerCleanup string + +// Supported AnnotationKeyRuntimeDockerCleanup values. +const ( + // AnnotationValueRuntimeDockerCleanupStop is the default. It stops the + // container once rendering is done. + AnnotationValueRuntimeDockerCleanupStop DockerCleanup = "Stop" + + // AnnotationValueRuntimeDockerCleanupRemove stops and removes the + // container once rendering is done. + AnnotationValueRuntimeDockerCleanupRemove DockerCleanup = "Remove" + + // AnnotationValueRuntimeDockerCleanupOrphan leaves the container running + // once rendering is done. + AnnotationValueRuntimeDockerCleanupOrphan DockerCleanup = "Orphan" + + AnnotationValueRuntimeDockerCleanupDefault = AnnotationValueRuntimeDockerCleanupRemove +) + +// AnnotationKeyRuntimeDockerPullPolicy can be added to a Function to control how its runtime +// image is pulled. +const AnnotationKeyRuntimeDockerPullPolicy = "render.crossplane.io/runtime-docker-pull-policy" + +// DockerPullPolicy can be added to a Function to control how its runtime image +// is pulled by Docker. +type DockerPullPolicy string + +// Supported pull policies. +const ( + // AnnotationValueRuntimeDockerPullPolicyAlways always pulls the image. + AnnotationValueRuntimeDockerPullPolicyAlways DockerPullPolicy = "Always" + + // AnnotationValueRuntimeDockerPullPolicyNever never pulls the image. + AnnotationValueRuntimeDockerPullPolicyNever DockerPullPolicy = "Never" + + // AnnotationValueRuntimeDockerPullPolicyIfNotPresent pulls the image if + // it's not present. + AnnotationValueRuntimeDockerPullPolicyIfNotPresent DockerPullPolicy = "IfNotPresent" + + AnnotationValueRuntimeDockerPullPolicyDefault DockerPullPolicy = AnnotationValueRuntimeDockerPullPolicyIfNotPresent +) + +// RuntimeDocker uses a Docker daemon to run a Function. +type RuntimeDocker struct { + // Image to run + Image string + + // Container name + Name string + + // Cleanup controls how the containers are handled after rendering. + Cleanup DockerCleanup + + // PullPolicy controls how the runtime image is pulled. + PullPolicy DockerPullPolicy + + // Keychain to use for pulling images from private registry. + Keychain authn.Keychain + + // log is the logger for this runtime. + log logging.Logger + + // Env is the list of environment variables to set for the container. + Env []string + + // BindAddress is the address to bind the function container to. + BindAddress string + + // Target is the host address to use when connecting to the function. + // If empty, it defaults to the published HostIP from Docker inspect. + // When published on 0.0.0.0, set this explicitly (e.g. the remote Docker host). + Target string + + // Network is the Docker network to connect the Function container to. + // When empty (the default), the container uses the default Docker network + // and is reached via host port bindings. When set, the container joins + // the specified network and is reached via its Docker hostname on port 9443. + Network string +} + +// GetDockerPullPolicy extracts PullPolicy configuration from the supplied +// Function. +func GetDockerPullPolicy(fn pkgv1.Function) (DockerPullPolicy, error) { + switch p := DockerPullPolicy(fn.GetAnnotations()[AnnotationKeyRuntimeDockerPullPolicy]); p { + case AnnotationValueRuntimeDockerPullPolicyAlways, AnnotationValueRuntimeDockerPullPolicyNever, AnnotationValueRuntimeDockerPullPolicyIfNotPresent: + return p, nil + case "": + return AnnotationValueRuntimeDockerPullPolicyDefault, nil + default: + return "", errors.Errorf("unsupported %q annotation value %q (unknown pull policy)", AnnotationKeyRuntimeDockerPullPolicy, p) + } +} + +// GetDockerCleanup extracts Cleanup configuration from the supplied Function. +func GetDockerCleanup(fn pkgv1.Function) (DockerCleanup, error) { + switch c := DockerCleanup(fn.GetAnnotations()[AnnotationKeyRuntimeDockerCleanup]); c { + case AnnotationValueRuntimeDockerCleanupStop, AnnotationValueRuntimeDockerCleanupOrphan, AnnotationValueRuntimeDockerCleanupRemove: + return c, nil + case "": + return AnnotationValueRuntimeDockerCleanupDefault, nil + default: + return "", errors.Errorf("unsupported %q annotation value %q (unknown cleanup policy)", AnnotationKeyRuntimeDockerCleanup, c) + } +} + +// GetRuntimeDocker extracts RuntimeDocker configuration from the supplied +// Function. +func GetRuntimeDocker(fn pkgv1.Function, log logging.Logger) (*RuntimeDocker, error) { + cleanup, err := GetDockerCleanup(fn) + if err != nil { + return nil, errors.Wrapf(err, "cannot get cleanup policy for Function %q", fn.GetName()) + } + // TODO(negz): Pull package in case it has a different controller image? I + // hope in most cases Functions will use 'fat' packages, and it's possible + // to manually override with an annotation so maybe not worth it. + pullPolicy, err := GetDockerPullPolicy(fn) + if err != nil { + return nil, errors.Wrapf(err, "cannot get pull policy for Function %q", fn.GetName()) + } + + r := &RuntimeDocker{ + Image: fn.Spec.Package, + Name: "", + Cleanup: cleanup, + PullPolicy: pullPolicy, + Keychain: authn.DefaultKeychain, + log: log, + BindAddress: "127.0.0.1", // Default to localhost for security + } + + if i := fn.GetAnnotations()[AnnotationKeyRuntimeDockerImage]; i != "" { + r.Image = i + } + + if i := fn.GetAnnotations()[AnnotationKeyRuntimeNamedContainer]; i != "" { + r.Name = i + } + + if i := fn.GetAnnotations()[AnnotationKeyRuntimeEnvironmentVariables]; i != "" { + pairs := strings.SplitSeq(i, ",") + for pair := range pairs { + if !strings.Contains(pair, "=") { + r.log.Debug("ignoring invalid environment variable", "pair", pair) + continue + } + + r.Env = append(r.Env, pair) + } + } + + if i := fn.GetAnnotations()[AnnotationKeyRuntimeDockerPublishAddress]; i != "" { + r.BindAddress = i + } + + if i := fn.GetAnnotations()[AnnotationKeyRuntimeDockerTarget]; i != "" { + r.Target = i + } + + if i := fn.GetAnnotations()[AnnotationKeyRuntimeDockerNetwork]; i != "" { + r.Network = i + } + + return r, nil +} + +var _ Runtime = &RuntimeDocker{} + +func (r *RuntimeDocker) findContainer(ctx context.Context, cli *client.Client) (string, error) { + if r.Name == "" { + return "", nil + } + + inspect, err := cli.ContainerInspect(ctx, r.Name) + if err != nil { + if errdefs.IsNotFound(err) { + return "", nil // Container doesn't exist, but that's not an error + } + return "", errors.Wrapf(err, "cannot inspect Docker container %q", r.Name) + } + + return inspect.ID, nil +} + +func (r *RuntimeDocker) createContainer(ctx context.Context, cli *client.Client) (string, error) { + r.log.Debug("Starting Docker container runtime setup", "image", r.Image) + + // Let Docker automatically allocate an available port on the bind address. + // This avoids race conditions and works reliably with Docker daemons. + port := nat.Port(fmt.Sprintf("%d/tcp", FunctionPort)) + + cfg := &container.Config{ + Image: r.Image, + Cmd: []string{"--insecure"}, + ExposedPorts: nat.PortSet{port: struct{}{}}, + Env: r.Env, + } + hcfg := &container.HostConfig{} + var ncfg *network.NetworkingConfig + + if r.Network != "" { + // When a Docker network is specified, the function container joins + // that network and is reached via its Docker hostname on the function + // port. No host port bindings are needed. + ncfg = &network.NetworkingConfig{ + EndpointsConfig: map[string]*network.EndpointSettings{ + r.Network: {}, + }, + } + r.log.Debug("Connecting container to Docker network", "network", r.Network) + } else { + hcfg.PortBindings = nat.PortMap{ + port: []nat.PortBinding{{ + HostIP: r.BindAddress, + HostPort: "0", // "0" => engine allocates an ephemeral port + }}, + } + } + + options, err := r.getPullOptions() + if err != nil { + // We can continue to pull an image if we don't have the PullOptions with RegistryAuth + // as long as the image is from a public registry. Therefore, we log the error message and continue. + r.log.Info("Cannot get pull options", "image", r.Image, "err", err) + } + + if r.PullPolicy == AnnotationValueRuntimeDockerPullPolicyAlways { + r.log.Debug("Pulling image with pullPolicy: Always", "image", r.Image) + + err = PullImage(ctx, cli, r.Image, options) + if err != nil { + return "", errors.Wrapf(err, "cannot pull Docker image %q", r.Image) + } + } + + r.log.Debug("Creating Docker container", "image", r.Image, "name", r.Name, "network", r.Network) + + rsp, err := cli.ContainerCreate(ctx, cfg, hcfg, ncfg, nil, r.Name) + if err != nil { + // TODO: If Docker ever exposes a structured way to distinguish + // network-not-found from image-not-found (e.g. via errdefs or a typed + // error), handle the network case here to avoid an unnecessary image + // pull attempt. For now, both surface as errdefs.IsNotFound and + // string-matching on the daemon message is too fragile. + if !errdefs.IsNotFound(err) { + // Non-image-not-found error: could be network misconfiguration, + // daemon permissions, etc. + if r.Network != "" { + return "", errors.Wrapf(err, "cannot create Docker container for image %q on network %q; verify the network exists and is accessible by the Docker daemon", r.Image, r.Network) + } + return "", errors.Wrapf(err, "cannot create Docker container for image %q", r.Image) + } + if r.PullPolicy == AnnotationValueRuntimeDockerPullPolicyNever { + // Image not found and we're not allowed to pull it. + return "", errors.Wrapf(err, "cannot create Docker container: image %q not found and pull policy is %q", r.Image, r.PullPolicy) + } + + // The image was not found, but we're allowed to pull it. + r.log.Debug("Image not found, pulling", "image", r.Image) + + err = PullImage(ctx, cli, r.Image, options) + if err != nil { + return "", errors.Wrapf(err, "cannot pull Docker image %q", r.Image) + } + + rsp, err = cli.ContainerCreate(ctx, cfg, hcfg, ncfg, nil, r.Name) + if err != nil { + if r.Network != "" { + return "", errors.Wrapf(err, "cannot create Docker container for image %q on network %q; verify the network exists and is accessible by the Docker daemon", r.Image, r.Network) + } + return "", errors.Wrapf(err, "cannot create Docker container for image %q", r.Image) + } + } + + return rsp.ID, nil +} + +// startContainer ensures the container is running and returns its address. +func (r *RuntimeDocker) startContainer(ctx context.Context, cli *client.Client, containerID string) (string, error) { + // Start the container (idempotent - safe to call on running containers) + if err := cli.ContainerStart(ctx, containerID, container.StartOptions{}); err != nil { + return "", errors.Wrap(err, "cannot start Docker container") + } + + // When using a Docker network, containers are resolvable by name within + // the network. Inspect the container to validate it is attached to the + // specified network and to get its DNS-resolvable name (Docker's embedded + // DNS resolves container names and hostnames, but not container IDs). + if r.Network != "" { + inspect, err := cli.ContainerInspect(ctx, containerID) + if err != nil { + return "", errors.Wrap(err, "cannot inspect Docker container") + } + if _, ok := inspect.NetworkSettings.Networks[r.Network]; !ok { + return "", errors.Errorf("container %q is not connected to Docker network %q; verify the %q annotation value matches an existing network", strings.TrimPrefix(inspect.Name, "/"), r.Network, AnnotationKeyRuntimeDockerNetwork) + } + hostname := strings.TrimPrefix(inspect.Name, "/") + if hostname == "" { + return "", errors.Errorf("cannot determine hostname for container %q on network %q", containerID, r.Network) + } + addr := net.JoinHostPort(hostname, fmt.Sprintf("%d", FunctionPort)) + r.log.Debug("Function container reachable on network", "network", r.Network, "address", addr) + return addr, nil + } + + // Inspect the container to get the actual allocated host port. + inspect, err := cli.ContainerInspect(ctx, containerID) + if err != nil { + return "", errors.Wrap(err, "cannot inspect Docker container") + } + + // Look up the specific function port instead of taking the first one + p := nat.Port(fmt.Sprintf("%d/tcp", FunctionPort)) + if len(inspect.NetworkSettings.Ports[p]) == 0 { + return "", errors.Errorf("container %q has no published binding for port %s", r.Name, p.Port()) + } + + binding := inspect.NetworkSettings.Ports[p][0] + host := r.Target + if host == "" { + host = binding.HostIP + } + if host == "" { + return "", errors.Errorf("container %q has port binding for %s but no host address", r.Name, p.Port()) + } + + return net.JoinHostPort(host, binding.HostPort), nil +} + +func (r *RuntimeDocker) getPullOptions() (typesimage.PullOptions, error) { + // Resolve auth token by looking into keychain + ref, err := name.ParseReference(r.Image) + if err != nil { + return typesimage.PullOptions{}, errors.Wrapf(err, "Image is not a valid reference %s", r.Image) + } + + auth, err := r.Keychain.Resolve(ref.Context().Registry) + if err != nil { + return typesimage.PullOptions{}, errors.Wrapf(err, "Cannot resolve auth for %s", ref.Context().RegistryStr()) + } + + authConfig, err := auth.Authorization() + if err != nil { + return typesimage.PullOptions{}, errors.Wrapf(err, "Cannot get auth config for %s", ref.Context().RegistryStr()) + } + + token, err := authConfig.MarshalJSON() + if err != nil { + return typesimage.PullOptions{}, errors.Wrapf(err, "Cannot marshal auth config for %s", ref.Context().RegistryStr()) + } + + return typesimage.PullOptions{ + RegistryAuth: base64.StdEncoding.EncodeToString(token), + }, nil +} + +// Start a Function as a Docker container. +func (r *RuntimeDocker) Start(ctx context.Context) (RuntimeContext, error) { + cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) + if err != nil { + return RuntimeContext{}, errors.Wrap(err, "cannot create Docker client using environment variables") + } + + // Try to find an existing container with the supplied container name. + containerID, err := r.findContainer(ctx, cli) + if err != nil { + return RuntimeContext{}, err + } + + // Create new container if not found. + if containerID == "" { + containerID, err = r.createContainer(ctx, cli) + if err != nil { + return RuntimeContext{}, err + } + } + + containerAddr, err := r.startContainer(ctx, cli, containerID) + if err != nil { + return RuntimeContext{}, err + } + + // Inline stop function + stop := func(ctx context.Context) error { + switch r.Cleanup { + case AnnotationValueRuntimeDockerCleanupOrphan: + return nil + case AnnotationValueRuntimeDockerCleanupStop: + if err := cli.ContainerStop(ctx, containerID, container.StopOptions{}); err != nil { + return errors.Wrap(err, "cannot stop Docker container") + } + case AnnotationValueRuntimeDockerCleanupRemove: + if err := cli.ContainerStop(ctx, containerID, container.StopOptions{}); err != nil { + return errors.Wrap(err, "cannot stop Docker container") + } + if err := cli.ContainerRemove(ctx, containerID, container.RemoveOptions{}); err != nil { + return errors.Wrap(err, "cannot remove Docker container") + } + } + + return nil + } + + return RuntimeContext{Target: containerAddr, Stop: stop}, nil +} + +type pullClient interface { + ImagePull(ctx context.Context, ref string, options typesimage.PullOptions) (io.ReadCloser, error) +} + +// PullImage pulls the supplied image using the supplied client. It blocks until +// the image has either finished pulling or hit an error. +func PullImage(ctx context.Context, p pullClient, image string, options typesimage.PullOptions) error { + out, err := p.ImagePull(ctx, image, options) + if err != nil { + return err + } + defer out.Close() //nolint:errcheck // TODO(negz): Can this error? + + // Each line read from out is a JSON object containing the status of the + // pull - similar to the progress bars you'd see if running docker pull. It + // seems that consuming all of this output is the best way to block until + // the image is actually pulled before we try to run it. + _, err = io.Copy(io.Discard, out) + + return err +} diff --git a/cmd/crossplane/render/runtime_docker_test.go b/cmd/crossplane/render/runtime_docker_test.go new file mode 100644 index 0000000..ea680ad --- /dev/null +++ b/cmd/crossplane/render/runtime_docker_test.go @@ -0,0 +1,249 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "context" + "io" + "testing" + + "github.com/docker/docker/api/types/image" + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" +) + +type mockPullClient struct { + MockPullImage func(_ context.Context, ref string, options image.PullOptions) (io.ReadCloser, error) +} + +func (m *mockPullClient) ImagePull(ctx context.Context, ref string, options image.PullOptions) (io.ReadCloser, error) { + return m.MockPullImage(ctx, ref, options) +} + +var _ pullClient = &mockPullClient{} + +func TestGetRuntimeDocker(t *testing.T) { + type args struct { + fn pkgv1.Function + } + + type want struct { + rd *RuntimeDocker + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "SuccessAllSet": { + reason: "should return a RuntimeDocker with all fields set according to the supplied Function's annotations", + args: args{ + fn: pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationKeyRuntimeDockerCleanup: string(AnnotationValueRuntimeDockerCleanupOrphan), + AnnotationKeyRuntimeDockerPullPolicy: string(AnnotationValueRuntimeDockerPullPolicyAlways), + AnnotationKeyRuntimeDockerImage: "test-image-from-annotation", + AnnotationKeyRuntimeEnvironmentVariables: "KCL_DEFAULT_REGISTRY=registry.example.com,ANOTHER_ENV_VAR=another-value", + AnnotationKeyRuntimeDockerNetwork: "test-network", + }, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "test-package", + }, + }, + }, + }, + want: want{ + rd: &RuntimeDocker{ + Image: "test-image-from-annotation", + Cleanup: AnnotationValueRuntimeDockerCleanupOrphan, + PullPolicy: AnnotationValueRuntimeDockerPullPolicyAlways, + Env: []string{"KCL_DEFAULT_REGISTRY=registry.example.com", "ANOTHER_ENV_VAR=another-value"}, + BindAddress: "127.0.0.1", + Network: "test-network", + }, + }, + }, + "SuccessNamedContainer": { + reason: "should return a RuntimeDocker with the correct name.", + args: args{ + fn: pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationKeyRuntimeDockerCleanup: string(AnnotationValueRuntimeDockerCleanupOrphan), + AnnotationKeyRuntimeNamedContainer: "test-container-name-function", + AnnotationKeyRuntimeDockerImage: "test-image-from-annotation", + AnnotationKeyRuntimeEnvironmentVariables: "SKIPPED_KEYvalue,KCL_DEFAULT_REGISTRY=registry.example.com", + }, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "test-package", + }, + }, + }, + }, + want: want{ + rd: &RuntimeDocker{ + Image: "test-image-from-annotation", + Cleanup: AnnotationValueRuntimeDockerCleanupOrphan, + Name: "test-container-name-function", + PullPolicy: AnnotationValueRuntimeDockerPullPolicyIfNotPresent, + Env: []string{"KCL_DEFAULT_REGISTRY=registry.example.com"}, + BindAddress: "127.0.0.1", + }, + }, + }, + "SuccessDefaults": { + reason: "should return a RuntimeDocker with default fields set if no annotation are set", + args: args{ + fn: pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{}, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "test-package", + }, + }, + }, + }, + want: want{ + rd: &RuntimeDocker{ + Image: "test-package", + Cleanup: AnnotationValueRuntimeDockerCleanupRemove, + PullPolicy: AnnotationValueRuntimeDockerPullPolicyIfNotPresent, + BindAddress: "127.0.0.1", + }, + }, + }, + "ErrorUnknownAnnotationValueCleanup": { + reason: "should return an error if the supplied Function has an unknown cleanup annotation value", + args: args{ + fn: pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationKeyRuntimeDockerCleanup: "wrong", + }, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "test-package", + }, + }, + }, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "ErrorUnknownAnnotationPullPolicy": { + reason: "should return an error if the supplied Function has an unknown pull policy annotation value", + args: args{ + fn: pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationKeyRuntimeDockerPullPolicy: "wrong", + }, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "test-package", + }, + }, + }, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "AnnotationsCleanupSetToStop": { + reason: "should return a RuntimeDocker with all fields set according to the supplied Function's annotations", + args: args{ + fn: pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationKeyRuntimeDockerCleanup: string(AnnotationValueRuntimeDockerCleanupStop), + AnnotationKeyRuntimeEnvironmentVariables: "SKIPPED_KEYvalue", + }, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "test-package", + }, + }, + }, + }, + want: want{ + rd: &RuntimeDocker{ + Image: "test-package", + Cleanup: AnnotationValueRuntimeDockerCleanupStop, + PullPolicy: AnnotationValueRuntimeDockerPullPolicyIfNotPresent, + Env: nil, + BindAddress: "127.0.0.1", + }, + }, + }, + "SuccessWithNetwork": { + reason: "should return a RuntimeDocker with Network set when the network annotation is provided", + args: args{ + fn: pkgv1.Function{ + ObjectMeta: metav1.ObjectMeta{ + Annotations: map[string]string{ + AnnotationKeyRuntimeDockerNetwork: "my-custom-network", + }, + }, + Spec: pkgv1.FunctionSpec{ + PackageSpec: pkgv1.PackageSpec{ + Package: "test-package", + }, + }, + }, + }, + want: want{ + rd: &RuntimeDocker{ + Image: "test-package", + Cleanup: AnnotationValueRuntimeDockerCleanupRemove, + PullPolicy: AnnotationValueRuntimeDockerPullPolicyIfNotPresent, + BindAddress: "127.0.0.1", + Network: "my-custom-network", + }, + }, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + rd, err := GetRuntimeDocker(tc.args.fn, logging.NewNopLogger()) + if diff := cmp.Diff(tc.want.rd, rd, cmpopts.IgnoreUnexported(RuntimeDocker{}), cmpopts.IgnoreFields(RuntimeDocker{}, "Keychain")); diff != "" { + t.Errorf("\n%s\nGetRuntimeDocker(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nGetRuntimeDocker(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/render/schemas.go b/cmd/crossplane/render/schemas.go new file mode 100644 index 0000000..b456554 --- /dev/null +++ b/cmd/crossplane/render/schemas.go @@ -0,0 +1,72 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "encoding/json" + iofs "io/fs" + "path/filepath" + + "github.com/spf13/afero" + "k8s.io/kube-openapi/pkg/spec3" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// LoadRequiredSchemas loads OpenAPI v3 schema documents from a directory, +// recursively. Each file should contain a single OpenAPI v3 document in JSON +// format (as returned by /openapi/v3/). +func LoadRequiredSchemas(fs afero.Fs, dir string) ([]spec3.OpenAPI, error) { + var files []string + + err := iofs.WalkDir(afero.NewIOFS(fs), dir, func(path string, d iofs.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() { + return nil + } + if filepath.Ext(path) == ".json" { + files = append(files, path) + } + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "cannot walk directory") + } + + if len(files) == 0 { + return nil, errors.Errorf("no JSON files found in %q", dir) + } + + schemas := make([]spec3.OpenAPI, 0, len(files)) + for _, file := range files { + data, err := afero.ReadFile(fs, file) + if err != nil { + return nil, errors.Wrapf(err, "cannot read file %q", file) + } + + s := spec3.OpenAPI{} + if err := json.Unmarshal(data, &s); err != nil { + return nil, errors.Wrapf(err, "cannot parse OpenAPI JSON from %q", file) + } + + schemas = append(schemas, s) + } + + return schemas, nil +} diff --git a/cmd/crossplane/render/schemas_test.go b/cmd/crossplane/render/schemas_test.go new file mode 100644 index 0000000..3ee61fa --- /dev/null +++ b/cmd/crossplane/render/schemas_test.go @@ -0,0 +1,140 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package render + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/spf13/afero" +) + +func TestLoadRequiredSchemas(t *testing.T) { + deploymentJSON := `{ + "components": { + "schemas": { + "io.k8s.api.apps.v1.Deployment": { + "type": "object", + "x-kubernetes-group-version-kind": [{"group": "apps", "kind": "Deployment", "version": "v1"}] + } + } + } + }` + + type args struct { + fs afero.Fs + dir string + } + type want struct { + count int + err error + } + + cases := map[string]struct { + reason string + args args + want want + }{ + "Directory": { + reason: "Should load all JSON files from a directory", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = fs.MkdirAll("/schemas", 0o755) + _ = afero.WriteFile(fs, "/schemas/apps-v1.json", []byte(deploymentJSON), 0o644) + _ = afero.WriteFile(fs, "/schemas/core-v1.json", []byte(deploymentJSON), 0o644) + _ = afero.WriteFile(fs, "/schemas/readme.txt", []byte("ignore me"), 0o644) + return fs + }(), + dir: "/schemas", + }, + want: want{ + count: 2, + }, + }, + "NestedDirectory": { + reason: "Should load JSON files from nested directories", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = fs.MkdirAll("/schemas/apps", 0o755) + _ = fs.MkdirAll("/schemas/core", 0o755) + _ = afero.WriteFile(fs, "/schemas/apps/v1.json", []byte(deploymentJSON), 0o644) + _ = afero.WriteFile(fs, "/schemas/core/v1.json", []byte(deploymentJSON), 0o644) + return fs + }(), + dir: "/schemas", + }, + want: want{ + count: 2, + }, + }, + "NotFound": { + reason: "Should return error for non-existent directory", + args: args{ + fs: afero.NewMemMapFs(), + dir: "/does-not-exist", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "InvalidJSON": { + reason: "Should return error for invalid JSON", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = fs.MkdirAll("/schemas", 0o755) + _ = afero.WriteFile(fs, "/schemas/bad.json", []byte("not valid json"), 0o644) + return fs + }(), + dir: "/schemas", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + "EmptyDirectory": { + reason: "Should return error for directory with no JSON files", + args: args{ + fs: func() afero.Fs { + fs := afero.NewMemMapFs() + _ = fs.MkdirAll("/empty", 0o755) + return fs + }(), + dir: "/empty", + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + schemas, err := LoadRequiredSchemas(tc.args.fs, tc.args.dir) + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nLoadRequiredSchemas(...): -want error, +got error:\n%s", tc.reason, diff) + } + if diff := cmp.Diff(tc.want.count, len(schemas)); diff != "" { + t.Errorf("\n%s\nLoadRequiredSchemas(...): -want count, +got count:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/render/testdata/cmd/composition-label-mismatch.yaml b/cmd/crossplane/render/testdata/cmd/composition-label-mismatch.yaml new file mode 100644 index 0000000..cb212ba --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/composition-label-mismatch.yaml @@ -0,0 +1,15 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.nop.example.org + labels: + env: dev +spec: + compositeTypeRef: + apiVersion: nop.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: be-a-dummy + functionRef: + name: function-dummy diff --git a/cmd/crossplane/render/testdata/cmd/composition-not-pipeline.yaml b/cmd/crossplane/render/testdata/cmd/composition-not-pipeline.yaml new file mode 100644 index 0000000..b08fdb1 --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/composition-not-pipeline.yaml @@ -0,0 +1,9 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.nop.example.org +spec: + compositeTypeRef: + apiVersion: nop.example.org/v1alpha1 + kind: XNopResource + mode: Resources diff --git a/cmd/crossplane/render/testdata/cmd/composition.yaml b/cmd/crossplane/render/testdata/cmd/composition.yaml new file mode 100644 index 0000000..5f001dc --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/composition.yaml @@ -0,0 +1,13 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.nop.example.org +spec: + compositeTypeRef: + apiVersion: nop.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: be-a-dummy + functionRef: + name: function-dummy diff --git a/cmd/crossplane/render/testdata/cmd/functions.yaml b/cmd/crossplane/render/testdata/cmd/functions.yaml new file mode 100644 index 0000000..f4df506 --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/functions.yaml @@ -0,0 +1,8 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Function +metadata: + name: function-dummy + annotations: + render.crossplane.io/runtime: InProcess +spec: + package: xpkg.crossplane.io/example/function-dummy:v0.0.0 diff --git a/cmd/crossplane/render/testdata/cmd/output/include-full-xr.yaml b/cmd/crossplane/render/testdata/cmd/output/include-full-xr.yaml new file mode 100644 index 0000000..c1f711d --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/output/include-full-xr.yaml @@ -0,0 +1,10 @@ +--- +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-render +spec: + coolField: I'm cool! + crossplane: + resourceRefs: these are the resource refs + fromXR: preserved diff --git a/cmd/crossplane/render/testdata/cmd/output/include-function-results.yaml b/cmd/crossplane/render/testdata/cmd/output/include-function-results.yaml new file mode 100644 index 0000000..837c2de --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/output/include-function-results.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-render +spec: + crossplane: + resourceRefs: these are the resource refs +--- +apiVersion: render.crossplane.io/v1beta1 +kind: Result +message: function says hi +reason: Hello +severity: Normal diff --git a/cmd/crossplane/render/testdata/cmd/output/success.yaml b/cmd/crossplane/render/testdata/cmd/output/success.yaml new file mode 100644 index 0000000..f617e83 --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/output/success.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-render +spec: + crossplane: + resourceRefs: these are the resource refs +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + annotations: + crossplane.io/composition-resource-name: composed-foo + name: composed-foo +spec: + coolField: composed! diff --git a/cmd/crossplane/render/testdata/cmd/xr-extra-spec.yaml b/cmd/crossplane/render/testdata/cmd/xr-extra-spec.yaml new file mode 100644 index 0000000..236733c --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/xr-extra-spec.yaml @@ -0,0 +1,7 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-render +spec: + coolField: "I'm cool!" + fromXR: preserved diff --git a/cmd/crossplane/render/testdata/cmd/xr-with-selector.yaml b/cmd/crossplane/render/testdata/cmd/xr-with-selector.yaml new file mode 100644 index 0000000..43dc24f --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/xr-with-selector.yaml @@ -0,0 +1,10 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-render +spec: + crossplane: + compositionSelector: + matchLabels: + env: prod + coolField: "I'm cool!" diff --git a/cmd/crossplane/render/testdata/cmd/xr-wrong-apiversion.yaml b/cmd/crossplane/render/testdata/cmd/xr-wrong-apiversion.yaml new file mode 100644 index 0000000..e9a1627 --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/xr-wrong-apiversion.yaml @@ -0,0 +1,6 @@ +apiVersion: other.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-render +spec: + coolField: "I'm cool!" diff --git a/cmd/crossplane/render/testdata/cmd/xr-wrong-kind.yaml b/cmd/crossplane/render/testdata/cmd/xr-wrong-kind.yaml new file mode 100644 index 0000000..832f44b --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/xr-wrong-kind.yaml @@ -0,0 +1,6 @@ +apiVersion: nop.example.org/v1alpha1 +kind: OtherKind +metadata: + name: test-render +spec: + coolField: "I'm cool!" diff --git a/cmd/crossplane/render/testdata/cmd/xr.yaml b/cmd/crossplane/render/testdata/cmd/xr.yaml new file mode 100644 index 0000000..a8d5e4a --- /dev/null +++ b/cmd/crossplane/render/testdata/cmd/xr.yaml @@ -0,0 +1,6 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-render +spec: + coolField: "I'm cool!" diff --git a/cmd/crossplane/render/testdata/composition.yaml b/cmd/crossplane/render/testdata/composition.yaml new file mode 100644 index 0000000..05eeee5 --- /dev/null +++ b/cmd/crossplane/render/testdata/composition.yaml @@ -0,0 +1,14 @@ +--- +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xnopresources.nop.example.org +spec: + compositeTypeRef: + apiVersion: nop.example.org/v1alpha1 + kind: XNopResource + mode: Pipeline + pipeline: + - step: be-a-dummy + functionRef: + name: function-dummy \ No newline at end of file diff --git a/cmd/crossplane/render/testdata/extra-resources.yaml b/cmd/crossplane/render/testdata/extra-resources.yaml new file mode 100644 index 0000000..a577051 --- /dev/null +++ b/cmd/crossplane/render/testdata/extra-resources.yaml @@ -0,0 +1,20 @@ +--- +apiVersion: example.org/v1alpha1 +kind: RequiredResourceA +metadata: + name: test-extra-a + annotations: + some-annotation: "some-value" +spec: + coolField: "I'm cool!" +--- +apiVersion: example.org/v1 +kind: RequiredResourceB +metadata: + name: test-extra-b + annotations: + some-other-annotation: "some-other-value" + labels: + some-label: "another-value" +spec: + coolerField: "I'm cooler!" diff --git a/cmd/crossplane/render/testdata/functions.yaml b/cmd/crossplane/render/testdata/functions.yaml new file mode 100644 index 0000000..08d601b --- /dev/null +++ b/cmd/crossplane/render/testdata/functions.yaml @@ -0,0 +1,30 @@ +apiVersion: pkg.crossplane.io/v1 +kind: Function +metadata: + name: function-auto-ready + annotations: + render.crossplane.io/runtime: Docker + render.crossplane.io/runtime-docker-cleanup: Orphan +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.1.2 +--- +apiVersion: pkg.crossplane.io/v1 +kind: Function +metadata: + name: function-dummy + annotations: + render.crossplane.io/runtime: Development + render.crossplane.io/runtime-development-target: localhost:9444 +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-dummy:v0.4.1 +--- +apiVersion: pkg.crossplane.io/v1 +kind: Function +metadata: + name: function-auto-ready + annotations: + render.crossplane.io/runtime: Docker + render.crossplane.io/runtime-docker-cleanup: Orphan + render.crossplane.io/runtime-docker-name: function-auto-ready +spec: + package: xpkg.crossplane.io/crossplane-contrib/function-auto-ready:v0.1.2 diff --git a/cmd/crossplane/render/testdata/observed.yaml b/cmd/crossplane/render/testdata/observed.yaml new file mode 100644 index 0000000..fb00cb0 --- /dev/null +++ b/cmd/crossplane/render/testdata/observed.yaml @@ -0,0 +1,18 @@ +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-render-a + annotations: + crossplane.io/composition-resource-name: resource-a +spec: + coolField: "I'm cool!" +--- +apiVersion: example.org/v1alpha1 +kind: ComposedResource +metadata: + name: test-render-b + annotations: + crossplane.io/composition-resource-name: resource-b +spec: + coolerField: "I'm cooler!" \ No newline at end of file diff --git a/cmd/crossplane/render/testdata/xr.yaml b/cmd/crossplane/render/testdata/xr.yaml new file mode 100644 index 0000000..a8d5e4a --- /dev/null +++ b/cmd/crossplane/render/testdata/xr.yaml @@ -0,0 +1,6 @@ +apiVersion: nop.example.org/v1alpha1 +kind: XNopResource +metadata: + name: test-render +spec: + coolField: "I'm cool!" diff --git a/cmd/crossplane/render/testdata/xrd.yaml b/cmd/crossplane/render/testdata/xrd.yaml new file mode 100644 index 0000000..66cef82 --- /dev/null +++ b/cmd/crossplane/render/testdata/xrd.yaml @@ -0,0 +1,26 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xnopresources.nop.example.org +spec: + group: nop.example.org + names: + kind: XNopResource + plural: xnopresources + singular: xnopresource + shortNames: + - xnr + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object + description: "A test resource" + properties: + spec: + type: object + properties: + coolField: + type: string \ No newline at end of file diff --git a/cmd/crossplane/render/xrd.go b/cmd/crossplane/render/xrd.go new file mode 100644 index 0000000..8d8955c --- /dev/null +++ b/cmd/crossplane/render/xrd.go @@ -0,0 +1,43 @@ +package render + +import ( + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + schema "k8s.io/apiextensions-apiserver/pkg/apiserver/schema" + structuraldefaulting "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// DefaultValues sets default values on the XR based on the CRD schema. +func DefaultValues(xr map[string]any, apiVersion string, crd extv1.CustomResourceDefinition) error { + var ( + k apiextensions.JSONSchemaProps + version *extv1.CustomResourceDefinitionVersion + ) + + for _, vr := range crd.Spec.Versions { + checkAPIVersion := crd.Spec.Group + "/" + vr.Name + if checkAPIVersion == apiVersion { + version = &vr + break + } + } + + if version == nil { + return errors.Errorf("the specified API version '%s' does not exist in the XRD", apiVersion) + } + + if err := extv1.Convert_v1_JSONSchemaProps_To_apiextensions_JSONSchemaProps(version.Schema.OpenAPIV3Schema, &k, nil); err != nil { + return err + } + + crdWithDefaults, err := schema.NewStructural(&k) + if err != nil { + return err + } + + structuraldefaulting.Default(xr, crdWithDefaults) + + return nil +} diff --git a/cmd/crossplane/render/xrd_test.go b/cmd/crossplane/render/xrd_test.go new file mode 100644 index 0000000..c12cb41 --- /dev/null +++ b/cmd/crossplane/render/xrd_test.go @@ -0,0 +1,251 @@ +package render + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" +) + +func TestDefaultValues(t *testing.T) { + type args struct { + xr map[string]any + crd extv1.CustomResourceDefinition + apiVersion string + } + + cases := map[string]struct { + name string + args args + want map[string]any + wantErr bool + }{ + "SetDefaultValues": { + name: "Should set default values according to schema", + args: args{ + apiVersion: "example.com/v1", + xr: map[string]any{ + "spec": map[string]any{}, + }, + crd: extv1.CustomResourceDefinition{ + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "example.com", + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "cooldown": { + Type: "integer", + Default: &extv1.JSON{Raw: []byte(`5`)}, + }, + "enabled": { + Type: "boolean", + Default: &extv1.JSON{Raw: []byte(`true`)}, + }, + "status": { + Type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: map[string]any{ + "spec": map[string]any{ + "cooldown": int64(5), + "enabled": true, + }, + }, + wantErr: false, + }, + "DontOverwriteExistingValues": { + name: "Should not overwrite existing values with defaults", + args: args{ + apiVersion: "example.com/v1", + xr: map[string]any{ + "spec": map[string]any{ + "cooldown": int64(10), + "enabled": false, + }, + }, + crd: extv1.CustomResourceDefinition{ + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "example.com", + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "cooldown": { + Type: "integer", + Default: &extv1.JSON{Raw: []byte(`5`)}, + }, + "enabled": { + Type: "boolean", + Default: &extv1.JSON{Raw: []byte(`true`)}, + }, + "status": { + Type: "string", + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: map[string]any{ + "spec": map[string]any{ + "cooldown": int64(10), + "enabled": false, + }, + }, + wantErr: false, + }, + "MultipleVersions": { + name: "Should only apply defaults from requested version", + args: args{ + apiVersion: "example.com/v2", + xr: map[string]any{ + "spec": map[string]any{}, + }, + crd: extv1.CustomResourceDefinition{ + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "example.com", + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "cooldown": { + Type: "integer", + Default: &extv1.JSON{Raw: []byte(`5`)}, + }, + "enabled": { + Type: "boolean", + Default: &extv1.JSON{Raw: []byte(`true`)}, + }, + }, + }, + }, + }, + }, + }, + { + Name: "v2", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "cooldown": { + Type: "integer", + Default: &extv1.JSON{Raw: []byte(`15`)}, + }, + "enabled": { + Type: "boolean", + Default: &extv1.JSON{Raw: []byte(`false`)}, + }, + "newField": { + Type: "string", + Default: &extv1.JSON{Raw: []byte(`"default"`)}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: map[string]any{ + "spec": map[string]any{ + "cooldown": int64(15), + "enabled": false, + "newField": "default", + }, + }, + wantErr: false, + }, + "IncorrectAPIVersion": { + name: "Should return error for incorrect API version", + args: args{ + apiVersion: "wrong-group/v1", + xr: map[string]any{ + "spec": map[string]any{}, + }, + crd: extv1.CustomResourceDefinition{ + Spec: extv1.CustomResourceDefinitionSpec{ + Group: "example.com", + Versions: []extv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Schema: &extv1.CustomResourceValidation{ + OpenAPIV3Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "cooldown": { + Type: "integer", + Default: &extv1.JSON{Raw: []byte(`5`)}, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + }, + want: map[string]any{ + "spec": map[string]any{}, + }, + wantErr: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := DefaultValues(tc.args.xr, tc.args.apiVersion, tc.args.crd) + if (err != nil) != tc.wantErr { + t.Errorf("DefaultValues() error = %v, wantErr %v", err, tc.wantErr) + } + + if diff := cmp.Diff(tc.want, tc.args.xr); diff != "" { + t.Errorf("DefaultValues() mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/cmd/crossplane/version/fetch.go b/cmd/crossplane/version/fetch.go new file mode 100644 index 0000000..927ec4d --- /dev/null +++ b/cmd/crossplane/version/fetch.go @@ -0,0 +1,90 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package version + +import ( + "context" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" + ctrl "sigs.k8s.io/controller-runtime" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +const ( + errKubeConfig = "failed to get kubeconfig" + errCreateK8sClientset = "could not create the clientset for Kubernetes" + errFetchCrossplaneDeployment = "could not fetch deployments" +) + +// FetchCrossplaneVersion initializes a Kubernetes client and fetches +// and returns the version of the Crossplane deployment. If the version +// does not have a leading 'v', it prepends it. +func FetchCrossplaneVersion(ctx context.Context) (string, error) { + var version string + + config, err := ctrl.GetConfig() + if err != nil { + return "", errors.Wrap(err, errKubeConfig) + } + + clientset, err := kubernetes.NewForConfig(config) + if err != nil { + return "", errors.Wrap(err, errCreateK8sClientset) + } + + deployments, err := clientset.AppsV1().Deployments("").List(ctx, metav1.ListOptions{ + LabelSelector: "app=crossplane", + }) + if err != nil { + return "", errors.Wrap(err, errFetchCrossplaneDeployment) + } + + for _, deployment := range deployments.Items { + v, ok := deployment.Labels["app.kubernetes.io/version"] + if ok { + if !strings.HasPrefix(v, "v") { + version = "v" + v + } + + return version, nil + } + + if len(deployment.Spec.Template.Spec.Containers) > 0 { + imageRef := deployment.Spec.Template.Spec.Containers[0].Image + + ref, err := name.ParseReference(imageRef) + if err != nil { + return "", errors.Wrap(err, "error parsing image reference") + } + + if tagged, ok := ref.(name.Tag); ok { + imageTag := tagged.TagStr() + if !strings.HasPrefix(imageTag, "v") { + imageTag = "v" + imageTag + } + + return imageTag, nil + } + } + } + + return "", errors.New("Crossplane version or image tag not found") +} diff --git a/cmd/crossplane/version/version.go b/cmd/crossplane/version/version.go new file mode 100644 index 0000000..a7d7197 --- /dev/null +++ b/cmd/crossplane/version/version.go @@ -0,0 +1,61 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package version contains version cmd +package version + +import ( + "context" + "fmt" + "time" + + "github.com/alecthomas/kong" + "github.com/pkg/errors" + + "github.com/crossplane/crossplane-runtime/v2/pkg/version" +) + +const ( + errGetCrossplaneVersion = "unable to get crossplane version" +) + +// Cmd represents the version command. +type Cmd struct { + Client bool `env:"" help:"If true, shows client version only (no server required)."` +} + +// Run runs the version command. +func (c *Cmd) Run(k *kong.Context) error { + _, _ = fmt.Fprintln(k.Stdout, "Client Version: "+version.New().GetVersionString()) + + if c.Client { + return nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + vxp, err := FetchCrossplaneVersion(ctx) + if err != nil { + return errors.Wrap(err, errGetCrossplaneVersion) + } + + if vxp != "" { + _, _ = fmt.Fprintln(k.Stdout, "Server Version: "+vxp) + } + + return nil +} diff --git a/cmd/crossplane/xpkg/batch.go b/cmd/crossplane/xpkg/batch.go new file mode 100644 index 0000000..bfad3a6 --- /dev/null +++ b/cmd/crossplane/xpkg/batch.go @@ -0,0 +1,570 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "bytes" + "context" + "fmt" + "io" + "maps" + "os" + "path/filepath" + "slices" + "strings" + "text/template" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/daemon" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/examples" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/yaml" +) + +const ( + errInvalidTemplate = "smaller provider metadata template is not valid" + errMetadataBackend = "failed to initialize the package metadata parser backend" + errCRDBackend = "failed to initialize the CRD parser backend" + errTemplateFmt = "failed to execute the provider metadata template using: %v" + errInvalidPlatformFmt = "failed to parse platform name. Expected syntax is _: %s" + errBuildPackageFmt = "failed to build smaller provider package: %s" + errGetConfigFmt = "failed to get config file from %s image for service %q" + errMutateConfigFmt = "failed to mutate config file from %s image for service %q" + errGetLayersFmt = "failed to get layers from %s image for service %q" + errGetBaseLayersFmt = "failed to get base layers from %s image for service %q" + errGetDigestFmt = "failed to get layer's digest from %s image for service %q" + errAppendLayersFmt = "failed to append layers to %s image for service %q" + errReadProviderBinFmt = "failed to read %q provider binary for %s platform from path: %s" + errNewLayerFmt = "failed to initialize a new image layer for %s platform for service %q" + errAddLayerFmt = "failed to add the smaller provider binary layer for %s platform for service %q" + errPushPackageFmt = "failed to push smaller provider package: %s" + errProcessFmt = "\nfailed to process smaller provider package for %q" + errOutputAbsFmt = "failed to get the absolute path for the package archive to store: %s/%s/%s" + errOpenPackageFmt = "failed to open package file for writing: %s" + errWritePackageFmt = "failed to store package archive in: %s" + errBatch = "processing of at least one smaller provider has failed" +) + +const ( + wildcard = "*" + tagLatest = "latest" +) + +// AfterApply constructs and binds context to any subcommands +// that have Run() methods that receive it. +func (c *batchCmd) AfterApply() error { + c.fs = afero.NewOsFs() + return nil +} + +// batchCmd builds and pushes a family of Crossplane provider packages. +type batchCmd struct { + fs afero.Fs + + FamilyBaseImage string `help:"Family image used as the base for the smaller provider packages." required:""` + ProviderName string `help:"Provider name, such as provider-aws to be used while formatting smaller provider package repositories." required:""` + FamilyPackageURLFormat string `help:"Family package URL format to be used for the smaller provider packages. Must be a valid OCI image URL with the format specifier \"%s\", which will be substituted with -." required:""` + SmallerProviders []string `default:"monolith" help:"Smaller provider names to build and push, such as ec2, eks or config."` + Concurrency uint `default:"0" help:"Maximum number of packages to process concurrently. Setting it to 0 puts no limit on the concurrency, i.e., all packages are processed in parallel."` + PushRetry uint `default:"3" help:"Number of retries when pushing a provider package fails."` + + Platform []string `default:"linux_amd64,linux_arm64" help:"Platforms to build the packages for. Each platform should use the _ syntax. An example is: linux_arm64."` + ProviderBinRoot string `help:"Provider binary paths root. Smaller provider binaries should reside under the platform directories in this folder." short:"p" type:"existingdir"` + OutputDir string `help:"Path of the package output directory." optional:"" short:"o"` + StorePackages []string `help:"Smaller provider names whose provider package should be stored under the package output directory specified with the --output-dir option." optional:""` + + PackageMetadataTemplate string `default:"./package/crossplane.yaml.tmpl" help:"Smaller provider metadata template. The template variables {{ .Service }} and {{ .Name }} are always set; optional variables may be supplied via --service-metadata and --template-var (the latter overrides on key conflicts)." type:"path"` + TemplateVar map[string]string `help:"Smaller provider metadata template variables to be used for the specified template."` + ServiceMetadataFile string `help:"Optional YAML file of per smaller-provider template variables. Top-level keys are smaller provider names (e.g. ec2, elb). Each entry is a map of variable names to scalars or lists; values are merged into the package metadata template as-is. Templates may use generic helpers toYAML and indent (YAML via gopkg.in/yaml.v3). Merged before --template-var." name:"service-metadata" optional:"" type:"path"` + + ExamplesGroupOverride map[string]string `help:"Overrides for the location of the example manifests folder of a smaller provider." optional:""` + CRDGroupOverride map[string]string `help:"Overrides for the locations of the CRD folders of the smaller providers." optional:""` + PackageRepoOverride map[string]string `help:"Overrides for the package repository names of the smaller providers." optional:""` + + ExamplesRoot string `default:"./examples" help:"Path to package examples directory." short:"e" type:"path"` + CRDRoot string `default:"./package/crds" help:"Path to package CRDs directory." type:"path"` + Ignore []string `help:"Paths to exclude from the smaller provider packages."` + BuildOnly bool `default:"false" help:"Only build the smaller provider packages and do not attempt to push them to a package repository."` + + ProviderNameSuffixForPush string `env:"PROVIDER_NAME_SUFFIX_FOR_PUSH" help:"Suffix for provider name during pushing the packages. This suffix is added to the end of the provider name. If there is a service name for the corresponded provider, then the suffix will be added to the base provider name and the service-scoped name will be after this suffix. Examples: provider-family-aws-suffix, provider-aws-suffix-s3" optional:""` + + perServiceTemplateVars map[string]map[string]any // loaded from ServiceMetadataFile in Run +} + +// Run executes the batch command. +func (c *batchCmd) Run(logger logging.Logger) error { + if err := c.loadServiceMetadata(); err != nil { + return err + } + + ctx := context.Background() + baseImgMap, err := makeBaseImgMap(ctx, c.Platform, c.FamilyBaseImage) + if err != nil { + return err + } + + chErr := make(chan error, len(c.SmallerProviders)) + defer close(chErr) + concurrency := make(chan struct{}, c.Concurrency) + defer close(concurrency) + for range c.Concurrency { + concurrency <- struct{}{} + } + for _, s := range c.SmallerProviders { + go func() { + // if concurrency is limited + if c.Concurrency != 0 { + <-concurrency + defer func() { + concurrency <- struct{}{} + }() + } + err := c.processService(logger, baseImgMap, s) + if err != nil { + logger.Info("Publishing of smaller provider package has failed for service", "service", s, "error", err) + } + chErr <- errors.WithMessagef(err, errProcessFmt, s) + }() + } + var result error + for range c.SmallerProviders { + err := <-chErr + switch { + case result == nil: + result = err + case err != nil: + result = errors.Wrap(result, err.Error()) + } + } + return errors.WithMessage(result, errBatch) +} + +// makeBaseImgMap processes the given platforms to return a map of platforms to +// base images to use for those platforms. +func makeBaseImgMap(ctx context.Context, platforms []string, familyBaseImage string) (map[string]v1.Image, error) { + baseImgMap := make(map[string]v1.Image, len(platforms)) + for _, p := range platforms { + tokens := strings.Split(p, "_") + if len(tokens) != 2 { + return nil, errors.Errorf(errInvalidPlatformFmt, p) + } + ref, err := name.ParseReference(fmt.Sprintf("%s-%s", familyBaseImage, tokens[1])) + if err != nil { + return nil, err + } + img, err := daemon.Image(ref, daemon.WithContext(ctx)) + if err != nil { + return nil, err + } + baseImgMap[p] = img // assumes correct OS + } + + return baseImgMap, nil +} + +// processService builds and pushes the smaller provider package +// associated with the specified service `s` and for the specified platforms. +// Each smaller provider package share a common (platform specific) base +// image specified in the `baseImgMap` keyed by the platform name +// (e.g., linux_arm64). The addendum layers (i.e., the layers +// added by xpkg push on top of the base image) are shared across platforms, +// and thus is computed only once. `processService` also adds +// the smaller provider controller binary (which is platform specific) on top +// of the addendum layers and then pushes the built multi-arch package +// (if `len(c.Platforms) > 1`) to the specified package repository. +func (c *batchCmd) processService(logger logging.Logger, baseImgMap map[string]v1.Image, s string) error { + imgs := make([]packageImage, 0, len(c.Platform)) + // image layers added on top of the base image by xpkg push to be reused + // across the platforms so that they are computed only once. + var addendumLayers []v1.Layer + // labels in the image configuration associated with these addendum layers. + var labels [][2]string + for _, p := range c.Platform { + var img v1.Image + var err error + switch { + // if the addendum layers have already been computed, + // use them instead of recomputing. + case len(addendumLayers) > 0: + img = baseImgMap[p] + img, err = c.appendAddendumLayers(img, addendumLayers, labels, p, s) + if err != nil { + return err + } + // then we need to compute the provider metadata "base" layer and the extensions layer. + default: + img, err = c.buildImage(baseImgMap, p, s) + if err != nil { + return err + } + // calculate addendum layers to reuse + addendumLayers, labels, err = getAddendumLayers(baseImgMap[p], img, p, s) + if err != nil { + return err + } + } + imgs = append(imgs, packageImage{Image: img, Path: fmt.Sprintf("%s-%s", s, p)}) + } + if err := c.storePackage(logger, s, imgs); err != nil { + return err + } + if c.BuildOnly { + return nil + } + // now try to push the package with the specified retry configuration. + return c.pushWithRetry(logger, imgs, s) +} + +// Optionally stores the provider package under the configured directory, +// if the service name exists in the c.StorePackage slice. +func (c *batchCmd) storePackage(logger logging.Logger, s string, imgs []packageImage) error { + found := slices.Contains(c.StorePackages, s) + if !found { + return nil + } + for i, p := range c.Platform { + if err := c.writePackage(logger, s, p, imgs[i].Image); err != nil { + return err + } + } + return nil +} + +// writePackage writes the given image as an xpkg file to the output +// directory, within a sub directory for each platform and each package file name +// with format: {provider}-{service}-{version}.xpkg. +func (c *batchCmd) writePackage(logger logging.Logger, service, platform string, img v1.Image) error { + fName := fmt.Sprintf("%s-%s-%s.xpkg", c.ProviderName, service, c.getPackageVersion()) + pkgPath, err := filepath.Abs(filepath.Join(c.OutputDir, platform, fName)) + if err != nil { + return errors.Wrapf(err, errOutputAbsFmt, c.OutputDir, platform, fName) + } + pkg, err := c.fs.Create(pkgPath) + if err != nil { + return errors.Wrapf(err, errOpenPackageFmt, pkgPath) + } + defer func() { _ = pkg.Close() }() + if err := tarball.Write(nil, img, pkg); err != nil { + return errors.Wrapf(err, errWritePackageFmt, pkgPath) + } + logger.Info(fmt.Sprintf("xpkg for service %q saved to %s", service, pkgPath)) + return nil +} + +// pushWithRetry attempts to push the given package images up to the configured +// retry count. If all retries fail then an error is returned. +func (c *batchCmd) pushWithRetry(logger logging.Logger, imgs []packageImage, s string) error { + t := c.getPackageURL(s) + tries := c.PushRetry + 1 + retryMsg := "" + for i := range tries { + logger.Info(fmt.Sprintf("Pushing xpkg to %s.%s", t, retryMsg)) + err := pushImages(logger, imgs, t) + if err == nil { + break + } + if i == tries-1 { // no more retries + logger.Info(fmt.Sprintf("Failed to push xpkg to %s. Total number of attempts: %d. Last error: %s", t, tries, err.Error())) + return errors.Wrapf(err, errPushPackageFmt, s) + } + + logger.Info(fmt.Sprintf("Failed to push xpkg to %s. Will retry...: %v", t, err)) + retryMsg = fmt.Sprintf(" Retry count: %d", i+1) + } + return nil +} + +func (c *batchCmd) getPackageVersion() string { + tokens := strings.Split(c.FamilyPackageURLFormat, ":") + if len(tokens) < 2 { + return tagLatest + } + return tokens[len(tokens)-1] +} + +func (c *batchCmd) getPackageRepo(s string) string { + repo := c.PackageRepoOverride[s] + if repo == "" { + repo = fmt.Sprintf("%s-%s", c.ProviderName, s) + } + return repo +} + +func (c *batchCmd) getPackageRepoWithSuffix(s string) string { + if v, ok := c.PackageRepoOverride[s]; ok { + return fmt.Sprintf("%s-%s", v, c.ProviderNameSuffixForPush) + } + return fmt.Sprintf("%s-%s-%s", c.ProviderName, c.ProviderNameSuffixForPush, s) +} + +func (c *batchCmd) getPackageURL(s string) string { + if c.ProviderNameSuffixForPush != "" { + return fmt.Sprintf(c.FamilyPackageURLFormat, c.getPackageRepoWithSuffix(s)) + } + return fmt.Sprintf(c.FamilyPackageURLFormat, c.getPackageRepo(s)) +} + +// getAddendumLayers returns the diff layers between the specified +// `baseImg` and the specified `img`. For each of these addendum layers, +// it also returns labels associated with that layer +// in the image configuration as a slice of (key, value) pairs. +func getAddendumLayers(baseImg, img v1.Image, platform, service string) (addendumLayers []v1.Layer, layerLabels [][2]string, err error) { + baseLayers, err := baseImg.Layers() + if err != nil { + return nil, nil, errors.Wrapf(err, errGetBaseLayersFmt, platform, service) + } + layers, err := img.Layers() + if err != nil { + return nil, nil, errors.Wrapf(err, errGetLayersFmt, platform, service) + } + addendumLayers = layers[len(baseLayers) : len(layers)-1] + // get associated labels from image config + cfg, err := img.ConfigFile() + if err != nil { + return nil, nil, errors.Wrapf(err, errGetConfigFmt, platform, service) + } + layerLabels = make([][2]string, 0, len(addendumLayers)) + for _, l := range addendumLayers { + d, err := l.Digest() + if err != nil { + return nil, nil, errors.Wrapf(err, errGetDigestFmt, platform, service) + } + label := "" + key := xpkg.Label(d.String()) + for k, v := range cfg.Config.Labels { + if key == k { + label = v + break + } + } + layerLabels = append(layerLabels, [2]string{key, label}) + } + return addendumLayers, layerLabels, nil +} + +// appendAddendumLayers appends the given addendum layers to the image and +// labels to the image config, allowing reuse of the addendum layers and only +// calculating them once. +func (c *batchCmd) appendAddendumLayers(img v1.Image, addendumLayers []v1.Layer, labels [][2]string, p, s string) (v1.Image, error) { + var err error + for _, l := range addendumLayers { + img, err = mutate.AppendLayers(img, l) + if err != nil { + return nil, errors.Wrapf(err, errAppendLayersFmt, p, s) + } + } + // add any already computed addendum layer labels + // into the image configuration. + cfg, err := img.ConfigFile() + if err != nil { + return nil, errors.Wrapf(err, errGetConfigFmt, p, s) + } + if cfg.Config.Labels == nil { + cfg.Config.Labels = make(map[string]string, len(labels)) + } + for _, kv := range labels { + if kv[1] == "" { + continue + } + cfg.Config.Labels[kv[0]] = kv[1] + } + img, err = mutate.Config(img, cfg.Config) + if err != nil { + return nil, errors.Wrapf(err, errMutateConfigFmt, p, s) + } + // add the smaller provider controller binary as a layer. + return c.addProviderBinaryLayer(img, p, s) +} + +func (c *batchCmd) buildImage(baseImgMap map[string]v1.Image, p, s string) (v1.Image, error) { + builder, err := c.getBuilder(s) + if err != nil { + return nil, err + } + img, _, err := builder.Build(context.Background(), xpkg.WithBase(baseImgMap[p])) + if err != nil { + return nil, errors.Wrapf(err, errBuildPackageFmt, s) + } + return c.addProviderBinaryLayer(img, p, s) +} + +// addProviderBinaryLayer adds the platform specific provider executable as the +// final layer to the given image. +func (c *batchCmd) addProviderBinaryLayer(img v1.Image, p, s string) (v1.Image, error) { + configFile, err := img.ConfigFile() + if err != nil { + return nil, errors.Wrapf(err, errGetConfigFmt, p, s) + } + binPath := filepath.Join(c.ProviderBinRoot, p, s) + buff, err := os.ReadFile(filepath.Clean(binPath)) + if err != nil { + return nil, errors.Wrapf(err, errReadProviderBinFmt, s, p, binPath) + } + l, err := xpkg.Layer(bytes.NewBuffer(buff), "/usr/local/bin/provider", "", int64(len(buff)), 0o755, &configFile.Config) + if err != nil { + return nil, errors.Wrapf(err, errNewLayerFmt, p, s) + } + img, err = mutate.AppendLayers(img, l) + return img, errors.Wrapf(err, errAddLayerFmt, p, s) +} + +func (c *batchCmd) getExamplesGroup(service string) string { + p := c.ExamplesGroupOverride[service] + switch p { + case wildcard: + p = "" + case "": + p = service + } + return filepath.Join(c.ExamplesRoot, p) +} + +// getBuilder initializes an xpkg builder for the given service that uses our +// batch parser backend to parse all the content for the package. +func (c *batchCmd) getBuilder(service string) (*xpkg.Builder, error) { + ex, err := filepath.Abs(c.getExamplesGroup(service)) + if err != nil { + return nil, err + } + + pp, err := yaml.New() + if err != nil { + return nil, err + } + + packageMetadata, err := c.getPackageMetadata(service) + if err != nil { + return nil, err + } + + return xpkg.New( + &batchParserBackend{ + packageMetadata: packageMetadata, + service: service, + fs: c.fs, + options: []parser.BackendOption{ + parser.FsDir(c.CRDRoot), + parser.FsFilters( + append( + buildFilters(c.CRDRoot, c.Ignore), + xpkg.SkipContains(c.ExamplesRoot), + func(_ string, info os.FileInfo) (bool, error) { + return !strings.HasPrefix(info.Name(), c.getCRDPrefix(service)), nil + })...), + }, + }, + parser.NewFsBackend( + c.fs, + parser.FsDir(ex), + parser.FsFilters( + buildFilters(ex, c.Ignore)...), + ), + pp, + examples.New(), + ), nil +} + +func (c *batchCmd) getCRDPrefix(service string) string { + o := c.CRDGroupOverride[service] + if o == wildcard { + return "" + } + if o == "" { + o = service + } + return o + "." +} + +// getPackageMetadata uses the provided package metadata template and does +// variable substitution to create the final package metadata file (i.e., +// crossplane.yaml). +func (c *batchCmd) getPackageMetadata(service string) (string, error) { + tmpl, err := template.New(filepath.Base(c.PackageMetadataTemplate)). + Funcs(packageMetadataFuncMap()). + ParseFiles(c.PackageMetadataTemplate) + if err != nil { + return "", errors.Wrap(err, errInvalidTemplate) + } + + data := make(map[string]any, len(c.TemplateVar)+2) + data["Service"] = service + data["Name"] = c.getPackageRepo(service) + if c.perServiceTemplateVars != nil { + if m, ok := c.perServiceTemplateVars[service]; ok { + maps.Copy(data, m) + } + } + for k, v := range c.TemplateVar { + data[k] = v + } + + buff := &bytes.Buffer{} + err = tmpl.Execute(buff, data) + if err != nil { + return "", errors.Wrapf(err, errTemplateFmt, data) + } + return buff.String(), nil +} + +type batchParserBackend struct { + packageMetadata string + service string + options []parser.BackendOption + + fs afero.Fs +} + +func (b *batchParserBackend) Init(ctx context.Context, opts ...parser.BackendOption) (io.ReadCloser, error) { + rcMetadata, err := parser.NewEchoBackend(b.packageMetadata).Init(ctx, opts...) + if err != nil { + return nil, errors.Wrap(err, errMetadataBackend) + } + rcCRD, err := parser.NewFsBackend(b.fs, b.options...).Init(ctx, opts...) + if err != nil { + return nil, errors.Wrap(err, errCRDBackend) + } + return &batchReadCloser{ + metadataReadCloser: rcMetadata, + crdReadCloser: rcCRD, + }, nil +} + +type batchReadCloser struct { + metadataReadCloser io.ReadCloser + crdReadCloser io.ReadCloser + metadataRead bool +} + +func (b *batchReadCloser) Read(p []byte) (n int, err error) { + if !b.metadataRead { + b.metadataRead = true + return b.metadataReadCloser.Read(p) + } + return b.crdReadCloser.Read(p) +} + +func (b *batchReadCloser) Close() error { + return b.crdReadCloser.Close() // echo backend's io.Closer implementation is a noop one. +} diff --git a/cmd/crossplane/xpkg/build.go b/cmd/crossplane/xpkg/build.go new file mode 100644 index 0000000..633526e --- /dev/null +++ b/cmd/crossplane/xpkg/build.go @@ -0,0 +1,231 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "path/filepath" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/daemon" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/examples" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/yaml" +) + +const ( + errGetNameFromMeta = "failed to get package name from crossplane.yaml" + errBuildPackage = "failed to build package" + errImageDigest = "failed to get package digest" + errCreatePackage = "failed to create package file" + errParseRuntimeImageRef = "failed to parse runtime image reference" + errPullRuntimeImage = "failed to pull runtime image" + errLoadRuntimeTarball = "failed to load runtime tarball" + errGetRuntimeBaseImageOpts = "failed to get runtime base image options" +) + +// AfterApply constructs and binds context to any subcommands +// that have Run() methods that receive it. +func (c *buildCmd) AfterApply() error { + c.fs = afero.NewOsFs() + + root, err := filepath.Abs(c.PackageRoot) + if err != nil { + return err + } + + c.root = root + + ex, err := filepath.Abs(c.ExamplesRoot) + if err != nil { + return err + } + + pp, err := yaml.New() + if err != nil { + return err + } + + c.builder = xpkg.New( + parser.NewFsBackend( + c.fs, + parser.FsDir(root), + parser.FsFilters( + append( + buildFilters(root, c.Ignore), + xpkg.SkipContains(c.ExamplesRoot))...), + ), + parser.NewFsBackend( + c.fs, + parser.FsDir(ex), + parser.FsFilters( + buildFilters(ex, c.Ignore)...), + ), + pp, + examples.New(), + ) + + return nil +} + +// buildCmd builds a crossplane package. +type buildCmd struct { + // Flags. Keep sorted alphabetically. + EmbedRuntimeImage string `help:"An OCI image to embed in the package as its runtime." placeholder:"NAME" xor:"runtime-image"` + EmbedRuntimeImageTarball string `help:"An OCI image tarball to embed in the package as its runtime." placeholder:"PATH" predictor:"file" type:"existingfile" xor:"runtime-image"` + ExamplesRoot string `default:"./examples" help:"A directory of example YAML files to include in the package." predictor:"directory" short:"e" type:"path"` + Ignore []string `help:"Comma-separated file paths, specified relative to --package-root, to exclude from the package. Wildcards are supported. Directories cannot be excluded." placeholder:"PATH"` + PackageFile string `help:"The file to write the package to. Defaults to a generated filename in --package-root." placeholder:"PATH" predictor:"xpkg_file" short:"o" type:"path"` + PackageRoot string `default:"." help:"The directory that contains the package's crossplane.yaml file." predictor:"directory" short:"f" type:"existingdir"` + + // Internal state. These aren't part of the user-exposed CLI structure. + fs afero.Fs + builder *xpkg.Builder + root string +} + +func (c *buildCmd) Help() string { + return ` +This command builds a package file from a local directory of files. + +Examples: + + # Build a package from the files in the 'package' directory. + crossplane xpkg build --package-root=package/ + + # Build a package that embeds a Provider's controller OCI image built with + # 'docker build' so that the package can also be used to run the provider. + # Provider and Function packages support embedding runtime images. + crossplane xpkg build --embed-runtime-image=cc873e13cdc1 +` +} + +// GetRuntimeBaseImageOpts returns the controller base image options. +func (c *buildCmd) GetRuntimeBaseImageOpts() ([]xpkg.BuildOpt, error) { + switch { + case c.EmbedRuntimeImageTarball != "": + img, err := tarball.ImageFromPath(filepath.Clean(c.EmbedRuntimeImageTarball), nil) + if err != nil { + return nil, errors.Wrap(err, errLoadRuntimeTarball) + } + + return []xpkg.BuildOpt{xpkg.WithBase(img)}, nil + case c.EmbedRuntimeImage != "": + // We intentionally don't use strict validation here. Usually + // we'll tag the runtime image as something like + // 'runtime-amd64', and never actually push it to an OCI + // registry. That tag wouldn't pass strict validation. + ref, err := name.ParseReference(c.EmbedRuntimeImage) + if err != nil { + return nil, errors.Wrap(err, errParseRuntimeImageRef) + } + + img, err := daemon.Image(ref, daemon.WithContext(context.Background())) + if err != nil { + return nil, errors.Wrap(err, errPullRuntimeImage) + } + + return []xpkg.BuildOpt{xpkg.WithBase(img)}, nil + } + + return nil, nil +} + +// GetOutputFileName prepares output file name. +func (c *buildCmd) GetOutputFileName(meta runtime.Object, hash v1.Hash) (string, error) { + output := filepath.Clean(c.PackageFile) + if c.PackageFile == "" { + pkgMeta, ok := meta.(metav1.Object) + if !ok { + return "", errors.New(errGetNameFromMeta) + } + + pkgName := xpkg.FriendlyID(pkgMeta.GetName(), hash.Hex) + output = xpkg.BuildPath(c.root, pkgName, xpkg.XpkgExtension) + } + + return output, nil +} + +// Run executes the build command. +func (c *buildCmd) Run(logger logging.Logger) error { + var buildOpts []xpkg.BuildOpt + + rtBuildOpts, err := c.GetRuntimeBaseImageOpts() + if err != nil { + return errors.Wrap(err, errGetRuntimeBaseImageOpts) + } + + buildOpts = append(buildOpts, rtBuildOpts...) + + img, meta, err := c.builder.Build(context.Background(), buildOpts...) + if err != nil { + return errors.Wrap(err, errBuildPackage) + } + + hash, err := img.Digest() + if err != nil { + return errors.Wrap(err, errImageDigest) + } + + output, err := c.GetOutputFileName(meta, hash) + if err != nil { + return err + } + + f, err := c.fs.Create(output) + if err != nil { + return errors.Wrap(err, errCreatePackage) + } + + defer func() { _ = f.Close() }() + + if err := tarball.Write(nil, img, f); err != nil { + return err + } + + logger.Info("xpkg saved", "output", output) + + return nil +} + +// default build filters skip directories, empty files, and files without YAML +// extension in addition to any paths specified. +func buildFilters(root string, skips []string) []parser.FilterFn { + defaultFns := []parser.FilterFn{ + parser.SkipDirs(), + parser.SkipNotYAML(), + parser.SkipEmpty(), + } + opts := make([]parser.FilterFn, len(skips)+len(defaultFns)) + copy(opts, defaultFns) + + for i, s := range skips { + opts[i+len(defaultFns)] = parser.SkipPath(filepath.Join(root, s)) + } + + return opts +} diff --git a/cmd/crossplane/xpkg/extract.go b/cmd/crossplane/xpkg/extract.go new file mode 100644 index 0000000..aad0d90 --- /dev/null +++ b/cmd/crossplane/xpkg/extract.go @@ -0,0 +1,229 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "archive/tar" + "compress/gzip" + "context" + "io" + "os" + "path/filepath" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/daemon" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" +) + +const ( + errMustProvideTag = "must provide package tag if fetching from registry or daemon" + errInvalidTag = "package tag is not a valid reference" + errFetchPackage = "failed to fetch package from remote" + errGetManifest = "failed to get package image manifest from remote" + errFetchLayer = "failed to fetch annotated base layer from remote" + errGetUncompressed = "failed to get uncompressed contents from layer" + errMultipleAnnotatedLayers = "package is invalid due to multiple annotated base layers" + errOpenPackageStream = "failed to open package stream file" + errCreateOutputFile = "failed to create output file" + errCreateGzipWriter = "failed to create gzip writer" + errExtractPackageContents = "failed to extract package contents" + cacheContentExt = ".gz" +) + +// fetchFn fetches a package from a source. +type fetchFn func(context.Context, name.Reference) (v1.Image, error) + +// registryFetch fetches a package from the registry. +func registryFetch(ctx context.Context, r name.Reference) (v1.Image, error) { + // Use default docker auth, i.e. for private repositories. + kc := authn.NewMultiKeychain(authn.DefaultKeychain) + return remote.Image(r, remote.WithContext(ctx), remote.WithAuthFromKeychain(kc)) +} + +// daemonFetch fetches a package from the Docker daemon. +func daemonFetch(ctx context.Context, r name.Reference) (v1.Image, error) { + return daemon.Image(r, daemon.WithContext(ctx)) +} + +func xpkgFetch(path string) fetchFn { + return func(_ context.Context, _ name.Reference) (v1.Image, error) { + return tarball.ImageFromPath(filepath.Clean(path), nil) + } +} + +// AfterApply constructs and binds context to any subcommands +// that have Run() methods that receive it. +func (c *extractCmd) AfterApply() error { + c.fs = afero.NewOsFs() + + c.fetch = registryFetch + if c.FromDaemon { + c.fetch = daemonFetch + } + + if c.FromXpkg { + // If package is not defined, attempt to find single package in current + // directory. + if c.Package == "" { + wd, err := os.Getwd() + if err != nil { + return errors.Wrap(err, errGetwd) + } + + path, err := xpkg.FindXpkgInDir(c.fs, wd) + if err != nil { + return errors.Wrap(err, errFindPackageinWd) + } + + c.Package = path + } + + c.fetch = xpkgFetch(c.Package) + } + + if !c.FromXpkg { + if c.Package == "" { + return errors.New(errMustProvideTag) + } + + name, err := name.ParseReference(c.Package, name.StrictValidation) + if err != nil { + return errors.Wrap(err, errInvalidTag) + } + + c.name = name + } + + return nil +} + +// extractCmd extracts package contents into a Crossplane cache compatible +// format. +type extractCmd struct { + fs afero.Fs + name name.Reference + fetch fetchFn + + Package string `arg:"" help:"Name of the package to extract. Must be a valid and fully qualified OCI image tag or a path if using --from-xpkg." optional:"" placeholder:"REGISTRY/REPOSITORY:TAG or PATH"` + FromDaemon bool `help:"Indicates that the image should be fetched from the Docker daemon."` + FromXpkg bool `help:"Indicates that the image should be fetched from a local xpkg. If package is not specified and only one exists in current directory it will be used."` + Output string `default:"out.gz" help:"Package output file path. Extension must be .gz or will be replaced." short:"o"` +} + +// Run runs the xpkg extract cmd. +func (c *extractCmd) Run(logger logging.Logger) error { //nolint:gocyclo // xpkg extract for cli + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + // Fetch package. + img, err := c.fetch(ctx, c.name) + if err != nil { + return errors.Wrap(err, errFetchPackage) + } + + // Get image manifest. + manifest, err := img.Manifest() + if err != nil { + return errors.Wrap(err, errGetManifest) + } + + // Determine if the image is using annotated layers. + var tarc io.ReadCloser + + foundAnnotated := false + + for _, l := range manifest.Layers { + if a, ok := l.Annotations[xpkg.AnnotationKey]; !ok || a != xpkg.PackageAnnotation { + continue + } + + if foundAnnotated { + return errors.New(errMultipleAnnotatedLayers) + } + + foundAnnotated = true + + layer, err := img.LayerByDigest(l.Digest) + if err != nil { + return errors.Wrap(err, errFetchLayer) + } + + tarc, err = layer.Uncompressed() + if err != nil { + return errors.Wrap(err, errGetUncompressed) + } + } + + // If we still don't have content then we need to flatten image filesystem. + if !foundAnnotated { + tarc = mutate.Extract(img) + } + + // The ReadCloser is an uncompressed tarball, either consisting of annotated + // layer contents or flattened filesystem content. Either way, we only want + // the package YAML stream. + t := tar.NewReader(tarc) + + var size int64 + + for { + h, err := t.Next() + if err != nil { + return errors.Wrap(err, errOpenPackageStream) + } + + if h.Name == xpkg.StreamFile { + size = h.Size + break + } + } + + out := xpkg.ReplaceExt(filepath.Clean(c.Output), cacheContentExt) + + cf, err := c.fs.Create(out) + if err != nil { + return errors.Wrap(err, errCreateOutputFile) + } + defer cf.Close() //nolint:errcheck // defer close + + w, err := gzip.NewWriterLevel(cf, gzip.BestSpeed) + if err != nil { + return errors.Wrap(err, errCreateGzipWriter) + } + + if _, err = io.CopyN(w, t, size); err != nil { + return errors.Wrap(err, errExtractPackageContents) + } + + if err := w.Close(); err != nil { + return errors.Wrap(err, errExtractPackageContents) + } + + logger.Debug("xpkg contents extracted to %s", out) + + return nil +} diff --git a/cmd/crossplane/xpkg/extract_test.go b/cmd/crossplane/xpkg/extract_test.go new file mode 100644 index 0000000..0092ce2 --- /dev/null +++ b/cmd/crossplane/xpkg/extract_test.go @@ -0,0 +1,137 @@ +/* +Copyright 2025 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "archive/tar" + "bytes" + "context" + "io" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/random" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/go-containerregistry/pkg/v1/types" + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/test" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" +) + +func TestExtractRun(t *testing.T) { + errBoom := errors.New("boom") + validTag := name.MustParseReference("crossplane/provider-aws:v0.24.1") + randLayer, _ := random.Layer(int64(1000), types.DockerLayer) + randImg, _ := mutate.Append(empty.Image, mutate.Addendum{ + Layer: randLayer, + Annotations: map[string]string{ + xpkg.AnnotationKey: xpkg.PackageAnnotation, + }, + }) + + randImgDup, _ := mutate.Append(randImg, mutate.Addendum{ + Layer: randLayer, + Annotations: map[string]string{ + xpkg.AnnotationKey: xpkg.PackageAnnotation, + }, + }) + + streamCont := "somestreamofyaml" + tarBuf := new(bytes.Buffer) + tw := tar.NewWriter(tarBuf) + hdr := &tar.Header{ + Name: xpkg.StreamFile, + Mode: int64(xpkg.StreamFileMode), + Size: int64(len(streamCont)), + } + _ = tw.WriteHeader(hdr) + _, _ = io.Copy(tw, strings.NewReader(streamCont)) + _ = tw.Close() + + packLayer, _ := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + // NOTE(hasheddan): we must construct a new reader each time as we + // ingest packImg in multiple tests below. + return io.NopCloser(bytes.NewReader(tarBuf.Bytes())), nil + }) + packImg, _ := mutate.AppendLayers(empty.Image, packLayer) + + cases := map[string]struct { + reason string + fs afero.Fs + name name.Reference + fetch fetchFn + out string + want error + }{ + "ErrorFetchPackage": { + reason: "Should return error if we fail to fetch package.", + name: validTag, + fetch: func(_ context.Context, _ name.Reference) (v1.Image, error) { + return nil, errBoom + }, + want: errors.Wrap(errBoom, errFetchPackage), + }, + "ErrorMultipleAnnotatedLayers": { + reason: "Should return error if manifest contains multiple annotated layers.", + name: validTag, + fetch: func(_ context.Context, _ name.Reference) (v1.Image, error) { + return randImgDup, nil + }, + want: errors.New(errMultipleAnnotatedLayers), + }, + "ErrorFetchBadPackage": { + reason: "Should return error if image with contents does not have package.yaml.", + name: validTag, + fetch: func(_ context.Context, _ name.Reference) (v1.Image, error) { + return randImg, nil + }, + want: errors.Wrap(io.EOF, errOpenPackageStream), + }, + "Success": { + reason: "Should not return error if we successfully fetch package and extract contents.", + name: validTag, + fetch: func(_ context.Context, _ name.Reference) (v1.Image, error) { + return packImg, nil + }, + fs: afero.NewMemMapFs(), + out: "out.gz", + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + logger := logging.NewNopLogger() + + err := (&extractCmd{ + fs: tc.fs, + fetch: tc.fetch, + name: tc.name, + Output: tc.out, + }).Run(logger) + if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\nRun(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/xpkg/init.go b/cmd/crossplane/xpkg/init.go new file mode 100644 index 0000000..8f0b689 --- /dev/null +++ b/cmd/crossplane/xpkg/init.go @@ -0,0 +1,307 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "fmt" + "io" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + + "github.com/alecthomas/kong" + "github.com/go-git/go-billy/v5/osfs" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/storage/memory" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" +) + +const ( + notes = "NOTES.txt" + initScript = "init.sh" +) + +// WellKnownTemplates are short aliases for template repositories. +func WellKnownTemplates() map[string]string { + return map[string]string{ + "provider-template": "https://github.com/crossplane/provider-template", + "provider-template-upjet": "https://github.com/crossplane/upjet-provider-template", + "function-template-go": "https://github.com/crossplane/function-template-go", + "function-template-python": "https://github.com/crossplane/function-template-python", + "configuration-template": "https://github.com/crossplane/configuration-template", + } +} + +// initCmd initializes a new package repository from a template repository. +type initCmd struct { + Name string `arg:"" help:"The name of the new package to initialize."` + Template string `arg:"" help:"The template name or URL to use to initialize the new package."` + + Directory string `default:"." help:"The directory to initialize. It must be empty. It will be created if it doesn't exist." predictor:"directory" short:"d" type:"path"` + RunInitScript bool `help:"Runs the init.sh script if it exists without prompting" name:"run-init-script" short:"r"` + RefName string `help:"The branch or tag to clone from the template repository." name:"ref-name" short:"b"` +} + +func (c *initCmd) Help() string { + tpl := ` +This command initializes a directory that you can use to build a package. It +uses a template to initialize the directory. It can use any Git repository as a +template. + +You can specify either a full Git URL or a well-known name as a template. The +following well-known template names are supported: + +%s + +If the template contains NOTES.txt in its root directory, it will be +printed to stdout. This is useful for providing instructions for how +to use the template. + +If the template contains init.sh in its root directory, it will be optionally +printed out and executed. This is useful for providing a script that can be +used to initialize the package automatically. Use the -r flag to run the +script without prompting. + +Examples: + + # Initialize a new Go Composition Function named function-example. + crossplane xpkg init function-example function-template-go + + # Initialize a new Provider named provider-example from a custom template. + crossplane xpkg init provider-example https://github.com/crossplane/provider-template-custom + + # Initialize a new Go Composition Function named function-example and run + # its init.sh script (if it exists) without prompting the user or displaying its contents. + crossplane xpkg init function-example function-template-go --run-init-script +` + + b := strings.Builder{} + for name, url := range WellKnownTemplates() { + b.WriteString(fmt.Sprintf(" - %s (%s)\n", name, url)) + } + + return fmt.Sprintf(tpl, b.String()) +} + +func (c *initCmd) Run(k *kong.Context, logger logging.Logger) error { + f, err := os.Stat(c.Directory) + switch { + case err == nil && !f.IsDir(): + return errors.Errorf("path %s is not a directory", c.Directory) + case os.IsNotExist(err): + if err := os.MkdirAll(c.Directory, 0o750); err != nil { + return errors.Wrapf(err, "failed to create directory %s", c.Directory) + } + + logger.Debug("Created directory", "path", c.Directory) + case err != nil: + return errors.Wrapf(err, "failed to stat directory %s", c.Directory) + } + + // check the directory only contains allowed files/directories, error out otherwise + if err := c.checkDirectoryContent(); err != nil { + return err + } + + repoURL, ok := WellKnownTemplates()[c.Template] + if !ok { + // If the template isn't one of the well-known ones, assume its a URL. + repoURL = c.Template + } + + fs := osfs.New(c.Directory, osfs.WithBoundOS()) + + r, err := git.Clone(memory.NewStorage(), fs, &git.CloneOptions{ + URL: repoURL, + Depth: 1, + ReferenceName: plumbing.ReferenceName(c.RefName), + }) + if err != nil { + return errors.Wrapf(err, "failed to clone repository from %q", repoURL) + } + + ref, err := r.Head() + if err != nil { + return errors.Wrapf(err, "failed to get repository's HEAD from %q", repoURL) + } + + if _, err := fmt.Fprintf(k.Stdout, "Initialized package %q in directory %q from %s (%s)\n", + c.Name, c.Directory, getPrettyURL(logger, repoURL, ref), ref.Name().Short()); err != nil { + return errors.Wrap(err, "failed to write to stdout") + } + + if err := c.handleNotes(k.Stdout, logger); err != nil { + return errors.Wrap(err, "failed to handle NOTES.txt") + } + + if err := c.handleInitScript(k, logger); err != nil { + return errors.Wrap(err, "failed to handle init.sh") + } + + return nil +} + +// handleNotes prints the NOTES.txt file in the template +// repository, if it exists. +func (c *initCmd) handleNotes(w io.Writer, logger logging.Logger) error { + notesFile := filepath.Join(c.Directory, notes) + + f, err := os.Stat(notesFile) + switch { + case os.IsNotExist(err): + // no NOTES.txt file, skip + logger.Debug("No NOTES.txt found, skipping") + return nil + case err != nil: + return errors.Wrapf(err, "failed to stat notes file %s", notesFile) + case f.IsDir(): + return errors.Errorf("%s is not a file", notesFile) + } + + return errors.Wrapf(printFile(w, notesFile), "failed to print file %s", notesFile) +} + +// handleInitScript runs the init.sh script in the template repository, if it +// exists. +func (c *initCmd) handleInitScript(k *kong.Context, logger logging.Logger) error { + scriptFile := filepath.Join(c.Directory, initScript) + + f, err := os.Stat(scriptFile) + switch { + case os.IsNotExist(err): + // no init.sh file, skip + logger.Debug("No init.sh found, skipping") + return nil + case err != nil: + return errors.Wrapf(err, "failed to stat init.sh file %s", scriptFile) + case f.IsDir(): + return errors.Errorf("%s is not a file", scriptFile) + } + + if c.RunInitScript { + return errors.Wrapf(runScript(k, scriptFile, c.Name, c.Directory), "failed to run init script %s", scriptFile) + } + + if _, err := fmt.Fprintln(k.Stdout, "\nFound init.sh script!"); err != nil { + return errors.Wrap(err, "failed to write to stdout") + } + + return errors.Wrapf(initPrompt(k, scriptFile, c.Name, c.Directory), "failed to handle init script %s", scriptFile) +} + +func initPrompt(k *kong.Context, scriptFile, name, dir string) error { + answer, err := prompt(k, "Do you want to run it? [y]es/[n]o/[v]iew: ") + if err != nil { + return errors.Wrap(err, "failed to prompt user") + } + + switch answer { + case "y", "yes": + return errors.Wrapf(runScript(k, scriptFile, name, dir), "failed to run init script %s", scriptFile) + case "v", "view": + if err := printFile(k.Stdout, scriptFile); err != nil { + return errors.Wrapf(err, "failed to print file %s", scriptFile) + } + + return initPrompt(k, scriptFile, name, dir) + } + + return nil +} + +func printFile(w io.Writer, path string) error { + // read and print the script + f, err := os.Open(filepath.Clean(path)) + if err != nil { + return errors.Wrapf(err, "failed to open file %s", path) + } + defer f.Close() //nolint:errcheck // It's safe to ignore the error because it only do read operation. + + content, err := io.ReadAll(f) + if err != nil { + return errors.Wrapf(err, "failed to read file %s", path) + } + + if _, err := fmt.Fprintf(w, "\n%s\n", content); err != nil { + return errors.Wrap(err, "failed to write to stdout") + } + + return nil +} + +func runScript(k *kong.Context, scriptFile string, args ...string) error { + cmd := exec.CommandContext(context.Background(), scriptFile, args...) + cmd.Stdout = k.Stdout + cmd.Stderr = k.Stderr + cmd.Stdin = os.Stdin + + return cmd.Run() +} + +func prompt(k *kong.Context, question string) (string, error) { + if _, err := fmt.Fprintf(k.Stdout, "%s", question); err != nil { + return "", errors.Wrap(err, "failed to write to stdout") + } + + var answer string + if _, err := fmt.Scanln(&answer); err != nil { + return "", errors.Wrap(err, "failed to read from stdin") + } + + return answer, nil +} + +func getPrettyURL(logger logging.Logger, repoURL string, ref *plumbing.Reference) string { + prettyURL, err := url.JoinPath(repoURL, "tree", ref.Hash().String()) + if err != nil { + // we won't show the commit URL in this case, no big issue + logger.Debug("Failed to create commit URL, will just use original url", "error", err) + return repoURL + } + + return prettyURL +} + +func (c *initCmd) checkDirectoryContent() error { + entries, err := os.ReadDir(c.Directory) + if err != nil { + return errors.Wrapf(err, "failed to read directory %s", c.Directory) + } + + notAllowedEntries := make([]string, 0) + + for _, entry := range entries { + // .git directory is allowed + if entry.Name() == ".git" && entry.IsDir() { + continue + } + // add all other entries to the list of unauthorized entries + notAllowedEntries = append(notAllowedEntries, entry.Name()) + } + + if len(notAllowedEntries) > 0 { + return errors.Errorf("directory %s is not empty, contains existing files/directories: %s", c.Directory, strings.Join(notAllowedEntries, ", ")) + } + + return nil +} diff --git a/cmd/crossplane/xpkg/init_test.go b/cmd/crossplane/xpkg/init_test.go new file mode 100644 index 0000000..3b1b26b --- /dev/null +++ b/cmd/crossplane/xpkg/init_test.go @@ -0,0 +1,84 @@ +package xpkg + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + _ "embed" +) + +//go:embed testdata/NOTES.txt +var notesFile string + +func TestHandleNotes(t *testing.T) { + type args struct { + file string + } + + type want struct { + result string + err error + } + + tests := map[string]struct { + reason string + args args + want want + }{ + "PrintsNotes": { + reason: "Should print the notes file", + args: args{ + file: notesFile, + }, + want: want{ + result: fmt.Sprintf("\n%s\n", notesFile), + err: nil, + }, + }, + "NoNotes": { + reason: "Should not print the notes file when it does not exist", + args: args{ + file: "", + }, + want: want{ + result: "", + err: nil, + }, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + logger := logging.NewNopLogger() + + dir := t.TempDir() + if tc.args.file != "" { + if err := os.WriteFile(filepath.Join(dir, notes), []byte(tc.args.file), 0o644); err != nil { + t.Fatalf("writeFile() error = %v", err) + } + } + + c := &initCmd{ + Directory: dir, + } + + b := &bytes.Buffer{} + + err := c.handleNotes(b, logger) + if diff := cmp.Diff(tc.want.result, b.String()); diff != "" { + t.Errorf("\n%s\nInitCmd.handleNotes(...): -want, +got:\n%s", tc.reason, diff) + } + + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("\n%s\nInitCmd.handleNotes(...): -want error, +got error:\n%s", tc.reason, diff) + } + }) + } +} diff --git a/cmd/crossplane/xpkg/install.go b/cmd/crossplane/xpkg/install.go new file mode 100644 index 0000000..b67b193 --- /dev/null +++ b/cmd/crossplane/xpkg/install.go @@ -0,0 +1,236 @@ +/* +Copyright 2020 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "fmt" + "net/http" + "time" + + "github.com/alecthomas/kong" + "github.com/google/go-containerregistry/pkg/name" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/wait" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/version" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + v1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + // Load all the auth plugins for the cloud providers. + _ "k8s.io/client-go/plugin/pkg/client/auth" +) + +const ( + errPkgIdentifier = "invalid package image identifier" + errKubeConfig = "failed to get kubeconfig" + errKubeClient = "failed to create kube client" +) + +// installCmd installs a package. +type installCmd struct { + // Arguments. + Kind string `arg:"" enum:"provider,configuration,function" help:"The kind of package to install. One of \"provider\", \"configuration\", or \"function\"."` + Package string `arg:"" help:"The package to install, must be fully qualified, including the registry, repository, and tag." placeholder:"REGISTRY/REPOSITORY:TAG"` + Name string `arg:"" help:"The name of the new package in the Crossplane API. Derived from the package repository and tag by default." optional:""` + + // Flags. Keep sorted alphabetically. + RuntimeConfig string `help:"Install the package with a runtime configuration (for example a DeploymentRuntimeConfig)." placeholder:"NAME"` + ManualActivation bool `help:"Require the new package's first revision to be manually activated." short:"m"` + PackagePullSecrets []string `help:"A comma-separated list of secrets the package manager should use to pull the package from the registry." placeholder:"NAME"` + RevisionHistoryLimit int64 `help:"How many package revisions may exist before the oldest revisions are deleted." placeholder:"LIMIT" short:"r"` + Wait time.Duration `default:"0s" help:"How long to wait for the package to install before returning. The command does not wait by default. Returns an error if the timeout is exceeded." short:"w"` +} + +func (c *installCmd) Help() string { + return ` +This command installs a package in a Crossplane control plane. It uses +~/.kube/config to connect to the control plane. You can override this using the +KUBECONFIG environment variable. + +IMPORTANT: the package must be fully qualified, including the registry, repository, and tag. + +Examples: + + # Wait 1 minute for the package to finish installing before returning. + crossplane xpkg install provider xpkg.crossplane.io/crossplane-contrib/provider-aws-eks:v0.41.0 --wait=1m + + # Install a Function named function-eg that uses a runtime config named + # customconfig. + crossplane xpkg install function xpkg.crossplane.io/crossplane/function-example:v0.1.4 function-eg \ + --runtime-config=customconfig +` +} + +// Run the package install cmd. +func (c *installCmd) Run(k *kong.Context, logger logging.Logger) error { + pkgName := c.Name + if pkgName == "" { + ref, err := name.ParseReference(c.Package, name.StrictValidation) + if err != nil { + logger.Debug(errPkgIdentifier, "error", err) + return errors.Wrap(err, errPkgIdentifier) + } + + pkgName = xpkg.ToDNSLabel(ref.Context().RepositoryStr()) + } + + logger = logger.WithValues( + "kind", c.Kind, + "ref", c.Package, + "name", pkgName, + ) + + rap := v1.AutomaticActivation + if c.ManualActivation { + rap = v1.ManualActivation + } + + secrets := make([]corev1.LocalObjectReference, len(c.PackagePullSecrets)) + for i, s := range c.PackagePullSecrets { + secrets[i] = corev1.LocalObjectReference{ + Name: s, + } + } + + spec := v1.PackageSpec{ + Package: c.Package, + RevisionActivationPolicy: &rap, + RevisionHistoryLimit: &c.RevisionHistoryLimit, + PackagePullSecrets: secrets, + } + + var pkg v1.Package + + switch c.Kind { + case "provider": + pkg = &v1.Provider{ + ObjectMeta: metav1.ObjectMeta{Name: pkgName}, + Spec: v1.ProviderSpec{PackageSpec: spec}, + } + case "configuration": + pkg = &v1.Configuration{ + ObjectMeta: metav1.ObjectMeta{Name: pkgName}, + Spec: v1.ConfigurationSpec{PackageSpec: spec}, + } + case "function": + pkg = &v1.Function{ + ObjectMeta: metav1.ObjectMeta{Name: pkgName}, + Spec: v1.FunctionSpec{PackageSpec: spec}, + } + default: + // The enum struct tag on the Kind field should make this impossible. + return errors.Errorf("unsupported package kind %q", c.Kind) + } + + if c.RuntimeConfig != "" { + rpkg, ok := pkg.(v1.PackageWithRuntime) + if !ok { + return errors.Errorf("package kind %T does not support runtime configuration", pkg) + } + + rpkg.SetRuntimeConfigRef(&v1.RuntimeConfigReference{Name: c.RuntimeConfig}) + } + + cfg, err := ctrl.GetConfig() + if err != nil { + return errors.Wrap(err, errKubeConfig) + } + + logger.Debug("Found kubeconfig") + + s := runtime.NewScheme() + _ = v1.AddToScheme(s) + _ = v1beta1.AddToScheme(s) + + kube, err := client.New(cfg, client.Options{Scheme: s}) + if err != nil { + return errors.Wrap(err, errKubeClient) + } + + logger.Debug("Created kubernetes client") + + timeout := 10 * time.Second + if c.Wait > 0 { + timeout = c.Wait + } + + ctx, cancel := context.WithTimeout(context.Background(), timeout) + defer cancel() + + if err := kube.Create(ctx, pkg); err != nil { + return errors.Wrap(warnIfNotFound(err), "cannot create package") + } + + if c.Wait > 0 { + // Poll every 2 seconds to see whether the package is ready. + logger.Debug("Waiting for package to be ready", "timeout", timeout) + + go wait.UntilWithContext(ctx, func(ctx context.Context) { + if err := kube.Get(ctx, client.ObjectKeyFromObject(pkg), pkg); err != nil { + logger.Debug("Cannot get package", "error", err) + return + } + + // Our package is ready, cancel the context to stop our wait loop. + if pkg.GetCondition(v1.TypeHealthy).Status == corev1.ConditionTrue { + logger.Debug("Package is ready") + cancel() + + return + } + + logger.Debug("Package is not yet ready") + }, 2*time.Second) + + <-ctx.Done() + + if err := ctx.Err(); errors.Is(err, context.DeadlineExceeded) { + return errors.Wrap(err, "Package did not become ready") + } + } + + _, err = fmt.Fprintf(k.Stdout, "%s/%s created\n", c.Kind, pkg.GetName()) + + return err +} + +// TODO(negz): What is this trying to do? My guess is its trying to handle the +// case where the CRD of the package kind isn't installed. Perhaps we could be +// clearer in the error? + +func warnIfNotFound(err error) error { + serr := &kerrors.StatusError{} + if !errors.As(err, &serr) { + return err + } + + if serr.ErrStatus.Code != http.StatusNotFound { + return err + } + + return errors.WithMessagef(err, "crossplane CLI (version %s) might be out of date", version.New().GetVersionString()) +} diff --git a/cmd/crossplane/xpkg/push.go b/cmd/crossplane/xpkg/push.go new file mode 100644 index 0000000..48eeffd --- /dev/null +++ b/cmd/crossplane/xpkg/push.go @@ -0,0 +1,249 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "crypto/tls" + "fmt" + "net/http" + "os" + "path/filepath" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" +) + +const ( + errGetwd = "failed to get working directory while searching for package" + errFindPackageinWd = "failed to find a package in current working directory" + errAnnotateLayers = "failed to propagate xpkg annotations from OCI image config file to image layers" + + errFmtNewTag = "failed to parse package tag %q" + errFmtReadPackage = "failed to read package file %s" + errFmtPushPackage = "failed to push package file %s" + errFmtGetDigest = "failed to get digest of package file %s" + errFmtNewDigest = "failed to parse digest %q for package file %s" + errFmtGetMediaType = "failed to get media type of package file %s" + errFmtGetConfigFile = "failed to get OCI config file of package file %s" + errFmtWriteIndex = "failed to push an OCI image index of %d packages" +) + +// pushCmd pushes a package. +type pushCmd struct { + // Arguments. + Package string `arg:"" help:"Where to push the package. Must be a fully qualified OCI tag, including the registry, repository, and tag." placeholder:"REGISTRY/REPOSITORY:TAG"` + + // Flags. Keep sorted alphabetically. + InsecureSkipTLSVerify bool `help:"[INSECURE] Skip verifying TLS certificates."` + PackageFiles []string `help:"A comma-separated list of xpkg files to push." placeholder:"PATH" predictor:"xpkg_file" short:"f" type:"existingfile"` + + // Internal state. These aren't part of the user-exposed CLI structure. + fs afero.Fs +} + +func (c *pushCmd) Help() string { + return ` +Packages can be pushed to any OCI registry. A package's OCI tag must be a semantic +version. Credentials for the registry are automatically retrieved from xpkg login +and dockers configuration as fallback. + +IMPORTANT: the package must be fully qualified, including the registry, repository, and tag. + +Examples: + + # Push a multi-platform package. + crossplane xpkg push -f function-amd64.xpkg,function-arm64.xpkg xpkg.crossplane.io/crossplane/function-example:v1.0.0 + + # Push the xpkg file in the current directory to a different registry. + crossplane xpkg push index.docker.io/crossplane/function-example:v1.0.0 +` +} + +// AfterApply sets the tag for the parent push command. +func (c *pushCmd) AfterApply() error { + c.fs = afero.NewOsFs() + return nil +} + +// Run runs the push cmd. +func (c *pushCmd) Run(logger logging.Logger) error { + // If package is not defined, attempt to find single package in current + // directory. + if len(c.PackageFiles) == 0 { + wd, err := os.Getwd() + if err != nil { + return errors.Wrap(err, errGetwd) + } + + path, err := xpkg.FindXpkgInDir(c.fs, wd) + if err != nil { + return errors.Wrap(err, errFindPackageinWd) + } + + c.PackageFiles = []string{path} + logger.Debug("Found package in directory", "path", path) + } + + // load images from all the provided package files + images := make([]packageImage, 0, len(c.PackageFiles)) + for _, p := range c.PackageFiles { + cleanPath := filepath.Clean(p) + + img, err := tarball.ImageFromPath(cleanPath, nil) + if err != nil { + return err + } + + images = append(images, packageImage{Image: img, Path: cleanPath}) + } + + t := http.DefaultTransport.(*http.Transport).Clone() //nolint:forcetypeassert // http.DefaultTransport is always *http.Transport + if c.InsecureSkipTLSVerify { + t.TLSClientConfig = &tls.Config{ + InsecureSkipVerify: true, //nolint:gosec // we need to support insecure connections if requested + } + } + + options := []remote.Option{ + remote.WithAuthFromKeychain(authn.DefaultKeychain), + remote.WithTransport(t), + } + + return pushImages(logger, images, c.Package, options...) +} + +// packageImage describes a package image that will be pushed. +type packageImage struct { + // The OCI Image of the package to be pushed. + Image v1.Image + + // optional path for the image (e.g. file path on disk) to help provide more + // information about its source + Path string +} + +// pushImages pushes package images to the given URL using the provided options. +func pushImages(logger logging.Logger, images []packageImage, url string, options ...remote.Option) error { + if len(options) == 0 { + options = []remote.Option{ + remote.WithAuthFromKeychain(authn.DefaultKeychain), + } + } + + tag, err := name.NewTag(url, name.StrictValidation) + if err != nil { + return errors.Wrapf(err, errFmtNewTag, url) + } + + // If there's only one package file, handle the simple path. + if len(images) == 1 { + pi := images[0] + + img, err := xpkg.AnnotateLayers(pi.Image) + if err != nil { + return errors.Wrapf(err, errAnnotateLayers) + } + + if err := remote.Write(tag, img, options...); err != nil { + return errors.Wrapf(err, errFmtPushPackage, pi.Path) + } + + logger.Debug("Pushed package", "path", pi.Path, "ref", tag.String()) + + return nil + } + + // If there's more than one package file we'll write (push) them all by + // their digest, and create an index with the specified tag. This pattern is + // typically used to create a multi-platform image. + adds := make([]mutate.IndexAddendum, len(images)) + + g, ctx := errgroup.WithContext(context.Background()) + for i, pi := range images { + g.Go(func() error { + img, err := xpkg.AnnotateLayers(pi.Image) + if err != nil { + return errors.Wrapf(err, errAnnotateLayers) + } + + d, err := img.Digest() + if err != nil { + return errors.Wrapf(err, errFmtGetDigest, pi.Path) + } + + n := fmt.Sprintf("%s@%s", tag.Repository.Name(), d.String()) + + ref, err := name.NewDigest(n, name.StrictValidation) + if err != nil { + return errors.Wrapf(err, errFmtNewDigest, n, pi.Path) + } + + mt, err := img.MediaType() + if err != nil { + return errors.Wrapf(err, errFmtGetMediaType, pi.Path) + } + + conf, err := img.ConfigFile() + if err != nil { + return errors.Wrapf(err, errFmtGetConfigFile, pi.Path) + } + + adds[i] = mutate.IndexAddendum{ + Add: img, + Descriptor: v1.Descriptor{ + MediaType: mt, + Platform: &v1.Platform{ + Architecture: conf.Architecture, + OS: conf.OS, + OSVersion: conf.OSVersion, + }, + }, + } + if err := remote.Write(ref, img, append(options, remote.WithContext(ctx))...); err != nil { + return errors.Wrapf(err, errFmtPushPackage, pi.Path) + } + + logger.Debug("Pushed package", "path", pi.Path, "ref", ref.String()) + + return nil + }) + } + + if err := g.Wait(); err != nil { + return err + } + + if err := remote.WriteIndex(tag, mutate.AppendManifests(empty.Index, adds...), options...); err != nil { + return errors.Wrapf(err, errFmtWriteIndex, len(adds)) + } + + logger.Debug("Wrote OCI index", "ref", tag.String(), "manifests", len(adds)) + + return nil +} diff --git a/cmd/crossplane/xpkg/service_metadata.go b/cmd/crossplane/xpkg/service_metadata.go new file mode 100644 index 0000000..06ccb29 --- /dev/null +++ b/cmd/crossplane/xpkg/service_metadata.go @@ -0,0 +1,114 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "os" + "path/filepath" + + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// loadServiceMetadata reads ServiceMetadataFile into perServiceTemplateVars. +// Each top-level key is a smaller provider name; the value is a map of template +// variable names to values suitable for text/template (scalars and []any lists; +// lists are preserved so templates can use {{ range .ServiceCategories }}). +func (c *batchCmd) loadServiceMetadata() error { + c.perServiceTemplateVars = nil + if c.ServiceMetadataFile == "" { + return nil + } + b, err := os.ReadFile(filepath.Clean(c.ServiceMetadataFile)) + if err != nil { + return errors.Wrap(err, "failed to load service metadata file; check the file path and YAML syntax") + } + var raw map[string]map[string]any + if err := yaml.Unmarshal(b, &raw); err != nil { + return errors.Wrap(err, "failed to load service metadata file; check the file path and YAML syntax") + } + out := make(map[string]map[string]any, len(raw)) + for svc, vars := range raw { + if vars == nil { + continue + } + sm, err := validateTemplateMetadataVars(vars) + if err != nil { + return errors.Wrapf(err, "invalid service metadata (service %q)", svc) + } + out[svc] = sm + } + c.perServiceTemplateVars = out + return nil +} + +func validateTemplateMetadataVars(vars map[string]any) (map[string]any, error) { + out := make(map[string]any, len(vars)) + for k, v := range vars { + nv, err := validateTemplateMetadataValue(v) + if err != nil { + return nil, errors.Wrapf(err, "variable %q", k) + } + out[k] = nv + } + return out, nil +} + +func validateTemplateMetadataValue(v any) (any, error) { + switch t := v.(type) { + case nil: + return nil, nil + case string: + return t, nil + case bool: + return t, nil + case int: + return t, nil + case int32: + return int(t), nil + case int64: + return t, nil + case uint: + return t, nil + case uint64: + return t, nil + case float64: + return t, nil + case []any: + out := make([]any, len(t)) + for i, e := range t { + ne, err := validateTemplateMetadataValue(e) + if err != nil { + return nil, err + } + if _, ok := ne.([]any); ok { + return nil, errors.New("invalid service metadata: lists cannot contain nested lists; allowed YAML value types are string, " + + "number, boolean, null, and flat lists of those scalars. Flatten nested list structure or serialize it " + + "(for example to a string) before adding it to metadata YAML.") + } + out[i] = ne + } + return out, nil + case map[string]any: + return nil, errors.New("invalid service metadata: value is a nested mapping or object; allowed YAML value types are string, " + + "number, boolean, null, and lists of those. Flatten nested data or serialize it (for example to a string) before adding it to metadata YAML.") + default: + return nil, errors.New("invalid service metadata: value has an unsupported form; allowed YAML value types are string, number, boolean, " + + "null, and lists of those. Convert or serialize this value to one of those forms before including it in metadata YAML.") + } +} diff --git a/cmd/crossplane/xpkg/service_metadata_test.go b/cmd/crossplane/xpkg/service_metadata_test.go new file mode 100644 index 0000000..1e45866 --- /dev/null +++ b/cmd/crossplane/xpkg/service_metadata_test.go @@ -0,0 +1,315 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" +) + +func TestValidateTemplateMetadataValue(t *testing.T) { + t.Parallel() + cases := map[string]struct { + in any + want any + wantErr error + }{ + "Nil": {in: nil, want: nil}, + "String": {in: "hello", want: "hello"}, + "Bool": {in: true, want: true}, + "Float": {in: float64(3.5), want: float64(3.5)}, + "StringSlice": {in: []any{"a", "b"}, want: []any{"a", "b"}}, + "MixedSlice": {in: []any{"x", float64(1)}, want: []any{"x", float64(1)}}, + "UnsupportedMap": {in: map[string]any{"k": "v"}, wantErr: cmpopts.AnyError}, + "UnsupportedInSlice": {in: []any{map[string]any{"k": "v"}}, wantErr: cmpopts.AnyError}, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + got, err := validateTemplateMetadataValue(tc.in) + if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("validateTemplateMetadataValue(...): -want +got errors:\n%s", diff) + } + if err != nil { + return + } + if diff := cmp.Diff(tc.want, got); diff != "" { + t.Errorf("validateTemplateMetadataValue(...): -want +got:\n%s", diff) + } + }) + } +} + +func TestBatchCmdLoadServiceMetadata(t *testing.T) { + t.Parallel() + cases := map[string]struct { + reason string + prepare func(t *testing.T) *batchCmd + wantErr error + wantVars map[string]map[string]any + wantVarsNil bool + }{ + "Success": { + reason: "valid YAML with two services populates batchCmd.perServiceTemplateVars", + prepare: func(t *testing.T) *batchCmd { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "meta.yaml") + content := `elb: + ServiceDescription: "Elastic Load Balancing manages load balancers." + ServiceCategories: + - networking + - load balancing +ec2: + Count: 3 +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return &batchCmd{ServiceMetadataFile: path} + }, + wantVars: map[string]map[string]any{ + "elb": { + "ServiceDescription": "Elastic Load Balancing manages load balancers.", + "ServiceCategories": []any{"networking", "load balancing"}, + }, + "ec2": { + "Count": float64(3), + }, + }, + }, + "EmptyPath": { + reason: "empty ServiceMetadataFile leaves batchCmd.perServiceTemplateVars nil", + prepare: func(t *testing.T) *batchCmd { + t.Helper() + return &batchCmd{} + }, + wantVarsNil: true, + }, + "FileNotFound": { + reason: "missing file errors from batchCmd.loadServiceMetadata and clears prior perServiceTemplateVars", + prepare: func(t *testing.T) *batchCmd { + t.Helper() + return &batchCmd{ + ServiceMetadataFile: filepath.Join(t.TempDir(), "does-not-exist.yaml"), + perServiceTemplateVars: map[string]map[string]any{ + "prior": {"k": "v"}, + }, + } + }, + wantErr: cmpopts.AnyError, + wantVarsNil: true, + }, + "MalformedYAML": { + reason: "malformed YAML errors and clears prior batchCmd.perServiceTemplateVars", + prepare: func(t *testing.T) *batchCmd { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "bad.yaml") + if err := os.WriteFile(path, []byte("[ not valid yaml {{{"), 0o600); err != nil { + t.Fatal(err) + } + return &batchCmd{ + ServiceMetadataFile: path, + perServiceTemplateVars: map[string]map[string]any{ + "prior": {"k": "v"}, + }, + } + }, + wantErr: cmpopts.AnyError, + wantVarsNil: true, + }, + "UnsupportedNestedMap": { + reason: "nested map in metadata errors via validateTemplateMetadataValue and clears perServiceTemplateVars", + prepare: func(t *testing.T) *batchCmd { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "nested-map.yaml") + content := `ec2: + Config: + nested: true +` + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + return &batchCmd{ + ServiceMetadataFile: path, + perServiceTemplateVars: map[string]map[string]any{ + "prior": {"k": "v"}, + }, + } + }, + wantErr: cmpopts.AnyError, + wantVarsNil: true, + }, + } + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + t.Parallel() + cmd := tc.prepare(t) + err := cmd.loadServiceMetadata() + if diff := cmp.Diff(tc.wantErr, err, cmpopts.EquateErrors()); diff != "" { + t.Fatalf("(*batchCmd).loadServiceMetadata (%s): -want +got errors:\n%s", tc.reason, diff) + } + if tc.wantVarsNil { + if cmd.perServiceTemplateVars != nil { + t.Fatalf("perServiceTemplateVars (%s): want nil, got %#v", tc.reason, cmd.perServiceTemplateVars) + } + return + } + if diff := cmp.Diff(tc.wantVars, cmd.perServiceTemplateVars); diff != "" { + t.Errorf("perServiceTemplateVars (%s): -want +got:\n%s", tc.reason, diff) + } + }) + } +} + +func TestBatchCmd_getPackageMetadata_mergeOrder(t *testing.T) { + t.Parallel() + dir := t.TempDir() + tmplPath := filepath.Join(dir, "crossplane.yaml.tmpl") + if err := os.WriteFile(tmplPath, []byte(`{{ .Service }}|{{ .Name }}|{{ .ServiceDescription }}`), 0o600); err != nil { + t.Fatal(err) + } + c := &batchCmd{ + PackageMetadataTemplate: tmplPath, + ProviderName: "provider-aws", + perServiceTemplateVars: map[string]map[string]any{ + "elb": {"ServiceDescription": "from-yaml"}, + }, + TemplateVar: map[string]string{"ServiceDescription": "from-cli"}, + } + got, err := c.getPackageMetadata("elb") + if err != nil { + t.Fatalf("getPackageMetadata: %v", err) + } + if want := "elb|provider-aws-elb|from-cli"; got != want { + t.Fatalf("getPackageMetadata with CLI override: got %q, want %q", got, want) + } + + c.TemplateVar = nil + got, err = c.getPackageMetadata("elb") + if err != nil { + t.Fatalf("getPackageMetadata: %v", err) + } + if want := "elb|provider-aws-elb|from-yaml"; got != want { + t.Fatalf("getPackageMetadata from YAML only: got %q, want %q", got, want) + } +} + +func TestBatchCmd_getPackageMetadata_ServiceCategoriesList(t *testing.T) { + t.Parallel() + dir := t.TempDir() + metaPath := filepath.Join(dir, "meta.yaml") + if err := os.WriteFile(metaPath, []byte(`ec2: + ServiceCategories: + - compute + - networking +`), 0o600); err != nil { + t.Fatal(err) + } + tmplPath := filepath.Join(dir, "crossplane.yaml.tmpl") + tmpl := `categories: +{{ indent 6 (toYAML .ServiceCategories) }} +` + if err := os.WriteFile(tmplPath, []byte(tmpl), 0o600); err != nil { + t.Fatal(err) + } + c := &batchCmd{ + PackageMetadataTemplate: tmplPath, + ProviderName: "provider-aws", + ServiceMetadataFile: metaPath, + } + if err := c.loadServiceMetadata(); err != nil { + t.Fatalf("loadServiceMetadata: %v", err) + } + got, err := c.getPackageMetadata("ec2") + if err != nil { + t.Fatalf("getPackageMetadata: %v", err) + } + want := `categories: + - compute + - networking +` + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("getPackageMetadata: -want +got:\n%s", diff) + } +} + +// TestBatchCmd_getPackageMetadata_perServiceDistinct exercises loadServiceMetadata +// and getPackageMetadata together: each smaller provider must render its own +// ServiceDescription (same path as xpkg batch uses when building packages). +func TestBatchCmd_getPackageMetadata_perServiceDistinct(t *testing.T) { + t.Parallel() + dir := t.TempDir() + metaPath := filepath.Join(dir, "service-metadata.yaml") + if err := os.WriteFile(metaPath, []byte(`elb: + ServiceDescription: "ELB-only text for classic load balancers." +s3: + ServiceDescription: "S3-only text for buckets and objects." +`), 0o600); err != nil { + t.Fatal(err) + } + tmplPath := filepath.Join(dir, "crossplane.yaml.tmpl") + tmpl := `apiVersion: meta.pkg.crossplane.io/v1 +kind: Provider +metadata: + name: {{ .Name }} + annotations: + meta.crossplane.io/description: | + {{ .ServiceDescription }} +` + if err := os.WriteFile(tmplPath, []byte(tmpl), 0o600); err != nil { + t.Fatal(err) + } + + c := &batchCmd{ + PackageMetadataTemplate: tmplPath, + ProviderName: "provider-aws", + ServiceMetadataFile: metaPath, + } + if err := c.loadServiceMetadata(); err != nil { + t.Fatalf("loadServiceMetadata: %v", err) + } + + elbYAML, err := c.getPackageMetadata("elb") + if err != nil { + t.Fatalf("getPackageMetadata(elb): %v", err) + } + s3YAML, err := c.getPackageMetadata("s3") + if err != nil { + t.Fatalf("getPackageMetadata(s3): %v", err) + } + + if !strings.Contains(elbYAML, "ELB-only text for classic load balancers.") { + t.Fatalf("elb metadata missing expected description:\n%s", elbYAML) + } + if strings.Contains(elbYAML, "S3-only") { + t.Fatalf("elb metadata leaked s3 text:\n%s", elbYAML) + } + if !strings.Contains(s3YAML, "S3-only text for buckets and objects.") { + t.Fatalf("s3 metadata missing expected description:\n%s", s3YAML) + } + if strings.Contains(s3YAML, "ELB-only") { + t.Fatalf("s3 metadata leaked elb text:\n%s", s3YAML) + } +} diff --git a/cmd/crossplane/xpkg/template_funcs.go b/cmd/crossplane/xpkg/template_funcs.go new file mode 100644 index 0000000..4d496d9 --- /dev/null +++ b/cmd/crossplane/xpkg/template_funcs.go @@ -0,0 +1,76 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "strings" + "text/template" + + yaml "gopkg.in/yaml.v3" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// packageMetadataFuncMap is bound to package metadata text/template execution. It is +// generic: no keys or types are special-cased for a particular provider or field name. +func packageMetadataFuncMap() template.FuncMap { + return template.FuncMap{ + "dict": dict, + "indent": indent, + "toYAML": toYAML, + } +} + +// dict builds map[string]any from alternating string keys and values (k1, v1, k2, v2, ...). +// Used with toYAML to emit structured YAML from template data without string concatenation. +func dict(pairs ...any) (map[string]any, error) { + if len(pairs)%2 != 0 { + return nil, errors.New("dict: requires an even number of arguments") + } + out := make(map[string]any, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + ks, ok := pairs[i].(string) + if !ok { + return nil, errors.Errorf("dict: argument %d must be a string key", i) + } + out[ks] = pairs[i+1] + } + return out, nil +} + +// toYAML marshals v with gopkg.in/yaml.v3 for use inside templates (e.g. block scalars). +func toYAML(v any) (string, error) { + b, err := yaml.Marshal(v) + if err != nil { + return "", errors.Wrap(err, "toYAML") + } + return strings.TrimSuffix(string(b), "\n"), nil +} + +// indent prefixes each line of s with n spaces. Used to nest a YAML fragment under a +// parent key (e.g. after meta...: |). +func indent(n int, s string) string { + if s == "" { + return "" + } + pad := strings.Repeat(" ", n) + lines := strings.Split(s, "\n") + for i := range lines { + lines[i] = pad + lines[i] + } + return strings.Join(lines, "\n") +} diff --git a/cmd/crossplane/xpkg/template_funcs_test.go b/cmd/crossplane/xpkg/template_funcs_test.go new file mode 100644 index 0000000..480099d --- /dev/null +++ b/cmd/crossplane/xpkg/template_funcs_test.go @@ -0,0 +1,112 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestToYAML(t *testing.T) { + t.Parallel() + t.Run("Slice", func(t *testing.T) { + t.Parallel() + got, err := toYAML([]any{"compute", "networking"}) + if err != nil { + t.Fatalf("toYAML: %v", err) + } + want := "- compute\n- networking" + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("toYAML: -want +got:\n%s", diff) + } + }) + t.Run("Nil", func(t *testing.T) { + t.Parallel() + got, err := toYAML(nil) + if err != nil { + t.Fatalf("toYAML: %v", err) + } + if diff := cmp.Diff("null", got); diff != "" { + t.Fatalf("toYAML(nil): -want +got:\n%s", diff) + } + }) +} + +func TestDict(t *testing.T) { + t.Parallel() + got, err := dict("a", 1, "b", "x") + if err != nil { + t.Fatalf("dict: %v", err) + } + want := map[string]any{"a": 1, "b": "x"} + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("dict: -want +got:\n%s", diff) + } +} + +func TestDictErrors(t *testing.T) { + t.Parallel() + t.Run("OddNumberOfArgs", func(t *testing.T) { + t.Parallel() + _, err := dict("a", 1, "b") + if err == nil { + t.Fatal("dict: want error for odd argument count") + } + want := "dict: requires an even number of arguments" + if diff := cmp.Diff(want, err.Error()); diff != "" { + t.Fatalf("dict: -want +got error string:\n%s", diff) + } + }) + t.Run("NonStringKey", func(t *testing.T) { + t.Parallel() + _, err := dict(1, "v") + if err == nil { + t.Fatal("dict: want error for non-string key") + } + want := "dict: argument 0 must be a string key" + if diff := cmp.Diff(want, err.Error()); diff != "" { + t.Fatalf("dict: -want +got error string:\n%s", diff) + } + }) +} + +func TestIndent(t *testing.T) { + t.Parallel() + got := indent(4, "a\nb") + want := " a\n b" + if diff := cmp.Diff(want, got); diff != "" { + t.Fatalf("indent: -want +got:\n%s", diff) + } +} + +func TestIndentToYAMLMatchesProviderPattern(t *testing.T) { + t.Parallel() + fragment, err := toYAML([]any{"compute", "virtual-machines"}) + if err != nil { + t.Fatalf("toYAML: %v", err) + } + got := indent(6, fragment) + wantLines := []string{ + " - compute", + " - virtual-machines", + } + if diff := cmp.Diff(strings.Join(wantLines, "\n"), got); diff != "" { + t.Fatalf("indent(toYAML(slice)): -want +got:\n%s", diff) + } +} diff --git a/cmd/crossplane/xpkg/testdata/NOTES.txt b/cmd/crossplane/xpkg/testdata/NOTES.txt new file mode 100644 index 0000000..5e0b87d --- /dev/null +++ b/cmd/crossplane/xpkg/testdata/NOTES.txt @@ -0,0 +1,2 @@ +These are test notes +Please ignore \ No newline at end of file diff --git a/cmd/crossplane/xpkg/update.go b/cmd/crossplane/xpkg/update.go new file mode 100644 index 0000000..f1ce7d7 --- /dev/null +++ b/cmd/crossplane/xpkg/update.go @@ -0,0 +1,135 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "fmt" + "time" + + "github.com/alecthomas/kong" + "github.com/google/go-containerregistry/pkg/name" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + v1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + _ "k8s.io/client-go/plugin/pkg/client/auth" // Load all the auth plugins for the cloud providers. +) + +// updateCmd updates a package. +type updateCmd struct { + // Arguments. + Kind string `arg:"" enum:"provider,configuration,function" help:"The kind of package to update. One of \"provider\", \"configuration\", or \"function\"."` + Package string `arg:"" help:"The package to update to. Must be fully qualified, including the registry, repository, and tag." placeholder:"REGISTRY/REPOSITORY:TAG"` + Name string `arg:"" help:"The name of the package to update in the Crossplane API. Derived from the package repository and tag by default." optional:""` +} + +func (c *updateCmd) Help() string { + return ` +This command updates a package in a Crossplane control plane. It uses +~/.kube/config to connect to the control plane. You can override this using the +KUBECONFIG environment variable. + +IMPORTANT: the package must be fully qualified, including the registry, repository, and tag. + +Examples: + + # Update the Function named function-eg + crossplane xpkg update function xpkg.crossplane.io/crossplane/function-example:v0.1.5 function-eg +` +} + +// Run the package update cmd. +func (c *updateCmd) Run(k *kong.Context, logger logging.Logger) error { + pkgName := c.Name + if pkgName == "" { + ref, err := name.ParseReference(c.Package, name.StrictValidation) + if err != nil { + logger.Debug(errPkgIdentifier, "error", err) + return errors.Wrap(err, errPkgIdentifier) + } + + pkgName = xpkg.ToDNSLabel(ref.Context().RepositoryStr()) + } + + logger = logger.WithValues( + "kind", c.Kind, + "ref", c.Package, + "name", pkgName, + ) + + var pkg v1.Package + + switch c.Kind { + case "provider": + pkg = &v1.Provider{} + case "configuration": + pkg = &v1.Configuration{} + case "function": + pkg = &v1.Function{} + default: + // The enum struct tag on the Kind field should make this impossible. + return errors.Errorf("unsupported package kind %q", c.Kind) + } + + cfg, err := ctrl.GetConfig() + if err != nil { + return errors.Wrap(err, errKubeConfig) + } + + logger.Debug("Found kubeconfig") + + s := runtime.NewScheme() + _ = v1.AddToScheme(s) + _ = v1beta1.AddToScheme(s) + + kube, err := client.New(cfg, client.Options{Scheme: s}) + if err != nil { + return errors.Wrap(err, errKubeClient) + } + + logger.Debug("Created kubernetes client") + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + if err := kube.Get(ctx, types.NamespacedName{Name: pkgName}, pkg); err != nil { + return errors.Wrap(warnIfNotFound(err), "cannot get package") + } + logger.Debug("Found existing package") + + pkg.SetSource(c.Package) + + return kube.Update(ctx, pkg) + }); err != nil { + return errors.Wrapf(err, "cannot update %s/%s", c.Kind, pkg.GetName()) + } + + _, err = fmt.Fprintf(k.Stdout, "%s/%s updated\n", c.Kind, pkg.GetName()) + + return err +} diff --git a/cmd/crossplane/xpkg/xpkg.go b/cmd/crossplane/xpkg/xpkg.go new file mode 100644 index 0000000..c12a4e7 --- /dev/null +++ b/cmd/crossplane/xpkg/xpkg.go @@ -0,0 +1,47 @@ +/* +Copyright 2023 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package xpkg contains Crossplane packaging commands. +package xpkg + +// TODO(lsviben) add the rest of the commands from up (batch, xpextract). + +// Cmd contains commands for interacting with xpkgs. +type Cmd struct { + // Keep subcommands sorted alphabetically. + Batch batchCmd `cmd:"" help:"Batch build and push a family of provider packages."` + Build buildCmd `cmd:"" help:"Build a new package."` + Init initCmd `cmd:"" help:"Initialize a new package from a template."` + Install installCmd `cmd:"" help:"Install a package in a control plane."` + Push pushCmd `cmd:"" help:"Push a package to a registry."` + Update updateCmd `cmd:"" help:"Update a package in a control plane."` + Extract extractCmd `cmd:"" help:"Extract package contents into a Crossplane cache compatible format. Fetches from a remote registry by default."` +} + +// Help prints out the help for the xpkg command. +func (c *Cmd) Help() string { + return ` +Crossplane can be extended using packages. Crossplane packages are called xpkgs. +Crossplane supports configuration, provider and function packages. + +A package is an opinionated OCI image that contains everything needed to extend +a Crossplane control plane with new functionality. For example installing a +provider package extends Crossplane with support for new kinds of managed +resource (MR). + +See https://docs.crossplane.io/latest/concepts/packages for more information. +` +} diff --git a/go.mod b/go.mod index e4304f1..c1bda7c 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,254 @@ module github.com/crossplane/cli/v2 go 1.25.9 -require google.golang.org/protobuf v1.36.11 +require ( + dario.cat/mergo v1.0.2 + github.com/Masterminds/semver/v3 v3.4.0 + github.com/alecthomas/kong v1.14.0 + github.com/charmbracelet/bubbletea v1.3.10 + github.com/containerd/errdefs v1.0.0 + github.com/crossplane/crossplane-runtime/v2 v2.3.0-rc.0.0.20260416145853-f43d88270996 + github.com/crossplane/crossplane/apis/v2 v2.0.0-20260415071903-2b072b20c4bd + github.com/crossplane/crossplane/v2 v2.2.1 + github.com/crossplane/function-sdk-go v0.6.1-0.20260422203639-1c756d23b966 + github.com/docker/cli v29.4.0+incompatible + github.com/docker/docker v28.5.2+incompatible + github.com/docker/go-connections v0.6.0 + github.com/emicklei/dot v1.10.0 + github.com/go-git/go-billy/v5 v5.8.0 + github.com/go-git/go-git/v5 v5.18.0 + github.com/google/go-cmp v0.7.0 + github.com/google/go-containerregistry v0.21.2 + github.com/pkg/errors v0.9.1 + github.com/posener/complete v1.2.3 + github.com/spf13/afero v1.15.0 + github.com/willabides/kongplete v0.4.0 + golang.org/x/sync v0.20.0 + google.golang.org/grpc v1.80.0 + google.golang.org/protobuf v1.36.11 + gopkg.in/yaml.v3 v3.0.1 + k8s.io/api v0.35.3 + k8s.io/apiextensions-apiserver v0.35.0 + k8s.io/apimachinery v0.35.3 + k8s.io/apiserver v0.35.0 + k8s.io/cli-runtime v0.34.1 + k8s.io/client-go v0.35.1 + k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 + k8s.io/metrics v0.34.1 + k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 + sigs.k8s.io/controller-runtime v0.23.1 + sigs.k8s.io/yaml v1.6.0 +) + +require ( + cel.dev/expr v0.25.1 // indirect + cloud.google.com/go/compute/metadata v0.9.0 // indirect + github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect + github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect + github.com/Azure/go-autorest v14.2.0+incompatible // indirect + github.com/Azure/go-autorest/autorest v0.11.30 // indirect + github.com/Azure/go-autorest/autorest/adal v0.9.24 // indirect + github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 // indirect + github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 // indirect + github.com/Azure/go-autorest/autorest/date v0.3.1 // indirect + github.com/Azure/go-autorest/logger v0.2.2 // indirect + github.com/Azure/go-autorest/tracing v0.6.1 // indirect + github.com/Microsoft/go-winio v0.6.2 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect + github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect + github.com/aws/aws-sdk-go-v2 v1.41.4 // indirect + github.com/aws/aws-sdk-go-v2/config v1.32.12 // indirect + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect + github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 // indirect + github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 // indirect + github.com/aws/aws-sdk-go-v2/service/ecr v1.55.3 // indirect + github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.10 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 // indirect + github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 // indirect + github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 // indirect + github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 // indirect + github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 // indirect + github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 // indirect + github.com/aws/smithy-go v1.24.2 // indirect + github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0 // indirect + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/beorn7/perks v1.0.1 // indirect + github.com/blang/semver v3.5.1+incompatible // indirect + github.com/blang/semver/v4 v4.0.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect + github.com/charmbracelet/lipgloss v1.1.0 // indirect + github.com/charmbracelet/x/ansi v0.10.1 // indirect + github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect + github.com/cloudflare/circl v1.6.3 // indirect + github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect + github.com/coreos/go-oidc/v3 v3.17.0 // indirect + github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect + github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/dimchansky/utfbom v1.1.1 // indirect + github.com/distribution/reference v0.6.0 // indirect + github.com/docker/distribution v2.8.3+incompatible // indirect + github.com/docker/docker-credential-helpers v0.9.5 // indirect + github.com/docker/go-units v0.5.0 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/emicklei/go-restful/v3 v3.13.0 // indirect + github.com/emirpasic/gods v1.18.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/fatih/color v1.18.0 // indirect + github.com/felixge/httpsnoop v1.0.4 // indirect + github.com/fsnotify/fsnotify v1.9.0 // indirect + github.com/fxamacker/cbor/v2 v2.9.0 // indirect + github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-jose/go-jose/v4 v4.1.4 // indirect + github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 // indirect + github.com/go-logr/logr v1.4.3 // indirect + github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-logr/zapr v1.3.0 // indirect + github.com/go-openapi/analysis v0.24.3 // indirect + github.com/go-openapi/errors v0.22.7 // indirect + github.com/go-openapi/jsonpointer v0.22.5 // indirect + github.com/go-openapi/jsonreference v0.21.5 // indirect + github.com/go-openapi/loads v0.23.3 // indirect + github.com/go-openapi/runtime v0.29.3 // indirect + github.com/go-openapi/spec v0.22.4 // indirect + github.com/go-openapi/strfmt v0.26.1 // indirect + github.com/go-openapi/swag v0.25.5 // indirect + github.com/go-openapi/swag/cmdutils v0.25.5 // indirect + github.com/go-openapi/swag/conv v0.25.5 // indirect + github.com/go-openapi/swag/fileutils v0.25.5 // indirect + github.com/go-openapi/swag/jsonname v0.25.5 // indirect + github.com/go-openapi/swag/jsonutils v0.25.5 // indirect + github.com/go-openapi/swag/loading v0.25.5 // indirect + github.com/go-openapi/swag/mangling v0.25.5 // indirect + github.com/go-openapi/swag/netutils v0.25.5 // indirect + github.com/go-openapi/swag/stringutils v0.25.5 // indirect + github.com/go-openapi/swag/typeutils v0.25.5 // indirect + github.com/go-openapi/swag/yamlutils v0.25.5 // indirect + github.com/go-openapi/validate v0.25.2 // indirect + github.com/go-viper/mapstructure/v2 v2.5.0 // indirect + github.com/gobuffalo/flect v1.0.3 // indirect + github.com/gogo/protobuf v1.3.2 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect + github.com/golang/snappy v0.0.4 // indirect + github.com/google/btree v1.1.3 // indirect + github.com/google/cel-go v0.27.0 // indirect + github.com/google/certificate-transparency-go v1.3.3 // indirect + github.com/google/gnostic-models v0.7.1 // indirect + github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20230919002926-dbcd01c402b2 // indirect + github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20250225234217-098045d5e61f // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect + github.com/hashicorp/errwrap v1.1.0 // indirect + github.com/hashicorp/go-cleanhttp v0.5.2 // indirect + github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/in-toto/attestation v1.1.2 // indirect + github.com/in-toto/in-toto-golang v0.10.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect + github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/kevinburke/ssh_config v1.2.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/letsencrypt/boulder v0.20260223.0 // indirect + github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-colorable v0.1.14 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/moby/docker-image-spec v1.3.1 // indirect + github.com/moby/term v0.5.2 // indirect + github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect + github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.16.0 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect + github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect + github.com/oklog/ulid/v2 v2.1.1 // indirect + github.com/opencontainers/go-digest v1.0.0 // indirect + github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect + github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.67.5 // indirect + github.com/prometheus/procfs v0.19.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect + github.com/sassoftware/relic v7.2.1+incompatible // indirect + github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect + github.com/sergi/go-diff v1.4.0 // indirect + github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/sigstore/cosign/v3 v3.0.5 // indirect + github.com/sigstore/protobuf-specs v0.5.0 // indirect + github.com/sigstore/rekor v1.5.1 // indirect + github.com/sigstore/rekor-tiles/v2 v2.2.1 // indirect + github.com/sigstore/sigstore v1.10.5 // indirect + github.com/sigstore/sigstore-go v1.1.4 // indirect + github.com/sigstore/timestamp-authority/v2 v2.0.6 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/spf13/cobra v1.10.2 // indirect + github.com/spf13/pflag v1.0.10 // indirect + github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect + github.com/theupdateframework/go-tuf v0.7.0 // indirect + github.com/theupdateframework/go-tuf/v2 v2.4.1 // indirect + github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect + github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c // indirect + github.com/transparency-dev/merkle v0.0.2 // indirect + github.com/vbatts/tar-split v0.12.2 // indirect + github.com/x448/float16 v0.8.4 // indirect + github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect + go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect + go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/metric v1.43.0 // indirect + go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.27.1 // indirect + go.yaml.in/yaml/v2 v2.4.3 // indirect + go.yaml.in/yaml/v3 v3.0.4 // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect + golang.org/x/mod v0.35.0 // indirect + golang.org/x/net v0.53.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/term v0.42.0 // indirect + golang.org/x/text v0.36.0 // indirect + golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.44.0 // indirect + gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect + gopkg.in/evanphx/json-patch.v4 v4.13.0 // indirect + gopkg.in/inf.v0 v0.9.1 // indirect + gopkg.in/warnings.v0 v0.1.2 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + k8s.io/code-generator v0.35.0 // indirect + k8s.io/component-base v0.35.0 // indirect + k8s.io/gengo/v2 v2.0.0-20251215205346-5ee0d033ba5b // indirect + k8s.io/klog/v2 v2.130.1 // indirect + sigs.k8s.io/controller-tools v0.20.0 // indirect + sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/randfill v1.0.0 // indirect + sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect +) diff --git a/go.sum b/go.sum index 296be18..ca426c3 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,1190 @@ +bitbucket.org/creachadair/shell v0.0.8/go.mod h1:vINzudofoUXZSJ5tREgpy+Etyjsag3ait5WOWImEVZ0= +buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.36.11-20250718181942-e35f9b667443.1/go.mod h1:1Znr6gmYBhbxWUPRrrVnSLXQsz8bvFVw1HHJq2bI3VQ= +buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20260209202127-80ab13bee0bf.1/go.mod h1:tvtbpgaVXZX4g6Pn+AnzFycuRK3MOz5HJfEGeEllXYM= +buf.build/gen/go/bufbuild/registry/connectrpc/go v1.19.1-20260126144947-819582968857.2/go.mod h1:mpsjeEaxOYPIJV2cz4IagLghZufRvx+NPVtInjEeoQ8= +buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.36.11-20260126144947-819582968857.1/go.mod h1:1JJi9jvOqRxSMa+JxiZSm57doB+db/1WYCIa2lHfc40= +buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.36.11-20241007202033-cf42259fcbfc.1/go.mod h1:nWVKKRA29zdt4uvkjka3i/y4mkrswyWwiu0TbdX0zts= +buf.build/go/app v0.2.0/go.mod h1:0XVOYemubVbxNXVY0DnsVgWeGkcbbAvjDa1fmhBC+Wo= +buf.build/go/bufplugin v0.9.0/go.mod h1:Z0CxA3sKQ6EPz/Os4kJJneeRO6CjPeidtP1ABh5jPPY= +buf.build/go/bufprivateusage v0.1.0/go.mod h1:GlCCJ3VVF7EqqU0CoRmo1FzAwwaKymEWSr+ty69xU5w= +buf.build/go/interrupt v1.1.0/go.mod h1:ql56nXPG1oHlvZa6efNC7SKAQ/tUjS6z0mhJl0gyeRM= +buf.build/go/protovalidate v1.1.3/go.mod h1:9XIuohWz+kj+9JVn3WQneHA5LZP50mjvneZMnbLkiIE= +buf.build/go/protoyaml v0.6.0/go.mod h1:RgUOsBu/GYKLDSIRgQXniXbNgFlGEZnQpRAUdLAFV2Q= +buf.build/go/spdx v0.2.0/go.mod h1:bXdwQFem9Si3nsbNy8aJKGPoaPi5DKwdeEp5/ArZ6w8= +buf.build/go/standard v0.1.0/go.mod h1:PiqpHz/7ZFq+kqvYhc/SK3lxFIB9N/aiH2CFC2JHIQg= +cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= +cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= +cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= +cloud.google.com/go v0.123.0/go.mod h1:xBoMV08QcqUGuPW65Qfm1o9Y4zKZBpGS+7bImXLTAZU= +cloud.google.com/go/auth v0.18.2 h1:+Nbt5Ev0xEqxlNjd6c+yYUeosQ5TtEUaNcN/3FozlaM= +cloud.google.com/go/auth v0.18.2/go.mod h1:xD+oY7gcahcu7G2SG2DsBerfFxgPAJz17zz2joOFF3M= +cloud.google.com/go/auth/oauth2adapt v0.2.8 h1:keo8NaayQZ6wimpNSmW5OPc283g65QNIiLpZnkHRbnc= +cloud.google.com/go/auth/oauth2adapt v0.2.8/go.mod h1:XQ9y31RkqZCcwJWNSx2Xvric3RrU88hAYYbjDWYDL+c= +cloud.google.com/go/compute v1.19.3/go.mod h1:qxvISKp/gYnXkSAD1ppcSOveRAmzxicEv/JlizULFrI= +cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs= +cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10= +cloud.google.com/go/iam v1.5.3 h1:+vMINPiDF2ognBJ97ABAYYwRgsaqxPbQDlMnbHMjolc= +cloud.google.com/go/iam v1.5.3/go.mod h1:MR3v9oLkZCTlaqljW6Eb2d3HGDGK5/bDv93jhfISFvU= +cloud.google.com/go/kms v1.26.0 h1:cK9mN2cf+9V63D3H1f6koxTatWy39aTI/hCjz1I+adU= +cloud.google.com/go/kms v1.26.0/go.mod h1:pHKOdFJm63hxBsiPkYtowZPltu9dW0MWvBa6IA4HM58= +cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7EhfW8= +cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= +cloud.google.com/go/monitoring v1.24.3/go.mod h1:nYP6W0tm3N9H/bOw8am7t62YTzZY+zUeQ+Bi6+2eonI= +cloud.google.com/go/profiler v0.4.3/go.mod h1:3xFodugWfPIQZWFcXdUmfa+yTiiyQ8fWrdT+d2Sg4J0= +cloud.google.com/go/pubsub v1.50.1/go.mod h1:6YVJv3MzWJUVdvQXG081sFvS0dWQOdnV+oTo++q/xFk= +cloud.google.com/go/pubsub/v2 v2.4.0/go.mod h1:2lS/XQKq5qtOMs6kHBK+WX1ytUC36kLl2ig3zqsGUx8= +cloud.google.com/go/security v1.19.2/go.mod h1:KXmf64mnOsLVKe8mk/bZpU1Rsvxqc0Ej0A6tgCeN93w= +cloud.google.com/go/spanner v1.87.0/go.mod h1:tcj735Y2aqphB6/l+X5MmwG4NnV+X1NJIbFSZGaHYXw= +cloud.google.com/go/storage v1.59.2/go.mod h1:cMWbtM+anpC74gn6qjLh+exqYcfmB9Hqe5z6adx+CLI= +cloud.google.com/go/trace v1.11.3/go.mod h1:pt7zCYiDSQjC9Y2oqCsh9jF4GStB/hmjrYLsxRR27q8= +connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= +connectrpc.com/otelconnect v0.9.0/go.mod h1:AEkVLjCPXra+ObGFCOClcJkNjS7zPaQSqvO0lCyjfZc= +contrib.go.opencensus.io/exporter/stackdriver v0.13.14/go.mod h1:5pSSGY0Bhuk7waTHuDf4aQ8D2DrhgETRo9fy6k3Xlzc= +cuelabs.dev/go/oci/ociregistry v0.0.0-20250722084951-074d06050084/go.mod h1:4WWeZNxUO1vRoZWAHIG0KZOd6dA25ypyWuwD3ti0Tdc= +cuelang.org/go v0.15.4/go.mod h1:NYw6n4akZcTjA7QQwJ1/gqWrrhsN4aZwhcAL0jv9rZE= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= +filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= +github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d h1:zjqpY4C7H15HjRPEenkS4SAn3Jy2eRRjkjZbGR30TOg= +github.com/AdamKorcz/go-fuzz-headers-1 v0.0.0-20230919221257-8b5d3ce2d11d/go.mod h1:XNqJ7hv2kY++g8XEHREpi+JqZo3+0l+CH2egBVN4yqM= +github.com/AliyunContainerService/ack-ram-tool/pkg/credentials/provider v0.14.0/go.mod h1:tlqp9mUGbsP+0z3Q+c0Q5MgSdq/OMwQhm5bffR3Q3ss= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible h1:fcYLmCpyNYRnvJbPerq7U0hS+6+I79yEDJBqVNcqUzU= +github.com/Azure/azure-sdk-for-go v68.0.0+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0 h1:fou+2+WFTib47nS+nz/ozhEBnvU96bKHy6LjRsY4E28= +github.com/Azure/azure-sdk-for-go/sdk/azcore v1.21.0/go.mod h1:t76Ruy8AHvUAC8GfMWJMa0ElSbuIcO03NLpynfbgsPA= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1 h1:Hk5QBxZQC1jb2Fwj6mpzme37xbCDdNTxU7O9eb5+LB4= +github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.13.1/go.mod h1:IYus9qsFobWIc2YVwe/WPjcnyCkPKtnHAqUYeebc8z0= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 h1:9iefClla7iYpfYWdzPCRDozdmndjTm8DXdpCzPajMgA= +github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2/go.mod h1:XtLgD3ZD34DAaVIIAyG3objl5DynM3CQ/vMcbBNJZGI= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0 h1:E4MgwLBGeVB5f2MdcIVD3ELVAWpr+WD6MUe1i+tM/PA= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/azkeys v1.4.0/go.mod h1:Y2b/1clN4zsAoUd/pgNAQHjLDnTis/6ROkUfyob6psM= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0 h1:nCYfgcSyHZXJI8J0IWE5MsCGlb2xp9fJiXyxWgmOFg4= +github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v1.2.0/go.mod h1:ucUjca2JtSZboY8IoUqyQyuuXvwbMBVwFOm0vdQPNhA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/Azure/go-autorest v14.2.0+incompatible h1:V5VMDjClD3GiElqLWO7mz2MxNAK/vTfRHdAubSIPRgs= +github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24= +github.com/Azure/go-autorest/autorest v0.11.28/go.mod h1:MrkzG3Y3AH668QyF9KRk5neJnGgmhQ6krbhR8Q5eMvA= +github.com/Azure/go-autorest/autorest v0.11.30 h1:iaZ1RGz/ALZtN5eq4Nr1SOFSlf2E4pDI3Tcsl+dZPVE= +github.com/Azure/go-autorest/autorest v0.11.30/go.mod h1:t1kpPIOpIVX7annvothKvb0stsrXa37i7b+xpmBW8Fs= +github.com/Azure/go-autorest/autorest/adal v0.9.18/go.mod h1:XVVeme+LZwABT8K5Lc3hA4nAe8LDBVle26gTrguhhPQ= +github.com/Azure/go-autorest/autorest/adal v0.9.22/go.mod h1:XuAbAEUv2Tta//+voMI038TrJBqjKam0me7qR+L8Cmk= +github.com/Azure/go-autorest/autorest/adal v0.9.24 h1:BHZfgGsGwdkHDyZdtQRQk1WeUdW0m2WPAwuHZwUi5i4= +github.com/Azure/go-autorest/autorest/adal v0.9.24/go.mod h1:7T1+g0PYFmACYW5LlG2fcoPiPlFHjClyRGL7dRlP5c8= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13 h1:Ov8avRZi2vmrE2JcXw+tu5K/yB41r7xK9GZDiBF7NdM= +github.com/Azure/go-autorest/autorest/azure/auth v0.5.13/go.mod h1:5BAVfWLWXihP47vYrPuBKKf4cS0bXI+KM9Qx6ETDJYo= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.6/go.mod h1:piCfgPho7BiIDdEQ1+g4VmKyD5y+p/XtSNqE6Hc4QD0= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.7 h1:Q9R3utmFg9K1B4OYtAZ7ZUUvIUdzQt7G2MN5Hi/d670= +github.com/Azure/go-autorest/autorest/azure/cli v0.4.7/go.mod h1:bVrAueELJ0CKLBpUHDIvD516TwmHmzqwCpvONWRsw3s= +github.com/Azure/go-autorest/autorest/date v0.3.0/go.mod h1:BI0uouVdmngYNUzGWeSYnokU+TrmwEsOqdt8Y6sso74= +github.com/Azure/go-autorest/autorest/date v0.3.1 h1:o9Z8Jyt+VJJTCZ/UORishuHOusBwolhjokt9s5k8I4w= +github.com/Azure/go-autorest/autorest/date v0.3.1/go.mod h1:Dz/RDmXlfiFFS/eW+b/xMUSFs1tboPVy6UjgADToWDM= +github.com/Azure/go-autorest/autorest/mocks v0.4.1/go.mod h1:LTp+uSrOhSkaKrUy935gNZuuIPPVsHlr9DSOxSayd+k= +github.com/Azure/go-autorest/autorest/mocks v0.4.2 h1:PGN4EDXnuQbojHbU0UWoNvmu9AGVwYHG9/fkDYhtAfw= +github.com/Azure/go-autorest/autorest/mocks v0.4.2/go.mod h1:Vy7OitM9Kei0i1Oj+LvyAWMXJHeKH1MVlzFugfVrmyU= +github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8= +github.com/Azure/go-autorest/logger v0.2.2 h1:hYqBsEBywrrOSW24kkOCXRcKfKhK76OzLTfF+MYDE2o= +github.com/Azure/go-autorest/logger v0.2.2/go.mod h1:I5fg9K52o+iuydlWfa9T5K6WFos9XYr9dYTFzpqgibw= +github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU= +github.com/Azure/go-autorest/tracing v0.6.1 h1:YUMSrC/CeD1ZnnXcNYU4a/fzsO35u2Fsful9L/2nyR0= +github.com/Azure/go-autorest/tracing v0.6.1/go.mod h1:/3EgjbsjraOqiicERAeu3m7/z0x1TzjQGAwDrJrXGkc= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= +github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/GoogleCloudPlatform/grpc-gcp-go/grpcgcp v1.5.3/go.mod h1:dppbR7CwXD4pgtV9t3wD1812RaLDcBjtblcDF5f1vI0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/detectors/gcp v1.31.0/go.mod h1:P4WPRUkOhJC13W//jWpyfJNDAIpvRbAUIYLX/4jtlE0= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/exporter/metric v0.55.0/go.mod h1:IA1C1U7jO/ENqm/vhi7V9YYpBsp+IMyqNrEN94N7tVc= +github.com/GoogleCloudPlatform/opentelemetry-operations-go/internal/resourcemapping v0.55.0/go.mod h1:Mf6O40IAyB9zR/1J8nGDDPirZQQPbYJni8Yisy7NTMc= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= +github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= +github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= +github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig v2.22.0+incompatible/go.mod h1:y6hNFY5UBTIWBxnzTeuNhlNS5hqE0NB0E6fgfo2Br3o= +github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= +github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= +github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= +github.com/ThalesIgnite/crypto11 v1.2.5/go.mod h1:ILDKtnCKiQ7zRoNxcp36Y1ZR8LBPmR2E23+wTQe/MlE= +github.com/VividCortex/ewma v1.2.0/go.mod h1:nz4BbCtbLyFDeC9SUHbtcT5644juEuWfUAUnGx7j5l4= +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= +github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= +github.com/alecthomas/kingpin/v2 v2.4.0/go.mod h1:0gyi0zQnjuFk8xrkNKamJoyUo382HRL7ATRpFZCw6tE= +github.com/alecthomas/kong v1.14.0 h1:gFgEUZWu2ZmZ+UhyZ1bDhuutbKN1nTtJTwh19Wsn21s= +github.com/alecthomas/kong v1.14.0/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I= +github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs= +github.com/alecthomas/repr v0.5.2/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= +github.com/alecthomas/units v0.0.0-20240927000941-0f3dac36c52b/go.mod h1:fvzegU4vN3H1qMT+8wDmzjAcDONcgo2/SZ/TyfdUOFs= +github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= +github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= +github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc= +github.com/alibabacloud-go/cr-20160607 v1.0.1/go.mod h1:QHeKZtZ3F3FOE+/uIXCBAp8POwnUYekpLwr1dtQa5r0= +github.com/alibabacloud-go/cr-20181201 v1.0.10/go.mod h1:VN9orB/w5G20FjytoSpZROqu9ZqxwycASmGqYUJSoDc= +github.com/alibabacloud-go/darabonba-openapi v0.2.1/go.mod h1:zXOqLbpIqq543oioL9IuuZYOQgHQ5B8/n5OPrnko8aY= +github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc= +github.com/alibabacloud-go/endpoint-util v1.1.1/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE= +github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws= +github.com/alibabacloud-go/tea v1.2.1/go.mod h1:qbzof29bM/IFhLMtJPrgTGK3eauV5J2wSyEUo4OEmnA= +github.com/alibabacloud-go/tea-utils v1.4.5/go.mod h1:KNcT0oXlZZxOXINnZBs6YvgOd5aYp9U67G+E3R8fcQw= +github.com/alibabacloud-go/tea-xml v1.1.3/go.mod h1:Rq08vgCcCAjHyRi/M7xlHKUykZCEtyBy9+DPF6GgEu8= +github.com/aliyun/credentials-go v1.3.2/go.mod h1:tlpz4uys4Rn7Ik4/piGRrTbXy2uLKvePgQJJduE+Y5c= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= +github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= +github.com/antihax/optional v1.0.0/go.mod h1:uupD/76wgC+ih3iEmQUL+0Ugr19nfwCT1kdvxnR2qWY= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= +github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so= +github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= +github.com/aws/aws-sdk-go v1.55.8 h1:JRmEUbU52aJQZ2AjX4q4Wu7t4uZjOu71uyNmaWlUkJQ= +github.com/aws/aws-sdk-go v1.55.8/go.mod h1:ZkViS9AqA6otK+JBBNH2++sx1sgxrPKcSzPPvQkUtXk= +github.com/aws/aws-sdk-go-v2 v1.41.4 h1:10f50G7WyU02T56ox1wWXq+zTX9I1zxG46HYuG1hH/k= +github.com/aws/aws-sdk-go-v2 v1.41.4/go.mod h1:mwsPRE8ceUUpiTgF7QmQIJ7lgsKUPQOUl3o72QBrE1o= +github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.4/go.mod h1:IOAPF6oT9KCsceNTvvYMNHy0+kMF8akOjeDvPENWxp4= +github.com/aws/aws-sdk-go-v2/config v1.32.12 h1:O3csC7HUGn2895eNrLytOJQdoL2xyJy0iYXhoZ1OmP0= +github.com/aws/aws-sdk-go-v2/config v1.32.12/go.mod h1:96zTvoOFR4FURjI+/5wY1vc1ABceROO4lWgWJuxgy0g= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12 h1:oqtA6v+y5fZg//tcTWahyN9PEn5eDU/Wpvc2+kJ4aY8= +github.com/aws/aws-sdk-go-v2/credentials v1.19.12/go.mod h1:U3R1RtSHx6NB0DvEQFGyf/0sbrpJrluENHdPy1j/3TE= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 h1:zOgq3uezl5nznfoK3ODuqbhVg1JzAGDUhXOsU0IDCAo= +github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20/go.mod h1:z/MVwUARehy6GAg/yQ1GO2IMl0k++cu1ohP9zo887wE= +github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12/go.mod h1:ql4uXYKoTM9WUAUSmthY4AtPVrlTBZOvnBJTiCUdPxI= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20 h1:CNXO7mvgThFGqOFgbNAP2nol2qAWBOGfqR/7tQlvLmc= +github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.20/go.mod h1:oydPDJKcfMhgfcgBUZaG+toBbwy8yPWubJXBVERtI4o= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20 h1:tN6W/hg+pkM+tf9XDkWUbDEjGLb+raoBMFsTodcoYKw= +github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.20/go.mod h1:YJ898MhD067hSHA6xYCx5ts/jEd8BSOLtQDL3iZsvbc= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6 h1:qYQ4pzQ2Oz6WpQ8T3HvGHnZydA72MnLuFK9tJwmrbHw= +github.com/aws/aws-sdk-go-v2/internal/ini v1.8.6/go.mod h1:O3h0IK87yXci+kg6flUKzJnWeziQUKciKrLjcatSNcY= +github.com/aws/aws-sdk-go-v2/internal/v4a v1.4.17/go.mod h1:CO+WeGmIdj/MlPel2KwID9Gt7CNq4M65HUfBW97liM0= +github.com/aws/aws-sdk-go-v2/service/ecr v1.55.3 h1:RtGctYMmkTerGClvdY6bHXdtly4FeYw9wz/NPz62LF8= +github.com/aws/aws-sdk-go-v2/service/ecr v1.55.3/go.mod h1:vBfBu24Ka3/5UZtepbTV0gnc9VPLT8ok+0oDDaYAzn4= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.10 h1:1A/sI3LNMi3fhRI5TFLMwwo7ALAALSFVCSGvFlr1Iys= +github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.38.10/go.mod h1:Diyyyz0b43X13pdi1mVMqlTwDjOmRbJMvDsqnduUYWM= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7 h1:5EniKhLZe4xzL7a+fU3C2tfUN4nWIqlLesfrjkuPFTY= +github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.7/go.mod h1:x0nZssQ3qZSnIcePWLvcoFisRXJzcTVvYpAAdYX8+GI= +github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.9.8/go.mod h1:FsTpJtvC4U1fyDXk7c71XoDv3HlRm8V3NiYLeYLh5YE= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20 h1:2HvVAIq+YqgGotK6EkMf+KIEqTISmTYh5zLpYyeTo1Y= +github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.20/go.mod h1:V4X406Y666khGa8ghKmphma/7C0DAtEQYhkq9z4vpbk= +github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17/go.mod h1:dcW24lbU0CzHusTE8LLHhRLI42ejmINN8Lcr22bwh/g= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.3 h1:s/zDSG/a/Su9aX+v0Ld9cimUCdkr5FWPmBV8owaEbZY= +github.com/aws/aws-sdk-go-v2/service/kms v1.50.3/go.mod h1:/iSgiUor15ZuxFGQSTf3lA2FmKxFsQoc2tADOarQBSw= +github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8 h1:0GFOLzEbOyZABS3PhYfBIx2rNBACYcKty+XGkTgw1ow= +github.com/aws/aws-sdk-go-v2/service/signin v1.0.8/go.mod h1:LXypKvk85AROkKhOG6/YEcHFPoX+prKTowKnVdcaIxE= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13 h1:kiIDLZ005EcKomYYITtfsjn7dtOwHDOFy7IbPXKek2o= +github.com/aws/aws-sdk-go-v2/service/sso v1.30.13/go.mod h1:2h/xGEowcW/g38g06g3KpRWDlT+OTfxxI0o1KqayAB8= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17 h1:jzKAXIlhZhJbnYwHbvUQZEB8KfgAEuG0dc08Bkda7NU= +github.com/aws/aws-sdk-go-v2/service/ssooidc v1.35.17/go.mod h1:Al9fFsXjv4KfbzQHGe6V4NZSZQXecFcvaIF4e70FoRA= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9 h1:Cng+OOwCHmFljXIxpEVXAGMnBia8MSU6Ch5i9PgBkcU= +github.com/aws/aws-sdk-go-v2/service/sts v1.41.9/go.mod h1:LrlIndBDdjA/EeXeyNBle+gyCwTlizzW5ycgWnvIxkk= +github.com/aws/smithy-go v1.24.2 h1:FzA3bu/nt/vDvmnkg+R8Xl46gmzEDam6mZ1hzmwXFng= +github.com/aws/smithy-go v1.24.2/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0 h1:JFWXO6QPihCknDdnL6VaQE57km4ZKheHIGd9YiOGcTo= +github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0/go.mod h1:046/oLyFlYdAghYQE2yHXi/E//VM5Cf3/dFmA+3CZ0c= +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA= +github.com/beevik/ntp v1.5.0/go.mod h1:mJEhBrwT76w9D+IfOEGvuzyuudiW9E52U2BaTrMOYow= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= +github.com/bgentry/speakeasy v0.2.0/go.mod h1:+zsyZBPWlz7T6j88CTgSN5bM796AkVf0kBD4zp0CCIs= +github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= +github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= +github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= +github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bmatcuk/doublestar/v4 v4.0.2/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/bufbuild/buf v1.66.1/go.mod h1:Vd3ELm8IePWaDJaS9FLy94FFOnLrjLi4mDxmXtw9Xio= +github.com/bufbuild/protocompile v0.14.2-0.20260306221011-519528254156/go.mod h1:cxhE8h+14t0Yxq2H9MV/UggzQ1L0gh0t2tJobITWsBE= +github.com/bufbuild/protoplugin v0.0.0-20250218205857-750e09ce93e1/go.mod h1:c5D8gWRIZ2HLWO3gXYTtUfw/hbJyD8xikv2ooPxnklQ= +github.com/buildkite/agent/v3 v3.115.4/go.mod h1:LKY99ujcnFwX8ihEXuMLuPIy3SPL2unKWGJ/DRLICr0= +github.com/buildkite/go-pipeline v0.16.0/go.mod h1:VE37qY3X5pmAKKUMoDZvPsHOQuyakB9cmXj9Qn6QasA= +github.com/buildkite/interpolate v0.1.5/go.mod h1:dHnrwHew5O8VNOAgMDpwRlFnhL5VSN6M1bHVmRZ9Ccc= +github.com/buildkite/roko v1.4.0/go.mod h1:0vbODqUFEcVf4v2xVXRfZZRsqJVsCCHTG/TBRByGK4E= +github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= +github.com/cavaliercoder/badio v0.0.0-20160213150051-ce5280129e9e/go.mod h1:V284PjgVwSk4ETmz84rpu9ehpGg7swlIH8npP9k2bGw= +github.com/cavaliercoder/go-rpm v0.0.0-20200122174316-8cb9fd9c31a8/go.mod h1:AZIh1CCnMrcVm6afFf96PBvE2MRpWFco91z8ObJtgDY= +github.com/cavaliergopher/cpio v1.0.1/go.mod h1:pBdaqQjnvXxdS/6CvNDwIANIFSP0xRKI16PX4xejRQc= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= +github.com/census-instrumentation/opencensus-proto v0.4.1/go.mod h1:4T9NM4+4Vw91VeyqjLS6ao50K5bOcLKN6Q42XnYaRYw= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/chainguard-dev/clog v1.8.0/go.mod h1:5MQOZi+Iu7fV7GcJG8ag8rCB5elEOpqRMKEASgnGVdo= +github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= +github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= +github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= +github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= +github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= +github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= +github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= +github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/cheggaaa/pb/v3 v3.1.6/go.mod h1:urxmfVtaxT+9aWk92DbsvXFZtNSWQSO5TRAp+MJ3l1s= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= +github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s= +github.com/cli/browser v1.3.0/go.mod h1:HH8s+fOAxjhQoBUAsKuPCbqUuxZDhQ2/aD+SzsEfBTk= +github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= +github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= +github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/cockroachdb/apd/v3 v3.2.1/go.mod h1:klXJcjp+FffLTHlhIG69tezTDvdP065naDsHzKhYSqc= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= +github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/common-nighthawk/go-figure v0.0.0-20210622060536-734e95fb86be/go.mod h1:mk5IQ+Y0ZeO87b858TlA645sVcEcbiX6YqP98kt+7+w= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= +github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= +github.com/containerd/typeurl/v2 v2.2.0/go.mod h1:8XOOxnyatxSWuG8OfsZXVnAF4iZfedjS/8UHSPJnX4g= +github.com/coreos/go-oidc v2.3.0+incompatible/go.mod h1:CgnwVTmzoESiwO9qyAFEMiHoZ1nMCKZlZ9V6mm3/LKc= +github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= +github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= +github.com/coreos/go-semver v0.3.1 h1:yi21YpKnrx1gt5R+la8n5WgS0kCrsPp33dmEyHReZr4= +github.com/coreos/go-semver v0.3.1/go.mod h1:irMmmIw/7yzSRPWryHsK7EYSg09caPQL03VsM8rvUec= +github.com/coreos/go-systemd/v22 v22.5.0 h1:RrqgGjYQKalulkV8NGVIfkXQf6YYmOyiJKk8iXXhfZs= +github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/crossplane/crossplane-runtime/v2 v2.3.0-rc.0.0.20260416145853-f43d88270996 h1:qJwaFdiL8LKPaHNEFM3eWQLQj8e23M5inw8OwQBeB6U= +github.com/crossplane/crossplane-runtime/v2 v2.3.0-rc.0.0.20260416145853-f43d88270996/go.mod h1:5n1nTsbBSWjKMfY/xqaOHBm08hXJAe/aklgFmGTKdmk= +github.com/crossplane/crossplane/apis/v2 v2.0.0-20260415071903-2b072b20c4bd h1:qg0Qlr34PEqdAAUZAbJ2fGkCrnRtVRduyvj5Yu9I0XI= +github.com/crossplane/crossplane/apis/v2 v2.0.0-20260415071903-2b072b20c4bd/go.mod h1:h7KE74Z4TFs1L/FFv3RdsiG9Uax7L56oHpcggSZnONg= +github.com/crossplane/crossplane/v2 v2.2.1 h1:1oN2prePpsJAi6+W/qe53AA/nHCeDQCIMDth5jetSr4= +github.com/crossplane/crossplane/v2 v2.2.1/go.mod h1:ZYkweHJ2Q/wJYheZHdtLl56mY/0tuGJWSXazyw6sVws= +github.com/crossplane/function-sdk-go v0.6.1-0.20260422203639-1c756d23b966 h1:+lw7GAiTECezXInECM2dO8xg/rLmFyFhwJkB4f4ru3U= +github.com/crossplane/function-sdk-go v0.6.1-0.20260422203639-1c756d23b966/go.mod h1:yg4qMMRBQPZ75INoGEjfGQ014z4GilpgDcx4Fdf6AaA= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= +github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= +github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/decred/dcrd/dcrec/secp256k1/v4 v4.4.0/go.mod h1:ZXNYxsqcloTdSy/rNShjYzMhyjf0LaoftYK0p+A3h40= +github.com/depcheck-test/depcheck-test v0.0.0-20220607135614-199033aaa936/go.mod h1:ttKPnOepYt4LLzD+loXQ1rT6EmpyIYHro7TAJuIIlHo= +github.com/dgraph-io/badger/v4 v4.9.1/go.mod h1:5/MEx97uzdPUHR4KtkNt8asfI2T4JiEiQlV7kWUo8c0= +github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= +github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= +github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= +github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= +github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= +github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= +github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= +github.com/docker/docker v28.5.2+incompatible h1:DBX0Y0zAjZbSrm1uzOkdr1onVghKaftjlSWt4AFexzM= +github.com/docker/docker v28.5.2+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk= +github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0GzfLOZ38ggPTKePkY= +github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= +github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= +github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/eggsampler/acme/v3 v3.6.2/go.mod h1:/qh0rKC/Dh7Jj+p4So7DbWmFNzC4dpcpK53r226Fhuo= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= +github.com/emicklei/dot v1.10.0 h1:z17n0ce/FBMz3QbShSzVGhiW447Qhu7fljzvp3Gs6ig= +github.com/emicklei/dot v1.10.0/go.mod h1:DeV7GvQtIw4h2u73RKBkkFdvVAz0D9fzeJrgPW6gy/s= +github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes= +github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= +github.com/emicklei/proto v1.14.2/go.mod h1:rn1FgRS/FANiZdD2djyH7TMA9jdRDcYQ9IEN9yvjX0A= +github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= +github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= +github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= +github.com/envoyproxy/go-control-plane/envoy v1.36.0/go.mod h1:ty89S1YCCVruQAm9OtKeEkQLTb+Lkz0k8v9W0Oxsv98= +github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= +github.com/envoyproxy/protoc-gen-validate v1.3.0/go.mod h1:HvYl7zwPa5mffgyeTUHA9zHIH36nmrm7oCbo4YKoSWA= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb4Z+d1UQi45df52xW8= +github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= +github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= +github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= +github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= +github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= +github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= +github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/flynn/go-docopt v0.0.0-20140912013429-f6dd2ebbb31e/go.mod h1:HyVoz1Mz5Co8TFO8EupIdlcpwShBmY98dkT2xeHkvEI= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= +github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= +github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= +github.com/fullstorydev/grpcurl v1.9.3/go.mod h1:/b4Wxe8bG6ndAjlfSUjwseQReUDUvBJiFEB7UllOlUE= +github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= +github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-chi/chi v4.1.2+incompatible/go.mod h1:eB3wogJHnLi3x/kFX2A+IbTBlXxmMeXJVKy9tTv1XzQ= +github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= +github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= +github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= +github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= +github.com/go-git/go-billy/v5 v5.8.0/go.mod h1:RpvI/rw4Vr5QA+Z60c6d6LXH0rYJo0uD5SqfmrrheCY= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= +github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= +github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= +github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8= +github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= +github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= +github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 h1:xcuWappghOVI8iNWoF2OKahVejd1LSVi/v4JED44Amo= +github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= +github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= +github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= +github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= +github.com/go-openapi/analysis v0.24.3 h1:a1hrvMr8X0Xt69KP5uVTu5jH62DscmDifrLzNglAayk= +github.com/go-openapi/analysis v0.24.3/go.mod h1:Nc+dWJ/FxZbhSow5Yh3ozg5CLJioB+XXT6MdLvJUsUw= +github.com/go-openapi/errors v0.22.7 h1:JLFBGC0Apwdzw3484MmBqspjPbwa2SHvpDm0u5aGhUA= +github.com/go-openapi/errors v0.22.7/go.mod h1://QW6SD9OsWtH6gHllUCddOXDL0tk0ZGNYHwsw4sW3w= +github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= +github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= +github.com/go-openapi/jsonreference v0.21.5 h1:6uCGVXU/aNF13AQNggxfysJ+5ZcU4nEAe+pJyVWRdiE= +github.com/go-openapi/jsonreference v0.21.5/go.mod h1:u25Bw85sX4E2jzFodh1FOKMTZLcfifd1Q+iKKOUxExw= +github.com/go-openapi/loads v0.23.3 h1:g5Xap1JfwKkUnZdn+S0L3SzBDpcTIYzZ5Qaag0YDkKQ= +github.com/go-openapi/loads v0.23.3/go.mod h1:NOH07zLajXo8y55hom0omlHWDVVvCwBM/S+csCK8LqA= +github.com/go-openapi/runtime v0.29.3 h1:h5twGaEqxtQg40ePiYm9vFFH1q06Czd7Ot6ufdK0w/Y= +github.com/go-openapi/runtime v0.29.3/go.mod h1:8A1W0/L5eyNJvKciqZtvIVQvYO66NlB7INMSZ9bw/oI= +github.com/go-openapi/spec v0.22.4 h1:4pxGjipMKu0FzFiu/DPwN3CTBRlVM2yLf/YTWorYfDQ= +github.com/go-openapi/spec v0.22.4/go.mod h1:WQ6Ai0VPWMZgMT4XySjlRIE6GP1bGQOtEThn3gcWLtQ= +github.com/go-openapi/strfmt v0.26.1 h1:7zGCHji7zSYDC2tCXIusoxYQz/48jAf2q+sF6wXTG+c= +github.com/go-openapi/strfmt v0.26.1/go.mod h1:Zslk5VZPOISLwmWTMBIS7oiVFem1o1EI6zULY8Uer7Y= +github.com/go-openapi/swag v0.25.5 h1:pNkwbUEeGwMtcgxDr+2GBPAk4kT+kJ+AaB+TMKAg+TU= +github.com/go-openapi/swag v0.25.5/go.mod h1:B3RT6l8q7X803JRxa2e59tHOiZlX1t8viplOcs9CwTA= +github.com/go-openapi/swag/cmdutils v0.25.5 h1:yh5hHrpgsw4NwM9KAEtaDTXILYzdXh/I8Whhx9hKj7c= +github.com/go-openapi/swag/cmdutils v0.25.5/go.mod h1:pdae/AFo6WxLl5L0rq87eRzVPm/XRHM3MoYgRMvG4A0= +github.com/go-openapi/swag/conv v0.25.5 h1:wAXBYEXJjoKwE5+vc9YHhpQOFj2JYBMF2DUi+tGu97g= +github.com/go-openapi/swag/conv v0.25.5/go.mod h1:CuJ1eWvh1c4ORKx7unQnFGyvBbNlRKbnRyAvDvzWA4k= +github.com/go-openapi/swag/fileutils v0.25.5 h1:B6JTdOcs2c0dBIs9HnkyTW+5gC+8NIhVBUwERkFhMWk= +github.com/go-openapi/swag/fileutils v0.25.5/go.mod h1:V3cT9UdMQIaH4WiTrUc9EPtVA4txS0TOmRURmhGF4kc= +github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= +github.com/go-openapi/swag/jsonname v0.25.5/go.mod h1:jNqqikyiAK56uS7n8sLkdaNY/uq6+D2m2LANat09pKU= +github.com/go-openapi/swag/jsonutils v0.25.5 h1:XUZF8awQr75MXeC+/iaw5usY/iM7nXPDwdG3Jbl9vYo= +github.com/go-openapi/swag/jsonutils v0.25.5/go.mod h1:48FXUaz8YsDAA9s5AnaUvAmry1UcLcNVWUjY42XkrN4= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5 h1:SX6sE4FrGb4sEnnxbFL/25yZBb5Hcg1inLeErd86Y1U= +github.com/go-openapi/swag/jsonutils/fixtures_test v0.25.5/go.mod h1:/2KvOTrKWjVA5Xli3DZWdMCZDzz3uV/T7bXwrKWPquo= +github.com/go-openapi/swag/loading v0.25.5 h1:odQ/umlIZ1ZVRteI6ckSrvP6e2w9UTF5qgNdemJHjuU= +github.com/go-openapi/swag/loading v0.25.5/go.mod h1:I8A8RaaQ4DApxhPSWLNYWh9NvmX2YKMoB9nwvv6oW6g= +github.com/go-openapi/swag/mangling v0.25.5 h1:hyrnvbQRS7vKePQPHHDso+k6CGn5ZBs5232UqWZmJZw= +github.com/go-openapi/swag/mangling v0.25.5/go.mod h1:6hadXM/o312N/h98RwByLg088U61TPGiltQn71Iw0NY= +github.com/go-openapi/swag/netutils v0.25.5 h1:LZq2Xc2QI8+7838elRAaPCeqJnHODfSyOa7ZGfxDKlU= +github.com/go-openapi/swag/netutils v0.25.5/go.mod h1:lHbtmj4m57APG/8H7ZcMMSWzNqIQcu0RFiXrPUara14= +github.com/go-openapi/swag/stringutils v0.25.5 h1:NVkoDOA8YBgtAR/zvCx5rhJKtZF3IzXcDdwOsYzrB6M= +github.com/go-openapi/swag/stringutils v0.25.5/go.mod h1:PKK8EZdu4QJq8iezt17HM8RXnLAzY7gW0O1KKarrZII= +github.com/go-openapi/swag/typeutils v0.25.5 h1:EFJ+PCga2HfHGdo8s8VJXEVbeXRCYwzzr9u4rJk7L7E= +github.com/go-openapi/swag/typeutils v0.25.5/go.mod h1:itmFmScAYE1bSD8C4rS0W+0InZUBrB2xSPbWt6DLGuc= +github.com/go-openapi/swag/yamlutils v0.25.5 h1:kASCIS+oIeoc55j28T4o8KwlV2S4ZLPT6G0iq2SSbVQ= +github.com/go-openapi/swag/yamlutils v0.25.5/go.mod h1:Gek1/SjjfbYvM+Iq4QGwa/2lEXde9n2j4a3wI3pNuOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1 h1:NZOrZmIb6PTv5LTFxr5/mKV/FjbUzGE7E6gLz7vFoOQ= +github.com/go-openapi/testify/enable/yaml/v2 v2.4.1/go.mod h1:r7dwsujEHawapMsxA69i+XMGZrQ5tRauhLAjV/sxg3Q= +github.com/go-openapi/testify/v2 v2.4.1 h1:zB34HDKj4tHwyUQHrUkpV0Q0iXQ6dUCOQtIqn8hE6Iw= +github.com/go-openapi/testify/v2 v2.4.1/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7xDJZz0= +github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= +github.com/go-piv/piv-go/v2 v2.4.0/go.mod h1:ShZi74nnrWNQEdWzRUd/3cSig3uNOcEZp+EWl0oewnI= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= +github.com/go-redis/redismock/v9 v9.2.0/go.mod h1:18KHfGDK4Y6c2R0H38EUGWAdc7ZQS9gfYxc94k7rWT0= +github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= +github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= +github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= +github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= +github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= +github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= +github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= +github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= +github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= +github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0= +github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= +github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/golang-jwt/jwt/v4 v4.0.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.2.0/go.mod h1:/xlHOz8bRuivTWchD4jCa+NbatV+wEUSzwAxVc6locg= +github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= +github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= +github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/glog v1.2.5/go.mod h1:6AhwSGph0fcJtXVM/PEHPqZlFeoLxhs7/t5UDAwmO+w= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= +github.com/golang/mock v1.7.0-rc.1/go.mod h1:s42URUywIqd+OcERslBJvOjepvNymP31m3q8d/GkuRs= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= +github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= +github.com/google/addlicense v1.1.1/go.mod h1:Sm/DHu7Jk+T5miFHHehdIjbi4M5+dJDRS3Cq0rncIxA= +github.com/google/btree v1.1.3 h1:CVpQJjYgC4VbzxeGVHfvZrv1ctoYCAI8vbl07Fcxlyg= +github.com/google/btree v1.1.3/go.mod h1:qOPhT0dTNdNzV6Z/lhRX0YXUafgPLFUh+gZMl761Gm4= +github.com/google/cel-go v0.27.0 h1:e7ih85+4qVrBuqQWTW4FKSqZYokVuc3HnhH5keboFTo= +github.com/google/cel-go v0.27.0/go.mod h1:tTJ11FWqnhw5KKpnWpvW9CJC3Y9GK4EIS0WXnBbebzw= +github.com/google/certificate-transparency-go v1.3.3 h1:hq/rSxztSkXN2tx/3jQqF6Xc0O565UQPdHrOWvZwybo= +github.com/google/certificate-transparency-go v1.3.3/go.mod h1:iR17ZgSaXRzSa5qvjFl8TnVD5h8ky2JMVio+dzoKMgA= +github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8= +github.com/google/gnostic v0.6.9/go.mod h1:Nm8234We1lq6iB9OmlgNv3nH91XLLVZHCDayfA3xq+E= +github.com/google/gnostic-models v0.7.1 h1:SisTfuFKJSKM5CPZkffwi6coztzzeYUhc3v4yxLWH8c= +github.com/google/gnostic-models v0.7.1/go.mod h1:whL5G0m6dmc5cPxKc5bdKdEN3UjI7OUGxBlw57miDrQ= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-containerregistry v0.21.2 h1:vYaMU4nU55JJGFC9JR/s8NZcTjbE9DBBbvusTW9NeS0= +github.com/google/go-containerregistry v0.21.2/go.mod h1:ctO5aCaewH4AK1AumSF5DPW+0+R+d2FmylMJdp5G7p0= +github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20230919002926-dbcd01c402b2 h1:ChuUQ1y5Vf+Eev+UgEed/ljibTIcWY7mYPtWYLK7fxU= +github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20230919002926-dbcd01c402b2/go.mod h1:Ek+8PQrShkA7aHEj3/zSW33wU0V/Bx3zW/gFh7l21xY= +github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20250225234217-098045d5e61f h1:GJRzEBoJv/A/E7JbTekq1Q0jFtAfY7TIxUFAK89Mmic= +github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20250225234217-098045d5e61f/go.mod h1:ZT74/OE6eosKneM9/LQItNxIMBV6CI5S46EXAnvkTBI= +github.com/google/go-github/v73 v73.0.0/go.mod h1:fa6w8+/V+edSU0muqdhCVY7Beh1M8F1IlQPZIANKIYw= +github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= +github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e h1:FJta/0WsADCe1r9vQjdHbd3KuiLPu7Y9WlyLGwMUNyE= +github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= +github.com/google/rpmpack v0.7.1/go.mod h1:h1JL16sUTWCLI/c39ox1rDaTBo3BXUQGjczVJyK4toU= +github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= +github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= +github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18= +github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA6RlzjJaT4hi3kII+zYw8wmLb8= +github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= +github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= +github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/gorilla/websocket v1.5.4-0.20250319132907-e064f32e3674/go.mod h1:r4w70xmWCQKmi1ONH4KIaBptdivuRPyosB9RmPlGEwA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= +github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= +github.com/grpc-ecosystem/go-grpc-middleware/providers/prometheus v1.1.0/go.mod h1:hM2alZsMUni80N33RBe6J0e423LB+odMj7d3EMP9l20= +github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.3/go.mod h1:NbCUVmiS4foBGBHOYlCT25+YmGpJ32dZPi75pGEUpj4= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= +github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= +github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/errwrap v1.1.0 h1:OxrOeh75EUXMY8TBjag2fzXGZ40LB6IKw45YeGUDY2I= +github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= +github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= +github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= +github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= +github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= +github.com/hashicorp/go-multierror v1.0.0/go.mod h1:dHtQlpGsu+cZNNAkkCN/P3hoUDHhCYQXV3UM06sGGrk= +github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= +github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-rootcerts v1.0.2 h1:jzhAVGtqPKbwpyCPELlgNWhE1znq+qwJtW5Oi2viEzc= +github.com/hashicorp/go-rootcerts v1.0.2/go.mod h1:pqUvnprVnM5bf7AOirdbb01K4ccR319Vf4pU3K5EGc8= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0 h1:U+kC2dOhMFQctRfhK0gRctKAPTloZdMU5ZJxaesJ/VM= +github.com/hashicorp/go-secure-stdlib/parseutil v0.2.0/go.mod h1:Ll013mhdmsVDuoIXVfBtvgGJsXDYkTw1kooNcoCXuE0= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9CdjCtrXrXGuOpxEA7Ts= +github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= +github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= +github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= +github.com/hashicorp/hcl v1.0.1-vault-7/go.mod h1:XYhtn6ijBSAj6n4YqAaf7RBPS4I06AItNorpy+MoQNM= +github.com/hashicorp/vault/api v1.22.0 h1:+HYFquE35/B74fHoIeXlZIP2YADVboaPjaSicHEZiH0= +github.com/hashicorp/vault/api v1.22.0/go.mod h1:IUZA2cDvr4Ok3+NtK2Oq/r+lJeXkeCrHRmqdyWfpmGM= +github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= +github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= +github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.2.0/go.mod h1:DvyZB1rfVYsBIigL8HwpZgxHwXozlTgGqn63UyNX5k4= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= +github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= +github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM= +github.com/in-toto/in-toto-golang v0.10.0 h1:+s2eZQSK3WmWfYV85qXVSBfqgawi/5L02MaqA4o/tpM= +github.com/in-toto/in-toto-golang v0.10.0/go.mod h1:wjT4RiyFlLWCmLUJjwB8oZcjaq7HA390aMJcD3xXgmg= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo= +github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= +github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= +github.com/jdx/go-netrc v1.0.0/go.mod h1:Gh9eFQJnoTNIRHXl2j5bJXA1u84hQWJWgGh569zF3v8= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 h1:TMtDYDHKYY15rFihtRfck/bfFqNfvcabqvXAFQfAUpY= +github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267/go.mod h1:h1nSAbGFqGVzn6Jyl1R/iCcBUHN4g+gW1u9CoBTrb9E= +github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= +github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= +github.com/jessevdk/go-flags v1.6.1/go.mod h1:Mk8T1hIAWpOiJiHa9rJASDK2UGWji0EuPGBnNLMooyc= +github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= +github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= +github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/jpillora/backoff v1.0.0/go.mod h1:J/6gKK9jxlEcS3zixgDgUAsiuZ7yrSoa/FX5e0EB2j4= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/julienschmidt/httprouter v1.3.0/go.mod h1:JR6WtHb+2LUe8TCKY3cZOxFyyO8IZAc4RVcycCCAKdM= +github.com/kelseyhightower/envconfig v1.4.0/go.mod h1:cccZRl6mQpaq41TPp5QxidR+Sa3axMbJDNb//FQX6Gg= +github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= +github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= +github.com/keybase/go-keychain v0.0.1/go.mod h1:PdEILRW3i9D8JcdM+FmY6RwkHGnhHxXwkPPMeUgOK1k= +github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= +github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/lestrrat-go/blackmagic v1.0.4/go.mod h1:6AWFyKNNj0zEXQYfTMPfZrAXUWUfTIZ5ECEUEJaijtw= +github.com/lestrrat-go/dsig v1.0.0/go.mod h1:dEgoOYYEJvW6XGbLasr8TFcAxoWrKlbQvmJgCR0qkDo= +github.com/lestrrat-go/dsig-secp256k1 v1.0.0/go.mod h1:CxUgAhssb8FToqbL8NjSPoGQlnO4w3LG1P0qPWQm/NU= +github.com/lestrrat-go/httpcc v1.0.1/go.mod h1:qiltp3Mt56+55GPVCbTdM9MlqhvzyuL6W/NMDA8vA5E= +github.com/lestrrat-go/httprc/v3 v3.0.1/go.mod h1:2uAvmbXE4Xq8kAUjVrZOq1tZVYYYs5iP62Cmtru00xk= +github.com/lestrrat-go/jwx/v3 v3.0.12/go.mod h1:HiUSaNmMLXgZ08OmGBaPVvoZQgJVOQphSrGr5zMamS8= +github.com/lestrrat-go/option v1.0.1/go.mod h1:5ZHFbivi4xwXxhxY9XHDe2FHo6/Z7WWmtT7T5nBBp3I= +github.com/lestrrat-go/option/v2 v2.0.0/go.mod h1:oSySsmzMoR0iRzCDCaUfsCzxQHUEuhOViQObyy7S6Vg= +github.com/letsencrypt/borp v0.0.0-20251118150929-89c6927051ae/go.mod h1:gMSMCNKhxox/ccR923EJsIvHeVVYfCABGbirqa0EwuM= +github.com/letsencrypt/boulder v0.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= +github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= +github.com/letsencrypt/challtestsrv v1.3.3/go.mod h1:Ur4e4FvELUXLGhkMztHOsPIsvGxD/kzSJninOrkM+zc= +github.com/letsencrypt/pkcs11key/v4 v4.0.0/go.mod h1:EFUvBDay26dErnNb70Nd0/VW3tJiIbETBPTl9ATXQag= +github.com/letsencrypt/validator/v10 v10.0.0-20230215210743-a0c7dfc17158/go.mod h1:ZFNBS3H6OEsprCRjscty6GCBe5ZiX44x6qY4s7+bDX0= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= +github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/magefile/mage v1.14.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= +github.com/magiconair/properties v1.8.5/go.mod h1:y3VJvCyxH9uVvJTWEGAELF3aiYNyPKd5NZ3oSwXrF60= +github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= +github.com/manifoldco/promptui v0.9.0/go.mod h1:ka04sppxSGFAtxX0qhlYQjISsg9mR4GWtQEhdbn6Pgg= +github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= +github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= +github.com/miekg/pkcs11 v1.1.2/go.mod h1:XsNlhZGX73bx86s2hdc/FuaLm2CPZJemRLMA+WTFxgs= +github.com/mitchellh/copystructure v1.0.0/go.mod h1:SNtv71yrdKgLRyLFxmLdkAbkKEFWgYaq1OVrnRcwhnw= +github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= +github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= +github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.1/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/spdystream v0.5.1/go.mod h1:xBAYlnt/ay+11ShkdFKNAG7LsyK/tmNBVvVOwrfMgdI= +github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= +github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= +github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= +github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= +github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= +github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= +github.com/mozillazg/docker-credential-acr-helper v0.4.0/go.mod h1:2kiicb3OlPytmlNC9XGkLvVC+f0qTiJw3f/mhmeeQBg= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= +github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= +github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U= +github.com/mwitkow/go-proto-validators v0.2.0/go.mod h1:ZfA1hW+UH/2ZHOWvQ3HnQaU0DtnpXu850MZiy+YUgcc= +github.com/mxk/go-flowrate v0.0.0-20140419014527-cca7078d478f/go.mod h1:ZdcZmHo+o7JKHSa8/e818NopupXU1YMK5fe1lsApnBw= +github.com/natefinch/atomic v1.0.1 h1:ZPYKxkqQOx3KZ+RsbnP/YsgvxWQPGxjC0oBt2AhwV0A= +github.com/natefinch/atomic v1.0.1/go.mod h1:N/D/ELrljoqDyT3rZrsUmtsuzvHkeB/wWjHV22AZRbM= +github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 h1:Up6+btDp321ZG5/zdSLo48H9Iaq0UQGthrhWC6pCxzE= +github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481/go.mod h1:yKZQO8QE2bHlgozqWDiRVqTFlLQSj30K/6SAK8EeYFw= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= +github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U= +github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= +github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= +github.com/oleiade/reflections v1.1.0/go.mod h1:mCxx0QseeVCHs5Um5HhJeCKVC7AwS8kO67tky4rdisA= +github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= +github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= +github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= +github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/open-policy-agent/opa v1.12.3/go.mod h1:RnDgm04GA1RjEXJvrsG9uNT/+FyBNmozcPvA2qz60M4= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pborman/uuid v1.2.1/go.mod h1:X/NO0urCmaxf9VXbdlT7C2Yzkj2IKimNn4k+gtPdI/k= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/petermattis/goid v0.0.0-20260226131333-17d1149c6ac6/go.mod h1:pxMtw7cyUw6B2bRH0ZBANSPg+AoSud1I1iyJHI69jH4= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= +github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c/go.mod h1:7rwL4CYBLnjLxUqIJNnCWiEdr3bn6IUYi15bNlnbCCU= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= +github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= +github.com/pquerna/cachecontrol v0.1.0/go.mod h1:NrUG3Z7Rdu85UNR3vm7SOsl1nFIeSiQnrHV5K9mBcUI= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= +github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= +github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/prometheus v0.51.0/go.mod h1:yv4MwOn3yHMQ6MZGHPg/U7Fcyqf+rxqiZfSur6myVtc= +github.com/protocolbuffers/txtpbfmt v0.0.0-20251016062345-16587c79cd91/go.mod h1:JSbkp0BviKovYYt9XunS95M3mLPibE9bGg+Y95DsEEY= +github.com/pseudomuto/protoc-gen-doc v1.5.1/go.mod h1:XpMKYg6zkcpgfpCfQ8GcWBDRtRxOmMR5w7pz4Xo+dYM= +github.com/pseudomuto/protokit v0.2.0/go.mod h1:2PdH30hxVHsup8KpBTOXTBeMVhJZVio3Q8ViKSAXT0Q= +github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII= +github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= +github.com/rcrowley/go-metrics v0.0.0-20250401214520-65e299d6c5c9/go.mod h1:bCqnVzQkZxMG4s8nGwiZ5l3QUCyqpo9Y+/ZMZ9VjZe4= +github.com/redis/go-redis/extra/rediscmd/v9 v9.5.3/go.mod h1:3dZmcLn3Qw6FLlWASn1g4y+YO9ycEFUOM+bhBmzLVKQ= +github.com/redis/go-redis/extra/redisotel/v9 v9.5.3/go.mod h1:7f/FMrf5RRRVHXgfk7CzSVzXHiWeuOQUu2bsVqWoa+g= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY= +github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= +github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rs/cors v1.11.1/go.mod h1:XyqrcTp5zjWr1wsJ8PIRZssZ8b/WMcMf71DJnit4EMU= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= +github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/sagikazarmark/locafero v0.11.0/go.mod h1:nVIGvgyzw595SUSUE6tvCp3YYTeHs15MvlmU87WwIik= +github.com/santhosh-tekuri/jsonschema/v5 v5.3.1/go.mod h1:uToXkOrWAZ6/Oc07xWQrPOhJotwFIyu2bBVN41fcDUY= +github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= +github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= +github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= +github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= +github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= +github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= +github.com/segmentio/asm v1.2.1/go.mod h1:BqMnlJP91P8d+4ibuonYZw9mfnzI9HfxselHZr5aAcs= +github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= +github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= +github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/sigstore/cosign/v3 v3.0.5 h1:c1zPqjU+H4wmirgysC+AkWMg7a7fykyOYF/m+F1150I= +github.com/sigstore/cosign/v3 v3.0.5/go.mod h1:ble1vMvJagCFyTIDkibCq6MIHiWDw00JNYl0f9rB4T4= +github.com/sigstore/fulcio v1.8.5/go.mod h1:tSLYK3JsKvJpDW1BsIsVHZgHj+f8TjXARzqIUWSsSPQ= +github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY= +github.com/sigstore/protobuf-specs v0.5.0/go.mod h1:+gXR+38nIa2oEupqDdzg4qSBT0Os+sP7oYv6alWewWc= +github.com/sigstore/rekor v1.5.1 h1:Ca1egHRWRuDvXV4tZu9aXEXc3Gej9FG+HKeapV9OAMQ= +github.com/sigstore/rekor v1.5.1/go.mod h1:gTLDuZuo3SyQCuZvKqwRPA79Qo/2rw39/WtLP/rZjUQ= +github.com/sigstore/rekor-tiles/v2 v2.2.1 h1:UmV1CBQ3SjxxPGpFmwDoOhoIwiKpM2Qm1pU5tPGmvNk= +github.com/sigstore/rekor-tiles/v2 v2.2.1/go.mod h1:z8n6l6oidpaLjjE6rJERuQqY9X38ulnHZCXyL+DEL7U= +github.com/sigstore/sigstore v1.10.5 h1:KqrOjDhNOVY+uOzQFat2FrGLClPPCb3uz8pK3wuI+ow= +github.com/sigstore/sigstore v1.10.5/go.mod h1:k/mcVVXw3I87dYG/iCVTSW2xTrW7vPzxxGic4KqsqXs= +github.com/sigstore/sigstore-go v1.1.4 h1:wTTsgCHOfqiEzVyBYA6mDczGtBkN7cM8mPpjJj5QvMg= +github.com/sigstore/sigstore-go v1.1.4/go.mod h1:2U/mQOT9cjjxrtIUeKDVhL+sHBKsnWddn8URlswdBsg= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5 h1:aqHRubTITULckG9JAcq2FEhtKkT/RRE8oErfuV3smSI= +github.com/sigstore/sigstore/pkg/signature/kms/aws v1.10.5/go.mod h1:h9eK9QyPqpFskF/ewFkRLtwh4/Q3FLc2/DXbym4IHN8= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5 h1:+9C6CUkv+J4iT67Lx+H1EGBfAdoAHqXumHadeIj9jA4= +github.com/sigstore/sigstore/pkg/signature/kms/azure v1.10.5/go.mod h1:myZsg7wRiy/vf102g5uUAitYhtXCwepmAGxgHG1VHuE= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5 h1:BpQx6AhjwIN9LmlO4ypkcMcHiWiepgZQGSw5U69frHU= +github.com/sigstore/sigstore/pkg/signature/kms/gcp v1.10.5/go.mod h1:ejMD/17lMJ4HykQRPdj5NNr+OQYIEZto8HjDKghVMOA= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5 h1:OFwQZgWkB/6J6W5sy3SkXE4pJnhNRnE2cJd8ySXmHpo= +github.com/sigstore/sigstore/pkg/signature/kms/hashivault v1.10.5/go.mod h1:Ee/enmyxi/RFLVlajbnjgH2wOWQwlJ0wY8qZrk43hEw= +github.com/sigstore/timestamp-authority/v2 v2.0.6 h1:1Vh7/SdmLsVLG6Br6/bisd1SnlicfDm0MJYiA+D7Ppw= +github.com/sigstore/timestamp-authority/v2 v2.0.6/go.mod h1:Nk5ucGBDyH0tXAIMZ0prf6xn8qfTnbJhSq+CDabYcfc= +github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/soheilhy/cmux v0.1.5/go.mod h1:T7TcVDs9LWfQgPlPsdngu6I6QIoyIFZDDC6sNE1GqG0= +github.com/sourcegraph/conc v0.3.1-0.20240121214520-5f936abd7ae8/go.mod h1:3n1Cwaq1E1/1lhQhtRK2ts/ZwZEhjcQeJQ1RuC6Q/8U= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/jwalterweatherman v1.1.0/go.mod h1:aNWZUN0dPAAO/Ljvb5BEdw96iTZ0EXowPYD95IqWIGo= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= +github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/spf13/viper v1.21.0/go.mod h1:P0lhsswPGWD/1lZJ9ny3fYnVqxiegrlNrEmgLjbTCAY= +github.com/spiffe/go-spiffe/v2 v2.6.0/go.mod h1:gm2SeUoMZEtpnzPNs2Csc0D/gX33k1xIx7lEzqblHEs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d h1:vfofYNRScrDdvS342BElfbETmL1Aiz3i2t0zfRj16Hs= +github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d/go.mod h1:RRCYJbIwD5jmqPI9XoAFR0OcDxqUctll6zUj/+B4S48= +github.com/tchap/go-patricia/v2 v2.3.3/go.mod h1:VZRHKAb53DLaG+nA9EaYYiaEx6YztwDlLElMsnSHD4k= +github.com/tetratelabs/wazero v1.11.0/go.mod h1:eV28rsN8Q+xwjogd7f4/Pp4xFxO7uOGbLcD/LzB1wiU= +github.com/thales-e-security/pool v0.0.2/go.mod h1:qtpMm2+thHtqhLzTwgDBj/OuNnMpupY8mv0Phz0gjhU= +github.com/theupdateframework/go-tuf v0.7.0 h1:CqbQFrWo1ae3/I0UCblSbczevCCbS31Qvs5LdxRWqRI= +github.com/theupdateframework/go-tuf v0.7.0/go.mod h1:uEB7WSY+7ZIugK6R1hiBMBjQftaFzn7ZCDJcp1tCUug= +github.com/theupdateframework/go-tuf/v2 v2.4.1 h1:K6ewW064rKZCPkRo1W/CTbTtm/+IB4+coG1iNURAGCw= +github.com/theupdateframework/go-tuf/v2 v2.4.1/go.mod h1:Nex2enPVYDFCklrnbTzl3OVwD7fgIAj0J5++z/rvCj8= +github.com/tidwall/btree v1.8.1/go.mod h1:jBbTdUWhSZClZWoDg54VnvV7/54modSOzDN7VXftj1A= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0 h1:N9UxlsOzu5mttdjhxkDLbzwtEecuXmlxZVo/ds7JKJI= +github.com/tink-crypto/tink-go-awskms/v2 v2.1.0/go.mod h1:PxSp9GlOkKL9rlybW804uspnHuO9nbD98V/fDX4uSis= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0 h1:3B9i6XBXNTRspfkTC0asN5W0K6GhOSgcujNiECNRNb0= +github.com/tink-crypto/tink-go-gcpkms/v2 v2.2.0/go.mod h1:jY5YN2BqD/KSCHM9SqZPIpJNG/u3zwfLXHgws4x2IRw= +github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0 h1:j+S+WKBQ5ya26A5EM/uXoVe+a2IaPQN8KgBJZ22cJ+4= +github.com/tink-crypto/tink-go-hcvault/v2 v2.4.0/go.mod h1:OCKJIujnTzDq7f+73NhVs99oA2c1TR6nsOpuasYM6Yo= +github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC60t388v4= +github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= +github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= +github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE= +github.com/tmc/grpc-websocket-proxy v0.0.0-20220101234140-673ab2c3ae75/go.mod h1:KO6IkyS8Y3j8OdNO85qEYBsRPuteD+YciPomcXdrMnk= +github.com/tomasen/realip v0.0.0-20180522021738-f0c99a92ddce/go.mod h1:o8v6yHRoik09Xen7gje4m9ERNah1d1PPsVq1VEx9vE4= +github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ2LiAUV+/RjckMyq9sXudfrPSuCY4FuPC1NyAw= +github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= +github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= +github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/transparency-dev/tessera v1.0.2/go.mod h1:WD/EMM6RXWRyImk9yyJ2hrs8xdknN/lpwUrFR2GemfU= +github.com/ulikunitz/xz v0.5.14/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= +github.com/urfave/negroni v1.0.0/go.mod h1:Meg73S6kFm/4PpbYdq35yYWoCZ9mS/YSx+lKnmiohz4= +github.com/valyala/fastjson v1.6.4/go.mod h1:CLCAqky6SMuOcxStkYQvblddUtoRxhYMGLrsQns1aXY= +github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= +github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts= +github.com/veraison/go-cose v1.3.0/go.mod h1:df09OV91aHoQWLmy1KsDdYiagtXgyAwAl8vFeFn1gMc= +github.com/vladimirvivien/gexe v0.4.1/go.mod h1:3gjgTqE2c0VyHnU5UOIwk7gyNzZDGulPb/DJPgcw64E= +github.com/weppos/publicsuffix-go v0.50.2/go.mod h1:CbQCKDtXF8UcT7hrxeMa0MDjwhpOI9iYOU7cfq+yo8k= +github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g= +github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8= +github.com/withfig/autocomplete-tools/integrations/cobra v1.2.1/go.mod h1:nmuySobZb4kFgFy6BptpXp/BBw+xFSyvVPP6auoJB4k= +github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= +github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= +github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= +github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb/go.mod h1:N2zxlSyiKSe5eX1tZViRH5QA0qijqEDrYZiPEAiq3wU= +github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415/go.mod h1:GwrjFmJcFw6At/Gs6z4yjiIwzuJ1/+UwLxMQDVQXShQ= +github.com/xhit/go-str2duration/v2 v2.1.0/go.mod h1:ohY8p+0f07DiV6Em5LKB0s2YpLtXVyJfNt1+BlmyAsU= +github.com/xiang90/probing v0.0.0-20221125231312-a49e3df8f510/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= +github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= +github.com/yashtewari/glob-intersection v0.2.0/go.mod h1:LK7pIC3piUjovexikBbJ26Yml7g8xa5bsjfx2v1fwok= +github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= +github.com/ysmood/fetchup v0.2.3/go.mod h1:xhibcRKziSvol0H1/pj33dnKrYyI2ebIvz5cOOkYGns= +github.com/ysmood/goob v0.4.0 h1:HsxXhyLBeGzWXnqVKtmT9qM7EuVs/XOgkX7T6r1o1AQ= +github.com/ysmood/goob v0.4.0/go.mod h1:u6yx7ZhS4Exf2MwciFr6nIM8knHQIE22lFpWHnfql18= +github.com/ysmood/got v0.40.0 h1:ZQk1B55zIvS7zflRrkGfPDrPG3d7+JOza1ZkNxcc74Q= +github.com/ysmood/got v0.40.0/go.mod h1:W7DdpuX6skL3NszLmAsC5hT7JAhuLZhByVzHTq874Qg= +github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= +github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= +github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= +github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= +github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= +github.com/zalando/go-keyring v0.2.3/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= +github.com/zmap/zcrypto v0.0.0-20250129210703-03c45d0bae98/go.mod h1:YTUyN/U1oJ7RzCEY5hUweYxbVUu7X+11wB7OXZT15oE= +github.com/zmap/zlint/v3 v3.6.6/go.mod h1:6yXG+CBOQBRpMCOnpIVPUUL296m5HYksZC9bj5LZkwE= +gitlab.com/gitlab-org/api/client-go v1.25.0/go.mod h1:r060AandE8Md/L5oKdUVjljL8YQprOAxKzUnpqWqP3A= +go.etcd.io/bbolt v1.4.3/go.mod h1:tKQlpPaYCVFctUIgFKFnAlvbmB3tpy1vkTnDWohtc0E= +go.etcd.io/etcd/api/v3 v3.6.8 h1:gqb1VN92TAI6G2FiBvWcqKtHiIjr4SU2GdXxTwyexbM= +go.etcd.io/etcd/api/v3 v3.6.8/go.mod h1:qyQj1HZPUV3B5cbAL8scG62+fyz5dSxxu0w8pn28N6Q= +go.etcd.io/etcd/client/pkg/v3 v3.6.8 h1:Qs/5C0LNFiqXxYf2GU8MVjYUEXJ6sZaYOz0zEqQgy50= +go.etcd.io/etcd/client/pkg/v3 v3.6.8/go.mod h1:GsiTRUZE2318PggZkAo6sWb6l8JLVrnckTNfbG8PWtw= +go.etcd.io/etcd/client/v3 v3.6.8 h1:B3G76t1UykqAOrbio7s/EPatixQDkQBevN8/mwiplrY= +go.etcd.io/etcd/client/v3 v3.6.8/go.mod h1:MVG4BpSIuumPi+ELF7wYtySETmoTWBHVcDoHdVupwt8= +go.etcd.io/etcd/etcdctl/v3 v3.6.8/go.mod h1:8X8SvxOc5kPQ0e+jbSx3RgKzTNQ3O8rBuQEoDKuQFX0= +go.etcd.io/etcd/etcdutl/v3 v3.6.8/go.mod h1:HGfpMG6Sjo9S6KWeXctiYcN8LjLbbUBdAjCYb8V977w= +go.etcd.io/etcd/pkg/v3 v3.6.8/go.mod h1:TRibVNe+FqJIe1abOAA1PsuQ4wqO87ZaOoprg09Tn8c= +go.etcd.io/etcd/server/v3 v3.6.8/go.mod h1:88dCtwUnSirkUoJbflQxxWXqtBSZa6lSG0Kuej+dois= +go.etcd.io/etcd/tests/v3 v3.6.8/go.mod h1:U1ioDy7TXzz2UXhSQfbJ3++PsryNwiniHtdbXZPprX0= +go.etcd.io/etcd/v3 v3.6.8/go.mod h1:syLTueu7AV0Pw/TcOTHEeWOtcAD/xFnnXB0gukO92Vc= +go.etcd.io/gofail v0.2.0/go.mod h1:nL3ILMGfkXTekKI3clMBNazKnjUZjYLKmBHzsVAnC1o= +go.etcd.io/raft/v3 v3.6.0/go.mod h1:nLvLevg6+xrVtHUmVaTcTz603gQPHfh7kUAwV6YpfGo= +go.lsp.dev/jsonrpc2 v0.10.0/go.mod h1:fmEzIdXPi/rf6d4uFcayi8HpFP1nBF99ERP1htC72Ac= +go.lsp.dev/pkg v0.0.0-20210717090340-384b27a52fb2/go.mod h1:gtSHRuYfbCT0qnbLnovpie/WEmqyJ7T4n6VXiFMBtcw= +go.lsp.dev/protocol v0.12.0/go.mod h1:Qb11/HgZQ72qQbeyPfJbu3hZBH23s1sr4st8czGeDMQ= +go.lsp.dev/uri v0.3.0/go.mod h1:P5sbO1IQR+qySTWOCnhnK7phBx+W3zbLqSMDJNTw88I= +go.mongodb.org/mongo-driver v1.17.6/go.mod h1:Hy04i7O2kC4RS06ZrhPRqj/u4DTYkFDAAccj+rVKqgQ= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/detectors/gcp v1.40.0/go.mod h1:99OY9ZCqyLkzJLTh5XhECpLRSxcZl+ZDKBEO+jMBFR4= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= +go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0/go.mod h1:ri3aaHSmCTVYu2AWv44YMauwAQc0aqI9gHKIcSbI1pU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= +go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= +go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= +go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= +go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= +go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +gocloud.dev v0.45.0/go.mod h1:0kXKmkCLG6d31N7NyLZWzt7jDSQura9zD/mWgiB6THI= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.0.0-20220722155217-630584e8d5aa/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= +golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= +golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= +golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM= +golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.0.0-20220607020251-c690dde0001d/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= +golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/telemetry v0.0.0-20260409153401-be6f6cb8b1fa/go.mod h1:kHjTxDEnAu6/Nl9lDkzjWpR+bmKfxeiRuSDlsMb70gE= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.15.0/go.mod h1:BDl952bC7+uMoWR75FIrCDx79TPU9oHkTZ9yRbYOrX0= +golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= +golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= +golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= +golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI= +golang.org/x/tools/go/expect v0.1.1-deprecated h1:jpBZDwmgPhXsKZC6WhL20P4b/wmnpsEAGHaNy0n/rJM= +golang.org/x/tools/go/expect v0.1.1-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM= +golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated/go.mod h1:RVAQXBGNv1ib0J382/DPCRS/BPnsGebyM1Gj5VSDpG8= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20220517211312-f3a8303e98df/go.mod h1:K8+ghG5WaK9qNqU5K3HdILfMLy1f3aNYFI/wnl100a8= +golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= +gomodules.xyz/jsonpatch/v2 v2.5.0 h1:JELs8RLM12qJGXU4u/TO3V25KW8GreMKl9pdkk14RM0= +gomodules.xyz/jsonpatch/v2 v2.5.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/api v0.272.0 h1:eLUQZGnAS3OHn31URRf9sAmRk3w2JjMx37d2k8AjJmA= +google.golang.org/api v0.272.0/go.mod h1:wKjowi5LNJc5qarNvDCvNQBn3rVK8nSy6jg2SwRwzIA= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5 h1:JNfk58HZ8lfmXbYK2vx/UvsqIL59TzByCxPIX4TDmsE= +google.golang.org/genproto v0.0.0-20260316180232-0b37fe3546d5/go.mod h1:x5julN69+ED4PcFk/XWayw35O0lf/nGa4aNgODCmNmw= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA= +google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= +google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.6.1/go.mod h1:YNKnb2OAApgYn2oYY47Rn7alMr1zWjb2U8Q0aoGWiNc= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/evanphx/json-patch.v4 v4.13.0 h1:czT3CmqEaQ1aanPc5SdlgQrrEIb8w/wwCvWWnfEbYzo= +gopkg.in/evanphx/json-patch.v4 v4.13.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/go-jose/go-jose.v2 v2.6.3/go.mod h1:zzZDPkNNw/c9IE7Z9jr11mBZQhKQTMzoEEIoEdZlFBI= +gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= +gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.67.1/go.mod h1:x/cyOwCgZqOkJoDIJ3c1KNHMo10+nLGAhh+kn3Zizss= +gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= +gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gotest.tools/v3 v3.1.0 h1:rVV8Tcg/8jHUkPUorwjaMTtemIMVXfIPKiOqnhEhakk= +gotest.tools/v3 v3.1.0/go.mod h1:fHy7eyTmJFO5bQbUsEGQ1v4m2J3Jz9eWL54TP2/ZuYQ= +k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= +k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= +k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= +k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= +k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= +k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= +k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= +k8s.io/cli-runtime v0.34.1 h1:btlgAgTrYd4sk8vJTRG6zVtqBKt9ZMDeQZo2PIzbL7M= +k8s.io/cli-runtime v0.34.1/go.mod h1:aVA65c+f0MZiMUPbseU/M9l1Wo2byeaGwUuQEQVVveE= +k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= +k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= +k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= +k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= +k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= +k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= +k8s.io/gengo/v2 v2.0.0-20251215205346-5ee0d033ba5b h1:0YkdvW3rX2vaBWsqCGZAekxPRwaI5NuYNprOsMNVLns= +k8s.io/gengo/v2 v2.0.0-20251215205346-5ee0d033ba5b/go.mod h1:yvyl3l9E+UxlqOMUULdKTAYB0rEhsmjr7+2Vb/1pCSo= +k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= +k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= +k8s.io/kms v0.35.0/go.mod h1:VT+4ekZAdrZDMgShK37vvlyHUVhwI9t/9tvh0AyCWmQ= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= +k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= +k8s.io/kubectl v0.34.1/go.mod h1:JRYlhJpGPyk3dEmJ+BuBiOB9/dAvnrALJEiY/C5qa6A= +k8s.io/metrics v0.34.1 h1:374Rexmp1xxgRt64Bi0TsjAM8cA/Y8skwCoPdjtIslE= +k8s.io/metrics v0.34.1/go.mod h1:Drf5kPfk2NJrlpcNdSiAAHn/7Y9KqxpRNagByM7Ei80= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= +k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +mvdan.cc/xurls/v2 v2.6.0/go.mod h1:bCvEZ1XvdA6wDnxY7jPPjEmigDtvtvPXAD/Exa9IMSk= +pluginrpc.com/pluginrpc v0.5.0/go.mod h1:UNWZ941hcVAoOZUn8YZsMmOZBzbUjQa3XMns8RQLp9o= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= +sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= +sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= +sigs.k8s.io/controller-runtime v0.23.1/go.mod h1:B6COOxKptp+YaUT5q4l6LqUJTRpizbgf9KSRNdQGns0= +sigs.k8s.io/controller-tools v0.20.0 h1:VWZF71pwSQ2lZZCt7hFGJsOfDc5dVG28/IysjjMWXL8= +sigs.k8s.io/controller-tools v0.20.0/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= +sigs.k8s.io/e2e-framework v0.6.0/go.mod h1:IREnCHnKgRCioLRmNi0hxSJ1kJ+aAdjEKK/gokcZu4k= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= +sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kind v0.30.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= +sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= +sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= +sigs.k8s.io/release-utils v0.12.4/go.mod h1:Tc3iM9DVM3W9oJu/6rEI+LnREuhy8lZ7wInQhRBtUoo= +sigs.k8s.io/structured-merge-diff/v4 v4.5.0/go.mod h1:N8f93tFZh9U6vpxwRArLiikrE5/2tiu1w1AGfACIGE4= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= +sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482/go.mod h1:M3W8sfWvn2HhQDIbGWj3S099YozAsymCo/wrT5ohRUE= +sigs.k8s.io/yaml v1.6.0 h1:G8fkbMSAFqgEFgh4b1wmtzDnioxFCUgTZhlbj5P9QYs= +sigs.k8s.io/yaml v1.6.0/go.mod h1:796bPqUfzR/0jLAl6XjHl3Ck7MiyVv8dbTdyT3/pMf4= +software.sslmate.com/src/go-pkcs12 v0.4.0 h1:H2g08FrTvSFKUj+D309j1DPfk5APnIdAQAB8aEykJ5k= +software.sslmate.com/src/go-pkcs12 v0.4.0/go.mod h1:Qiz0EyvDRJjjxGyUQa2cCNZn/wMyzrRJ/qcDXOQazLI= diff --git a/gomod2nix.toml b/gomod2nix.toml index 384520b..546278b 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -1,7 +1,742 @@ schema = 3 -cachePackages = ["google.golang.org/protobuf/encoding/protojson", "google.golang.org/protobuf/encoding/prototext", "google.golang.org/protobuf/encoding/protowire", "google.golang.org/protobuf/proto", "google.golang.org/protobuf/reflect/protoreflect", "google.golang.org/protobuf/reflect/protoregistry", "google.golang.org/protobuf/runtime/protoiface", "google.golang.org/protobuf/runtime/protoimpl", "google.golang.org/protobuf/types/known/structpb", "google.golang.org/protobuf/types/known/timestamppb"] +cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario.cat/mergo", "github.com/Azure/azure-sdk-for-go/services/preview/containerregistry/runtime/2019-08-15-preview/containerregistry", "github.com/Azure/azure-sdk-for-go/version", "github.com/Azure/go-autorest/autorest", "github.com/Azure/go-autorest/autorest/adal", "github.com/Azure/go-autorest/autorest/azure", "github.com/Azure/go-autorest/autorest/azure/auth", "github.com/Azure/go-autorest/autorest/azure/cli", "github.com/Azure/go-autorest/autorest/date", "github.com/Azure/go-autorest/logger", "github.com/Azure/go-autorest/tracing", "github.com/Masterminds/semver/v3", "github.com/ProtonMail/go-crypto/bitcurves", "github.com/ProtonMail/go-crypto/brainpool", "github.com/ProtonMail/go-crypto/eax", "github.com/ProtonMail/go-crypto/ocb", "github.com/ProtonMail/go-crypto/openpgp", "github.com/ProtonMail/go-crypto/openpgp/aes/keywrap", "github.com/ProtonMail/go-crypto/openpgp/armor", "github.com/ProtonMail/go-crypto/openpgp/ecdh", "github.com/ProtonMail/go-crypto/openpgp/ecdsa", "github.com/ProtonMail/go-crypto/openpgp/ed25519", "github.com/ProtonMail/go-crypto/openpgp/ed448", "github.com/ProtonMail/go-crypto/openpgp/eddsa", "github.com/ProtonMail/go-crypto/openpgp/elgamal", "github.com/ProtonMail/go-crypto/openpgp/errors", "github.com/ProtonMail/go-crypto/openpgp/packet", "github.com/ProtonMail/go-crypto/openpgp/s2k", "github.com/ProtonMail/go-crypto/openpgp/x25519", "github.com/ProtonMail/go-crypto/openpgp/x448", "github.com/alecthomas/kong", "github.com/antlr4-go/antlr/v4", "github.com/asaskevich/govalidator", "github.com/aws/aws-sdk-go-v2/aws", "github.com/aws/aws-sdk-go-v2/aws/defaults", "github.com/aws/aws-sdk-go-v2/aws/middleware", "github.com/aws/aws-sdk-go-v2/aws/protocol/query", "github.com/aws/aws-sdk-go-v2/aws/protocol/restjson", "github.com/aws/aws-sdk-go-v2/aws/protocol/xml", "github.com/aws/aws-sdk-go-v2/aws/ratelimit", "github.com/aws/aws-sdk-go-v2/aws/retry", "github.com/aws/aws-sdk-go-v2/aws/signer/v4", "github.com/aws/aws-sdk-go-v2/aws/transport/http", "github.com/aws/aws-sdk-go-v2/config", "github.com/aws/aws-sdk-go-v2/credentials", "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds", "github.com/aws/aws-sdk-go-v2/credentials/endpointcreds", "github.com/aws/aws-sdk-go-v2/credentials/logincreds", "github.com/aws/aws-sdk-go-v2/credentials/processcreds", "github.com/aws/aws-sdk-go-v2/credentials/ssocreds", "github.com/aws/aws-sdk-go-v2/credentials/stscreds", "github.com/aws/aws-sdk-go-v2/feature/ec2/imds", "github.com/aws/aws-sdk-go-v2/service/ecr", "github.com/aws/aws-sdk-go-v2/service/ecr/types", "github.com/aws/aws-sdk-go-v2/service/ecrpublic", "github.com/aws/aws-sdk-go-v2/service/ecrpublic/types", "github.com/aws/aws-sdk-go-v2/service/signin", "github.com/aws/aws-sdk-go-v2/service/signin/types", "github.com/aws/aws-sdk-go-v2/service/sso", "github.com/aws/aws-sdk-go-v2/service/sso/types", "github.com/aws/aws-sdk-go-v2/service/ssooidc", "github.com/aws/aws-sdk-go-v2/service/ssooidc/types", "github.com/aws/aws-sdk-go-v2/service/sts", "github.com/aws/aws-sdk-go-v2/service/sts/types", "github.com/aws/smithy-go", "github.com/aws/smithy-go/auth", "github.com/aws/smithy-go/auth/bearer", "github.com/aws/smithy-go/context", "github.com/aws/smithy-go/document", "github.com/aws/smithy-go/encoding", "github.com/aws/smithy-go/encoding/httpbinding", "github.com/aws/smithy-go/encoding/json", "github.com/aws/smithy-go/encoding/xml", "github.com/aws/smithy-go/endpoints", "github.com/aws/smithy-go/endpoints/private/rulesfn", "github.com/aws/smithy-go/io", "github.com/aws/smithy-go/logging", "github.com/aws/smithy-go/metrics", "github.com/aws/smithy-go/middleware", "github.com/aws/smithy-go/private/requestcompression", "github.com/aws/smithy-go/ptr", "github.com/aws/smithy-go/rand", "github.com/aws/smithy-go/time", "github.com/aws/smithy-go/tracing", "github.com/aws/smithy-go/transport/http", "github.com/aws/smithy-go/waiter", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/api", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cache", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/config", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/version", "github.com/aymanbagabas/go-osc52/v2", "github.com/beorn7/perks/quantile", "github.com/blang/semver", "github.com/blang/semver/v4", "github.com/cenkalti/backoff/v5", "github.com/cespare/xxhash/v2", "github.com/charmbracelet/bubbletea", "github.com/charmbracelet/colorprofile", "github.com/charmbracelet/lipgloss", "github.com/charmbracelet/x/ansi", "github.com/charmbracelet/x/ansi/parser", "github.com/charmbracelet/x/cellbuf", "github.com/charmbracelet/x/term", "github.com/chrismellard/docker-credential-acr-env/pkg/credhelper", "github.com/chrismellard/docker-credential-acr-env/pkg/registry", "github.com/chrismellard/docker-credential-acr-env/pkg/token", "github.com/cloudflare/circl/dh/x25519", "github.com/cloudflare/circl/dh/x448", "github.com/cloudflare/circl/ecc/goldilocks", "github.com/cloudflare/circl/math", "github.com/cloudflare/circl/math/fp25519", "github.com/cloudflare/circl/math/fp448", "github.com/cloudflare/circl/math/mlsbset", "github.com/cloudflare/circl/sign", "github.com/cloudflare/circl/sign/ed25519", "github.com/cloudflare/circl/sign/ed448", "github.com/containerd/errdefs", "github.com/containerd/errdefs/pkg/errhttp", "github.com/containerd/stargz-snapshotter/estargz", "github.com/containerd/stargz-snapshotter/estargz/errorutil", "github.com/coreos/go-oidc/v3/oidc", "github.com/crossplane/crossplane-runtime/v2/pkg/errors", "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath", "github.com/crossplane/crossplane-runtime/v2/pkg/logging", "github.com/crossplane/crossplane-runtime/v2/pkg/meta", "github.com/crossplane/crossplane-runtime/v2/pkg/resource", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/claim", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/reference", "github.com/crossplane/crossplane-runtime/v2/pkg/test", "github.com/crossplane/crossplane-runtime/v2/pkg/version", "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/examples", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/yaml", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/signature", "github.com/crossplane/crossplane/apis/v2/apiextensions/v1", "github.com/crossplane/crossplane/apis/v2/apiextensions/v1alpha1", "github.com/crossplane/crossplane/apis/v2/apiextensions/v1beta1", "github.com/crossplane/crossplane/apis/v2/apiextensions/v2", "github.com/crossplane/crossplane/apis/v2/core/v2", "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1", "github.com/crossplane/crossplane/apis/v2/pkg", "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1", "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1alpha1", "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1beta1", "github.com/crossplane/crossplane/apis/v2/pkg/v1", "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1", "github.com/crossplane/crossplane/v2/proto/fn/v1", "github.com/crossplane/function-sdk-go/errors", "github.com/crossplane/function-sdk-go/resource", "github.com/crossplane/function-sdk-go/resource/composed", "github.com/crossplane/function-sdk-go/resource/composite", "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer", "github.com/cyphar/filepath-securejoin", "github.com/davecgh/go-spew/spew", "github.com/digitorus/pkcs7", "github.com/digitorus/timestamp", "github.com/dimchansky/utfbom", "github.com/distribution/reference", "github.com/docker/cli/cli/config", "github.com/docker/cli/cli/config/configfile", "github.com/docker/cli/cli/config/credentials", "github.com/docker/cli/cli/config/memorystore", "github.com/docker/cli/cli/config/types", "github.com/docker/distribution/registry/client/auth/challenge", "github.com/docker/docker-credential-helpers/client", "github.com/docker/docker-credential-helpers/credentials", "github.com/docker/docker/api", "github.com/docker/docker/api/types", "github.com/docker/docker/api/types/blkiodev", "github.com/docker/docker/api/types/build", "github.com/docker/docker/api/types/checkpoint", "github.com/docker/docker/api/types/common", "github.com/docker/docker/api/types/container", "github.com/docker/docker/api/types/events", "github.com/docker/docker/api/types/filters", "github.com/docker/docker/api/types/image", "github.com/docker/docker/api/types/mount", "github.com/docker/docker/api/types/network", "github.com/docker/docker/api/types/registry", "github.com/docker/docker/api/types/storage", "github.com/docker/docker/api/types/strslice", "github.com/docker/docker/api/types/swarm", "github.com/docker/docker/api/types/swarm/runtime", "github.com/docker/docker/api/types/system", "github.com/docker/docker/api/types/time", "github.com/docker/docker/api/types/versions", "github.com/docker/docker/api/types/volume", "github.com/docker/docker/client", "github.com/docker/docker/pkg/stdcopy", "github.com/docker/go-connections/nat", "github.com/docker/go-connections/sockets", "github.com/docker/go-connections/tlsconfig", "github.com/docker/go-units", "github.com/dustin/go-humanize", "github.com/emicklei/dot", "github.com/emicklei/go-restful/v3", "github.com/emicklei/go-restful/v3/log", "github.com/emirpasic/gods/containers", "github.com/emirpasic/gods/lists", "github.com/emirpasic/gods/lists/arraylist", "github.com/emirpasic/gods/trees", "github.com/emirpasic/gods/trees/binaryheap", "github.com/emirpasic/gods/utils", "github.com/evanphx/json-patch/v5", "github.com/felixge/httpsnoop", "github.com/fsnotify/fsnotify", "github.com/fxamacker/cbor/v2", "github.com/go-chi/chi/v5", "github.com/go-chi/chi/v5/middleware", "github.com/go-git/gcfg", "github.com/go-git/gcfg/scanner", "github.com/go-git/gcfg/token", "github.com/go-git/gcfg/types", "github.com/go-git/go-billy/v5", "github.com/go-git/go-billy/v5/helper/chroot", "github.com/go-git/go-billy/v5/helper/polyfill", "github.com/go-git/go-billy/v5/osfs", "github.com/go-git/go-billy/v5/util", "github.com/go-git/go-git/v5", "github.com/go-git/go-git/v5/config", "github.com/go-git/go-git/v5/plumbing", "github.com/go-git/go-git/v5/plumbing/cache", "github.com/go-git/go-git/v5/plumbing/color", "github.com/go-git/go-git/v5/plumbing/filemode", "github.com/go-git/go-git/v5/plumbing/format/config", "github.com/go-git/go-git/v5/plumbing/format/diff", "github.com/go-git/go-git/v5/plumbing/format/gitignore", "github.com/go-git/go-git/v5/plumbing/format/idxfile", "github.com/go-git/go-git/v5/plumbing/format/index", "github.com/go-git/go-git/v5/plumbing/format/objfile", "github.com/go-git/go-git/v5/plumbing/format/packfile", "github.com/go-git/go-git/v5/plumbing/format/pktline", "github.com/go-git/go-git/v5/plumbing/hash", "github.com/go-git/go-git/v5/plumbing/object", "github.com/go-git/go-git/v5/plumbing/protocol/packp", "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability", "github.com/go-git/go-git/v5/plumbing/protocol/packp/sideband", "github.com/go-git/go-git/v5/plumbing/revlist", "github.com/go-git/go-git/v5/plumbing/storer", "github.com/go-git/go-git/v5/plumbing/transport", "github.com/go-git/go-git/v5/plumbing/transport/client", "github.com/go-git/go-git/v5/plumbing/transport/file", "github.com/go-git/go-git/v5/plumbing/transport/git", "github.com/go-git/go-git/v5/plumbing/transport/http", "github.com/go-git/go-git/v5/plumbing/transport/server", "github.com/go-git/go-git/v5/plumbing/transport/ssh", "github.com/go-git/go-git/v5/storage", "github.com/go-git/go-git/v5/storage/filesystem", "github.com/go-git/go-git/v5/storage/filesystem/dotgit", "github.com/go-git/go-git/v5/storage/memory", "github.com/go-git/go-git/v5/utils/binary", "github.com/go-git/go-git/v5/utils/diff", "github.com/go-git/go-git/v5/utils/ioutil", "github.com/go-git/go-git/v5/utils/merkletrie", "github.com/go-git/go-git/v5/utils/merkletrie/filesystem", "github.com/go-git/go-git/v5/utils/merkletrie/index", "github.com/go-git/go-git/v5/utils/merkletrie/noder", "github.com/go-git/go-git/v5/utils/sync", "github.com/go-git/go-git/v5/utils/trace", "github.com/go-jose/go-jose/v4", "github.com/go-jose/go-jose/v4/cipher", "github.com/go-jose/go-jose/v4/json", "github.com/go-json-experiment/json", "github.com/go-json-experiment/json/jsontext", "github.com/go-logr/logr", "github.com/go-logr/logr/funcr", "github.com/go-logr/logr/slogr", "github.com/go-logr/stdr", "github.com/go-logr/zapr", "github.com/go-openapi/analysis", "github.com/go-openapi/errors", "github.com/go-openapi/jsonpointer", "github.com/go-openapi/jsonreference", "github.com/go-openapi/loads", "github.com/go-openapi/runtime", "github.com/go-openapi/runtime/client", "github.com/go-openapi/runtime/logger", "github.com/go-openapi/runtime/middleware", "github.com/go-openapi/runtime/middleware/denco", "github.com/go-openapi/runtime/middleware/header", "github.com/go-openapi/runtime/middleware/untyped", "github.com/go-openapi/runtime/security", "github.com/go-openapi/runtime/yamlpc", "github.com/go-openapi/spec", "github.com/go-openapi/strfmt", "github.com/go-openapi/swag", "github.com/go-openapi/swag/cmdutils", "github.com/go-openapi/swag/conv", "github.com/go-openapi/swag/fileutils", "github.com/go-openapi/swag/jsonname", "github.com/go-openapi/swag/jsonutils", "github.com/go-openapi/swag/jsonutils/adapters", "github.com/go-openapi/swag/jsonutils/adapters/ifaces", "github.com/go-openapi/swag/jsonutils/adapters/stdlib/json", "github.com/go-openapi/swag/loading", "github.com/go-openapi/swag/mangling", "github.com/go-openapi/swag/netutils", "github.com/go-openapi/swag/stringutils", "github.com/go-openapi/swag/typeutils", "github.com/go-openapi/swag/yamlutils", "github.com/go-openapi/validate", "github.com/go-viper/mapstructure/v2", "github.com/gogo/protobuf/proto", "github.com/gogo/protobuf/sortkeys", "github.com/golang-jwt/jwt/v4", "github.com/golang/groupcache/lru", "github.com/golang/snappy", "github.com/google/btree", "github.com/google/cel-go/cel", "github.com/google/cel-go/checker", "github.com/google/cel-go/checker/decls", "github.com/google/cel-go/common", "github.com/google/cel-go/common/ast", "github.com/google/cel-go/common/containers", "github.com/google/cel-go/common/debug", "github.com/google/cel-go/common/decls", "github.com/google/cel-go/common/env", "github.com/google/cel-go/common/functions", "github.com/google/cel-go/common/operators", "github.com/google/cel-go/common/overloads", "github.com/google/cel-go/common/runes", "github.com/google/cel-go/common/stdlib", "github.com/google/cel-go/common/types", "github.com/google/cel-go/common/types/pb", "github.com/google/cel-go/common/types/ref", "github.com/google/cel-go/common/types/traits", "github.com/google/cel-go/ext", "github.com/google/cel-go/interpreter", "github.com/google/cel-go/interpreter/functions", "github.com/google/cel-go/parser", "github.com/google/cel-go/parser/gen", "github.com/google/certificate-transparency-go", "github.com/google/certificate-transparency-go/asn1", "github.com/google/certificate-transparency-go/client", "github.com/google/certificate-transparency-go/client/configpb", "github.com/google/certificate-transparency-go/ctutil", "github.com/google/certificate-transparency-go/gossip/minimal/x509ext", "github.com/google/certificate-transparency-go/jsonclient", "github.com/google/certificate-transparency-go/loglist3", "github.com/google/certificate-transparency-go/tls", "github.com/google/certificate-transparency-go/x509", "github.com/google/certificate-transparency-go/x509/pkix", "github.com/google/certificate-transparency-go/x509util", "github.com/google/gnostic-models/compiler", "github.com/google/gnostic-models/extensions", "github.com/google/gnostic-models/jsonschema", "github.com/google/gnostic-models/openapiv2", "github.com/google/gnostic-models/openapiv3", "github.com/google/go-cmp/cmp", "github.com/google/go-cmp/cmp/cmpopts", "github.com/google/go-containerregistry/pkg/authn", "github.com/google/go-containerregistry/pkg/authn/k8schain", "github.com/google/go-containerregistry/pkg/authn/kubernetes", "github.com/google/go-containerregistry/pkg/compression", "github.com/google/go-containerregistry/pkg/crane", "github.com/google/go-containerregistry/pkg/legacy", "github.com/google/go-containerregistry/pkg/legacy/tarball", "github.com/google/go-containerregistry/pkg/logs", "github.com/google/go-containerregistry/pkg/name", "github.com/google/go-containerregistry/pkg/v1", "github.com/google/go-containerregistry/pkg/v1/daemon", "github.com/google/go-containerregistry/pkg/v1/empty", "github.com/google/go-containerregistry/pkg/v1/google", "github.com/google/go-containerregistry/pkg/v1/layout", "github.com/google/go-containerregistry/pkg/v1/match", "github.com/google/go-containerregistry/pkg/v1/mutate", "github.com/google/go-containerregistry/pkg/v1/partial", "github.com/google/go-containerregistry/pkg/v1/random", "github.com/google/go-containerregistry/pkg/v1/remote", "github.com/google/go-containerregistry/pkg/v1/remote/transport", "github.com/google/go-containerregistry/pkg/v1/static", "github.com/google/go-containerregistry/pkg/v1/stream", "github.com/google/go-containerregistry/pkg/v1/tarball", "github.com/google/go-containerregistry/pkg/v1/types", "github.com/google/uuid", "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options", "github.com/grpc-ecosystem/grpc-gateway/v2/runtime", "github.com/grpc-ecosystem/grpc-gateway/v2/utilities", "github.com/hashicorp/errwrap", "github.com/hashicorp/go-cleanhttp", "github.com/hashicorp/go-multierror", "github.com/hashicorp/go-retryablehttp", "github.com/in-toto/attestation/go/v1", "github.com/in-toto/in-toto-golang/in_toto", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.1", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1", "github.com/jbenet/go-context/io", "github.com/jedisct1/go-minisign", "github.com/json-iterator/go", "github.com/kevinburke/ssh_config", "github.com/klauspost/compress", "github.com/klauspost/compress/fse", "github.com/klauspost/compress/huff0", "github.com/klauspost/compress/zstd", "github.com/letsencrypt/boulder/core", "github.com/letsencrypt/boulder/core/proto", "github.com/letsencrypt/boulder/goodkey", "github.com/letsencrypt/boulder/identifier", "github.com/letsencrypt/boulder/probs", "github.com/letsencrypt/boulder/revocation", "github.com/liggitt/tabwriter", "github.com/lucasb-eyer/go-colorful", "github.com/mattn/go-isatty", "github.com/mattn/go-runewidth", "github.com/mitchellh/go-homedir", "github.com/moby/docker-image-spec/specs-go/v1", "github.com/moby/term", "github.com/modern-go/concurrent", "github.com/modern-go/reflect2", "github.com/muesli/ansi", "github.com/muesli/ansi/compressor", "github.com/muesli/cancelreader", "github.com/muesli/termenv", "github.com/munnerz/goautoneg", "github.com/nozzle/throttler", "github.com/oklog/ulid/v2", "github.com/opencontainers/go-digest", "github.com/opencontainers/image-spec/specs-go", "github.com/opencontainers/image-spec/specs-go/v1", "github.com/pjbgf/sha1cd", "github.com/pjbgf/sha1cd/ubc", "github.com/pkg/browser", "github.com/pkg/errors", "github.com/pmezard/go-difflib/difflib", "github.com/posener/complete", "github.com/posener/complete/cmd", "github.com/posener/complete/cmd/install", "github.com/prometheus/client_golang/prometheus", "github.com/prometheus/client_golang/prometheus/collectors", "github.com/prometheus/client_golang/prometheus/promhttp", "github.com/prometheus/client_model/go", "github.com/prometheus/common/expfmt", "github.com/prometheus/common/model", "github.com/prometheus/procfs", "github.com/rivo/uniseg", "github.com/riywo/loginshell", "github.com/sassoftware/relic/lib/pkcs7", "github.com/sassoftware/relic/lib/x509tools", "github.com/secure-systems-lab/go-securesystemslib/cjson", "github.com/secure-systems-lab/go-securesystemslib/dsse", "github.com/secure-systems-lab/go-securesystemslib/encrypted", "github.com/secure-systems-lab/go-securesystemslib/signerverifier", "github.com/sergi/go-diff/diffmatchpatch", "github.com/shibumi/go-pathspec", "github.com/sigstore/cosign/v3/pkg/blob", "github.com/sigstore/cosign/v3/pkg/cosign", "github.com/sigstore/cosign/v3/pkg/cosign/attestation", "github.com/sigstore/cosign/v3/pkg/cosign/bundle", "github.com/sigstore/cosign/v3/pkg/cosign/env", "github.com/sigstore/cosign/v3/pkg/cosign/fulcioverifier/ctutil", "github.com/sigstore/cosign/v3/pkg/oci", "github.com/sigstore/cosign/v3/pkg/oci/empty", "github.com/sigstore/cosign/v3/pkg/oci/layout", "github.com/sigstore/cosign/v3/pkg/oci/remote", "github.com/sigstore/cosign/v3/pkg/oci/signed", "github.com/sigstore/cosign/v3/pkg/oci/static", "github.com/sigstore/cosign/v3/pkg/types", "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1", "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1", "github.com/sigstore/protobuf-specs/gen/pb-go/dsse", "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1", "github.com/sigstore/protobuf-specs/gen/pb-go/trustroot/v1", "github.com/sigstore/rekor-tiles/v2/pkg/client", "github.com/sigstore/rekor-tiles/v2/pkg/client/write", "github.com/sigstore/rekor-tiles/v2/pkg/generated/protobuf", "github.com/sigstore/rekor-tiles/v2/pkg/note", "github.com/sigstore/rekor-tiles/v2/pkg/types/verifier", "github.com/sigstore/rekor-tiles/v2/pkg/verify", "github.com/sigstore/rekor/pkg/client", "github.com/sigstore/rekor/pkg/generated/client", "github.com/sigstore/rekor/pkg/generated/client/entries", "github.com/sigstore/rekor/pkg/generated/client/index", "github.com/sigstore/rekor/pkg/generated/client/pubkey", "github.com/sigstore/rekor/pkg/generated/client/tlog", "github.com/sigstore/rekor/pkg/generated/models", "github.com/sigstore/rekor/pkg/log", "github.com/sigstore/rekor/pkg/pki", "github.com/sigstore/rekor/pkg/pki/identity", "github.com/sigstore/rekor/pkg/pki/minisign", "github.com/sigstore/rekor/pkg/pki/pgp", "github.com/sigstore/rekor/pkg/pki/pkcs7", "github.com/sigstore/rekor/pkg/pki/pkitypes", "github.com/sigstore/rekor/pkg/pki/ssh", "github.com/sigstore/rekor/pkg/pki/tuf", "github.com/sigstore/rekor/pkg/pki/x509", "github.com/sigstore/rekor/pkg/tle", "github.com/sigstore/rekor/pkg/types", "github.com/sigstore/rekor/pkg/types/dsse", "github.com/sigstore/rekor/pkg/types/dsse/v0.0.1", "github.com/sigstore/rekor/pkg/types/hashedrekord", "github.com/sigstore/rekor/pkg/types/hashedrekord/v0.0.1", "github.com/sigstore/rekor/pkg/types/intoto", "github.com/sigstore/rekor/pkg/types/intoto/v0.0.1", "github.com/sigstore/rekor/pkg/types/intoto/v0.0.2", "github.com/sigstore/rekor/pkg/types/rekord", "github.com/sigstore/rekor/pkg/types/rekord/v0.0.1", "github.com/sigstore/rekor/pkg/util", "github.com/sigstore/rekor/pkg/verify", "github.com/sigstore/sigstore-go/pkg/bundle", "github.com/sigstore/sigstore-go/pkg/fulcio/certificate", "github.com/sigstore/sigstore-go/pkg/root", "github.com/sigstore/sigstore-go/pkg/sign", "github.com/sigstore/sigstore-go/pkg/tlog", "github.com/sigstore/sigstore-go/pkg/tuf", "github.com/sigstore/sigstore-go/pkg/util", "github.com/sigstore/sigstore-go/pkg/verify", "github.com/sigstore/sigstore/pkg/cryptoutils", "github.com/sigstore/sigstore/pkg/cryptoutils/goodkey", "github.com/sigstore/sigstore/pkg/fulcioroots", "github.com/sigstore/sigstore/pkg/oauth", "github.com/sigstore/sigstore/pkg/oauthflow", "github.com/sigstore/sigstore/pkg/signature", "github.com/sigstore/sigstore/pkg/signature/dsse", "github.com/sigstore/sigstore/pkg/signature/options", "github.com/sigstore/sigstore/pkg/signature/payload", "github.com/sigstore/sigstore/pkg/tuf", "github.com/sigstore/timestamp-authority/v2/pkg/verification", "github.com/sirupsen/logrus", "github.com/skeema/knownhosts", "github.com/spf13/afero", "github.com/spf13/afero/mem", "github.com/spf13/cobra", "github.com/spf13/pflag", "github.com/syndtr/goleveldb/leveldb", "github.com/syndtr/goleveldb/leveldb/cache", "github.com/syndtr/goleveldb/leveldb/comparer", "github.com/syndtr/goleveldb/leveldb/errors", "github.com/syndtr/goleveldb/leveldb/filter", "github.com/syndtr/goleveldb/leveldb/iterator", "github.com/syndtr/goleveldb/leveldb/journal", "github.com/syndtr/goleveldb/leveldb/memdb", "github.com/syndtr/goleveldb/leveldb/opt", "github.com/syndtr/goleveldb/leveldb/storage", "github.com/syndtr/goleveldb/leveldb/table", "github.com/syndtr/goleveldb/leveldb/util", "github.com/theupdateframework/go-tuf", "github.com/theupdateframework/go-tuf/client", "github.com/theupdateframework/go-tuf/client/leveldbstore", "github.com/theupdateframework/go-tuf/data", "github.com/theupdateframework/go-tuf/pkg/keys", "github.com/theupdateframework/go-tuf/pkg/targets", "github.com/theupdateframework/go-tuf/sign", "github.com/theupdateframework/go-tuf/util", "github.com/theupdateframework/go-tuf/v2/metadata", "github.com/theupdateframework/go-tuf/v2/metadata/config", "github.com/theupdateframework/go-tuf/v2/metadata/fetcher", "github.com/theupdateframework/go-tuf/v2/metadata/trustedmetadata", "github.com/theupdateframework/go-tuf/v2/metadata/updater", "github.com/theupdateframework/go-tuf/verify", "github.com/titanous/rocacheck", "github.com/transparency-dev/formats/log", "github.com/transparency-dev/merkle", "github.com/transparency-dev/merkle/compact", "github.com/transparency-dev/merkle/proof", "github.com/transparency-dev/merkle/rfc6962", "github.com/vbatts/tar-split/archive/tar", "github.com/willabides/kongplete", "github.com/x448/float16", "github.com/xanzy/ssh-agent", "github.com/xo/terminfo", "go.opentelemetry.io/auto/sdk", "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", "go.opentelemetry.io/otel", "go.opentelemetry.io/otel/attribute", "go.opentelemetry.io/otel/baggage", "go.opentelemetry.io/otel/codes", "go.opentelemetry.io/otel/metric", "go.opentelemetry.io/otel/metric/embedded", "go.opentelemetry.io/otel/metric/noop", "go.opentelemetry.io/otel/propagation", "go.opentelemetry.io/otel/semconv/v1.37.0", "go.opentelemetry.io/otel/semconv/v1.40.0", "go.opentelemetry.io/otel/semconv/v1.40.0/httpconv", "go.opentelemetry.io/otel/trace", "go.opentelemetry.io/otel/trace/embedded", "go.opentelemetry.io/otel/trace/noop", "go.uber.org/multierr", "go.uber.org/zap", "go.uber.org/zap/buffer", "go.uber.org/zap/zapcore", "go.yaml.in/yaml/v2", "go.yaml.in/yaml/v3", "golang.org/x/crypto/argon2", "golang.org/x/crypto/blake2b", "golang.org/x/crypto/blowfish", "golang.org/x/crypto/cast5", "golang.org/x/crypto/chacha20", "golang.org/x/crypto/cryptobyte", "golang.org/x/crypto/cryptobyte/asn1", "golang.org/x/crypto/curve25519", "golang.org/x/crypto/ed25519", "golang.org/x/crypto/hkdf", "golang.org/x/crypto/nacl/secretbox", "golang.org/x/crypto/ocsp", "golang.org/x/crypto/openpgp", "golang.org/x/crypto/openpgp/armor", "golang.org/x/crypto/openpgp/elgamal", "golang.org/x/crypto/openpgp/errors", "golang.org/x/crypto/openpgp/packet", "golang.org/x/crypto/openpgp/s2k", "golang.org/x/crypto/pbkdf2", "golang.org/x/crypto/pkcs12", "golang.org/x/crypto/salsa20/salsa", "golang.org/x/crypto/scrypt", "golang.org/x/crypto/sha3", "golang.org/x/crypto/ssh", "golang.org/x/crypto/ssh/agent", "golang.org/x/crypto/ssh/knownhosts", "golang.org/x/crypto/ssh/terminal", "golang.org/x/exp/slices", "golang.org/x/mod/semver", "golang.org/x/mod/sumdb/note", "golang.org/x/net/context", "golang.org/x/net/http/httpguts", "golang.org/x/net/http2", "golang.org/x/net/http2/hpack", "golang.org/x/net/idna", "golang.org/x/net/proxy", "golang.org/x/net/trace", "golang.org/x/oauth2", "golang.org/x/oauth2/authhandler", "golang.org/x/oauth2/google", "golang.org/x/oauth2/google/externalaccount", "golang.org/x/oauth2/jws", "golang.org/x/oauth2/jwt", "golang.org/x/sync/errgroup", "golang.org/x/sync/singleflight", "golang.org/x/sys/cpu", "golang.org/x/sys/execabs", "golang.org/x/sys/unix", "golang.org/x/term", "golang.org/x/text/feature/plural", "golang.org/x/text/language", "golang.org/x/text/message", "golang.org/x/text/message/catalog", "golang.org/x/text/runes", "golang.org/x/text/secure/bidirule", "golang.org/x/text/transform", "golang.org/x/text/unicode/bidi", "golang.org/x/text/unicode/norm", "golang.org/x/time/rate", "gomodules.xyz/jsonpatch/v2", "google.golang.org/genproto/googleapis/api", "google.golang.org/genproto/googleapis/api/annotations", "google.golang.org/genproto/googleapis/api/expr/v1alpha1", "google.golang.org/genproto/googleapis/api/httpbody", "google.golang.org/genproto/googleapis/rpc/status", "google.golang.org/grpc", "google.golang.org/grpc/attributes", "google.golang.org/grpc/backoff", "google.golang.org/grpc/balancer", "google.golang.org/grpc/balancer/base", "google.golang.org/grpc/balancer/endpointsharding", "google.golang.org/grpc/balancer/grpclb/state", "google.golang.org/grpc/balancer/pickfirst", "google.golang.org/grpc/balancer/roundrobin", "google.golang.org/grpc/binarylog/grpc_binarylog_v1", "google.golang.org/grpc/channelz", "google.golang.org/grpc/codes", "google.golang.org/grpc/connectivity", "google.golang.org/grpc/credentials", "google.golang.org/grpc/credentials/insecure", "google.golang.org/grpc/encoding", "google.golang.org/grpc/encoding/proto", "google.golang.org/grpc/experimental/stats", "google.golang.org/grpc/grpclog", "google.golang.org/grpc/health/grpc_health_v1", "google.golang.org/grpc/keepalive", "google.golang.org/grpc/mem", "google.golang.org/grpc/metadata", "google.golang.org/grpc/peer", "google.golang.org/grpc/resolver", "google.golang.org/grpc/resolver/dns", "google.golang.org/grpc/serviceconfig", "google.golang.org/grpc/stats", "google.golang.org/grpc/status", "google.golang.org/grpc/tap", "google.golang.org/protobuf/encoding/protodelim", "google.golang.org/protobuf/encoding/protojson", "google.golang.org/protobuf/encoding/prototext", "google.golang.org/protobuf/encoding/protowire", "google.golang.org/protobuf/proto", "google.golang.org/protobuf/protoadapt", "google.golang.org/protobuf/reflect/protodesc", "google.golang.org/protobuf/reflect/protoreflect", "google.golang.org/protobuf/reflect/protoregistry", "google.golang.org/protobuf/runtime/protoiface", "google.golang.org/protobuf/runtime/protoimpl", "google.golang.org/protobuf/testing/protocmp", "google.golang.org/protobuf/types/descriptorpb", "google.golang.org/protobuf/types/dynamicpb", "google.golang.org/protobuf/types/gofeaturespb", "google.golang.org/protobuf/types/known/anypb", "google.golang.org/protobuf/types/known/durationpb", "google.golang.org/protobuf/types/known/emptypb", "google.golang.org/protobuf/types/known/fieldmaskpb", "google.golang.org/protobuf/types/known/structpb", "google.golang.org/protobuf/types/known/timestamppb", "google.golang.org/protobuf/types/known/wrapperspb", "gopkg.in/evanphx/json-patch.v4", "gopkg.in/inf.v0", "gopkg.in/warnings.v0", "gopkg.in/yaml.v3", "k8s.io/api/admission/v1", "k8s.io/api/admission/v1beta1", "k8s.io/api/admissionregistration/v1", "k8s.io/api/admissionregistration/v1alpha1", "k8s.io/api/admissionregistration/v1beta1", "k8s.io/api/apidiscovery/v2", "k8s.io/api/apidiscovery/v2beta1", "k8s.io/api/apiserverinternal/v1alpha1", "k8s.io/api/apps/v1", "k8s.io/api/apps/v1beta1", "k8s.io/api/apps/v1beta2", "k8s.io/api/authentication/v1", "k8s.io/api/authentication/v1alpha1", "k8s.io/api/authentication/v1beta1", "k8s.io/api/authorization/v1", "k8s.io/api/authorization/v1beta1", "k8s.io/api/autoscaling/v1", "k8s.io/api/autoscaling/v2", "k8s.io/api/autoscaling/v2beta1", "k8s.io/api/autoscaling/v2beta2", "k8s.io/api/batch/v1", "k8s.io/api/batch/v1beta1", "k8s.io/api/certificates/v1", "k8s.io/api/certificates/v1alpha1", "k8s.io/api/certificates/v1beta1", "k8s.io/api/coordination/v1", "k8s.io/api/coordination/v1alpha2", "k8s.io/api/coordination/v1beta1", "k8s.io/api/core/v1", "k8s.io/api/discovery/v1", "k8s.io/api/discovery/v1beta1", "k8s.io/api/events/v1", "k8s.io/api/events/v1beta1", "k8s.io/api/extensions/v1beta1", "k8s.io/api/flowcontrol/v1", "k8s.io/api/flowcontrol/v1beta1", "k8s.io/api/flowcontrol/v1beta2", "k8s.io/api/flowcontrol/v1beta3", "k8s.io/api/networking/v1", "k8s.io/api/networking/v1beta1", "k8s.io/api/node/v1", "k8s.io/api/node/v1alpha1", "k8s.io/api/node/v1beta1", "k8s.io/api/policy/v1", "k8s.io/api/policy/v1beta1", "k8s.io/api/rbac/v1", "k8s.io/api/rbac/v1alpha1", "k8s.io/api/rbac/v1beta1", "k8s.io/api/resource/v1", "k8s.io/api/resource/v1alpha3", "k8s.io/api/resource/v1beta1", "k8s.io/api/resource/v1beta2", "k8s.io/api/scheduling/v1", "k8s.io/api/scheduling/v1alpha1", "k8s.io/api/scheduling/v1beta1", "k8s.io/api/storage/v1", "k8s.io/api/storage/v1alpha1", "k8s.io/api/storage/v1beta1", "k8s.io/api/storagemigration/v1beta1", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning", "k8s.io/apiextensions-apiserver/pkg/apiserver/validation", "k8s.io/apiextensions-apiserver/pkg/features", "k8s.io/apimachinery/pkg/api/equality", "k8s.io/apimachinery/pkg/api/errors", "k8s.io/apimachinery/pkg/api/meta", "k8s.io/apimachinery/pkg/api/meta/testrestmapper", "k8s.io/apimachinery/pkg/api/operation", "k8s.io/apimachinery/pkg/api/resource", "k8s.io/apimachinery/pkg/api/safe", "k8s.io/apimachinery/pkg/api/validate", "k8s.io/apimachinery/pkg/api/validate/constraints", "k8s.io/apimachinery/pkg/api/validate/content", "k8s.io/apimachinery/pkg/api/validation", "k8s.io/apimachinery/pkg/api/validation/path", "k8s.io/apimachinery/pkg/apis/meta/v1", "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured", "k8s.io/apimachinery/pkg/apis/meta/v1/validation", "k8s.io/apimachinery/pkg/apis/meta/v1beta1", "k8s.io/apimachinery/pkg/conversion", "k8s.io/apimachinery/pkg/conversion/queryparams", "k8s.io/apimachinery/pkg/fields", "k8s.io/apimachinery/pkg/labels", "k8s.io/apimachinery/pkg/runtime", "k8s.io/apimachinery/pkg/runtime/schema", "k8s.io/apimachinery/pkg/runtime/serializer", "k8s.io/apimachinery/pkg/runtime/serializer/cbor", "k8s.io/apimachinery/pkg/runtime/serializer/cbor/direct", "k8s.io/apimachinery/pkg/runtime/serializer/json", "k8s.io/apimachinery/pkg/runtime/serializer/protobuf", "k8s.io/apimachinery/pkg/runtime/serializer/recognizer", "k8s.io/apimachinery/pkg/runtime/serializer/streaming", "k8s.io/apimachinery/pkg/runtime/serializer/versioning", "k8s.io/apimachinery/pkg/selection", "k8s.io/apimachinery/pkg/types", "k8s.io/apimachinery/pkg/util/cache", "k8s.io/apimachinery/pkg/util/diff", "k8s.io/apimachinery/pkg/util/dump", "k8s.io/apimachinery/pkg/util/duration", "k8s.io/apimachinery/pkg/util/errors", "k8s.io/apimachinery/pkg/util/framer", "k8s.io/apimachinery/pkg/util/intstr", "k8s.io/apimachinery/pkg/util/json", "k8s.io/apimachinery/pkg/util/managedfields", "k8s.io/apimachinery/pkg/util/mergepatch", "k8s.io/apimachinery/pkg/util/naming", "k8s.io/apimachinery/pkg/util/net", "k8s.io/apimachinery/pkg/util/rand", "k8s.io/apimachinery/pkg/util/runtime", "k8s.io/apimachinery/pkg/util/sets", "k8s.io/apimachinery/pkg/util/strategicpatch", "k8s.io/apimachinery/pkg/util/uuid", "k8s.io/apimachinery/pkg/util/validation", "k8s.io/apimachinery/pkg/util/validation/field", "k8s.io/apimachinery/pkg/util/version", "k8s.io/apimachinery/pkg/util/wait", "k8s.io/apimachinery/pkg/util/yaml", "k8s.io/apimachinery/pkg/version", "k8s.io/apimachinery/pkg/watch", "k8s.io/apimachinery/third_party/forked/golang/json", "k8s.io/apimachinery/third_party/forked/golang/reflect", "k8s.io/apiserver/pkg/apis/cel", "k8s.io/apiserver/pkg/authentication/serviceaccount", "k8s.io/apiserver/pkg/authentication/user", "k8s.io/apiserver/pkg/authorization/authorizer", "k8s.io/apiserver/pkg/cel", "k8s.io/apiserver/pkg/cel/common", "k8s.io/apiserver/pkg/cel/environment", "k8s.io/apiserver/pkg/cel/library", "k8s.io/apiserver/pkg/cel/metrics", "k8s.io/apiserver/pkg/cel/openapi", "k8s.io/apiserver/pkg/features", "k8s.io/apiserver/pkg/util/compatibility", "k8s.io/apiserver/pkg/util/feature", "k8s.io/apiserver/pkg/warning", "k8s.io/cli-runtime/pkg/printers", "k8s.io/client-go/applyconfigurations/admissionregistration/v1", "k8s.io/client-go/applyconfigurations/admissionregistration/v1alpha1", "k8s.io/client-go/applyconfigurations/admissionregistration/v1beta1", "k8s.io/client-go/applyconfigurations/apiserverinternal/v1alpha1", "k8s.io/client-go/applyconfigurations/apps/v1", "k8s.io/client-go/applyconfigurations/apps/v1beta1", "k8s.io/client-go/applyconfigurations/apps/v1beta2", "k8s.io/client-go/applyconfigurations/autoscaling/v1", "k8s.io/client-go/applyconfigurations/autoscaling/v2", "k8s.io/client-go/applyconfigurations/autoscaling/v2beta1", "k8s.io/client-go/applyconfigurations/autoscaling/v2beta2", "k8s.io/client-go/applyconfigurations/batch/v1", "k8s.io/client-go/applyconfigurations/batch/v1beta1", "k8s.io/client-go/applyconfigurations/certificates/v1", "k8s.io/client-go/applyconfigurations/certificates/v1alpha1", "k8s.io/client-go/applyconfigurations/certificates/v1beta1", "k8s.io/client-go/applyconfigurations/coordination/v1", "k8s.io/client-go/applyconfigurations/coordination/v1alpha2", "k8s.io/client-go/applyconfigurations/coordination/v1beta1", "k8s.io/client-go/applyconfigurations/core/v1", "k8s.io/client-go/applyconfigurations/discovery/v1", "k8s.io/client-go/applyconfigurations/discovery/v1beta1", "k8s.io/client-go/applyconfigurations/events/v1", "k8s.io/client-go/applyconfigurations/events/v1beta1", "k8s.io/client-go/applyconfigurations/extensions/v1beta1", "k8s.io/client-go/applyconfigurations/flowcontrol/v1", "k8s.io/client-go/applyconfigurations/flowcontrol/v1beta1", "k8s.io/client-go/applyconfigurations/flowcontrol/v1beta2", "k8s.io/client-go/applyconfigurations/flowcontrol/v1beta3", "k8s.io/client-go/applyconfigurations/meta/v1", "k8s.io/client-go/applyconfigurations/networking/v1", "k8s.io/client-go/applyconfigurations/networking/v1beta1", "k8s.io/client-go/applyconfigurations/node/v1", "k8s.io/client-go/applyconfigurations/node/v1alpha1", "k8s.io/client-go/applyconfigurations/node/v1beta1", "k8s.io/client-go/applyconfigurations/policy/v1", "k8s.io/client-go/applyconfigurations/policy/v1beta1", "k8s.io/client-go/applyconfigurations/rbac/v1", "k8s.io/client-go/applyconfigurations/rbac/v1alpha1", "k8s.io/client-go/applyconfigurations/rbac/v1beta1", "k8s.io/client-go/applyconfigurations/resource/v1", "k8s.io/client-go/applyconfigurations/resource/v1alpha3", "k8s.io/client-go/applyconfigurations/resource/v1beta1", "k8s.io/client-go/applyconfigurations/resource/v1beta2", "k8s.io/client-go/applyconfigurations/scheduling/v1", "k8s.io/client-go/applyconfigurations/scheduling/v1alpha1", "k8s.io/client-go/applyconfigurations/scheduling/v1beta1", "k8s.io/client-go/applyconfigurations/storage/v1", "k8s.io/client-go/applyconfigurations/storage/v1alpha1", "k8s.io/client-go/applyconfigurations/storage/v1beta1", "k8s.io/client-go/applyconfigurations/storagemigration/v1beta1", "k8s.io/client-go/discovery", "k8s.io/client-go/discovery/cached/memory", "k8s.io/client-go/dynamic", "k8s.io/client-go/features", "k8s.io/client-go/gentype", "k8s.io/client-go/informers", "k8s.io/client-go/informers/admissionregistration", "k8s.io/client-go/informers/admissionregistration/v1", "k8s.io/client-go/informers/admissionregistration/v1alpha1", "k8s.io/client-go/informers/admissionregistration/v1beta1", "k8s.io/client-go/informers/apiserverinternal", "k8s.io/client-go/informers/apiserverinternal/v1alpha1", "k8s.io/client-go/informers/apps", "k8s.io/client-go/informers/apps/v1", "k8s.io/client-go/informers/apps/v1beta1", "k8s.io/client-go/informers/apps/v1beta2", "k8s.io/client-go/informers/autoscaling", "k8s.io/client-go/informers/autoscaling/v1", "k8s.io/client-go/informers/autoscaling/v2", "k8s.io/client-go/informers/autoscaling/v2beta1", "k8s.io/client-go/informers/autoscaling/v2beta2", "k8s.io/client-go/informers/batch", "k8s.io/client-go/informers/batch/v1", "k8s.io/client-go/informers/batch/v1beta1", "k8s.io/client-go/informers/certificates", "k8s.io/client-go/informers/certificates/v1", "k8s.io/client-go/informers/certificates/v1alpha1", "k8s.io/client-go/informers/certificates/v1beta1", "k8s.io/client-go/informers/coordination", "k8s.io/client-go/informers/coordination/v1", "k8s.io/client-go/informers/coordination/v1alpha2", "k8s.io/client-go/informers/coordination/v1beta1", "k8s.io/client-go/informers/core", "k8s.io/client-go/informers/core/v1", "k8s.io/client-go/informers/discovery", "k8s.io/client-go/informers/discovery/v1", "k8s.io/client-go/informers/discovery/v1beta1", "k8s.io/client-go/informers/events", "k8s.io/client-go/informers/events/v1", "k8s.io/client-go/informers/events/v1beta1", "k8s.io/client-go/informers/extensions", "k8s.io/client-go/informers/extensions/v1beta1", "k8s.io/client-go/informers/flowcontrol", "k8s.io/client-go/informers/flowcontrol/v1", "k8s.io/client-go/informers/flowcontrol/v1beta1", "k8s.io/client-go/informers/flowcontrol/v1beta2", "k8s.io/client-go/informers/flowcontrol/v1beta3", "k8s.io/client-go/informers/networking", "k8s.io/client-go/informers/networking/v1", "k8s.io/client-go/informers/networking/v1beta1", "k8s.io/client-go/informers/node", "k8s.io/client-go/informers/node/v1", "k8s.io/client-go/informers/node/v1alpha1", "k8s.io/client-go/informers/node/v1beta1", "k8s.io/client-go/informers/policy", "k8s.io/client-go/informers/policy/v1", "k8s.io/client-go/informers/policy/v1beta1", "k8s.io/client-go/informers/rbac", "k8s.io/client-go/informers/rbac/v1", "k8s.io/client-go/informers/rbac/v1alpha1", "k8s.io/client-go/informers/rbac/v1beta1", "k8s.io/client-go/informers/resource", "k8s.io/client-go/informers/resource/v1", "k8s.io/client-go/informers/resource/v1alpha3", "k8s.io/client-go/informers/resource/v1beta1", "k8s.io/client-go/informers/resource/v1beta2", "k8s.io/client-go/informers/scheduling", "k8s.io/client-go/informers/scheduling/v1", "k8s.io/client-go/informers/scheduling/v1alpha1", "k8s.io/client-go/informers/scheduling/v1beta1", "k8s.io/client-go/informers/storage", "k8s.io/client-go/informers/storage/v1", "k8s.io/client-go/informers/storage/v1alpha1", "k8s.io/client-go/informers/storage/v1beta1", "k8s.io/client-go/informers/storagemigration", "k8s.io/client-go/informers/storagemigration/v1beta1", "k8s.io/client-go/kubernetes", "k8s.io/client-go/kubernetes/scheme", "k8s.io/client-go/kubernetes/typed/admissionregistration/v1", "k8s.io/client-go/kubernetes/typed/admissionregistration/v1alpha1", "k8s.io/client-go/kubernetes/typed/admissionregistration/v1beta1", "k8s.io/client-go/kubernetes/typed/apiserverinternal/v1alpha1", "k8s.io/client-go/kubernetes/typed/apps/v1", "k8s.io/client-go/kubernetes/typed/apps/v1beta1", "k8s.io/client-go/kubernetes/typed/apps/v1beta2", "k8s.io/client-go/kubernetes/typed/authentication/v1", "k8s.io/client-go/kubernetes/typed/authentication/v1alpha1", "k8s.io/client-go/kubernetes/typed/authentication/v1beta1", "k8s.io/client-go/kubernetes/typed/authorization/v1", "k8s.io/client-go/kubernetes/typed/authorization/v1beta1", "k8s.io/client-go/kubernetes/typed/autoscaling/v1", "k8s.io/client-go/kubernetes/typed/autoscaling/v2", "k8s.io/client-go/kubernetes/typed/autoscaling/v2beta1", "k8s.io/client-go/kubernetes/typed/autoscaling/v2beta2", "k8s.io/client-go/kubernetes/typed/batch/v1", "k8s.io/client-go/kubernetes/typed/batch/v1beta1", "k8s.io/client-go/kubernetes/typed/certificates/v1", "k8s.io/client-go/kubernetes/typed/certificates/v1alpha1", "k8s.io/client-go/kubernetes/typed/certificates/v1beta1", "k8s.io/client-go/kubernetes/typed/coordination/v1", "k8s.io/client-go/kubernetes/typed/coordination/v1alpha2", "k8s.io/client-go/kubernetes/typed/coordination/v1beta1", "k8s.io/client-go/kubernetes/typed/core/v1", "k8s.io/client-go/kubernetes/typed/discovery/v1", "k8s.io/client-go/kubernetes/typed/discovery/v1beta1", "k8s.io/client-go/kubernetes/typed/events/v1", "k8s.io/client-go/kubernetes/typed/events/v1beta1", "k8s.io/client-go/kubernetes/typed/extensions/v1beta1", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta1", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta2", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta3", "k8s.io/client-go/kubernetes/typed/networking/v1", "k8s.io/client-go/kubernetes/typed/networking/v1beta1", "k8s.io/client-go/kubernetes/typed/node/v1", "k8s.io/client-go/kubernetes/typed/node/v1alpha1", "k8s.io/client-go/kubernetes/typed/node/v1beta1", "k8s.io/client-go/kubernetes/typed/policy/v1", "k8s.io/client-go/kubernetes/typed/policy/v1beta1", "k8s.io/client-go/kubernetes/typed/rbac/v1", "k8s.io/client-go/kubernetes/typed/rbac/v1alpha1", "k8s.io/client-go/kubernetes/typed/rbac/v1beta1", "k8s.io/client-go/kubernetes/typed/resource/v1", "k8s.io/client-go/kubernetes/typed/resource/v1alpha3", "k8s.io/client-go/kubernetes/typed/resource/v1beta1", "k8s.io/client-go/kubernetes/typed/resource/v1beta2", "k8s.io/client-go/kubernetes/typed/scheduling/v1", "k8s.io/client-go/kubernetes/typed/scheduling/v1alpha1", "k8s.io/client-go/kubernetes/typed/scheduling/v1beta1", "k8s.io/client-go/kubernetes/typed/storage/v1", "k8s.io/client-go/kubernetes/typed/storage/v1alpha1", "k8s.io/client-go/kubernetes/typed/storage/v1beta1", "k8s.io/client-go/kubernetes/typed/storagemigration/v1beta1", "k8s.io/client-go/listers", "k8s.io/client-go/listers/admissionregistration/v1", "k8s.io/client-go/listers/admissionregistration/v1alpha1", "k8s.io/client-go/listers/admissionregistration/v1beta1", "k8s.io/client-go/listers/apiserverinternal/v1alpha1", "k8s.io/client-go/listers/apps/v1", "k8s.io/client-go/listers/apps/v1beta1", "k8s.io/client-go/listers/apps/v1beta2", "k8s.io/client-go/listers/autoscaling/v1", "k8s.io/client-go/listers/autoscaling/v2", "k8s.io/client-go/listers/autoscaling/v2beta1", "k8s.io/client-go/listers/autoscaling/v2beta2", "k8s.io/client-go/listers/batch/v1", "k8s.io/client-go/listers/batch/v1beta1", "k8s.io/client-go/listers/certificates/v1", "k8s.io/client-go/listers/certificates/v1alpha1", "k8s.io/client-go/listers/certificates/v1beta1", "k8s.io/client-go/listers/coordination/v1", "k8s.io/client-go/listers/coordination/v1alpha2", "k8s.io/client-go/listers/coordination/v1beta1", "k8s.io/client-go/listers/core/v1", "k8s.io/client-go/listers/discovery/v1", "k8s.io/client-go/listers/discovery/v1beta1", "k8s.io/client-go/listers/events/v1", "k8s.io/client-go/listers/events/v1beta1", "k8s.io/client-go/listers/extensions/v1beta1", "k8s.io/client-go/listers/flowcontrol/v1", "k8s.io/client-go/listers/flowcontrol/v1beta1", "k8s.io/client-go/listers/flowcontrol/v1beta2", "k8s.io/client-go/listers/flowcontrol/v1beta3", "k8s.io/client-go/listers/networking/v1", "k8s.io/client-go/listers/networking/v1beta1", "k8s.io/client-go/listers/node/v1", "k8s.io/client-go/listers/node/v1alpha1", "k8s.io/client-go/listers/node/v1beta1", "k8s.io/client-go/listers/policy/v1", "k8s.io/client-go/listers/policy/v1beta1", "k8s.io/client-go/listers/rbac/v1", "k8s.io/client-go/listers/rbac/v1alpha1", "k8s.io/client-go/listers/rbac/v1beta1", "k8s.io/client-go/listers/resource/v1", "k8s.io/client-go/listers/resource/v1alpha3", "k8s.io/client-go/listers/resource/v1beta1", "k8s.io/client-go/listers/resource/v1beta2", "k8s.io/client-go/listers/scheduling/v1", "k8s.io/client-go/listers/scheduling/v1alpha1", "k8s.io/client-go/listers/scheduling/v1beta1", "k8s.io/client-go/listers/storage/v1", "k8s.io/client-go/listers/storage/v1alpha1", "k8s.io/client-go/listers/storage/v1beta1", "k8s.io/client-go/listers/storagemigration/v1beta1", "k8s.io/client-go/metadata", "k8s.io/client-go/openapi", "k8s.io/client-go/openapi/cached", "k8s.io/client-go/pkg/apis/clientauthentication", "k8s.io/client-go/pkg/apis/clientauthentication/install", "k8s.io/client-go/pkg/apis/clientauthentication/v1", "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1", "k8s.io/client-go/pkg/version", "k8s.io/client-go/plugin/pkg/client/auth", "k8s.io/client-go/plugin/pkg/client/auth/azure", "k8s.io/client-go/plugin/pkg/client/auth/exec", "k8s.io/client-go/plugin/pkg/client/auth/gcp", "k8s.io/client-go/plugin/pkg/client/auth/oidc", "k8s.io/client-go/rest", "k8s.io/client-go/rest/watch", "k8s.io/client-go/restmapper", "k8s.io/client-go/testing", "k8s.io/client-go/third_party/forked/golang/template", "k8s.io/client-go/tools/auth", "k8s.io/client-go/tools/cache", "k8s.io/client-go/tools/cache/synctrack", "k8s.io/client-go/tools/clientcmd", "k8s.io/client-go/tools/clientcmd/api", "k8s.io/client-go/tools/clientcmd/api/latest", "k8s.io/client-go/tools/clientcmd/api/v1", "k8s.io/client-go/tools/events", "k8s.io/client-go/tools/leaderelection", "k8s.io/client-go/tools/leaderelection/resourcelock", "k8s.io/client-go/tools/metrics", "k8s.io/client-go/tools/pager", "k8s.io/client-go/tools/record", "k8s.io/client-go/tools/record/util", "k8s.io/client-go/tools/reference", "k8s.io/client-go/transport", "k8s.io/client-go/util/apply", "k8s.io/client-go/util/cert", "k8s.io/client-go/util/connrotation", "k8s.io/client-go/util/consistencydetector", "k8s.io/client-go/util/flowcontrol", "k8s.io/client-go/util/homedir", "k8s.io/client-go/util/jsonpath", "k8s.io/client-go/util/keyutil", "k8s.io/client-go/util/retry", "k8s.io/client-go/util/watchlist", "k8s.io/client-go/util/workqueue", "k8s.io/component-base/cli/flag", "k8s.io/component-base/compatibility", "k8s.io/component-base/featuregate", "k8s.io/component-base/metrics", "k8s.io/component-base/metrics/legacyregistry", "k8s.io/component-base/metrics/prometheus/compatversion", "k8s.io/component-base/metrics/prometheus/feature", "k8s.io/component-base/metrics/prometheusextension", "k8s.io/component-base/version", "k8s.io/component-base/zpages/features", "k8s.io/klog/v2", "k8s.io/kube-openapi/pkg/cached", "k8s.io/kube-openapi/pkg/common", "k8s.io/kube-openapi/pkg/handler3", "k8s.io/kube-openapi/pkg/schemaconv", "k8s.io/kube-openapi/pkg/spec3", "k8s.io/kube-openapi/pkg/util", "k8s.io/kube-openapi/pkg/util/proto", "k8s.io/kube-openapi/pkg/validation/errors", "k8s.io/kube-openapi/pkg/validation/spec", "k8s.io/kube-openapi/pkg/validation/strfmt", "k8s.io/kube-openapi/pkg/validation/strfmt/bson", "k8s.io/kube-openapi/pkg/validation/validate", "k8s.io/metrics/pkg/apis/metrics", "k8s.io/metrics/pkg/apis/metrics/v1alpha1", "k8s.io/metrics/pkg/apis/metrics/v1beta1", "k8s.io/metrics/pkg/client/clientset/versioned", "k8s.io/metrics/pkg/client/clientset/versioned/scheme", "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1alpha1", "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1", "k8s.io/utils/buffer", "k8s.io/utils/clock", "k8s.io/utils/lru", "k8s.io/utils/net", "k8s.io/utils/ptr", "k8s.io/utils/trace", "sigs.k8s.io/controller-runtime", "sigs.k8s.io/controller-runtime/pkg/builder", "sigs.k8s.io/controller-runtime/pkg/cache", "sigs.k8s.io/controller-runtime/pkg/certwatcher", "sigs.k8s.io/controller-runtime/pkg/certwatcher/metrics", "sigs.k8s.io/controller-runtime/pkg/client", "sigs.k8s.io/controller-runtime/pkg/client/apiutil", "sigs.k8s.io/controller-runtime/pkg/client/config", "sigs.k8s.io/controller-runtime/pkg/cluster", "sigs.k8s.io/controller-runtime/pkg/config", "sigs.k8s.io/controller-runtime/pkg/controller", "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil", "sigs.k8s.io/controller-runtime/pkg/controller/priorityqueue", "sigs.k8s.io/controller-runtime/pkg/conversion", "sigs.k8s.io/controller-runtime/pkg/event", "sigs.k8s.io/controller-runtime/pkg/handler", "sigs.k8s.io/controller-runtime/pkg/healthz", "sigs.k8s.io/controller-runtime/pkg/leaderelection", "sigs.k8s.io/controller-runtime/pkg/log", "sigs.k8s.io/controller-runtime/pkg/log/zap", "sigs.k8s.io/controller-runtime/pkg/manager", "sigs.k8s.io/controller-runtime/pkg/manager/signals", "sigs.k8s.io/controller-runtime/pkg/metrics", "sigs.k8s.io/controller-runtime/pkg/metrics/server", "sigs.k8s.io/controller-runtime/pkg/predicate", "sigs.k8s.io/controller-runtime/pkg/reconcile", "sigs.k8s.io/controller-runtime/pkg/recorder", "sigs.k8s.io/controller-runtime/pkg/scheme", "sigs.k8s.io/controller-runtime/pkg/source", "sigs.k8s.io/controller-runtime/pkg/webhook", "sigs.k8s.io/controller-runtime/pkg/webhook/admission", "sigs.k8s.io/controller-runtime/pkg/webhook/admission/metrics", "sigs.k8s.io/controller-runtime/pkg/webhook/conversion", "sigs.k8s.io/controller-runtime/pkg/webhook/conversion/metrics", "sigs.k8s.io/json", "sigs.k8s.io/randfill", "sigs.k8s.io/randfill/bytesource", "sigs.k8s.io/structured-merge-diff/v6/fieldpath", "sigs.k8s.io/structured-merge-diff/v6/merge", "sigs.k8s.io/structured-merge-diff/v6/schema", "sigs.k8s.io/structured-merge-diff/v6/typed", "sigs.k8s.io/structured-merge-diff/v6/value", "sigs.k8s.io/yaml", "sigs.k8s.io/yaml/kyaml"] [mod] + [mod."cel.dev/expr"] + version = "v0.25.1" + hash = "sha256-TEdMxFUPK7IZuCXMufwCkbN+ZZIXSQclljIybFZcByo=" + [mod."cloud.google.com/go/compute/metadata"] + version = "v0.9.0" + hash = "sha256-VFqQwLJKyH1zReR/XtygEHP5UkI01T9BHEL0hvXtauo=" + [mod."dario.cat/mergo"] + version = "v1.0.2" + hash = "sha256-p6jdiHlLEfZES8vJnDywG4aVzIe16p0CU6iglglIweA=" + [mod."github.com/Azure/azure-sdk-for-go"] + version = "v68.0.0+incompatible" + hash = "sha256-xaa9LgrrLjgbOh/XM1dZMWH/sZeGMb59gK0CTB6JUUI=" + [mod."github.com/Azure/go-ansiterm"] + version = "v0.0.0-20250102033503-faa5f7b0171c" + hash = "sha256-4WYKJtxjnm3egDAh9ocTR+gy5UUqVoY3knHy9c17XIY=" + [mod."github.com/Azure/go-autorest"] + version = "v14.2.0+incompatible" + hash = "sha256-dvWOcudtx0NP6U2RDt40hwtELFRdYdLEklRWYterRN0=" + [mod."github.com/Azure/go-autorest/autorest"] + version = "v0.11.30" + hash = "sha256-CykvDRDHHCyhIZOxbvpT/a0VEhuJmIwKw/MEjS3hmEs=" + [mod."github.com/Azure/go-autorest/autorest/adal"] + version = "v0.9.24" + hash = "sha256-itnCV0BJlMi5MHFlxePRUA/XPwofDzTksUVh7jcqarE=" + [mod."github.com/Azure/go-autorest/autorest/azure/auth"] + version = "v0.5.13" + hash = "sha256-s901woJ0T3B+1QUUOMcjz0ops2pXzZQ+x7/XEuC91Ko=" + [mod."github.com/Azure/go-autorest/autorest/azure/cli"] + version = "v0.4.7" + hash = "sha256-ljC1ag2fX8jLdlgr1wgLx66QdRHYa9VdOu0r9RFDtLo=" + [mod."github.com/Azure/go-autorest/autorest/date"] + version = "v0.3.1" + hash = "sha256-DqCnDxzYgcAPEpnlHqa+eL3msZvbkYNSMq6ftSEMSQo=" + [mod."github.com/Azure/go-autorest/logger"] + version = "v0.2.2" + hash = "sha256-fmbHaafgS17KXIXpqqChOF8qqi+lfJHZM4o+i0pmNSs=" + [mod."github.com/Azure/go-autorest/tracing"] + version = "v0.6.1" + hash = "sha256-nstDZC8Btx78yzqIR4clfu+R93rebUOZalEW1ZaQfIY=" + [mod."github.com/Masterminds/semver/v3"] + version = "v3.4.0" + hash = "sha256-75kRraVwYVjYLWZvuSlts4Iu28Eh3SpiF0GHc7vCYHI=" + [mod."github.com/Microsoft/go-winio"] + version = "v0.6.2" + hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" + [mod."github.com/ProtonMail/go-crypto"] + version = "v1.1.6" + hash = "sha256-XlFT3uxgpPYFTND54uO8fH33jtQqAHWa7zrv24nw/PE=" + [mod."github.com/alecthomas/kong"] + version = "v1.14.0" + hash = "sha256-F6BYciiFpdtjKosD+L+z0Sc2s903iD2ElbytjcM4mas=" + [mod."github.com/antlr4-go/antlr/v4"] + version = "v4.13.1" + hash = "sha256-beAuxHNRUuhzcSJUh/8ztVf1zCUiaT72fg2Jvx0AuNQ=" + [mod."github.com/asaskevich/govalidator"] + version = "v0.0.0-20230301143203-a9d515a09cc2" + hash = "sha256-UCENzt1c1tFgsAzK2TNq5s2g0tQMQ5PxFaQKe8hTL/A=" + [mod."github.com/aws/aws-sdk-go-v2"] + version = "v1.41.4" + hash = "sha256-k9xv4f8YPSzZ1yR3/zuyNDGenZKk0DD4lceL713yXtc=" + [mod."github.com/aws/aws-sdk-go-v2/config"] + version = "v1.32.12" + hash = "sha256-aTkdSRe8KPmVZdsunU8j/hZQLhGw1ckKpLN/ryRBZM0=" + [mod."github.com/aws/aws-sdk-go-v2/credentials"] + version = "v1.19.12" + hash = "sha256-xEIT1ARA9RYrQtLZIus71E6niNHIOVM1J7mUnA5AhJQ=" + [mod."github.com/aws/aws-sdk-go-v2/feature/ec2/imds"] + version = "v1.18.20" + hash = "sha256-dCTpdKZheVCSt+R+NnFOnlS0bCt4gPavlDh15Kl/sMQ=" + [mod."github.com/aws/aws-sdk-go-v2/internal/configsources"] + version = "v1.4.20" + hash = "sha256-aATIk4oLd7aaV66ereBdjINLMDwmIHxu+NNsgKWH1t4=" + [mod."github.com/aws/aws-sdk-go-v2/internal/endpoints/v2"] + version = "v2.7.20" + hash = "sha256-G6266uj64sgfDTJ9V1UY1sQs3UmryB0CFgxzmbjjChY=" + [mod."github.com/aws/aws-sdk-go-v2/internal/ini"] + version = "v1.8.6" + hash = "sha256-oIRPqu99vnGINAWKnCEytpv7N0gRWO7S72tb1r8oxvk=" + [mod."github.com/aws/aws-sdk-go-v2/service/ecr"] + version = "v1.55.3" + hash = "sha256-J9v9A2bMBTPM0K/aHM3TrS0nBkuTNFVQyqtnc1ZwE7w=" + [mod."github.com/aws/aws-sdk-go-v2/service/ecrpublic"] + version = "v1.38.10" + hash = "sha256-uJtfhtkG4pfehKHyc2dsqafvt6fKMnMgMe/uPemJrPY=" + [mod."github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding"] + version = "v1.13.7" + hash = "sha256-AfYJdpmnW01Bk/jfHATlNU6lddjqcigFkHw/zcT9WO4=" + [mod."github.com/aws/aws-sdk-go-v2/service/internal/presigned-url"] + version = "v1.13.20" + hash = "sha256-a5TifKunIoqKd2uAceYh6F1LvMHMyEQcWvJf0sxKhPM=" + [mod."github.com/aws/aws-sdk-go-v2/service/signin"] + version = "v1.0.8" + hash = "sha256-o4pWg3yMZHxdI94x5Z6qbiRg7gpmzbpJnJWsR1BOc44=" + [mod."github.com/aws/aws-sdk-go-v2/service/sso"] + version = "v1.30.13" + hash = "sha256-V277a0ikm/H0paIeDLtPGEyav2a69Kdb9d5bh+JLAeY=" + [mod."github.com/aws/aws-sdk-go-v2/service/ssooidc"] + version = "v1.35.17" + hash = "sha256-r5V5DoCIR4yzN1Ttg+dIA85GVkWMPgeD6Zu0rWGqNJE=" + [mod."github.com/aws/aws-sdk-go-v2/service/sts"] + version = "v1.41.9" + hash = "sha256-I15uxeoKxDURsZrEVDzCRtVIu/HE756M1Rt7PPpdZ7c=" + [mod."github.com/aws/smithy-go"] + version = "v1.24.2" + hash = "sha256-v0y+Lir61fgdCwdVoca5mK+FcGh9OD3cTEwHIfLytcI=" + [mod."github.com/awslabs/amazon-ecr-credential-helper/ecr-login"] + version = "v0.12.0" + hash = "sha256-PHoHWGX9RVkAQNQF2fz5Hv4JEaGBvLhcuUwlu5IQjU0=" + [mod."github.com/aymanbagabas/go-osc52/v2"] + version = "v2.0.1" + hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" + [mod."github.com/beorn7/perks"] + version = "v1.0.1" + hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" + [mod."github.com/blang/semver"] + version = "v3.5.1+incompatible" + hash = "sha256-vmoIH5J0esVFmLDT2ecwtalvJqRRoLwomysyvlIRmo8=" + [mod."github.com/blang/semver/v4"] + version = "v4.0.0" + hash = "sha256-dJC22MjnfT5WqJ7x7Tc3Bvpw9tFnBn9HqfWFiM57JVc=" + [mod."github.com/cenkalti/backoff/v5"] + version = "v5.0.3" + hash = "sha256-bKq43PPD8RM6e7HePxHaO27traqm76bkvHcTVTQ+jeY=" + [mod."github.com/cespare/xxhash/v2"] + version = "v2.3.0" + hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" + [mod."github.com/charmbracelet/bubbletea"] + version = "v1.3.10" + hash = "sha256-7wr85TLszu1CHNEMv+o4w+r24Z0xdzCgecPv+ZtRX/A=" + [mod."github.com/charmbracelet/colorprofile"] + version = "v0.2.3-0.20250311203215-f60798e515dc" + hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=" + [mod."github.com/charmbracelet/lipgloss"] + version = "v1.1.0" + hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4=" + [mod."github.com/charmbracelet/x/ansi"] + version = "v0.10.1" + hash = "sha256-nY4zkUGnuD+Lczwt+NMXdQ38cAsy5mtxzXrFSJmR0E4=" + [mod."github.com/charmbracelet/x/cellbuf"] + version = "v0.0.13-0.20250311204145-2c3ea96c31dd" + hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU=" + [mod."github.com/charmbracelet/x/term"] + version = "v0.2.1" + hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM=" + [mod."github.com/chrismellard/docker-credential-acr-env"] + version = "v0.0.0-20230304212654-82a0ddb27589" + hash = "sha256-EWyO62fm/zhWdo4/96bscr3POG/5tKsWXYqp5mTwP0Y=" + [mod."github.com/cloudflare/circl"] + version = "v1.6.3" + hash = "sha256-XZm4EastgX67Dgm5BpOEW/PY4aLcHM/O8+Xbz26PuTY=" + [mod."github.com/containerd/errdefs"] + version = "v1.0.0" + hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" + [mod."github.com/containerd/errdefs/pkg"] + version = "v0.3.0" + hash = "sha256-BILJ0Be4cc8xfvLPylc/Pvwwa+w88+Hd0njzetUCeTg=" + [mod."github.com/containerd/stargz-snapshotter/estargz"] + version = "v0.18.2" + hash = "sha256-6KS9ObQ1tKXkvvKQy1BmxJ59aisDGvEtqhj1Oo54IRY=" + [mod."github.com/coreos/go-oidc/v3"] + version = "v3.17.0" + hash = "sha256-b9dCq5GN5ac64UG23Rijv1qcmUZNcxb8DJQycAa96EQ=" + [mod."github.com/crossplane/crossplane-runtime/v2"] + version = "v2.3.0-rc.0.0.20260416145853-f43d88270996" + hash = "sha256-CsYEM4KYGLjD2LVmmoCbMAgPKfd4+DNAycb5lb7NtBA=" + [mod."github.com/crossplane/crossplane/apis/v2"] + version = "v2.0.0-20260415071903-2b072b20c4bd" + hash = "sha256-HzEq78XSJYoApLfsYT3T1jCB5w6s6ceqKRhapuFSfSI=" + [mod."github.com/crossplane/crossplane/v2"] + version = "v2.2.1" + hash = "sha256-rl0fsMXZXW9frHb4xELmqyc86awkU3fOb82UX2NBFhw=" + [mod."github.com/crossplane/function-sdk-go"] + version = "v0.6.1-0.20260422203639-1c756d23b966" + hash = "sha256-pThEiroLnSbY8R9fBx+amQm8ww1IDMqPeSPrGs5zfPQ=" + [mod."github.com/cyberphone/json-canonicalization"] + version = "v0.0.0-20241213102144-19d51d7fe467" + hash = "sha256-eqH3UKAZ9eOlZjYdN7nWuJ1hFm2JAP1PVbJInQk6OLw=" + [mod."github.com/cyphar/filepath-securejoin"] + version = "v0.4.1" + hash = "sha256-NOV6MfbkcQbfhNmfADQw2SJmZ6q1nw0wwg8Pm2tf2DM=" + [mod."github.com/davecgh/go-spew"] + version = "v1.1.2-0.20180830191138-d8f796af33cc" + hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" + [mod."github.com/digitorus/pkcs7"] + version = "v0.0.0-20230818184609-3a137a874352" + hash = "sha256-zhgLL+kS2vkOhiK3kkI6yMhr71JOYo/uuxDo1dsC2k0=" + [mod."github.com/digitorus/timestamp"] + version = "v0.0.0-20231217203849-220c5c2851b7" + hash = "sha256-uNkyMBsdbLN1PiDLHAGWUYf6sZ08ENbxpv9RkNtzaW0=" + [mod."github.com/dimchansky/utfbom"] + version = "v1.1.1" + hash = "sha256-w8KEprK54zJkMat78T6zldjDwvhbc/O8s6pVFzfmg1I=" + [mod."github.com/distribution/reference"] + version = "v0.6.0" + hash = "sha256-gr4tL+qz4jKyAtl8LINcxMSanztdt+pybj1T+2ulQv4=" + [mod."github.com/docker/cli"] + version = "v29.4.0+incompatible" + hash = "sha256-mUN7Fu9e4ahtUJBUvCHUk+ICFq1d6vs7MoJf0/cw+mA=" + [mod."github.com/docker/distribution"] + version = "v2.8.3+incompatible" + hash = "sha256-XhRURCGNpJC83QZTtgCxHHFL76HaxIxjt70HwUa847E=" + [mod."github.com/docker/docker"] + version = "v28.5.2+incompatible" + hash = "sha256-M8Q4m6vNbxKVpmEyYXwBnGinWvnN2LyH84zH73dDClE=" + [mod."github.com/docker/docker-credential-helpers"] + version = "v0.9.5" + hash = "sha256-7fm66H8bvqjiEssTy/oiAMmQd7T15aVS+EANrw+4H4U=" + [mod."github.com/docker/go-connections"] + version = "v0.6.0" + hash = "sha256-RoK/DIT7Q1Yxzbr0xcgYP7ORJgH894tjlKDp+voznGM=" + [mod."github.com/docker/go-units"] + version = "v0.5.0" + hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE=" + [mod."github.com/dustin/go-humanize"] + version = "v1.0.1" + hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" + [mod."github.com/emicklei/dot"] + version = "v1.10.0" + hash = "sha256-ovi4o/VOn4LsaqyiFIeIARDT8RcBzinMt/uV7/V459E=" + [mod."github.com/emicklei/go-restful/v3"] + version = "v3.13.0" + hash = "sha256-lB2Z29RDLiVQE5NrsV1s2iHeQ4ciGwNj5OG1zJxwZV8=" + [mod."github.com/emirpasic/gods"] + version = "v1.18.1" + hash = "sha256-hGDKddjLj+5dn2woHtXKUdd49/3xdsqnhx7VEdCu1m4=" + [mod."github.com/erikgeiser/coninput"] + version = "v0.0.0-20211004153227-1c3628e74d0f" + hash = "sha256-OWSqN1+IoL73rWXWdbbcahZu8n2al90Y3eT5Z0vgHvU=" + [mod."github.com/evanphx/json-patch/v5"] + version = "v5.9.11" + hash = "sha256-DaWzRi5dIr3U7kJlV3Qm1DWoKh5W+FI2BW/ATXT40J4=" + [mod."github.com/fatih/color"] + version = "v1.18.0" + hash = "sha256-pP5y72FSbi4j/BjyVq/XbAOFjzNjMxZt2R/lFFxGWvY=" + [mod."github.com/felixge/httpsnoop"] + version = "v1.0.4" + hash = "sha256-c1JKoRSndwwOyOxq9ddCe+8qn7mG9uRq2o/822x5O/c=" + [mod."github.com/fsnotify/fsnotify"] + version = "v1.9.0" + hash = "sha256-WtpE1N6dpHwEvIub7Xp/CrWm0fd6PX7MKA4PV44rp2g=" + [mod."github.com/fxamacker/cbor/v2"] + version = "v2.9.0" + hash = "sha256-/IZK76MRCrz9XCiilieH5tKaLnIWyPJhwxDoVKB8dFc=" + [mod."github.com/go-chi/chi/v5"] + version = "v5.2.5" + hash = "sha256-Y1+17ky94849aqk3iKf30F1u+G6K3nzZzLOBSeqIUow=" + [mod."github.com/go-git/gcfg"] + version = "v1.5.1-0.20230307220236-3a3c6141e376" + hash = "sha256-f4k0gSYuo0/q3WOoTxl2eFaj7WZpdz29ih6CKc8Ude8=" + [mod."github.com/go-git/go-billy/v5"] + version = "v5.8.0" + hash = "sha256-c4ScYknbM+MPHF+2vqnGMBuBLc4U1iYuyymq61Vts9Y=" + [mod."github.com/go-git/go-git/v5"] + version = "v5.18.0" + hash = "sha256-9n2mTwPvx9d90ZEmezIq6SY7UET/32WNw2xYAp8mQ5Y=" + [mod."github.com/go-jose/go-jose/v4"] + version = "v4.1.4" + hash = "sha256-MKoJKXup1jfwOyN8mHXu1CQ8fvFJTaEf3K2LVtNSRhc=" + [mod."github.com/go-json-experiment/json"] + version = "v0.0.0-20240815175050-ebd3a8989ca1" + hash = "sha256-Vo2PWnwXTK7dp2xGdvHs4sMLH6MgERLh9B5cTWyqYEo=" + [mod."github.com/go-logr/logr"] + version = "v1.4.3" + hash = "sha256-Nnp/dEVNMxLp3RSPDHZzGbI8BkSNuZMX0I0cjWKXXLA=" + [mod."github.com/go-logr/stdr"] + version = "v1.2.2" + hash = "sha256-rRweAP7XIb4egtT1f2gkz4sYOu7LDHmcJ5iNsJUd0sE=" + [mod."github.com/go-logr/zapr"] + version = "v1.3.0" + hash = "sha256-ehak315/wxBKtuFhCz+TPsvNzYBv0/oZ3tVIjT52hc0=" + [mod."github.com/go-openapi/analysis"] + version = "v0.24.3" + hash = "sha256-jBLHbyhrdLwc0x/P3MlUik0xZPV6xOaKAa5aPV77sBY=" + [mod."github.com/go-openapi/errors"] + version = "v0.22.7" + hash = "sha256-Iy5ieFbpjjbVEQ+UWXIeI8jzN/TjtsOoEX5IB6/v6gc=" + [mod."github.com/go-openapi/jsonpointer"] + version = "v0.22.5" + hash = "sha256-btK8c3hxbO9mAlvRYuAaNdjaHNYyLvNl/RfNxPsqQuw=" + [mod."github.com/go-openapi/jsonreference"] + version = "v0.21.5" + hash = "sha256-fBeVESt+JKLthLDTyuABzPV/hOmg4t8dPtwvAs1Ojog=" + [mod."github.com/go-openapi/loads"] + version = "v0.23.3" + hash = "sha256-KeSh5BVDAkNBw50Yoyi19lEzODOrTcASyjtnpcrcZ10=" + [mod."github.com/go-openapi/runtime"] + version = "v0.29.3" + hash = "sha256-nid3RStsHKEZgCNPU1+4NkqDsIOAc52JOF68zH3/bC4=" + [mod."github.com/go-openapi/spec"] + version = "v0.22.4" + hash = "sha256-nwNLmtrjR3w+vFGlBFSq4vM2ZsDQS4RDBQGYmlGv7BY=" + [mod."github.com/go-openapi/strfmt"] + version = "v0.26.1" + hash = "sha256-HcLytF6mvc2I+ReaKTjL5/LJzKfdwhb0iIpxjjV9+6M=" + [mod."github.com/go-openapi/swag"] + version = "v0.25.5" + hash = "sha256-ptIgtll6FVI7viEVxM/imWUjgaeLP/oViAarM/EhsWU=" + [mod."github.com/go-openapi/swag/cmdutils"] + version = "v0.25.5" + hash = "sha256-sEGS7K9gzBuKgkoIiHn5Mgv7+SvPqJ1iFZRsXrso/2M=" + [mod."github.com/go-openapi/swag/conv"] + version = "v0.25.5" + hash = "sha256-+yLC40AK2pyn62zStk7Q13Bsb4/HDsJUKTTNBSWSTvg=" + [mod."github.com/go-openapi/swag/fileutils"] + version = "v0.25.5" + hash = "sha256-zYxEpqJuZ97vFLQxfYwegDQhffKhpsDtYF1xhOVxL4c=" + [mod."github.com/go-openapi/swag/jsonname"] + version = "v0.25.5" + hash = "sha256-ypcI24qrUOd0lbZUJcFByQr07U7WtQvIu/YhuewUWDo=" + [mod."github.com/go-openapi/swag/jsonutils"] + version = "v0.25.5" + hash = "sha256-e6OOoTIH/zrI/unpNIu3foYEvSWKb7Jvf+6E6/nvpMg=" + [mod."github.com/go-openapi/swag/loading"] + version = "v0.25.5" + hash = "sha256-gwy+xJkF3PHT5YMYnXgSX9XhuvwwOVpH60QTLsAh6/E=" + [mod."github.com/go-openapi/swag/mangling"] + version = "v0.25.5" + hash = "sha256-SXSdvYE+wIm95KHRUPYjPEdFU6hc85/7H5rJH7bdTSM=" + [mod."github.com/go-openapi/swag/netutils"] + version = "v0.25.5" + hash = "sha256-FzjcovD9ZGR/dNyU019KC/CRVn/OJ6XUJ3hS5J4w6go=" + [mod."github.com/go-openapi/swag/stringutils"] + version = "v0.25.5" + hash = "sha256-Ze2Y056Imqyq6kHPcACuqHt992WbXfC9LDziSCFuO/c=" + [mod."github.com/go-openapi/swag/typeutils"] + version = "v0.25.5" + hash = "sha256-A1mGLvoaLCT0iORn4tiyKWB8L69dMJzFjBFpt80Xzkg=" + [mod."github.com/go-openapi/swag/yamlutils"] + version = "v0.25.5" + hash = "sha256-+EumuV+qkhYn08XfR1ngIKMh79Mkj8vItpg0y0spX+c=" + [mod."github.com/go-openapi/validate"] + version = "v0.25.2" + hash = "sha256-jH7GfH+JyC1tD2Ejz8ioI5U7IKYqQbllU381qSo5D30=" + [mod."github.com/go-viper/mapstructure/v2"] + version = "v2.5.0" + hash = "sha256-LbrCBANBprVI84M0CWrXc7rriJL5ac5VKbh58LBTw7U=" + [mod."github.com/gobuffalo/flect"] + version = "v1.0.3" + hash = "sha256-gpA1fe9XTjZ9r+yYCysCgXKo1AmYNuNFwWn7ZQ4Ky1M=" + [mod."github.com/gogo/protobuf"] + version = "v1.3.2" + hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" + [mod."github.com/golang-jwt/jwt/v4"] + version = "v4.5.2" + hash = "sha256-rTSqYEPooi8Uu4aXMW6k9dynOV+URYTGzVmbG3EQ7uo=" + [mod."github.com/golang/groupcache"] + version = "v0.0.0-20241129210726-2c02b8208cf8" + hash = "sha256-AdLZ3dJLe/yduoNvZiXugZxNfmwJjNQyQGsIdzYzH74=" + [mod."github.com/golang/snappy"] + version = "v0.0.4" + hash = "sha256-Umx+5xHAQCN/Gi4HbtMhnDCSPFAXSsjVbXd8n5LhjAA=" + [mod."github.com/google/btree"] + version = "v1.1.3" + hash = "sha256-/6Us2eNRFi2IIp7p5uPUXLridilAdk4SmZhcTYR0csw=" + [mod."github.com/google/cel-go"] + version = "v0.27.0" + hash = "sha256-ECoJqNV108IURzaxtaznDajf1yw3jvQPjZi0dQHSIBY=" + [mod."github.com/google/certificate-transparency-go"] + version = "v1.3.3" + hash = "sha256-CdAOfBmZ7xs51YUxLhg5edjxeCQqE2Kw0jOe+jHgvfA=" + [mod."github.com/google/gnostic-models"] + version = "v0.7.1" + hash = "sha256-dfSFaYzgD4HrdL6ZsN8V9w0SMzx0WXl38dIy4dnjhhc=" + [mod."github.com/google/go-cmp"] + version = "v0.7.0" + hash = "sha256-JbxZFBFGCh/Rj5XZ1vG94V2x7c18L8XKB0N9ZD5F2rM=" + [mod."github.com/google/go-containerregistry"] + version = "v0.21.2" + hash = "sha256-6ONiJAQYguYJc6vcvdgFZYNtecrrOC7OzTVX4sIN5T8=" + [mod."github.com/google/go-containerregistry/pkg/authn/k8schain"] + version = "v0.0.0-20230919002926-dbcd01c402b2" + hash = "sha256-y/xHODMYpIsday3XuwTS8bO5+1CMjgazalC2fijnC6c=" + [mod."github.com/google/go-containerregistry/pkg/authn/kubernetes"] + version = "v0.0.0-20250225234217-098045d5e61f" + hash = "sha256-UZyDwMt9qQw5XHHDOlTyYMRvG1BiDfBHeZLmoMzunB4=" + [mod."github.com/google/uuid"] + version = "v1.6.0" + hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" + [mod."github.com/grpc-ecosystem/grpc-gateway/v2"] + version = "v2.28.0" + hash = "sha256-QeWb6jN6noeGPCzECgpUSb9YX9LzvKGwImEuX+A03gs=" + [mod."github.com/hashicorp/errwrap"] + version = "v1.1.0" + hash = "sha256-6lwuMQOfBq+McrViN3maJTIeh4f8jbEqvLy2c9FvvFw=" + [mod."github.com/hashicorp/go-cleanhttp"] + version = "v0.5.2" + hash = "sha256-N9GOKYo7tK6XQUFhvhImtL7PZW/mr4C4Manx/yPVvcQ=" + [mod."github.com/hashicorp/go-multierror"] + version = "v1.1.1" + hash = "sha256-ANzPEUJIZIlToxR89Mn7Db73d9LGI51ssy7eNnUgmlA=" + [mod."github.com/hashicorp/go-retryablehttp"] + version = "v0.7.8" + hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=" + [mod."github.com/in-toto/attestation"] + version = "v1.1.2" + hash = "sha256-BdRbWCnzMCMyZmo8lkovtvGWQq2qCB7S2XBZWClJ6TM=" + [mod."github.com/in-toto/in-toto-golang"] + version = "v0.10.0" + hash = "sha256-ZL2+v1bszcWWTLUsIVf0A7q37Vw+v9vgqb2AlMHabe0=" + [mod."github.com/inconshreveable/mousetrap"] + version = "v1.1.0" + hash = "sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=" + [mod."github.com/jbenet/go-context"] + version = "v0.0.0-20150711004518-d14ea06fba99" + hash = "sha256-VANNCWNNpARH/ILQV9sCQsBWgyL2iFT+4AHZREpxIWE=" + [mod."github.com/jedisct1/go-minisign"] + version = "v0.0.0-20230811132847-661be99b8267" + hash = "sha256-tWufMmbfSlJRLsD1/ye5H+9b/uEQnBCQwORLJ1KwRh8=" + [mod."github.com/json-iterator/go"] + version = "v1.1.12" + hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM=" + [mod."github.com/kevinburke/ssh_config"] + version = "v1.2.0" + hash = "sha256-Ta7ZOmyX8gG5tzWbY2oES70EJPfI90U7CIJS9EAce0s=" + [mod."github.com/klauspost/compress"] + version = "v1.18.5" + hash = "sha256-H9b5iFJf4XbEnkGQCjGQAJ3aYhVDiolKrDewTbhuzQo=" + [mod."github.com/letsencrypt/boulder"] + version = "v0.20260223.0" + hash = "sha256-p/AuDyJr7chBqbXT+LLa3ShKX96aC3SsfzR2ekb2+xM=" + [mod."github.com/liggitt/tabwriter"] + version = "v0.0.0-20181228230101-89fcab3d43de" + hash = "sha256-b6pLitORwgfGpOHpe45ykj00P17utbDv8bv6MCVoCBM=" + [mod."github.com/lucasb-eyer/go-colorful"] + version = "v1.2.0" + hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" + [mod."github.com/mattn/go-colorable"] + version = "v0.1.14" + hash = "sha256-JC60PjKj7MvhZmUHTZ9p372FV72I9Mxvli3fivTbxuA=" + [mod."github.com/mattn/go-isatty"] + version = "v0.0.20" + hash = "sha256-qhw9hWtU5wnyFyuMbKx+7RB8ckQaFQ8D+8GKPkN3HHQ=" + [mod."github.com/mattn/go-localereader"] + version = "v0.0.1" + hash = "sha256-JlWckeGaWG+bXK8l8WEdZqmSiTwCA8b1qbmBKa/Fj3E=" + [mod."github.com/mattn/go-runewidth"] + version = "v0.0.16" + hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" + [mod."github.com/mitchellh/go-homedir"] + version = "v1.1.0" + hash = "sha256-oduBKXHAQG8X6aqLEpqZHs5DOKe84u6WkBwi4W6cv3k=" + [mod."github.com/moby/docker-image-spec"] + version = "v1.3.1" + hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" + [mod."github.com/moby/term"] + version = "v0.5.2" + hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU=" + [mod."github.com/modern-go/concurrent"] + version = "v0.0.0-20180306012644-bacd9c7ef1dd" + hash = "sha256-OTySieAgPWR4oJnlohaFTeK1tRaVp/b0d1rYY8xKMzo=" + [mod."github.com/modern-go/reflect2"] + version = "v1.0.3-0.20250322232337-35a7c28c31ee" + hash = "sha256-0pkWWZRB3lGFyzmlxxrm0KWVQo9HNXNafaUu3k+rE1g=" + [mod."github.com/muesli/ansi"] + version = "v0.0.0-20230316100256-276c6243b2f6" + hash = "sha256-qRKn0Bh2yvP0QxeEMeZe11Vz0BPFIkVcleKsPeybKMs=" + [mod."github.com/muesli/cancelreader"] + version = "v0.2.2" + hash = "sha256-uEPpzwRJBJsQWBw6M71FDfgJuR7n55d/7IV8MO+rpwQ=" + [mod."github.com/muesli/termenv"] + version = "v0.16.0" + hash = "sha256-hGo275DJlyLtcifSLpWnk8jardOksdeX9lH4lBeE3gI=" + [mod."github.com/munnerz/goautoneg"] + version = "v0.0.0-20191010083416-a7dc8b61c822" + hash = "sha256-79URDDFenmGc9JZu+5AXHToMrtTREHb3BC84b/gym9Q=" + [mod."github.com/nozzle/throttler"] + version = "v0.0.0-20180817012639-2ea982251481" + hash = "sha256-pufLisYZW//uJXtCkobaU0Etnu+ZPQCqaRzRItx65hk=" + [mod."github.com/oklog/ulid/v2"] + version = "v2.1.1" + hash = "sha256-kPNLaZMGwGc7ngPCivf/n4Bis219yOkGAaa6mt7+yTY=" + [mod."github.com/opencontainers/go-digest"] + version = "v1.0.0" + hash = "sha256-cfVDjHyWItmUGZ2dzQhCHgmOmou8v7N+itDkLZVkqkQ=" + [mod."github.com/opencontainers/image-spec"] + version = "v1.1.1" + hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" + [mod."github.com/pjbgf/sha1cd"] + version = "v0.3.2" + hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" + [mod."github.com/pkg/browser"] + version = "v0.0.0-20240102092130-5ac0b6a4141c" + hash = "sha256-9iaSHHpcA1fXVF5f8RlKyo1DSoHx7eGXIC2/4LFaoBY=" + [mod."github.com/pkg/errors"] + version = "v0.9.1" + hash = "sha256-mNfQtcrQmu3sNg/7IwiieKWOgFQOVVe2yXgKBpe/wZw=" + [mod."github.com/pmezard/go-difflib"] + version = "v1.0.1-0.20181226105442-5d4384ee4fb2" + hash = "sha256-XA4Oj1gdmdV/F/+8kMI+DBxKPthZ768hbKsO3d9Gx90=" + [mod."github.com/posener/complete"] + version = "v1.2.3" + hash = "sha256-/17KFHD0SsGALg9iLXNIdvVFcotOO+H6bOOD5SY0MVs=" + [mod."github.com/prometheus/client_golang"] + version = "v1.23.2" + hash = "sha256-3GD4fBFa1tJu8MS4TNP6r2re2eViUE+kWUaieIOQXCg=" + [mod."github.com/prometheus/client_model"] + version = "v0.6.2" + hash = "sha256-q6Fh6v8iNJN9ypD47LjWmx66YITa3FyRjZMRsuRTFeQ=" + [mod."github.com/prometheus/common"] + version = "v0.67.5" + hash = "sha256-pDzmYsAANsaIf3W9HxpbgRnZ4BkPhJBBwzKq2E58FRw=" + [mod."github.com/prometheus/procfs"] + version = "v0.19.2" + hash = "sha256-PJW21pew9v+XA7Miow8JVPct+FPIHmQHphwO+g2kNWA=" + [mod."github.com/rivo/uniseg"] + version = "v0.4.7" + hash = "sha256-rDcdNYH6ZD8KouyyiZCUEy8JrjOQoAkxHBhugrfHjFo=" + [mod."github.com/riywo/loginshell"] + version = "v0.0.0-20200815045211-7d26008be1ab" + hash = "sha256-keDEue4jkpIVm9GxZYAAIvYlDjk/eilAT/xGanTcHo0=" + [mod."github.com/sassoftware/relic"] + version = "v7.2.1+incompatible" + hash = "sha256-vHyTdLRh6OlfoGzVgvx7I0+E6tpE7V43lCQaHD/e8J4=" + [mod."github.com/secure-systems-lab/go-securesystemslib"] + version = "v0.10.0" + hash = "sha256-KY68WNnb3tgNTi0QWsmirkPfmU0xyaP23QVuSuawtHQ=" + [mod."github.com/sergi/go-diff"] + version = "v1.4.0" + hash = "sha256-rs9NKpv/qcQEMRg7CmxGdP4HGuFdBxlpWf9LbA9wS4k=" + [mod."github.com/shibumi/go-pathspec"] + version = "v1.3.0" + hash = "sha256-ZHLft/o+xyJrUlaCwnCDqbjkPj6iIxlOuA0fFBuwVvM=" + [mod."github.com/sigstore/cosign/v3"] + version = "v3.0.5" + hash = "sha256-wN5iAfcBCDTvhbvSar4DBw7w1sxIFWcMKv8qkx07mfo=" + [mod."github.com/sigstore/protobuf-specs"] + version = "v0.5.0" + hash = "sha256-nImiBItjCQwskGHqYYthBjUfHHxy8VnVwSMWkK6GiNo=" + [mod."github.com/sigstore/rekor"] + version = "v1.5.1" + hash = "sha256-4+wM/pNyOtFZM9WQDSLubScfbquWmvDRY93vHjIrf9k=" + [mod."github.com/sigstore/rekor-tiles/v2"] + version = "v2.2.1" + hash = "sha256-mRnRvIp0UE7o5CUJiG8hs5xFJABuEAWWnjvTwz/4cKo=" + [mod."github.com/sigstore/sigstore"] + version = "v1.10.5" + hash = "sha256-t9oup+yS4jWxEoVbYLjUrI+Hu1XlWe+bu7KVOCIf+aE=" + [mod."github.com/sigstore/sigstore-go"] + version = "v1.1.4" + hash = "sha256-EsPVloCbJXMXOUKsNU00WQzzR2DfkbmCGYGdYgnH94I=" + [mod."github.com/sigstore/timestamp-authority/v2"] + version = "v2.0.6" + hash = "sha256-k1LVuwm+cgCotsNxZbGI+c8jmMTI0itBvXc5TGVu27I=" + [mod."github.com/sirupsen/logrus"] + version = "v1.9.4" + hash = "sha256-ltRvmtM3XTCAFwY0IesfRqYIivyXPPuvkFjL4ARh1wg=" + [mod."github.com/skeema/knownhosts"] + version = "v1.3.1" + hash = "sha256-kjqQDzuncQNTuOYegqVZExwuOt/Z73m2ST7NZFEKixI=" + [mod."github.com/spf13/afero"] + version = "v1.15.0" + hash = "sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8=" + [mod."github.com/spf13/cobra"] + version = "v1.10.2" + hash = "sha256-nbRCTFiDCC2jKK7AHi79n7urYCMP5yDZnWtNVJrDi+k=" + [mod."github.com/spf13/pflag"] + version = "v1.0.10" + hash = "sha256-uDPnWjHpSrzXr17KEYEA1yAbizfcsfo5AyztY2tS6ZU=" + [mod."github.com/syndtr/goleveldb"] + version = "v1.0.1-0.20220721030215-126854af5e6d" + hash = "sha256-z7HzuNVmpAJalsebJ+X7jdXq7BykcOyfzhFT8os+euM=" + [mod."github.com/theupdateframework/go-tuf"] + version = "v0.7.0" + hash = "sha256-YwQTq6V20iI46KufNAi+1P1qrn0ldZxsFRY7dhXbO1s=" + [mod."github.com/theupdateframework/go-tuf/v2"] + version = "v2.4.1" + hash = "sha256-v9ULpLPiK+0wBn+36zwA2ci8bX1ugO+qf3+1nd7xI4g=" + [mod."github.com/titanous/rocacheck"] + version = "v0.0.0-20171023193734-afe73141d399" + hash = "sha256-r5XUB1A/doHNd5pu1cL0J8Jwy5IBtc8gQtG5NmKEYPU=" + [mod."github.com/transparency-dev/formats"] + version = "v0.0.0-20251017110053-404c0d5b696c" + hash = "sha256-IaDd91Eeh6DasW5UcQaUpYobBwSNJO2nC64rySBs4wI=" + [mod."github.com/transparency-dev/merkle"] + version = "v0.0.2" + hash = "sha256-4KsqpIqgXlypi1X88PekMRfWJ/Y8tuww6DAuXar2+FY=" + [mod."github.com/vbatts/tar-split"] + version = "v0.12.2" + hash = "sha256-6gOHl4puCV9T2EWpFpqMCkV9N2PEPSiWbNZNp20q7iM=" + [mod."github.com/willabides/kongplete"] + version = "v0.4.0" + hash = "sha256-PIgYbQo/kbxm5wDBrf2RPZvlfxZK0ndEwrnviISCoxg=" + [mod."github.com/x448/float16"] + version = "v0.8.4" + hash = "sha256-VKzMTMS9pIB/cwe17xPftCSK9Mf4Y6EuBEJlB4by5mE=" + [mod."github.com/xanzy/ssh-agent"] + version = "v0.3.3" + hash = "sha256-l3pGB6IdzcPA/HLk93sSN6NM2pKPy+bVOoacR5RC2+c=" + [mod."github.com/xo/terminfo"] + version = "v0.0.0-20220910002029-abceb7e1c41e" + hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" + [mod."go.opentelemetry.io/auto/sdk"] + version = "v1.2.1" + hash = "sha256-73bFYhnxNf4SfeQ52ebnwOWywdQbqc9lWawCcSgofvE=" + [mod."go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"] + version = "v0.67.0" + hash = "sha256-efihYJm1SmM0T+n4e8MsUtTS5yAROK8svGwPAycd7fA=" + [mod."go.opentelemetry.io/otel"] + version = "v1.43.0" + hash = "sha256-oRemJUZhA7AzfUoBbRVA32u/XhMpipxLywHoJ1qsHBs=" + [mod."go.opentelemetry.io/otel/metric"] + version = "v1.43.0" + hash = "sha256-iUfx5AvN2oiqlh2v8/oFa+2jm8RX4kbb6X1EOKRyPPw=" + [mod."go.opentelemetry.io/otel/trace"] + version = "v1.43.0" + hash = "sha256-LLx1PjBGzDwZ3//Gp14R1DCMlnMCzFxnGYqVUz5jTmk=" + [mod."go.uber.org/multierr"] + version = "v1.11.0" + hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0=" + [mod."go.uber.org/zap"] + version = "v1.27.1" + hash = "sha256-bn/MMu7X3GkUuW12Xwn9JYbOJeEu9+yoQtkmO+36xlQ=" + [mod."go.yaml.in/yaml/v2"] + version = "v2.4.3" + hash = "sha256-WqfrOUQFvfuORgl1yyVOcsEXU/vwWQHkcVWx3vCxvaw=" + [mod."go.yaml.in/yaml/v3"] + version = "v3.0.4" + hash = "sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4=" + [mod."golang.org/x/crypto"] + version = "v0.50.0" + hash = "sha256-vC1BJT7+3UBWLyEE5n3to0NKhMo6m2HGow2HiFgpQLo=" + [mod."golang.org/x/exp"] + version = "v0.0.0-20260218203240-3dfff04db8fa" + hash = "sha256-vsit1x+iQymfiWhINdZgd4wQhMoWMp3tqcjbDjcV0oY=" + [mod."golang.org/x/mod"] + version = "v0.35.0" + hash = "sha256-ICEQxokHywOFInDPqoP+go9l1tZSz3roknF5SXPtNV4=" + [mod."golang.org/x/net"] + version = "v0.53.0" + hash = "sha256-G9gKLmyaf6lIV429NKX+YlL6oUPJwlv+BrG6qGhzvmU=" + [mod."golang.org/x/oauth2"] + version = "v0.36.0" + hash = "sha256-evS7WkMrpgonmTcqtWFpC5rSKZN8O+vnAhNUs1MS9kw=" + [mod."golang.org/x/sync"] + version = "v0.20.0" + hash = "sha256-ybcjhCfK6lroUM0yswUvWooW8MOQZBXyiSqoxG6Uy0Y=" + [mod."golang.org/x/sys"] + version = "v0.43.0" + hash = "sha256-aDQXqSTZES2l/132PBxhZN4ywldpPyfm7LByYCHzzwM=" + [mod."golang.org/x/term"] + version = "v0.42.0" + hash = "sha256-FCiDvAfq7dgBGQuiDYDFJbj/JPawhrmPF2qdUEftQ1c=" + [mod."golang.org/x/text"] + version = "v0.36.0" + hash = "sha256-/0t9C6Mc8kYjxweFB0us2lGKo8GovHhBiq5nlMOppC0=" + [mod."golang.org/x/time"] + version = "v0.15.0" + hash = "sha256-5D24A65wn7k93Jj3+918UKjB9ccmGHPBEqjD2XDB92E=" + [mod."golang.org/x/tools"] + version = "v0.44.0" + hash = "sha256-xuj5FLtSJsAojLLTLXtPdLAIFNTKoVFbDMuqRXmj2W4=" + [mod."gomodules.xyz/jsonpatch/v2"] + version = "v2.5.0" + hash = "sha256-L3Xy24GTtcDHmMgc9rlgUm3GrxFO7XQKJhfYIr3li1s=" + [mod."google.golang.org/genproto/googleapis/api"] + version = "v0.0.0-20260401024825-9d38bb4040a9" + hash = "sha256-BXg1F9SwkNalLgqbjfpmr+KcFrZcZXuubJh990f+1mI=" + [mod."google.golang.org/genproto/googleapis/rpc"] + version = "v0.0.0-20260401024825-9d38bb4040a9" + hash = "sha256-ldJTTb7hhj1mdmzTn9IEkQVwCoj3KRlENZtUSEKHABU=" + [mod."google.golang.org/grpc"] + version = "v1.80.0" + hash = "sha256-+p50KGJvGWdpB/4f0h477dCAfoOL5m2PzG8BGOekVgY=" [mod."google.golang.org/protobuf"] version = "v1.36.11" hash = "sha256-7W+6jntfI/awWL3JP6yQedxqP5S9o3XvPgJ2XxxsIeE=" + [mod."gopkg.in/evanphx/json-patch.v4"] + version = "v4.13.0" + hash = "sha256-1iyZpBaeBLmNkJ3T4A9fAEXEYB9nk9V02ug4pwl5dy0=" + [mod."gopkg.in/inf.v0"] + version = "v0.9.1" + hash = "sha256-z84XlyeWLcoYOvWLxPkPFgLkpjyb2Y4pdeGMyySOZQI=" + [mod."gopkg.in/warnings.v0"] + version = "v0.1.2" + hash = "sha256-ATVL9yEmgYbkJ1DkltDGRn/auGAjqGOfjQyBYyUo8s8=" + [mod."gopkg.in/yaml.v2"] + version = "v2.4.0" + hash = "sha256-uVEGglIedjOIGZzHW4YwN1VoRSTK8o0eGZqzd+TNdd0=" + [mod."gopkg.in/yaml.v3"] + version = "v3.0.1" + hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=" + [mod."k8s.io/api"] + version = "v0.35.3" + hash = "sha256-MIl5MB5b6QsV/VSWoDDmqx8GdNnfNmAnXIe0DRKo5vI=" + [mod."k8s.io/apiextensions-apiserver"] + version = "v0.35.0" + hash = "sha256-RZdGkV4SoCTY022pIzQbeVwaxIYhIpXapLZrD593gh8=" + [mod."k8s.io/apimachinery"] + version = "v0.35.3" + hash = "sha256-+dplbHUOfaCaD2E9IS4F3lnjSCr/a4LjTgdB9de92Pw=" + [mod."k8s.io/apiserver"] + version = "v0.35.0" + hash = "sha256-jte6rWgLJddOrVPdBavTDM5ivq0nlkdvfxuprWJTMDM=" + [mod."k8s.io/cli-runtime"] + version = "v0.34.1" + hash = "sha256-s5yRTk0QmBoV0wSZT5jCJfKTRbBcAjEpCTmm1TxX4jo=" + [mod."k8s.io/client-go"] + version = "v0.35.1" + hash = "sha256-QEQ7TLUviAXDbvp2s6tT3HZtXy7pLjj5qSDu89iC9ek=" + [mod."k8s.io/code-generator"] + version = "v0.35.0" + hash = "sha256-0F8vNVdF/quBPGxOpxBL/GhupnkMoTE8dcZYX57RZKk=" + [mod."k8s.io/component-base"] + version = "v0.35.0" + hash = "sha256-fIAmKs3/T8oHrXBX3sMWr/D1DGhyCsgM+6ZFdaA8BXU=" + [mod."k8s.io/gengo/v2"] + version = "v2.0.0-20251215205346-5ee0d033ba5b" + hash = "sha256-FxD4b+cOzKuXsGI4NpsaUK/YTOMxugMGAh2jY3od3p8=" + [mod."k8s.io/klog/v2"] + version = "v2.130.1" + hash = "sha256-n5vls1o1a0V0KYv+3SULq4q3R2Is15K8iDHhFlsSH4o=" + [mod."k8s.io/kube-openapi"] + version = "v0.0.0-20260127142750-a19766b6e2d4" + hash = "sha256-NS8NvGTX3Ycoc4JU/jwLgtNlD5OOQ5zk2hzvFFSD/jM=" + [mod."k8s.io/metrics"] + version = "v0.34.1" + hash = "sha256-vlk5vnjLmB9LNyw98u7hPWn9dqfTKa9b7O+kV9wijCw=" + [mod."k8s.io/utils"] + version = "v0.0.0-20260319190234-28399d86e0b5" + hash = "sha256-ER2/AqF5AbVv4lfIDoggmlGfTnNH0cNccDisJqNyXn4=" + [mod."sigs.k8s.io/controller-runtime"] + version = "v0.23.1" + hash = "sha256-iOaYAJgy/Q1Hi6afs5mLtP8K5J8Cs/MlDoGp8wE1GOY=" + [mod."sigs.k8s.io/controller-tools"] + version = "v0.20.0" + hash = "sha256-1/v8hCCykTDMjhx4jM84/v+WJlQ8M1whdgpGfgmSRRU=" + [mod."sigs.k8s.io/json"] + version = "v0.0.0-20250730193827-2d320260d730" + hash = "sha256-y3vUPJYL6oxu/8c0j4vgX6fzqHtVPSCjfyuWkZYf6+I=" + [mod."sigs.k8s.io/randfill"] + version = "v1.0.0" + hash = "sha256-xldQxDwW84hmlihdSOFfjXyauhxEWV9KmIDLZMTcYNo=" + [mod."sigs.k8s.io/structured-merge-diff/v6"] + version = "v6.3.2-0.20260122202528-d9cc6641c482" + hash = "sha256-4ZUkeHKvdhsZmFSSZKAL/1GZdPO6735BY6FqRc+8tog=" + [mod."sigs.k8s.io/yaml"] + version = "v1.6.0" + hash = "sha256-49hg7IVPzwxeovp+HTMiWa/10NMMTSTjAdCmIv6p9dw=" diff --git a/internal/docker/docker.go b/internal/docker/docker.go new file mode 100644 index 0000000..61c005d --- /dev/null +++ b/internal/docker/docker.go @@ -0,0 +1,477 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package docker contains helpers for working with Docker-compatible container +// runtimes. +package docker + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/docker/cli/cli/config" + "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/filters" + "github.com/docker/docker/api/types/image" + "github.com/docker/docker/api/types/network" + "github.com/docker/docker/client" + "github.com/docker/docker/pkg/stdcopy" + "github.com/google/go-containerregistry/pkg/name" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// Check attempts to connect to the local Docker daemon and returns an error if +// it's unable to do so. +func Check(ctx context.Context) error { + cli, err := NewClient() + if err != nil { + return err + } + if _, err := cli.Ping(ctx); err != nil { + return errors.Wrap(err, "failed to ping docker daemon") + } + return nil +} + +// GetContainerIDByName returns the ID of the container with the given name. +func GetContainerIDByName(ctx context.Context, name string, includeStopped bool) (string, bool, error) { + c, found, err := GetContainerByName(ctx, name, includeStopped) + if err != nil { + return "", false, err + } + if !found { + return "", false, nil + } + return c.ID, true, nil +} + +// GetContainerByName returns the container with the given name. +func GetContainerByName(ctx context.Context, name string, includeStopped bool) (*container.Summary, bool, error) { + cli, err := NewClient() + if err != nil { + return nil, false, err + } + + cs, err := cli.ContainerList(ctx, container.ListOptions{ + Filters: filters.NewArgs(filters.KeyValuePair{Key: "name", Value: name}), + All: includeStopped, + }) + if err != nil { + return nil, false, errors.Wrap(err, "failed to list containers") + } + if len(cs) == 0 { + return nil, false, nil + } + return &cs[0], true, nil +} + +// GetNetworkIDByName returns the ID of the network with the given name. +func GetNetworkIDByName(ctx context.Context, name string) (string, bool, error) { + cli, err := NewClient() + if err != nil { + return "", false, err + } + + ns, err := cli.NetworkList(ctx, network.ListOptions{ + Filters: filters.NewArgs(filters.KeyValuePair{Key: "name", Value: name}), + }) + if err != nil { + return "", false, errors.Wrap(err, "failed to list networks") + } + if len(ns) == 0 { + return "", false, nil + } + return ns[0].ID, true, nil +} + +// StartContainer starts a container with the given name using the given image. +func StartContainer(ctx context.Context, name, img string, opts ...StartContainerOption) (string, error) { + cfg := &startContainerConfig{ + containerConfig: &container.Config{ + Image: img, + }, + } + for _, opt := range opts { + opt(cfg) + } + + cli, err := NewClient() + if err != nil { + return "", err + } + + if _, err := cli.ImageInspect(ctx, img); err != nil { + auth, err := defaultRegistryAuth(img) + if err != nil { + return "", err + } + + out, err := cli.ImagePull(ctx, img, image.PullOptions{ + RegistryAuth: auth, + }) + if err != nil { + return "", errors.Wrapf(err, "failed to pull image %q", img) + } + + if _, err := io.Copy(io.Discard, out); err != nil { + return "", errors.Wrapf(err, "failed to read image pull output for %s", img) + } + } + + resp, err := cli.ContainerCreate(ctx, + cfg.containerConfig, + cfg.hostConfig, + nil, + nil, + name, + ) + if err != nil { + return "", errors.Wrap(err, "failed to create container") + } + + for path, tarball := range cfg.copyFiles { + if err := cli.CopyToContainer(ctx, resp.ID, filepath.Clean(path), bytes.NewReader(tarball), container.CopyToContainerOptions{}); err != nil { + return "", errors.Wrapf(err, "failed to copy files to container path %s", path) + } + } + + if err := cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + return "", errors.Wrap(err, "failed to start container") + } + + for _, nid := range cfg.networks { + if err := cli.NetworkConnect(ctx, nid, resp.ID, nil); err != nil { + return "", errors.Wrapf(err, "failed to connect container to network %q", nid) + } + } + + return resp.ID, nil +} + +func defaultRegistryAuth(imageName string) (string, error) { + hostname, err := resolveRegistryFromImage(imageName) + if err != nil { + return "", errors.Wrapf(err, "cannot resolve registry for image %q", imageName) + } + + cfg, err := config.Load(config.Dir()) + if err != nil { + return "", errors.Wrap(err, "cannot load Docker registry auth config") + } + + auth, err := cfg.GetAuthConfig(hostname) + if err != nil { + return "", errors.Wrapf(err, "cannot get auth config for registry %q", hostname) + } + + data, err := json.Marshal(auth) + if err != nil { + return "", errors.Wrap(err, "cannot marshal Docker auth config") + } + + return base64.StdEncoding.EncodeToString(data), nil +} + +func resolveRegistryFromImage(img string) (string, error) { + ref, err := name.ParseReference(img, name.StrictValidation) + if err != nil { + return "", errors.Wrapf(err, "cannot parse image reference %q", img) + } + + return ref.Context().RegistryStr(), nil +} + +// StartContainerByID starts an existing container by ID. +func StartContainerByID(ctx context.Context, id string) error { + cli, err := NewClient() + if err != nil { + return err + } + return errors.Wrap(cli.ContainerStart(ctx, id, container.StartOptions{}), "failed to start container") +} + +type startContainerConfig struct { + containerConfig *container.Config + hostConfig *container.HostConfig + networks []string + copyFiles map[string][]byte +} + +// StartContainerOption provides optional options for StartContainer. +type StartContainerOption func(*startContainerConfig) + +// StartWithCommand sets the command to use when starting a container. +func StartWithCommand(cmd []string) StartContainerOption { + return func(cfg *startContainerConfig) { + if cfg.containerConfig == nil { + cfg.containerConfig = &container.Config{} + } + cfg.containerConfig.Cmd = cmd + } +} + +// StartWithBindMount adds a bind mount when starting a container. +func StartWithBindMount(hostPath, containerPath string) StartContainerOption { + return func(cfg *startContainerConfig) { + if cfg.hostConfig == nil { + cfg.hostConfig = &container.HostConfig{} + } + cfg.hostConfig.Binds = append(cfg.hostConfig.Binds, fmt.Sprintf("%s:%s", hostPath, containerPath)) + } +} + +// StartWithNetworkID adds a network to which a container should be added. +func StartWithNetworkID(nid string) StartContainerOption { + return func(cfg *startContainerConfig) { + cfg.networks = append(cfg.networks, nid) + } +} + +// StartWithCopyFiles adds files that should be copied to the given path before +// starting the container. +func StartWithCopyFiles(tarball []byte, path string) StartContainerOption { + return func(cfg *startContainerConfig) { + if cfg.copyFiles == nil { + cfg.copyFiles = make(map[string][]byte) + } + cfg.copyFiles[path] = tarball + } +} + +// StartWithEnv adds environment variables that will be passed to the container. +func StartWithEnv(env ...string) StartContainerOption { + return func(cfg *startContainerConfig) { + cfg.containerConfig.Env = append(cfg.containerConfig.Env, env...) + } +} + +// StartWithWorkingDirectory sets the working directory for the container. +func StartWithWorkingDirectory(path string) StartContainerOption { + return func(cfg *startContainerConfig) { + cfg.containerConfig.WorkingDir = path + } +} + +// StopContainerByID stops and removes a container. +func StopContainerByID(ctx context.Context, cid string) error { + cli, err := NewClient() + if err != nil { + return err + } + + if err := cli.ContainerStop(ctx, cid, container.StopOptions{}); err != nil { + return errors.Wrap(err, "failed to stop container") + } + return errors.Wrap( + cli.ContainerRemove(ctx, cid, container.RemoveOptions{Force: true, RemoveVolumes: true}), + "failed to remove container", + ) +} + +// WaitForContainerByID waits for the container with the given ID to stop. +func WaitForContainerByID(ctx context.Context, cid string) error { + cli, err := NewClient() + if err != nil { + return err + } + + statusCh, errCh := cli.ContainerWait(ctx, cid, container.WaitConditionNotRunning) + select { + case status := <-statusCh: + if status.StatusCode != 0 { + out, err := cli.ContainerLogs(ctx, cid, container.LogsOptions{ShowStdout: true, ShowStderr: true}) + if err != nil { + return errors.Wrapf(err, "failed to get container logs") + } + + logs := new(strings.Builder) + if _, err := io.Copy(logs, out); err != nil { + return errors.Wrapf(err, "failed to read container logs") + } + + return fmt.Errorf("container exited with non-zero status: %d, logs: %s", status.StatusCode, logs.String()) + } + case err := <-errCh: + return errors.Wrapf(err, "container unknown failure") + } + + return nil +} + +// RunContainerOption provides optional options for RunContainer. +type RunContainerOption func(*runContainerConfig) + +type runContainerConfig struct { + containerConfig *container.Config + hostConfig *container.HostConfig + networkConfig *network.NetworkingConfig + stdin []byte +} + +// RunWithCommand sets the command to run in the container. +func RunWithCommand(cmd []string) RunContainerOption { + return func(cfg *runContainerConfig) { + cfg.containerConfig.Cmd = cmd + } +} + +// RunWithStdin provides data to write to the container's stdin. The container +// is configured with OpenStdin and StdinOnce so it receives EOF after the data +// is written. +func RunWithStdin(data []byte) RunContainerOption { + return func(cfg *runContainerConfig) { + cfg.stdin = data + cfg.containerConfig.OpenStdin = true + cfg.containerConfig.StdinOnce = true + cfg.containerConfig.AttachStdin = true + } +} + +// RunWithNetworkName connects the container to a Docker network by name. +func RunWithNetworkName(name string) RunContainerOption { + return func(cfg *runContainerConfig) { + if cfg.networkConfig == nil { + cfg.networkConfig = &network.NetworkingConfig{} + } + if cfg.networkConfig.EndpointsConfig == nil { + cfg.networkConfig.EndpointsConfig = map[string]*network.EndpointSettings{} + } + cfg.networkConfig.EndpointsConfig[name] = &network.EndpointSettings{} + } +} + +// RunWithExtraHosts adds extra /etc/hosts entries to the container (e.g. +// "host.docker.internal:host-gateway"). +func RunWithExtraHosts(hosts []string) RunContainerOption { + return func(cfg *runContainerConfig) { + cfg.hostConfig.ExtraHosts = append(cfg.hostConfig.ExtraHosts, hosts...) + } +} + +// RunWithBindMount adds a bind mount to the container. +func RunWithBindMount(hostPath, containerPath string) RunContainerOption { + return func(cfg *runContainerConfig) { + cfg.hostConfig.Binds = append(cfg.hostConfig.Binds, fmt.Sprintf("%s:%s", hostPath, containerPath)) + } +} + +// RunContainer creates a container, optionally pipes stdin, waits for it to +// exit, and returns stdout and stderr. The container is always removed on +// return. This is intended for short-lived "run to completion" containers. +func RunContainer(ctx context.Context, img string, opts ...RunContainerOption) ([]byte, []byte, error) { + cfg := &runContainerConfig{ + containerConfig: &container.Config{ + Image: img, + AttachStdout: true, + AttachStderr: true, + }, + hostConfig: &container.HostConfig{}, + } + for _, opt := range opts { + opt(cfg) + } + + cli, err := NewClient() + if err != nil { + return nil, nil, err + } + + // Pull the image if it's not already present. + if _, err := cli.ImageInspect(ctx, img); err != nil { + auth, authErr := defaultRegistryAuth(img) + if authErr != nil { + return nil, nil, authErr + } + out, pullErr := cli.ImagePull(ctx, img, image.PullOptions{RegistryAuth: auth}) + if pullErr != nil { + return nil, nil, errors.Wrapf(pullErr, "failed to pull image %q", img) + } + if _, err := io.Copy(io.Discard, out); err != nil { + return nil, nil, errors.Wrapf(err, "failed to read image pull output for %q", img) + } + } + + resp, err := cli.ContainerCreate(ctx, cfg.containerConfig, cfg.hostConfig, cfg.networkConfig, nil, "") + if err != nil { + return nil, nil, errors.Wrap(err, "failed to create container") + } + defer func() { //nolint:contextcheck // Intentionally use a detached context for cleanup. + _ = cli.ContainerRemove(context.Background(), resp.ID, container.RemoveOptions{Force: true}) + }() + + // Attach before starting so we don't miss any output. Docker + // multiplexes stdout/stderr with 8-byte frame headers when the + // container is not using a TTY. + attach, err := cli.ContainerAttach(ctx, resp.ID, container.AttachOptions{ + Stream: true, + Stdout: true, + Stderr: true, + Stdin: cfg.stdin != nil, + }) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to attach to container") + } + defer attach.Close() + + if err := cli.ContainerStart(ctx, resp.ID, container.StartOptions{}); err != nil { + return nil, nil, errors.Wrap(err, "failed to start container") + } + + // Write stdin data if provided, then close the write side so the + // container sees EOF. + if cfg.stdin != nil { + if _, err := attach.Conn.Write(cfg.stdin); err != nil { + return nil, nil, errors.Wrap(err, "failed to write to container stdin") + } + if err := attach.CloseWrite(); err != nil { + return nil, nil, errors.Wrap(err, "failed to close container stdin") + } + } + + var stdout, stderr bytes.Buffer + if _, err := stdcopy.StdCopy(&stdout, &stderr, attach.Reader); err != nil { + return nil, nil, errors.Wrap(err, "failed to read container output") + } + + // Wait for the container to finish. + statusCh, errCh := cli.ContainerWait(ctx, resp.ID, container.WaitConditionNotRunning) + select { + case status := <-statusCh: + if status.StatusCode != 0 { + return stdout.Bytes(), stderr.Bytes(), fmt.Errorf("container exited with status %d: %s", status.StatusCode, stderr.String()) + } + case err := <-errCh: + return nil, nil, errors.Wrap(err, "error waiting for container") + } + + return stdout.Bytes(), stderr.Bytes(), nil +} + +// NewClient creates a new Docker client configured from environment variables. +func NewClient() (*client.Client, error) { + cli, err := client.NewClientWithOpts(client.WithAPIVersionNegotiation(), client.FromEnv) + if err != nil { + return nil, errors.Wrap(err, "failed to create docker client") + } + return cli, nil +} From 8741207b073a8d0f27d724fab24059caa6a17f6c Mon Sep 17 00:00:00 2001 From: Adam Wolfe Gordon Date: Tue, 5 May 2026 11:07:10 -0600 Subject: [PATCH 05/23] render: Use stable as the default image tag The CLI's version will not be guaranteed to match a Crossplane version going forward, so we can't use the CLI's version number as the default image tag for render. Use the `stable` tag instead, so that we always use the latest stable Crossplane. Signed-off-by: Adam Wolfe Gordon --- cmd/crossplane/render/engine.go | 25 +++++++++++++------------ 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/cmd/crossplane/render/engine.go b/cmd/crossplane/render/engine.go index e7dcbea..5095da4 100644 --- a/cmd/crossplane/render/engine.go +++ b/cmd/crossplane/render/engine.go @@ -21,7 +21,6 @@ import ( "fmt" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" - "github.com/crossplane/crossplane-runtime/v2/pkg/version" pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" @@ -51,9 +50,9 @@ type Engine interface { // EngineFlags contains flags for configuring the render engine. It is embedded // by render command structs to provide shared engine configuration. type EngineFlags struct { - CrossplaneVersion string `help:"Version of the Crossplane image to use for rendering (e.g. v2.2.1). Defaults to the current CLI version." placeholder:"VERSION" xor:"crossplane-selector"` - CrossplaneImage string `help:"Override the full Crossplane Docker image reference for rendering." placeholder:"IMAGE" xor:"crossplane-selector"` - CrossplaneBinary string `help:"Path to a local crossplane binary to use instead of Docker." placeholder:"PATH" type:"existingfile" xor:"crossplane-selector"` + CrossplaneVersion string `help:"Version of the Crossplane image to use for rendering (e.g. v2.3.0). Defaults to the latest stable version." placeholder:"VERSION" xor:"crossplane-selector"` + CrossplaneImage string `help:"Override the full Crossplane Docker image reference for rendering." placeholder:"IMAGE" xor:"crossplane-selector"` + CrossplaneBinary string `help:"Path to a local crossplane binary to use instead of Docker." placeholder:"PATH" type:"existingfile" xor:"crossplane-selector"` } // NewEngineFromFlags creates an Engine from the flag configuration. If a binary @@ -64,15 +63,17 @@ func NewEngineFromFlags(f *EngineFlags, log logging.Logger) Engine { return &localRenderEngine{BinaryPath: f.CrossplaneBinary} } - img := f.CrossplaneImage + return &dockerRenderEngine{image: crossplaneImageFromFlags(f), log: log} +} + +func crossplaneImageFromFlags(f *EngineFlags) string { + if f.CrossplaneImage != "" { + return f.CrossplaneImage + } - if img == "" { - tag := f.CrossplaneVersion - if tag == "" { - tag = version.New().GetVersionString() - } - img = fmt.Sprintf("%s:%s", DefaultCrossplaneImage, tag) + if f.CrossplaneVersion != "" { + return fmt.Sprintf("%s:%s", DefaultCrossplaneImage, f.CrossplaneVersion) } - return &dockerRenderEngine{image: img, log: log} + return DefaultCrossplaneImage + ":stable" } From f810ad011e1c75d9677879016f251e185aa0ddd3 Mon Sep 17 00:00:00 2001 From: Adam Wolfe Gordon Date: Tue, 5 May 2026 11:31:01 -0600 Subject: [PATCH 06/23] Add a skeleton README.md We'll fill out more content in the README later, but bootstrap it for now. Signed-off-by: Adam Wolfe Gordon --- README.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..908c32a --- /dev/null +++ b/README.md @@ -0,0 +1,18 @@ +![CI](https://github.com/crossplane/cli/workflows/CI/badge.svg) [![Go Report Card](https://goreportcard.com/badge/github.com/crossplane/cli)](https://goreportcard.com/report/github.com/crossplane/cli) + +![Crossplane CLI](banner.png) + +The Crossplane CLI is a command-line tool for working with [Crossplane], the +cloud-native framework for platform engineering. It provides tools for building +platforms on top of Crossplane and working with Crossplane clusters. + +Crossplane is a [Cloud Native Computing Foundation][cncf] project. + +## License + +Crossplane is under the Apache 2.0 license. + + + +[Crossplane]: https://crossplane.io +[cncf]: https://www.cncf.io/ From 67a6d335f9bac757eb1209f005740f9332417186 Mon Sep 17 00:00:00 2001 From: Adam Wolfe Gordon Date: Tue, 5 May 2026 11:41:07 -0600 Subject: [PATCH 07/23] Add missing banner graphic for README Signed-off-by: Adam Wolfe Gordon --- banner.png | Bin 0 -> 299244 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 banner.png diff --git a/banner.png b/banner.png new file mode 100644 index 0000000000000000000000000000000000000000..0a495e1dcdc3435b4beff651713960dbbb755a0d GIT binary patch literal 299244 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

v_%QToBI|P=ol8-b$wS)q-V?PXJ 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