├── .gitignore ├── .goreleaser.yaml ├── Makefile ├── README.md ├── cmd └── crossplane-lint │ ├── lint_package.go │ ├── main.go │ └── version.go ├── go.mod ├── go.sum └── internal ├── config └── config.go ├── utils └── sync │ └── collect.go └── xpkg ├── fetch ├── cache.go ├── interface.go └── remote.go ├── lint ├── interface.go ├── jsonpath │ └── jsonpath.go ├── linter │ ├── linter.go │ └── rules │ │ ├── composition.go │ │ ├── composition_fieldpath.go │ │ └── generic.go ├── print │ ├── interface.go │ └── text.go └── schema │ ├── store.go │ ├── xcrd.go │ └── xcrd_schema.go ├── package.go └── parse ├── dependecies.go ├── directory.go ├── image.go └── interface.go /.gitignore: -------------------------------------------------------------------------------- 1 | /.vscode 2 | **/__debug_bin 3 | /.do-not-commit 4 | /.cache 5 | /dist 6 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | builds: 2 | - id: crossplane-lint 3 | main: ./cmd/crossplane-lint 4 | env: 5 | - CGO_ENABLED=0 6 | binary: crossplane-lint 7 | goos: 8 | - darwin 9 | - linux 10 | goarch: 11 | - amd64 12 | - arm64 13 | archives: 14 | - id: default 15 | format: tar.gz 16 | name_template: "{{ .ProjectName }}_{{ .Os }}_{{ .Arch }}" 17 | 18 | release: 19 | mode: keep-existing 20 | # Goreleaser cannot link to artifactory and we don't want attachments in Gitlab. 21 | skip_upload: true 22 | 23 | changelog: 24 | use: git 25 | groups: 26 | - title: New Features 27 | regexp: '^[\w\d]+\sfeat(\([\w-_\d]+\))?!?:.*$' 28 | order: 0 29 | - title: Bug fixes 30 | regexp: '^[\w\d]+\sfix(\([\w-_\d]+\))?!?:.*$' 31 | order: 1 32 | - title: Others 33 | regexp: '^[\w\d]+\s(build|chore|ci|docs|style|refactor|perf|test)(\([\w-_\d]+\))?!?:.*$' 34 | order: 999 35 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ============================================================================== 2 | # Colors 3 | 4 | BLACK := $(shell printf "\033[30m") 5 | BLACK_BOLD := $(shell printf "\033[30;1m") 6 | RED := $(shell printf "\033[31m") 7 | RED_BOLD := $(shell printf "\033[31;1m") 8 | GREEN := $(shell printf "\033[32m") 9 | GREEN_BOLD := $(shell printf "\033[32;1m") 10 | YELLOW := $(shell printf "\033[33m") 11 | YELLOW_BOLD := $(shell printf "\033[33;1m") 12 | BLUE := $(shell printf "\033[34m") 13 | BLUE_BOLD := $(shell printf "\033[34;1m") 14 | MAGENTA := $(shell printf "\033[35m") 15 | MAGENTA_BOLD := $(shell printf "\033[35;1m") 16 | CYAN := $(shell printf "\033[36m") 17 | CYAN_BOLD := $(shell printf "\033[36;1m") 18 | WHITE := $(shell printf "\033[37m") 19 | WHITE_BOLD := $(shell printf "\033[37;1m") 20 | CNone := $(shell printf "\033[0m") 21 | 22 | # ============================================================================== 23 | # Logger 24 | 25 | TIME_LONG = `date +%Y-%m-%d' '%H:%M:%S` 26 | TIME_SHORT = `date +%H:%M:%S` 27 | TIME = $(TIME_SHORT) 28 | 29 | INFO = echo ${TIME} ${BLUE}[ .. ]${CNone} 30 | WARN = echo ${TIME} ${YELLOW}[WARN]${CNone} 31 | ERR = echo ${TIME} ${RED}[FAIL]${CNone} 32 | OK = echo ${TIME} ${GREEN}[ OK ]${CNone} 33 | FAIL = (echo ${TIME} ${RED}[FAIL]${CNone} && false) 34 | 35 | # ==================================================================================== 36 | # Platform options 37 | 38 | # all supported platforms we build for this can be set to other platforms if desired 39 | # we use the golang os and arch names for convenience 40 | PLATFORMS ?= darwin_amd64 darwin_arm64 windows_amd64 linux_amd64 linux_arm64 41 | 42 | # Set the host's OS. Only linux and darwin supported for now 43 | HOSTOS := $(shell uname -s | tr '[:upper:]' '[:lower:]') 44 | ifeq ($(filter darwin linux,$(HOSTOS)),) 45 | $(error build only supported on linux and darwin host currently) 46 | endif 47 | 48 | # Set the host's arch. 49 | HOSTARCH := $(shell uname -m) 50 | 51 | # If SAFEHOSTARCH and TARGETARCH have not been defined yet, use HOST 52 | ifeq ($(origin SAFEHOSTARCH),undefined) 53 | SAFEHOSTARCH := $(HOSTARCH) 54 | endif 55 | 56 | # Automatically translate x86_64 to amd64 57 | ifeq ($(HOSTARCH),x86_64) 58 | SAFEHOSTARCH := amd64 59 | endif 60 | 61 | # Automatically translate aarch64 to arm64 62 | ifeq ($(HOSTARCH),aarch64) 63 | SAFEHOSTARCH := arm64 64 | endif 65 | 66 | ifeq ($(filter amd64 arm64 ,$(SAFEHOSTARCH)),) 67 | $(error build only supported on amd64, arm64 and ppc64le host currently) 68 | endif 69 | 70 | # Standardize Host Platform variables 71 | HOST_PLATFORM := $(HOSTOS)_$(HOSTARCH) 72 | SAFEHOSTPLATFORM := $(HOSTOS)-$(SAFEHOSTARCH) 73 | SAFEHOST_PLATFORM := $(HOSTOS)_$(SAFEHOSTARCH) 74 | 75 | # ============================================================================== 76 | # Common variables 77 | 78 | SELF_DIR := $(dir $(lastword $(MAKEFILE_LIST))) 79 | 80 | ifeq ($(origin ROOT_DIR),undefined) 81 | ROOT_DIR := $(abspath $(SELF_DIR)) 82 | endif 83 | 84 | CACHE_DIR ?= $(ROOT_DIR)/.cache 85 | TOOLS_HOST_DIR ?= $(CACHE_DIR)/tools/$(SAFEHOST_PLATFORM) 86 | 87 | GO_LINT_DIR := $(abspath $(OUTPUT_DIR)/lint) 88 | GO_LINT_OUTPUT := $(GO_LINT_DIR)/$(PLATFORM) 89 | 90 | GITHUB_URL ?= https://github.com 91 | 92 | ifneq ($(CI),) 93 | RUNNING_IN_CI := true 94 | endif 95 | 96 | # ============================================================================== 97 | # Tools 98 | 99 | GOLANGCILINT_VERSION ?= 1.49.0 100 | GOLANGCILINT := $(TOOLS_HOST_DIR)/golangci-lint-v$(GOLANGCILINT_VERSION) 101 | GOLANGCILINT_TEMP := $(TOOLS_HOST_DIR)/tmp-golangci-lint 102 | 103 | $(GOLANGCILINT): 104 | @$(INFO) installing golangci-lint-v$(GOLANGCILINT_VERSION) $(SAFEHOSTPLATFORM) 105 | @mkdir -p $(GOLANGCILINT_TEMP) || $(FAIL) 106 | @curl -fsSL $(GITHUB_URL)/golangci/golangci-lint/releases/download/v$(GOLANGCILINT_VERSION)/golangci-lint-$(GOLANGCILINT_VERSION)-$(SAFEHOSTPLATFORM).tar.gz | tar -xz --strip-components=1 -C $(GOLANGCILINT_TEMP) || $(FAIL) 107 | @mv $(GOLANGCILINT_TEMP)/golangci-lint $(GOLANGCILINT) || $(FAIL) 108 | @rm -fr $(GOLANGCILINT_TEMP) 109 | @$(OK) installing golangci-lint-v$(GOLANGCILINT_VERSION) $(SAFEHOSTPLATFORM) 110 | 111 | GORELEASER_VERSION ?= 1.11.2 112 | GORELEASER := $(TOOLS_HOST_DIR)/goreleaser-v$(GORELEASER_VERSION) 113 | GORELEASER_TEMP := $(TOOLS_HOST_DIR)/tmp-goreleaser 114 | ifeq ($(SAFEHOSTARCH),amd64) 115 | GORELEASER_ARCH := x86_64 116 | else 117 | GORELEASER_ARCH := $(SAFEHOSTARCH) 118 | endif 119 | 120 | $(GORELEASER): 121 | @$(INFO) installing goreleaser-v$(GORELEASER_VERSION) $(SAFEHOSTPLATFORM) 122 | @mkdir -p $(GORELEASER_TEMP) || $(FAIL) 123 | @curl -fsSL $(GITHUB_URL)/goreleaser/goreleaser/releases/download/v$(GORELEASER_VERSION)/goreleaser_$(HOSTOS)_$(GORELEASER_ARCH).tar.gz | tar -xz -C $(GORELEASER_TEMP) || $(FAIL) 124 | @mv $(GORELEASER_TEMP)/goreleaser $(GORELEASER) || $(FAIL) 125 | @rm -fr $(GORELEASER_TEMP) 126 | @$(OK) installing goreleaser-v$(GORELEASER_VERSION) $(SAFEHOSTPLATFORM) 127 | 128 | # ============================================================================== 129 | # Targets 130 | 131 | build: $(GORELEASER) 132 | @$(INFO) Building snapshot for host platform 133 | @$(GORELEASER) build --rm-dist --snapshot --single-target || $(FAIL) 134 | @$(OK) Building snapshot for host platform 135 | 136 | build.all: $(GORELEASER) 137 | @$(INFO) Building binaries for all platforms 138 | @$(GORELEASER) build --rm-dist --snapshot || $(FAIL) 139 | @$(OK) Building binaries for all platforms 140 | 141 | release: $(GORELEASER) 142 | @$(INFO) Building release for all platforms 143 | @$(GORELEASER) release --rm-dist 144 | @$(OK) Building release for all platforms 145 | 146 | ifeq ($(RUNNING_IN_CI),true) 147 | # The timeout is increased to 10m, to accommodate CI machines with low resources. 148 | GO_LINT_ARGS += --timeout 10m0s 149 | endif 150 | 151 | lint: $(GOLANGCILINT) 152 | @$(INFO) golangci-lint 153 | @$(GOLANGCILINT) run $(GO_LINT_ARGS) || $(FAIL) 154 | @$(OK) golangci-lint 155 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # crossplane-lint 2 | [Crossplane compositions](https://docs.crossplane.io/v1.10/reference/composition/) are a great way to build platform APIs. However they are also a great way to introduce bugs! `crossplane-lint` helps to find those issues as it knows about the internals of compositions & XRDs. 3 | 4 | It validates: 5 | - Schema of managed resources against compositions 6 | - Schema of XRDs against compositions 7 | - Different patch types (`FromCompositeFieldPath`, `ToCompositeFieldPath` and `CombineFromComposite`) 8 | 9 | ## Commands 10 | 11 | For a detailed overview of all commands and parameters run `crossplane-lint --help`. 12 | 13 | ### Package linting 14 | 15 | ```bash 16 | crossplane-lint package -f 17 | ``` 18 | 19 | Scans crossplane composition and XRDs in the given directory for issues. 20 | The linter can load additional packages (for example to include provider CRDs) that are defined in `.crossplane-lint.yaml` in the current working directory: 21 | 22 | ```yaml 23 | additionalPackages: 24 | - image: crossplanecontrib/provider-aws:v0.34.0 25 | - image: crossplanecontrib/provider-gitlab:v0.3.0 26 | - image: crossplanecontrib/provider-helm:v0.12.0 27 | - image: xpkg.upbound.io/grafana/provider-grafana:v0.0.10 28 | - image: crossplanecontrib/provider-kubernetes:v0.5.0 29 | - image: crossplanecontrib/provider-styra:v0.3.0 30 | ``` 31 | ## Roadmap 32 | - Patch Static Type Checking 33 | - Patch Transform validation 34 | - Validaton of the XRD Schema itself 35 | - Disable linter rules per file / line 36 | ## Development 37 | 38 | The binaries are built using [goreleaser](https://github.com/goreleaser/goreleaser). 39 | The individual build steps are executed using `make`. 40 | 41 | To build a snapshot for your current OS: 42 | ``` 43 | make build 44 | ``` 45 | 46 | To build a snapshot for all platforms: 47 | ``` 48 | make build.all 49 | ``` 50 | 51 | To lint your code: 52 | ``` 53 | make lint 54 | ``` 55 | 56 | Currently, only builds for Mac and Linux (arm and amd64) are available. 57 | -------------------------------------------------------------------------------- /cmd/crossplane-lint/lint_package.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | 7 | "github.com/go-log/log" 8 | "github.com/pkg/errors" 9 | "github.com/spf13/afero" 10 | "sigs.k8s.io/yaml" 11 | 12 | "github.com/crossplane-contrib/crossplane-lint/internal/config" 13 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/fetch" 14 | linter "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint/linter" 15 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint/print" 16 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint/schema" 17 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/parse" 18 | ) 19 | 20 | const ( 21 | errParsePackage = "failed to parse package" 22 | errLoadPackageDependencies = "failed to load package dependencies" 23 | errLintPackage = "failed to lint package" 24 | errLinterIssues = "%d issues discovered during linting" 25 | errLoadConfig = "failed to load config" 26 | errRegisterPackageSchema = "failed to register package schemas" 27 | ) 28 | 29 | type lintPackageCmd struct { 30 | Package string `short:"f" help:"Path to the package that should be linted" type:"existingDir" required:"true"` 31 | 32 | Config string `env:"CROSSPLANE-LINT_CONFIG" type:"path" help:"Path to the config file." default:".crossplane-lint.yaml"` 33 | Home string `env:"CROSSPLANE-LINT_HOME" type:"path" help:"Path to the CROSSPLANE-LINT home directy."` 34 | } 35 | 36 | func (c *lintPackageCmd) Run(fs afero.Fs, logger log.Logger) error { 37 | parser := parse.NewPackageDirectoryParser(fs) 38 | 39 | pkg, err := parser.ParsePackage(c.Package) 40 | if err != nil { 41 | return errors.Wrap(err, errParsePackage) 42 | } 43 | 44 | config, err := c.getConfig(fs) 45 | if err != nil { 46 | return errors.Wrap(err, errLoadConfig) 47 | } 48 | 49 | imageCacheDir, err := c.getImageCacheDir() 50 | if err != nil { 51 | return err 52 | } 53 | fetcher := fetch.NewFsCacheFetcher( 54 | afero.NewBasePathFs(fs, imageCacheDir), 55 | fetch.NewRemoteFetcher(), 56 | ) 57 | 58 | pkgDeps, err := parse.LoadPackageDependencies(config.AdditionalPackages, parse.NewPackageImageParser(fetcher)) 59 | if err != nil { 60 | return errors.Wrap(err, errLoadPackageDependencies) 61 | } 62 | 63 | schemaStore := schema.NewSchemaStore() 64 | if err := schemaStore.RegisterPackage(pkg); err != nil { 65 | return errors.Wrap(err, errRegisterPackageSchema) 66 | } 67 | for _, dep := range pkgDeps { 68 | if err := schemaStore.RegisterPackage(dep); err != nil { 69 | return errors.Wrap(err, errRegisterPackageSchema) 70 | } 71 | } 72 | 73 | pkgLinter := linter.Newlinter(schemaStore) 74 | report := pkgLinter.Lint(pkg) 75 | if len(report.Issues) == 0 { 76 | return nil 77 | } 78 | printer := c.buildPrinter() 79 | if err := printer.PrintReport(report); err != nil { 80 | return err 81 | } 82 | return errors.Errorf(errLinterIssues, len(report.Issues)) 83 | } 84 | 85 | func (c *lintPackageCmd) buildPrinter() print.Printer { 86 | return print.NewTextPrinter(os.Stdout) 87 | } 88 | 89 | func (c *lintPackageCmd) getImageCacheDir() (string, error) { 90 | var homeDir string 91 | 92 | if c.Home != "" { 93 | homeDir = c.Home 94 | } else { 95 | userHome, err := os.UserConfigDir() 96 | if err != nil { 97 | return "", err 98 | } 99 | homeDir = filepath.Join(userHome, "crossplane-lint") 100 | } 101 | return filepath.Join(homeDir, "images"), nil 102 | } 103 | 104 | func (c *lintPackageCmd) getConfig(fs afero.Fs) (config.Configuration, error) { 105 | data, err := afero.ReadFile(fs, c.Config) 106 | if err != nil { 107 | return config.Configuration{}, err 108 | } 109 | con := config.Configuration{} 110 | if err := yaml.Unmarshal(data, &con); err != nil { 111 | return config.DefaultConfig, errorIgnore(err, os.IsNotExist) 112 | } 113 | return con, nil 114 | } 115 | 116 | func errorIgnore(err error, filter func(error) bool) error { 117 | if filter(err) { 118 | return nil 119 | } 120 | return err 121 | } 122 | -------------------------------------------------------------------------------- /cmd/crossplane-lint/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/alecthomas/kong" 7 | "github.com/go-log/log" 8 | fmtLog "github.com/go-log/log/fmt" 9 | "github.com/spf13/afero" 10 | ) 11 | 12 | var cli struct { 13 | // Lint struct { 14 | // Package lintPackageCmd `cmd:"package" help:"Scan a package for issues"` 15 | // } `cmd:"lint"` 16 | Package lintPackageCmd `cmd:"package" help:"Scan a directory of compositions and XRDs"` 17 | Version versionCmd `cmd:"version" help:"Print version information"` 18 | } 19 | 20 | var _ = kong.Must(&cli) 21 | 22 | func main() { 23 | fs := afero.NewOsFs() 24 | logger := fmtLog.NewFromWriter(os.Stderr) 25 | 26 | ctx := kong.Parse(&cli, 27 | kong.Name("crossplane-lint"), 28 | kong.Description("Linting of crossplane compositions and XRDs"), 29 | kong.BindTo(fs, (*afero.Fs)(nil)), 30 | kong.BindTo(logger, (*log.Logger)(nil)), 31 | ) 32 | 33 | err := ctx.Run() 34 | ctx.FatalIfErrorf(err) 35 | } 36 | -------------------------------------------------------------------------------- /cmd/crossplane-lint/version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/pkg/errors" 8 | "sigs.k8s.io/yaml" 9 | ) 10 | 11 | const ( 12 | errFmtInvalidFormatParameter = "invalid format parameter '%s'" 13 | ) 14 | 15 | // Set by goreleaser. 16 | // See https://goreleaser.com/cookbooks/using-main.version?h=ldf. 17 | var ( 18 | version string = "dev" 19 | commit string = "none" 20 | date string = "unknown" 21 | ) 22 | 23 | type versionInfo struct { 24 | Version string `json:"version"` 25 | GitCommit string `json:"gitCommit"` 26 | BuildDate string `json:"buildDate"` 27 | } 28 | 29 | type versionCmd struct { 30 | Output string `short:"o" enum:"yaml,json" default:"yaml" help:"Defines the output format of the version information."` 31 | } 32 | 33 | func (c *versionCmd) Run() error { 34 | v := &versionInfo{ 35 | Version: version, 36 | GitCommit: commit, 37 | BuildDate: date, 38 | } 39 | 40 | output, err := c.formatOutput(v) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | fmt.Println(string(output)) 46 | return nil 47 | } 48 | 49 | func (c *versionCmd) formatOutput(v *versionInfo) ([]byte, error) { 50 | switch c.Output { 51 | case "yaml": 52 | return yaml.Marshal(v) 53 | case "json": 54 | return json.Marshal(v) 55 | } 56 | return nil, errors.Errorf(errFmtInvalidFormatParameter, c.Output) 57 | } 58 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/crossplane-contrib/crossplane-lint 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/alecthomas/kong v0.3.0 7 | github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.0.0-20220516163817-760aa214b375 8 | github.com/crossplane/crossplane v1.10.0 9 | github.com/crossplane/crossplane-runtime v0.19.0-rc.0.0.20221012013934-bce61005a175 10 | github.com/go-log/log v0.2.0 11 | github.com/google/go-containerregistry v0.11.0 12 | github.com/gookit/color v1.5.2 13 | github.com/pkg/errors v0.9.1 14 | github.com/spf13/afero v1.8.0 15 | github.com/vmware-labs/yaml-jsonpath v0.3.2 16 | golang.org/x/sync v0.0.0-20220601150217-0de741cfad7f 17 | gopkg.in/yaml.v3 v3.0.1 18 | k8s.io/apiextensions-apiserver v0.23.0 19 | k8s.io/apimachinery v0.24.0 20 | k8s.io/utils v0.0.0-20220210201930-3a6ce19ff2f9 21 | sigs.k8s.io/yaml v1.3.0 22 | ) 23 | 24 | require ( 25 | github.com/aws/aws-sdk-go-v2 v1.16.3 // indirect 26 | github.com/aws/aws-sdk-go-v2/config v1.15.6 // indirect 27 | github.com/aws/aws-sdk-go-v2/credentials v1.12.1 // indirect 28 | github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.12.4 // indirect 29 | github.com/aws/aws-sdk-go-v2/internal/configsources v1.1.10 // indirect 30 | github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.4.4 // indirect 31 | github.com/aws/aws-sdk-go-v2/internal/ini v1.3.11 // indirect 32 | github.com/aws/aws-sdk-go-v2/service/ecr v1.17.4 // indirect 33 | github.com/aws/aws-sdk-go-v2/service/ecrpublic v1.13.4 // indirect 34 | github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.9.4 // indirect 35 | github.com/aws/aws-sdk-go-v2/service/sso v1.11.4 // indirect 36 | github.com/aws/aws-sdk-go-v2/service/sts v1.16.5 // indirect 37 | github.com/aws/smithy-go v1.11.2 // indirect 38 | github.com/beorn7/perks v1.0.1 // indirect 39 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 40 | github.com/containerd/stargz-snapshotter/estargz v0.12.0 // indirect 41 | github.com/davecgh/go-spew v1.1.1 // indirect 42 | github.com/docker/cli v20.10.17+incompatible // indirect 43 | github.com/docker/distribution v2.8.1+incompatible // indirect 44 | github.com/docker/docker v20.10.17+incompatible // indirect 45 | github.com/docker/docker-credential-helpers v0.6.4 // indirect 46 | github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960 // indirect 47 | github.com/emicklei/go-restful v2.15.0+incompatible // indirect 48 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 49 | github.com/fsnotify/fsnotify v1.5.1 // indirect 50 | github.com/go-logr/logr v1.2.3 // indirect 51 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 52 | github.com/go-openapi/jsonreference v0.20.0 // indirect 53 | github.com/go-openapi/swag v0.21.1 // indirect 54 | github.com/gogo/protobuf v1.3.2 // indirect 55 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 56 | github.com/golang/protobuf v1.5.2 // indirect 57 | github.com/google/gnostic v0.6.9 // indirect 58 | github.com/google/go-cmp v0.5.8 // indirect 59 | github.com/google/gofuzz v1.2.0 // indirect 60 | github.com/google/uuid v1.3.0 // indirect 61 | github.com/imdario/mergo v0.3.12 // indirect 62 | github.com/jmespath/go-jmespath v0.4.0 // indirect 63 | github.com/josharian/intern v1.0.0 // indirect 64 | github.com/json-iterator/go v1.1.12 // indirect 65 | github.com/klauspost/compress v1.15.8 // indirect 66 | github.com/kr/pretty v0.2.1 // indirect 67 | github.com/mailru/easyjson v0.7.7 // indirect 68 | github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect 69 | github.com/mitchellh/go-homedir v1.1.0 // indirect 70 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 71 | github.com/modern-go/reflect2 v1.0.2 // indirect 72 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 73 | github.com/opencontainers/go-digest v1.0.0 // indirect 74 | github.com/opencontainers/image-spec v1.0.3-0.20220114050600-8b9d41f48198 // indirect 75 | github.com/prometheus/client_golang v1.11.0 // indirect 76 | github.com/prometheus/client_model v0.2.0 // indirect 77 | github.com/prometheus/common v0.30.0 // indirect 78 | github.com/prometheus/procfs v0.7.3 // indirect 79 | github.com/sirupsen/logrus v1.9.0 // indirect 80 | github.com/spf13/pflag v1.0.5 // indirect 81 | github.com/vbatts/tar-split v0.11.2 // indirect 82 | github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778 // indirect 83 | golang.org/x/net v0.0.0-20220909164309-bea034e7d591 // indirect 84 | golang.org/x/oauth2 v0.0.0-20220909003341-f21342109be1 // indirect 85 | golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10 // indirect 86 | golang.org/x/term v0.0.0-20220411215600-e5f449aeb171 // indirect 87 | golang.org/x/text v0.3.7 // indirect 88 | golang.org/x/time v0.0.0-20220411224347-583f2d630306 // indirect 89 | gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect 90 | google.golang.org/appengine v1.6.7 // indirect 91 | google.golang.org/protobuf v1.28.1 // indirect 92 | gopkg.in/inf.v0 v0.9.1 // indirect 93 | gopkg.in/yaml.v2 v2.4.0 // indirect 94 | gotest.tools/v3 v3.1.0 // indirect 95 | k8s.io/api v0.24.0 // indirect 96 | k8s.io/client-go v0.24.0 // indirect 97 | k8s.io/component-base v0.23.0 // indirect 98 | k8s.io/klog/v2 v2.60.1 // indirect 99 | k8s.io/kube-openapi v0.0.0-20220413171646-5e7f5fdc6da6 // indirect 100 | sigs.k8s.io/controller-runtime v0.11.0 // indirect 101 | sigs.k8s.io/json v0.0.0-20211208200746-9f7c6b3444d2 // indirect 102 | sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect 103 | ) 104 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | type PackageDescriptor struct { 4 | Image string `json:"image"` 5 | } 6 | 7 | type Configuration struct { 8 | AdditionalPackages []PackageDescriptor `json:"additionalPackages"` 9 | } 10 | 11 | var ( 12 | DefaultConfig = Configuration{ 13 | AdditionalPackages: []PackageDescriptor{}, 14 | } 15 | ) 16 | -------------------------------------------------------------------------------- /internal/utils/sync/collect.go: -------------------------------------------------------------------------------- 1 | package sync 2 | 3 | import "sync" 4 | 5 | // CollectWithError collects the results of resChan and errChan and returns the 6 | // results as array. 7 | func CollectWithError[TRes any](resChan chan TRes, errChan chan error) ([]TRes, []error) { 8 | results := []TRes{} 9 | errors := []error{} 10 | wg := sync.WaitGroup{} 11 | wg.Add(2) 12 | go func() { 13 | for r := range resChan { 14 | results = append(results, r) 15 | } 16 | wg.Done() 17 | }() 18 | go func() { 19 | for err := range errChan { 20 | errors = append(errors, err) 21 | } 22 | wg.Done() 23 | }() 24 | wg.Wait() 25 | return results, errors 26 | } 27 | -------------------------------------------------------------------------------- /internal/xpkg/fetch/cache.go: -------------------------------------------------------------------------------- 1 | package fetch 2 | 3 | import ( 4 | "context" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | 12 | "github.com/google/go-containerregistry/pkg/name" 13 | v1 "github.com/google/go-containerregistry/pkg/v1" 14 | "github.com/google/go-containerregistry/pkg/v1/tarball" 15 | "github.com/pkg/errors" 16 | "github.com/spf13/afero" 17 | ) 18 | 19 | const ( 20 | errGetCached = "failed to load cached image" 21 | errStoreCache = "failed to cache image" 22 | ) 23 | 24 | var _ Fetcher = &FsCacheFetcher{} 25 | 26 | type FsCacheFetcher struct { 27 | fetcher Fetcher 28 | fs afero.Fs 29 | } 30 | 31 | func NewFsCacheFetcher(fs afero.Fs, wrapped Fetcher) *FsCacheFetcher { 32 | return &FsCacheFetcher{ 33 | fs: fs, 34 | fetcher: wrapped, 35 | } 36 | } 37 | 38 | func (f *FsCacheFetcher) Fetch(ctx context.Context, ref name.Reference, secrets ...string) (v1.Image, error) { 39 | cached, err := f.getCached(ref) 40 | if err != nil { 41 | return nil, errors.Wrap(err, errGetCached) 42 | } 43 | if cached != nil { 44 | return cached, nil 45 | } 46 | 47 | // If not cached, fetch it and store it. 48 | img, err := f.fetcher.Fetch(ctx, ref) 49 | if err != nil { 50 | return nil, err 51 | } 52 | // Return the image anyway even if caching fails. 53 | return img, errors.Wrap(f.store(img, ref), errStoreCache) 54 | } 55 | 56 | func (f *FsCacheFetcher) getCached(ref name.Reference) (v1.Image, error) { 57 | fileName := cachedFileName(ref) 58 | exists, err := afero.Exists(f.fs, fileName) 59 | if err != nil { 60 | return nil, err 61 | } 62 | if !exists { 63 | return nil, nil 64 | } 65 | opener := func() (io.ReadCloser, error) { 66 | return f.fs.Open(fileName) 67 | } 68 | return tarball.Image(opener, nil) 69 | } 70 | 71 | func (f *FsCacheFetcher) store(img v1.Image, ref name.Reference) error { 72 | fileName := cachedFileName(ref) 73 | tempFileName := fmt.Sprintf("%s.tmp", fileName) 74 | if err := f.fs.MkdirAll(filepath.Dir(fileName), 0755); err != nil { 75 | return err 76 | } 77 | file, err := f.fs.OpenFile(tempFileName, os.O_CREATE|os.O_WRONLY, 0644) 78 | if err != nil { 79 | return err 80 | } 81 | defer func() { 82 | if file != nil { 83 | file.Close() 84 | } 85 | }() 86 | if err := tarball.Write(ref, img, file); err != nil { 87 | return err 88 | } 89 | file.Close() 90 | file = nil 91 | return f.fs.Rename(tempFileName, fileName) 92 | } 93 | 94 | func cachedFileName(ref name.Reference) string { 95 | sum := md5.Sum([]byte(ref.Name())) 96 | return hex.EncodeToString(sum[:]) 97 | } 98 | -------------------------------------------------------------------------------- /internal/xpkg/fetch/interface.go: -------------------------------------------------------------------------------- 1 | package fetch 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/google/go-containerregistry/pkg/name" 7 | v1 "github.com/google/go-containerregistry/pkg/v1" 8 | ) 9 | 10 | // Fetcher fetches package images. 11 | type Fetcher interface { 12 | Fetch(ctx context.Context, ref name.Reference, secrets ...string) (v1.Image, error) 13 | // Head(ctx context.Context, ref name.Reference) (*v1.Descriptor, error) 14 | } 15 | -------------------------------------------------------------------------------- /internal/xpkg/fetch/remote.go: -------------------------------------------------------------------------------- 1 | package fetch 2 | 3 | import ( 4 | "context" 5 | "io" 6 | "net/http" 7 | 8 | ecr "github.com/awslabs/amazon-ecr-credential-helper/ecr-login" 9 | "github.com/google/go-containerregistry/pkg/authn" 10 | "github.com/google/go-containerregistry/pkg/name" 11 | v1 "github.com/google/go-containerregistry/pkg/v1" 12 | "github.com/google/go-containerregistry/pkg/v1/remote" 13 | ) 14 | 15 | var ( 16 | amazonKeychain authn.Keychain = authn.NewKeychainFromHelper(ecr.NewECRHelper(ecr.WithLogger(io.Discard))) 17 | ) 18 | 19 | var _ Fetcher = &RemoteFetcher{} 20 | 21 | // RemoteFetcher uses default and AWS credentials to connect to a registry. 22 | type RemoteFetcher struct { 23 | transport http.RoundTripper 24 | } 25 | 26 | // NewRemoteFetcher creates a new RemoteFetcher. 27 | func NewRemoteFetcher() *RemoteFetcher { 28 | return &RemoteFetcher{ 29 | transport: remote.DefaultTransport.Clone(), 30 | } 31 | } 32 | 33 | // Fetch fetches a package image. 34 | func (i *RemoteFetcher) Fetch(ctx context.Context, ref name.Reference, secrets ...string) (v1.Image, error) { 35 | auth := authn.NewMultiKeychain( 36 | authn.DefaultKeychain, 37 | amazonKeychain, 38 | ) 39 | return remote.Image(ref, remote.WithAuthFromKeychain(auth), remote.WithTransport(i.transport), remote.WithContext(ctx)) 40 | } 41 | -------------------------------------------------------------------------------- /internal/xpkg/lint/interface.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 5 | "k8s.io/apimachinery/pkg/runtime/schema" 6 | 7 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 8 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint/jsonpath" 9 | ) 10 | 11 | // Linter checks if a package for issues. 12 | type Linter interface { 13 | // Lint pkg. 14 | Lint(pkg *xpkg.Package) LinterReport 15 | } 16 | 17 | type LinterReport struct { 18 | Issues []Issue 19 | } 20 | 21 | type Issue struct { 22 | RuleName string 23 | Entry *xpkg.PackageEntry 24 | Path jsonpath.JSONPath 25 | PathValue string 26 | Description string 27 | } 28 | 29 | type LinterContext interface { 30 | ReportIssue(issue Issue) 31 | GetCRDSchema(gvk schema.GroupVersionKind) *extv1.CustomResourceDefinitionVersion 32 | } 33 | -------------------------------------------------------------------------------- /internal/xpkg/lint/jsonpath/jsonpath.go: -------------------------------------------------------------------------------- 1 | package jsonpath 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | type JSONPathSegment interface { 9 | String() string 10 | } 11 | 12 | type FieldSegment string 13 | 14 | func (f FieldSegment) String() string { 15 | if strings.ContainsRune(string(f), '.') { 16 | return fmt.Sprintf("[%s]", string(f)) 17 | } 18 | return fmt.Sprintf(".%s", string(f)) 19 | } 20 | 21 | type IndexSegment int 22 | 23 | func (i IndexSegment) String() string { 24 | return fmt.Sprintf("[%d]", i) 25 | } 26 | 27 | type JSONPath []JSONPathSegment 28 | 29 | func (p JSONPath) String() string { 30 | b := strings.Builder{} 31 | for _, s := range p { 32 | b.WriteString(s.String()) 33 | } 34 | return b.String() 35 | } 36 | 37 | func NewJSONPath(rawSegments ...any) JSONPath { 38 | path := make(JSONPath, len(rawSegments)) 39 | for i, s := range rawSegments { 40 | switch val := s.(type) { 41 | case JSONPathSegment: 42 | path[i] = val 43 | case int: 44 | path[i] = IndexSegment(val) 45 | case string: 46 | path[i] = FieldSegment(val) 47 | default: 48 | path[i] = FieldSegment(fmt.Sprint(val)) 49 | } 50 | } 51 | return path 52 | } 53 | -------------------------------------------------------------------------------- /internal/xpkg/lint/linter/linter.go: -------------------------------------------------------------------------------- 1 | package lint 2 | 3 | import ( 4 | "golang.org/x/sync/errgroup" 5 | extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 6 | "k8s.io/apimachinery/pkg/runtime/schema" 7 | 8 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 9 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint" 10 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint/linter/rules" 11 | lintschema "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint/schema" 12 | ) 13 | 14 | type linterContext struct { 15 | ruleName string 16 | issueChan chan lint.Issue 17 | schemaStore *lintschema.SchemaStore 18 | } 19 | 20 | func (c *linterContext) ReportIssue(issue lint.Issue) { 21 | issue.RuleName = c.ruleName 22 | c.issueChan <- issue 23 | } 24 | 25 | func (c *linterContext) GetCRDSchema(gvk schema.GroupVersionKind) *extv1.CustomResourceDefinitionVersion { 26 | return c.schemaStore.GetCRDSchema(gvk) 27 | } 28 | 29 | var defaultRules = map[string]LinterRule{ 30 | "generic.checkDuplicates": LinterRuleFunc(rules.CheckDuplicateObjects), 31 | "composition.checkCompositeType": LinterRuleFunc(rules.CheckCompositionCompositeTypeRef), 32 | "composition.checkPathFieldPaths": LinterRuleFunc(rules.CheckCompositionFieldPaths), 33 | } 34 | 35 | var _ lint.Linter = &linter{} 36 | 37 | type LinterRule interface { 38 | Validate(ctx lint.LinterContext, pkg *xpkg.Package) 39 | } 40 | 41 | type LinterRuleFunc func(ctx lint.LinterContext, pkg *xpkg.Package) 42 | 43 | func (f LinterRuleFunc) Validate(ctx lint.LinterContext, pkg *xpkg.Package) { 44 | f(ctx, pkg) 45 | } 46 | 47 | type linter struct { 48 | schemaValidator *lintschema.SchemaStore 49 | rules map[string]LinterRule 50 | } 51 | 52 | func Newlinter(schemaValidator *lintschema.SchemaStore) lint.Linter { 53 | return &linter{ 54 | schemaValidator: schemaValidator, 55 | rules: defaultRules, 56 | } 57 | } 58 | 59 | func (l *linter) Lint(pkg *xpkg.Package) lint.LinterReport { 60 | issueChan := l.runRulesConcurrently(pkg) 61 | report := lint.LinterReport{} 62 | for iss := range issueChan { 63 | report.Issues = append(report.Issues, iss) 64 | } 65 | return report 66 | } 67 | 68 | func (l *linter) runRulesConcurrently(pkg *xpkg.Package) chan lint.Issue { 69 | issueChan := make(chan lint.Issue) 70 | eg := errgroup.Group{} 71 | 72 | for name, r := range l.rules { 73 | ctx := &linterContext{ 74 | ruleName: name, 75 | issueChan: issueChan, 76 | schemaStore: l.schemaValidator, 77 | } 78 | rule := r 79 | eg.Go(func() error { 80 | rule.Validate(ctx, pkg) 81 | return nil 82 | }) 83 | } 84 | go func() { 85 | _ = eg.Wait() 86 | close(issueChan) 87 | }() 88 | return issueChan 89 | } 90 | -------------------------------------------------------------------------------- /internal/xpkg/lint/linter/rules/composition.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | xpv1 "github.com/crossplane/crossplane/apis/apiextensions/v1" 5 | "github.com/pkg/errors" 6 | "k8s.io/apimachinery/pkg/runtime/schema" 7 | 8 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 9 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint" 10 | ) 11 | 12 | const ( 13 | errConvertTo = "failed to convert object to %s" 14 | 15 | errParseGroupVersion = "failed to convert object to Composition" 16 | errNoMatchingCompositeType = "no composite type found for %s.%s/%s" 17 | ) 18 | 19 | // CheckCompositionCompositeTypeRef checks if a composition in manifest points 20 | // to a valid composition. 21 | func CheckCompositionCompositeTypeRef(ctx lint.LinterContext, pkg *xpkg.Package) { 22 | for _, manifest := range pkg.Entries { 23 | if !manifest.IsComposition() { 24 | continue 25 | } 26 | comp, err := manifest.AsComposition() 27 | if err != nil { 28 | ctx.ReportIssue(lint.Issue{ 29 | Entry: &manifest, 30 | Description: errors.Wrapf(err, errConvertTo, "Composition").Error(), 31 | }) 32 | continue 33 | } 34 | gv, err := schema.ParseGroupVersion(comp.Spec.CompositeTypeRef.APIVersion) 35 | if err != nil { 36 | ctx.ReportIssue(lint.Issue{ 37 | Entry: &manifest, 38 | Description: errors.Wrap(err, errParseGroupVersion).Error(), 39 | }) 40 | continue 41 | } 42 | gvk := gv.WithKind(comp.Spec.CompositeTypeRef.Kind) 43 | xrd, issue := getCompositeXRD(pkg, gvk) 44 | if issue != nil { 45 | ctx.ReportIssue(*issue) 46 | } else if xrd == nil { 47 | ctx.ReportIssue(lint.Issue{ 48 | Entry: &manifest, 49 | Description: errors.Errorf(errNoMatchingCompositeType, gvk.Kind, gvk.Group, gvk.Version).Error(), 50 | }) 51 | } 52 | } 53 | } 54 | 55 | func getCompositeXRD(pkg *xpkg.Package, compositeGVK schema.GroupVersionKind) (*xpv1.CompositeResourceDefinitionVersion, *lint.Issue) { 56 | for _, e := range pkg.Entries { 57 | if !e.IsXRD() { 58 | continue 59 | } 60 | xrd, err := e.AsXRD() 61 | if err != nil { 62 | return nil, &lint.Issue{ 63 | Entry: &e, 64 | Description: errors.Wrapf(err, errConvertTo, "XRD").Error(), 65 | } 66 | } 67 | if xrd.Spec.Group == compositeGVK.Group && xrd.Spec.Names.Kind == compositeGVK.Kind { 68 | for _, v := range xrd.Spec.Versions { 69 | if v.Name == compositeGVK.Version { 70 | return &v, nil 71 | } 72 | } 73 | } 74 | } 75 | return nil, nil 76 | } 77 | -------------------------------------------------------------------------------- /internal/xpkg/lint/linter/rules/composition_fieldpath.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "sync" 5 | 6 | "github.com/crossplane/crossplane-runtime/pkg/fieldpath" 7 | xpv1 "github.com/crossplane/crossplane/apis/apiextensions/v1" 8 | "github.com/pkg/errors" 9 | extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 10 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | "k8s.io/utils/pointer" 13 | "sigs.k8s.io/yaml" 14 | 15 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 16 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint" 17 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint/jsonpath" 18 | ) 19 | 20 | type scopedContext struct { 21 | linterContext lint.LinterContext 22 | entry *xpkg.PackageEntry 23 | basePath jsonpath.JSONPath 24 | } 25 | 26 | func (c *scopedContext) ReportIssueRequireField(path jsonpath.JSONPath) { 27 | c.linterContext.ReportIssue(lint.Issue{ 28 | Entry: c.entry, 29 | Description: "require field", 30 | Path: jsonpath.NewJSONPath(c.basePath, path), 31 | }) 32 | } 33 | 34 | func (c *scopedContext) ReportIssueFieldPath(path jsonpath.JSONPath, description, pathValue string) { 35 | c.linterContext.ReportIssue(lint.Issue{ 36 | Entry: c.entry, 37 | Description: description, 38 | Path: jsonpath.NewJSONPath(c.basePath, path), 39 | PathValue: pathValue, 40 | }) 41 | } 42 | 43 | func (c *scopedContext) Wrap(path jsonpath.JSONPath) scopedContext { 44 | return scopedContext{ 45 | entry: c.entry, 46 | linterContext: c.linterContext, 47 | basePath: jsonpath.NewJSONPath(c.basePath, path), 48 | } 49 | } 50 | 51 | func CheckCompositionFieldPaths(ctx lint.LinterContext, pkg *xpkg.Package) { 52 | wg := sync.WaitGroup{} 53 | wg.Add(len(pkg.Entries)) 54 | 55 | for _, m := range pkg.Entries { 56 | manifest := m 57 | go func() { 58 | checkCompositionFieldPaths(ctx, pkg, manifest) 59 | wg.Done() 60 | }() 61 | } 62 | wg.Wait() 63 | } 64 | 65 | func checkCompositionFieldPaths(ctx lint.LinterContext, pkg *xpkg.Package, manifest xpkg.PackageEntry) { 66 | if !manifest.IsComposition() { 67 | return 68 | } 69 | comp, err := manifest.AsComposition() 70 | if err != nil { 71 | ctx.ReportIssue(lint.Issue{ 72 | Entry: &manifest, 73 | Description: errors.Wrapf(err, errConvertTo, "Composition").Error(), 74 | }) 75 | return 76 | } 77 | compositeGv, err := schema.ParseGroupVersion(comp.Spec.CompositeTypeRef.APIVersion) 78 | if err != nil { 79 | ctx.ReportIssue(lint.Issue{ 80 | Entry: &manifest, 81 | Description: errors.Wrap(err, errParseGroupVersion).Error(), 82 | }) 83 | return 84 | } 85 | compositeGvk := compositeGv.WithKind(comp.Spec.CompositeTypeRef.Kind) 86 | compositeCRD := ctx.GetCRDSchema(compositeGvk) 87 | if compositeCRD == nil { 88 | ctx.ReportIssue(lint.Issue{ 89 | Entry: &manifest, 90 | Path: jsonpath.NewJSONPath("spec", "compositeTypeRef"), 91 | Description: errors.Errorf(errNoCRDForGVK, compositeGvk.String()).Error(), 92 | }) 93 | return 94 | } 95 | 96 | for i, r := range comp.Spec.Resources { 97 | base := &unstructured.Unstructured{} 98 | if err := yaml.Unmarshal(r.Base.Raw, base); err != nil { 99 | ctx.ReportIssue(lint.Issue{ 100 | Entry: &manifest, 101 | Path: jsonpath.NewJSONPath("spec", "resources", i, "base"), 102 | Description: errors.Wrap(err, "failed to parse base").Error(), 103 | }) 104 | continue 105 | } 106 | baseGvk := base.GetObjectKind().GroupVersionKind() 107 | baseCrd := ctx.GetCRDSchema(baseGvk) 108 | if baseCrd == nil { 109 | ctx.ReportIssue(lint.Issue{ 110 | Entry: &manifest, 111 | Path: jsonpath.NewJSONPath("spec", "resources", i, "base"), 112 | Description: errors.Errorf(errNoCRDForGVK, baseGvk.String()).Error(), 113 | }) 114 | continue 115 | } 116 | for ip, p := range r.Patches { 117 | sctx := scopedContext{ 118 | linterContext: ctx, 119 | entry: &manifest, 120 | basePath: jsonpath.NewJSONPath("spec", "resources", i, "patches", ip), 121 | } 122 | validatePatch(sctx, p, comp, compositeGvk, baseGvk) 123 | } 124 | } 125 | } 126 | 127 | func validatePatch(ctx scopedContext, p xpv1.Patch, comp *xpv1.Composition, compositeGvk, baseGvk schema.GroupVersionKind) { 128 | switch p.Type { 129 | case xpv1.PatchTypeCombineToComposite: 130 | validateCombinePatch(ctx, p, baseGvk, compositeGvk) 131 | case xpv1.PatchTypeCombineFromComposite: 132 | validateCombinePatch(ctx, p, compositeGvk, baseGvk) 133 | case xpv1.PatchTypeToCompositeFieldPath: 134 | validateSinglePatch(ctx, p, baseGvk, compositeGvk) 135 | case "", xpv1.PatchTypeFromCompositeFieldPath: 136 | validateSinglePatch(ctx, p, compositeGvk, baseGvk) 137 | case xpv1.PatchTypePatchSet: 138 | if p.PatchSetName == nil { 139 | ctx.ReportIssueRequireField(jsonpath.NewJSONPath("patchSetName")) 140 | break 141 | } 142 | var patchSet *xpv1.PatchSet 143 | var patchSetIndex int 144 | for i, s := range comp.Spec.PatchSets { 145 | if s.Name == *p.PatchSetName { 146 | patchSet = &s 147 | patchSetIndex = i 148 | break 149 | } 150 | } 151 | if patchSet == nil { 152 | ctx.ReportIssueFieldPath(jsonpath.NewJSONPath("patchSetName"), "no matching patchset found", *p.PatchSetName) 153 | break 154 | } 155 | validatePatchSet(ctx, *patchSet, compositeGvk, baseGvk, jsonpath.NewJSONPath("#inlined", "spec", "patchSets", patchSetIndex)) 156 | default: 157 | ctx.ReportIssueFieldPath(jsonpath.NewJSONPath("type"), "unknown patch type", string(p.Type)) 158 | } 159 | } 160 | 161 | func validateCombinePatch(ctx scopedContext, p xpv1.Patch, fromGvk, toGvk schema.GroupVersionKind) { 162 | if p.Combine == nil || len(p.Combine.Variables) == 0 { 163 | ctx.ReportIssueRequireField(jsonpath.NewJSONPath("combine", "variables")) 164 | } else { 165 | for i, v := range p.Combine.Variables { 166 | if v.FromFieldPath == "" { 167 | ctx.ReportIssueRequireField(jsonpath.NewJSONPath("combine", "variables", i, "fromFieldPath")) 168 | continue 169 | } 170 | if err := validateFieldPath(ctx, fromGvk, v.FromFieldPath); err != nil { 171 | ctx.ReportIssueFieldPath(jsonpath.NewJSONPath("combine", "variables", i, "fromFieldPath"), err.Error(), v.FromFieldPath) 172 | } 173 | } 174 | } 175 | if p.ToFieldPath == nil { 176 | ctx.ReportIssueRequireField(jsonpath.NewJSONPath("toFieldPath")) 177 | return 178 | } 179 | if err := validateFieldPath(ctx, toGvk, *p.ToFieldPath); err != nil { 180 | ctx.ReportIssueFieldPath(jsonpath.NewJSONPath("toFieldPath"), err.Error(), *p.ToFieldPath) 181 | } 182 | } 183 | 184 | func validateSinglePatch(ctx scopedContext, p xpv1.Patch, fromGvk, toGvk schema.GroupVersionKind) { 185 | if p.FromFieldPath == nil { 186 | ctx.ReportIssueRequireField(jsonpath.NewJSONPath("fromFieldPath")) 187 | } else if err := validateFieldPath(ctx, fromGvk, *p.FromFieldPath); err != nil { 188 | ctx.ReportIssueFieldPath(jsonpath.NewJSONPath("fromFieldPath"), err.Error(), *p.FromFieldPath) 189 | } 190 | toFieldPath := p.ToFieldPath 191 | if toFieldPath == nil { 192 | toFieldPath = p.FromFieldPath 193 | } 194 | if toFieldPath == nil { 195 | ctx.ReportIssueRequireField(jsonpath.NewJSONPath("toFieldPath")) 196 | } else if err := validateFieldPath(ctx, toGvk, *toFieldPath); err != nil { 197 | ctx.ReportIssueFieldPath(jsonpath.NewJSONPath("toFieldPath"), err.Error(), *toFieldPath) 198 | } 199 | } 200 | 201 | func validatePatchSet(ctx scopedContext, ps xpv1.PatchSet, compositeGvk, baseGvk schema.GroupVersionKind, patchPath jsonpath.JSONPath) { 202 | for i, p := range ps.Patches { 203 | sctx := ctx.Wrap(jsonpath.NewJSONPath(patchPath, "patches", i, "type")) 204 | switch p.Type { 205 | case xpv1.PatchTypeCombineToComposite: 206 | validateCombinePatch(sctx, p, baseGvk, compositeGvk) 207 | case xpv1.PatchTypeCombineFromComposite: 208 | validateCombinePatch(sctx, p, compositeGvk, baseGvk) 209 | case xpv1.PatchTypeToCompositeFieldPath: 210 | validateSinglePatch(sctx, p, baseGvk, compositeGvk) 211 | case "", xpv1.PatchTypeFromCompositeFieldPath: 212 | validateSinglePatch(sctx, p, compositeGvk, baseGvk) 213 | case xpv1.PatchTypePatchSet: 214 | sctx.ReportIssueFieldPath(jsonpath.NewJSONPath("type"), "nested patch sets are not allowed", string(p.Type)) 215 | default: 216 | sctx.ReportIssueFieldPath(jsonpath.NewJSONPath("type"), "unknown patch type", string(p.Type)) 217 | } 218 | } 219 | } 220 | 221 | const ( 222 | errValidateFieldPath = "failed to validate segment %d" 223 | errNoCRDForGVK = "no CRD for %s" 224 | errFieldPathWrongType = "expected type '%s' but got '%s'" 225 | errFieldNotFound = "property '%s' not found" 226 | errFieldArrayNoItems = "prop type is array but missing items definition" 227 | errInvalidSegmentType = "invalid segment type %d" 228 | ) 229 | 230 | func validateFieldPath(ctx scopedContext, gvk schema.GroupVersionKind, rawPath string) error { 231 | path, err := fieldpath.Parse(rawPath) 232 | if err != nil { 233 | return err 234 | } 235 | crd := ctx.linterContext.GetCRDSchema(gvk) 236 | if crd == nil { 237 | return errors.Errorf(errNoCRDForGVK, gvk.String()) 238 | } 239 | current := crd.Schema.OpenAPIV3Schema 240 | for _, segment := range path { 241 | if current == nil { 242 | return nil 243 | } 244 | var err error 245 | current, err = validateFieldPathSegment(current, segment) 246 | if err != nil { // Workaround for now 247 | return err 248 | } 249 | } 250 | return nil 251 | } 252 | 253 | func validateFieldPathSegment(current *extv1.JSONSchemaProps, segment fieldpath.Segment) (*extv1.JSONSchemaProps, error) { 254 | switch segment.Type { 255 | case fieldpath.SegmentField: 256 | propType := current.Type 257 | if propType == "" { 258 | propType = "object" 259 | } 260 | if propType != "object" { 261 | return nil, errors.Errorf(errFieldPathWrongType, "object", propType) 262 | } 263 | if pointer.BoolDeref(current.XPreserveUnknownFields, false) { 264 | return nil, nil 265 | } 266 | prop, exists := current.Properties[segment.Field] 267 | if !exists { 268 | if current.AdditionalProperties != nil && current.AdditionalProperties.Allows { 269 | return current.AdditionalProperties.Schema, nil 270 | } 271 | return nil, errors.Errorf(errFieldNotFound, segment.Field) 272 | } 273 | return &prop, nil 274 | case fieldpath.SegmentIndex: 275 | if current.Type != "array" { 276 | return nil, errors.Errorf(errFieldPathWrongType, "array", current.Type) 277 | } 278 | // TOOD: We currently don't support multiple item schemas. 279 | if current.Items == nil || current.Items.Schema == nil { 280 | return nil, errors.New(errFieldArrayNoItems) 281 | } 282 | return current.Items.Schema, nil 283 | } 284 | return nil, errors.Errorf(errInvalidSegmentType, segment.Type) 285 | } 286 | -------------------------------------------------------------------------------- /internal/xpkg/lint/linter/rules/generic.go: -------------------------------------------------------------------------------- 1 | package rules 2 | 3 | import ( 4 | "fmt" 5 | 6 | "k8s.io/apimachinery/pkg/runtime/schema" 7 | 8 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 9 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint" 10 | ) 11 | 12 | type groupVersionKindName struct { 13 | gvk schema.GroupVersionKind 14 | name string 15 | } 16 | 17 | // CheckDuplicateObjects checks if there are multiple objects in the package 18 | // that share the same GroupVersionKind and name. 19 | func CheckDuplicateObjects(ctx lint.LinterContext, pkg *xpkg.Package) { 20 | objects := map[groupVersionKindName][]*xpkg.PackageEntry{} 21 | for _, e := range pkg.Entries { 22 | gvkn := groupVersionKindName{ 23 | gvk: e.Object.GroupVersionKind(), 24 | name: e.Object.GetName(), 25 | } 26 | arr, exists := objects[gvkn] 27 | if exists { 28 | objects[gvkn] = append(arr, &e) 29 | } else { 30 | objects[gvkn] = []*xpkg.PackageEntry{&e} 31 | } 32 | } 33 | for gvkn, same := range objects { 34 | if len(same) > 1 { 35 | ctx.ReportIssue(lint.Issue{ 36 | Entry: same[0], 37 | Description: fmt.Sprintf("Multiple objects of kind '%s' with name '%s'", gvkn.gvk.String(), gvkn.name), 38 | }) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/xpkg/lint/print/interface.go: -------------------------------------------------------------------------------- 1 | package print 2 | 3 | import ( 4 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint" 5 | ) 6 | 7 | type Printer interface { 8 | PrintReport(report lint.LinterReport) error 9 | } 10 | -------------------------------------------------------------------------------- /internal/xpkg/lint/print/text.go: -------------------------------------------------------------------------------- 1 | package print 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | 7 | "github.com/gookit/color" 8 | "github.com/pkg/errors" 9 | "github.com/vmware-labs/yaml-jsonpath/pkg/yamlpath" 10 | 11 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 12 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint" 13 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/lint/jsonpath" 14 | ) 15 | 16 | const ( 17 | errEvaluateJSONPath = "failed to evaluate JSON Path" 18 | errGetYamlNode = "failed to parse yaml" 19 | errParsePath = "failed to parse JSON path" 20 | errFindPath = "failed to evaluate JSON path" 21 | ) 22 | 23 | var _ Printer = &TextPrinter{} 24 | 25 | type TextPrinter struct { 26 | out io.Writer 27 | } 28 | 29 | func NewTextPrinter(out io.Writer) *TextPrinter { 30 | return &TextPrinter{ 31 | out: out, 32 | } 33 | } 34 | 35 | func (p *TextPrinter) PrintReport(report lint.LinterReport) error { 36 | for _, issue := range report.Issues { 37 | if err := p.printIssue(issue); err != nil { 38 | return err 39 | } 40 | fmt.Fprintln(p.out, "") 41 | } 42 | return nil 43 | } 44 | 45 | func (p *TextPrinter) printIssue(issue lint.Issue) error { 46 | fmt.Fprintf(p.out, "[%s] %s\n", color.Red.Render(issue.RuleName), issue.Description) 47 | if issue.Entry == nil { 48 | return nil 49 | } 50 | if issue.Path == nil { 51 | fmt.Fprintln(p.out, color.Blue.Render(fmt.Sprintf(" in %s", issue.Entry.Source))) 52 | return nil 53 | } 54 | line, column, err := evalJSONPath(issue.Entry, issue.Path) 55 | if err != nil { 56 | return errors.Wrap(err, errEvaluateJSONPath) 57 | } 58 | fmt.Fprintf(p.out, " %s: %s\n", issue.Path.String(), issue.PathValue) 59 | fmt.Fprintln(p.out, color.Blue.Render(fmt.Sprintf(" in %s:%d:%d", issue.Entry.Source, line, column))) 60 | return nil 61 | } 62 | 63 | func evalJSONPath(e *xpkg.PackageEntry, path jsonpath.JSONPath) (line, column int, err error) { 64 | yamlPath, err := yamlpath.NewPath(path.String()) 65 | if err != nil { 66 | return 0, 0, errors.Wrap(err, errParsePath) 67 | } 68 | node, err := e.GetYamlNode() 69 | if err != nil { 70 | return 0, 0, errors.Wrap(err, errGetYamlNode) 71 | } 72 | pathNodes, err := yamlPath.Find(node) 73 | if err != nil { 74 | return 0, 0, errors.Wrap(err, errFindPath) 75 | } 76 | if len(pathNodes) == 0 { 77 | return 0, 0, nil 78 | } 79 | pathNode := pathNodes[0] 80 | return pathNode.Line, pathNode.Column, nil 81 | } 82 | -------------------------------------------------------------------------------- /internal/xpkg/lint/schema/store.go: -------------------------------------------------------------------------------- 1 | package schema 2 | 3 | import ( 4 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 5 | "github.com/pkg/errors" 6 | extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 7 | "k8s.io/apimachinery/pkg/runtime/schema" 8 | ) 9 | 10 | const ( 11 | errBuildCompositeCRD = "failed to build composite CRD" 12 | errBuildClaimCRD = "failed to build claim CRD" 13 | ) 14 | 15 | type SchemaStore struct { 16 | versions map[schema.GroupVersionKind]*extv1.CustomResourceDefinitionVersion 17 | } 18 | 19 | func NewSchemaStore() *SchemaStore { 20 | return &SchemaStore{ 21 | versions: map[schema.GroupVersionKind]*extv1.CustomResourceDefinitionVersion{}, 22 | } 23 | } 24 | 25 | func (s *SchemaStore) RegisterPackage(pkg *xpkg.Package) error { 26 | for _, e := range pkg.Entries { 27 | switch { 28 | case e.IsCRD(): 29 | crd, err := e.AsCRD() 30 | if err != nil { 31 | return err 32 | } 33 | s.registerCRD(crd) 34 | case e.IsXRD(): 35 | xrd, err := e.AsXRD() 36 | if err != nil { 37 | return err 38 | } 39 | comp, err := ForCompositeResource(xrd) 40 | if err != nil { 41 | return errors.Wrap(err, errBuildCompositeCRD) 42 | } 43 | s.registerCRD(comp) 44 | if xrd.Spec.ClaimNames != nil { 45 | claim, err := ForCompositeResourceClaim(xrd) 46 | if err != nil { 47 | return errors.Wrap(err, errBuildClaimCRD) 48 | } 49 | s.registerCRD(claim) 50 | } 51 | } 52 | } 53 | return nil 54 | } 55 | 56 | func (s *SchemaStore) registerCRD(crd *extv1.CustomResourceDefinition) { 57 | gk := schema.GroupKind{ 58 | Group: crd.Spec.Group, 59 | Kind: crd.Spec.Names.Kind, 60 | } 61 | for _, v := range crd.Spec.Versions { 62 | version := v 63 | gvk := gk.WithVersion(version.Name) 64 | addMetaDataToSchema(&version) 65 | s.versions[gvk] = &version 66 | } 67 | } 68 | 69 | func addMetaDataToSchema(crdv *extv1.CustomResourceDefinitionVersion) { 70 | additionalMetaProps := map[string]extv1.JSONSchemaProps{ 71 | "name": { 72 | Type: "string", 73 | }, 74 | "namespace": { 75 | Type: "string", 76 | }, 77 | "labels": { 78 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 79 | Allows: true, 80 | Schema: &extv1.JSONSchemaProps{ 81 | Type: "string", 82 | }, 83 | }, 84 | }, 85 | "annotations": { 86 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 87 | Allows: true, 88 | Schema: &extv1.JSONSchemaProps{ 89 | Type: "string", 90 | }, 91 | }, 92 | }, 93 | "uid": { 94 | Type: "string", 95 | }, 96 | } 97 | if crdv.Schema == nil { 98 | crdv.Schema = &extv1.CustomResourceValidation{} 99 | } 100 | if crdv.Schema.OpenAPIV3Schema == nil { 101 | crdv.Schema.OpenAPIV3Schema = &extv1.JSONSchemaProps{ 102 | Properties: map[string]extv1.JSONSchemaProps{}, 103 | } 104 | } 105 | if _, exists := crdv.Schema.OpenAPIV3Schema.Properties["metadata"]; !exists { 106 | crdv.Schema.OpenAPIV3Schema.Properties["metadata"] = extv1.JSONSchemaProps{ 107 | Type: "object", 108 | Properties: map[string]extv1.JSONSchemaProps{}, 109 | } 110 | } 111 | if crdv.Schema.OpenAPIV3Schema.Properties["metadata"].Properties == nil { 112 | prop := crdv.Schema.OpenAPIV3Schema.Properties["metadata"] 113 | prop.Properties = map[string]extv1.JSONSchemaProps{} 114 | crdv.Schema.OpenAPIV3Schema.Properties["metadata"] = prop 115 | } 116 | for name, prop := range additionalMetaProps { 117 | if _, exists := crdv.Schema.OpenAPIV3Schema.Properties["metadata"].Properties[name]; !exists { 118 | props := crdv.Schema.OpenAPIV3Schema.Properties["metadata"] 119 | props.Properties[name] = prop 120 | crdv.Schema.OpenAPIV3Schema.Properties["metadata"] = props 121 | } 122 | } 123 | } 124 | 125 | func (s *SchemaStore) GetCRDSchema(gvk schema.GroupVersionKind) *extv1.CustomResourceDefinitionVersion { 126 | version, exists := s.versions[gvk] 127 | if !exists { 128 | return nil 129 | } 130 | return version 131 | } 132 | -------------------------------------------------------------------------------- /internal/xpkg/lint/schema/xcrd.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package xcrd generates CustomResourceDefinitions from Crossplane definitions. 18 | // 19 | // v1.JSONSchemaProps is incompatible with controller-tools (as of 0.2.4) 20 | // because it is missing JSON tags and uses float64, which is a disallowed type. 21 | // We thus copy the entire struct as CRDSpecTemplate. See the below issue: 22 | // https://github.com/kubernetes-sigs/controller-tools/issues/291 23 | package schema 24 | 25 | import ( 26 | "encoding/json" 27 | 28 | extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 29 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 30 | "k8s.io/utils/pointer" 31 | 32 | "github.com/crossplane/crossplane-runtime/pkg/errors" 33 | "github.com/crossplane/crossplane-runtime/pkg/meta" 34 | v1 "github.com/crossplane/crossplane/apis/apiextensions/v1" 35 | ) 36 | 37 | // Category names for generated claim and composite CRDs. 38 | const ( 39 | CategoryClaim = "claim" 40 | CategoryComposite = "composite" 41 | ) 42 | 43 | const ( 44 | errFmtGetProps = "cannot get %q properties from validation schema" 45 | errParseValidation = "cannot parse validation schema" 46 | errInvalidClaimNames = "invalid resource claim names" 47 | errMissingClaimNames = "missing names" 48 | errFmtConflictingClaimName = "%q conflicts with composite resource name" 49 | ) 50 | 51 | // ForCompositeResource derives the CustomResourceDefinition for a composite 52 | // resource from the supplied CompositeResourceDefinition. 53 | func ForCompositeResource(xrd *v1.CompositeResourceDefinition) (*extv1.CustomResourceDefinition, error) { 54 | crd := &extv1.CustomResourceDefinition{ 55 | Spec: extv1.CustomResourceDefinitionSpec{ 56 | Scope: extv1.ClusterScoped, 57 | Group: xrd.Spec.Group, 58 | Names: xrd.Spec.Names, 59 | Versions: make([]extv1.CustomResourceDefinitionVersion, len(xrd.Spec.Versions)), 60 | }, 61 | } 62 | 63 | crd.SetName(xrd.GetName()) 64 | crd.SetLabels(xrd.GetLabels()) 65 | crd.SetOwnerReferences([]metav1.OwnerReference{meta.AsController( 66 | meta.TypedReferenceTo(xrd, v1.CompositeResourceDefinitionGroupVersionKind), 67 | )}) 68 | 69 | crd.Spec.Names.Categories = append(crd.Spec.Names.Categories, CategoryComposite) 70 | 71 | for i, vr := range xrd.Spec.Versions { 72 | crd.Spec.Versions[i] = extv1.CustomResourceDefinitionVersion{ 73 | Name: vr.Name, 74 | Served: vr.Served, 75 | Storage: vr.Referenceable, 76 | Deprecated: pointer.BoolDeref(vr.Deprecated, false), 77 | DeprecationWarning: vr.DeprecationWarning, 78 | AdditionalPrinterColumns: append(vr.AdditionalPrinterColumns, CompositeResourcePrinterColumns()...), 79 | Schema: &extv1.CustomResourceValidation{ 80 | OpenAPIV3Schema: BaseProps(), 81 | }, 82 | Subresources: &extv1.CustomResourceSubresources{ 83 | Status: &extv1.CustomResourceSubresourceStatus{}, 84 | }, 85 | } 86 | 87 | p, required, err := getProps("spec", vr.Schema) 88 | if err != nil { 89 | return nil, errors.Wrapf(err, errFmtGetProps, "spec") 90 | } 91 | specProps := crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties["spec"] 92 | specProps.Required = append(specProps.Required, required...) 93 | for k, v := range p { 94 | specProps.Properties[k] = v 95 | } 96 | for k, v := range CompositeResourceSpecProps() { 97 | specProps.Properties[k] = v 98 | } 99 | crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties["spec"] = specProps 100 | 101 | statusP, statusRequired, err := getProps("status", vr.Schema) 102 | if err != nil { 103 | return nil, errors.Wrapf(err, errFmtGetProps, "status") 104 | } 105 | statusProps := crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties["status"] 106 | statusProps.Required = statusRequired 107 | for k, v := range statusP { 108 | statusProps.Properties[k] = v 109 | } 110 | for k, v := range CompositeResourceStatusProps() { 111 | statusProps.Properties[k] = v 112 | } 113 | crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties["status"] = statusProps 114 | } 115 | 116 | return crd, nil 117 | } 118 | 119 | // ForCompositeResourceClaim derives the CustomResourceDefinition for a 120 | // composite resource claim from the supplied CompositeResourceDefinition. 121 | func ForCompositeResourceClaim(xrd *v1.CompositeResourceDefinition) (*extv1.CustomResourceDefinition, error) { 122 | if err := validateClaimNames(xrd); err != nil { 123 | return nil, errors.Wrap(err, errInvalidClaimNames) 124 | } 125 | 126 | crd := &extv1.CustomResourceDefinition{ 127 | Spec: extv1.CustomResourceDefinitionSpec{ 128 | Scope: extv1.NamespaceScoped, 129 | Group: xrd.Spec.Group, 130 | Names: *xrd.Spec.ClaimNames, 131 | Versions: make([]extv1.CustomResourceDefinitionVersion, len(xrd.Spec.Versions)), 132 | }, 133 | } 134 | 135 | crd.SetName(xrd.Spec.ClaimNames.Plural + "." + xrd.Spec.Group) 136 | crd.SetLabels(xrd.GetLabels()) 137 | crd.SetOwnerReferences([]metav1.OwnerReference{meta.AsController( 138 | meta.TypedReferenceTo(xrd, v1.CompositeResourceDefinitionGroupVersionKind), 139 | )}) 140 | 141 | crd.Spec.Names.Categories = append(crd.Spec.Names.Categories, CategoryClaim) 142 | 143 | for i, vr := range xrd.Spec.Versions { 144 | crd.Spec.Versions[i] = extv1.CustomResourceDefinitionVersion{ 145 | Name: vr.Name, 146 | Served: vr.Served, 147 | Storage: vr.Referenceable, 148 | Deprecated: pointer.BoolDeref(vr.Deprecated, false), 149 | DeprecationWarning: vr.DeprecationWarning, 150 | AdditionalPrinterColumns: append(vr.AdditionalPrinterColumns, CompositeResourceClaimPrinterColumns()...), 151 | Schema: &extv1.CustomResourceValidation{ 152 | OpenAPIV3Schema: BaseProps(), 153 | }, 154 | Subresources: &extv1.CustomResourceSubresources{ 155 | Status: &extv1.CustomResourceSubresourceStatus{}, 156 | }, 157 | } 158 | 159 | p, required, err := getProps("spec", vr.Schema) 160 | if err != nil { 161 | return nil, errors.Wrapf(err, errFmtGetProps, "spec") 162 | } 163 | specProps := crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties["spec"] 164 | specProps.Required = append(specProps.Required, required...) 165 | for k, v := range p { 166 | specProps.Properties[k] = v 167 | } 168 | for k, v := range CompositeResourceClaimSpecProps() { 169 | specProps.Properties[k] = v 170 | } 171 | crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties["spec"] = specProps 172 | 173 | statusP, statusRequired, err := getProps("status", vr.Schema) 174 | if err != nil { 175 | return nil, errors.Wrapf(err, errFmtGetProps, "status") 176 | } 177 | statusProps := crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties["status"] 178 | statusProps.Required = statusRequired 179 | for k, v := range statusP { 180 | statusProps.Properties[k] = v 181 | } 182 | for k, v := range CompositeResourceStatusProps() { 183 | statusProps.Properties[k] = v 184 | } 185 | crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties["status"] = statusProps 186 | } 187 | 188 | return crd, nil 189 | } 190 | 191 | func validateClaimNames(d *v1.CompositeResourceDefinition) error { 192 | if d.Spec.ClaimNames == nil { 193 | return errors.New(errMissingClaimNames) 194 | } 195 | 196 | if n := d.Spec.ClaimNames.Kind; n == d.Spec.Names.Kind { 197 | return errors.Errorf(errFmtConflictingClaimName, n) 198 | } 199 | 200 | if n := d.Spec.ClaimNames.Plural; n == d.Spec.Names.Plural { 201 | return errors.Errorf(errFmtConflictingClaimName, n) 202 | } 203 | 204 | if n := d.Spec.ClaimNames.Singular; n != "" && n == d.Spec.Names.Singular { 205 | return errors.Errorf(errFmtConflictingClaimName, n) 206 | } 207 | 208 | if n := d.Spec.ClaimNames.ListKind; n != "" && n == d.Spec.Names.ListKind { 209 | return errors.Errorf(errFmtConflictingClaimName, n) 210 | } 211 | 212 | return nil 213 | } 214 | 215 | func getProps(field string, v *v1.CompositeResourceValidation) (map[string]extv1.JSONSchemaProps, []string, error) { 216 | if v == nil { 217 | return nil, nil, nil 218 | } 219 | 220 | s := &extv1.JSONSchemaProps{} 221 | if err := json.Unmarshal(v.OpenAPIV3Schema.Raw, s); err != nil { 222 | return nil, nil, errors.Wrap(err, errParseValidation) 223 | } 224 | 225 | spec, ok := s.Properties[field] 226 | if !ok { 227 | return nil, nil, nil 228 | } 229 | 230 | return spec.Properties, spec.Required, nil 231 | } 232 | 233 | // IsEstablished is a helper function to check whether api-server is ready 234 | // to accept the instances of registered CRD. 235 | func IsEstablished(s extv1.CustomResourceDefinitionStatus) bool { 236 | for _, c := range s.Conditions { 237 | if c.Type == extv1.Established { 238 | return c.Status == extv1.ConditionTrue 239 | } 240 | } 241 | return false 242 | } 243 | -------------------------------------------------------------------------------- /internal/xpkg/lint/schema/xcrd_schema.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2020 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package schema 18 | 19 | import extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 20 | 21 | // Label keys. 22 | const ( 23 | LabelKeyNamePrefixForComposed = "crossplane.io/composite" 24 | LabelKeyClaimName = "crossplane.io/claim-name" 25 | LabelKeyClaimNamespace = "crossplane.io/claim-namespace" 26 | ) 27 | 28 | // PropagateSpecProps is the list of XRC spec properties to propagate 29 | // when translating an XRC into an XR and vice-versa. 30 | var PropagateSpecProps = []string{"compositionRef", "compositionSelector", "compositionUpdatePolicy"} 31 | 32 | // TODO(negz): Add descriptions to schema fields. 33 | 34 | // BaseProps is a partial OpenAPIV3Schema for the spec fields that Crossplane 35 | // expects to be present for all CRDs that it creates. 36 | func BaseProps() *extv1.JSONSchemaProps { 37 | return &extv1.JSONSchemaProps{ 38 | Type: "object", 39 | Required: []string{"spec"}, 40 | Properties: map[string]extv1.JSONSchemaProps{ 41 | "apiVersion": { 42 | Type: "string", 43 | }, 44 | "kind": { 45 | Type: "string", 46 | }, 47 | "metadata": { 48 | // NOTE(muvaf): api-server takes care of validating 49 | // metadata. 50 | Type: "object", 51 | Properties: map[string]extv1.JSONSchemaProps{ 52 | "name": { 53 | Type: "string", 54 | }, 55 | "namespace": { 56 | Type: "string", 57 | }, 58 | "labels": { 59 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 60 | Allows: true, 61 | Schema: &extv1.JSONSchemaProps{ 62 | Type: "string", 63 | }, 64 | }, 65 | }, 66 | "annotations": { 67 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 68 | Allows: true, 69 | Schema: &extv1.JSONSchemaProps{ 70 | Type: "string", 71 | }, 72 | }, 73 | }, 74 | }, 75 | }, 76 | "spec": { 77 | Type: "object", 78 | Properties: map[string]extv1.JSONSchemaProps{}, 79 | }, 80 | "status": { 81 | Type: "object", 82 | Properties: map[string]extv1.JSONSchemaProps{}, 83 | }, 84 | }, 85 | } 86 | } 87 | 88 | // CompositeResourceSpecProps is a partial OpenAPIV3Schema for the spec fields 89 | // that Crossplane expects to be present for all defined infrastructure 90 | // resources. 91 | func CompositeResourceSpecProps() map[string]extv1.JSONSchemaProps { 92 | return map[string]extv1.JSONSchemaProps{ 93 | "compositionRef": { 94 | Type: "object", 95 | Required: []string{"name"}, 96 | Properties: map[string]extv1.JSONSchemaProps{ 97 | "name": {Type: "string"}, 98 | }, 99 | }, 100 | "compositionSelector": { 101 | Type: "object", 102 | Required: []string{"matchLabels"}, 103 | Properties: map[string]extv1.JSONSchemaProps{ 104 | "matchLabels": { 105 | Type: "object", 106 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 107 | Allows: true, 108 | Schema: &extv1.JSONSchemaProps{Type: "string"}, 109 | }, 110 | }, 111 | }, 112 | }, 113 | "compositionRevisionRef": { 114 | Type: "object", 115 | Required: []string{"name"}, 116 | Properties: map[string]extv1.JSONSchemaProps{ 117 | "name": {Type: "string"}, 118 | }, 119 | Description: "Alpha: This field may be deprecated or changed without notice.", 120 | }, 121 | "compositionUpdatePolicy": { 122 | Type: "string", 123 | Enum: []extv1.JSON{ 124 | {Raw: []byte(`"Automatic"`)}, 125 | {Raw: []byte(`"Manual"`)}, 126 | }, 127 | Default: &extv1.JSON{Raw: []byte(`"Automatic"`)}, 128 | Description: "Alpha: This field may be deprecated or changed without notice.", 129 | }, 130 | "claimRef": { 131 | Type: "object", 132 | Required: []string{"apiVersion", "kind", "namespace", "name"}, 133 | Properties: map[string]extv1.JSONSchemaProps{ 134 | "apiVersion": {Type: "string"}, 135 | "kind": {Type: "string"}, 136 | "namespace": {Type: "string"}, 137 | "name": {Type: "string"}, 138 | }, 139 | }, 140 | "resourceRefs": { 141 | Type: "array", 142 | Items: &extv1.JSONSchemaPropsOrArray{ 143 | Schema: &extv1.JSONSchemaProps{ 144 | Type: "object", 145 | Properties: map[string]extv1.JSONSchemaProps{ 146 | "apiVersion": {Type: "string"}, 147 | "name": {Type: "string"}, 148 | "kind": {Type: "string"}, 149 | }, 150 | Required: []string{"apiVersion", "kind"}, 151 | }, 152 | }, 153 | }, 154 | "publishConnectionDetailsTo": { 155 | Type: "object", 156 | Required: []string{"name"}, 157 | Properties: map[string]extv1.JSONSchemaProps{ 158 | "name": {Type: "string"}, 159 | "configRef": { 160 | Type: "object", 161 | Default: &extv1.JSON{Raw: []byte(`{"name": "default"}`)}, 162 | Properties: map[string]extv1.JSONSchemaProps{ 163 | "name": { 164 | Type: "string", 165 | }, 166 | }, 167 | }, 168 | "metadata": { 169 | Type: "object", 170 | Properties: map[string]extv1.JSONSchemaProps{ 171 | "labels": { 172 | Type: "object", 173 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 174 | Allows: true, 175 | Schema: &extv1.JSONSchemaProps{Type: "string"}, 176 | }, 177 | }, 178 | "annotations": { 179 | Type: "object", 180 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 181 | Allows: true, 182 | Schema: &extv1.JSONSchemaProps{Type: "string"}, 183 | }, 184 | }, 185 | "type": { 186 | Type: "string", 187 | }, 188 | }, 189 | }, 190 | }, 191 | }, 192 | "writeConnectionSecretToRef": { 193 | Type: "object", 194 | Required: []string{"name", "namespace"}, 195 | Properties: map[string]extv1.JSONSchemaProps{ 196 | "name": {Type: "string"}, 197 | "namespace": {Type: "string"}, 198 | }, 199 | }, 200 | } 201 | } 202 | 203 | // CompositeResourceClaimSpecProps is a partial OpenAPIV3Schema for the spec 204 | // fields that Crossplane expects to be present for all published infrastructure 205 | // resources. 206 | func CompositeResourceClaimSpecProps() map[string]extv1.JSONSchemaProps { 207 | return map[string]extv1.JSONSchemaProps{ 208 | "compositionRef": { 209 | Type: "object", 210 | Required: []string{"name"}, 211 | Properties: map[string]extv1.JSONSchemaProps{ 212 | "name": {Type: "string"}, 213 | }, 214 | }, 215 | "compositionSelector": { 216 | Type: "object", 217 | Required: []string{"matchLabels"}, 218 | Properties: map[string]extv1.JSONSchemaProps{ 219 | "matchLabels": { 220 | Type: "object", 221 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 222 | Allows: true, 223 | Schema: &extv1.JSONSchemaProps{Type: "string"}, 224 | }, 225 | }, 226 | }, 227 | }, 228 | "compositionRevisionRef": { 229 | Type: "object", 230 | Required: []string{"name"}, 231 | Properties: map[string]extv1.JSONSchemaProps{ 232 | "name": {Type: "string"}, 233 | }, 234 | }, 235 | "compositionUpdatePolicy": { 236 | Type: "string", 237 | Enum: []extv1.JSON{ 238 | {Raw: []byte(`"Automatic"`)}, 239 | {Raw: []byte(`"Manual"`)}, 240 | }, 241 | Default: &extv1.JSON{Raw: []byte(`"Automatic"`)}, 242 | }, 243 | "compositeDeletePolicy": { 244 | Type: "string", 245 | Enum: []extv1.JSON{ 246 | {Raw: []byte(`"Background"`)}, 247 | {Raw: []byte(`"Foreground"`)}, 248 | }, 249 | Default: &extv1.JSON{Raw: []byte(`"Background"`)}}, 250 | "resourceRef": { 251 | Type: "object", 252 | Required: []string{"apiVersion", "kind", "name"}, 253 | Properties: map[string]extv1.JSONSchemaProps{ 254 | "apiVersion": {Type: "string"}, 255 | "kind": {Type: "string"}, 256 | "name": {Type: "string"}, 257 | }, 258 | }, 259 | "publishConnectionDetailsTo": { 260 | Type: "object", 261 | Required: []string{"name"}, 262 | Properties: map[string]extv1.JSONSchemaProps{ 263 | "name": {Type: "string"}, 264 | "configRef": { 265 | Type: "object", 266 | Default: &extv1.JSON{Raw: []byte(`{"name": "default"}`)}, 267 | Properties: map[string]extv1.JSONSchemaProps{ 268 | "name": { 269 | Type: "string", 270 | }, 271 | }, 272 | }, 273 | "metadata": { 274 | Type: "object", 275 | Properties: map[string]extv1.JSONSchemaProps{ 276 | "labels": { 277 | Type: "object", 278 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 279 | Allows: true, 280 | Schema: &extv1.JSONSchemaProps{Type: "string"}, 281 | }, 282 | }, 283 | "annotations": { 284 | Type: "object", 285 | AdditionalProperties: &extv1.JSONSchemaPropsOrBool{ 286 | Allows: true, 287 | Schema: &extv1.JSONSchemaProps{Type: "string"}, 288 | }, 289 | }, 290 | "type": { 291 | Type: "string", 292 | }, 293 | }, 294 | }, 295 | }, 296 | }, 297 | "writeConnectionSecretToRef": { 298 | Type: "object", 299 | Required: []string{"name"}, 300 | Properties: map[string]extv1.JSONSchemaProps{ 301 | "name": {Type: "string"}, 302 | }, 303 | }, 304 | } 305 | } 306 | 307 | // CompositeResourceStatusProps is a partial OpenAPIV3Schema for the status 308 | // fields that Crossplane expects to be present for all defined or published 309 | // infrastructure resources. 310 | func CompositeResourceStatusProps() map[string]extv1.JSONSchemaProps { 311 | return map[string]extv1.JSONSchemaProps{ 312 | "conditions": { 313 | Description: "Conditions of the resource.", 314 | Type: "array", 315 | Items: &extv1.JSONSchemaPropsOrArray{ 316 | Schema: &extv1.JSONSchemaProps{ 317 | Type: "object", 318 | Required: []string{"lastTransitionTime", "reason", "status", "type"}, 319 | Properties: map[string]extv1.JSONSchemaProps{ 320 | "lastTransitionTime": {Type: "string", Format: "date-time"}, 321 | "message": {Type: "string"}, 322 | "reason": {Type: "string"}, 323 | "status": {Type: "string"}, 324 | "type": {Type: "string"}, 325 | }, 326 | }, 327 | }, 328 | }, 329 | "connectionDetails": { 330 | Type: "object", 331 | Properties: map[string]extv1.JSONSchemaProps{ 332 | "lastPublishedTime": {Type: "string", Format: "date-time"}, 333 | }, 334 | }, 335 | } 336 | } 337 | 338 | // CompositeResourcePrinterColumns returns the set of default printer columns 339 | // that should exist in all generated composite resource CRDs. 340 | func CompositeResourcePrinterColumns() []extv1.CustomResourceColumnDefinition { 341 | return []extv1.CustomResourceColumnDefinition{ 342 | { 343 | Name: "SYNCED", 344 | Type: "string", 345 | JSONPath: ".status.conditions[?(@.type=='Synced')].status", 346 | }, 347 | { 348 | Name: "READY", 349 | Type: "string", 350 | JSONPath: ".status.conditions[?(@.type=='Ready')].status", 351 | }, 352 | { 353 | Name: "COMPOSITION", 354 | Type: "string", 355 | JSONPath: ".spec.compositionRef.name", 356 | }, 357 | { 358 | Name: "AGE", 359 | Type: "date", 360 | JSONPath: ".metadata.creationTimestamp", 361 | }, 362 | } 363 | } 364 | 365 | // CompositeResourceClaimPrinterColumns returns the set of default printer 366 | // columns that should exist in all generated composite resource claim CRDs. 367 | func CompositeResourceClaimPrinterColumns() []extv1.CustomResourceColumnDefinition { 368 | return []extv1.CustomResourceColumnDefinition{ 369 | { 370 | Name: "SYNCED", 371 | Type: "string", 372 | JSONPath: ".status.conditions[?(@.type=='Synced')].status", 373 | }, 374 | { 375 | Name: "READY", 376 | Type: "string", 377 | JSONPath: ".status.conditions[?(@.type=='Ready')].status", 378 | }, 379 | { 380 | Name: "CONNECTION-SECRET", 381 | Type: "string", 382 | JSONPath: ".spec.writeConnectionSecretToRef.name", 383 | }, 384 | { 385 | Name: "AGE", 386 | Type: "date", 387 | JSONPath: ".metadata.creationTimestamp", 388 | }, 389 | } 390 | } 391 | 392 | // GetPropFields returns the fields from a map of schema properties 393 | func GetPropFields(props map[string]extv1.JSONSchemaProps) []string { 394 | propFields := make([]string, len(props)) 395 | i := 0 396 | for k := range props { 397 | propFields[i] = k 398 | i++ 399 | } 400 | return propFields 401 | } 402 | -------------------------------------------------------------------------------- /internal/xpkg/package.go: -------------------------------------------------------------------------------- 1 | package xpkg 2 | 3 | import ( 4 | "reflect" 5 | 6 | xpv1 "github.com/crossplane/crossplane/apis/apiextensions/v1" 7 | xppkgv1 "github.com/crossplane/crossplane/apis/pkg/meta/v1" 8 | "github.com/pkg/errors" 9 | goyaml "gopkg.in/yaml.v3" 10 | extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | "k8s.io/apimachinery/pkg/runtime" 13 | "sigs.k8s.io/yaml" 14 | ) 15 | 16 | // Package represents the content of a Crossplane package. 17 | type Package struct { 18 | // Source that was used to create this package. 19 | Source string 20 | 21 | // Name of this Package. 22 | Name string 23 | 24 | // Entries in this package. 25 | Entries []PackageEntry 26 | } 27 | 28 | // GetPackageDescriptor returns the first package descriptor in this Package or 29 | // nil of there is none. 30 | func (p *Package) GetPackageDescriptor() *PackageEntry { 31 | for _, obj := range p.Entries { 32 | if obj.IsPackageDescriptor() { 33 | return &obj 34 | } 35 | } 36 | return nil 37 | } 38 | 39 | type PackageEntry struct { 40 | // Object of this PackageEntry. 41 | Object unstructured.Unstructured 42 | 43 | // Source of this PackageEntry. 44 | Source string 45 | 46 | // The raw manifest of this PackageEntry. 47 | Raw string 48 | 49 | // Cached conversion of this PackageEntry. 50 | cachedConversion runtime.Object 51 | 52 | // Cached YAML node for JSON path evaluation. 53 | cachedNode *goyaml.Node 54 | } 55 | 56 | const ( 57 | errPackageObjectNotType = "object is not of kind %s" 58 | errPackageObjectConvert = "failed to convert object to %s" 59 | ) 60 | 61 | // IsXRD determines if e is an XRD. 62 | func (e *PackageEntry) IsXRD() bool { 63 | return e.Object.GroupVersionKind() == xpv1.CompositeResourceDefinitionGroupVersionKind 64 | } 65 | 66 | // AsXRD returns the Object of e as a CompositeResourceDefinition. 67 | func (e *PackageEntry) AsXRD() (*xpv1.CompositeResourceDefinition, error) { 68 | if !e.IsXRD() { 69 | return nil, errors.Errorf(errPackageObjectNotType, "XRD") 70 | } 71 | return convertToWithCache[*xpv1.CompositeResourceDefinition](e.Raw, &e.cachedConversion) 72 | } 73 | 74 | // IsComposition determines if e is a Composition. 75 | func (e *PackageEntry) IsComposition() bool { 76 | return e.Object.GroupVersionKind() == xpv1.CompositionGroupVersionKind 77 | } 78 | 79 | // AsComposition returns the Object of e as a CompositeResourceDefinition. 80 | func (e *PackageEntry) AsComposition() (*xpv1.Composition, error) { 81 | if !e.IsComposition() { 82 | return nil, errors.Errorf(errPackageObjectNotType, "Composition") 83 | } 84 | return convertToWithCache[*xpv1.Composition](e.Raw, &e.cachedConversion) 85 | } 86 | 87 | // Determines if e is either a Provider or Configuration package descriptor. 88 | func (e *PackageEntry) IsPackageDescriptor() bool { 89 | gvk := e.Object.GroupVersionKind() 90 | return gvk == xppkgv1.ProviderGroupVersionKind || gvk == xppkgv1.ConfigurationGroupVersionKind 91 | } 92 | 93 | // AsPackageDescriptor returns the Object of e as a Pkg. 94 | func (e *PackageEntry) AsPackageDescriptor() (xppkgv1.Pkg, error) { 95 | gvk := e.Object.GroupVersionKind() 96 | if gvk == xppkgv1.ProviderGroupVersionKind { 97 | return convertToWithCache[*xppkgv1.Provider](e.Raw, &e.cachedConversion) 98 | } 99 | if gvk == xppkgv1.ConfigurationGroupVersionKind { 100 | return convertToWithCache[*xppkgv1.Configuration](e.Raw, &e.cachedConversion) 101 | } 102 | return nil, errors.Errorf(errPackageObjectNotType, "package descriptor") 103 | } 104 | 105 | var ( 106 | crdGVK = extv1.SchemeGroupVersion.WithKind("CustomResourceDefinition") 107 | ) 108 | 109 | // IsCRD returns 110 | func (e *PackageEntry) IsCRD() bool { 111 | gvk := e.Object.GroupVersionKind() 112 | return gvk == crdGVK 113 | } 114 | 115 | func (e *PackageEntry) AsCRD() (*extv1.CustomResourceDefinition, error) { 116 | if !e.IsCRD() { 117 | return nil, errors.Errorf(errPackageObjectNotType, "Composition") 118 | } 119 | return convertToWithCache[*extv1.CustomResourceDefinition](e.Raw, &e.cachedConversion) 120 | } 121 | 122 | // Converts Raw to T if cache is not of type T. 123 | func convertToWithCache[T runtime.Object](raw string, cached *runtime.Object) (T, error) { 124 | if c, ok := (*cached).(T); ok { 125 | return c, nil 126 | } 127 | var res T 128 | if err := yaml.Unmarshal([]byte(raw), &res); err != nil { 129 | return res, errors.Wrapf(err, errPackageObjectConvert, reflect.TypeOf(res).Name()) 130 | } 131 | return res, nil 132 | } 133 | 134 | // GetYamlNode for e.Raw. 135 | func (e *PackageEntry) GetYamlNode() (*goyaml.Node, error) { 136 | if e.cachedNode != nil { 137 | return e.cachedNode, nil 138 | } 139 | node := &goyaml.Node{} 140 | if err := goyaml.Unmarshal([]byte(e.Raw), node); err != nil { 141 | return nil, err 142 | } 143 | e.cachedNode = node 144 | return node, nil 145 | } 146 | -------------------------------------------------------------------------------- /internal/xpkg/parse/dependecies.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/pkg/errors" 8 | "golang.org/x/sync/errgroup" 9 | 10 | "github.com/crossplane-contrib/crossplane-lint/internal/config" 11 | "github.com/crossplane-contrib/crossplane-lint/internal/utils/sync" 12 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 13 | ) 14 | 15 | const ( 16 | errLoadPackages = "failed to load packages" 17 | errLoadPackage = "failed to load package %s" 18 | ) 19 | 20 | func LoadPackageDependencies(deps []config.PackageDescriptor, parser PackageParser) ([]*xpkg.Package, error) { 21 | resultChan, errorChan := loadPackageDependenciesConcurrently(deps, parser) 22 | loadedDeps, errs := sync.CollectWithError(resultChan, errorChan) 23 | for _, err := range errs { 24 | fmt.Fprintln(os.Stderr, err.Error()) 25 | } 26 | if len(errs) > 0 { 27 | return nil, errors.New(errLoadPackages) 28 | } 29 | return loadedDeps, nil 30 | } 31 | 32 | func loadPackageDependenciesConcurrently(deps []config.PackageDescriptor, parser PackageParser) (chan *xpkg.Package, chan error) { 33 | errorChan := make(chan error) 34 | resultChan := make(chan *xpkg.Package) 35 | eg := errgroup.Group{} 36 | for _, d := range deps { 37 | dep := d 38 | eg.Go(func() error { 39 | res, err := loadPackageDependency(dep, parser) 40 | if err != nil { 41 | errorChan <- err 42 | } else { 43 | resultChan <- res 44 | } 45 | return err 46 | }) 47 | } 48 | 49 | go func() { 50 | _ = eg.Wait() 51 | close(errorChan) 52 | close(resultChan) 53 | }() 54 | return resultChan, errorChan 55 | } 56 | 57 | func loadPackageDependency(dep config.PackageDescriptor, parser PackageParser) (*xpkg.Package, error) { 58 | depPkg, err := parser.ParsePackage(dep.Image) 59 | return depPkg, errors.Wrapf(err, errLoadPackage, dep.Image) 60 | } 61 | -------------------------------------------------------------------------------- /internal/xpkg/parse/directory.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "io/fs" 5 | "path/filepath" 6 | "sync" 7 | 8 | "github.com/pkg/errors" 9 | "github.com/spf13/afero" 10 | "golang.org/x/sync/errgroup" 11 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 12 | "sigs.k8s.io/yaml" 13 | 14 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 15 | ) 16 | 17 | const ( 18 | errReadPackageDirectory = "failed to read package from directory" 19 | 20 | parserParallelism = 20 21 | ) 22 | 23 | var _ PackageParser = &PackageDirectoryParser{} 24 | 25 | // PackageDirectoryParser parses a package from a directory. 26 | type PackageDirectoryParser struct { 27 | fs afero.Fs 28 | } 29 | 30 | // NewPackageDirectoryParser creates a new PackageDirectoryParser. 31 | func NewPackageDirectoryParser(fs afero.Fs) *PackageDirectoryParser { 32 | return &PackageDirectoryParser{fs} 33 | 34 | } 35 | 36 | // ParsePackage from the files in the given directory. 37 | func (p *PackageDirectoryParser) ParsePackage(directory string) (*xpkg.Package, error) { 38 | resultChan, errorChan := p.parseFilesConcurrently(directory) 39 | 40 | wg := sync.WaitGroup{} 41 | 42 | pkg := &xpkg.Package{} 43 | wg.Add(2) 44 | go func() { 45 | for res := range resultChan { 46 | pkg.Entries = append(pkg.Entries, res) 47 | } 48 | wg.Done() 49 | }() 50 | 51 | hasErrors := false 52 | go func() { 53 | for err := range errorChan { 54 | println(err.Error()) 55 | hasErrors = true 56 | } 57 | wg.Done() 58 | }() 59 | 60 | wg.Wait() 61 | if hasErrors { 62 | return nil, errors.New(errReadPackageDirectory) 63 | } 64 | return pkg, nil 65 | } 66 | 67 | func (p *PackageDirectoryParser) parseFilesConcurrently(directory string) (chan xpkg.PackageEntry, chan error) { 68 | errChan := make(chan error) 69 | resChan := make(chan xpkg.PackageEntry) 70 | 71 | // Limit amout of parallel file handles to avoid syscall.ETOOMANYREFS on 72 | // macos. 73 | eg := errgroup.Group{} 74 | eg.SetLimit(parserParallelism) 75 | eg.Go(func() error { 76 | err := afero.Walk(p.fs, directory, func(path string, info fs.FileInfo, err error) error { 77 | if err != nil { 78 | return err 79 | } 80 | ext := filepath.Ext(path) 81 | if !info.IsDir() && (ext == ".yaml" || ext == ".yml") { 82 | eg.Go(func() error { 83 | res, err := p.parseFile(path) 84 | if err != nil { 85 | errChan <- err 86 | return err 87 | } 88 | resChan <- res 89 | return nil 90 | }) 91 | } 92 | return nil 93 | }) 94 | if err != nil { 95 | errChan <- err 96 | } 97 | return err 98 | }) 99 | 100 | go func() { 101 | _ = eg.Wait() 102 | close(errChan) 103 | close(resChan) 104 | }() 105 | return resChan, errChan 106 | } 107 | 108 | func (p *PackageDirectoryParser) parseFile(path string) (xpkg.PackageEntry, error) { 109 | raw, err := afero.ReadFile(p.fs, path) 110 | if err != nil { 111 | return xpkg.PackageEntry{}, err 112 | } 113 | 114 | o := unstructured.Unstructured{} 115 | if err := yaml.Unmarshal(raw, &o); err != nil { 116 | return xpkg.PackageEntry{}, err 117 | } 118 | return xpkg.PackageEntry{ 119 | Object: o, 120 | Source: path, 121 | Raw: string(raw), 122 | }, nil 123 | } 124 | -------------------------------------------------------------------------------- /internal/xpkg/parse/image.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "archive/tar" 5 | "bufio" 6 | "context" 7 | "io" 8 | "unicode" 9 | 10 | "github.com/google/go-containerregistry/pkg/name" 11 | v1 "github.com/google/go-containerregistry/pkg/v1" 12 | "github.com/google/go-containerregistry/pkg/v1/mutate" 13 | "github.com/pkg/errors" 14 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 15 | "k8s.io/apimachinery/pkg/util/yaml" 16 | 17 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 18 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg/fetch" 19 | ) 20 | 21 | const ( 22 | errMultipleAnnotatedLayers = "package is invalid due to multiple annotated base layers" 23 | errFetchLayer = "failed to fetch annotated base layer from remote" 24 | errGetUncompressed = "failed to get uncompressed contents from layer" 25 | errOpenPackageStream = "failed to open package stream file" 26 | ) 27 | 28 | const ( 29 | layerAnnotation = "io.crossplane.xpkg" 30 | baseAnnotationValue = "base" 31 | streamFile = "package.yaml" 32 | ) 33 | 34 | var _ PackageParser = &PackageImageParser{} 35 | 36 | // PackageImageParser parses a package from an OCI image using a fetch.Fetcher 37 | // as backend. 38 | type PackageImageParser struct { 39 | fetcher fetch.Fetcher 40 | } 41 | 42 | // NewPackageImageParser creates a new PackageImageParser. 43 | func NewPackageImageParser(fetcher fetch.Fetcher) *PackageImageParser { 44 | return &PackageImageParser{ 45 | fetcher: fetcher, 46 | } 47 | } 48 | 49 | // ParsePackage from an OCI image. `pkg` is interpreted as the name OCI image 50 | // name (i.e. `repo/image:tag`). 51 | func (p *PackageImageParser) ParsePackage(pkg string) (*xpkg.Package, error) { 52 | ref, err := name.ParseReference(pkg) 53 | if err != nil { 54 | return nil, err 55 | } 56 | img, err := p.fetcher.Fetch(context.TODO(), ref) 57 | if err != nil { 58 | return nil, err 59 | } 60 | return p.getPackageFromImage(ref, img) 61 | } 62 | 63 | func (p *PackageImageParser) getPackageFromImage(ref name.Reference, img v1.Image) (*xpkg.Package, error) { 64 | // Copied from https://github.com/crossplane/crossplane/blob/eea7d35de8153e00e76a8eb98c2d46988e26f065/internal/controller/pkg/revision/imageback.go#L78 65 | manifest, err := img.Manifest() 66 | if err != nil { 67 | return nil, err 68 | } 69 | // Determine if the image is using annotated layers. 70 | var tarc io.ReadCloser 71 | defer func() { 72 | if tarc != nil { 73 | tarc.Close() 74 | } 75 | }() 76 | 77 | foundAnnotated := false 78 | for _, l := range manifest.Layers { 79 | if a, ok := l.Annotations[layerAnnotation]; !ok || a != baseAnnotationValue { 80 | continue 81 | } 82 | // NOTE(hasheddan): the xpkg specification dictates that only one layer 83 | // descriptor may be annotated as xpkg base. Since iterating through all 84 | // descriptors is relatively inexpensive, we opt to do so in order to 85 | // verify that we aren't just using the first layer annotated as xpkg 86 | // base. 87 | if foundAnnotated { 88 | return nil, errors.New(errMultipleAnnotatedLayers) 89 | } 90 | foundAnnotated = true 91 | layer, err := img.LayerByDigest(l.Digest) 92 | if err != nil { 93 | return nil, errors.Wrap(err, errFetchLayer) 94 | } 95 | tarc, err = layer.Uncompressed() 96 | if err != nil { 97 | return nil, errors.Wrap(err, errGetUncompressed) 98 | } 99 | } 100 | 101 | // If we still don't have content then we need to flatten image filesystem. 102 | if !foundAnnotated { 103 | tarc = mutate.Extract(img) 104 | } 105 | 106 | // The ReadCloser is an uncompressed tarball, either consisting of annotated 107 | // layer contents or flattened filesystem content. Either way, we only want 108 | // the package YAML stream. 109 | t := tar.NewReader(tarc) 110 | for { 111 | h, err := t.Next() 112 | if err != nil { 113 | return nil, errors.Wrap(err, errOpenPackageStream) 114 | } 115 | if h.Name == streamFile { 116 | break 117 | } 118 | } 119 | return parsePackage(t, ref.Name()) 120 | } 121 | 122 | // parsePackage from a single file stream. 123 | func parsePackage(reader io.Reader, sourceName string) (*xpkg.Package, error) { 124 | pkg := &xpkg.Package{ 125 | Source: sourceName, 126 | Name: sourceName, 127 | } 128 | yr := yaml.NewYAMLReader(bufio.NewReader(reader)) 129 | for { 130 | data, err := yr.Read() 131 | if err != nil && !errors.Is(err, io.EOF) { 132 | return pkg, err 133 | } 134 | if errors.Is(err, io.EOF) { 135 | break 136 | } 137 | if len(data) == 0 { 138 | continue 139 | } 140 | if isWhiteSpace(data) { 141 | continue 142 | } 143 | 144 | o := unstructured.Unstructured{} 145 | if err := yaml.Unmarshal(data, &o); err != nil { 146 | return nil, err 147 | } 148 | e := xpkg.PackageEntry{ 149 | Object: o, 150 | Source: sourceName, 151 | Raw: string(data), 152 | } 153 | pkg.Entries = append(pkg.Entries, e) 154 | } 155 | return pkg, nil 156 | } 157 | 158 | // isWhiteSpace determines whether the passed in bytes are all unicode white 159 | // space. 160 | func isWhiteSpace(bytes []byte) bool { 161 | empty := true 162 | for _, b := range bytes { 163 | if !unicode.IsSpace(rune(b)) { 164 | empty = false 165 | break 166 | } 167 | } 168 | return empty 169 | } 170 | -------------------------------------------------------------------------------- /internal/xpkg/parse/interface.go: -------------------------------------------------------------------------------- 1 | package parse 2 | 3 | import ( 4 | "github.com/crossplane-contrib/crossplane-lint/internal/xpkg" 5 | ) 6 | 7 | // PackageParser creates a new package from a supplied source. 8 | type PackageParser interface { 9 | // ParsePackage from pkg. 10 | ParsePackage(pkg string) (*xpkg.Package, error) 11 | } 12 | --------------------------------------------------------------------------------