diff --git a/apis/dev/v1alpha1/defaults.go b/apis/dev/v1alpha1/defaults.go new file mode 100644 index 0000000..254cea3 --- /dev/null +++ b/apis/dev/v1alpha1/defaults.go @@ -0,0 +1,51 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +// Default sets default values for a Project. +func (p *Project) Default() { + if p.Spec.Paths == nil { + p.Spec.Paths = &ProjectPaths{} + } + p.Spec.Paths.Default() + + if len(p.Spec.Architectures) == 0 { + p.Spec.Architectures = []string{"amd64", "arm64"} + } +} + +// Default sets default values for ProjectPaths. +func (p *ProjectPaths) Default() { + if p.APIs == "" { + p.APIs = "apis" + } + if p.Functions == "" { + p.Functions = "functions" + } + if p.Examples == "" { + p.Examples = "examples" + } + if p.Tests == "" { + p.Tests = "tests" + } + if p.Operations == "" { + p.Operations = "operations" + } + if p.Schemas == "" { + p.Schemas = "schemas" + } +} diff --git a/apis/dev/v1alpha1/doc.go b/apis/dev/v1alpha1/doc.go new file mode 100644 index 0000000..aff333a --- /dev/null +++ b/apis/dev/v1alpha1/doc.go @@ -0,0 +1,21 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package v1alpha1 contains the Project API type. +// +// +groupName=dev.crossplane.io +// +kubebuilder:object:generate=true +package v1alpha1 diff --git a/apis/dev/v1alpha1/project_types.go b/apis/dev/v1alpha1/project_types.go new file mode 100644 index 0000000..dc9dad5 --- /dev/null +++ b/apis/dev/v1alpha1/project_types.go @@ -0,0 +1,188 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkgmetav1 "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1" + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" +) + +// Dependency type constants. +const ( + // DependencyTypeK8s represents Kubernetes API dependencies. + DependencyTypeK8s = "k8s" + // DependencyTypeCRD represents Custom Resource Definition dependencies. + DependencyTypeCRD = "crd" + // DependencyTypeXpkg represents Crossplane package dependencies. + DependencyTypeXpkg = "xpkg" +) + +// Project defines a Crossplane Project, which can be built into a Crossplane +// Configuration package. +// +// +kubebuilder:object:root=true +type Project struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec ProjectSpec `json:"spec"` +} + +// ProjectSpec is the spec for a Project. Since a Project is not a Kubernetes +// resource there is no Status, only Spec. +type ProjectSpec struct { + ProjectPackageMetadata `json:",inline"` + + // Repository is the OCI repository to which the configuration package built + // from this project will be pushed. It is also used to form the OCI + // repository paths for embedded functions in the project by appending an + // underscore and the function name. The repository can be overridden at + // build time, but the repository used for build and push must match in + // order for dependencies on embedded functions to resolve correctly. + Repository string `json:"repository"` + + // Crossplane defines the Crossplane version constraints for the + // configuration package built from the project. If not specified, the + // constraint will be '>=v2.0.0-rc.0' such that the packages support any + // Crossplane 2.x release. + Crossplane *pkgmetav1.CrossplaneConstraints `json:"crossplane,omitempty"` + // Dependencies are built-time and runtime dependencies of the project. + Dependencies []Dependency `json:"dependencies,omitempty"` + // Paths defines the relative paths to various parts of the project. + Paths *ProjectPaths `json:"paths,omitempty"` + // Architectures indicates for which architectures embedded functions should + // be built. If not specified, it defaults to [amd64, arm64]. + Architectures []string `json:"architectures,omitempty"` + // ImageConfigs configure how images are fetched during + // development. Currently, only rewriting is supported; other options will + // be silently ignored. Note that these configs are for development only; + // any necessary ImageConfigs for deployment into a cluster must be created + // separately at deployment time. + ImageConfigs []pkgv1beta1.ImageConfig `json:"imageConfigs,omitempty"` +} + +// ProjectPackageMetadata holds metadata about the project, which will become +// package metadata when a project is built into a Crossplane package. +type ProjectPackageMetadata struct { + Maintainer string `json:"maintainer,omitempty"` + Source string `json:"source,omitempty"` + License string `json:"license,omitempty"` + Description string `json:"description,omitempty"` + Readme string `json:"readme,omitempty"` +} + +// ProjectPaths configures the locations of various parts of the project, for +// use at build time. All paths must be relative to the project root. +type ProjectPaths struct { + // APIs is the directory holding the project's apis (XRDs and + // compositions). If not specified, it defaults to `apis/`. + APIs string `json:"apis,omitempty"` + // Functions is the directory holding the project's functions. If not + // specified, it defaults to `functions/`. + Functions string `json:"functions,omitempty"` + // Examples is the directory holding the project's examples. If not + // specified, it defaults to `examples/`. + Examples string `json:"examples,omitempty"` + // Tests is the directory holding the project's tests. If not + // specified, it defaults to `tests/`. + Tests string `json:"tests,omitempty"` + // Operations is the directory holding the project's operations. If not + // specified, it defaults to `operations/`. + Operations string `json:"operations,omitempty"` + // Schemas is the directory holding language bindings for the project's XRDs + // and dependencies. If not specified, it defaults to `schemas/`. + Schemas string `json:"schemas,omitempty"` +} + +// Dependency defines a dependency for a Crossplane project. The Type field +// determines which sub-fields are relevant. +type Dependency struct { + // Type defines the type of dependency. + // +kubebuilder:validation:Enum=k8s;crd;xpkg + Type string `json:"type"` + + // Xpkg defines the Crossplane package reference for the dependency. + // Only used when Type is "xpkg". + // +optional + Xpkg *XpkgDependency `json:"xpkg,omitempty"` + + // Git defines the git repository source for the dependency. + // Only used when Type is "crd". + // +optional + Git *GitDependency `json:"git,omitempty"` + + // HTTP defines the HTTP source for the dependency. + // Only used when Type is "crd". + // +optional + HTTP *HTTPDependency `json:"http,omitempty"` + + // K8s defines the Kubernetes API version for the dependency. + // Only used when Type is "k8s". + // +optional + K8s *K8sDependency `json:"k8s,omitempty"` +} + +// XpkgDependency defines the xpkg-specific fields for a package dependency. +type XpkgDependency struct { + // APIVersion of the dependency package. This should be the package + // apiVersion (e.g., pkg.crossplane.io/v1), not the package metadata type. + APIVersion string `json:"apiVersion"` + + // Kind of the dependency package. + Kind string `json:"kind"` + + // Package is the OCI image reference of the dependency package. + Package string `json:"package"` + + // Version is the semantic version constraints for the dependency. + Version string `json:"version"` + + // APIOnly indicates that this dependency is only needed for API/schema + // purposes and should not be included as a runtime dependency in the + // built package. Only xpkg dependencies can be runtime dependencies. + // Default is false, meaning xpkg dependencies are runtime by default. + // +optional + APIOnly bool `json:"apiOnly,omitempty"` +} + +// GitDependency defines a git repository source for an API dependency. +type GitDependency struct { + // Repository is the git repository URL. + Repository string `json:"repository"` + + // Ref is the git reference (branch, tag, or commit SHA). + // +optional + Ref string `json:"ref,omitempty"` + + // Path is the path within the repository to the API definition. + // +optional + Path string `json:"path,omitempty"` +} + +// HTTPDependency defines an HTTP source for an API dependency. +type HTTPDependency struct { + // URL is the HTTP/HTTPS URL to fetch the API dependency from. + URL string `json:"url"` +} + +// K8sDependency defines a Kubernetes API version reference. +type K8sDependency struct { + // Version is the Kubernetes API version (e.g., "v1.33.0"). + Version string `json:"version"` +} diff --git a/apis/dev/v1alpha1/validate.go b/apis/dev/v1alpha1/validate.go new file mode 100644 index 0000000..fcc84b6 --- /dev/null +++ b/apis/dev/v1alpha1/validate.go @@ -0,0 +1,178 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "errors" + "fmt" + "path/filepath" +) + +const ( + // AdditionalMetadataKeyPrefix is the required prefix for all additional metadata keys. + AdditionalMetadataKeyPrefix = "meta.upbound.io/" +) + +// Validate validates a project. +func (p *Project) Validate() error { + var errs []error + + if p.GetName() == "" { + errs = append(errs, errors.New("name must not be empty")) + } + errs = append(errs, p.Spec.Validate()) + + return errors.Join(errs...) +} + +// Validate validates a project's spec. +func (s *ProjectSpec) Validate() error { + var errs []error + + if s.Repository == "" { + errs = append(errs, errors.New("repository must not be empty")) + } + + if s.Paths != nil { + if s.Paths.APIs != "" && filepath.IsAbs(s.Paths.APIs) { + errs = append(errs, errors.New("apis path must be relative")) + } + if s.Paths.Functions != "" && filepath.IsAbs(s.Paths.Functions) { + errs = append(errs, errors.New("functions path must be relative")) + } + if s.Paths.Examples != "" && filepath.IsAbs(s.Paths.Examples) { + errs = append(errs, errors.New("examples path must be relative")) + } + if s.Paths.Tests != "" && filepath.IsAbs(s.Paths.Tests) { + errs = append(errs, errors.New("tests path must be relative")) + } + if s.Paths.Operations != "" && filepath.IsAbs(s.Paths.Operations) { + errs = append(errs, errors.New("operations path must be relative")) + } + if s.Paths.Schemas != "" && filepath.IsAbs(s.Paths.Schemas) { + errs = append(errs, errors.New("schemas path must be relative")) + } + } + + if s.Architectures != nil && len(s.Architectures) == 0 { + errs = append(errs, errors.New("architectures must not be empty")) + } + + // Validate dependencies + for i, dep := range s.Dependencies { + if err := dep.Validate(); err != nil { + errs = append(errs, fmt.Errorf("dependency %d: %w", i, err)) + } + } + + return errors.Join(errs...) +} + +// Validate validates a dependency. +func (d *Dependency) Validate() error { + var errs []error + + if d.Type == "" { + errs = append(errs, errors.New("type must not be empty")) + } + + // Count non-nil sources + sourceCount := 0 + if d.Xpkg != nil { + sourceCount++ + if err := d.Xpkg.Validate(); err != nil { + errs = append(errs, fmt.Errorf("xpkg: %w", err)) + } + } + if d.Git != nil { + sourceCount++ + if err := d.Git.Validate(); err != nil { + errs = append(errs, fmt.Errorf("git: %w", err)) + } + } + if d.HTTP != nil { + sourceCount++ + if err := d.HTTP.Validate(); err != nil { + errs = append(errs, fmt.Errorf("http: %w", err)) + } + } + if d.K8s != nil { + sourceCount++ + if err := d.K8s.Validate(); err != nil { + errs = append(errs, fmt.Errorf("k8s: %w", err)) + } + } + + if sourceCount != 1 { + errs = append(errs, errors.New("exactly one source (xpkg, git, http, or k8s) must be specified")) + } + + return errors.Join(errs...) +} + +// Validate validates an xpkg dependency. +func (x *XpkgDependency) Validate() error { + var errs []error + + if x.APIVersion == "" { + errs = append(errs, errors.New("apiVersion must not be empty")) + } + if x.Kind == "" { + errs = append(errs, errors.New("kind must not be empty")) + } + if x.Package == "" { + errs = append(errs, errors.New("package must not be empty")) + } + if x.Version == "" { + errs = append(errs, errors.New("version must not be empty")) + } + + return errors.Join(errs...) +} + +// Validate validates a git dependency. +func (g *GitDependency) Validate() error { + var errs []error + + if g.Repository == "" { + errs = append(errs, errors.New("repository must not be empty")) + } + + return errors.Join(errs...) +} + +// Validate validates an HTTP dependency. +func (h *HTTPDependency) Validate() error { + var errs []error + + if h.URL == "" { + errs = append(errs, errors.New("url must not be empty")) + } + + return errors.Join(errs...) +} + +// Validate validates a Kubernetes API dependency. +func (k *K8sDependency) Validate() error { + var errs []error + + if k.Version == "" { + errs = append(errs, errors.New("version must not be empty")) + } + + return errors.Join(errs...) +} diff --git a/apis/dev/v1alpha1/validate_test.go b/apis/dev/v1alpha1/validate_test.go new file mode 100644 index 0000000..66c4ab1 --- /dev/null +++ b/apis/dev/v1alpha1/validate_test.go @@ -0,0 +1,429 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package v1alpha1 + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + pkgmetav1 "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1" +) + +func TestValidate(t *testing.T) { + t.Parallel() + + tcs := map[string]struct { + input *Project + expectedErrors []string + }{ + "MinimalValid": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + }, + }, + }, + "MaximalValid": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + ProjectPackageMetadata: ProjectPackageMetadata{ + Maintainer: "Acme Corporation", + Source: "https://github.com/acmeco/my-project.git", + License: "Apache-2.0", + Description: "I'm a unit test", + Readme: "Don't use me, I'm a unit test", + }, + Crossplane: &pkgmetav1.CrossplaneConstraints{ + Version: ">=1.17.0", + }, + Dependencies: []Dependency{{ + Type: "xpkg", + Xpkg: &XpkgDependency{ + Package: "xpkg.upbound.io/upbound/provider-aws-s3", + Version: ">=0.2.1", + APIVersion: "pkg.crossplane.io/v1", + Kind: "Provider", + }, + }}, + Paths: &ProjectPaths{ + APIs: "apis/", + Functions: "functions/", + Examples: "examples/", + Tests: "tests/", + Operations: "operations/", + }, + Architectures: []string{"arch1"}, + }, + }, + }, + "MissingName": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{}, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + }, + }, + expectedErrors: []string{ + "name must not be empty", + }, + }, + "MissingRepository": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{}, + }, + expectedErrors: []string{ + "repository must not be empty", + }, + }, + "AbsolutePaths": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Paths: &ProjectPaths{ + APIs: "/tmp/apis", + Functions: "/tmp/functions", + Examples: "/tmp/examples", + Tests: "/tmp/tests", + Operations: "/tmp/operations", + }, + }, + }, + expectedErrors: []string{ + "apis path must be relative", + "functions path must be relative", + "examples path must be relative", + "tests path must be relative", + "operations path must be relative", + }, + }, + "EmptyArchitectures": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Architectures: []string{}, + }, + }, + expectedErrors: []string{ + "architectures must not be empty", + }, + }, + "ValidAPIDependency": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Dependencies: []Dependency{ + { + Type: "crd", + Git: &GitDependency{ + Repository: "https://github.com/crossplane/crossplane.git", + Ref: "v1.14.0", + Path: "cluster/crds", + }, + }, + }, + }, + }, + }, + "InvalidAPIDependencyNoType": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Dependencies: []Dependency{ + { + Git: &GitDependency{ + Repository: "https://github.com/crossplane/crossplane.git", + }, + }, + }, + }, + }, + expectedErrors: []string{ + "dependency 0: type must not be empty", + }, + }, + "InvalidAPIDependencyNoSource": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Dependencies: []Dependency{ + { + Type: "crd", + }, + }, + }, + }, + expectedErrors: []string{ + "dependency 0: exactly one source (xpkg, git, http, or k8s) must be specified", + }, + }, + "InvalidAPIDependencyMultipleSources": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Dependencies: []Dependency{ + { + Type: "crd", + Git: &GitDependency{ + Repository: "https://github.com/crossplane/crossplane.git", + }, + HTTP: &HTTPDependency{ + URL: "https://example.com/api.yaml", + }, + }, + }, + }, + }, + expectedErrors: []string{ + "dependency 0: exactly one source (xpkg, git, http, or k8s) must be specified", + }, + }, + "InvalidAPIDependencyGitEmptyRepository": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Dependencies: []Dependency{ + { + Type: "crd", + Git: &GitDependency{ + Repository: "", + }, + }, + }, + }, + }, + expectedErrors: []string{ + "dependency 0: git: repository must not be empty", + }, + }, + "InvalidAPIDependencyHTTPEmptyURL": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Dependencies: []Dependency{ + { + Type: "crd", + HTTP: &HTTPDependency{ + URL: "", + }, + }, + }, + }, + }, + expectedErrors: []string{ + "dependency 0: http: url must not be empty", + }, + }, + "InvalidAPIDependencyK8sEmptyVersion": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Dependencies: []Dependency{ + { + Type: "k8s", + K8s: &K8sDependency{ + Version: "", + }, + }, + }, + }, + }, + expectedErrors: []string{ + "dependency 0: k8s: version must not be empty", + }, + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + t.Parallel() + + err := tc.input.Validate() + if len(tc.expectedErrors) == 0 { + if err != nil { + t.Errorf("Validate(): unexpected error: %v", err) + } + return + } + for _, expected := range tc.expectedErrors { + if err == nil || !strings.Contains(err.Error(), expected) { + t.Errorf("Validate(): expected error containing %q, got %v", expected, err) + } + } + }) + } +} + +func TestDefault(t *testing.T) { + t.Parallel() + + tcs := map[string]struct { + input *Project + want *Project + }{ + "FullySpecified": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + ProjectPackageMetadata: ProjectPackageMetadata{ + Maintainer: "Acme Corporation", + Source: "https://github.com/acmeco/my-project.git", + License: "Apache-2.0", + Description: "I'm a unit test", + Readme: "Don't use me, I'm a unit test", + }, + Crossplane: &pkgmetav1.CrossplaneConstraints{ + Version: ">=1.17.0", + }, + Dependencies: []Dependency{{ + Type: "xpkg", + Xpkg: &XpkgDependency{ + Package: "xpkg.upbound.io/upbound/provider-aws-s3", + Version: ">=0.2.1", + APIVersion: "pkg.crossplane.io/v1", + Kind: "Provider", + }, + }}, + Paths: &ProjectPaths{ + APIs: "not-default-apis/", + Functions: "not-default-functions/", + Examples: "not-default-examples/", + Tests: "not-default-tests/", + Operations: "not-default-operations/", + }, + Architectures: []string{"arch1"}, + }, + }, + want: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + ProjectPackageMetadata: ProjectPackageMetadata{ + Maintainer: "Acme Corporation", + Source: "https://github.com/acmeco/my-project.git", + License: "Apache-2.0", + Description: "I'm a unit test", + Readme: "Don't use me, I'm a unit test", + }, + Crossplane: &pkgmetav1.CrossplaneConstraints{ + Version: ">=1.17.0", + }, + Dependencies: []Dependency{{ + Type: "xpkg", + Xpkg: &XpkgDependency{ + Package: "xpkg.upbound.io/upbound/provider-aws-s3", + Version: ">=0.2.1", + APIVersion: "pkg.crossplane.io/v1", + Kind: "Provider", + }, + }}, + Paths: &ProjectPaths{ + APIs: "not-default-apis/", + Functions: "not-default-functions/", + Examples: "not-default-examples/", + Tests: "not-default-tests/", + Operations: "not-default-operations/", + Schemas: "schemas", + }, + Architectures: []string{"arch1"}, + }, + }, + }, + "MinimalValid": { + input: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + }, + }, + want: &Project{ + ObjectMeta: metav1.ObjectMeta{ + Name: "my-project", + }, + Spec: ProjectSpec{ + Repository: "xpkg.upbound.io/acmeco/my-project", + Paths: &ProjectPaths{ + APIs: "apis", + Examples: "examples", + Functions: "functions", + Tests: "tests", + Operations: "operations", + Schemas: "schemas", + }, + Architectures: []string{"amd64", "arm64"}, + }, + }, + }, + } + + for name, tc := range tcs { + t.Run(name, func(t *testing.T) { + t.Parallel() + + tc.input.Default() + if diff := cmp.Diff(tc.want, tc.input); diff != "" { + t.Errorf("Default(): -want, +got:\n%s", diff) + } + }) + } +} diff --git a/apis/dev/v1alpha1/zz_generated.deepcopy.go b/apis/dev/v1alpha1/zz_generated.deepcopy.go new file mode 100644 index 0000000..19714fc --- /dev/null +++ b/apis/dev/v1alpha1/zz_generated.deepcopy.go @@ -0,0 +1,223 @@ +//go:build !ignore_autogenerated + +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by controller-gen. DO NOT EDIT. + +package v1alpha1 + +import ( + "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1" + "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + runtime "k8s.io/apimachinery/pkg/runtime" +) + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Dependency) DeepCopyInto(out *Dependency) { + *out = *in + if in.Xpkg != nil { + in, out := &in.Xpkg, &out.Xpkg + *out = new(XpkgDependency) + **out = **in + } + if in.Git != nil { + in, out := &in.Git, &out.Git + *out = new(GitDependency) + **out = **in + } + if in.HTTP != nil { + in, out := &in.HTTP, &out.HTTP + *out = new(HTTPDependency) + **out = **in + } + if in.K8s != nil { + in, out := &in.K8s, &out.K8s + *out = new(K8sDependency) + **out = **in + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Dependency. +func (in *Dependency) DeepCopy() *Dependency { + if in == nil { + return nil + } + out := new(Dependency) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *GitDependency) DeepCopyInto(out *GitDependency) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitDependency. +func (in *GitDependency) DeepCopy() *GitDependency { + if in == nil { + return nil + } + out := new(GitDependency) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *HTTPDependency) DeepCopyInto(out *HTTPDependency) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new HTTPDependency. +func (in *HTTPDependency) DeepCopy() *HTTPDependency { + if in == nil { + return nil + } + out := new(HTTPDependency) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *K8sDependency) DeepCopyInto(out *K8sDependency) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new K8sDependency. +func (in *K8sDependency) DeepCopy() *K8sDependency { + if in == nil { + return nil + } + out := new(K8sDependency) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *Project) DeepCopyInto(out *Project) { + *out = *in + out.TypeMeta = in.TypeMeta + in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) + in.Spec.DeepCopyInto(&out.Spec) +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Project. +func (in *Project) DeepCopy() *Project { + if in == nil { + return nil + } + out := new(Project) + in.DeepCopyInto(out) + return out +} + +// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. +func (in *Project) DeepCopyObject() runtime.Object { + if c := in.DeepCopy(); c != nil { + return c + } + return nil +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectPackageMetadata) DeepCopyInto(out *ProjectPackageMetadata) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectPackageMetadata. +func (in *ProjectPackageMetadata) DeepCopy() *ProjectPackageMetadata { + if in == nil { + return nil + } + out := new(ProjectPackageMetadata) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectPaths) DeepCopyInto(out *ProjectPaths) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectPaths. +func (in *ProjectPaths) DeepCopy() *ProjectPaths { + if in == nil { + return nil + } + out := new(ProjectPaths) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *ProjectSpec) DeepCopyInto(out *ProjectSpec) { + *out = *in + out.ProjectPackageMetadata = in.ProjectPackageMetadata + if in.Crossplane != nil { + in, out := &in.Crossplane, &out.Crossplane + *out = new(v1.CrossplaneConstraints) + **out = **in + } + if in.Dependencies != nil { + in, out := &in.Dependencies, &out.Dependencies + *out = make([]Dependency, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } + if in.Paths != nil { + in, out := &in.Paths, &out.Paths + *out = new(ProjectPaths) + **out = **in + } + if in.Architectures != nil { + in, out := &in.Architectures, &out.Architectures + *out = make([]string, len(*in)) + copy(*out, *in) + } + if in.ImageConfigs != nil { + in, out := &in.ImageConfigs, &out.ImageConfigs + *out = make([]v1beta1.ImageConfig, len(*in)) + for i := range *in { + (*in)[i].DeepCopyInto(&(*out)[i]) + } + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new ProjectSpec. +func (in *ProjectSpec) DeepCopy() *ProjectSpec { + if in == nil { + return nil + } + out := new(ProjectSpec) + in.DeepCopyInto(out) + return out +} + +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *XpkgDependency) DeepCopyInto(out *XpkgDependency) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new XpkgDependency. +func (in *XpkgDependency) DeepCopy() *XpkgDependency { + if in == nil { + return nil + } + out := new(XpkgDependency) + in.DeepCopyInto(out) + return out +} diff --git a/cmd/crossplane/composition/composition.go b/cmd/crossplane/composition/composition.go index f9b42c0..693b2ff 100644 --- a/cmd/crossplane/composition/composition.go +++ b/cmd/crossplane/composition/composition.go @@ -24,6 +24,7 @@ import ( // Cmd contains commands for working with Crossplane Compositions. type Cmd struct { - Convert convert.Cmd `cmd:"" help:"Convert a Composition to a newer version." maturity:"beta"` - Render xr.Cmd `cmd:"" help:"Render a composite resource (XR)."` + Convert convert.Cmd `cmd:"" help:"Convert a Composition to a newer version." maturity:"beta"` + Generate generateCmd `cmd:"" help:"Generate a Composition for a CompositeResourceDefinition (XRD)." maturity:"beta"` + Render xr.Cmd `cmd:"" help:"Render a composite resource (XR)."` } diff --git a/cmd/crossplane/composition/generate.go b/cmd/crossplane/composition/generate.go new file mode 100644 index 0000000..f865e6a --- /dev/null +++ b/cmd/crossplane/composition/generate.go @@ -0,0 +1,254 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package composition + +import ( + "context" + "fmt" + "path/filepath" + "strings" + + "github.com/spf13/afero" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + apiextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + v2 "github.com/crossplane/crossplane/apis/v2/apiextensions/v2" + pkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +const ( + functionAutoReadyName = "crossplane-contrib-function-auto-ready" + functionAutoReadyPackage = "xpkg.crossplane.io/crossplane-contrib/function-auto-ready" +) + +type generateCmd struct { + XRD string `arg:"" help:"Path to the CompositeResourceDefinition (XRD) file."` + Name string `help:"Name prefix for the composition." optional:""` + Plural string `help:"Custom plural for the CompositeTypeRef.Kind." optional:""` + Path string `help:"Output file path override." optional:""` + ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + + projFS afero.Fs + apisFS afero.Fs + proj *v1alpha1.Project + depManager *dependency.Manager +} + +// AfterApply sets up the project filesystem. +func (c *generateCmd) AfterApply() error { + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projDirPath := filepath.Dir(projFilePath) + c.projFS = afero.NewBasePathFs(afero.NewOsFs(), projDirPath) + + proj, err := projectfile.Parse(c.projFS, filepath.Base(c.ProjectFile)) + if err != nil { + return err + } + + c.proj = proj + c.apisFS = afero.NewBasePathFs(c.projFS, proj.Spec.Paths.APIs) + cacheDir := c.CacheDir + if cacheDir == "" { + cacheDir = dependency.DefaultCacheDir() + } + + client, err := clixpkg.NewClient( + clixpkg.NewRemoteFetcher(), + clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir), + ) + if err != nil { + return err + } + resolver := clixpkg.NewResolver(client) + + c.depManager = dependency.NewManager(proj, c.projFS, + dependency.WithProjectFile(filepath.Base(c.ProjectFile)), + dependency.WithXpkgClient(client), + dependency.WithResolver(resolver), + ) + return nil +} + +func (c *generateCmd) Run(sp terminal.SpinnerPrinter) error { + ctx := context.Background() + + if err := sp.WrapWithSuccessSpinner("Ensuring function-auto-ready dependency", func() error { + return c.ensureFunctionAutoReady(ctx) + }); err != nil { + return errors.Wrap(err, "failed to ensure function-auto-ready dependency") + } + + return sp.WrapWithSuccessSpinner("Writing Composition", func() error { + comp, plural, err := c.newComposition() + if err != nil { + return errors.Wrap(err, "failed to create Composition") + } + + compYAML, err := marshalComposition(comp) + if err != nil { + return errors.Wrap(err, "failed to marshal Composition to YAML") + } + + filePath := c.Path + if filePath == "" { + if c.Name != "" { + filePath = fmt.Sprintf("%s/composition-%s.yaml", strings.ToLower(plural), c.Name) + } else { + filePath = fmt.Sprintf("%s/composition.yaml", strings.ToLower(plural)) + } + } + + exists, err := afero.Exists(c.apisFS, filePath) + if err != nil { + return errors.Wrap(err, "failed to check if file exists") + } + if exists { + return errors.Errorf("file %q already exists, use --path to specify a different output path or delete the existing file", filePath) + } + + if err := c.apisFS.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return errors.Wrap(err, "failed to create directories for the specified output path") + } + + return afero.WriteFile(c.apisFS, filePath, compYAML, 0o644) + }) +} + +func (c *generateCmd) ensureFunctionAutoReady(ctx context.Context) error { + for _, dep := range c.proj.Spec.Dependencies { + if dep.Type == v1alpha1.DependencyTypeXpkg && dep.Xpkg != nil && dep.Xpkg.Package == functionAutoReadyPackage { + return nil + } + } + + return c.depManager.AddDependency(ctx, &v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeXpkg, + Xpkg: &v1alpha1.XpkgDependency{ + APIVersion: pkgv1.FunctionGroupVersionKind.GroupVersion().String(), + Kind: pkgv1.FunctionKind, + Package: functionAutoReadyPackage, + Version: ">=v0.0.0", + }, + }) +} + +func (c *generateCmd) newComposition() (*apiextv1.Composition, string, error) { + group, version, kind, plural, err := c.processXRD() + if err != nil { + return nil, "", errors.Wrap(err, "failed to load XRD") + } + + name := strings.ToLower(fmt.Sprintf("%s.%s", plural, group)) + if c.Name != "" { + name = strings.ToLower(fmt.Sprintf("%s.%s.%s", c.Name, plural, group)) + } + + comp := &apiextv1.Composition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: apiextv1.CompositionGroupVersionKind.GroupVersion().String(), + Kind: apiextv1.CompositionGroupVersionKind.Kind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: apiextv1.CompositionSpec{ + CompositeTypeRef: apiextv1.TypeReference{ + APIVersion: fmt.Sprintf("%s/%s", group, version), + Kind: kind, + }, + Mode: apiextv1.CompositionModePipeline, + Pipeline: []apiextv1.PipelineStep{ + { + Step: functionAutoReadyName, + FunctionRef: apiextv1.FunctionReference{ + Name: xpkg.ToDNSLabel(functionAutoReadyName), + }, + }, + }, + }, + } + + return comp, plural, nil +} + +func (c *generateCmd) processXRD() (group, version, kind, plural string, err error) { + raw, err := afero.ReadFile(c.projFS, c.XRD) + if err != nil { + return "", "", "", "", errors.Wrapf(err, "failed to read XRD file %s", c.XRD) + } + + var xrd v2.CompositeResourceDefinition + if err := yaml.Unmarshal(raw, &xrd); err != nil { + return "", "", "", "", errors.Wrap(err, "failed to unmarshal XRD") + } + + if xrd.Spec.Group == "" { + return "", "", "", "", errors.New("XRD spec.group is required") + } + if xrd.Spec.Names.Kind == "" { + return "", "", "", "", errors.New("XRD spec.names.kind is required") + } + + group = xrd.Spec.Group + kind = xrd.Spec.Names.Kind + plural = xrd.Spec.Names.Plural + if c.Plural != "" { + plural = c.Plural + } + + // Find the version that is served and referenceable. + for _, v := range xrd.Spec.Versions { + if v.Served && v.Referenceable { + version = v.Name + break + } + } + if version == "" { + return "", "", "", "", errors.New("no served and referenceable version found in XRD") + } + + return group, version, kind, plural, nil +} + +// marshalComposition marshals a Composition to YAML, removing creationTimestamp and status. +func marshalComposition(obj any) ([]byte, error) { + unst, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, err + } + + unstructured.RemoveNestedField(unst, "status") + unstructured.RemoveNestedField(unst, "metadata", "creationTimestamp") + + return yaml.Marshal(unst) +} diff --git a/cmd/crossplane/composition/generate_test.go b/cmd/crossplane/composition/generate_test.go new file mode 100644 index 0000000..e3dfbe1 --- /dev/null +++ b/cmd/crossplane/composition/generate_test.go @@ -0,0 +1,243 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package composition + +import ( + "context" + "io" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" + "sigs.k8s.io/yaml" + + apiextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/terminal" +) + +const testProjectYAML = `apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: test-project +spec: + paths: + apis: apis +` + +const testXRDYAML = `apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: xexamples.example.org +spec: + group: example.org + names: + kind: XExample + plural: xexamples + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object +` + +// testProjectWithAutoReady returns a Project that already has +// function-auto-ready in dependencies, so ensureFunctionAutoReady is a no-op +// without needing a real dependency manager. +func testProjectWithAutoReady() *v1alpha1.Project { + return &v1alpha1.Project{ + Spec: v1alpha1.ProjectSpec{ + Paths: &v1alpha1.ProjectPaths{ + APIs: "apis", + }, + Dependencies: []v1alpha1.Dependency{ + { + Type: v1alpha1.DependencyTypeXpkg, + Xpkg: &v1alpha1.XpkgDependency{ + Package: functionAutoReadyPackage, + Version: ">=v0.0.0", + }, + }, + }, + }, + } +} + +func setupTestFS(t *testing.T) (afero.Fs, afero.Fs) { + t.Helper() + fs := afero.NewMemMapFs() + _ = afero.WriteFile(fs, "crossplane-project.yaml", []byte(testProjectYAML), 0o644) + _ = fs.MkdirAll("apis/xexamples", 0o755) + _ = afero.WriteFile(fs, "apis/xexamples/definition.yaml", []byte(testXRDYAML), 0o644) + return fs, afero.NewBasePathFs(fs, "apis") +} + +func TestGenerateComposition(t *testing.T) { + type want struct { + file string + compName string + apiVersion string + kind string + mode apiextv1.CompositionMode + pipelineLen int + firstStep string + errSubstring string + } + + cases := map[string]struct { + name string + plural string + preExisting map[string]string + want want + }{ + "Default": { + want: want{ + file: "xexamples/composition.yaml", + compName: "xexamples.example.org", + apiVersion: "example.org/v1alpha1", + kind: "XExample", + mode: apiextv1.CompositionModePipeline, + pipelineLen: 1, + firstStep: functionAutoReadyName, + }, + }, + "WithName": { + name: "aws", + want: want{ + file: "xexamples/composition-aws.yaml", + compName: "aws.xexamples.example.org", + }, + }, + "WithCustomPlural": { + plural: "xthings", + want: want{ + file: "xthings/composition.yaml", + compName: "xthings.example.org", + }, + }, + "FileAlreadyExists": { + preExisting: map[string]string{ + "apis/xexamples/composition.yaml": "existing", + }, + want: want{ + errSubstring: "already exists", + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + fs, apisFS := setupTestFS(t) + for path, content := range tc.preExisting { + _ = afero.WriteFile(fs, path, []byte(content), 0o644) + } + + cmd := &generateCmd{ + XRD: "apis/xexamples/definition.yaml", + Name: tc.name, + Plural: tc.plural, + projFS: fs, + apisFS: apisFS, + proj: testProjectWithAutoReady(), + } + + err := cmd.Run(terminal.NewSpinnerPrinter(io.Discard, false)) + if tc.want.errSubstring != "" { + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.want.errSubstring) + } + if !strings.Contains(err.Error(), tc.want.errSubstring) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.want.errSubstring) + } + return + } + if err != nil { + t.Fatal(err) + } + + exists, err := afero.Exists(apisFS, tc.want.file) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Fatalf("expected %q to be created", tc.want.file) + } + + data, err := afero.ReadFile(apisFS, tc.want.file) + if err != nil { + t.Fatal(err) + } + + var comp apiextv1.Composition + if err := yaml.Unmarshal(data, &comp); err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.want.compName, comp.Name); diff != "" { + t.Errorf("name mismatch (-want +got):\n%s", diff) + } + if tc.want.apiVersion != "" { + if diff := cmp.Diff(tc.want.apiVersion, comp.Spec.CompositeTypeRef.APIVersion); diff != "" { + t.Errorf("apiVersion mismatch (-want +got):\n%s", diff) + } + } + if tc.want.kind != "" { + if diff := cmp.Diff(tc.want.kind, comp.Spec.CompositeTypeRef.Kind); diff != "" { + t.Errorf("kind mismatch (-want +got):\n%s", diff) + } + } + if tc.want.mode != "" { + if diff := cmp.Diff(tc.want.mode, comp.Spec.Mode); diff != "" { + t.Errorf("mode mismatch (-want +got):\n%s", diff) + } + } + if tc.want.pipelineLen > 0 { + if len(comp.Spec.Pipeline) != tc.want.pipelineLen { + t.Fatalf("pipeline len = %d, want %d", len(comp.Spec.Pipeline), tc.want.pipelineLen) + } + if diff := cmp.Diff(tc.want.firstStep, comp.Spec.Pipeline[0].Step); diff != "" { + t.Errorf("first step mismatch (-want +got):\n%s", diff) + } + } + }) + } +} + +func TestEnsureFunctionAutoReady(t *testing.T) { + cases := map[string]struct { + proj *v1alpha1.Project + }{ + // depManager is nil — if ensureFunctionAutoReady doesn't short-circuit, + // it will panic, which is the desired failure mode for this test. + "AlreadyExists": { + proj: testProjectWithAutoReady(), + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + cmd := &generateCmd{proj: tc.proj} + if err := cmd.ensureFunctionAutoReady(context.Background()); err != nil { + t.Fatalf("unexpected error: %v", err) + } + }) + } +} diff --git a/cmd/crossplane/dependency/add.go b/cmd/crossplane/dependency/add.go new file mode 100644 index 0000000..053d2e3 --- /dev/null +++ b/cmd/crossplane/dependency/add.go @@ -0,0 +1,174 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dependency + +import ( + "context" + "path/filepath" + "strings" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// addCmd adds a dependency to the current project. +type addCmd struct { + Package string `arg:"" help:"Package to be added (e.g. xpkg.crossplane.io/crossplane-contrib/provider-nop:v0.5.0, k8s:v1.33.0, a git repo URL, or an HTTP URL)."` + ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + + // Flags for specific dependency types. + APIOnly bool `help:"Mark an xpkg dependency as API-only (not a runtime dependency)." name:"api-only"` + GitRef string `help:"Git ref for CRD dependencies (branch, tag, or commit SHA)." name:"git-ref"` + GitPath string `help:"Path within the git repository for CRD dependencies." name:"git-path"` +} + +// Run executes the add command. +func (c *addCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error { + ctx := context.Background() + + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projDirPath := filepath.Dir(projFilePath) + projFS := afero.NewBasePathFs(afero.NewOsFs(), projDirPath) + + proj, err := projectfile.Parse(projFS, filepath.Base(c.ProjectFile)) + if err != nil { + return err + } + + cacheDir := c.CacheDir + if cacheDir == "" { + cacheDir = dependency.DefaultCacheDir() + } + + client, err := clixpkg.NewClient( + clixpkg.NewRemoteFetcher(), + clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir), + ) + if err != nil { + return err + } + resolver := clixpkg.NewResolver(client) + + m := dependency.NewManager(proj, projFS, + dependency.WithProjectFile(c.ProjectFile), + dependency.WithXpkgClient(client), + dependency.WithResolver(resolver), + ) + + dep, err := c.buildDependency() + if err != nil { + return err + } + + desc := dependency.GetSourceDescription(dep) + logger.Debug("Adding dependency", "dependency", desc) + return sp.WrapWithSuccessSpinner("Adding "+desc, func() error { + return m.AddDependency(ctx, &dep) + }) +} + +func (c *addCmd) buildDependency() (v1alpha1.Dependency, error) { + // k8s dependency: k8s:vX.Y.Z + if version, found := strings.CutPrefix(c.Package, "k8s:"); found { + if version == "" { + return v1alpha1.Dependency{}, errors.New("k8s version is required (e.g., k8s:v1.33.0)") + } + if c.APIOnly { + return v1alpha1.Dependency{}, errors.New("--api-only is only valid for xpkg dependencies") + } + return v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeK8s, + K8s: &v1alpha1.K8sDependency{ + Version: version, + }, + }, nil + } + + // CRD dependency via git ref. + if c.GitRef != "" { + if c.Package == "" { + return v1alpha1.Dependency{}, errors.New("repository URL is required for git-based CRD dependencies") + } + if c.APIOnly { + return v1alpha1.Dependency{}, errors.New("--api-only is only valid for xpkg dependencies") + } + return v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeCRD, + Git: &v1alpha1.GitDependency{ + Repository: c.Package, + Ref: c.GitRef, + Path: c.GitPath, + }, + }, nil + } + + // CRD dependency via HTTP URL. + if strings.HasPrefix(c.Package, "http://") || strings.HasPrefix(c.Package, "https://") { + if c.APIOnly { + return v1alpha1.Dependency{}, errors.New("--api-only is only valid for xpkg dependencies") + } + return v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeCRD, + HTTP: &v1alpha1.HTTPDependency{ + URL: c.Package, + }, + }, nil + } + + // Default: xpkg dependency. We allow three formats: + // + // 1. registry.example.com/repo: + // 2. registry.example.com/repo@ + // 3. registry.example.com/repo (implies a semver constraint of '>=v0.0.0'). + pkg := c.Package + version := ">=v0.0.0" + if ref, err := name.NewDigest(c.Package, name.StrictValidation); err == nil { + pkg = ref.Context().String() + version = ref.DigestStr() + } else if repo, tag, ok := strings.Cut(c.Package, ":"); ok { + // NOTE(adamwg): This doesn't work properly if the dependency has a + // colon in the registry part for a port number (e.g., + // example.com:5000/my-repo:v1.2.3). But there's no easy way to handle + // that correctly in all cases (since we also allow + // `example.com:5000/my-repo, with no tag/constraint), so leave the + // corner case unhandled for now. + pkg = repo + version = tag + } + + return v1alpha1.Dependency{ + Type: v1alpha1.DependencyTypeXpkg, + Xpkg: &v1alpha1.XpkgDependency{ + Package: pkg, + Version: version, + APIOnly: c.APIOnly, + }, + }, nil +} diff --git a/cmd/crossplane/dependency/auth.go b/cmd/crossplane/dependency/auth.go new file mode 100644 index 0000000..2a3ecca --- /dev/null +++ b/cmd/crossplane/dependency/auth.go @@ -0,0 +1,37 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dependency + +import ( + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" +) + +type gitAuthProvider struct { + username string + token string +} + +func (g *gitAuthProvider) GetAuthMethod() (transport.AuthMethod, error) { + if g.token != "" { + return &http.BasicAuth{ + Username: g.username, + Password: g.token, + }, nil + } + return nil, nil +} diff --git a/cmd/crossplane/dependency/cache.go b/cmd/crossplane/dependency/cache.go new file mode 100644 index 0000000..6449d8a --- /dev/null +++ b/cmd/crossplane/dependency/cache.go @@ -0,0 +1,136 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dependency + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/alecthomas/kong" + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// updateCacheCmd updates the dependency cache by regenerating all schemas. +type updateCacheCmd struct { + ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + GitToken string `env:"CROSSPLANE_GIT_TOKEN" help:"Token for git HTTPS authentication."` + GitUsername string `default:"x-access-token" env:"CROSSPLANE_GIT_USERNAME" help:"Username for git HTTPS authentication."` +} + +// Run executes the update-cache command. +func (c *updateCacheCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error { + ctx := context.Background() + + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projDirPath := filepath.Dir(projFilePath) + projFS := afero.NewBasePathFs(afero.NewOsFs(), projDirPath) + + proj, err := projectfile.Parse(projFS, filepath.Base(c.ProjectFile)) + if err != nil { + return err + } + + cacheDir := c.CacheDir + if cacheDir == "" { + cacheDir = dependency.DefaultCacheDir() + } + + client, err := clixpkg.NewClient( + clixpkg.NewRemoteFetcher(), + clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir), + ) + if err != nil { + return err + } + resolver := clixpkg.NewResolver(client) + + opts := []dependency.ManagerOption{ + dependency.WithProjectFile(c.ProjectFile), + dependency.WithXpkgClient(client), + dependency.WithResolver(resolver), + } + + if c.GitToken != "" { + opts = append(opts, dependency.WithGitAuthProvider(&gitAuthProvider{ + username: c.GitUsername, + token: c.GitToken, + })) + } + + m := dependency.NewManager(proj, projFS, opts...) + + logger.Debug("Updating all dependencies") + return sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { + return m.RefreshAll(ctx, ch) + }) +} + +// cleanCacheCmd removes all generated schemas. +type cleanCacheCmd struct { + ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + KeepPackages bool `help:"Keep cached xpkg package contents; remove only generated schemas." name:"keep-packages"` +} + +// Run executes the clean-cache command. +func (c *cleanCacheCmd) Run(k *kong.Context, _ logging.Logger) error { + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projDirPath := filepath.Dir(projFilePath) + projFS := afero.NewBasePathFs(afero.NewOsFs(), projDirPath) + + proj, err := projectfile.Parse(projFS, filepath.Base(c.ProjectFile)) + if err != nil { + return err + } + + m := dependency.NewManager(proj, projFS, + dependency.WithProjectFile(c.ProjectFile), + ) + + if err := m.Clean(); err != nil { + return err + } + + if !c.KeepPackages { + cacheDir := c.CacheDir + if cacheDir == "" { + cacheDir = dependency.DefaultCacheDir() + } + if err := dependency.CleanPackages(cacheDir, afero.NewOsFs()); err != nil { + return err + } + } + + fmt.Fprintln(k.Stdout, "Schema cache cleaned") //nolint:errcheck // TODO(adamwg): Clean up output. + return nil +} diff --git a/cmd/crossplane/dependency/dependency.go b/cmd/crossplane/dependency/dependency.go new file mode 100644 index 0000000..bbc46cf --- /dev/null +++ b/cmd/crossplane/dependency/dependency.go @@ -0,0 +1,25 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package dependency contains commands for managing project dependencies. +package dependency + +// Cmd contains commands for dependency management. +type Cmd struct { + Add addCmd `cmd:"" help:"Add a dependency to the current project."` + UpdateCache updateCacheCmd `cmd:"" help:"Update the dependency cache for the current project."` + CleanCache cleanCacheCmd `cmd:"" help:"Clean the dependency cache."` +} diff --git a/cmd/crossplane/function/function.go b/cmd/crossplane/function/function.go new file mode 100644 index 0000000..7ce0ae8 --- /dev/null +++ b/cmd/crossplane/function/function.go @@ -0,0 +1,23 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package function contains commands for working with Functions. +package function + +// Cmd contains Function subcommands. +type Cmd struct { + Generate generateCmd `cmd:"" help:"Generate a Function for a Composition."` +} diff --git a/cmd/crossplane/function/generate.go b/cmd/crossplane/function/generate.go new file mode 100644 index 0000000..34eb773 --- /dev/null +++ b/cmd/crossplane/function/generate.go @@ -0,0 +1,407 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package function + +import ( + "archive/tar" + "bytes" + "context" + "embed" + "fmt" + "io/fs" + "path" + "path/filepath" + "strings" + "text/template" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/afero" + "github.com/spf13/afero/tarfs" + "golang.org/x/mod/module" + "k8s.io/apimachinery/pkg/util/validation" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + v1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/filesystem" + "github.com/crossplane/cli/v2/internal/kcl" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/schemas/generator" + "github.com/crossplane/cli/v2/internal/schemas/manager" + "github.com/crossplane/cli/v2/internal/schemas/runner" + "github.com/crossplane/cli/v2/internal/terminal" +) + +var ( + //go:embed templates/kcl/* + kclTemplates embed.FS + //go:embed all:templates/python + pythonTemplates embed.FS + //go:embed templates/go-templating/* + goTemplatingTemplates embed.FS + + // The go template contains a go.mod, so we can't embed it as an + // embed.FS. Instead we have to embed it as a tar archive and extract it + // in code. + //go:embed templates/go.tar + goTemplate []byte +) + +type generateCmd struct { + Name string `arg:"" help:"Name of the function to generate. Must be a valid DNS-1035 label."` + PipelinePath string `arg:"" help:"Path to a Composition YAML file to add a pipeline step to." optional:""` + Language string `default:"go-templating" enum:"go,go-templating,kcl,python" help:"Language to use for the function." short:"l"` + ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition file." short:"f"` + + projFS afero.Fs + functionsFS afero.Fs + schemasFS afero.Fs + proj *v1alpha1.Project + fsPath string + projectRepository string + projectSource string +} + +// AfterApply sets up the project filesystem. +func (c *generateCmd) AfterApply() error { + if errs := validation.IsDNS1035Label(c.Name); len(errs) > 0 { + return errors.Errorf("invalid function name %q: %s", c.Name, strings.Join(errs, "; ")) + } + + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projDirPath := filepath.Dir(projFilePath) + c.projFS = afero.NewBasePathFs(afero.NewOsFs(), projDirPath) + + proj, err := projectfile.Parse(c.projFS, filepath.Base(c.ProjectFile)) + if err != nil { + return err + } + c.proj = proj + + c.functionsFS = afero.NewBasePathFs(c.projFS, proj.Spec.Paths.Functions) + c.schemasFS = afero.NewBasePathFs(c.projFS, proj.Spec.Paths.Schemas) + c.fsPath = path.Join(proj.Spec.Paths.Functions, c.Name) + c.projectRepository = proj.Spec.Repository + c.projectSource = proj.Spec.Source + return nil +} + +// Run generates a function scaffold. +func (c *generateCmd) Run(sp terminal.SpinnerPrinter) error { + if err := c.validatePaths(); err != nil { + return err + } + + ctx := context.Background() + apisFS := afero.NewBasePathFs(c.projFS, c.proj.Spec.Paths.APIs) + if c.proj.Spec.Paths.APIs == "/" { + apisFS = c.projFS + } + schemaMgr := manager.New( + c.schemasFS, + generator.AllLanguages(), + runner.NewRealSchemaRunner(runner.WithImageConfig(c.proj.Spec.ImageConfigs)), + ) + + if err := sp.WrapWithSuccessSpinner("Generating schemas", func() error { + _, err := schemaMgr.Generate(ctx, manager.NewFSSource(c.proj.Spec.Paths.APIs, apisFS)) + return err + }); err != nil { + return errors.Wrap(err, "failed to generate schemas") + } + + type generatorFunc func(afero.Fs) error + generators := map[string]generatorFunc{ + "go": c.generateGoFiles, + "go-templating": c.generateGoTemplatingFiles, + "kcl": c.generateKCLFiles, + "python": c.generatePythonFiles, + } + + generator, ok := generators[c.Language] + if !ok { + return errors.Errorf("unsupported language %q", c.Language) + } + + memFS := afero.NewMemMapFs() + if err := sp.WrapWithSuccessSpinner(fmt.Sprintf("Generating %s function", c.Language), func() error { + return generator(memFS) + }); err != nil { + return errors.Wrap(err, "cannot generate function files") + } + + if err := sp.WrapWithSuccessSpinner("Writing function files", func() error { + if err := copyFiles(memFS, c.functionsFS, c.Name); err != nil { + return errors.Wrap(err, "cannot write function files") + } + + if needsModelsSymlink(c.Language) { + symlinkPath := filepath.Join(c.proj.Spec.Paths.Functions, c.Name, "model") + schemasPath := filepath.Join(c.proj.Spec.Paths.Schemas, c.Language) + + projFS, ok := c.projFS.(*afero.BasePathFs) + if !ok { + return errors.Errorf("unexpected filesystem type %T for project", c.projFS) + } + + if err := filesystem.CreateSymlink(projFS, symlinkPath, projFS, schemasPath); err != nil { + return errors.Wrapf(err, "cannot create models symlink") + } + } + return nil + }); err != nil { + return err + } + + if c.PipelinePath != "" { + if err := sp.WrapWithSuccessSpinner("Adding pipeline step to composition", func() error { + repo, err := name.NewRepository(c.projectRepository + "_" + c.Name) + if err != nil { + return errors.Wrapf(err, "cannot build function reference from repository %q", c.projectRepository) + } + functionRef := xpkg.ToDNSLabel(repo.RepositoryStr()) + return addStepToComposition(c.projFS, c.PipelinePath, c.Name, functionRef) + }); err != nil { + return errors.Wrap(err, "cannot add pipeline step to composition") + } + } + + return nil +} + +func (c *generateCmd) validatePaths() error { + if c.PipelinePath != "" { + exists, err := afero.Exists(c.projFS, c.PipelinePath) + if err != nil { + return errors.Wrapf(err, "cannot check pipeline path %q", c.PipelinePath) + } + if !exists { + return errors.Errorf("pipeline path %q does not exist", c.PipelinePath) + } + } + + exists, err := afero.DirExists(c.functionsFS, c.Name) + if err != nil { + return errors.Wrapf(err, "cannot check function directory %q", c.Name) + } + if exists { + empty, err := afero.IsEmpty(c.functionsFS, c.Name) + if err != nil { + return errors.Wrapf(err, "cannot check function directory %q", c.Name) + } + if !empty { + return errors.Errorf("function directory %q already exists and is not empty", c.Name) + } + } + + return nil +} + +func needsModelsSymlink(language string) bool { + return language == "kcl" +} + +type kclTemplateData struct { + ModName string + Imports []kclImportStatement +} + +type kclImportStatement struct { + ImportPath string + Alias string +} + +func (c *generateCmd) generateKCLFiles(fs afero.Fs) error { + tmpls := template.Must(template.ParseFS(kclTemplates, "templates/kcl/*")) + + foundFolders, _ := filesystem.FindNestedFoldersWithPattern(c.schemasFS, "kcl", "*.k") + + existingAliases := make(map[string]bool) + importStatements := make([]kclImportStatement, 0, len(foundFolders)) + for _, folder := range foundFolders { + importPath, alias := kcl.FormatKclImportPath(folder, existingAliases) + if importPath == "" { + continue + } + importStatements = append(importStatements, kclImportStatement{ + ImportPath: importPath, + Alias: alias, + }) + } + + tmplData := kclTemplateData{ + ModName: c.Name, + Imports: importStatements, + } + + return renderTemplates(fs, tmpls, tmplData) +} + +type pythonTemplateData struct { + HasSchemas bool + SchemasPath string +} + +func (c *generateCmd) generatePythonFiles(targetFS afero.Fs) error { + hasSchemas, _ := afero.DirExists(c.schemasFS, "python") + if hasSchemas { + entries, err := afero.ReadDir(c.schemasFS, "python") + if err != nil { + return errors.Wrap(err, "cannot read python schemas directory") + } + hasSchemas = len(entries) > 0 + } + + // Compute the relative path from the function dir to schemas/python/. + fnDir := filepath.Join("/", c.proj.Spec.Paths.Functions, c.Name) + relRoot, err := filepath.Rel(fnDir, "/") + if err != nil { + return errors.Wrap(err, "cannot determine path to schemas directory") + } + schemasPath := filepath.ToSlash(filepath.Join(relRoot, c.proj.Spec.Paths.Schemas, "python")) + + // template.ParseFS doesn't handle subdirectories, so we need to template + // the top-level directory and the 'function' sub-directory separately. + data := pythonTemplateData{ + HasSchemas: hasSchemas, + SchemasPath: schemasPath, + } + tmpls := template.Must(template.ParseFS(pythonTemplates, "templates/python/*.*")) + if err := renderTemplates(targetFS, tmpls, data); err != nil { + return err + } + + if err := targetFS.Mkdir("function", 0o755); err != nil { + return errors.Wrap(err, "cannot create function directory") + } + tmpls = template.Must(template.ParseFS(pythonTemplates, "templates/python/function/*.*")) + return renderTemplates(afero.NewBasePathFs(targetFS, "function"), tmpls, data) +} + +type goTemplateData struct { + ModulePath string + Imports []goImport +} + +type goImport struct { + Module string + Version string + Replace string +} + +func (c *generateCmd) generateGoFiles(fs afero.Fs) error { + source := strings.TrimPrefix(c.projectSource, "https://") + goModPath := path.Join(source, "functions", c.Name) + if source == "" || module.CheckPath(goModPath) != nil { + goModPath = c.projectRepository + "/" + c.Name + } + if module.CheckPath(goModPath) != nil { + goModPath = "project.example.com/functions/" + c.Name + } + + // Compute relative path from the function dir to schemas/go/. + fnDir := filepath.Join("/", c.proj.Spec.Paths.Functions, c.Name) + relRoot, err := filepath.Rel(fnDir, "/") + if err != nil { + return errors.Wrap(err, "cannot determine path to models directory") + } + + var imports []goImport + schemasGoPath := filepath.Join(relRoot, c.proj.Spec.Paths.Schemas, "go") + hasSchemas, _ := afero.DirExists(c.schemasFS, "go") + if hasSchemas { + imports = []goImport{{ + Module: "dev.crossplane.io/models", + Version: "v0.0.0", + Replace: schemasGoPath, + }} + } + + tr := tar.NewReader(bytes.NewReader(goTemplate)) + templateFS := afero.NewIOFS(tarfs.New(tr)) + + tmpls := template.Must(template.ParseFS(templateFS, "*")) + tmplData := goTemplateData{ + ModulePath: goModPath, + Imports: imports, + } + + return renderTemplates(fs, tmpls, tmplData) +} + +type goTemplatingTemplateData struct { + ModelIndexPath string +} + +func (c *generateCmd) generateGoTemplatingFiles(fs afero.Fs) error { + var modelPath string + indexExists, _ := afero.Exists(c.schemasFS, "json/index.schema.json") + if indexExists { + var err error + modelPath, err = filepath.Rel(c.fsPath, "schemas/json/index.schema.json") + if err != nil { + return errors.Wrap(err, "cannot determine model path") + } + } + + tmpls := template.Must(template.ParseFS(goTemplatingTemplates, "templates/go-templating/*")) + tmplData := goTemplatingTemplateData{ + ModelIndexPath: modelPath, + } + + return renderTemplates(fs, tmpls, tmplData) +} + +func renderTemplates(targetFS afero.Fs, tmpls *template.Template, data any) error { + for _, tmpl := range tmpls.Templates() { + fname := tmpl.Name() + // Strip .tmpl suffix from output filename. + outName := strings.TrimSuffix(fname, ".tmpl") + + file, err := targetFS.Create(filepath.Clean(outName)) + if err != nil { + return errors.Wrapf(err, "cannot create file %s", outName) + } + if err := tmpl.Execute(file, data); err != nil { + return errors.Wrapf(err, "cannot render template %s", fname) + } + if err := file.Close(); err != nil { + return errors.Wrapf(err, "cannot close file %s", outName) + } + } + return nil +} + +func copyFiles(src, dst afero.Fs, dstDir string) error { + return afero.Walk(src, "", func(p string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return dst.MkdirAll(path.Join(dstDir, p), 0o755) + } + data, err := afero.ReadFile(src, p) + if err != nil { + return err + } + return afero.WriteFile(dst, path.Join(dstDir, p), data, 0o644) + }) +} diff --git a/cmd/crossplane/function/generate_test.go b/cmd/crossplane/function/generate_test.go new file mode 100644 index 0000000..eee1372 --- /dev/null +++ b/cmd/crossplane/function/generate_test.go @@ -0,0 +1,473 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package function + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/afero" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + apiextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + v1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/terminal" +) + +func testProject() *v1alpha1.Project { + return &v1alpha1.Project{ + Spec: v1alpha1.ProjectSpec{ + Paths: &v1alpha1.ProjectPaths{ + Functions: "functions", + Schemas: "schemas", + }, + }, + } +} + +// seedFS returns a fresh MemMapFs populated with the given files. A nil byte +// slice creates an empty file. +func seedFS(t *testing.T, files map[string][]byte) afero.Fs { + t.Helper() + fs := afero.NewMemMapFs() + for path, content := range files { + dir := path[:strings.LastIndex(path, "/")+1] + if dir != "" { + _ = fs.MkdirAll(strings.TrimSuffix(dir, "/"), 0o755) + } + _ = afero.WriteFile(fs, path, content, 0o644) + } + return fs +} + +func assertFiles(t *testing.T, fs afero.Fs, paths []string) { + t.Helper() + for _, f := range paths { + exists, err := afero.Exists(fs, f) + if err != nil { + t.Fatal(err) + } + if !exists { + t.Errorf("expected file %q to exist", f) + } + } +} + +func assertContains(t *testing.T, fs afero.Fs, contains, notContains map[string][]byte) { + t.Helper() + for path, want := range contains { + data, err := afero.ReadFile(fs, path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if !bytes.Contains(data, want) { + t.Errorf("%s: missing %q\ngot:\n%s", path, want, data) + } + } + for path, want := range notContains { + data, err := afero.ReadFile(fs, path) + if err != nil { + t.Fatalf("read %s: %v", path, err) + } + if bytes.Contains(data, want) { + t.Errorf("%s: should not contain %q\ngot:\n%s", path, want, data) + } + } +} + +func TestGenerateGoTemplatingFiles(t *testing.T) { + cases := map[string]struct { + seedSchemas map[string][]byte + wantFiles []string + wantContains map[string][]byte + wantNotContains map[string][]byte + }{ + "NoSchema": { + wantFiles: []string{"00-prelude.yaml.gotmpl", "01-compose.yaml.gotmpl"}, + }, + "WithSchema": { + seedSchemas: map[string][]byte{ + "json/index.schema.json": []byte("{}"), + }, + wantFiles: []string{"00-prelude.yaml.gotmpl", "01-compose.yaml.gotmpl"}, + wantContains: map[string][]byte{ + "01-compose.yaml.gotmpl": []byte("yaml-language-server"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &generateCmd{ + Name: "my-func", + schemasFS: seedFS(t, tc.seedSchemas), + fsPath: "functions/my-func", + } + fs := afero.NewMemMapFs() + if err := c.generateGoTemplatingFiles(fs); err != nil { + t.Fatal(err) + } + assertFiles(t, fs, tc.wantFiles) + assertContains(t, fs, tc.wantContains, tc.wantNotContains) + }) + } +} + +func TestGenerateKCLFiles(t *testing.T) { + cases := map[string]struct { + seedSchemas map[string][]byte + wantFiles []string + wantContains map[string][]byte + wantNotContains map[string][]byte + }{ + "NoSchemas": { + wantFiles: []string{"main.k", "kcl.mod", "kcl.mod.lock"}, + wantContains: map[string][]byte{ + "kcl.mod": []byte(`name = "my-func"`), + }, + wantNotContains: map[string][]byte{ + "kcl.mod": []byte("[dependencies]"), + }, + }, + "WithSchemas": { + seedSchemas: map[string][]byte{ + "kcl/io/example/aws/ec2/v1beta1/res.k": []byte("schema Bucket:"), + "kcl/io/example/aws/s3/v1beta2/res.k": []byte("schema Bucket:"), + }, + wantFiles: []string{"main.k", "kcl.mod", "kcl.mod.lock"}, + wantContains: map[string][]byte{ + "main.k": []byte("import models.io.example.aws.ec2.v1beta1 as ec2v1beta1"), + "kcl.mod": []byte(`models = { path = "./model" }`), + }, + }, + "WithSchemasS3Import": { + seedSchemas: map[string][]byte{ + "kcl/io/example/aws/s3/v1beta2/res.k": []byte("schema Bucket:"), + }, + wantContains: map[string][]byte{ + "main.k": []byte("import models.io.example.aws.s3.v1beta2 as s3v1beta2"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &generateCmd{ + Name: "my-func", + schemasFS: seedFS(t, tc.seedSchemas), + } + fs := afero.NewMemMapFs() + if err := c.generateKCLFiles(fs); err != nil { + t.Fatal(err) + } + assertFiles(t, fs, tc.wantFiles) + assertContains(t, fs, tc.wantContains, tc.wantNotContains) + }) + } +} + +func TestGeneratePythonFiles(t *testing.T) { + cases := map[string]struct { + seedSchemas map[string][]byte + wantFiles []string + wantContains map[string][]byte + wantNotContains map[string][]byte + }{ + "NoSchemas": { + wantFiles: []string{ + "README.md", + "pyproject.toml", + "function/__init__.py", + "function/__version__.py", + "function/main.py", + "function/fn.py", + }, + wantNotContains: map[string][]byte{ + "pyproject.toml": []byte("crossplane-models"), + }, + }, + "WithSchemas": { + seedSchemas: map[string][]byte{ + "python/io/example/aws/v1beta1/__init__.py": nil, + }, + wantContains: map[string][]byte{ + "pyproject.toml": []byte("crossplane-models @ file:./../../schemas/python"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &generateCmd{ + Name: "my-func", + schemasFS: seedFS(t, tc.seedSchemas), + proj: testProject(), + } + fs := afero.NewMemMapFs() + if err := c.generatePythonFiles(fs); err != nil { + t.Fatal(err) + } + assertFiles(t, fs, tc.wantFiles) + assertContains(t, fs, tc.wantContains, tc.wantNotContains) + }) + } +} + +func TestGenerateGoFiles(t *testing.T) { + cases := map[string]struct { + seedSchemas map[string][]byte + wantFiles []string + wantContains map[string][]byte + }{ + "NoSchemas": { + wantFiles: []string{"main.go", "fn.go", "fn_test.go", "go.mod", "go.sum"}, + wantContains: map[string][]byte{ + "go.mod": []byte("github.com/example/my-project"), + }, + }, + "WithSchemas": { + seedSchemas: map[string][]byte{ + "go/.keep": nil, + }, + wantContains: map[string][]byte{ + "go.mod": []byte("dev.crossplane.io/models"), + }, + }, + "WithSchemasReplace": { + seedSchemas: map[string][]byte{ + "go/.keep": nil, + }, + wantContains: map[string][]byte{ + "go.mod": []byte("replace dev.crossplane.io/models"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + schemasFS := seedFS(t, tc.seedSchemas) + // Tests that probe for the schemas dir need it to exist; an empty + // MemMapFs has no entries, so create the dir explicitly. + if len(tc.seedSchemas) > 0 { + _ = schemasFS.MkdirAll("go", 0o755) + } + + c := &generateCmd{ + Name: "my-func", + projectSource: "github.com/example/my-project", + schemasFS: schemasFS, + proj: testProject(), + } + fs := afero.NewMemMapFs() + if err := c.generateGoFiles(fs); err != nil { + t.Fatal(err) + } + assertFiles(t, fs, tc.wantFiles) + assertContains(t, fs, tc.wantContains, nil) + }) + } +} + +func TestRunErrors(t *testing.T) { + cases := map[string]struct { + name string + language string + seedFunctionsFS map[string][]byte + stage string // "afterApply" or "run" + wantErrSubstring string + }{ + "InvalidName": { + name: "INVALID_NAME", + stage: "afterApply", + wantErrSubstring: "invalid function name", + }, + "DirectoryNotEmpty": { + name: "my-func", + language: "go-templating", + seedFunctionsFS: map[string][]byte{ + "my-func/existing.txt": []byte("data"), + }, + stage: "run", + wantErrSubstring: "already exists and is not empty", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + c := &generateCmd{ + Name: tc.name, + Language: tc.language, + functionsFS: seedFS(t, tc.seedFunctionsFS), + projFS: afero.NewMemMapFs(), + } + var err error + switch tc.stage { + case "afterApply": + err = c.AfterApply() + case "run": + err = c.Run(terminal.NewSpinnerPrinter(io.Discard, false)) + } + if err == nil { + t.Fatalf("expected error containing %q, got nil", tc.wantErrSubstring) + } + if !strings.Contains(err.Error(), tc.wantErrSubstring) { + t.Errorf("error = %q, want substring %q", err.Error(), tc.wantErrSubstring) + } + }) + } +} + +func TestAddCompositionStep(t *testing.T) { + cases := map[string]struct { + start []apiextv1.PipelineStep + step string + fnRef string + wantLen int + wantFirstStep string + wantFirstFn string + }{ + "PrependsToExisting": { + start: []apiextv1.PipelineStep{ + { + Step: "existing", + FunctionRef: apiextv1.FunctionReference{Name: "existing-fn"}, + }, + }, + step: "my-func", + fnRef: "my-fn-ref", + wantLen: 2, + wantFirstStep: "my-func", + wantFirstFn: "my-fn-ref", + }, + "DedupsWhenAlreadyPresent": { + start: []apiextv1.PipelineStep{ + { + Step: "my-func", + FunctionRef: apiextv1.FunctionReference{Name: "my-fn-ref"}, + }, + }, + step: "my-func", + fnRef: "my-fn-ref", + wantLen: 1, + wantFirstStep: "my-func", + wantFirstFn: "my-fn-ref", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + comp := &apiextv1.Composition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v1", + Kind: "Composition", + }, + Spec: apiextv1.CompositionSpec{ + Pipeline: tc.start, + }, + } + if err := addCompositionStep(comp, tc.step, tc.fnRef); err != nil { + t.Fatal(err) + } + if len(comp.Spec.Pipeline) != tc.wantLen { + t.Fatalf("pipeline len = %d, want %d", len(comp.Spec.Pipeline), tc.wantLen) + } + if diff := cmp.Diff(tc.wantFirstStep, comp.Spec.Pipeline[0].Step); diff != "" { + t.Errorf("first step mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.wantFirstFn, comp.Spec.Pipeline[0].FunctionRef.Name); diff != "" { + t.Errorf("first functionRef mismatch (-want +got):\n%s", diff) + } + }) + } +} + +func TestAddStepToComposition(t *testing.T) { + cases := map[string]struct { + comp *apiextv1.Composition + step string + fnRef string + wantLen int + wantFirstStep string + }{ + "PrependsAndPersists": { + comp: &apiextv1.Composition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v1", + Kind: "Composition", + }, + ObjectMeta: metav1.ObjectMeta{Name: "test-comp"}, + Spec: apiextv1.CompositionSpec{ + CompositeTypeRef: apiextv1.TypeReference{ + APIVersion: "example.org/v1", + Kind: "XExample", + }, + Mode: apiextv1.CompositionModePipeline, + Pipeline: []apiextv1.PipelineStep{ + { + Step: "auto-ready", + FunctionRef: apiextv1.FunctionReference{Name: "crossplane-contrib-function-auto-ready"}, + }, + }, + }, + }, + step: "my-func", + fnRef: "my-fn-ref", + wantLen: 2, + wantFirstStep: "my-func", + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + compYAML, err := yaml.Marshal(tc.comp) + if err != nil { + t.Fatal(err) + } + + fs := afero.NewMemMapFs() + if err := afero.WriteFile(fs, "composition.yaml", compYAML, 0o644); err != nil { + t.Fatal(err) + } + + if err := addStepToComposition(fs, "composition.yaml", tc.step, tc.fnRef); err != nil { + t.Fatal(err) + } + + data, err := afero.ReadFile(fs, "composition.yaml") + if err != nil { + t.Fatal(err) + } + + var result apiextv1.Composition + if err := yaml.Unmarshal(data, &result); err != nil { + t.Fatal(err) + } + + if len(result.Spec.Pipeline) != tc.wantLen { + t.Fatalf("pipeline len = %d, want %d", len(result.Spec.Pipeline), tc.wantLen) + } + if diff := cmp.Diff(tc.wantFirstStep, result.Spec.Pipeline[0].Step); diff != "" { + t.Errorf("first step mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/cmd/crossplane/function/pipeline.go b/cmd/crossplane/function/pipeline.go new file mode 100644 index 0000000..a92ca7f --- /dev/null +++ b/cmd/crossplane/function/pipeline.go @@ -0,0 +1,89 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package function + +import ( + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + apiextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" +) + +func addStepToComposition(fs afero.Fs, path, stepName, functionRef string) error { + comp, err := readAndUnmarshalComposition(fs, path) + if err != nil { + return err + } + + if err := addCompositionStep(comp, stepName, functionRef); err != nil { + return err + } + + data, err := marshalComposition(comp) + if err != nil { + return errors.Wrap(err, "cannot marshal composition") + } + + return afero.WriteFile(fs, path, data, 0o644) +} + +func addCompositionStep(comp *apiextv1.Composition, stepName, functionRef string) error { + for _, step := range comp.Spec.Pipeline { + if step.Step == stepName && step.FunctionRef.Name == functionRef { + return nil // already exists + } + } + + step := apiextv1.PipelineStep{ + Step: stepName, + FunctionRef: apiextv1.FunctionReference{ + Name: functionRef, + }, + } + + comp.Spec.Pipeline = append([]apiextv1.PipelineStep{step}, comp.Spec.Pipeline...) + return nil +} + +func readAndUnmarshalComposition(fs afero.Fs, path string) (*apiextv1.Composition, error) { + data, err := afero.ReadFile(fs, path) + if err != nil { + return nil, errors.Wrapf(err, "cannot read composition at %q", path) + } + + var comp apiextv1.Composition + if err := yaml.Unmarshal(data, &comp); err != nil { + return nil, errors.Wrap(err, "cannot unmarshal composition") + } + return &comp, nil +} + +func marshalComposition(obj any) ([]byte, error) { + unst, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, err + } + + unstructured.RemoveNestedField(unst, "status") + unstructured.RemoveNestedField(unst, "metadata", "creationTimestamp") + + return yaml.Marshal(unst) +} diff --git a/cmd/crossplane/function/templates/go-templating/00-prelude.yaml.gotmpl b/cmd/crossplane/function/templates/go-templating/00-prelude.yaml.gotmpl new file mode 100644 index 0000000..c67ec6e --- /dev/null +++ b/cmd/crossplane/function/templates/go-templating/00-prelude.yaml.gotmpl @@ -0,0 +1,4 @@ +{{ print `# Get the observed composite resource into a variable. This can be used in any +# subsequent templates. +{{ $xr := getCompositeResource . }} +` }} diff --git a/cmd/crossplane/function/templates/go-templating/01-compose.yaml.gotmpl b/cmd/crossplane/function/templates/go-templating/01-compose.yaml.gotmpl new file mode 100644 index 0000000..d6bb114 --- /dev/null +++ b/cmd/crossplane/function/templates/go-templating/01-compose.yaml.gotmpl @@ -0,0 +1,24 @@ +{{- if .ModelIndexPath -}} +# code: language=yaml +# yaml-language-server: $schema={{- .ModelIndexPath }} +{{ end -}} +{{ print ` +# Write your composed resources here. You can use any of the features described +# in this documentation: +# https://github.com/crossplane-contrib/function-go-templating?tab=readme-ov-file#using-this-function +# +# Example: +# +# --- +# apiVersion: nop.crossplane.io/v1alpha1 +# kind: NopResource +# metadata: +# annotations: +# {{ setResourceNameAnnotation "example" }} +# spec: +# forProvider: +# conditionAfter: +# - conditionType: Ready +# conditionStatus: "True" +# time: 5s +` }} diff --git a/cmd/crossplane/function/templates/go.tar b/cmd/crossplane/function/templates/go.tar new file mode 100644 index 0000000..1720727 Binary files /dev/null and b/cmd/crossplane/function/templates/go.tar differ diff --git a/cmd/crossplane/function/templates/kcl/kcl.mod.lock b/cmd/crossplane/function/templates/kcl/kcl.mod.lock new file mode 100644 index 0000000..61a252c --- /dev/null +++ b/cmd/crossplane/function/templates/kcl/kcl.mod.lock @@ -0,0 +1 @@ +[dependencies] diff --git a/cmd/crossplane/function/templates/kcl/kcl.mod.tmpl b/cmd/crossplane/function/templates/kcl/kcl.mod.tmpl new file mode 100644 index 0000000..4725b1a --- /dev/null +++ b/cmd/crossplane/function/templates/kcl/kcl.mod.tmpl @@ -0,0 +1,8 @@ +[package] +name = "{{.ModName}}" +version = "0.0.1" +{{- if .Imports }} + +[dependencies] +models = { path = "./model" } +{{- end }} diff --git a/cmd/crossplane/function/templates/kcl/main.k b/cmd/crossplane/function/templates/kcl/main.k new file mode 100644 index 0000000..6527a03 --- /dev/null +++ b/cmd/crossplane/function/templates/kcl/main.k @@ -0,0 +1,32 @@ +{{- if .Imports }} +{{- range .Imports }} +import {{.ImportPath}} as {{.Alias}} +{{- end }} +{{ "\n" -}} +{{- end -}} +oxr = option("params").oxr # observed composite resource +_ocds = option("params").ocds # observed composed resources +_dxr = option("params").dxr # desired composite resource +dcds = option("params").dcds # desired composed resources + +_metadata = lambda name: str -> any { + { annotations = { "krm.kcl.dev/composition-resource-name" = name }} +} + +# Example to retrieve variables from "xr"; update as needed +# _region = "us-east-1" +# if oxr.spec?.parameters?.region: +# _region = oxr.spec.parameters.region + +_items = [ +# Example S3 Bucket managed resource configuration; update as needed +# s3v1beta2.Bucket{ +# metadata: _metadata("my-bucket") +# spec: { +# forProvider: { +# region: _region +# } +# } +# } +] +items = _items diff --git a/cmd/crossplane/function/templates/python/README.md b/cmd/crossplane/function/templates/python/README.md new file mode 100644 index 0000000..ea69553 --- /dev/null +++ b/cmd/crossplane/function/templates/python/README.md @@ -0,0 +1,4 @@ +# Composition Function + +The Python `hatch` toolchain requires that projects have a README file. You may +fill in details about your compostiion function here. diff --git a/cmd/crossplane/function/templates/python/function/__init__.py b/cmd/crossplane/function/templates/python/function/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cmd/crossplane/function/templates/python/function/__version__.py b/cmd/crossplane/function/templates/python/function/__version__.py new file mode 100644 index 0000000..6c8e6b9 --- /dev/null +++ b/cmd/crossplane/function/templates/python/function/__version__.py @@ -0,0 +1 @@ +__version__ = "0.0.0" diff --git a/cmd/crossplane/function/templates/python/function/fn.py b/cmd/crossplane/function/templates/python/function/fn.py new file mode 100644 index 0000000..5d0d17b --- /dev/null +++ b/cmd/crossplane/function/templates/python/function/fn.py @@ -0,0 +1,29 @@ +"""A Crossplane composition function.""" + +import grpc +from crossplane.function import logging, response +from crossplane.function.proto.v1 import run_function_pb2 as fnv1 +from crossplane.function.proto.v1 import run_function_pb2_grpc as grpcv1 + + +class FunctionRunner(grpcv1.FunctionRunnerService): + """A FunctionRunner handles gRPC RunFunctionRequests.""" + + def __init__(self): + """Create a new FunctionRunner.""" + self.log = logging.get_logger() + + async def RunFunction( + self, req: fnv1.RunFunctionRequest, _: grpc.aio.ServicerContext + ) -> fnv1.RunFunctionResponse: + """Run the function.""" + log = self.log.bind(tag=req.meta.tag) + log.info("Running function") + + rsp = response.to(req) + + # Add your composition logic here. For example, to compose desired + # resources, populate rsp.desired.resources using + # crossplane.function.resource. + + return rsp diff --git a/cmd/crossplane/function/templates/python/function/main.py b/cmd/crossplane/function/templates/python/function/main.py new file mode 100644 index 0000000..26c8806 --- /dev/null +++ b/cmd/crossplane/function/templates/python/function/main.py @@ -0,0 +1,51 @@ +"""The composition function's main CLI.""" + +import click +from crossplane.function import logging, runtime + +from function import fn + + +@click.command() +@click.option( + "--debug", + "-d", + is_flag=True, + help="Emit debug logs.", +) +@click.option( + "--address", + default="0.0.0.0:9443", + show_default=True, + help="Address at which to listen for gRPC connections", +) +@click.option( + "--tls-certs-dir", + help="Serve using mTLS certificates.", + envvar="TLS_SERVER_CERTS_DIR", +) +@click.option( + "--insecure", + is_flag=True, + help="Run without mTLS credentials. " + "If you supply this flag --tls-certs-dir will be ignored.", +) +def cli(debug: bool, address: str, tls_certs_dir: str, insecure: bool) -> None: # noqa:FBT001 + """A Crossplane composition function.""" + try: + level = logging.Level.INFO + if debug: + level = logging.Level.DEBUG + logging.configure(level=level) + runtime.serve( + fn.FunctionRunner(), + address, + creds=runtime.load_credentials(tls_certs_dir), + insecure=insecure, + ) + except Exception as e: + click.echo(f"Cannot run function: {e}") + + +if __name__ == "__main__": + cli() diff --git a/cmd/crossplane/function/templates/python/pyproject.toml.tmpl b/cmd/crossplane/function/templates/python/pyproject.toml.tmpl new file mode 100644 index 0000000..c0c4aec --- /dev/null +++ b/cmd/crossplane/function/templates/python/pyproject.toml.tmpl @@ -0,0 +1,32 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "function" +description = "A Crossplane composition function." +readme = "README.md" +requires-python = ">=3.11,<3.14" +license = "Apache-2.0" +dependencies = [ + "crossplane-function-sdk-python==0.11.0", + "click==8.3.2", + "grpcio>=1.73.1", +{{- if .HasSchemas }} + "crossplane-models @ file:./{{ .SchemasPath }}", +{{- end }} +] +dynamic = ["version"] + +[project.scripts] +function = "function.main:cli" + +[tool.hatch.build.targets.wheel] +packages = ["function"] + +[tool.hatch.version] +path = "function/__version__.py" +validate-bump = false + +[tool.hatch.metadata] +allow-direct-references = true diff --git a/cmd/crossplane/main.go b/cmd/crossplane/main.go index d3ee38c..fd5808d 100644 --- a/cmd/crossplane/main.go +++ b/cmd/crossplane/main.go @@ -24,6 +24,7 @@ import ( "strings" "github.com/alecthomas/kong" + "github.com/charmbracelet/x/term" "github.com/spf13/afero" "github.com/willabides/kongplete" "sigs.k8s.io/controller-runtime/pkg/log/zap" @@ -34,13 +35,18 @@ import ( "github.com/crossplane/cli/v2/cmd/crossplane/completion" "github.com/crossplane/cli/v2/cmd/crossplane/composition" configcmd "github.com/crossplane/cli/v2/cmd/crossplane/config" + "github.com/crossplane/cli/v2/cmd/crossplane/dependency" + "github.com/crossplane/cli/v2/cmd/crossplane/function" "github.com/crossplane/cli/v2/cmd/crossplane/operation" + "github.com/crossplane/cli/v2/cmd/crossplane/project" renderxr "github.com/crossplane/cli/v2/cmd/crossplane/render/xr" "github.com/crossplane/cli/v2/cmd/crossplane/resource" "github.com/crossplane/cli/v2/cmd/crossplane/version" "github.com/crossplane/cli/v2/cmd/crossplane/xpkg" + "github.com/crossplane/cli/v2/cmd/crossplane/xrd" "github.com/crossplane/cli/v2/internal/config" "github.com/crossplane/cli/v2/internal/maturity" + "github.com/crossplane/cli/v2/internal/terminal" ) var _ = kong.Must(&cli{}) @@ -65,10 +71,14 @@ type cli struct { Cluster cluster.Cmd `cmd:"" help:"Inspect a Crossplane cluster." maturity:"beta"` Composition composition.Cmd `cmd:"" help:"Work with Crossplane Compositions."` Config configcmd.Cmd `cmd:"" help:"View and modify the crossplane CLI config file."` + Dependency dependency.Cmd `cmd:"" help:"Manage dependencies of control plane Projects." maturity:"beta"` + Function function.Cmd `cmd:"" help:"Work with functions in control plane Projects." maturity:"beta"` Operation operation.Cmd `cmd:"" help:"Work with Crossplane Operations." maturity:"alpha"` + Project project.Cmd `cmd:"" help:"Work with control plane Projects." maturity:"beta"` Resource resource.Cmd `cmd:"" help:"Work with Crossplane resources." maturity:"beta"` Version version.Cmd `cmd:"" help:"Print the client and server version information for the current context."` XPKG xpkg.Cmd `cmd:"" help:"Work with Crossplane packages."` + XRD xrd.Cmd `cmd:"" help:"Work with Crossplane Composite Resource Defintions (XRDs)." maturity:"beta"` // Hidden top-level alias for render, since it's GA but has moved. Render renderxr.Cmd `cmd:"" help:"Render Crossplane compositions locally using functions." hidden:""` @@ -129,6 +139,11 @@ func main() { ctx, err := parser.Parse(os.Args[1:]) parser.FatalIfErrorf(err) + // Set up a spinner printer for commands to use. This helps ensure output + // consistency across commands. + sp := terminal.NewSpinnerPrinter(os.Stderr, term.IsTerminal(os.Stderr.Fd())) + ctx.BindTo(sp, (*terminal.SpinnerPrinter)(nil)) + err = ctx.Run() ctx.FatalIfErrorf(err) } diff --git a/cmd/crossplane/project/build.go b/cmd/crossplane/project/build.go new file mode 100644 index 0000000..8044e0f --- /dev/null +++ b/cmd/crossplane/project/build.go @@ -0,0 +1,163 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/project" + "github.com/crossplane/cli/v2/internal/project/functions" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/schemas/generator" + "github.com/crossplane/cli/v2/internal/schemas/manager" + "github.com/crossplane/cli/v2/internal/schemas/runner" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// buildCmd builds a project into Crossplane packages. +type buildCmd struct { + ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition." short:"f"` + Repository string `help:"Override the repository in the project file." optional:""` + OutputDir string `default:"_output" help:"Output directory for packages." short:"o"` + MaxConcurrency uint `default:"8" help:"Max concurrent function builds."` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + + proj *devv1alpha1.Project + projFS afero.Fs +} + +// AfterApply parses flags and reads the project file. +func (c *buildCmd) AfterApply() error { + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projDirPath := filepath.Dir(projFilePath) + c.projFS = afero.NewBasePathFs(afero.NewOsFs(), projDirPath) + + projFileName := filepath.Base(c.ProjectFile) + prj, err := projectfile.Parse(c.projFS, projFileName) + if err != nil { + return errors.New("this is not a project directory") + } + c.proj = prj + + return nil +} + +// Run executes the build command. +func (c *buildCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error { + ctx := context.Background() + + if c.Repository != "" { + ref, err := name.NewRepository(c.Repository) + if err != nil { + return errors.Wrap(err, "failed to parse repository") + } + c.proj.Spec.Repository = ref.String() + } + + concurrency := max(1, c.MaxConcurrency) + + schemasFS := afero.NewBasePathFs(c.projFS, c.proj.Spec.Paths.Schemas) + generators := generator.AllLanguages() + schemaRunner := runner.NewRealSchemaRunner(runner.WithImageConfig(c.proj.Spec.ImageConfigs)) + schemaMgr := manager.New(schemasFS, generators, schemaRunner) + cacheDir := c.CacheDir + if cacheDir == "" { + cacheDir = dependency.DefaultCacheDir() + } + + client, err := clixpkg.NewClient( + clixpkg.NewRemoteFetcher(), + clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir), + ) + if err != nil { + return err + } + resolver := clixpkg.NewResolver(client) + + depMgr := dependency.NewManager(c.proj, c.projFS, + dependency.WithProjectFile(c.ProjectFile), + dependency.WithSchemaFS(schemasFS), + dependency.WithSchemaGenerators(generators), + dependency.WithSchemaRunner(schemaRunner), + dependency.WithXpkgClient(client), + dependency.WithResolver(resolver), + ) + + b := project.NewBuilder( + project.BuildWithMaxConcurrency(concurrency), + project.BuildWithFunctionIdentifier(functions.DefaultIdentifier), + project.BuildWithSchemaManager(schemaMgr), + project.BuildWithDependencyManager(depMgr), + ) + + var imgMap project.ImageTagMap + err = sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { + var buildErr error + imgMap, buildErr = b.Build(ctx, c.proj, c.projFS, + project.BuildWithLogger(logger), + project.BuildWithEventChannel(ch), + ) + return buildErr + }) + if err != nil { + return err + } + + outFile := filepath.Join(c.OutputDir, fmt.Sprintf("%s.xpkg", c.proj.Name)) + if err := sp.WrapWithSuccessSpinner("Writing packages to disk", func() error { + outputFS := afero.NewOsFs() + err = outputFS.MkdirAll(c.OutputDir, 0o755) + if err != nil { + return errors.Wrapf(err, "failed to create output directory %q", c.OutputDir) + } + + f, err := outputFS.Create(outFile) + if err != nil { + return errors.Wrapf(err, "failed to create output file %q", outFile) + } + defer f.Close() //nolint:errcheck // Can't do anything useful with this error. + + err = tarball.MultiWrite(imgMap, f) + if err != nil { + return errors.Wrap(err, "failed to write package to file") + } + return nil + }); err != nil { + return err + } + + logger.Debug("Build complete", "output", outFile) + fmt.Printf("Built project %q to %s\n", c.proj.Name, outFile) //nolint:forbidigo // CLI output. + + return nil +} diff --git a/cmd/crossplane/project/init.go b/cmd/crossplane/project/init.go new file mode 100644 index 0000000..aca74a8 --- /dev/null +++ b/cmd/crossplane/project/init.go @@ -0,0 +1,116 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "k8s.io/apimachinery/pkg/util/validation" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/internal/terminal" +) + +const projectFileName = "crossplane-project.yaml" + +// initCmd initializes a new project. +type initCmd struct { + Name string `arg:"" help:"The name of the new project."` + Directory string `help:"Directory to initialize. Defaults to project name." short:"d" type:"path"` +} + +func (c *initCmd) Run(sp terminal.SpinnerPrinter) error { + // Validate the project name is a valid DNS-1035 label. + if errs := validation.IsDNS1035Label(c.Name); len(errs) > 0 { + return errors.Errorf("'%s' is not a valid project name. DNS-1035 constraints: %s", c.Name, strings.Join(errs, "; ")) + } + + if c.Directory == "" { + c.Directory = c.Name + } + + // Check if the target directory is suitable. + if err := c.checkTargetDirectory(); err != nil { + return err + } + + return sp.WrapWithSuccessSpinner("Initializing project", func() error { + if err := os.MkdirAll(c.Directory, 0o750); err != nil { + return errors.Wrapf(err, "failed to create directory %s", c.Directory) + } + + // Write a minimal crossplane-project.yaml. + projFile := filepath.Join(c.Directory, projectFileName) + content := fmt.Sprintf(`apiVersion: dev.crossplane.io/v1alpha1 +kind: Project +metadata: + name: %s +spec: + repository: example.com/my-org/%s +`, c.Name, c.Name) + + if err := os.WriteFile(projFile, []byte(content), 0o600); err != nil { + return errors.Wrapf(err, "failed to write %s", projectFileName) + } + + // Create default subdirectories. + dirs := []string{"apis", "functions", "examples", "tests", "operations"} + for _, dir := range dirs { + dirPath := filepath.Join(c.Directory, dir) + if err := os.MkdirAll(dirPath, 0o700); err != nil { + return errors.Wrapf(err, "failed to create directory %s", dirPath) + } + // Write a .gitkeep so empty dirs are tracked. + keepFile := filepath.Join(dirPath, ".gitkeep") + if err := os.WriteFile(keepFile, nil, 0o600); err != nil { + return errors.Wrapf(err, "failed to write %s", keepFile) + } + } + + return nil + }) +} + +func (c *initCmd) checkTargetDirectory() error { + f, err := os.Stat(c.Directory) + switch { + case os.IsNotExist(err): + return nil // Will be created + case err != nil: + return errors.Wrapf(err, "failed to stat directory %s", c.Directory) + case !f.IsDir(): + return errors.Errorf("path %s is not a directory", c.Directory) + } + + entries, err := os.ReadDir(c.Directory) + if err != nil { + return errors.Wrapf(err, "failed to read directory %s", c.Directory) + } + + for _, entry := range entries { + if entry.Name() == ".git" && entry.IsDir() { + continue + } + return errors.Errorf("directory %s is not empty", c.Directory) + } + + return nil +} diff --git a/cmd/crossplane/project/project.go b/cmd/crossplane/project/project.go new file mode 100644 index 0000000..060eef0 --- /dev/null +++ b/cmd/crossplane/project/project.go @@ -0,0 +1,26 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package project contains commands for working with Crossplane projects. +package project + +// Cmd contains project subcommands. +type Cmd struct { + Init initCmd `cmd:"" help:"Initialize a new project."` + Build buildCmd `cmd:"" help:"Build a project into Crossplane packages."` + Run runCmd `cmd:"" help:"Build and run a project in a local dev control plane."` + Stop stopCmd `cmd:"" help:"Tear down a local dev control plane."` +} diff --git a/cmd/crossplane/project/run.go b/cmd/crossplane/project/run.go new file mode 100644 index 0000000..cb6382a --- /dev/null +++ b/cmd/crossplane/project/run.go @@ -0,0 +1,313 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "context" + "fmt" + "maps" + "path/filepath" + "time" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "sigs.k8s.io/controller-runtime/pkg/scheme" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + xpkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + xpkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/cmd/crossplane/render" + "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/project" + "github.com/crossplane/cli/v2/internal/project/controlplane" + "github.com/crossplane/cli/v2/internal/project/functions" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/schemas/generator" + "github.com/crossplane/cli/v2/internal/schemas/manager" + "github.com/crossplane/cli/v2/internal/schemas/runner" + "github.com/crossplane/cli/v2/internal/terminal" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// runCmd builds a project and runs it in a local dev control plane. +type runCmd struct { + ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition." short:"f"` + Repository string `help:"Override the repository." optional:""` + MaxConcurrency uint `default:"8" help:"Max concurrent builds."` + CacheDir string `env:"CROSSPLANE_XPKG_CACHE" help:"Directory for cached xpkg package contents." name:"cache-dir"` + + ControlPlaneName string `help:"Name of the dev control plane. Defaults to project name."` + CrossplaneVersion string `help:"Version of Crossplane to install."` + RegistryDir string `help:"Directory for local registry images."` + ClusterAdmin bool `default:"true" help:"Allow Crossplane cluster admin." negatable:""` + Timeout time.Duration `default:"5m" help:"Max wait for project readiness."` + InitResources []string `help:"Resources to apply before installing." type:"path"` + ExtraResources []string `help:"Resources to apply after installing." type:"path"` + + proj *devv1alpha1.Project + projFS afero.Fs + + initResources []runtime.RawExtension + extraResources []runtime.RawExtension +} + +// AfterApply parses flags and reads the project file. +func (c *runCmd) AfterApply() error { + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projDirPath := filepath.Dir(projFilePath) + c.projFS = afero.NewBasePathFs(afero.NewOsFs(), projDirPath) + + projFileName := filepath.Base(c.ProjectFile) + prj, err := projectfile.Parse(c.projFS, projFileName) + if err != nil { + return errors.New("this is not a project directory") + } + c.proj = prj + + for _, m := range c.InitResources { + yamls, err := render.LoadYAMLStream(afero.NewOsFs(), m) + if err != nil { + return errors.Wrapf(err, "failed to read init resources from %s", m) + } + for _, bs := range yamls { + var e runtime.RawExtension + if err := yaml.Unmarshal(bs, &e); err != nil { + return errors.Wrapf(err, "failed to unmarshal init resource from %s", m) + } + c.initResources = append(c.initResources, e) + } + } + for _, m := range c.ExtraResources { + yamls, err := render.LoadYAMLStream(afero.NewOsFs(), m) + if err != nil { + return errors.Wrapf(err, "failed to read extra resources from %s", m) + } + for _, bs := range yamls { + var e runtime.RawExtension + if err := yaml.Unmarshal(bs, &e); err != nil { + return errors.Wrapf(err, "failed to unmarshal extra resource from %s", m) + } + c.extraResources = append(c.extraResources, e) + } + } + + return nil +} + +// Run executes the run command. +func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error { //nolint:gocyclo // Main command orchestration. + ctx := context.Background() + + if c.Repository != "" { + ref, err := name.NewRepository(c.Repository) + if err != nil { + return errors.Wrap(err, "failed to parse repository") + } + c.proj.Spec.Repository = ref.String() + } + + if c.ControlPlaneName == "" { + c.ControlPlaneName = "crossplane-" + c.proj.Name + } + + concurrency := max(1, c.MaxConcurrency) + + schemasFS := afero.NewBasePathFs(c.projFS, c.proj.Spec.Paths.Schemas) + generators := generator.AllLanguages() + schemaRunner := runner.NewRealSchemaRunner(runner.WithImageConfig(c.proj.Spec.ImageConfigs)) + schemaMgr := manager.New(schemasFS, generators, schemaRunner) + cacheDir := c.CacheDir + if cacheDir == "" { + cacheDir = dependency.DefaultCacheDir() + } + + client, err := clixpkg.NewClient( + clixpkg.NewRemoteFetcher(), + clixpkg.WithCacheDir(afero.NewOsFs(), cacheDir), + ) + if err != nil { + return err + } + resolver := clixpkg.NewResolver(client) + + depMgr := dependency.NewManager(c.proj, c.projFS, + dependency.WithProjectFile(c.ProjectFile), + dependency.WithSchemaFS(schemasFS), + dependency.WithSchemaGenerators(generators), + dependency.WithSchemaRunner(schemaRunner), + dependency.WithXpkgClient(client), + dependency.WithResolver(resolver), + ) + + b := project.NewBuilder( + project.BuildWithMaxConcurrency(concurrency), + project.BuildWithFunctionIdentifier(functions.DefaultIdentifier), + project.BuildWithSchemaManager(schemaMgr), + project.BuildWithDependencyManager(depMgr), + ) + + var ( + imgMap project.ImageTagMap + devCtp controlplane.DevControlPlane + ) + + // Parallel build + control plane setup with async spinners. + err = sp.WrapAsyncWithSuccessSpinners(func(ch async.EventChannel) error { + eg, egCtx := errgroup.WithContext(ctx) + + eg.Go(func() error { + ch.SendEvent("Setting up control plane", async.EventStatusStarted) + var ctpErr error + devCtp, ctpErr = controlplane.EnsureLocalDevControlPlane(egCtx, + controlplane.WithName(c.ControlPlaneName), + controlplane.WithCrossplaneVersion(c.CrossplaneVersion), + controlplane.WithRegistryDir(c.RegistryDir), + controlplane.WithClusterAdmin(c.ClusterAdmin), + controlplane.WithLogger(logger), + ) + if ctpErr != nil { + ch.SendEvent("Setting up control plane", async.EventStatusFailure) + return ctpErr + } + + ctpSchemeBuilders := []*scheme.Builder{ + xpkgv1.SchemeBuilder, + xpkgv1beta1.SchemeBuilder, + } + for _, bld := range ctpSchemeBuilders { + if err := bld.AddToScheme(devCtp.Client().Scheme()); err != nil { + ch.SendEvent("Setting up control plane", async.EventStatusFailure) + return err + } + } + ch.SendEvent("Setting up control plane", async.EventStatusSuccess) + return nil + }) + + eg.Go(func() error { + var buildErr error + imgMap, buildErr = b.Build(egCtx, c.proj, c.projFS, + project.BuildWithLogger(logger), + project.BuildWithEventChannel(ch), + ) + return buildErr + }) + + return eg.Wait() + }) + if err != nil { + return err + } + + // Sideload built images into the local registry. + tagStr := fmt.Sprintf("%s:v0.0.0-%d", c.proj.Spec.Repository, time.Now().Unix()) + tag, err := name.NewTag(tagStr, name.StrictValidation) + if err != nil { + return errors.Wrap(err, "failed to construct image tag") + } + + logger.Debug("Loading packages into control plane") + if err := sp.WrapWithSuccessSpinner("Loading packages into control plane", func() error { + return devCtp.Sideload(ctx, imgMap, tag) + }); err != nil { + return errors.Wrap(err, "failed to sideload packages") + } + + // Apply init resources. + if len(c.initResources) > 0 { + logger.Debug("Applying init resources") + if err := sp.WrapWithSuccessSpinner("Applying init resources", func() error { + return project.ApplyResources(ctx, devCtp.Client(), c.initResources) + }); err != nil { + return errors.Wrap(err, "failed to apply init resources") + } + } + + // Install the configuration and wait for readiness. + readyCtx := ctx + if c.Timeout != 0 { + timeoutCtx, cancel := context.WithTimeout(ctx, c.Timeout) + defer cancel() + readyCtx = timeoutCtx + } + + logger.Debug("Installing configuration package") + if err := sp.WrapWithSuccessSpinner("Installing configuration", func() error { + return project.InstallConfiguration(readyCtx, devCtp.Client(), c.proj.Name, tag, logger) + }); err != nil { + return errors.Wrap(err, "failed to install configuration") + } + + // Apply extra resources. + if len(c.extraResources) > 0 { + logger.Debug("Applying extra resources") + if err := sp.WrapWithSuccessSpinner("Applying extra resources", func() error { + return project.ApplyResources(ctx, devCtp.Client(), c.extraResources) + }); err != nil { + return errors.Wrap(err, "failed to apply extra resources") + } + } + + // Update kubeconfig. + ctpKubeconfig, err := devCtp.Kubeconfig().RawConfig() + if err != nil { + return errors.Wrap(err, "failed to get kubeconfig") + } + + if err := writeKubeconfig(ctpKubeconfig); err != nil { + return errors.Wrap(err, "failed to update kubeconfig") + } + + fmt.Println(devCtp.Info()) //nolint:forbidigo // CLI output. + fmt.Printf("Kubeconfig updated. Current context is %q.\n", ctpKubeconfig.CurrentContext) //nolint:forbidigo // CLI output. + fmt.Printf("Run `kubectl get configurations` to see the installed project configuration.\n") //nolint:forbidigo // CLI output. + fmt.Printf("Run `kind delete cluster --name %s` to clean up.\n", c.ControlPlaneName) //nolint:forbidigo // CLI output. + + return nil +} + +func writeKubeconfig(rawConfig clientcmdapi.Config) error { + // Merge the control plane's kubeconfig into the user's default kubeconfig + // and set it as the current context. + defaultPath := clientcmd.RecommendedHomeFile + + existing, err := clientcmd.LoadFromFile(defaultPath) + if err != nil { + // If the file doesn't exist, start fresh. + existing = clientcmdapi.NewConfig() + } + + maps.Copy(existing.Clusters, rawConfig.Clusters) + maps.Copy(existing.AuthInfos, rawConfig.AuthInfos) + maps.Copy(existing.Contexts, rawConfig.Contexts) + existing.CurrentContext = rawConfig.CurrentContext + + return clientcmd.WriteToFile(*existing, defaultPath) +} diff --git a/cmd/crossplane/project/stop.go b/cmd/crossplane/project/stop.go new file mode 100644 index 0000000..b5ec19e --- /dev/null +++ b/cmd/crossplane/project/stop.go @@ -0,0 +1,71 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "context" + "fmt" + "path/filepath" + + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + "github.com/crossplane/cli/v2/internal/project/controlplane" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/terminal" +) + +// stopCmd tears down a local dev control plane. +type stopCmd struct { + ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition." short:"f"` + ControlPlaneName string `help:"Name of the dev control plane. Defaults to project name."` + RegistryDir string `help:"Directory for local registry images."` +} + +// Run executes the stop command. +func (c *stopCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error { + ctx := context.Background() + + name := c.ControlPlaneName + if name == "" { + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projDirPath := filepath.Dir(projFilePath) + projFS := afero.NewBasePathFs(afero.NewOsFs(), projDirPath) + + projFileName := filepath.Base(c.ProjectFile) + prj, err := projectfile.Parse(projFS, projFileName) + if err != nil { + return errors.New("this is not a project directory; use --control-plane-name to specify the control plane name") + } + name = "crossplane-" + prj.Name + } + + logger.Debug("Tearing down local dev control plane", "name", name) + if err := sp.WrapWithSuccessSpinner("Tearing down control plane", func() error { + return controlplane.TeardownLocalDevControlPlane(ctx, name, c.RegistryDir) + }); err != nil { + return err + } + + fmt.Printf("Local dev control plane %q has been torn down.\n", name) //nolint:forbidigo // CLI output. + return nil +} diff --git a/cmd/crossplane/xrd/generate.go b/cmd/crossplane/xrd/generate.go new file mode 100644 index 0000000..da95bff --- /dev/null +++ b/cmd/crossplane/xrd/generate.go @@ -0,0 +1,447 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xrd + +import ( + "encoding/json" + "fmt" + "path/filepath" + "slices" + "strings" + + "github.com/alecthomas/kong" + "github.com/gobuffalo/flect" + "github.com/kubernetes-sigs/kro/pkg/simpleschema" + "github.com/spf13/afero" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/runtime/schema" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + v2 "github.com/crossplane/crossplane/apis/v2/apiextensions/v2" + + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/xrd" +) + +type generateCmd struct { + File string `arg:"" help:"Path to the XR or XRC YAML file."` + From string `default:"xr" enum:"xr,simpleschema" help:"Input format: xr or simpleschema."` + Path string `help:"Output path within the APIs directory." optional:""` + Plural string `help:"Custom plural form for the XRD." optional:""` + ProjectFile string `default:"crossplane-project.yaml" help:"Path to project definition." short:"f"` + + projFS afero.Fs + apisFS afero.Fs + relFile string +} + +// AfterApply sets up the project filesystem. +func (c *generateCmd) AfterApply() error { + projFilePath, err := filepath.Abs(c.ProjectFile) + if err != nil { + return err + } + projDirPath := filepath.Dir(projFilePath) + c.projFS = afero.NewBasePathFs(afero.NewOsFs(), projDirPath) + + proj, err := projectfile.Parse(c.projFS, filepath.Base(c.ProjectFile)) + if err != nil { + return err + } + + c.apisFS = afero.NewBasePathFs(c.projFS, proj.Spec.Paths.APIs) + + c.relFile = c.File + if filepath.IsAbs(c.File) { + relPath, err := filepath.Rel(afero.FullBaseFsPath(c.projFS.(*afero.BasePathFs), "."), c.File) //nolint:forcetypeassert // We know the type of projFS from above. + if err != nil { + return errors.Wrap(err, "failed to make file path relative to project filesystem") + } + if strings.HasPrefix(relPath, "..") || filepath.IsAbs(relPath) { + return errors.New("file path is outside the project filesystem") + } + c.relFile = relPath + } + + return nil +} + +func (c *generateCmd) Run(k *kong.Context) error { + yamlData, err := afero.ReadFile(c.projFS, c.relFile) + if err != nil { + return errors.Wrapf(err, "failed to read file %s", c.relFile) + } + + var xrdObj *v2.CompositeResourceDefinition + switch c.From { + case "simpleschema": + xrdObj, err = newXRDFromSimpleSchema(yamlData, c.Plural) + default: + xrdObj, err = newXRDFromExample(yamlData, c.Plural) + } + if err != nil { + return errors.Wrap(err, "failed to create CompositeResourceDefinition (XRD)") + } + + pluralName := xrdObj.Spec.Names.Plural + + xrdYAML, err := marshalXRD(xrdObj) + if err != nil { + return errors.Wrap(err, "failed to marshal XRD to YAML") + } + + filePath := c.Path + if filePath == "" { + filePath = fmt.Sprintf("%s/definition.yaml", pluralName) + } + + exists, err := afero.Exists(c.apisFS, filePath) + if err != nil { + return errors.Wrap(err, "failed to check if file exists") + } + if exists { + return errors.Errorf("file %q already exists, use --path to specify a different output path or delete the existing file", filePath) + } + + if err := c.apisFS.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + return errors.Wrap(err, "failed to create directories for the specified output path") + } + + if err := afero.WriteFile(c.apisFS, filePath, xrdYAML, 0o644); err != nil { + return errors.Wrap(err, "failed to write CompositeResourceDefinition (XRD) to file") + } + + _, err = fmt.Fprintf(k.Stdout, "Created CompositeResourceDefinition (XRD) at %s\n", filePath) + return err +} + +// marshalXRD marshals an XRD to YAML, removing creationTimestamp and status. +func marshalXRD(obj any) ([]byte, error) { + unst, err := runtime.DefaultUnstructuredConverter.ToUnstructured(obj) + if err != nil { + return nil, err + } + + unstructured.RemoveNestedField(unst, "status") + unstructured.RemoveNestedField(unst, "metadata", "creationTimestamp") + + return yaml.Marshal(unst) +} + +func isCELExpression(value any) bool { + if str, ok := value.(string); ok { + return strings.HasPrefix(str, "${") && strings.HasSuffix(str, "}") + } + return false +} + +// celFieldPath tracks paths to fields containing CEL expressions. +type celFieldPath []string + +// findCELFields recursively finds all field paths that contain CEL expressions. +func findCELFields(data map[string]any, currentPath []string) []celFieldPath { + var paths []celFieldPath + + for key, value := range data { + fieldPath := make([]string, len(currentPath), len(currentPath)+1) + copy(fieldPath, currentPath) + fieldPath = append(fieldPath, key) + + if isCELExpression(value) { + paths = append(paths, celFieldPath(fieldPath)) + } else if nestedMap, ok := value.(map[string]any); ok { + paths = append(paths, findCELFields(nestedMap, fieldPath)...) + } + } + + return paths +} + +// replaceCELWithPlaceholder replaces CEL expressions with "object" placeholder for simpleschema processing. +func replaceCELWithPlaceholder(data map[string]any) map[string]any { + result := make(map[string]any) + + for key, value := range data { + if isCELExpression(value) { + result[key] = "object" + } else if nestedMap, ok := value.(map[string]any); ok { + result[key] = replaceCELWithPlaceholder(nestedMap) + } else { + result[key] = value + } + } + + return result +} + +// markCELFieldsPreserveUnknown marks fields at the given paths with x-kubernetes-preserve-unknown-fields: true. +func markCELFieldsPreserveUnknown(schema *extv1.JSONSchemaProps, paths []celFieldPath) { + if schema == nil || len(paths) == 0 { + return + } + + preserveTrue := true + + for _, path := range paths { + current := schema + for i, key := range path { + if current.Properties == nil { + break + } + + if prop, exists := current.Properties[key]; exists { + if i == len(path)-1 { + prop.XPreserveUnknownFields = &preserveTrue + prop.Type = "" + prop.Properties = nil + current.Properties[key] = prop + } else { + current = &prop + } + } + } + } +} + +// newXRDFromSimpleSchema creates a new CompositeResourceDefinition v2 from a SimpleSchema definition. +func newXRDFromSimpleSchema(yamlData []byte, customPlural string) (*v2.CompositeResourceDefinition, error) { + var simpleInput inputXR + if err := yaml.Unmarshal(yamlData, &simpleInput); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal YAML") + } + + gv, err := schema.ParseGroupVersion(simpleInput.APIVersion) + if err != nil { + return nil, errors.Wrap(err, "failed to parse API version") + } + + kind := simpleInput.Kind + plural := customPlural + if plural == "" { + plural = flect.Pluralize(kind) + } + + specSchema, err := simpleschema.ToOpenAPISpec(simpleInput.Spec, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to convert spec to OpenAPI schema") + } + + statusSchema := &extv1.JSONSchemaProps{Type: "object", Properties: map[string]extv1.JSONSchemaProps{}} + if len(simpleInput.Status) > 0 { + celPaths := findCELFields(simpleInput.Status, nil) + processedStatus := replaceCELWithPlaceholder(simpleInput.Status) + + statusSchema, err = simpleschema.ToOpenAPISpec(processedStatus, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to convert status to OpenAPI schema") + } + + markCELFieldsPreserveUnknown(statusSchema, celPaths) + } + + openAPIV3Schema := &extv1.JSONSchemaProps{ + Description: fmt.Sprintf("%s is the Schema for the %s API.", kind, kind), + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": *specSchema, + "status": *statusSchema, + }, + Required: []string{"spec"}, + } + + schemaBytes, err := json.Marshal(openAPIV3Schema) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal OpenAPI schema") + } + + return &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v2.CompositeResourceDefinitionGroupVersionKind.GroupVersion().String(), + Kind: v2.CompositeResourceDefinitionGroupVersionKind.Kind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(fmt.Sprintf("%s.%s", plural, gv.Group)), + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: gv.Group, + Scope: v2.CompositeResourceScopeNamespaced, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: flect.Capitalize(kind), + Plural: strings.ToLower(plural), + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + AdditionalPrinterColumns: simpleInput.AdditionalPrinterColumns, + Name: gv.Version, + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: runtime.RawExtension{Raw: schemaBytes}, + }, + }, + }, + }, + }, nil +} + +type inputXR struct { + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata"` + + Spec map[string]any `json:"spec"` + Status map[string]any `json:"status"` + AdditionalPrinterColumns []extv1.CustomResourceColumnDefinition `json:"additionalPrinterColumns"` +} + +// newXRDFromExample creates an XRD based on an example XR, ineferring property types +// heuristically based on the property values in the example. +func newXRDFromExample(yamlData []byte, customPlural string) (*v2.CompositeResourceDefinition, error) { + var topLevelKeys map[string]any + if err := yaml.Unmarshal(yamlData, &topLevelKeys); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal YAML to check top-level keys") + } + for key := range topLevelKeys { + allowedKeys := []string{"apiVersion", "kind", "metadata", "spec", "status", "additionalPrinterColumns"} + if !slices.Contains(allowedKeys, key) { + return nil, errors.Errorf("invalid manifest: valid top-level keys are: %v", allowedKeys) + } + } + + var input inputXR + if err := yaml.Unmarshal(yamlData, &input); err != nil { + return nil, errors.Wrap(err, "failed to unmarshal YAML") + } + + if input.APIVersion == "" { + return nil, errors.New("invalid manifest: apiVersion is required") + } + if strings.Count(input.APIVersion, "/") != 1 { + return nil, errors.New("invalid manifest: apiVersion must be in the format group/version") + } + if input.Kind == "" { + return nil, errors.New("invalid manifest: kind is required") + } + if input.Name == "" { + return nil, errors.New("invalid manifest: metadata.name is required") + } + if input.Spec == nil { + return nil, errors.New("invalid manifest: spec is required") + } + + fieldsToRemove := []string{ + "resourceRefs", + "writeConnectionSecretToRef", + "publishConnectionDetailsTo", + "environmentConfigRefs", + "compositionUpdatePolicy", + "compositionRevisionRef", + "compositionRevisionSelector", + "compositionRef", + "compositionSelector", + "claimRef", + } + for _, field := range fieldsToRemove { + delete(input.Spec, field) + } + + gv, err := schema.ParseGroupVersion(input.APIVersion) + if err != nil { + return nil, errors.Wrap(err, "failed to parse API version") + } + + kind := input.Kind + + plural := customPlural + if plural == "" { + plural = flect.Pluralize(kind) + } + + description := fmt.Sprintf("%s is the Schema for the %s API.", kind, kind) + + specProps, err := xrd.InferProperties(input.Spec) + if err != nil { + return nil, errors.Wrap(err, "failed to infer properties for spec") + } + + statusProps, err := xrd.InferProperties(input.Status) + if err != nil { + return nil, errors.Wrap(err, "failed to infer properties for status") + } + + openAPIV3Schema := &extv1.JSONSchemaProps{ + Description: description, + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Description: fmt.Sprintf("%sSpec defines the desired state of %s.", kind, kind), + Type: "object", + Properties: specProps, + }, + "status": { + Description: fmt.Sprintf("%sStatus defines the observed state of %s.", kind, kind), + Type: "object", + Properties: statusProps, + }, + }, + Required: []string{"spec"}, + } + + schemaBytes, err := json.Marshal(openAPIV3Schema) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal OpenAPI v3 schema") + } + + scope := v2.CompositeResourceScopeCluster + if input.Namespace != "" { + scope = v2.CompositeResourceScopeNamespaced + } + + return &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: v2.CompositeResourceDefinitionGroupVersionKind.GroupVersion().String(), + Kind: v2.CompositeResourceDefinitionGroupVersionKind.Kind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: strings.ToLower(fmt.Sprintf("%s.%s", plural, gv.Group)), + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: gv.Group, + Scope: scope, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: flect.Capitalize(kind), + Plural: strings.ToLower(plural), + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + Name: gv.Version, + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: runtime.RawExtension{Raw: schemaBytes}, + }, + }, + }, + }, + }, nil +} diff --git a/cmd/crossplane/xrd/generate_test.go b/cmd/crossplane/xrd/generate_test.go new file mode 100644 index 0000000..a7e4e0c --- /dev/null +++ b/cmd/crossplane/xrd/generate_test.go @@ -0,0 +1,739 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xrd + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + v2 "github.com/crossplane/crossplane/apis/v2/apiextensions/v2" +) + +func TestNewXRDv2(t *testing.T) { + type want struct { + xrd *v2.CompositeResourceDefinition + err error + } + + cases := map[string]struct { + inputYAML string + customPlural string + want want + }{ + "ClusterScopedXR": { + inputYAML: ` +apiVersion: aws.u5d.io/v1 +kind: XEKS +metadata: + name: test +spec: + parameters: + id: test + region: eu-central-1 +`, + customPlural: "xeks", + want: want{ + xrd: &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v2", + Kind: "CompositeResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "xeks.aws.u5d.io", + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: "aws.u5d.io", + Scope: v2.CompositeResourceScopeCluster, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: "XEKS", + Plural: "xeks", + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: jsonSchemaPropsToRawExtension(&extv1.JSONSchemaProps{ + Description: "XEKS is the Schema for the XEKS API.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Description: "XEKSSpec defines the desired state of XEKS.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "parameters": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "id": {Type: "string"}, + "region": {Type: "string"}, + }, + }, + }, + }, + "status": { + Description: "XEKSStatus defines the observed state of XEKS.", + Type: "object", + }, + }, + Required: []string{"spec"}, + }), + }, + }, + }, + }, + }, + }, + }, + "NamespaceScopedXRC": { + inputYAML: ` +apiVersion: aws.u5d.io/v1 +kind: EKS +metadata: + name: test + namespace: test-namespace +spec: + parameters: + id: test + region: eu-central-1 +`, + customPlural: "eks", + want: want{ + xrd: &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v2", + Kind: "CompositeResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "eks.aws.u5d.io", + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: "aws.u5d.io", + Scope: v2.CompositeResourceScopeNamespaced, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: "EKS", + Plural: "eks", + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: jsonSchemaPropsToRawExtension(&extv1.JSONSchemaProps{ + Description: "EKS is the Schema for the EKS API.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Description: "EKSSpec defines the desired state of EKS.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "parameters": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "id": {Type: "string"}, + "region": {Type: "string"}, + }, + }, + }, + }, + "status": { + Description: "EKSStatus defines the observed state of EKS.", + Type: "object", + }, + }, + Required: []string{"spec"}, + }), + }, + }, + }, + }, + }, + }, + }, + "CustomPluralPostgres": { + inputYAML: ` +apiVersion: database.u5d.io/v1 +kind: Postgres +metadata: + name: test +spec: + parameters: + version: "13" +`, + customPlural: "postgreses", + want: want{ + xrd: &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v2", + Kind: "CompositeResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "postgreses.database.u5d.io", + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: "database.u5d.io", + Scope: v2.CompositeResourceScopeCluster, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: "Postgres", + Plural: "postgreses", + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: jsonSchemaPropsToRawExtension(&extv1.JSONSchemaProps{ + Description: "Postgres is the Schema for the Postgres API.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Description: "PostgresSpec defines the desired state of Postgres.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "parameters": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "version": {Type: "string"}, + }, + }, + }, + }, + "status": { + Description: "PostgresStatus defines the observed state of Postgres.", + Type: "object", + }, + }, + Required: []string{"spec"}, + }), + }, + }, + }, + }, + }, + }, + }, + "BucketWithStatus": { + inputYAML: ` +apiVersion: storage.u5d.io/v1 +kind: Bucket +metadata: + name: test +spec: + parameters: + storage: "13" +status: + bucketName: test +`, + want: want{ + xrd: &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v2", + Kind: "CompositeResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "buckets.storage.u5d.io", + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: "storage.u5d.io", + Scope: v2.CompositeResourceScopeCluster, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: "Bucket", + Plural: "buckets", + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: jsonSchemaPropsToRawExtension(&extv1.JSONSchemaProps{ + Description: "Bucket is the Schema for the Bucket API.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Description: "BucketSpec defines the desired state of Bucket.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "parameters": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "storage": {Type: "string"}, + }, + }, + }, + }, + "status": { + Description: "BucketStatus defines the observed state of Bucket.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "bucketName": {Type: "string"}, + }, + }, + }, + Required: []string{"spec"}, + }), + }, + }, + }, + }, + }, + }, + }, + "RemoveXPStandardFieldsFromSpec": { + inputYAML: ` +apiVersion: aws.u5d.io/v1 +kind: XEKS +metadata: + name: test +spec: + parameters: + id: test + region: eu-central-1 + resourceRefs: + - name: resource1 + writeConnectionSecretToRef: + name: secret + publishConnectionDetailsTo: + name: details + environmentConfigRefs: + - name: config1 + compositionSelector: + matchLabels: + layer: functions +`, + customPlural: "xeks", + want: want{ + xrd: &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v2", + Kind: "CompositeResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "xeks.aws.u5d.io", + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: "aws.u5d.io", + Scope: v2.CompositeResourceScopeCluster, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: "XEKS", + Plural: "xeks", + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: jsonSchemaPropsToRawExtension(&extv1.JSONSchemaProps{ + Description: "XEKS is the Schema for the XEKS API.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Description: "XEKSSpec defines the desired state of XEKS.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "parameters": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "id": {Type: "string"}, + "region": {Type: "string"}, + }, + }, + }, + }, + "status": { + Description: "XEKSStatus defines the observed state of XEKS.", + Type: "object", + }, + }, + Required: []string{"spec"}, + }), + }, + }, + }, + }, + }, + }, + }, + "MissingAPIVersion": { + inputYAML: ` +kind: Postgres +metadata: + name: test +spec: + parameters: + version: "13" +`, + customPlural: "postgreses", + want: want{ + err: errors.New("invalid manifest: apiVersion is required"), + }, + }, + "MissingKind": { + inputYAML: ` +apiVersion: database.u5d.io/v1 +metadata: + name: test +spec: + parameters: + version: "13" +`, + customPlural: "postgreses", + want: want{ + err: errors.New("invalid manifest: kind is required"), + }, + }, + "MissingMetadataName": { + inputYAML: ` +apiVersion: database.u5d.io/v1 +kind: Postgres +spec: + parameters: + version: "13" +`, + customPlural: "postgreses", + want: want{ + err: errors.New("invalid manifest: metadata.name is required"), + }, + }, + "MissingSpec": { + inputYAML: ` +apiVersion: database.u5d.io/v1 +kind: Postgres +metadata: + name: test +`, + customPlural: "postgreses", + want: want{ + err: errors.New("invalid manifest: spec is required"), + }, + }, + "InvalidTopLevelKey": { + inputYAML: ` +apiVersion: database.u5d.io/v1 +kind: Postgres +metadata: + name: test +spec: + parameters: + version: "13" +invalidKey: shouldNotBeHere +`, + customPlural: "postgreses", + want: want{ + err: errors.New("invalid manifest: valid top-level keys are: [apiVersion kind metadata spec status additionalPrinterColumns]"), + }, + }, + "InvalidAPIVersionMultipleSlashes": { + inputYAML: ` +apiVersion: invalid/group/version/v1 +kind: InvalidResource +metadata: + name: test +spec: + parameters: + key: value +`, + customPlural: "invalidresources", + want: want{ + err: errors.New("invalid manifest: apiVersion must be in the format group/version"), + }, + }, + "MixedTypesInArray": { + inputYAML: ` +apiVersion: aws.u5d.io/v1 +kind: MyClaim +metadata: + name: my-claim +spec: + parameters: + - 1 + - "2" + - true +`, + customPlural: "myclaims", + want: want{ + err: errors.New("failed to infer properties for spec: error inferring property for key 'parameters': mixed types detected in array"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := newXRDFromExample([]byte(tc.inputYAML), tc.customPlural) + + gotErrMsg := "" + wantErrMsg := "" + if err != nil { + gotErrMsg = strings.TrimSpace(err.Error()) + } + if tc.want.err != nil { + wantErrMsg = strings.TrimSpace(tc.want.err.Error()) + } + + if gotErrMsg != wantErrMsg { + t.Errorf("newXRDv2() error - got: %q, want: %q", gotErrMsg, wantErrMsg) + } + + if diff := cmp.Diff(got, tc.want.xrd, cmpopts.IgnoreFields(extv1.JSONSchemaProps{}, "Required")); diff != "" { + t.Errorf("newXRDv2() -got, +want:\n%s", diff) + } + }) + } +} + +func TestNewXRDFromSimpleSchema(t *testing.T) { + type want struct { + xrd *v2.CompositeResourceDefinition + err error + } + + preserveTrue := true + + cases := map[string]struct { + inputYAML string + customPlural string + want want + }{ + "BasicSimpleSchema": { + inputYAML: ` +apiVersion: aws.u5d.io/v1 +kind: XEKS +metadata: + name: test +spec: + region: string + count: integer +`, + customPlural: "xeks", + want: want{ + xrd: &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v2", + Kind: "CompositeResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "xeks.aws.u5d.io", + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: "aws.u5d.io", + Scope: v2.CompositeResourceScopeNamespaced, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: "XEKS", + Plural: "xeks", + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: jsonSchemaPropsToRawExtension(&extv1.JSONSchemaProps{ + Description: "XEKS is the Schema for the XEKS API.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "region": {Type: "string"}, + "count": {Type: "integer"}, + }, + }, + "status": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{}, + }, + }, + Required: []string{"spec"}, + }), + }, + }, + }, + }, + }, + }, + }, + "SimpleSchemaWithCELStatus": { + inputYAML: ` +apiVersion: aws.u5d.io/v1 +kind: XEKS +metadata: + name: test +spec: + region: string +status: + clusterArn: ${resources.cluster.status.atProvider.arn} + vpcId: ${resources.vpc.status.atProvider.vpcId} +`, + customPlural: "xeks", + want: want{ + xrd: &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v2", + Kind: "CompositeResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "xeks.aws.u5d.io", + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: "aws.u5d.io", + Scope: v2.CompositeResourceScopeNamespaced, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: "XEKS", + Plural: "xeks", + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: jsonSchemaPropsToRawExtension(&extv1.JSONSchemaProps{ + Description: "XEKS is the Schema for the XEKS API.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "region": {Type: "string"}, + }, + }, + "status": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "clusterArn": {XPreserveUnknownFields: &preserveTrue}, + "vpcId": {XPreserveUnknownFields: &preserveTrue}, + }, + }, + }, + Required: []string{"spec"}, + }), + }, + }, + }, + }, + }, + }, + }, + "SimpleSchemaWithCustomPlural": { + inputYAML: ` +apiVersion: database.u5d.io/v1 +kind: Postgres +metadata: + name: test +spec: + version: string +`, + customPlural: "postgreses", + want: want{ + xrd: &v2.CompositeResourceDefinition{ + TypeMeta: metav1.TypeMeta{ + APIVersion: "apiextensions.crossplane.io/v2", + Kind: "CompositeResourceDefinition", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "postgreses.database.u5d.io", + }, + Spec: v2.CompositeResourceDefinitionSpec{ + Group: "database.u5d.io", + Scope: v2.CompositeResourceScopeNamespaced, + Names: extv1.CustomResourceDefinitionNames{ + Categories: []string{"crossplane"}, + Kind: "Postgres", + Plural: "postgreses", + }, + Versions: []v2.CompositeResourceDefinitionVersion{ + { + Name: "v1", + Referenceable: true, + Served: true, + Schema: &v2.CompositeResourceValidation{ + OpenAPIV3Schema: jsonSchemaPropsToRawExtension(&extv1.JSONSchemaProps{ + Description: "Postgres is the Schema for the Postgres API.", + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "version": {Type: "string"}, + }, + }, + "status": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{}, + }, + }, + Required: []string{"spec"}, + }), + }, + }, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := newXRDFromSimpleSchema([]byte(tc.inputYAML), tc.customPlural) + + gotErrMsg := "" + wantErrMsg := "" + if err != nil { + gotErrMsg = strings.TrimSpace(err.Error()) + } + if tc.want.err != nil { + wantErrMsg = strings.TrimSpace(tc.want.err.Error()) + } + + if gotErrMsg != wantErrMsg { + t.Errorf("newXRDFromSimpleSchema() error - got: %q, want: %q", gotErrMsg, wantErrMsg) + } + + if diff := cmp.Diff(got, tc.want.xrd, cmpopts.IgnoreFields(extv1.JSONSchemaProps{}, "Required")); diff != "" { + t.Errorf("newXRDFromSimpleSchema() -got, +want:\n%s", diff) + } + }) + } +} + +func jsonSchemaPropsToRawExtension(schema *extv1.JSONSchemaProps) runtime.RawExtension { + schemaBytes, err := json.Marshal(schema) + if err != nil { + panic(err) + } + return runtime.RawExtension{Raw: schemaBytes} +} diff --git a/cmd/crossplane/xrd/xrd.go b/cmd/crossplane/xrd/xrd.go new file mode 100644 index 0000000..85065ee --- /dev/null +++ b/cmd/crossplane/xrd/xrd.go @@ -0,0 +1,23 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package xrd contains commands for working with CompositeResourceDefinitions. +package xrd + +// Cmd contains XRD subcommands. +type Cmd struct { + Generate generateCmd `cmd:"" help:"Generate an XRD from a Composite Resource (XR) or SimpleSchema definition."` +} diff --git a/generate.go b/generate.go index 2e0fe6c..ee4e8d9 100644 --- a/generate.go +++ b/generate.go @@ -26,4 +26,7 @@ limitations under the License. // Note that the vendor dir does temporarily exist during a Nix build. //go:generate buf generate --exclude-path vendor +// Generate deepcopy methods for the dev API group. +//go:generate controller-gen object:headerFile=./hack/boilerplate.go.txt paths=./apis/dev/v1alpha1 + package generate diff --git a/go.mod b/go.mod index 797c11b..3e96b04 100644 --- a/go.mod +++ b/go.mod @@ -6,7 +6,10 @@ require ( dario.cat/mergo v1.0.2 github.com/Masterminds/semver/v3 v3.4.0 github.com/alecthomas/kong v1.14.0 + github.com/charmbracelet/bubbles v1.0.0 github.com/charmbracelet/bubbletea v1.3.10 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/term v0.2.2 github.com/containerd/errdefs v1.0.0 github.com/crossplane/crossplane-runtime/v2 v2.3.0-rc.0.0.20260416145853-f43d88270996 github.com/crossplane/crossplane/apis/v2 v2.0.0-20260415071903-2b072b20c4bd @@ -15,32 +18,44 @@ require ( github.com/docker/docker v28.5.2+incompatible github.com/docker/go-connections v0.6.0 github.com/emicklei/dot v1.10.0 + github.com/getkin/kin-openapi v0.137.0 github.com/go-git/go-billy/v5 v5.8.0 github.com/go-git/go-git/v5 v5.18.0 + github.com/gobuffalo/flect v1.0.3 github.com/google/go-cmp v0.7.0 github.com/google/go-containerregistry v0.21.2 + github.com/google/ko v0.18.1 + github.com/invopop/jsonschema v0.14.0 + github.com/kubernetes-sigs/kro v0.9.1 + github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 github.com/pkg/errors v0.9.1 github.com/posener/complete v1.2.3 github.com/spf13/afero v1.15.0 github.com/willabides/kongplete v0.4.0 + golang.org/x/mod v0.35.0 golang.org/x/sync v0.20.0 + golang.org/x/text v0.36.0 + golang.org/x/tools v0.44.0 google.golang.org/grpc v1.80.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 + helm.sh/helm/v3 v3.20.2 k8s.io/api v0.35.3 - k8s.io/apiextensions-apiserver v0.35.0 + k8s.io/apiextensions-apiserver v0.35.1 k8s.io/apimachinery v0.35.3 - k8s.io/apiserver v0.35.0 - k8s.io/cli-runtime v0.34.1 + k8s.io/apiserver v0.35.1 + k8s.io/cli-runtime v0.35.1 k8s.io/client-go v0.35.1 k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 - k8s.io/metrics v0.34.1 + k8s.io/metrics v0.35.1 k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 sigs.k8s.io/controller-runtime v0.23.1 + sigs.k8s.io/kind v0.30.0 sigs.k8s.io/yaml v1.6.0 ) require ( + al.essio.dev/pkg/shellescape v1.6.0 // indirect cel.dev/expr v0.25.1 // indirect cloud.google.com/go/compute/metadata v0.9.0 // indirect github.com/Azure/azure-sdk-for-go v68.0.0+incompatible // indirect @@ -53,6 +68,11 @@ require ( github.com/Azure/go-autorest/autorest/date v0.3.1 // indirect github.com/Azure/go-autorest/logger v0.2.2 // indirect github.com/Azure/go-autorest/tracing v0.6.1 // indirect + github.com/BurntSushi/toml v1.6.0 // indirect + github.com/MakeNowJust/heredoc v1.0.0 // indirect + github.com/Masterminds/goutils v1.1.1 // indirect + github.com/Masterminds/sprig/v3 v3.3.0 // indirect + github.com/Masterminds/squirrel v1.5.4 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/antlr4-go/antlr/v4 v4.13.1 // indirect @@ -75,42 +95,54 @@ require ( github.com/aws/smithy-go v1.24.2 // indirect github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/blang/semver v3.5.1+incompatible // indirect github.com/blang/semver/v4 v4.0.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect - github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.10.1 // indirect - github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/chai2010/gettext-go v1.0.2 // indirect + github.com/charmbracelet/colorprofile v0.4.1 // indirect + github.com/charmbracelet/x/ansi v0.11.6 // indirect + github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 // indirect + github.com/clipperhouse/displaywidth v0.9.0 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.5.0 // indirect github.com/cloudflare/circl v1.6.3 // indirect + github.com/containerd/containerd v1.7.30 // indirect github.com/containerd/errdefs/pkg v0.3.0 // indirect + github.com/containerd/log v0.1.0 // indirect + github.com/containerd/platforms v0.2.1 // indirect github.com/containerd/stargz-snapshotter/estargz v0.18.2 // indirect github.com/coreos/go-oidc/v3 v3.17.0 // indirect github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 // indirect - github.com/cyphar/filepath-securejoin v0.4.1 // indirect + github.com/cyphar/filepath-securejoin v0.6.1 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect - github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 // indirect - github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 // indirect + github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c // indirect + github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea // indirect github.com/dimchansky/utfbom v1.1.1 // indirect github.com/distribution/reference v0.6.0 // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.5 // indirect github.com/docker/go-units v0.5.0 // indirect + github.com/dprotaso/go-yit v0.0.0-20250513223454-5ece0c5aa76c // indirect github.com/dustin/go-humanize v1.0.1 // indirect github.com/emicklei/go-restful/v3 v3.13.0 // indirect github.com/emirpasic/gods v1.18.1 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/evanphx/json-patch v5.9.11+incompatible // indirect github.com/evanphx/json-patch/v5 v5.9.11 // indirect + github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect github.com/fatih/color v1.18.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fxamacker/cbor/v2 v2.9.0 // indirect github.com/go-chi/chi/v5 v5.2.5 // indirect + github.com/go-errors/errors v1.4.2 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect + github.com/go-gorp/gorp/v3 v3.1.0 // indirect github.com/go-jose/go-jose/v4 v4.1.4 // indirect github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -138,8 +170,7 @@ require ( github.com/go-openapi/swag/yamlutils v0.25.5 // indirect github.com/go-openapi/validate v0.25.2 // indirect github.com/go-viper/mapstructure/v2 v2.5.0 // indirect - github.com/gobuffalo/flect v1.0.3 // indirect - github.com/gogo/protobuf v1.3.2 // indirect + github.com/gobwas/glob v0.2.3 // indirect github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/golang/snappy v0.0.4 // indirect @@ -150,40 +181,59 @@ require ( github.com/google/go-containerregistry/pkg/authn/k8schain v0.0.0-20230919002926-dbcd01c402b2 // indirect github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-20250225234217-098045d5e61f // indirect github.com/google/uuid v1.6.0 // indirect + github.com/gosuri/uitable v0.0.4 // indirect + github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/huandu/xstrings v1.5.0 // indirect github.com/in-toto/attestation v1.1.2 // indirect github.com/in-toto/in-toto-golang v0.10.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect - github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 // indirect + github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 // indirect + github.com/jmoiron/sqlx v1.4.0 // indirect + github.com/josharian/intern v1.0.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect github.com/klauspost/compress v1.18.5 // indirect + github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 // indirect + github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 // indirect github.com/letsencrypt/boulder v0.20260223.0 // indirect + github.com/lib/pq v1.10.9 // indirect github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect - github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/lucasb-eyer/go-colorful v1.3.0 // indirect + github.com/mailru/easyjson v0.9.1 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-localereader v0.0.1 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/mattn/go-runewidth v0.0.19 // indirect + github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect + github.com/mitchellh/go-wordwrap v1.0.1 // indirect + github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect - github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/term v0.5.2 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/termenv v0.16.0 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/nozzle/throttler v0.0.0-20180817012639-2ea982251481 // indirect + github.com/oasdiff/yaml v0.0.9 // indirect + github.com/oasdiff/yaml3 v0.0.12 // indirect github.com/oklog/ulid/v2 v2.1.1 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect + github.com/pelletier/go-toml v1.9.5 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/peterbourgon/diskv v2.0.1+incompatible // indirect github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect @@ -193,10 +243,15 @@ require ( github.com/prometheus/procfs v0.19.2 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab // indirect + github.com/rubenv/sql-migrate v1.8.1 // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 // indirect github.com/sassoftware/relic v7.2.1+incompatible // indirect github.com/secure-systems-lab/go-securesystemslib v0.10.0 // indirect github.com/sergi/go-diff v1.4.0 // indirect github.com/shibumi/go-pathspec v1.3.0 // indirect + github.com/shopspring/decimal v1.4.0 // indirect + github.com/sigstore/cosign/v2 v2.6.1 // indirect github.com/sigstore/cosign/v3 v3.0.5 // indirect github.com/sigstore/protobuf-specs v0.5.0 // indirect github.com/sigstore/rekor v1.5.1 // indirect @@ -206,40 +261,46 @@ require ( github.com/sigstore/timestamp-authority/v2 v2.0.6 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/skeema/knownhosts v1.3.1 // indirect + github.com/speakeasy-api/jsonpath v0.6.3 // indirect + github.com/speakeasy-api/openapi v1.19.2 // indirect + github.com/spf13/cast v1.10.0 // indirect github.com/spf13/cobra v1.10.2 // indirect github.com/spf13/pflag v1.0.10 // indirect github.com/syndtr/goleveldb v1.0.1-0.20220721030215-126854af5e6d // indirect github.com/theupdateframework/go-tuf v0.7.0 // indirect github.com/theupdateframework/go-tuf/v2 v2.4.1 // indirect github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 // indirect - github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c // indirect + github.com/transparency-dev/formats v0.0.0-20251208091212-1378f9e1b1b7 // indirect github.com/transparency-dev/merkle v0.0.2 // indirect github.com/vbatts/tar-split v0.12.2 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + github.com/woodsbury/decimal128 v1.4.0 // indirect github.com/x448/float16 v0.8.4 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect + github.com/xlab/treeprint v1.2.0 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/otel v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 // indirect go.opentelemetry.io/otel/metric v1.43.0 // indirect go.opentelemetry.io/otel/sdk v1.43.0 // indirect - go.opentelemetry.io/otel/sdk/metric v1.43.0 // indirect go.opentelemetry.io/otel/trace v1.43.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.50.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect - golang.org/x/mod v0.35.0 // indirect golang.org/x/net v0.53.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect golang.org/x/sys v0.43.0 // indirect golang.org/x/term v0.42.0 // indirect - golang.org/x/text v0.36.0 // indirect golang.org/x/time v0.15.0 // indirect - golang.org/x/tools v0.44.0 // indirect gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect @@ -247,12 +308,17 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - k8s.io/code-generator v0.35.0 // indirect - k8s.io/component-base v0.35.0 // indirect + k8s.io/code-generator v0.35.1 // indirect + k8s.io/component-base v0.35.1 // indirect k8s.io/gengo/v2 v2.0.0-20251215205346-5ee0d033ba5b // indirect k8s.io/klog/v2 v2.130.1 // indirect + k8s.io/kubectl v0.35.1 // indirect + oras.land/oras-go/v2 v2.6.0 // indirect + sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 // indirect sigs.k8s.io/controller-tools v0.20.0 // indirect sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 // indirect + sigs.k8s.io/kustomize/api v0.20.1 // indirect + sigs.k8s.io/kustomize/kyaml v0.20.1 // indirect sigs.k8s.io/randfill v1.0.0 // indirect sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 // indirect ) diff --git a/go.sum b/go.sum index b603ad5..edf75c3 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA= +al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890= cel.dev/expr v0.25.1 h1:1KrZg61W6TWSxuNZ37Xy49ps13NUovb66QLprthtwi4= cel.dev/expr v0.25.1/go.mod h1:hrXvqGP6G6gyx8UAHSHJ5RGk//1Oj5nXQ2NI02Nrsg4= cloud.google.com/go v0.123.0 h1:2NAUJwPR47q+E35uaJeYoNhuNEM9kM8SjgRgdeOJUSE= @@ -16,6 +18,7 @@ cloud.google.com/go/longrunning v0.8.0 h1:LiKK77J3bx5gDLi4SMViHixjD2ohlkwBi+mKA7 cloud.google.com/go/longrunning v0.8.0/go.mod h1:UmErU2Onzi+fKDg2gR7dusz11Pe26aknR4kHmJJqIfk= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk= @@ -64,11 +67,25 @@ github.com/Azure/go-autorest/tracing v0.6.1 h1:YUMSrC/CeD1ZnnXcNYU4a/fzsO35u2Fsf github.com/Azure/go-autorest/tracing v0.6.1/go.mod h1:/3EgjbsjraOqiicERAeu3m7/z0x1TzjQGAwDrJrXGkc= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0 h1:XRzhVemXdgvJqCH0sFfrBUTnUJSBrBf7++ypk+twtRs= github.com/AzureAD/microsoft-authentication-library-for-go v1.6.0/go.mod h1:HKpQxkWaGLJ+D/5H8QRpyQXA1eKjxkFlOMwck5+33Jk= +github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= +github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= +github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU= +github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= +github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE= +github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= +github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= +github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= +github.com/Masterminds/squirrel v1.5.4 h1:uUcX/aBc8O7Fg9kaISIUsHXdKuqehiXAMQTYX8afzqM= +github.com/Masterminds/squirrel v1.5.4/go.mod h1:NNaOrjSoIDfDA40n7sr2tPNZRfjzjA400rg+riTZj10= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= +github.com/NYTimes/gziphandler v1.1.1 h1:ZUDjpQae29j0ryrS0u/B8HZfJBtBQHjqw2rQ2cqUQ3I= +github.com/NYTimes/gziphandler v1.1.1/go.mod h1:n/CVRwUEOgIxrgPvAQhUUr9oeUtvrhMomdKFjzJNB0c= github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= @@ -127,45 +144,65 @@ github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0 h1:JFWXO6QPihC github.com/awslabs/amazon-ecr-credential-helper/ecr-login v0.12.0/go.mod h1:046/oLyFlYdAghYQE2yHXi/E//VM5Cf3/dFmA+3CZ0c= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/blang/semver v3.5.1+incompatible h1:cQNTCjp13qL8KC3Nbxr/y2Bqb63oX6wdnnjpJbkM4JQ= github.com/blang/semver v3.5.1+incompatible/go.mod h1:kRBLl5iJ+tD4TcOOxsy/0fnwebNt5EWlYSAyrTnjyyk= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0 h1:e+C0SB5R1pu//O4MQ3f9cFuPGoOVeF2fE4Og9otCc70= +github.com/bshuster-repo/logrus-logstash-hook v1.0.0/go.mod h1:zsTqEiSzDgAa/8GZR7E1qaXrhYNDKBYy5/dWPTIflbk= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/chai2010/gettext-go v1.0.2 h1:1Lwwip6Q2QGsAdl/ZKPCwTe9fe0CjlUbqj5bFNSjIRk= +github.com/chai2010/gettext-go v1.0.2/go.mod h1:y+wnP2cHYaVj19NZhYKAwEMH2CI1gNHeQQ+5AjwawxA= +github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc= +github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E= github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw= github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= -github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= +github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk= +github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ= -github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= -github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= +github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= +github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= +github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589 h1:krfRl01rzPzxSxyLyrChD+U+MzsBXbm0OwYYB67uF+4= github.com/chrismellard/docker-credential-acr-env v0.0.0-20230304212654-82a0ddb27589/go.mod h1:OuDyvmLnMCwa2ep4Jkm6nyA0ocJuZlGyk2gGseVzERM= github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA= +github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U= +github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8= github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb h1:EDmT6Q9Zs+SbUoc7Ik9EfrFqcylYqgPZ9ANSbTAntnE= github.com/codahale/rfc6979 v0.0.0-20141003034818-6a90f24967eb/go.mod h1:ZjrT6AXHbDs86ZSdt/osfBi5qfexBrKUdONk989Wnk4= +github.com/containerd/containerd v1.7.30 h1:/2vezDpLDVGGmkUXmlNPLCCNKHJ5BbC5tJB5JNzQhqE= +github.com/containerd/containerd v1.7.30/go.mod h1:fek494vwJClULlTpExsmOyKCMUAbuVjlFsJQc4/j44M= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/containerd/stargz-snapshotter/estargz v0.18.2 h1:yXkZFYIzz3eoLwlTUZKz2iQ4MrckBxJjkmD16ynUTrw= github.com/containerd/stargz-snapshotter/estargz v0.18.2/go.mod h1:XyVU5tcJ3PRpkA9XS2T5us6Eg35yM0214Y+wvrZTBrY= github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc= @@ -185,23 +222,29 @@ github.com/crossplane/function-sdk-go v0.6.1-0.20260422203639-1c756d23b966 h1:+l github.com/crossplane/function-sdk-go v0.6.1-0.20260422203639-1c756d23b966/go.mod h1:yg4qMMRBQPZ75INoGEjfGQ014z4GilpgDcx4Fdf6AaA= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467 h1:uX1JmpONuD549D73r6cgnxyUu18Zb7yHAy5AYU0Pm4Q= github.com/cyberphone/json-canonicalization v0.0.0-20241213102144-19d51d7fe467/go.mod h1:uzvlm1mxhHkdfqitSA92i7Se+S9ksOn3a3qmv/kyOCw= -github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= -github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= +github.com/cyphar/filepath-securejoin v0.6.1 h1:5CeZ1jPXEiYt3+Z6zqprSAgSWiggmpVyciv8syjIpVE= +github.com/cyphar/filepath-securejoin v0.6.1/go.mod h1:A8hd4EnAeyujCJRrICiOWqjS1AX0a9kM5XL+NwKoYSc= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78= +github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc= github.com/digitorus/pkcs7 v0.0.0-20230713084857-e76b763bdc49/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= -github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352 h1:ge14PCmCvPjpMQMIAH7uKg0lrtNSOdpYsRXlwk3QbaE= -github.com/digitorus/pkcs7 v0.0.0-20230818184609-3a137a874352/go.mod h1:SKVExuS+vpu2l9IoOc0RwqE7NYnb0JlcFHFnEJkVDzc= -github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7 h1:lxmTCgmHE1GUYL7P0MlNa00M67axePTq+9nBSGddR8I= -github.com/digitorus/timestamp v0.0.0-20231217203849-220c5c2851b7/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= +github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c h1:g349iS+CtAvba7i0Ee9EP1TlTZ9w+UncBY6HSmsFZa0= +github.com/digitorus/pkcs7 v0.0.0-20250730155240-ffadbf3f398c/go.mod h1:mCGGmWkOQvEuLdIRfPIpXViBfpWto4AhwtJlAvo62SQ= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea h1:ALRwvjsSP53QmnN3Bcj0NpR8SsFLnskny/EIMebAk1c= +github.com/digitorus/timestamp v0.0.0-20250524132541-c45532741eea/go.mod h1:GvWntX9qiTlOud0WkQ6ewFm0LPy5JUR1Xo0Ngbd1w6Y= github.com/dimchansky/utfbom v1.1.1 h1:vV6w1AhK4VMnhBno/TPVCoK9U/LP0PkLCS9tbxHdi/U= github.com/dimchansky/utfbom v1.1.1/go.mod h1:SxdoEBH5qIqFocHMyGOXVAybYJdr71b1Q/j0mACtrfE= +github.com/distribution/distribution/v3 v3.0.0 h1:q4R8wemdRQDClzoNNStftB2ZAfqOiN6UX90KJc4HjyM= +github.com/distribution/distribution/v3 v3.0.0/go.mod h1:tRNuFoZsUdyRVegq8xGNeds4KLjwLCRin/tTo6i1DhU= github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= @@ -212,8 +255,15 @@ github.com/docker/docker-credential-helpers v0.9.5 h1:EFNN8DHvaiK8zVqFA2DT6BjXE0 github.com/docker/docker-credential-helpers v0.9.5/go.mod h1:v1S+hepowrQXITkEfw6o4+BMbGot02wiKpzWhGUZK6c= github.com/docker/go-connections v0.6.0 h1:LlMG9azAe1TqfR7sO+NJttz1gy6KO7VJBh+pMmjSD94= github.com/docker/go-connections v0.6.0/go.mod h1:AahvXYshr6JgfUJGdDCs2b5EZG/vmaMAntpSFH5BFKE= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c h1:+pKlWGMw7gf6bQ+oDZB4KHQFypsfjYlq/C4rfL7D3g8= +github.com/docker/go-events v0.0.0-20190806004212-e31b211e4f1c/go.mod h1:Uw6UezgYA44ePAFQYUehOuCzmy5zmg/+nl2ZfMWGkpA= +github.com/docker/go-metrics v0.0.1 h1:AgB/0SvBxihN0X8OR4SjsblXkbMvalQ8cjmtKQ2rQV8= +github.com/docker/go-metrics v0.0.1/go.mod h1:cG1hvH2utMXtqgqqYE9plW6lDxS3/5ayHzueweSI3Vw= github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20250513223454-5ece0c5aa76c h1:EMwsP/vaHQDLhAX1kNIng5mHEhg+CkS18m0AL825n6U= +github.com/dprotaso/go-yit v0.0.0-20250513223454-5ece0c5aa76c/go.mod h1:lHwJo6jMevQL9tNpW6vLyhkK13bYHBcoh9tUakMhbnE= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= @@ -230,10 +280,16 @@ github.com/evanphx/json-patch v5.9.11+incompatible h1:ixHHqfcGvxhWkniF1tWxBHA0yb github.com/evanphx/json-patch v5.9.11+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= github.com/evanphx/json-patch/v5 v5.9.11 h1:/8HVnzMq13/3x9TPvjG08wUGqBTmZBsCWzjTM0wiaDU= github.com/evanphx/json-patch/v5 v5.9.11/go.mod h1:3j+LviiESTElxA4p3EMKAB9HXj3/XEtnUf6OZxqIQTM= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f h1:Wl78ApPPB2Wvf/TIe2xdyJxTlb6obmF18d8QdkxNDu4= +github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f/go.mod h1:OSYXu++VVOHnXeitef/D8n/6y4QV8uLHSFXX4NeXMGc= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= +github.com/foxcpp/go-mockdns v1.2.0 h1:omK3OrHRD1IWJz1FuFBCFquhXslXoF17OvBS6JPzZF0= +github.com/foxcpp/go-mockdns v1.2.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= +github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= +github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= github.com/fsnotify/fsnotify v1.5.4/go.mod h1:OVB6XrOHzAwXMpEM7uPOzcehqUV2UqJxmVXmkdnm1bU= @@ -241,10 +297,14 @@ github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM= github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ= +github.com/getkin/kin-openapi v0.137.0 h1:Q3HhawNQV0GfvO2mIYMUBUSEFrDsVlzcYz4VydL9YEo= +github.com/getkin/kin-openapi v0.137.0/go.mod h1:vUYWaKyMqj7PfTybelXtLuLN9tReS12vxnzMRK+z2GY= github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= +github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA= +github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= github.com/go-git/go-billy/v5 v5.8.0 h1:I8hjc3LbBlXTtVuFNJuwYuMiHvQJDq1AT6u4DwDzZG0= @@ -253,6 +313,8 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= github.com/go-git/go-git/v5 v5.18.0 h1:O831KI+0PR51hM2kep6T8k+w0/LIAD490gvqMCvL5hM= github.com/go-git/go-git/v5 v5.18.0/go.mod h1:pW/VmeqkanRFqR6AljLcs7EA7FbZaN5MQqO7oZADXpo= +github.com/go-gorp/gorp/v3 v3.1.0 h1:ItKF/Vbuj31dmV4jxA1qblpSwkl9g1typ24xoe70IGs= +github.com/go-gorp/gorp/v3 v3.1.0/go.mod h1:dLEjIyyRNiXvNZ8PSmzpt1GsWAUK8kjVhEpjH8TixEw= github.com/go-jose/go-jose/v4 v4.1.4 h1:moDMcTHmvE6Groj34emNPLs/qtYXRVcd6S7NHbHz3kA= github.com/go-jose/go-jose/v4 v4.1.4/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08= github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 h1:xcuWappghOVI8iNWoF2OKahVejd1LSVi/v4JED44Amo= @@ -314,16 +376,21 @@ github.com/go-openapi/validate v0.25.2 h1:12NsfLAwGegqbGWr2CnvT65X/Q2USJipmJ9b7x github.com/go-openapi/validate v0.25.2/go.mod h1:Pgl1LpPPGFnZ+ys4/hTlDiRYQdI1ocKypgE+8Q8BLfY= github.com/go-rod/rod v0.116.2 h1:A5t2Ky2A+5eD/ZJQr1EfsQSe5rms5Xof/qj296e+ZqA= github.com/go-rod/rod v0.116.2/go.mod h1:H+CMO9SCNc2TJ2WfrG+pKhITz57uGNYU43qYHh438Mg= +github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 h1:p104kn46Q8WdvHunIJ9dAyjPVtrBPhSr3KT2yUst43I= github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= +github.com/go-test/deep v1.1.1 h1:0r/53hagsehfO4bzD2Pgr/+RgHqhmf+k1Bpse2cTu1U= +github.com/go-test/deep v1.1.1/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro= github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4= github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= +github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= +github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -373,11 +440,15 @@ github.com/google/go-containerregistry/pkg/authn/kubernetes v0.0.0-2025022523421 github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/ko v0.18.1 h1:F2WDFIi/eZe5thmFCuk/uH0eVr7ilWCThl+UoTHEKSk= +github.com/google/ko v0.18.1/go.mod h1:YjJWJhmZ7prVtHm/LFfwqeIAIhcyr/gxtztI8+Jrxl4= github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e h1:FJta/0WsADCe1r9vQjdHbd3KuiLPu7Y9WlyLGwMUNyE= github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA= github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/trillian v1.7.2 h1:EPBxc4YWY4Ak8tcuhyFleY+zYlbCDCa4Sn24e1Ka8Js= github.com/google/trillian v1.7.2/go.mod h1:mfQJW4qRH6/ilABtPYNBerVJAJ/upxHLX81zxNQw05s= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -386,6 +457,14 @@ github.com/googleapis/enterprise-certificate-proxy v0.3.14 h1:yh8ncqsbUY4shRD5dA github.com/googleapis/enterprise-certificate-proxy v0.3.14/go.mod h1:vqVt9yG9480NtzREnTlmGSBmFrA+bzb0yl0TxoBQXOg= github.com/googleapis/gax-go/v2 v2.19.0 h1:fYQaUOiGwll0cGj7jmHT/0nPlcrZDFPrZRhTsoCr8hE= github.com/googleapis/gax-go/v2 v2.19.0/go.mod h1:w2ROXVdfGEVFXzmlciUU4EdjHgWvB5h2n6x/8XSTTJA= +github.com/gorilla/handlers v1.5.2 h1:cLTUSsNkgcwhgRqvCNmdbRWG0A3N4F+M2nWKdScwyEE= +github.com/gorilla/handlers v1.5.2/go.mod h1:dX+xVpaxdSw+q0Qek8SSsl3dfMk3jNddUkMzo0GtH0w= +github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= +github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= +github.com/gosuri/uitable v0.0.4 h1:IG2xLKRvErL3uhY6e1BylFzG+aJiwQviDDTfOKeKTpY= +github.com/gosuri/uitable v0.0.4/go.mod h1:tKR86bXuXPZazfOTG1FIzvjIdXzd0mo4Vtn16vt0PJo= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 h1:+ngKgrYPPJrOjhax5N+uePQ0Fh1Z7PheYoUI/0nzkPA= +github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0 h1:UH//fgunKIs4JdUbpDl1VZCDaL56wXCB/5+wF6uHfaI= github.com/grpc-ecosystem/go-grpc-middleware v1.4.0/go.mod h1:g5qyo/la0ALbONm6Vbp88Yd8NsDy6rZz+RcrMPxvld8= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0 h1:Ovs26xHkKqVztRpIrF/92BcuyuQ/YW4NSIpoGtfXNho= @@ -412,6 +491,8 @@ github.com/hashicorp/go-secure-stdlib/strutil v0.1.2 h1:kes8mmyCpxJsI7FTwtzRqEy9 github.com/hashicorp/go-secure-stdlib/strutil v0.1.2/go.mod h1:Gou2R9+il93BqX25LAKCLuM+y9U2T4hlwvT1yprcna4= github.com/hashicorp/go-sockaddr v1.0.7 h1:G+pTkSO01HpR5qCxg7lxfsFEZaG+C0VssTy/9dbT+Fw= github.com/hashicorp/go-sockaddr v1.0.7/go.mod h1:FZQbEYa1pxkQ7WLpyXJ6cbjpT8q0YgQaK/JakXqGyWw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5 h1:l2zaLDubNhW4XO3LnliVj0GXO3+/CGNJAg1dcN2Fpfw= +github.com/hashicorp/golang-lru/arc/v2 v2.0.5/go.mod h1:ny6zBSQZi2JxIeYcv7kt2sH2PXJtirBN7RDhRpxPkxU= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/hashicorp/hcl v1.0.1-vault-7 h1:ag5OxFVy3QYTFTJODRzTKVZ6xvdfLLCA1cy/Y6xGI0I= @@ -423,6 +504,8 @@ github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSo github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef h1:A9HsByNhogrvm9cWb28sjiS3i7tcKCkflWFEkHfuAgM= github.com/howeyc/gopass v0.0.0-20210920133722-c8aef6fb66ef/go.mod h1:lADxMC39cJJqL93Duh1xhAs4I2Zs8mKS89XWXFGp9cs= github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= +github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= github.com/in-toto/attestation v1.1.2 h1:MBFn6lsMq6dptQZJBhalXTcWMb/aJy3V+GX3VYj/V1E= github.com/in-toto/attestation v1.1.2/go.mod h1:gYFddHMZj3DiQ0b62ltNi1Vj5rC879bTmBbrv9CRHpM= @@ -430,6 +513,8 @@ github.com/in-toto/in-toto-golang v0.10.0 h1:+s2eZQSK3WmWfYV85qXVSBfqgawi/5L02Ma github.com/in-toto/in-toto-golang v0.10.0/go.mod h1:wjT4RiyFlLWCmLUJjwB8oZcjaq7HA390aMJcD3xXgmg= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -440,20 +525,22 @@ github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= -github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267 h1:TMtDYDHKYY15rFihtRfck/bfFqNfvcabqvXAFQfAUpY= -github.com/jedisct1/go-minisign v0.0.0-20230811132847-661be99b8267/go.mod h1:h1nSAbGFqGVzn6Jyl1R/iCcBUHN4g+gW1u9CoBTrb9E= +github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7 h1:FWpSWRD8FbVkKQu8M1DM9jF5oXFLyE+XpisIYfdzbic= +github.com/jedisct1/go-minisign v0.0.0-20241212093149-d2f9f49435c7/go.mod h1:BMxO138bOokdgt4UaxZiEfypcSHX0t6SIFimVP1oRfk= github.com/jellydator/ttlcache/v3 v3.4.0 h1:YS4P125qQS0tNhtL6aeYkheEaB/m8HCqdMMP4mnWdTY= github.com/jellydator/ttlcache/v3 v3.4.0/go.mod h1:Hw9EgjymziQD3yGsQdf1FqFdpp7YjFMd4Srg5EJlgD4= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24 h1:liMMTbpW34dhU4az1GN0pTPADwNmvoRSeoZ6PItiqnY= github.com/jmespath/go-jmespath v0.4.1-0.20220621161143-b0104c826a24/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= github.com/jmhodges/clock v1.2.0 h1:eq4kys+NI0PLngzaHEe7AmPT90XMGIEySD1JfV1PDIs= github.com/jmhodges/clock v1.2.0/go.mod h1:qKjhA7x7u/lQpPB1XAqX1b1lCI/w3/fNuYpI/ZjLynI= +github.com/jmoiron/sqlx v1.4.0 h1:1PLqN7S1UYp5t4SrVVnt4nUVNemrDAtxlulVe+Qgm3o= +github.com/jmoiron/sqlx v1.4.0/go.mod h1:ZrZ7UsYB/weZdl2Bxg6jCRO9c3YHl8r3ahlKmRT4JLY= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= -github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= -github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -463,26 +550,47 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kubernetes-sigs/kro v0.9.1 h1:xf/jNNgK6ChVk6LXaHhSHycEJnXiwNVUKkScB3tHuio= +github.com/kubernetes-sigs/kro v0.9.1/go.mod h1:s5vJ+L1MLgF5I2xKexuQIa+WubQ1S4mhhbL3xMILKss= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0 h1:SOEGU9fKiNWd/HOJuq6+3iTQz8KNCLtVX6idSoTLdUw= +github.com/lann/builder v0.0.0-20180802200727-47ae307949d0/go.mod h1:dXGbAdH5GtBTC4WfIxhKZfyBF/HBFgRZSWwZ9g/He9o= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0 h1:P6pPBnrTSX3DEVR4fDembhRWSsG5rVo6hYhAB/ADZrk= +github.com/lann/ps v0.0.0-20150810152359-62de8c46ede0/go.mod h1:vmVJ0l/dxyfGW6FmdpVm2joNMFikkuWg0EoCKLGUMNw= github.com/letsencrypt/boulder v0.20260223.0 h1:xdS2OnJNUasR6TgVIOpqqcvdkOu47+PQQMBk9ThuWBw= github.com/letsencrypt/boulder v0.20260223.0/go.mod h1:r3aTSA7UZ7dbDfiGK+HLHJz0bWNbHk6YSPiXgzl23sA= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de h1:9TO3cAIGXtEhnIaL+V+BEER86oLrvS+kWobKpbJuye0= github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de/go.mod h1:zAbeS9B/r2mtpb6U+EI2rYA5OAXxsYw6wTamcNW+zcE= -github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= -github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag= +github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= +github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= -github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw= +github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= +github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= +github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= +github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c h1:cqn374mizHuIWj+OSJCajGr/phAmuMug9qIX3l9CflE= github.com/mitchellh/mapstructure v1.5.1-0.20231216201459-8508981c8b6c/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= +github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= +github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= @@ -497,6 +605,10 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee h1:W5t00kpgFdJifH4BDsTlE89Zl93FEloxaWZfGcifgq8= github.com/modern-go/reflect2 v1.0.3-0.20250322232337-35a7c28c31ee/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= +github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ= github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= @@ -515,27 +627,45 @@ github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/nxadm/tail v1.4.11 h1:8feyoE3OzPrcshW5/MJ4sGESc5cqmGkGCWlco4l0bqY= github.com/nxadm/tail v1.4.11/go.mod h1:OTaG3NK980DZzxbRq6lEuzgU+mug70nY11sMd4JXXHc= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.0 h1:/8daqIYZfwnsHEAZdHUu9m0D5LA+5DoJCP7zLlT5Cs0= +github.com/oapi-codegen/oapi-codegen/v2 v2.7.0/go.mod h1:qzFy6iuobJw/hD1aRILee4G87/ShmhR0xYCwcUtZMCw= +github.com/oasdiff/yaml v0.0.9 h1:zQOvd2UKoozsSsAknnWoDJlSK4lC0mpmjfDsfqNwX48= +github.com/oasdiff/yaml v0.0.9/go.mod h1:8lvhgJG4xiKPj3HN5lDow4jZHPlx1i7dIwzkdAo6oAM= +github.com/oasdiff/yaml3 v0.0.12 h1:75urAtPeDg2/iDEWwzNrLOWxI9N/dCh81nTTJtokt2M= +github.com/oasdiff/yaml3 v0.0.12/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= -github.com/onsi/ginkgo/v2 v2.27.2 h1:LzwLj0b89qtIy6SSASkzlNvX6WktqurSHwkk2ipF/Ns= -github.com/onsi/ginkgo/v2 v2.27.2/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/ginkgo/v2 v2.27.5 h1:ZeVgZMx2PDMdJm/+w5fE/OyG6ILo1Y3e+QX4zSR0zTE= +github.com/onsi/ginkgo/v2 v2.27.5/go.mod h1:ArE1D/XhNXBXCBkKOLkbsb2c81dQHCRcF5zwn/ykDRo= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= -github.com/onsi/gomega v1.38.3 h1:eTX+W6dobAYfFeGC2PV6RwXRu/MyT+cQguijutvkpSM= -github.com/onsi/gomega v1.38.3/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= +github.com/onsi/gomega v1.39.0 h1:y2ROC3hKFmQZJNFeGAMeHZKkjBL65mIZcvrLQBF9k6Q= +github.com/onsi/gomega v1.39.0/go.mod h1:ZCU1pkQcXDO5Sl9/VVEGlDyp+zm0m1cmeG5TOzLgdh4= github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= +github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8= +github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= +github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5 h1:Ii+DKncOVM8Cu1Hc+ETb5K+23HdAMvESYE3ZJ5b5cMI= +github.com/phayes/freeport v0.0.0-20220201140144-74d24b5ae9f5/go.mod h1:iIss55rKnNBTvrwdmkUpLnDpZoAHvWaiq5+iMmen4AE= github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c h1:+mdjkGKdHQG3305AYmdv1U2eRNDiU2ErMBj1gwrq8eQ= @@ -547,34 +677,53 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/posener/complete v1.2.3 h1:NP0eAhjcjImqslEwo/1hq7gpajME0fTLTezBKDqfXqo= github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s= +github.com/poy/onpar v1.1.2 h1:QaNrNiZx0+Nar5dLgTVp5mXkyoVFIbepjyEoGSnhbAY= +github.com/poy/onpar v1.1.2/go.mod h1:6X8FLNoxyr9kkmnlqpK6LSoiOtrO6MICtWwEuWkLjzg= github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= -github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/redis/go-redis/extra/rediscmd/v9 v9.5.3 h1:1/BDligzCa40GTllkDnY3Y5DTHuKCONbB2JcRyIfl20= +github.com/redis/go-redis/extra/rediscmd/v9 v9.5.3/go.mod h1:3dZmcLn3Qw6FLlWASn1g4y+YO9ycEFUOM+bhBmzLVKQ= +github.com/redis/go-redis/extra/redisotel/v9 v9.5.3 h1:kuvuJL/+MZIEdvtb/kTBRiRgYaOmx1l+lYJyVdrRUOs= +github.com/redis/go-redis/extra/redisotel/v9 v9.5.3/go.mod h1:7f/FMrf5RRRVHXgfk7CzSVzXHiWeuOQUu2bsVqWoa+g= +github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs= +github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab h1:ZjX6I48eZSFetPb41dHudEyVr5v953N15TsNZXlkcWY= github.com/riywo/loginshell v0.0.0-20200815045211-7d26008be1ab/go.mod h1:/PfPXh0EntGc3QAAyUaviy4S9tzy4Zp0e2ilq4voC6E= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= +github.com/rubenv/sql-migrate v1.8.1 h1:EPNwCvjAowHI3TnZ+4fQu3a915OpnQoPAjTXCGOy2U0= +github.com/rubenv/sql-migrate v1.8.1/go.mod h1:BTIKBORjzyxZDS6dzoiw6eAFYJ1iNlGAtjn4LGeVjS8= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/ryanuber/go-glob v1.0.0 h1:iQh3xXAumdQ+4Ufa5b25cRpC5TYKlno6hsv6Cb3pkBk= github.com/ryanuber/go-glob v1.0.0/go.mod h1:807d1WSdnB0XRJzKNil9Om6lcp/3a0v4qIHxIXzX/Yc= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2 h1:KRzFb2m7YtdldCEkzs6KqmJw4nqEVZGK7IN2kJkjTuQ= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.2/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= github.com/sassoftware/relic v7.2.1+incompatible h1:Pwyh1F3I0r4clFJXkSI8bOyJINGqpgjJU3DYAZeI05A= github.com/sassoftware/relic v7.2.1+incompatible/go.mod h1:CWfAxv73/iLZ17rbyhIEq3K9hs5w6FpNMdUT//qR+zk= github.com/sassoftware/relic/v7 v7.6.2 h1:rS44Lbv9G9eXsukknS4mSjIAuuX+lMq/FnStgmZlUv4= github.com/sassoftware/relic/v7 v7.6.2/go.mod h1:kjmP0IBVkJZ6gXeAu35/KCEfca//+PKM6vTAsyDPY+k= github.com/secure-systems-lab/go-securesystemslib v0.10.0 h1:l+H5ErcW0PAehBNrBxoGv1jjNpGYdZ9RcheFkB2WI14= github.com/secure-systems-lab/go-securesystemslib v0.10.0/go.mod h1:MRKONWmRoFzPNQ9USRF9i1mc7MvAVvF1LlW8X5VWDvk= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shibumi/go-pathspec v1.3.0 h1:QUyMZhFo0Md5B8zV8x2tesohbb5kfbpTi9rBnKh5dkI= github.com/shibumi/go-pathspec v1.3.0/go.mod h1:Xutfslp817l2I1cZvgcfeMQJG5QnU2lh5tVaaMCl3jE= +github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= +github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= +github.com/sigstore/cosign/v2 v2.6.1 h1:7Wf67ENNCjg+1fLqHRPgKUNaCCnCavnEfCe1LApOoIo= +github.com/sigstore/cosign/v2 v2.6.1/go.mod h1:L37doL+7s6IeCXFODV2J7kds5Po/srlVzA//++YqAJ8= github.com/sigstore/cosign/v3 v3.0.5 h1:c1zPqjU+H4wmirgysC+AkWMg7a7fykyOYF/m+F1150I= github.com/sigstore/cosign/v3 v3.0.5/go.mod h1:ble1vMvJagCFyTIDkibCq6MIHiWDw00JNYl0f9rB4T4= github.com/sigstore/protobuf-specs v0.5.0 h1:F8YTI65xOHw70NrvPwJ5PhAzsvTnuJMGLkA4FIkofAY= @@ -602,8 +751,14 @@ github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= +github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= +github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= +github.com/speakeasy-api/openapi v1.19.2/go.mod h1:UfKa7FqE4jgexJZuj51MmdHAFGmDv0Zaw3+yOd81YKU= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/spf13/cast v1.10.0 h1:h2x0u2shc1QuLHfxi+cTJvs30+ZAHOGRic8uyGTDWxY= +github.com/spf13/cast v1.10.0/go.mod h1:jNfB8QC9IA6ZuY2ZjDp0KtFO2LZZlg4S/7bzP6qqeHo= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= @@ -618,6 +773,8 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= @@ -641,18 +798,26 @@ github.com/tink-crypto/tink-go/v2 v2.6.0 h1:+KHNBHhWH33Vn+igZWcsgdEPUxKwBMEe0QC6 github.com/tink-crypto/tink-go/v2 v2.6.0/go.mod h1:2WbBA6pfNsAfBwDCggboaHeB2X29wkU8XHtGwh2YIk8= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399 h1:e/5i7d4oYZ+C1wj2THlRK+oAhjeS/TRQwMfkIuet3w0= github.com/titanous/rocacheck v0.0.0-20171023193734-afe73141d399/go.mod h1:LdwHTNJT99C5fTAzDz0ud328OgXz+gierycbcIx2fRs= -github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c h1:5a2XDQ2LiAUV+/RjckMyq9sXudfrPSuCY4FuPC1NyAw= -github.com/transparency-dev/formats v0.0.0-20251017110053-404c0d5b696c/go.mod h1:g85IafeFJZLxlzZCDRu4JLpfS7HKzR+Hw9qRh3bVzDI= +github.com/transparency-dev/formats v0.0.0-20251208091212-1378f9e1b1b7 h1:PwfIAvobqihWBi1/KIsw0IzTEJ89rYJqmXfzmqacySw= +github.com/transparency-dev/formats v0.0.0-20251208091212-1378f9e1b1b7/go.mod h1:mQ5ASe7MNPT+yRc47hLguwsNdE2Go0mT6piyzUO+ynw= github.com/transparency-dev/merkle v0.0.2 h1:Q9nBoQcZcgPamMkGn7ghV8XiTZ/kRxn1yCG81+twTK4= github.com/transparency-dev/merkle v0.0.2/go.mod h1:pqSy+OXefQ1EDUVmAJ8MUhHB9TXGuzVAT58PqBoHz1A= +github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU= +github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/vbatts/tar-split v0.12.2 h1:w/Y6tjxpeiFMR47yzZPlPj/FcPLpXbTUi/9H7d3CPa4= github.com/vbatts/tar-split v0.12.2/go.mod h1:eF6B6i6ftWQcDqEn3/iGFRFRo8cBIMSJVOpnNdfTMFA= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= github.com/willabides/kongplete v0.4.0 h1:eivXxkp5ud5+4+NVN9e4goxC5mSh3n1RHov+gsblM2g= github.com/willabides/kongplete v0.4.0/go.mod h1:0P0jtWD9aTsqPSUAl4de35DLghrr57XcayPyvqSi2X8= +github.com/woodsbury/decimal128 v1.4.0 h1:xJATj7lLu4f2oObouMt2tgGiElE5gO6mSWUjQsBgUlc= +github.com/woodsbury/decimal128 v1.4.0/go.mod h1:BP46FUrVjVhdTbKT+XuQh2xfQaGki9LMIRJSFuh6THU= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= +github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ= +github.com/xlab/treeprint v1.2.0/go.mod h1:gj5Gd3gPdKtR1ikdDK6fnFLdmIS0X30kTTuNd/WEJu0= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/ysmood/fetchup v0.2.3 h1:ulX+SonA0Vma5zUFXtv52Kzip/xe7aj4vqT5AJwQ+ZQ= @@ -665,7 +830,6 @@ github.com/ysmood/gson v0.7.3 h1:QFkWbTH8MxyUTKPkVWAENJhxqdBa4lYTQWqZCiLG6kE= github.com/ysmood/gson v0.7.3/go.mod h1:3Kzs5zDl21g5F/BlLTNcuAGAYLKt2lV5G8D1zF3RNmg= github.com/ysmood/leakless v0.9.0 h1:qxCG5VirSBvmi3uynXFkcnLMzkphdh3xx5FtrORwDCU= github.com/ysmood/leakless v0.9.0/go.mod h1:R8iAXPRaG97QJwqxs74RdwzcRHT1SWCGTNqY8q0JvMQ= -github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= github.com/zalando/go-keyring v0.2.3 h1:v9CUu9phlABObO4LPWycf+zwMG7nlbb3t/B5wa97yms= @@ -678,22 +842,46 @@ go.etcd.io/etcd/client/v3 v3.6.8 h1:B3G76t1UykqAOrbio7s/EPatixQDkQBevN8/mwiplrY= go.etcd.io/etcd/client/v3 v3.6.8/go.mod h1:MVG4BpSIuumPi+ELF7wYtySETmoTWBHVcDoHdVupwt8= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0 h1:YH4g8lQroajqUwWbq/tr2QX1JFmEXaDLgG+ew9bLMWo= -go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.63.0/go.mod h1:fvPi2qXDqFs8M4B4fmJhE92TyQs9Ydjlg3RvfUp+NbQ= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0 h1:UW0+QyeyBVhn+COBec3nGhfnFe5lwB0ic1JBVjzhk0w= +go.opentelemetry.io/contrib/bridges/prometheus v0.57.0/go.mod h1:ppciCHRLsyCio54qbzQv0E4Jyth/fLWDTJYfvWpcSVk= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0 h1:jmTVJ86dP60C01K3slFQa2NQ/Aoi7zA+wy7vMOKD9H4= +go.opentelemetry.io/contrib/exporters/autoexport v0.57.0/go.mod h1:EJBheUMttD/lABFyLXhce47Wr6DPWYReCzaZiXadH7g= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0 h1:RN3ifU8y4prNWeEnQp2kRRHz8UwonAEYZl8tUzHEXAk= +go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.64.0/go.mod h1:habDz3tEWiFANTo6oUE99EmaFUrCNYAAg3wiVmusm70= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0 h1:WzNab7hOOLzdDF/EoWCt4glhrbMPVMOO5JYTmpz36Ls= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.8.0/go.mod h1:hKvJwTzJdp90Vh7p6q/9PAOd55dI6WA6sWj62a/JvSs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0 h1:S+LdBGiQXtJdowoJoQPEtI52syEP/JYBUpjO49EQhV8= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.8.0/go.mod h1:5KXybFvPGds3QinJWQT7pmXf+TN5YIa7CNYObWRkj50= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0 h1:t/Qur3vKSkUCcDVaSumWF2PKHt85pc7fRvFuoVT8qFU= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.32.0/go.mod h1:Rl61tySSdcOJWoEgYZVtmnKdA0GeKrSqkHC1t+91CH8= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0 h1:in9O8ESIOlwJAEGTkkf34DesGRAc/Pn8qJ7k3r/42LM= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.39.0/go.mod h1:Rp0EXBm5tfnv0WL+ARyO/PHBEaEAT8UUHQ6AGJcSq6c= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc= go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0 h1:krvC4JMfIOVdEuNPTtQ0ZjCiXrybhv+uOHMfHRmnvVo= +go.opentelemetry.io/otel/exporters/prometheus v0.62.0/go.mod h1:fgOE6FM/swEnsVQCqCnbOfRV4tOnWPg7bVeo4izBuhQ= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0 h1:CHXNXwfKWfzS65yrlB2PVds1IBZcdsX8Vepy9of0iRU= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.8.0/go.mod h1:zKU4zUgKiaRxrdovSS2amdM5gOc59slmo/zJwGX+YBg= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0 h1:SZmDnHcgp3zwlPBS2JX2urGYe/jBKEIT6ZedHRUyCz8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.32.0/go.mod h1:fdWW0HtZJ7+jNpTKUR0GpMEDP69nR8YBJQxNiVCE3jk= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0 h1:cC2yDI3IQd0Udsux7Qmq8ToKAx1XCilTQECZ0KDZyTw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.32.0/go.mod h1:2PD5Ex6z8CFzDbTdOlwyNIUywRr1DN0ospafJM1wJ+s= +go.opentelemetry.io/otel/log v0.8.0 h1:egZ8vV5atrUWUbnSsHn6vB8R21G2wrKqNiDt3iWertk= +go.opentelemetry.io/otel/log v0.8.0/go.mod h1:M9qvDdUTRCopJcGRKg57+JSQ9LgLBrwwfC32epk5NX8= go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/log v0.8.0 h1:zg7GUYXqxk1jnGF/dTdLPrK06xJdrXgqgFLnI4Crxvs= +go.opentelemetry.io/otel/sdk/log v0.8.0/go.mod h1:50iXr0UVwQrYS45KbruFrEt4LvAdCaWWgIrsN3ZQggo= go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= @@ -702,6 +890,8 @@ go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpu go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.step.sm/crypto v0.77.2 h1:qFjjei+RHc5kP5R7NW9OUWT7SqWIuAOvOkXqg4fNWj8= go.step.sm/crypto v0.77.2/go.mod h1:W0YJb9onM5l78qgkXIJ2Up6grnwW8EtpCKIza/NCg0o= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= @@ -712,6 +902,8 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= @@ -723,7 +915,6 @@ golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= -golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= @@ -732,7 +923,6 @@ golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU= golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= -golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= @@ -749,7 +939,6 @@ golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -804,9 +993,7 @@ golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c= @@ -857,42 +1044,50 @@ gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkep gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.1.0 h1:rVV8Tcg/8jHUkPUorwjaMTtemIMVXfIPKiOqnhEhakk= gotest.tools/v3 v3.1.0/go.mod h1:fHy7eyTmJFO5bQbUsEGQ1v4m2J3Jz9eWL54TP2/ZuYQ= +helm.sh/helm/v3 v3.20.2 h1:binM4rvPx5DcNsa1sIt7UZi55lRbu3pZUFmQkSoRh48= +helm.sh/helm/v3 v3.20.2/go.mod h1:Fl1kBaWCpkUrM6IYXPjQ3bdZQfFrogKArqptvueZ6Ww= k8s.io/api v0.35.3 h1:pA2fiBc6+N9PDf7SAiluKGEBuScsTzd2uYBkA5RzNWQ= k8s.io/api v0.35.3/go.mod h1:9Y9tkBcFwKNq2sxwZTQh1Njh9qHl81D0As56tu42GA4= -k8s.io/apiextensions-apiserver v0.35.0 h1:3xHk2rTOdWXXJM+RDQZJvdx0yEOgC0FgQ1PlJatA5T4= -k8s.io/apiextensions-apiserver v0.35.0/go.mod h1:E1Ahk9SADaLQ4qtzYFkwUqusXTcaV2uw3l14aqpL2LU= +k8s.io/apiextensions-apiserver v0.35.1 h1:p5vvALkknlOcAqARwjS20kJffgzHqwyQRM8vHLwgU7w= +k8s.io/apiextensions-apiserver v0.35.1/go.mod h1:2CN4fe1GZ3HMe4wBr25qXyJnJyZaquy4nNlNmb3R7AQ= k8s.io/apimachinery v0.35.3 h1:MeaUwQCV3tjKP4bcwWGgZ/cp/vpsRnQzqO6J6tJyoF8= k8s.io/apimachinery v0.35.3/go.mod h1:jQCgFZFR1F4Ik7hvr2g84RTJSZegBc8yHgFWKn//hns= -k8s.io/apiserver v0.35.0 h1:CUGo5o+7hW9GcAEF3x3usT3fX4f9r8xmgQeCBDaOgX4= -k8s.io/apiserver v0.35.0/go.mod h1:QUy1U4+PrzbJaM3XGu2tQ7U9A4udRRo5cyxkFX0GEds= -k8s.io/cli-runtime v0.34.1 h1:btlgAgTrYd4sk8vJTRG6zVtqBKt9ZMDeQZo2PIzbL7M= -k8s.io/cli-runtime v0.34.1/go.mod h1:aVA65c+f0MZiMUPbseU/M9l1Wo2byeaGwUuQEQVVveE= +k8s.io/apiserver v0.35.1 h1:potxdhhTL4i6AYAa2QCwtlhtB1eCdWQFvJV6fXgJzxs= +k8s.io/apiserver v0.35.1/go.mod h1:BiL6Dd3A2I/0lBnteXfWmCFobHM39vt5+hJQd7Lbpi4= +k8s.io/cli-runtime v0.35.1 h1:uKcXFe8J7AMAM4Gm2JDK4mp198dBEq2nyeYtO+JfGJE= +k8s.io/cli-runtime v0.35.1/go.mod h1:55/hiXIq1C8qIJ3WBrWxEwDLdHQYhBNRdZOz9f7yvTw= k8s.io/client-go v0.35.1 h1:+eSfZHwuo/I19PaSxqumjqZ9l5XiTEKbIaJ+j1wLcLM= k8s.io/client-go v0.35.1/go.mod h1:1p1KxDt3a0ruRfc/pG4qT/3oHmUj1AhSHEcxNSGg+OA= -k8s.io/code-generator v0.35.0 h1:TvrtfKYZTm9oDF2z+veFKSCcgZE3Igv0svY+ehCmjHQ= -k8s.io/code-generator v0.35.0/go.mod h1:iS1gvVf3c/T71N5DOGYO+Gt3PdJ6B9LYSvIyQ4FHzgc= -k8s.io/component-base v0.35.0 h1:+yBrOhzri2S1BVqyVSvcM3PtPyx5GUxCK2tinZz1G94= -k8s.io/component-base v0.35.0/go.mod h1:85SCX4UCa6SCFt6p3IKAPej7jSnF3L8EbfSyMZayJR0= +k8s.io/code-generator v0.35.1 h1:yLKR2la7Z9cWT5qmk67ayx8xXLM4RRKQMnC8YPvTWRI= +k8s.io/code-generator v0.35.1/go.mod h1:F2Fhm7aA69tC/VkMXLDokdovltXEF026Tb9yfQXQWKg= +k8s.io/component-base v0.35.1 h1:XgvpRf4srp037QWfGBLFsYMUQJkE5yMa94UsJU7pmcE= +k8s.io/component-base v0.35.1/go.mod h1:HI/6jXlwkiOL5zL9bqA3en1Ygv60F03oEpnuU1G56Bs= k8s.io/gengo/v2 v2.0.0-20251215205346-5ee0d033ba5b h1:0YkdvW3rX2vaBWsqCGZAekxPRwaI5NuYNprOsMNVLns= k8s.io/gengo/v2 v2.0.0-20251215205346-5ee0d033ba5b/go.mod h1:yvyl3l9E+UxlqOMUULdKTAYB0rEhsmjr7+2Vb/1pCSo= k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4 h1:HhDfevmPS+OalTjQRKbTHppRIz01AWi8s45TMXStgYY= k8s.io/kube-openapi v0.0.0-20260127142750-a19766b6e2d4/go.mod h1:kdmbQkyfwUagLfXIad1y2TdrjPFWp2Q89B3qkRwf/pQ= -k8s.io/metrics v0.34.1 h1:374Rexmp1xxgRt64Bi0TsjAM8cA/Y8skwCoPdjtIslE= -k8s.io/metrics v0.34.1/go.mod h1:Drf5kPfk2NJrlpcNdSiAAHn/7Y9KqxpRNagByM7Ei80= +k8s.io/kubectl v0.35.1 h1:zP3Er8C5i1dcAFUMh9Eva0kVvZHptXIn/+8NtRWMxwg= +k8s.io/kubectl v0.35.1/go.mod h1:cQ2uAPs5IO/kx8R5s5J3Ihv3VCYwrx0obCXum0CvnXo= +k8s.io/metrics v0.35.1 h1:MUcrUcWlq81XiripkydzCGsY9zQawDXfP9IICNNcVVw= +k8s.io/metrics v0.35.1/go.mod h1:9x7xWOAOiWzHA0vaqLgSE4PXF3vyT5ts5XIbx8OSjiI= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 h1:kBawHLSnx/mYHmRnNUf9d4CpjREbeZuxoSGOX/J+aYM= k8s.io/utils v0.0.0-20260319190234-28399d86e0b5/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= +oras.land/oras-go/v2 v2.6.0 h1:X4ELRsiGkrbeox69+9tzTu492FMUu7zJQW6eJU+I2oc= +oras.land/oras-go/v2 v2.6.0/go.mod h1:magiQDfG6H1O9APp+rOsvCPcW1GD2MM7vgnKY0Y+u1o= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0 h1:hSfpvjjTQXQY2Fol2CS0QHMNs/WI1MOSGzCm1KhM5ec= sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.34.0/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw= sigs.k8s.io/controller-runtime v0.23.1 h1:TjJSM80Nf43Mg21+RCy3J70aj/W6KyvDtOlpKf+PupE= @@ -901,6 +1096,12 @@ sigs.k8s.io/controller-tools v0.20.0 h1:VWZF71pwSQ2lZZCt7hFGJsOfDc5dVG28/IysjjMW sigs.k8s.io/controller-tools v0.20.0/go.mod h1:b4qPmjGU3iZwqn34alUU5tILhNa9+VXK+J3QV0fT/uU= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730/go.mod h1:mdzfpAEoE6DHQEN0uh9ZbOCuHbLK5wOm7dK4ctXE9Tg= +sigs.k8s.io/kind v0.30.0 h1:2Xi1KFEfSMm0XDcvKnUt15ZfgRPCT0OnCBbpgh8DztY= +sigs.k8s.io/kind v0.30.0/go.mod h1:FSqriGaoTPruiXWfRnUXNykF8r2t+fHtK0P0m1AbGF8= +sigs.k8s.io/kustomize/api v0.20.1 h1:iWP1Ydh3/lmldBnH/S5RXgT98vWYMaTUL1ADcr+Sv7I= +sigs.k8s.io/kustomize/api v0.20.1/go.mod h1:t6hUFxO+Ph0VxIk1sKp1WS0dOjbPCtLJ4p8aADLwqjM= +sigs.k8s.io/kustomize/kyaml v0.20.1 h1:PCMnA2mrVbRP3NIB6v9kYCAc38uvFLVs8j/CD567A78= +sigs.k8s.io/kustomize/kyaml v0.20.1/go.mod h1:0EmkQHRUsJxY8Ug9Niig1pUMSCGHxQ5RklbpV/Ri6po= sigs.k8s.io/randfill v1.0.0 h1:JfjMILfT8A6RbawdsK2JXGBR5AQVfd+9TbzrlneTyrU= sigs.k8s.io/randfill v1.0.0/go.mod h1:XeLlZ/jmk4i1HRopwe7/aU3H5n1zNUcX6TM94b3QxOY= sigs.k8s.io/structured-merge-diff/v6 v6.3.2-0.20260122202528-d9cc6641c482 h1:2WOzJpHUBVrrkDjU4KBT8n5LDcj824eX0I5UKcgeRUs= diff --git a/gomod2nix.toml b/gomod2nix.toml index b6431e8..2c5e14f 100644 --- a/gomod2nix.toml +++ b/gomod2nix.toml @@ -1,7 +1,10 @@ schema = 3 -cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario.cat/mergo", "github.com/Azure/azure-sdk-for-go/services/preview/containerregistry/runtime/2019-08-15-preview/containerregistry", "github.com/Azure/azure-sdk-for-go/version", "github.com/Azure/go-autorest/autorest", "github.com/Azure/go-autorest/autorest/adal", "github.com/Azure/go-autorest/autorest/azure", "github.com/Azure/go-autorest/autorest/azure/auth", "github.com/Azure/go-autorest/autorest/azure/cli", "github.com/Azure/go-autorest/autorest/date", "github.com/Azure/go-autorest/logger", "github.com/Azure/go-autorest/tracing", "github.com/Masterminds/semver/v3", "github.com/ProtonMail/go-crypto/bitcurves", "github.com/ProtonMail/go-crypto/brainpool", "github.com/ProtonMail/go-crypto/eax", "github.com/ProtonMail/go-crypto/ocb", "github.com/ProtonMail/go-crypto/openpgp", "github.com/ProtonMail/go-crypto/openpgp/aes/keywrap", "github.com/ProtonMail/go-crypto/openpgp/armor", "github.com/ProtonMail/go-crypto/openpgp/ecdh", "github.com/ProtonMail/go-crypto/openpgp/ecdsa", "github.com/ProtonMail/go-crypto/openpgp/ed25519", "github.com/ProtonMail/go-crypto/openpgp/ed448", "github.com/ProtonMail/go-crypto/openpgp/eddsa", "github.com/ProtonMail/go-crypto/openpgp/elgamal", "github.com/ProtonMail/go-crypto/openpgp/errors", "github.com/ProtonMail/go-crypto/openpgp/packet", "github.com/ProtonMail/go-crypto/openpgp/s2k", "github.com/ProtonMail/go-crypto/openpgp/x25519", "github.com/ProtonMail/go-crypto/openpgp/x448", "github.com/alecthomas/kong", "github.com/antlr4-go/antlr/v4", "github.com/asaskevich/govalidator", "github.com/aws/aws-sdk-go-v2/aws", "github.com/aws/aws-sdk-go-v2/aws/defaults", "github.com/aws/aws-sdk-go-v2/aws/middleware", "github.com/aws/aws-sdk-go-v2/aws/protocol/query", "github.com/aws/aws-sdk-go-v2/aws/protocol/restjson", "github.com/aws/aws-sdk-go-v2/aws/protocol/xml", "github.com/aws/aws-sdk-go-v2/aws/ratelimit", "github.com/aws/aws-sdk-go-v2/aws/retry", "github.com/aws/aws-sdk-go-v2/aws/signer/v4", "github.com/aws/aws-sdk-go-v2/aws/transport/http", "github.com/aws/aws-sdk-go-v2/config", "github.com/aws/aws-sdk-go-v2/credentials", "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds", "github.com/aws/aws-sdk-go-v2/credentials/endpointcreds", "github.com/aws/aws-sdk-go-v2/credentials/logincreds", "github.com/aws/aws-sdk-go-v2/credentials/processcreds", "github.com/aws/aws-sdk-go-v2/credentials/ssocreds", "github.com/aws/aws-sdk-go-v2/credentials/stscreds", "github.com/aws/aws-sdk-go-v2/feature/ec2/imds", "github.com/aws/aws-sdk-go-v2/service/ecr", "github.com/aws/aws-sdk-go-v2/service/ecr/types", "github.com/aws/aws-sdk-go-v2/service/ecrpublic", "github.com/aws/aws-sdk-go-v2/service/ecrpublic/types", "github.com/aws/aws-sdk-go-v2/service/signin", "github.com/aws/aws-sdk-go-v2/service/signin/types", "github.com/aws/aws-sdk-go-v2/service/sso", "github.com/aws/aws-sdk-go-v2/service/sso/types", "github.com/aws/aws-sdk-go-v2/service/ssooidc", "github.com/aws/aws-sdk-go-v2/service/ssooidc/types", "github.com/aws/aws-sdk-go-v2/service/sts", "github.com/aws/aws-sdk-go-v2/service/sts/types", "github.com/aws/smithy-go", "github.com/aws/smithy-go/auth", "github.com/aws/smithy-go/auth/bearer", "github.com/aws/smithy-go/context", "github.com/aws/smithy-go/document", "github.com/aws/smithy-go/encoding", "github.com/aws/smithy-go/encoding/httpbinding", "github.com/aws/smithy-go/encoding/json", "github.com/aws/smithy-go/encoding/xml", "github.com/aws/smithy-go/endpoints", "github.com/aws/smithy-go/endpoints/private/rulesfn", "github.com/aws/smithy-go/io", "github.com/aws/smithy-go/logging", "github.com/aws/smithy-go/metrics", "github.com/aws/smithy-go/middleware", "github.com/aws/smithy-go/private/requestcompression", "github.com/aws/smithy-go/ptr", "github.com/aws/smithy-go/rand", "github.com/aws/smithy-go/time", "github.com/aws/smithy-go/tracing", "github.com/aws/smithy-go/transport/http", "github.com/aws/smithy-go/waiter", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/api", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cache", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/config", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/version", "github.com/aymanbagabas/go-osc52/v2", "github.com/beorn7/perks/quantile", "github.com/blang/semver", "github.com/blang/semver/v4", "github.com/cenkalti/backoff/v5", "github.com/cespare/xxhash/v2", "github.com/charmbracelet/bubbletea", "github.com/charmbracelet/colorprofile", "github.com/charmbracelet/lipgloss", "github.com/charmbracelet/x/ansi", "github.com/charmbracelet/x/ansi/parser", "github.com/charmbracelet/x/cellbuf", "github.com/charmbracelet/x/term", "github.com/chrismellard/docker-credential-acr-env/pkg/credhelper", "github.com/chrismellard/docker-credential-acr-env/pkg/registry", "github.com/chrismellard/docker-credential-acr-env/pkg/token", "github.com/cloudflare/circl/dh/x25519", "github.com/cloudflare/circl/dh/x448", "github.com/cloudflare/circl/ecc/goldilocks", "github.com/cloudflare/circl/math", "github.com/cloudflare/circl/math/fp25519", "github.com/cloudflare/circl/math/fp448", "github.com/cloudflare/circl/math/mlsbset", "github.com/cloudflare/circl/sign", "github.com/cloudflare/circl/sign/ed25519", "github.com/cloudflare/circl/sign/ed448", "github.com/containerd/errdefs", "github.com/containerd/errdefs/pkg/errhttp", "github.com/containerd/stargz-snapshotter/estargz", "github.com/containerd/stargz-snapshotter/estargz/errorutil", "github.com/coreos/go-oidc/v3/oidc", "github.com/crossplane/crossplane-runtime/v2/pkg/errors", "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath", "github.com/crossplane/crossplane-runtime/v2/pkg/logging", "github.com/crossplane/crossplane-runtime/v2/pkg/meta", "github.com/crossplane/crossplane-runtime/v2/pkg/resource", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/claim", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/reference", "github.com/crossplane/crossplane-runtime/v2/pkg/test", "github.com/crossplane/crossplane-runtime/v2/pkg/version", "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/examples", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/yaml", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/signature", "github.com/crossplane/crossplane/apis/v2/apiextensions/v1", "github.com/crossplane/crossplane/apis/v2/apiextensions/v1alpha1", "github.com/crossplane/crossplane/apis/v2/apiextensions/v1beta1", "github.com/crossplane/crossplane/apis/v2/apiextensions/v2", "github.com/crossplane/crossplane/apis/v2/core/v2", "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1", "github.com/crossplane/crossplane/apis/v2/pkg", "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1", "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1alpha1", "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1beta1", "github.com/crossplane/crossplane/apis/v2/pkg/v1", "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1", "github.com/crossplane/function-sdk-go/errors", "github.com/crossplane/function-sdk-go/proto/v1", "github.com/crossplane/function-sdk-go/resource", "github.com/crossplane/function-sdk-go/resource/composed", "github.com/crossplane/function-sdk-go/resource/composite", "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer", "github.com/cyphar/filepath-securejoin", "github.com/davecgh/go-spew/spew", "github.com/digitorus/pkcs7", "github.com/digitorus/timestamp", "github.com/dimchansky/utfbom", "github.com/distribution/reference", "github.com/docker/cli/cli/config", "github.com/docker/cli/cli/config/configfile", "github.com/docker/cli/cli/config/credentials", "github.com/docker/cli/cli/config/memorystore", "github.com/docker/cli/cli/config/types", "github.com/docker/distribution/registry/client/auth/challenge", "github.com/docker/docker-credential-helpers/client", "github.com/docker/docker-credential-helpers/credentials", "github.com/docker/docker/api", "github.com/docker/docker/api/types", "github.com/docker/docker/api/types/blkiodev", "github.com/docker/docker/api/types/build", "github.com/docker/docker/api/types/checkpoint", "github.com/docker/docker/api/types/common", "github.com/docker/docker/api/types/container", "github.com/docker/docker/api/types/events", "github.com/docker/docker/api/types/filters", "github.com/docker/docker/api/types/image", "github.com/docker/docker/api/types/mount", "github.com/docker/docker/api/types/network", "github.com/docker/docker/api/types/registry", "github.com/docker/docker/api/types/storage", "github.com/docker/docker/api/types/strslice", "github.com/docker/docker/api/types/swarm", "github.com/docker/docker/api/types/swarm/runtime", "github.com/docker/docker/api/types/system", "github.com/docker/docker/api/types/time", "github.com/docker/docker/api/types/versions", "github.com/docker/docker/api/types/volume", "github.com/docker/docker/client", "github.com/docker/docker/pkg/stdcopy", "github.com/docker/go-connections/nat", "github.com/docker/go-connections/sockets", "github.com/docker/go-connections/tlsconfig", "github.com/docker/go-units", "github.com/dustin/go-humanize", "github.com/emicklei/dot", "github.com/emicklei/go-restful/v3", "github.com/emicklei/go-restful/v3/log", "github.com/emirpasic/gods/containers", "github.com/emirpasic/gods/lists", "github.com/emirpasic/gods/lists/arraylist", "github.com/emirpasic/gods/trees", "github.com/emirpasic/gods/trees/binaryheap", "github.com/emirpasic/gods/utils", "github.com/evanphx/json-patch/v5", "github.com/felixge/httpsnoop", "github.com/fsnotify/fsnotify", "github.com/fxamacker/cbor/v2", "github.com/go-chi/chi/v5", "github.com/go-chi/chi/v5/middleware", "github.com/go-git/gcfg", "github.com/go-git/gcfg/scanner", "github.com/go-git/gcfg/token", "github.com/go-git/gcfg/types", "github.com/go-git/go-billy/v5", "github.com/go-git/go-billy/v5/helper/chroot", "github.com/go-git/go-billy/v5/helper/polyfill", "github.com/go-git/go-billy/v5/osfs", "github.com/go-git/go-billy/v5/util", "github.com/go-git/go-git/v5", "github.com/go-git/go-git/v5/config", "github.com/go-git/go-git/v5/plumbing", "github.com/go-git/go-git/v5/plumbing/cache", "github.com/go-git/go-git/v5/plumbing/color", "github.com/go-git/go-git/v5/plumbing/filemode", "github.com/go-git/go-git/v5/plumbing/format/config", "github.com/go-git/go-git/v5/plumbing/format/diff", "github.com/go-git/go-git/v5/plumbing/format/gitignore", "github.com/go-git/go-git/v5/plumbing/format/idxfile", "github.com/go-git/go-git/v5/plumbing/format/index", "github.com/go-git/go-git/v5/plumbing/format/objfile", "github.com/go-git/go-git/v5/plumbing/format/packfile", "github.com/go-git/go-git/v5/plumbing/format/pktline", "github.com/go-git/go-git/v5/plumbing/hash", "github.com/go-git/go-git/v5/plumbing/object", "github.com/go-git/go-git/v5/plumbing/protocol/packp", "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability", "github.com/go-git/go-git/v5/plumbing/protocol/packp/sideband", "github.com/go-git/go-git/v5/plumbing/revlist", "github.com/go-git/go-git/v5/plumbing/storer", "github.com/go-git/go-git/v5/plumbing/transport", "github.com/go-git/go-git/v5/plumbing/transport/client", "github.com/go-git/go-git/v5/plumbing/transport/file", "github.com/go-git/go-git/v5/plumbing/transport/git", "github.com/go-git/go-git/v5/plumbing/transport/http", "github.com/go-git/go-git/v5/plumbing/transport/server", "github.com/go-git/go-git/v5/plumbing/transport/ssh", "github.com/go-git/go-git/v5/storage", "github.com/go-git/go-git/v5/storage/filesystem", "github.com/go-git/go-git/v5/storage/filesystem/dotgit", "github.com/go-git/go-git/v5/storage/memory", "github.com/go-git/go-git/v5/utils/binary", "github.com/go-git/go-git/v5/utils/diff", "github.com/go-git/go-git/v5/utils/ioutil", "github.com/go-git/go-git/v5/utils/merkletrie", "github.com/go-git/go-git/v5/utils/merkletrie/filesystem", "github.com/go-git/go-git/v5/utils/merkletrie/index", "github.com/go-git/go-git/v5/utils/merkletrie/noder", "github.com/go-git/go-git/v5/utils/sync", "github.com/go-git/go-git/v5/utils/trace", "github.com/go-jose/go-jose/v4", "github.com/go-jose/go-jose/v4/cipher", "github.com/go-jose/go-jose/v4/json", "github.com/go-json-experiment/json", "github.com/go-json-experiment/json/jsontext", "github.com/go-logr/logr", "github.com/go-logr/logr/funcr", "github.com/go-logr/logr/slogr", "github.com/go-logr/stdr", "github.com/go-logr/zapr", "github.com/go-openapi/analysis", "github.com/go-openapi/errors", "github.com/go-openapi/jsonpointer", "github.com/go-openapi/jsonreference", "github.com/go-openapi/loads", "github.com/go-openapi/runtime", "github.com/go-openapi/runtime/client", "github.com/go-openapi/runtime/logger", "github.com/go-openapi/runtime/middleware", "github.com/go-openapi/runtime/middleware/denco", "github.com/go-openapi/runtime/middleware/header", "github.com/go-openapi/runtime/middleware/untyped", "github.com/go-openapi/runtime/security", "github.com/go-openapi/runtime/yamlpc", "github.com/go-openapi/spec", "github.com/go-openapi/strfmt", "github.com/go-openapi/swag", "github.com/go-openapi/swag/cmdutils", "github.com/go-openapi/swag/conv", "github.com/go-openapi/swag/fileutils", "github.com/go-openapi/swag/jsonname", "github.com/go-openapi/swag/jsonutils", "github.com/go-openapi/swag/jsonutils/adapters", "github.com/go-openapi/swag/jsonutils/adapters/ifaces", "github.com/go-openapi/swag/jsonutils/adapters/stdlib/json", "github.com/go-openapi/swag/loading", "github.com/go-openapi/swag/mangling", "github.com/go-openapi/swag/netutils", "github.com/go-openapi/swag/stringutils", "github.com/go-openapi/swag/typeutils", "github.com/go-openapi/swag/yamlutils", "github.com/go-openapi/validate", "github.com/go-viper/mapstructure/v2", "github.com/gogo/protobuf/proto", "github.com/gogo/protobuf/sortkeys", "github.com/golang-jwt/jwt/v4", "github.com/golang/groupcache/lru", "github.com/golang/snappy", "github.com/google/btree", "github.com/google/cel-go/cel", "github.com/google/cel-go/checker", "github.com/google/cel-go/checker/decls", "github.com/google/cel-go/common", "github.com/google/cel-go/common/ast", "github.com/google/cel-go/common/containers", "github.com/google/cel-go/common/debug", "github.com/google/cel-go/common/decls", "github.com/google/cel-go/common/env", "github.com/google/cel-go/common/functions", "github.com/google/cel-go/common/operators", "github.com/google/cel-go/common/overloads", "github.com/google/cel-go/common/runes", "github.com/google/cel-go/common/stdlib", "github.com/google/cel-go/common/types", "github.com/google/cel-go/common/types/pb", "github.com/google/cel-go/common/types/ref", "github.com/google/cel-go/common/types/traits", "github.com/google/cel-go/ext", "github.com/google/cel-go/interpreter", "github.com/google/cel-go/interpreter/functions", "github.com/google/cel-go/parser", "github.com/google/cel-go/parser/gen", "github.com/google/certificate-transparency-go", "github.com/google/certificate-transparency-go/asn1", "github.com/google/certificate-transparency-go/client", "github.com/google/certificate-transparency-go/client/configpb", "github.com/google/certificate-transparency-go/ctutil", "github.com/google/certificate-transparency-go/gossip/minimal/x509ext", "github.com/google/certificate-transparency-go/jsonclient", "github.com/google/certificate-transparency-go/loglist3", "github.com/google/certificate-transparency-go/tls", "github.com/google/certificate-transparency-go/x509", "github.com/google/certificate-transparency-go/x509/pkix", "github.com/google/certificate-transparency-go/x509util", "github.com/google/gnostic-models/compiler", "github.com/google/gnostic-models/extensions", "github.com/google/gnostic-models/jsonschema", "github.com/google/gnostic-models/openapiv2", "github.com/google/gnostic-models/openapiv3", "github.com/google/go-cmp/cmp", "github.com/google/go-cmp/cmp/cmpopts", "github.com/google/go-containerregistry/pkg/authn", "github.com/google/go-containerregistry/pkg/authn/k8schain", "github.com/google/go-containerregistry/pkg/authn/kubernetes", "github.com/google/go-containerregistry/pkg/compression", "github.com/google/go-containerregistry/pkg/crane", "github.com/google/go-containerregistry/pkg/legacy", "github.com/google/go-containerregistry/pkg/legacy/tarball", "github.com/google/go-containerregistry/pkg/logs", "github.com/google/go-containerregistry/pkg/name", "github.com/google/go-containerregistry/pkg/v1", "github.com/google/go-containerregistry/pkg/v1/daemon", "github.com/google/go-containerregistry/pkg/v1/empty", "github.com/google/go-containerregistry/pkg/v1/google", "github.com/google/go-containerregistry/pkg/v1/layout", "github.com/google/go-containerregistry/pkg/v1/match", "github.com/google/go-containerregistry/pkg/v1/mutate", "github.com/google/go-containerregistry/pkg/v1/partial", "github.com/google/go-containerregistry/pkg/v1/random", "github.com/google/go-containerregistry/pkg/v1/remote", "github.com/google/go-containerregistry/pkg/v1/remote/transport", "github.com/google/go-containerregistry/pkg/v1/static", "github.com/google/go-containerregistry/pkg/v1/stream", "github.com/google/go-containerregistry/pkg/v1/tarball", "github.com/google/go-containerregistry/pkg/v1/types", "github.com/google/uuid", "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options", "github.com/grpc-ecosystem/grpc-gateway/v2/runtime", "github.com/grpc-ecosystem/grpc-gateway/v2/utilities", "github.com/hashicorp/errwrap", "github.com/hashicorp/go-cleanhttp", "github.com/hashicorp/go-multierror", "github.com/hashicorp/go-retryablehttp", "github.com/in-toto/attestation/go/v1", "github.com/in-toto/in-toto-golang/in_toto", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.1", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1", "github.com/jbenet/go-context/io", "github.com/jedisct1/go-minisign", "github.com/json-iterator/go", "github.com/kevinburke/ssh_config", "github.com/klauspost/compress", "github.com/klauspost/compress/fse", "github.com/klauspost/compress/huff0", "github.com/klauspost/compress/zstd", "github.com/letsencrypt/boulder/core", "github.com/letsencrypt/boulder/core/proto", "github.com/letsencrypt/boulder/goodkey", "github.com/letsencrypt/boulder/identifier", "github.com/letsencrypt/boulder/probs", "github.com/letsencrypt/boulder/revocation", "github.com/liggitt/tabwriter", "github.com/lucasb-eyer/go-colorful", "github.com/mattn/go-isatty", "github.com/mattn/go-runewidth", "github.com/mitchellh/go-homedir", "github.com/moby/docker-image-spec/specs-go/v1", "github.com/moby/term", "github.com/modern-go/concurrent", "github.com/modern-go/reflect2", "github.com/muesli/ansi", "github.com/muesli/ansi/compressor", "github.com/muesli/cancelreader", "github.com/muesli/termenv", "github.com/munnerz/goautoneg", "github.com/nozzle/throttler", "github.com/oklog/ulid/v2", "github.com/opencontainers/go-digest", "github.com/opencontainers/image-spec/specs-go", "github.com/opencontainers/image-spec/specs-go/v1", "github.com/pjbgf/sha1cd", "github.com/pjbgf/sha1cd/ubc", "github.com/pkg/browser", "github.com/pkg/errors", "github.com/pmezard/go-difflib/difflib", "github.com/posener/complete", "github.com/posener/complete/cmd", "github.com/posener/complete/cmd/install", "github.com/prometheus/client_golang/prometheus", "github.com/prometheus/client_golang/prometheus/collectors", "github.com/prometheus/client_golang/prometheus/promhttp", "github.com/prometheus/client_model/go", "github.com/prometheus/common/expfmt", "github.com/prometheus/common/model", "github.com/prometheus/procfs", "github.com/rivo/uniseg", "github.com/riywo/loginshell", "github.com/sassoftware/relic/lib/pkcs7", "github.com/sassoftware/relic/lib/x509tools", "github.com/secure-systems-lab/go-securesystemslib/cjson", "github.com/secure-systems-lab/go-securesystemslib/dsse", "github.com/secure-systems-lab/go-securesystemslib/encrypted", "github.com/secure-systems-lab/go-securesystemslib/signerverifier", "github.com/sergi/go-diff/diffmatchpatch", "github.com/shibumi/go-pathspec", "github.com/sigstore/cosign/v3/pkg/blob", "github.com/sigstore/cosign/v3/pkg/cosign", "github.com/sigstore/cosign/v3/pkg/cosign/attestation", "github.com/sigstore/cosign/v3/pkg/cosign/bundle", "github.com/sigstore/cosign/v3/pkg/cosign/env", "github.com/sigstore/cosign/v3/pkg/cosign/fulcioverifier/ctutil", "github.com/sigstore/cosign/v3/pkg/oci", "github.com/sigstore/cosign/v3/pkg/oci/empty", "github.com/sigstore/cosign/v3/pkg/oci/layout", "github.com/sigstore/cosign/v3/pkg/oci/remote", "github.com/sigstore/cosign/v3/pkg/oci/signed", "github.com/sigstore/cosign/v3/pkg/oci/static", "github.com/sigstore/cosign/v3/pkg/types", "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1", "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1", "github.com/sigstore/protobuf-specs/gen/pb-go/dsse", "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1", "github.com/sigstore/protobuf-specs/gen/pb-go/trustroot/v1", "github.com/sigstore/rekor-tiles/v2/pkg/client", "github.com/sigstore/rekor-tiles/v2/pkg/client/write", "github.com/sigstore/rekor-tiles/v2/pkg/generated/protobuf", "github.com/sigstore/rekor-tiles/v2/pkg/note", "github.com/sigstore/rekor-tiles/v2/pkg/types/verifier", "github.com/sigstore/rekor-tiles/v2/pkg/verify", "github.com/sigstore/rekor/pkg/client", "github.com/sigstore/rekor/pkg/generated/client", "github.com/sigstore/rekor/pkg/generated/client/entries", "github.com/sigstore/rekor/pkg/generated/client/index", "github.com/sigstore/rekor/pkg/generated/client/pubkey", "github.com/sigstore/rekor/pkg/generated/client/tlog", "github.com/sigstore/rekor/pkg/generated/models", "github.com/sigstore/rekor/pkg/log", "github.com/sigstore/rekor/pkg/pki", "github.com/sigstore/rekor/pkg/pki/identity", "github.com/sigstore/rekor/pkg/pki/minisign", "github.com/sigstore/rekor/pkg/pki/pgp", "github.com/sigstore/rekor/pkg/pki/pkcs7", "github.com/sigstore/rekor/pkg/pki/pkitypes", "github.com/sigstore/rekor/pkg/pki/ssh", "github.com/sigstore/rekor/pkg/pki/tuf", "github.com/sigstore/rekor/pkg/pki/x509", "github.com/sigstore/rekor/pkg/tle", "github.com/sigstore/rekor/pkg/types", "github.com/sigstore/rekor/pkg/types/dsse", "github.com/sigstore/rekor/pkg/types/dsse/v0.0.1", "github.com/sigstore/rekor/pkg/types/hashedrekord", "github.com/sigstore/rekor/pkg/types/hashedrekord/v0.0.1", "github.com/sigstore/rekor/pkg/types/intoto", "github.com/sigstore/rekor/pkg/types/intoto/v0.0.1", "github.com/sigstore/rekor/pkg/types/intoto/v0.0.2", "github.com/sigstore/rekor/pkg/types/rekord", "github.com/sigstore/rekor/pkg/types/rekord/v0.0.1", "github.com/sigstore/rekor/pkg/util", "github.com/sigstore/rekor/pkg/verify", "github.com/sigstore/sigstore-go/pkg/bundle", "github.com/sigstore/sigstore-go/pkg/fulcio/certificate", "github.com/sigstore/sigstore-go/pkg/root", "github.com/sigstore/sigstore-go/pkg/sign", "github.com/sigstore/sigstore-go/pkg/tlog", "github.com/sigstore/sigstore-go/pkg/tuf", "github.com/sigstore/sigstore-go/pkg/util", "github.com/sigstore/sigstore-go/pkg/verify", "github.com/sigstore/sigstore/pkg/cryptoutils", "github.com/sigstore/sigstore/pkg/cryptoutils/goodkey", "github.com/sigstore/sigstore/pkg/fulcioroots", "github.com/sigstore/sigstore/pkg/oauth", "github.com/sigstore/sigstore/pkg/oauthflow", "github.com/sigstore/sigstore/pkg/signature", "github.com/sigstore/sigstore/pkg/signature/dsse", "github.com/sigstore/sigstore/pkg/signature/options", "github.com/sigstore/sigstore/pkg/signature/payload", "github.com/sigstore/sigstore/pkg/tuf", "github.com/sigstore/timestamp-authority/v2/pkg/verification", "github.com/sirupsen/logrus", "github.com/skeema/knownhosts", "github.com/spf13/afero", "github.com/spf13/afero/mem", "github.com/spf13/cobra", "github.com/spf13/pflag", "github.com/syndtr/goleveldb/leveldb", "github.com/syndtr/goleveldb/leveldb/cache", "github.com/syndtr/goleveldb/leveldb/comparer", "github.com/syndtr/goleveldb/leveldb/errors", "github.com/syndtr/goleveldb/leveldb/filter", "github.com/syndtr/goleveldb/leveldb/iterator", "github.com/syndtr/goleveldb/leveldb/journal", "github.com/syndtr/goleveldb/leveldb/memdb", "github.com/syndtr/goleveldb/leveldb/opt", "github.com/syndtr/goleveldb/leveldb/storage", "github.com/syndtr/goleveldb/leveldb/table", "github.com/syndtr/goleveldb/leveldb/util", "github.com/theupdateframework/go-tuf", "github.com/theupdateframework/go-tuf/client", "github.com/theupdateframework/go-tuf/client/leveldbstore", "github.com/theupdateframework/go-tuf/data", "github.com/theupdateframework/go-tuf/pkg/keys", "github.com/theupdateframework/go-tuf/pkg/targets", "github.com/theupdateframework/go-tuf/sign", "github.com/theupdateframework/go-tuf/util", "github.com/theupdateframework/go-tuf/v2/metadata", "github.com/theupdateframework/go-tuf/v2/metadata/config", "github.com/theupdateframework/go-tuf/v2/metadata/fetcher", "github.com/theupdateframework/go-tuf/v2/metadata/trustedmetadata", "github.com/theupdateframework/go-tuf/v2/metadata/updater", "github.com/theupdateframework/go-tuf/verify", "github.com/titanous/rocacheck", "github.com/transparency-dev/formats/log", "github.com/transparency-dev/merkle", "github.com/transparency-dev/merkle/compact", "github.com/transparency-dev/merkle/proof", "github.com/transparency-dev/merkle/rfc6962", "github.com/vbatts/tar-split/archive/tar", "github.com/willabides/kongplete", "github.com/x448/float16", "github.com/xanzy/ssh-agent", "github.com/xo/terminfo", "go.opentelemetry.io/auto/sdk", "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", "go.opentelemetry.io/otel", "go.opentelemetry.io/otel/attribute", "go.opentelemetry.io/otel/baggage", "go.opentelemetry.io/otel/codes", "go.opentelemetry.io/otel/metric", "go.opentelemetry.io/otel/metric/embedded", "go.opentelemetry.io/otel/metric/noop", "go.opentelemetry.io/otel/propagation", "go.opentelemetry.io/otel/semconv/v1.37.0", "go.opentelemetry.io/otel/semconv/v1.40.0", "go.opentelemetry.io/otel/semconv/v1.40.0/httpconv", "go.opentelemetry.io/otel/trace", "go.opentelemetry.io/otel/trace/embedded", "go.opentelemetry.io/otel/trace/noop", "go.uber.org/multierr", "go.uber.org/zap", "go.uber.org/zap/buffer", "go.uber.org/zap/zapcore", "go.yaml.in/yaml/v2", "go.yaml.in/yaml/v3", "golang.org/x/crypto/argon2", "golang.org/x/crypto/blake2b", "golang.org/x/crypto/blowfish", "golang.org/x/crypto/cast5", "golang.org/x/crypto/chacha20", "golang.org/x/crypto/cryptobyte", "golang.org/x/crypto/cryptobyte/asn1", "golang.org/x/crypto/curve25519", "golang.org/x/crypto/ed25519", "golang.org/x/crypto/hkdf", "golang.org/x/crypto/nacl/secretbox", "golang.org/x/crypto/ocsp", "golang.org/x/crypto/openpgp", "golang.org/x/crypto/openpgp/armor", "golang.org/x/crypto/openpgp/elgamal", "golang.org/x/crypto/openpgp/errors", "golang.org/x/crypto/openpgp/packet", "golang.org/x/crypto/openpgp/s2k", "golang.org/x/crypto/pbkdf2", "golang.org/x/crypto/pkcs12", "golang.org/x/crypto/salsa20/salsa", "golang.org/x/crypto/scrypt", "golang.org/x/crypto/sha3", "golang.org/x/crypto/ssh", "golang.org/x/crypto/ssh/agent", "golang.org/x/crypto/ssh/knownhosts", "golang.org/x/crypto/ssh/terminal", "golang.org/x/exp/slices", "golang.org/x/mod/semver", "golang.org/x/mod/sumdb/note", "golang.org/x/net/context", "golang.org/x/net/http/httpguts", "golang.org/x/net/http2", "golang.org/x/net/http2/hpack", "golang.org/x/net/idna", "golang.org/x/net/proxy", "golang.org/x/net/trace", "golang.org/x/oauth2", "golang.org/x/oauth2/authhandler", "golang.org/x/oauth2/google", "golang.org/x/oauth2/google/externalaccount", "golang.org/x/oauth2/jws", "golang.org/x/oauth2/jwt", "golang.org/x/sync/errgroup", "golang.org/x/sync/singleflight", "golang.org/x/sys/cpu", "golang.org/x/sys/execabs", "golang.org/x/sys/unix", "golang.org/x/term", "golang.org/x/text/feature/plural", "golang.org/x/text/language", "golang.org/x/text/message", "golang.org/x/text/message/catalog", "golang.org/x/text/runes", "golang.org/x/text/secure/bidirule", "golang.org/x/text/transform", "golang.org/x/text/unicode/bidi", "golang.org/x/text/unicode/norm", "golang.org/x/time/rate", "gomodules.xyz/jsonpatch/v2", "google.golang.org/genproto/googleapis/api", "google.golang.org/genproto/googleapis/api/annotations", "google.golang.org/genproto/googleapis/api/expr/v1alpha1", "google.golang.org/genproto/googleapis/api/httpbody", "google.golang.org/genproto/googleapis/rpc/status", "google.golang.org/grpc", "google.golang.org/grpc/attributes", "google.golang.org/grpc/backoff", "google.golang.org/grpc/balancer", "google.golang.org/grpc/balancer/base", "google.golang.org/grpc/balancer/endpointsharding", "google.golang.org/grpc/balancer/grpclb/state", "google.golang.org/grpc/balancer/pickfirst", "google.golang.org/grpc/balancer/roundrobin", "google.golang.org/grpc/binarylog/grpc_binarylog_v1", "google.golang.org/grpc/channelz", "google.golang.org/grpc/codes", "google.golang.org/grpc/connectivity", "google.golang.org/grpc/credentials", "google.golang.org/grpc/credentials/insecure", "google.golang.org/grpc/encoding", "google.golang.org/grpc/encoding/proto", "google.golang.org/grpc/experimental/stats", "google.golang.org/grpc/grpclog", "google.golang.org/grpc/health/grpc_health_v1", "google.golang.org/grpc/keepalive", "google.golang.org/grpc/mem", "google.golang.org/grpc/metadata", "google.golang.org/grpc/peer", "google.golang.org/grpc/resolver", "google.golang.org/grpc/resolver/dns", "google.golang.org/grpc/serviceconfig", "google.golang.org/grpc/stats", "google.golang.org/grpc/status", "google.golang.org/grpc/tap", "google.golang.org/protobuf/encoding/protodelim", "google.golang.org/protobuf/encoding/protojson", "google.golang.org/protobuf/encoding/prototext", "google.golang.org/protobuf/encoding/protowire", "google.golang.org/protobuf/proto", "google.golang.org/protobuf/protoadapt", "google.golang.org/protobuf/reflect/protodesc", "google.golang.org/protobuf/reflect/protoreflect", "google.golang.org/protobuf/reflect/protoregistry", "google.golang.org/protobuf/runtime/protoiface", "google.golang.org/protobuf/runtime/protoimpl", "google.golang.org/protobuf/testing/protocmp", "google.golang.org/protobuf/types/descriptorpb", "google.golang.org/protobuf/types/dynamicpb", "google.golang.org/protobuf/types/gofeaturespb", "google.golang.org/protobuf/types/known/anypb", "google.golang.org/protobuf/types/known/durationpb", "google.golang.org/protobuf/types/known/emptypb", "google.golang.org/protobuf/types/known/fieldmaskpb", "google.golang.org/protobuf/types/known/structpb", "google.golang.org/protobuf/types/known/timestamppb", "google.golang.org/protobuf/types/known/wrapperspb", "gopkg.in/evanphx/json-patch.v4", "gopkg.in/inf.v0", "gopkg.in/warnings.v0", "gopkg.in/yaml.v3", "k8s.io/api/admission/v1", "k8s.io/api/admission/v1beta1", "k8s.io/api/admissionregistration/v1", "k8s.io/api/admissionregistration/v1alpha1", "k8s.io/api/admissionregistration/v1beta1", "k8s.io/api/apidiscovery/v2", "k8s.io/api/apidiscovery/v2beta1", "k8s.io/api/apiserverinternal/v1alpha1", "k8s.io/api/apps/v1", "k8s.io/api/apps/v1beta1", "k8s.io/api/apps/v1beta2", "k8s.io/api/authentication/v1", "k8s.io/api/authentication/v1alpha1", "k8s.io/api/authentication/v1beta1", "k8s.io/api/authorization/v1", "k8s.io/api/authorization/v1beta1", "k8s.io/api/autoscaling/v1", "k8s.io/api/autoscaling/v2", "k8s.io/api/autoscaling/v2beta1", "k8s.io/api/autoscaling/v2beta2", "k8s.io/api/batch/v1", "k8s.io/api/batch/v1beta1", "k8s.io/api/certificates/v1", "k8s.io/api/certificates/v1alpha1", "k8s.io/api/certificates/v1beta1", "k8s.io/api/coordination/v1", "k8s.io/api/coordination/v1alpha2", "k8s.io/api/coordination/v1beta1", "k8s.io/api/core/v1", "k8s.io/api/discovery/v1", "k8s.io/api/discovery/v1beta1", "k8s.io/api/events/v1", "k8s.io/api/events/v1beta1", "k8s.io/api/extensions/v1beta1", "k8s.io/api/flowcontrol/v1", "k8s.io/api/flowcontrol/v1beta1", "k8s.io/api/flowcontrol/v1beta2", "k8s.io/api/flowcontrol/v1beta3", "k8s.io/api/networking/v1", "k8s.io/api/networking/v1beta1", "k8s.io/api/node/v1", "k8s.io/api/node/v1alpha1", "k8s.io/api/node/v1beta1", "k8s.io/api/policy/v1", "k8s.io/api/policy/v1beta1", "k8s.io/api/rbac/v1", "k8s.io/api/rbac/v1alpha1", "k8s.io/api/rbac/v1beta1", "k8s.io/api/resource/v1", "k8s.io/api/resource/v1alpha3", "k8s.io/api/resource/v1beta1", "k8s.io/api/resource/v1beta2", "k8s.io/api/scheduling/v1", "k8s.io/api/scheduling/v1alpha1", "k8s.io/api/scheduling/v1beta1", "k8s.io/api/storage/v1", "k8s.io/api/storage/v1alpha1", "k8s.io/api/storage/v1beta1", "k8s.io/api/storagemigration/v1beta1", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning", "k8s.io/apiextensions-apiserver/pkg/apiserver/validation", "k8s.io/apiextensions-apiserver/pkg/features", "k8s.io/apimachinery/pkg/api/equality", "k8s.io/apimachinery/pkg/api/errors", "k8s.io/apimachinery/pkg/api/meta", "k8s.io/apimachinery/pkg/api/meta/testrestmapper", "k8s.io/apimachinery/pkg/api/operation", "k8s.io/apimachinery/pkg/api/resource", "k8s.io/apimachinery/pkg/api/safe", "k8s.io/apimachinery/pkg/api/validate", "k8s.io/apimachinery/pkg/api/validate/constraints", "k8s.io/apimachinery/pkg/api/validate/content", "k8s.io/apimachinery/pkg/api/validation", "k8s.io/apimachinery/pkg/api/validation/path", "k8s.io/apimachinery/pkg/apis/meta/v1", "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured", "k8s.io/apimachinery/pkg/apis/meta/v1/validation", "k8s.io/apimachinery/pkg/apis/meta/v1beta1", "k8s.io/apimachinery/pkg/conversion", "k8s.io/apimachinery/pkg/conversion/queryparams", "k8s.io/apimachinery/pkg/fields", "k8s.io/apimachinery/pkg/labels", "k8s.io/apimachinery/pkg/runtime", "k8s.io/apimachinery/pkg/runtime/schema", "k8s.io/apimachinery/pkg/runtime/serializer", "k8s.io/apimachinery/pkg/runtime/serializer/cbor", "k8s.io/apimachinery/pkg/runtime/serializer/cbor/direct", "k8s.io/apimachinery/pkg/runtime/serializer/json", "k8s.io/apimachinery/pkg/runtime/serializer/protobuf", "k8s.io/apimachinery/pkg/runtime/serializer/recognizer", "k8s.io/apimachinery/pkg/runtime/serializer/streaming", "k8s.io/apimachinery/pkg/runtime/serializer/versioning", "k8s.io/apimachinery/pkg/selection", "k8s.io/apimachinery/pkg/types", "k8s.io/apimachinery/pkg/util/cache", "k8s.io/apimachinery/pkg/util/diff", "k8s.io/apimachinery/pkg/util/dump", "k8s.io/apimachinery/pkg/util/duration", "k8s.io/apimachinery/pkg/util/errors", "k8s.io/apimachinery/pkg/util/framer", "k8s.io/apimachinery/pkg/util/intstr", "k8s.io/apimachinery/pkg/util/json", "k8s.io/apimachinery/pkg/util/managedfields", "k8s.io/apimachinery/pkg/util/mergepatch", "k8s.io/apimachinery/pkg/util/naming", "k8s.io/apimachinery/pkg/util/net", "k8s.io/apimachinery/pkg/util/rand", "k8s.io/apimachinery/pkg/util/runtime", "k8s.io/apimachinery/pkg/util/sets", "k8s.io/apimachinery/pkg/util/strategicpatch", "k8s.io/apimachinery/pkg/util/uuid", "k8s.io/apimachinery/pkg/util/validation", "k8s.io/apimachinery/pkg/util/validation/field", "k8s.io/apimachinery/pkg/util/version", "k8s.io/apimachinery/pkg/util/wait", "k8s.io/apimachinery/pkg/util/yaml", "k8s.io/apimachinery/pkg/version", "k8s.io/apimachinery/pkg/watch", "k8s.io/apimachinery/third_party/forked/golang/json", "k8s.io/apimachinery/third_party/forked/golang/reflect", "k8s.io/apiserver/pkg/apis/cel", "k8s.io/apiserver/pkg/authentication/serviceaccount", "k8s.io/apiserver/pkg/authentication/user", "k8s.io/apiserver/pkg/authorization/authorizer", "k8s.io/apiserver/pkg/cel", "k8s.io/apiserver/pkg/cel/common", "k8s.io/apiserver/pkg/cel/environment", "k8s.io/apiserver/pkg/cel/library", "k8s.io/apiserver/pkg/cel/metrics", "k8s.io/apiserver/pkg/cel/openapi", "k8s.io/apiserver/pkg/features", "k8s.io/apiserver/pkg/util/compatibility", "k8s.io/apiserver/pkg/util/feature", "k8s.io/apiserver/pkg/warning", "k8s.io/cli-runtime/pkg/printers", "k8s.io/client-go/applyconfigurations/admissionregistration/v1", "k8s.io/client-go/applyconfigurations/admissionregistration/v1alpha1", "k8s.io/client-go/applyconfigurations/admissionregistration/v1beta1", "k8s.io/client-go/applyconfigurations/apiserverinternal/v1alpha1", "k8s.io/client-go/applyconfigurations/apps/v1", "k8s.io/client-go/applyconfigurations/apps/v1beta1", "k8s.io/client-go/applyconfigurations/apps/v1beta2", "k8s.io/client-go/applyconfigurations/autoscaling/v1", "k8s.io/client-go/applyconfigurations/autoscaling/v2", "k8s.io/client-go/applyconfigurations/autoscaling/v2beta1", "k8s.io/client-go/applyconfigurations/autoscaling/v2beta2", "k8s.io/client-go/applyconfigurations/batch/v1", "k8s.io/client-go/applyconfigurations/batch/v1beta1", "k8s.io/client-go/applyconfigurations/certificates/v1", "k8s.io/client-go/applyconfigurations/certificates/v1alpha1", "k8s.io/client-go/applyconfigurations/certificates/v1beta1", "k8s.io/client-go/applyconfigurations/coordination/v1", "k8s.io/client-go/applyconfigurations/coordination/v1alpha2", "k8s.io/client-go/applyconfigurations/coordination/v1beta1", "k8s.io/client-go/applyconfigurations/core/v1", "k8s.io/client-go/applyconfigurations/discovery/v1", "k8s.io/client-go/applyconfigurations/discovery/v1beta1", "k8s.io/client-go/applyconfigurations/events/v1", "k8s.io/client-go/applyconfigurations/events/v1beta1", "k8s.io/client-go/applyconfigurations/extensions/v1beta1", "k8s.io/client-go/applyconfigurations/flowcontrol/v1", "k8s.io/client-go/applyconfigurations/flowcontrol/v1beta1", "k8s.io/client-go/applyconfigurations/flowcontrol/v1beta2", "k8s.io/client-go/applyconfigurations/flowcontrol/v1beta3", "k8s.io/client-go/applyconfigurations/meta/v1", "k8s.io/client-go/applyconfigurations/networking/v1", "k8s.io/client-go/applyconfigurations/networking/v1beta1", "k8s.io/client-go/applyconfigurations/node/v1", "k8s.io/client-go/applyconfigurations/node/v1alpha1", "k8s.io/client-go/applyconfigurations/node/v1beta1", "k8s.io/client-go/applyconfigurations/policy/v1", "k8s.io/client-go/applyconfigurations/policy/v1beta1", "k8s.io/client-go/applyconfigurations/rbac/v1", "k8s.io/client-go/applyconfigurations/rbac/v1alpha1", "k8s.io/client-go/applyconfigurations/rbac/v1beta1", "k8s.io/client-go/applyconfigurations/resource/v1", "k8s.io/client-go/applyconfigurations/resource/v1alpha3", "k8s.io/client-go/applyconfigurations/resource/v1beta1", "k8s.io/client-go/applyconfigurations/resource/v1beta2", "k8s.io/client-go/applyconfigurations/scheduling/v1", "k8s.io/client-go/applyconfigurations/scheduling/v1alpha1", "k8s.io/client-go/applyconfigurations/scheduling/v1beta1", "k8s.io/client-go/applyconfigurations/storage/v1", "k8s.io/client-go/applyconfigurations/storage/v1alpha1", "k8s.io/client-go/applyconfigurations/storage/v1beta1", "k8s.io/client-go/applyconfigurations/storagemigration/v1beta1", "k8s.io/client-go/discovery", "k8s.io/client-go/discovery/cached/memory", "k8s.io/client-go/dynamic", "k8s.io/client-go/features", "k8s.io/client-go/gentype", "k8s.io/client-go/informers", "k8s.io/client-go/informers/admissionregistration", "k8s.io/client-go/informers/admissionregistration/v1", "k8s.io/client-go/informers/admissionregistration/v1alpha1", "k8s.io/client-go/informers/admissionregistration/v1beta1", "k8s.io/client-go/informers/apiserverinternal", "k8s.io/client-go/informers/apiserverinternal/v1alpha1", "k8s.io/client-go/informers/apps", "k8s.io/client-go/informers/apps/v1", "k8s.io/client-go/informers/apps/v1beta1", "k8s.io/client-go/informers/apps/v1beta2", "k8s.io/client-go/informers/autoscaling", "k8s.io/client-go/informers/autoscaling/v1", "k8s.io/client-go/informers/autoscaling/v2", "k8s.io/client-go/informers/autoscaling/v2beta1", "k8s.io/client-go/informers/autoscaling/v2beta2", "k8s.io/client-go/informers/batch", "k8s.io/client-go/informers/batch/v1", "k8s.io/client-go/informers/batch/v1beta1", "k8s.io/client-go/informers/certificates", "k8s.io/client-go/informers/certificates/v1", "k8s.io/client-go/informers/certificates/v1alpha1", "k8s.io/client-go/informers/certificates/v1beta1", "k8s.io/client-go/informers/coordination", "k8s.io/client-go/informers/coordination/v1", "k8s.io/client-go/informers/coordination/v1alpha2", "k8s.io/client-go/informers/coordination/v1beta1", "k8s.io/client-go/informers/core", "k8s.io/client-go/informers/core/v1", "k8s.io/client-go/informers/discovery", "k8s.io/client-go/informers/discovery/v1", "k8s.io/client-go/informers/discovery/v1beta1", "k8s.io/client-go/informers/events", "k8s.io/client-go/informers/events/v1", "k8s.io/client-go/informers/events/v1beta1", "k8s.io/client-go/informers/extensions", "k8s.io/client-go/informers/extensions/v1beta1", "k8s.io/client-go/informers/flowcontrol", "k8s.io/client-go/informers/flowcontrol/v1", "k8s.io/client-go/informers/flowcontrol/v1beta1", "k8s.io/client-go/informers/flowcontrol/v1beta2", "k8s.io/client-go/informers/flowcontrol/v1beta3", "k8s.io/client-go/informers/networking", "k8s.io/client-go/informers/networking/v1", "k8s.io/client-go/informers/networking/v1beta1", "k8s.io/client-go/informers/node", "k8s.io/client-go/informers/node/v1", "k8s.io/client-go/informers/node/v1alpha1", "k8s.io/client-go/informers/node/v1beta1", "k8s.io/client-go/informers/policy", "k8s.io/client-go/informers/policy/v1", "k8s.io/client-go/informers/policy/v1beta1", "k8s.io/client-go/informers/rbac", "k8s.io/client-go/informers/rbac/v1", "k8s.io/client-go/informers/rbac/v1alpha1", "k8s.io/client-go/informers/rbac/v1beta1", "k8s.io/client-go/informers/resource", "k8s.io/client-go/informers/resource/v1", "k8s.io/client-go/informers/resource/v1alpha3", "k8s.io/client-go/informers/resource/v1beta1", "k8s.io/client-go/informers/resource/v1beta2", "k8s.io/client-go/informers/scheduling", "k8s.io/client-go/informers/scheduling/v1", "k8s.io/client-go/informers/scheduling/v1alpha1", "k8s.io/client-go/informers/scheduling/v1beta1", "k8s.io/client-go/informers/storage", "k8s.io/client-go/informers/storage/v1", "k8s.io/client-go/informers/storage/v1alpha1", "k8s.io/client-go/informers/storage/v1beta1", "k8s.io/client-go/informers/storagemigration", "k8s.io/client-go/informers/storagemigration/v1beta1", "k8s.io/client-go/kubernetes", "k8s.io/client-go/kubernetes/scheme", "k8s.io/client-go/kubernetes/typed/admissionregistration/v1", "k8s.io/client-go/kubernetes/typed/admissionregistration/v1alpha1", "k8s.io/client-go/kubernetes/typed/admissionregistration/v1beta1", "k8s.io/client-go/kubernetes/typed/apiserverinternal/v1alpha1", "k8s.io/client-go/kubernetes/typed/apps/v1", "k8s.io/client-go/kubernetes/typed/apps/v1beta1", "k8s.io/client-go/kubernetes/typed/apps/v1beta2", "k8s.io/client-go/kubernetes/typed/authentication/v1", "k8s.io/client-go/kubernetes/typed/authentication/v1alpha1", "k8s.io/client-go/kubernetes/typed/authentication/v1beta1", "k8s.io/client-go/kubernetes/typed/authorization/v1", "k8s.io/client-go/kubernetes/typed/authorization/v1beta1", "k8s.io/client-go/kubernetes/typed/autoscaling/v1", "k8s.io/client-go/kubernetes/typed/autoscaling/v2", "k8s.io/client-go/kubernetes/typed/autoscaling/v2beta1", "k8s.io/client-go/kubernetes/typed/autoscaling/v2beta2", "k8s.io/client-go/kubernetes/typed/batch/v1", "k8s.io/client-go/kubernetes/typed/batch/v1beta1", "k8s.io/client-go/kubernetes/typed/certificates/v1", "k8s.io/client-go/kubernetes/typed/certificates/v1alpha1", "k8s.io/client-go/kubernetes/typed/certificates/v1beta1", "k8s.io/client-go/kubernetes/typed/coordination/v1", "k8s.io/client-go/kubernetes/typed/coordination/v1alpha2", "k8s.io/client-go/kubernetes/typed/coordination/v1beta1", "k8s.io/client-go/kubernetes/typed/core/v1", "k8s.io/client-go/kubernetes/typed/discovery/v1", "k8s.io/client-go/kubernetes/typed/discovery/v1beta1", "k8s.io/client-go/kubernetes/typed/events/v1", "k8s.io/client-go/kubernetes/typed/events/v1beta1", "k8s.io/client-go/kubernetes/typed/extensions/v1beta1", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta1", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta2", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta3", "k8s.io/client-go/kubernetes/typed/networking/v1", "k8s.io/client-go/kubernetes/typed/networking/v1beta1", "k8s.io/client-go/kubernetes/typed/node/v1", "k8s.io/client-go/kubernetes/typed/node/v1alpha1", "k8s.io/client-go/kubernetes/typed/node/v1beta1", "k8s.io/client-go/kubernetes/typed/policy/v1", "k8s.io/client-go/kubernetes/typed/policy/v1beta1", "k8s.io/client-go/kubernetes/typed/rbac/v1", "k8s.io/client-go/kubernetes/typed/rbac/v1alpha1", "k8s.io/client-go/kubernetes/typed/rbac/v1beta1", "k8s.io/client-go/kubernetes/typed/resource/v1", "k8s.io/client-go/kubernetes/typed/resource/v1alpha3", "k8s.io/client-go/kubernetes/typed/resource/v1beta1", "k8s.io/client-go/kubernetes/typed/resource/v1beta2", "k8s.io/client-go/kubernetes/typed/scheduling/v1", "k8s.io/client-go/kubernetes/typed/scheduling/v1alpha1", "k8s.io/client-go/kubernetes/typed/scheduling/v1beta1", "k8s.io/client-go/kubernetes/typed/storage/v1", "k8s.io/client-go/kubernetes/typed/storage/v1alpha1", "k8s.io/client-go/kubernetes/typed/storage/v1beta1", "k8s.io/client-go/kubernetes/typed/storagemigration/v1beta1", "k8s.io/client-go/listers", "k8s.io/client-go/listers/admissionregistration/v1", "k8s.io/client-go/listers/admissionregistration/v1alpha1", "k8s.io/client-go/listers/admissionregistration/v1beta1", "k8s.io/client-go/listers/apiserverinternal/v1alpha1", "k8s.io/client-go/listers/apps/v1", "k8s.io/client-go/listers/apps/v1beta1", "k8s.io/client-go/listers/apps/v1beta2", "k8s.io/client-go/listers/autoscaling/v1", "k8s.io/client-go/listers/autoscaling/v2", "k8s.io/client-go/listers/autoscaling/v2beta1", "k8s.io/client-go/listers/autoscaling/v2beta2", "k8s.io/client-go/listers/batch/v1", "k8s.io/client-go/listers/batch/v1beta1", "k8s.io/client-go/listers/certificates/v1", "k8s.io/client-go/listers/certificates/v1alpha1", "k8s.io/client-go/listers/certificates/v1beta1", "k8s.io/client-go/listers/coordination/v1", "k8s.io/client-go/listers/coordination/v1alpha2", "k8s.io/client-go/listers/coordination/v1beta1", "k8s.io/client-go/listers/core/v1", "k8s.io/client-go/listers/discovery/v1", "k8s.io/client-go/listers/discovery/v1beta1", "k8s.io/client-go/listers/events/v1", "k8s.io/client-go/listers/events/v1beta1", "k8s.io/client-go/listers/extensions/v1beta1", "k8s.io/client-go/listers/flowcontrol/v1", "k8s.io/client-go/listers/flowcontrol/v1beta1", "k8s.io/client-go/listers/flowcontrol/v1beta2", "k8s.io/client-go/listers/flowcontrol/v1beta3", "k8s.io/client-go/listers/networking/v1", "k8s.io/client-go/listers/networking/v1beta1", "k8s.io/client-go/listers/node/v1", "k8s.io/client-go/listers/node/v1alpha1", "k8s.io/client-go/listers/node/v1beta1", "k8s.io/client-go/listers/policy/v1", "k8s.io/client-go/listers/policy/v1beta1", "k8s.io/client-go/listers/rbac/v1", "k8s.io/client-go/listers/rbac/v1alpha1", "k8s.io/client-go/listers/rbac/v1beta1", "k8s.io/client-go/listers/resource/v1", "k8s.io/client-go/listers/resource/v1alpha3", "k8s.io/client-go/listers/resource/v1beta1", "k8s.io/client-go/listers/resource/v1beta2", "k8s.io/client-go/listers/scheduling/v1", "k8s.io/client-go/listers/scheduling/v1alpha1", "k8s.io/client-go/listers/scheduling/v1beta1", "k8s.io/client-go/listers/storage/v1", "k8s.io/client-go/listers/storage/v1alpha1", "k8s.io/client-go/listers/storage/v1beta1", "k8s.io/client-go/listers/storagemigration/v1beta1", "k8s.io/client-go/metadata", "k8s.io/client-go/openapi", "k8s.io/client-go/openapi/cached", "k8s.io/client-go/pkg/apis/clientauthentication", "k8s.io/client-go/pkg/apis/clientauthentication/install", "k8s.io/client-go/pkg/apis/clientauthentication/v1", "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1", "k8s.io/client-go/pkg/version", "k8s.io/client-go/plugin/pkg/client/auth", "k8s.io/client-go/plugin/pkg/client/auth/azure", "k8s.io/client-go/plugin/pkg/client/auth/exec", "k8s.io/client-go/plugin/pkg/client/auth/gcp", "k8s.io/client-go/plugin/pkg/client/auth/oidc", "k8s.io/client-go/rest", "k8s.io/client-go/rest/watch", "k8s.io/client-go/restmapper", "k8s.io/client-go/testing", "k8s.io/client-go/third_party/forked/golang/template", "k8s.io/client-go/tools/auth", "k8s.io/client-go/tools/cache", "k8s.io/client-go/tools/cache/synctrack", "k8s.io/client-go/tools/clientcmd", "k8s.io/client-go/tools/clientcmd/api", "k8s.io/client-go/tools/clientcmd/api/latest", "k8s.io/client-go/tools/clientcmd/api/v1", "k8s.io/client-go/tools/events", "k8s.io/client-go/tools/leaderelection", "k8s.io/client-go/tools/leaderelection/resourcelock", "k8s.io/client-go/tools/metrics", "k8s.io/client-go/tools/pager", "k8s.io/client-go/tools/record", "k8s.io/client-go/tools/record/util", "k8s.io/client-go/tools/reference", "k8s.io/client-go/transport", "k8s.io/client-go/util/apply", "k8s.io/client-go/util/cert", "k8s.io/client-go/util/connrotation", "k8s.io/client-go/util/consistencydetector", "k8s.io/client-go/util/flowcontrol", "k8s.io/client-go/util/homedir", "k8s.io/client-go/util/jsonpath", "k8s.io/client-go/util/keyutil", "k8s.io/client-go/util/retry", "k8s.io/client-go/util/watchlist", "k8s.io/client-go/util/workqueue", "k8s.io/component-base/cli/flag", "k8s.io/component-base/compatibility", "k8s.io/component-base/featuregate", "k8s.io/component-base/metrics", "k8s.io/component-base/metrics/legacyregistry", "k8s.io/component-base/metrics/prometheus/compatversion", "k8s.io/component-base/metrics/prometheus/feature", "k8s.io/component-base/metrics/prometheusextension", "k8s.io/component-base/version", "k8s.io/component-base/zpages/features", "k8s.io/klog/v2", "k8s.io/kube-openapi/pkg/cached", "k8s.io/kube-openapi/pkg/common", "k8s.io/kube-openapi/pkg/handler3", "k8s.io/kube-openapi/pkg/schemaconv", "k8s.io/kube-openapi/pkg/spec3", "k8s.io/kube-openapi/pkg/util", "k8s.io/kube-openapi/pkg/util/proto", "k8s.io/kube-openapi/pkg/validation/errors", "k8s.io/kube-openapi/pkg/validation/spec", "k8s.io/kube-openapi/pkg/validation/strfmt", "k8s.io/kube-openapi/pkg/validation/strfmt/bson", "k8s.io/kube-openapi/pkg/validation/validate", "k8s.io/metrics/pkg/apis/metrics", "k8s.io/metrics/pkg/apis/metrics/v1alpha1", "k8s.io/metrics/pkg/apis/metrics/v1beta1", "k8s.io/metrics/pkg/client/clientset/versioned", "k8s.io/metrics/pkg/client/clientset/versioned/scheme", "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1alpha1", "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1", "k8s.io/utils/buffer", "k8s.io/utils/clock", "k8s.io/utils/lru", "k8s.io/utils/net", "k8s.io/utils/ptr", "k8s.io/utils/trace", "sigs.k8s.io/controller-runtime", "sigs.k8s.io/controller-runtime/pkg/builder", "sigs.k8s.io/controller-runtime/pkg/cache", "sigs.k8s.io/controller-runtime/pkg/certwatcher", "sigs.k8s.io/controller-runtime/pkg/certwatcher/metrics", "sigs.k8s.io/controller-runtime/pkg/client", "sigs.k8s.io/controller-runtime/pkg/client/apiutil", "sigs.k8s.io/controller-runtime/pkg/client/config", "sigs.k8s.io/controller-runtime/pkg/cluster", "sigs.k8s.io/controller-runtime/pkg/config", "sigs.k8s.io/controller-runtime/pkg/controller", "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil", "sigs.k8s.io/controller-runtime/pkg/controller/priorityqueue", "sigs.k8s.io/controller-runtime/pkg/conversion", "sigs.k8s.io/controller-runtime/pkg/event", "sigs.k8s.io/controller-runtime/pkg/handler", "sigs.k8s.io/controller-runtime/pkg/healthz", "sigs.k8s.io/controller-runtime/pkg/leaderelection", "sigs.k8s.io/controller-runtime/pkg/log", "sigs.k8s.io/controller-runtime/pkg/log/zap", "sigs.k8s.io/controller-runtime/pkg/manager", "sigs.k8s.io/controller-runtime/pkg/manager/signals", "sigs.k8s.io/controller-runtime/pkg/metrics", "sigs.k8s.io/controller-runtime/pkg/metrics/server", "sigs.k8s.io/controller-runtime/pkg/predicate", "sigs.k8s.io/controller-runtime/pkg/reconcile", "sigs.k8s.io/controller-runtime/pkg/recorder", "sigs.k8s.io/controller-runtime/pkg/scheme", "sigs.k8s.io/controller-runtime/pkg/source", "sigs.k8s.io/controller-runtime/pkg/webhook", "sigs.k8s.io/controller-runtime/pkg/webhook/admission", "sigs.k8s.io/controller-runtime/pkg/webhook/admission/metrics", "sigs.k8s.io/controller-runtime/pkg/webhook/conversion", "sigs.k8s.io/controller-runtime/pkg/webhook/conversion/metrics", "sigs.k8s.io/json", "sigs.k8s.io/randfill", "sigs.k8s.io/randfill/bytesource", "sigs.k8s.io/structured-merge-diff/v6/fieldpath", "sigs.k8s.io/structured-merge-diff/v6/merge", "sigs.k8s.io/structured-merge-diff/v6/schema", "sigs.k8s.io/structured-merge-diff/v6/typed", "sigs.k8s.io/structured-merge-diff/v6/value", "sigs.k8s.io/yaml", "sigs.k8s.io/yaml/kyaml"] +cachePackages = ["al.essio.dev/pkg/shellescape", "cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario.cat/mergo", "github.com/Azure/azure-sdk-for-go/services/preview/containerregistry/runtime/2019-08-15-preview/containerregistry", "github.com/Azure/azure-sdk-for-go/version", "github.com/Azure/go-autorest/autorest", "github.com/Azure/go-autorest/autorest/adal", "github.com/Azure/go-autorest/autorest/azure", "github.com/Azure/go-autorest/autorest/azure/auth", "github.com/Azure/go-autorest/autorest/azure/cli", "github.com/Azure/go-autorest/autorest/date", "github.com/Azure/go-autorest/logger", "github.com/Azure/go-autorest/tracing", "github.com/BurntSushi/toml", "github.com/MakeNowJust/heredoc", "github.com/Masterminds/goutils", "github.com/Masterminds/semver/v3", "github.com/Masterminds/sprig/v3", "github.com/Masterminds/squirrel", "github.com/ProtonMail/go-crypto/bitcurves", "github.com/ProtonMail/go-crypto/brainpool", "github.com/ProtonMail/go-crypto/eax", "github.com/ProtonMail/go-crypto/ocb", "github.com/ProtonMail/go-crypto/openpgp", "github.com/ProtonMail/go-crypto/openpgp/aes/keywrap", "github.com/ProtonMail/go-crypto/openpgp/armor", "github.com/ProtonMail/go-crypto/openpgp/ecdh", "github.com/ProtonMail/go-crypto/openpgp/ecdsa", "github.com/ProtonMail/go-crypto/openpgp/ed25519", "github.com/ProtonMail/go-crypto/openpgp/ed448", "github.com/ProtonMail/go-crypto/openpgp/eddsa", "github.com/ProtonMail/go-crypto/openpgp/elgamal", "github.com/ProtonMail/go-crypto/openpgp/errors", "github.com/ProtonMail/go-crypto/openpgp/packet", "github.com/ProtonMail/go-crypto/openpgp/s2k", "github.com/ProtonMail/go-crypto/openpgp/x25519", "github.com/ProtonMail/go-crypto/openpgp/x448", "github.com/alecthomas/kong", "github.com/antlr4-go/antlr/v4", "github.com/asaskevich/govalidator", "github.com/aws/aws-sdk-go-v2/aws", "github.com/aws/aws-sdk-go-v2/aws/defaults", "github.com/aws/aws-sdk-go-v2/aws/middleware", "github.com/aws/aws-sdk-go-v2/aws/protocol/query", "github.com/aws/aws-sdk-go-v2/aws/protocol/restjson", "github.com/aws/aws-sdk-go-v2/aws/protocol/xml", "github.com/aws/aws-sdk-go-v2/aws/ratelimit", "github.com/aws/aws-sdk-go-v2/aws/retry", "github.com/aws/aws-sdk-go-v2/aws/signer/v4", "github.com/aws/aws-sdk-go-v2/aws/transport/http", "github.com/aws/aws-sdk-go-v2/config", "github.com/aws/aws-sdk-go-v2/credentials", "github.com/aws/aws-sdk-go-v2/credentials/ec2rolecreds", "github.com/aws/aws-sdk-go-v2/credentials/endpointcreds", "github.com/aws/aws-sdk-go-v2/credentials/logincreds", "github.com/aws/aws-sdk-go-v2/credentials/processcreds", "github.com/aws/aws-sdk-go-v2/credentials/ssocreds", "github.com/aws/aws-sdk-go-v2/credentials/stscreds", "github.com/aws/aws-sdk-go-v2/feature/ec2/imds", "github.com/aws/aws-sdk-go-v2/service/ecr", "github.com/aws/aws-sdk-go-v2/service/ecr/types", "github.com/aws/aws-sdk-go-v2/service/ecrpublic", "github.com/aws/aws-sdk-go-v2/service/ecrpublic/types", "github.com/aws/aws-sdk-go-v2/service/signin", "github.com/aws/aws-sdk-go-v2/service/signin/types", "github.com/aws/aws-sdk-go-v2/service/sso", "github.com/aws/aws-sdk-go-v2/service/sso/types", "github.com/aws/aws-sdk-go-v2/service/ssooidc", "github.com/aws/aws-sdk-go-v2/service/ssooidc/types", "github.com/aws/aws-sdk-go-v2/service/sts", "github.com/aws/aws-sdk-go-v2/service/sts/types", "github.com/aws/smithy-go", "github.com/aws/smithy-go/auth", "github.com/aws/smithy-go/auth/bearer", "github.com/aws/smithy-go/context", "github.com/aws/smithy-go/document", "github.com/aws/smithy-go/encoding", "github.com/aws/smithy-go/encoding/httpbinding", "github.com/aws/smithy-go/encoding/json", "github.com/aws/smithy-go/encoding/xml", "github.com/aws/smithy-go/endpoints", "github.com/aws/smithy-go/endpoints/private/rulesfn", "github.com/aws/smithy-go/io", "github.com/aws/smithy-go/logging", "github.com/aws/smithy-go/metrics", "github.com/aws/smithy-go/middleware", "github.com/aws/smithy-go/private/requestcompression", "github.com/aws/smithy-go/ptr", "github.com/aws/smithy-go/rand", "github.com/aws/smithy-go/time", "github.com/aws/smithy-go/tracing", "github.com/aws/smithy-go/transport/http", "github.com/aws/smithy-go/waiter", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/api", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/cache", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/config", "github.com/awslabs/amazon-ecr-credential-helper/ecr-login/version", "github.com/aymanbagabas/go-osc52/v2", "github.com/bahlo/generic-list-go", "github.com/beorn7/perks/quantile", "github.com/blang/semver", "github.com/blang/semver/v4", "github.com/buger/jsonparser", "github.com/cenkalti/backoff/v5", "github.com/cespare/xxhash/v2", "github.com/chai2010/gettext-go", "github.com/chai2010/gettext-go/mo", "github.com/chai2010/gettext-go/plural", "github.com/chai2010/gettext-go/po", "github.com/charmbracelet/bubbles/spinner", "github.com/charmbracelet/bubbletea", "github.com/charmbracelet/colorprofile", "github.com/charmbracelet/lipgloss", "github.com/charmbracelet/x/ansi", "github.com/charmbracelet/x/ansi/parser", "github.com/charmbracelet/x/cellbuf", "github.com/charmbracelet/x/term", "github.com/chrismellard/docker-credential-acr-env/pkg/credhelper", "github.com/chrismellard/docker-credential-acr-env/pkg/registry", "github.com/chrismellard/docker-credential-acr-env/pkg/token", "github.com/clipperhouse/displaywidth", "github.com/clipperhouse/stringish", "github.com/clipperhouse/uax29/v2/graphemes", "github.com/cloudflare/circl/dh/x25519", "github.com/cloudflare/circl/dh/x448", "github.com/cloudflare/circl/ecc/goldilocks", "github.com/cloudflare/circl/math", "github.com/cloudflare/circl/math/fp25519", "github.com/cloudflare/circl/math/fp448", "github.com/cloudflare/circl/math/mlsbset", "github.com/cloudflare/circl/sign", "github.com/cloudflare/circl/sign/ed25519", "github.com/cloudflare/circl/sign/ed448", "github.com/containerd/containerd/archive/compression", "github.com/containerd/containerd/content", "github.com/containerd/containerd/errdefs", "github.com/containerd/containerd/filters", "github.com/containerd/containerd/images", "github.com/containerd/containerd/labels", "github.com/containerd/containerd/pkg/randutil", "github.com/containerd/containerd/remotes", "github.com/containerd/errdefs", "github.com/containerd/errdefs/pkg/errhttp", "github.com/containerd/log", "github.com/containerd/platforms", "github.com/containerd/stargz-snapshotter/estargz", "github.com/containerd/stargz-snapshotter/estargz/errorutil", "github.com/coreos/go-oidc/v3/oidc", "github.com/crossplane/crossplane-runtime/v2/pkg/errors", "github.com/crossplane/crossplane-runtime/v2/pkg/fieldpath", "github.com/crossplane/crossplane-runtime/v2/pkg/logging", "github.com/crossplane/crossplane-runtime/v2/pkg/meta", "github.com/crossplane/crossplane-runtime/v2/pkg/resource", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/claim", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composed", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/composite", "github.com/crossplane/crossplane-runtime/v2/pkg/resource/unstructured/reference", "github.com/crossplane/crossplane-runtime/v2/pkg/test", "github.com/crossplane/crossplane-runtime/v2/pkg/version", "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/examples", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/yaml", "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/signature", "github.com/crossplane/crossplane/apis/v2/apiextensions/v1", "github.com/crossplane/crossplane/apis/v2/apiextensions/v1alpha1", "github.com/crossplane/crossplane/apis/v2/apiextensions/v1beta1", "github.com/crossplane/crossplane/apis/v2/apiextensions/v2", "github.com/crossplane/crossplane/apis/v2/core/v2", "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1", "github.com/crossplane/crossplane/apis/v2/pkg", "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1", "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1alpha1", "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1beta1", "github.com/crossplane/crossplane/apis/v2/pkg/v1", "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1", "github.com/crossplane/function-sdk-go/errors", "github.com/crossplane/function-sdk-go/proto/v1", "github.com/crossplane/function-sdk-go/resource", "github.com/crossplane/function-sdk-go/resource/composed", "github.com/crossplane/function-sdk-go/resource/composite", "github.com/cyberphone/json-canonicalization/go/src/webpki.org/jsoncanonicalizer", "github.com/cyphar/filepath-securejoin", "github.com/davecgh/go-spew/spew", "github.com/digitorus/pkcs7", "github.com/digitorus/timestamp", "github.com/dimchansky/utfbom", "github.com/distribution/reference", "github.com/docker/cli/cli/config", "github.com/docker/cli/cli/config/configfile", "github.com/docker/cli/cli/config/credentials", "github.com/docker/cli/cli/config/memorystore", "github.com/docker/cli/cli/config/types", "github.com/docker/distribution/registry/client/auth/challenge", "github.com/docker/docker-credential-helpers/client", "github.com/docker/docker-credential-helpers/credentials", "github.com/docker/docker/api", "github.com/docker/docker/api/types", "github.com/docker/docker/api/types/blkiodev", "github.com/docker/docker/api/types/build", "github.com/docker/docker/api/types/checkpoint", "github.com/docker/docker/api/types/common", "github.com/docker/docker/api/types/container", "github.com/docker/docker/api/types/events", "github.com/docker/docker/api/types/filters", "github.com/docker/docker/api/types/image", "github.com/docker/docker/api/types/mount", "github.com/docker/docker/api/types/network", "github.com/docker/docker/api/types/registry", "github.com/docker/docker/api/types/storage", "github.com/docker/docker/api/types/strslice", "github.com/docker/docker/api/types/swarm", "github.com/docker/docker/api/types/swarm/runtime", "github.com/docker/docker/api/types/system", "github.com/docker/docker/api/types/time", "github.com/docker/docker/api/types/versions", "github.com/docker/docker/api/types/volume", "github.com/docker/docker/client", "github.com/docker/docker/pkg/stdcopy", "github.com/docker/go-connections/nat", "github.com/docker/go-connections/sockets", "github.com/docker/go-connections/tlsconfig", "github.com/docker/go-units", "github.com/dprotaso/go-yit", "github.com/dustin/go-humanize", "github.com/emicklei/dot", "github.com/emicklei/go-restful/v3", "github.com/emicklei/go-restful/v3/log", "github.com/emirpasic/gods/containers", "github.com/emirpasic/gods/lists", "github.com/emirpasic/gods/lists/arraylist", "github.com/emirpasic/gods/trees", "github.com/emirpasic/gods/trees/binaryheap", "github.com/emirpasic/gods/utils", "github.com/evanphx/json-patch", "github.com/evanphx/json-patch/v5", "github.com/exponent-io/jsonpath", "github.com/fatih/color", "github.com/felixge/httpsnoop", "github.com/fsnotify/fsnotify", "github.com/fxamacker/cbor/v2", "github.com/getkin/kin-openapi/openapi3", "github.com/go-chi/chi/v5", "github.com/go-chi/chi/v5/middleware", "github.com/go-errors/errors", "github.com/go-git/gcfg", "github.com/go-git/gcfg/scanner", "github.com/go-git/gcfg/token", "github.com/go-git/gcfg/types", "github.com/go-git/go-billy/v5", "github.com/go-git/go-billy/v5/helper/chroot", "github.com/go-git/go-billy/v5/helper/iofs", "github.com/go-git/go-billy/v5/helper/polyfill", "github.com/go-git/go-billy/v5/memfs", "github.com/go-git/go-billy/v5/osfs", "github.com/go-git/go-billy/v5/util", "github.com/go-git/go-git/v5", "github.com/go-git/go-git/v5/config", "github.com/go-git/go-git/v5/plumbing", "github.com/go-git/go-git/v5/plumbing/cache", "github.com/go-git/go-git/v5/plumbing/color", "github.com/go-git/go-git/v5/plumbing/filemode", "github.com/go-git/go-git/v5/plumbing/format/config", "github.com/go-git/go-git/v5/plumbing/format/diff", "github.com/go-git/go-git/v5/plumbing/format/gitignore", "github.com/go-git/go-git/v5/plumbing/format/idxfile", "github.com/go-git/go-git/v5/plumbing/format/index", "github.com/go-git/go-git/v5/plumbing/format/objfile", "github.com/go-git/go-git/v5/plumbing/format/packfile", "github.com/go-git/go-git/v5/plumbing/format/pktline", "github.com/go-git/go-git/v5/plumbing/hash", "github.com/go-git/go-git/v5/plumbing/object", "github.com/go-git/go-git/v5/plumbing/protocol/packp", "github.com/go-git/go-git/v5/plumbing/protocol/packp/capability", "github.com/go-git/go-git/v5/plumbing/protocol/packp/sideband", "github.com/go-git/go-git/v5/plumbing/revlist", "github.com/go-git/go-git/v5/plumbing/storer", "github.com/go-git/go-git/v5/plumbing/transport", "github.com/go-git/go-git/v5/plumbing/transport/client", "github.com/go-git/go-git/v5/plumbing/transport/file", "github.com/go-git/go-git/v5/plumbing/transport/git", "github.com/go-git/go-git/v5/plumbing/transport/http", "github.com/go-git/go-git/v5/plumbing/transport/server", "github.com/go-git/go-git/v5/plumbing/transport/ssh", "github.com/go-git/go-git/v5/storage", "github.com/go-git/go-git/v5/storage/filesystem", "github.com/go-git/go-git/v5/storage/filesystem/dotgit", "github.com/go-git/go-git/v5/storage/memory", "github.com/go-git/go-git/v5/utils/binary", "github.com/go-git/go-git/v5/utils/diff", "github.com/go-git/go-git/v5/utils/ioutil", "github.com/go-git/go-git/v5/utils/merkletrie", "github.com/go-git/go-git/v5/utils/merkletrie/filesystem", "github.com/go-git/go-git/v5/utils/merkletrie/index", "github.com/go-git/go-git/v5/utils/merkletrie/noder", "github.com/go-git/go-git/v5/utils/sync", "github.com/go-git/go-git/v5/utils/trace", "github.com/go-gorp/gorp/v3", "github.com/go-jose/go-jose/v4", "github.com/go-jose/go-jose/v4/cipher", "github.com/go-jose/go-jose/v4/json", "github.com/go-json-experiment/json", "github.com/go-json-experiment/json/jsontext", "github.com/go-logr/logr", "github.com/go-logr/logr/funcr", "github.com/go-logr/logr/slogr", "github.com/go-logr/stdr", "github.com/go-logr/zapr", "github.com/go-openapi/analysis", "github.com/go-openapi/errors", "github.com/go-openapi/jsonpointer", "github.com/go-openapi/jsonreference", "github.com/go-openapi/loads", "github.com/go-openapi/runtime", "github.com/go-openapi/runtime/client", "github.com/go-openapi/runtime/logger", "github.com/go-openapi/runtime/middleware", "github.com/go-openapi/runtime/middleware/denco", "github.com/go-openapi/runtime/middleware/header", "github.com/go-openapi/runtime/middleware/untyped", "github.com/go-openapi/runtime/security", "github.com/go-openapi/runtime/yamlpc", "github.com/go-openapi/spec", "github.com/go-openapi/strfmt", "github.com/go-openapi/swag", "github.com/go-openapi/swag/cmdutils", "github.com/go-openapi/swag/conv", "github.com/go-openapi/swag/fileutils", "github.com/go-openapi/swag/jsonname", "github.com/go-openapi/swag/jsonutils", "github.com/go-openapi/swag/jsonutils/adapters", "github.com/go-openapi/swag/jsonutils/adapters/ifaces", "github.com/go-openapi/swag/jsonutils/adapters/stdlib/json", "github.com/go-openapi/swag/loading", "github.com/go-openapi/swag/mangling", "github.com/go-openapi/swag/netutils", "github.com/go-openapi/swag/stringutils", "github.com/go-openapi/swag/typeutils", "github.com/go-openapi/swag/yamlutils", "github.com/go-openapi/validate", "github.com/go-viper/mapstructure/v2", "github.com/gobuffalo/flect", "github.com/gobwas/glob", "github.com/gobwas/glob/compiler", "github.com/gobwas/glob/match", "github.com/gobwas/glob/syntax", "github.com/gobwas/glob/syntax/ast", "github.com/gobwas/glob/syntax/lexer", "github.com/gobwas/glob/util/runes", "github.com/gobwas/glob/util/strings", "github.com/golang-jwt/jwt/v4", "github.com/golang/groupcache/lru", "github.com/golang/snappy", "github.com/google/btree", "github.com/google/cel-go/cel", "github.com/google/cel-go/checker", "github.com/google/cel-go/checker/decls", "github.com/google/cel-go/common", "github.com/google/cel-go/common/ast", "github.com/google/cel-go/common/containers", "github.com/google/cel-go/common/debug", "github.com/google/cel-go/common/decls", "github.com/google/cel-go/common/env", "github.com/google/cel-go/common/functions", "github.com/google/cel-go/common/operators", "github.com/google/cel-go/common/overloads", "github.com/google/cel-go/common/runes", "github.com/google/cel-go/common/stdlib", "github.com/google/cel-go/common/types", "github.com/google/cel-go/common/types/pb", "github.com/google/cel-go/common/types/ref", "github.com/google/cel-go/common/types/traits", "github.com/google/cel-go/ext", "github.com/google/cel-go/interpreter", "github.com/google/cel-go/interpreter/functions", "github.com/google/cel-go/parser", "github.com/google/cel-go/parser/gen", "github.com/google/certificate-transparency-go", "github.com/google/certificate-transparency-go/asn1", "github.com/google/certificate-transparency-go/client", "github.com/google/certificate-transparency-go/client/configpb", "github.com/google/certificate-transparency-go/ctutil", "github.com/google/certificate-transparency-go/gossip/minimal/x509ext", "github.com/google/certificate-transparency-go/jsonclient", "github.com/google/certificate-transparency-go/loglist3", "github.com/google/certificate-transparency-go/tls", "github.com/google/certificate-transparency-go/x509", "github.com/google/certificate-transparency-go/x509/pkix", "github.com/google/certificate-transparency-go/x509util", "github.com/google/gnostic-models/compiler", "github.com/google/gnostic-models/extensions", "github.com/google/gnostic-models/jsonschema", "github.com/google/gnostic-models/openapiv2", "github.com/google/gnostic-models/openapiv3", "github.com/google/go-cmp/cmp", "github.com/google/go-cmp/cmp/cmpopts", "github.com/google/go-containerregistry/pkg/authn", "github.com/google/go-containerregistry/pkg/authn/k8schain", "github.com/google/go-containerregistry/pkg/authn/kubernetes", "github.com/google/go-containerregistry/pkg/compression", "github.com/google/go-containerregistry/pkg/crane", "github.com/google/go-containerregistry/pkg/legacy", "github.com/google/go-containerregistry/pkg/legacy/tarball", "github.com/google/go-containerregistry/pkg/logs", "github.com/google/go-containerregistry/pkg/name", "github.com/google/go-containerregistry/pkg/v1", "github.com/google/go-containerregistry/pkg/v1/daemon", "github.com/google/go-containerregistry/pkg/v1/empty", "github.com/google/go-containerregistry/pkg/v1/google", "github.com/google/go-containerregistry/pkg/v1/layout", "github.com/google/go-containerregistry/pkg/v1/match", "github.com/google/go-containerregistry/pkg/v1/mutate", "github.com/google/go-containerregistry/pkg/v1/partial", "github.com/google/go-containerregistry/pkg/v1/random", "github.com/google/go-containerregistry/pkg/v1/remote", "github.com/google/go-containerregistry/pkg/v1/remote/transport", "github.com/google/go-containerregistry/pkg/v1/static", "github.com/google/go-containerregistry/pkg/v1/stream", "github.com/google/go-containerregistry/pkg/v1/tarball", "github.com/google/go-containerregistry/pkg/v1/types", "github.com/google/ko/pkg/build", "github.com/google/ko/pkg/caps", "github.com/google/uuid", "github.com/gosuri/uitable", "github.com/gosuri/uitable/util/strutil", "github.com/gosuri/uitable/util/wordwrap", "github.com/gregjones/httpcache", "github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-openapiv2/options", "github.com/grpc-ecosystem/grpc-gateway/v2/runtime", "github.com/grpc-ecosystem/grpc-gateway/v2/utilities", "github.com/hashicorp/errwrap", "github.com/hashicorp/go-cleanhttp", "github.com/hashicorp/go-multierror", "github.com/hashicorp/go-retryablehttp", "github.com/huandu/xstrings", "github.com/in-toto/attestation/go/v1", "github.com/in-toto/in-toto-golang/in_toto", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/common", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.1", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v0.2", "github.com/in-toto/in-toto-golang/in_toto/slsa_provenance/v1", "github.com/invopop/jsonschema", "github.com/jbenet/go-context/io", "github.com/jedisct1/go-minisign", "github.com/jmoiron/sqlx", "github.com/jmoiron/sqlx/reflectx", "github.com/josharian/intern", "github.com/json-iterator/go", "github.com/kevinburke/ssh_config", "github.com/klauspost/compress", "github.com/klauspost/compress/fse", "github.com/klauspost/compress/huff0", "github.com/klauspost/compress/zstd", "github.com/kubernetes-sigs/kro/pkg/graph/dag", "github.com/kubernetes-sigs/kro/pkg/simpleschema", "github.com/kubernetes-sigs/kro/pkg/simpleschema/types", "github.com/lann/builder", "github.com/lann/ps", "github.com/letsencrypt/boulder/core", "github.com/letsencrypt/boulder/core/proto", "github.com/letsencrypt/boulder/goodkey", "github.com/letsencrypt/boulder/identifier", "github.com/letsencrypt/boulder/probs", "github.com/letsencrypt/boulder/revocation", "github.com/lib/pq", "github.com/lib/pq/oid", "github.com/lib/pq/scram", "github.com/liggitt/tabwriter", "github.com/lucasb-eyer/go-colorful", "github.com/mailru/easyjson/jlexer", "github.com/mattn/go-colorable", "github.com/mattn/go-isatty", "github.com/mattn/go-runewidth", "github.com/mitchellh/copystructure", "github.com/mitchellh/go-homedir", "github.com/mitchellh/go-wordwrap", "github.com/mitchellh/reflectwalk", "github.com/moby/docker-image-spec/specs-go/v1", "github.com/moby/term", "github.com/modern-go/concurrent", "github.com/modern-go/reflect2", "github.com/mohae/deepcopy", "github.com/monochromegane/go-gitignore", "github.com/muesli/ansi", "github.com/muesli/ansi/compressor", "github.com/muesli/cancelreader", "github.com/muesli/termenv", "github.com/munnerz/goautoneg", "github.com/nozzle/throttler", "github.com/oapi-codegen/oapi-codegen/v2/pkg/codegen", "github.com/oapi-codegen/oapi-codegen/v2/pkg/util", "github.com/oasdiff/yaml", "github.com/oasdiff/yaml3", "github.com/oklog/ulid/v2", "github.com/opencontainers/go-digest", "github.com/opencontainers/image-spec/specs-go", "github.com/opencontainers/image-spec/specs-go/v1", "github.com/pb33f/ordered-map/v2", "github.com/pelletier/go-toml", "github.com/perimeterx/marshmallow", "github.com/peterbourgon/diskv", "github.com/pjbgf/sha1cd", "github.com/pjbgf/sha1cd/ubc", "github.com/pkg/browser", "github.com/pkg/errors", "github.com/pmezard/go-difflib/difflib", "github.com/posener/complete", "github.com/posener/complete/cmd", "github.com/posener/complete/cmd/install", "github.com/prometheus/client_golang/prometheus", "github.com/prometheus/client_golang/prometheus/collectors", "github.com/prometheus/client_golang/prometheus/promhttp", "github.com/prometheus/client_model/go", "github.com/prometheus/common/expfmt", "github.com/prometheus/common/model", "github.com/prometheus/procfs", "github.com/rivo/uniseg", "github.com/riywo/loginshell", "github.com/rubenv/sql-migrate", "github.com/rubenv/sql-migrate/sqlparse", "github.com/russross/blackfriday/v2", "github.com/santhosh-tekuri/jsonschema/v6", "github.com/santhosh-tekuri/jsonschema/v6/kind", "github.com/sassoftware/relic/lib/pkcs7", "github.com/sassoftware/relic/lib/x509tools", "github.com/secure-systems-lab/go-securesystemslib/cjson", "github.com/secure-systems-lab/go-securesystemslib/dsse", "github.com/secure-systems-lab/go-securesystemslib/encrypted", "github.com/secure-systems-lab/go-securesystemslib/signerverifier", "github.com/sergi/go-diff/diffmatchpatch", "github.com/shibumi/go-pathspec", "github.com/shopspring/decimal", "github.com/sigstore/cosign/v2/pkg/cosign/bundle", "github.com/sigstore/cosign/v2/pkg/cosign/env", "github.com/sigstore/cosign/v2/pkg/oci", "github.com/sigstore/cosign/v2/pkg/oci/empty", "github.com/sigstore/cosign/v2/pkg/oci/mutate", "github.com/sigstore/cosign/v2/pkg/oci/signed", "github.com/sigstore/cosign/v2/pkg/oci/static", "github.com/sigstore/cosign/v2/pkg/types", "github.com/sigstore/cosign/v3/pkg/blob", "github.com/sigstore/cosign/v3/pkg/cosign", "github.com/sigstore/cosign/v3/pkg/cosign/attestation", "github.com/sigstore/cosign/v3/pkg/cosign/bundle", "github.com/sigstore/cosign/v3/pkg/cosign/env", "github.com/sigstore/cosign/v3/pkg/cosign/fulcioverifier/ctutil", "github.com/sigstore/cosign/v3/pkg/oci", "github.com/sigstore/cosign/v3/pkg/oci/empty", "github.com/sigstore/cosign/v3/pkg/oci/layout", "github.com/sigstore/cosign/v3/pkg/oci/remote", "github.com/sigstore/cosign/v3/pkg/oci/signed", "github.com/sigstore/cosign/v3/pkg/oci/static", "github.com/sigstore/cosign/v3/pkg/types", "github.com/sigstore/protobuf-specs/gen/pb-go/bundle/v1", "github.com/sigstore/protobuf-specs/gen/pb-go/common/v1", "github.com/sigstore/protobuf-specs/gen/pb-go/dsse", "github.com/sigstore/protobuf-specs/gen/pb-go/rekor/v1", "github.com/sigstore/protobuf-specs/gen/pb-go/trustroot/v1", "github.com/sigstore/rekor-tiles/v2/pkg/client", "github.com/sigstore/rekor-tiles/v2/pkg/client/write", "github.com/sigstore/rekor-tiles/v2/pkg/generated/protobuf", "github.com/sigstore/rekor-tiles/v2/pkg/note", "github.com/sigstore/rekor-tiles/v2/pkg/types/verifier", "github.com/sigstore/rekor-tiles/v2/pkg/verify", "github.com/sigstore/rekor/pkg/client", "github.com/sigstore/rekor/pkg/generated/client", "github.com/sigstore/rekor/pkg/generated/client/entries", "github.com/sigstore/rekor/pkg/generated/client/index", "github.com/sigstore/rekor/pkg/generated/client/pubkey", "github.com/sigstore/rekor/pkg/generated/client/tlog", "github.com/sigstore/rekor/pkg/generated/models", "github.com/sigstore/rekor/pkg/log", "github.com/sigstore/rekor/pkg/pki", "github.com/sigstore/rekor/pkg/pki/identity", "github.com/sigstore/rekor/pkg/pki/minisign", "github.com/sigstore/rekor/pkg/pki/pgp", "github.com/sigstore/rekor/pkg/pki/pkcs7", "github.com/sigstore/rekor/pkg/pki/pkitypes", "github.com/sigstore/rekor/pkg/pki/ssh", "github.com/sigstore/rekor/pkg/pki/tuf", "github.com/sigstore/rekor/pkg/pki/x509", "github.com/sigstore/rekor/pkg/tle", "github.com/sigstore/rekor/pkg/types", "github.com/sigstore/rekor/pkg/types/dsse", "github.com/sigstore/rekor/pkg/types/dsse/v0.0.1", "github.com/sigstore/rekor/pkg/types/hashedrekord", "github.com/sigstore/rekor/pkg/types/hashedrekord/v0.0.1", "github.com/sigstore/rekor/pkg/types/intoto", "github.com/sigstore/rekor/pkg/types/intoto/v0.0.1", "github.com/sigstore/rekor/pkg/types/intoto/v0.0.2", "github.com/sigstore/rekor/pkg/types/rekord", "github.com/sigstore/rekor/pkg/types/rekord/v0.0.1", "github.com/sigstore/rekor/pkg/util", "github.com/sigstore/rekor/pkg/verify", "github.com/sigstore/sigstore-go/pkg/bundle", "github.com/sigstore/sigstore-go/pkg/fulcio/certificate", "github.com/sigstore/sigstore-go/pkg/root", "github.com/sigstore/sigstore-go/pkg/sign", "github.com/sigstore/sigstore-go/pkg/tlog", "github.com/sigstore/sigstore-go/pkg/tuf", "github.com/sigstore/sigstore-go/pkg/util", "github.com/sigstore/sigstore-go/pkg/verify", "github.com/sigstore/sigstore/pkg/cryptoutils", "github.com/sigstore/sigstore/pkg/cryptoutils/goodkey", "github.com/sigstore/sigstore/pkg/fulcioroots", "github.com/sigstore/sigstore/pkg/oauth", "github.com/sigstore/sigstore/pkg/oauthflow", "github.com/sigstore/sigstore/pkg/signature", "github.com/sigstore/sigstore/pkg/signature/dsse", "github.com/sigstore/sigstore/pkg/signature/options", "github.com/sigstore/sigstore/pkg/signature/payload", "github.com/sigstore/sigstore/pkg/tuf", "github.com/sigstore/timestamp-authority/v2/pkg/verification", "github.com/sirupsen/logrus", "github.com/skeema/knownhosts", "github.com/speakeasy-api/jsonpath/pkg/jsonpath", "github.com/speakeasy-api/jsonpath/pkg/jsonpath/config", "github.com/speakeasy-api/jsonpath/pkg/jsonpath/token", "github.com/speakeasy-api/openapi/overlay", "github.com/speakeasy-api/openapi/overlay/loader", "github.com/spf13/afero", "github.com/spf13/afero/mem", "github.com/spf13/afero/tarfs", "github.com/spf13/cast", "github.com/spf13/cobra", "github.com/spf13/pflag", "github.com/syndtr/goleveldb/leveldb", "github.com/syndtr/goleveldb/leveldb/cache", "github.com/syndtr/goleveldb/leveldb/comparer", "github.com/syndtr/goleveldb/leveldb/errors", "github.com/syndtr/goleveldb/leveldb/filter", "github.com/syndtr/goleveldb/leveldb/iterator", "github.com/syndtr/goleveldb/leveldb/journal", "github.com/syndtr/goleveldb/leveldb/memdb", "github.com/syndtr/goleveldb/leveldb/opt", "github.com/syndtr/goleveldb/leveldb/storage", "github.com/syndtr/goleveldb/leveldb/table", "github.com/syndtr/goleveldb/leveldb/util", "github.com/theupdateframework/go-tuf", "github.com/theupdateframework/go-tuf/client", "github.com/theupdateframework/go-tuf/client/leveldbstore", "github.com/theupdateframework/go-tuf/data", "github.com/theupdateframework/go-tuf/pkg/keys", "github.com/theupdateframework/go-tuf/pkg/targets", "github.com/theupdateframework/go-tuf/sign", "github.com/theupdateframework/go-tuf/util", "github.com/theupdateframework/go-tuf/v2/metadata", "github.com/theupdateframework/go-tuf/v2/metadata/config", "github.com/theupdateframework/go-tuf/v2/metadata/fetcher", "github.com/theupdateframework/go-tuf/v2/metadata/trustedmetadata", "github.com/theupdateframework/go-tuf/v2/metadata/updater", "github.com/theupdateframework/go-tuf/verify", "github.com/titanous/rocacheck", "github.com/transparency-dev/formats/log", "github.com/transparency-dev/merkle", "github.com/transparency-dev/merkle/compact", "github.com/transparency-dev/merkle/proof", "github.com/transparency-dev/merkle/rfc6962", "github.com/vbatts/tar-split/archive/tar", "github.com/vmware-labs/yaml-jsonpath/pkg/yamlpath", "github.com/willabides/kongplete", "github.com/woodsbury/decimal128", "github.com/x448/float16", "github.com/xanzy/ssh-agent", "github.com/xlab/treeprint", "github.com/xo/terminfo", "go.opentelemetry.io/auto/sdk", "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp", "go.opentelemetry.io/otel", "go.opentelemetry.io/otel/attribute", "go.opentelemetry.io/otel/baggage", "go.opentelemetry.io/otel/codes", "go.opentelemetry.io/otel/exporters/otlp/otlptrace", "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc", "go.opentelemetry.io/otel/metric", "go.opentelemetry.io/otel/metric/embedded", "go.opentelemetry.io/otel/metric/noop", "go.opentelemetry.io/otel/propagation", "go.opentelemetry.io/otel/sdk", "go.opentelemetry.io/otel/sdk/instrumentation", "go.opentelemetry.io/otel/sdk/resource", "go.opentelemetry.io/otel/sdk/trace", "go.opentelemetry.io/otel/semconv/v1.17.0", "go.opentelemetry.io/otel/semconv/v1.37.0", "go.opentelemetry.io/otel/semconv/v1.37.0/otelconv", "go.opentelemetry.io/otel/semconv/v1.40.0", "go.opentelemetry.io/otel/semconv/v1.40.0/httpconv", "go.opentelemetry.io/otel/semconv/v1.40.0/otelconv", "go.opentelemetry.io/otel/trace", "go.opentelemetry.io/otel/trace/embedded", "go.opentelemetry.io/otel/trace/noop", "go.opentelemetry.io/proto/otlp/collector/trace/v1", "go.opentelemetry.io/proto/otlp/common/v1", "go.opentelemetry.io/proto/otlp/resource/v1", "go.opentelemetry.io/proto/otlp/trace/v1", "go.uber.org/multierr", "go.uber.org/zap", "go.uber.org/zap/buffer", "go.uber.org/zap/zapcore", "go.yaml.in/yaml/v2", "go.yaml.in/yaml/v3", "go.yaml.in/yaml/v4", "golang.org/x/crypto/argon2", "golang.org/x/crypto/bcrypt", "golang.org/x/crypto/blake2b", "golang.org/x/crypto/blowfish", "golang.org/x/crypto/cast5", "golang.org/x/crypto/chacha20", "golang.org/x/crypto/cryptobyte", "golang.org/x/crypto/cryptobyte/asn1", "golang.org/x/crypto/curve25519", "golang.org/x/crypto/ed25519", "golang.org/x/crypto/hkdf", "golang.org/x/crypto/nacl/secretbox", "golang.org/x/crypto/ocsp", "golang.org/x/crypto/openpgp", "golang.org/x/crypto/openpgp/armor", "golang.org/x/crypto/openpgp/clearsign", "golang.org/x/crypto/openpgp/elgamal", "golang.org/x/crypto/openpgp/errors", "golang.org/x/crypto/openpgp/packet", "golang.org/x/crypto/openpgp/s2k", "golang.org/x/crypto/pbkdf2", "golang.org/x/crypto/pkcs12", "golang.org/x/crypto/salsa20/salsa", "golang.org/x/crypto/scrypt", "golang.org/x/crypto/sha3", "golang.org/x/crypto/ssh", "golang.org/x/crypto/ssh/agent", "golang.org/x/crypto/ssh/knownhosts", "golang.org/x/crypto/ssh/terminal", "golang.org/x/exp/slices", "golang.org/x/mod/modfile", "golang.org/x/mod/module", "golang.org/x/mod/semver", "golang.org/x/mod/sumdb/note", "golang.org/x/net/context", "golang.org/x/net/http/httpguts", "golang.org/x/net/http2", "golang.org/x/net/http2/hpack", "golang.org/x/net/idna", "golang.org/x/net/proxy", "golang.org/x/net/trace", "golang.org/x/net/websocket", "golang.org/x/oauth2", "golang.org/x/oauth2/authhandler", "golang.org/x/oauth2/google", "golang.org/x/oauth2/google/externalaccount", "golang.org/x/oauth2/jws", "golang.org/x/oauth2/jwt", "golang.org/x/sync/errgroup", "golang.org/x/sync/semaphore", "golang.org/x/sync/singleflight", "golang.org/x/sys/cpu", "golang.org/x/sys/execabs", "golang.org/x/sys/unix", "golang.org/x/term", "golang.org/x/text/cases", "golang.org/x/text/encoding", "golang.org/x/text/encoding/unicode", "golang.org/x/text/feature/plural", "golang.org/x/text/language", "golang.org/x/text/message", "golang.org/x/text/message/catalog", "golang.org/x/text/runes", "golang.org/x/text/secure/bidirule", "golang.org/x/text/transform", "golang.org/x/text/unicode/bidi", "golang.org/x/text/unicode/norm", "golang.org/x/time/rate", "golang.org/x/tools/go/ast/astutil", "golang.org/x/tools/go/ast/edge", "golang.org/x/tools/go/ast/inspector", "golang.org/x/tools/go/gcexportdata", "golang.org/x/tools/go/packages", "golang.org/x/tools/go/types/objectpath", "golang.org/x/tools/go/types/typeutil", "golang.org/x/tools/imports", "gomodules.xyz/jsonpatch/v2", "google.golang.org/genproto/googleapis/api", "google.golang.org/genproto/googleapis/api/annotations", "google.golang.org/genproto/googleapis/api/expr/v1alpha1", "google.golang.org/genproto/googleapis/api/httpbody", "google.golang.org/genproto/googleapis/rpc/errdetails", "google.golang.org/genproto/googleapis/rpc/status", "google.golang.org/grpc", "google.golang.org/grpc/attributes", "google.golang.org/grpc/backoff", "google.golang.org/grpc/balancer", "google.golang.org/grpc/balancer/base", "google.golang.org/grpc/balancer/endpointsharding", "google.golang.org/grpc/balancer/grpclb/state", "google.golang.org/grpc/balancer/pickfirst", "google.golang.org/grpc/balancer/roundrobin", "google.golang.org/grpc/binarylog/grpc_binarylog_v1", "google.golang.org/grpc/channelz", "google.golang.org/grpc/codes", "google.golang.org/grpc/connectivity", "google.golang.org/grpc/credentials", "google.golang.org/grpc/credentials/insecure", "google.golang.org/grpc/encoding", "google.golang.org/grpc/encoding/gzip", "google.golang.org/grpc/encoding/proto", "google.golang.org/grpc/experimental/stats", "google.golang.org/grpc/grpclog", "google.golang.org/grpc/health/grpc_health_v1", "google.golang.org/grpc/keepalive", "google.golang.org/grpc/mem", "google.golang.org/grpc/metadata", "google.golang.org/grpc/peer", "google.golang.org/grpc/resolver", "google.golang.org/grpc/resolver/dns", "google.golang.org/grpc/serviceconfig", "google.golang.org/grpc/stats", "google.golang.org/grpc/status", "google.golang.org/grpc/tap", "google.golang.org/protobuf/encoding/protodelim", "google.golang.org/protobuf/encoding/protojson", "google.golang.org/protobuf/encoding/prototext", "google.golang.org/protobuf/encoding/protowire", "google.golang.org/protobuf/proto", "google.golang.org/protobuf/protoadapt", "google.golang.org/protobuf/reflect/protodesc", "google.golang.org/protobuf/reflect/protoreflect", "google.golang.org/protobuf/reflect/protoregistry", "google.golang.org/protobuf/runtime/protoiface", "google.golang.org/protobuf/runtime/protoimpl", "google.golang.org/protobuf/testing/protocmp", "google.golang.org/protobuf/types/descriptorpb", "google.golang.org/protobuf/types/dynamicpb", "google.golang.org/protobuf/types/gofeaturespb", "google.golang.org/protobuf/types/known/anypb", "google.golang.org/protobuf/types/known/durationpb", "google.golang.org/protobuf/types/known/emptypb", "google.golang.org/protobuf/types/known/fieldmaskpb", "google.golang.org/protobuf/types/known/structpb", "google.golang.org/protobuf/types/known/timestamppb", "google.golang.org/protobuf/types/known/wrapperspb", "gopkg.in/evanphx/json-patch.v4", "gopkg.in/inf.v0", "gopkg.in/warnings.v0", "gopkg.in/yaml.v3", "helm.sh/helm/v3/pkg/action", "helm.sh/helm/v3/pkg/chart", "helm.sh/helm/v3/pkg/chart/loader", "helm.sh/helm/v3/pkg/chartutil", "helm.sh/helm/v3/pkg/cli", "helm.sh/helm/v3/pkg/downloader", "helm.sh/helm/v3/pkg/engine", "helm.sh/helm/v3/pkg/getter", "helm.sh/helm/v3/pkg/helmpath", "helm.sh/helm/v3/pkg/helmpath/xdg", "helm.sh/helm/v3/pkg/ignore", "helm.sh/helm/v3/pkg/kube", "helm.sh/helm/v3/pkg/kube/fake", "helm.sh/helm/v3/pkg/lint", "helm.sh/helm/v3/pkg/lint/rules", "helm.sh/helm/v3/pkg/lint/support", "helm.sh/helm/v3/pkg/plugin", "helm.sh/helm/v3/pkg/postrender", "helm.sh/helm/v3/pkg/provenance", "helm.sh/helm/v3/pkg/pusher", "helm.sh/helm/v3/pkg/registry", "helm.sh/helm/v3/pkg/release", "helm.sh/helm/v3/pkg/releaseutil", "helm.sh/helm/v3/pkg/repo", "helm.sh/helm/v3/pkg/storage", "helm.sh/helm/v3/pkg/storage/driver", "helm.sh/helm/v3/pkg/time", "helm.sh/helm/v3/pkg/time/ctime", "helm.sh/helm/v3/pkg/uploader", "k8s.io/api/admission/v1", "k8s.io/api/admission/v1beta1", "k8s.io/api/admissionregistration/v1", "k8s.io/api/admissionregistration/v1alpha1", "k8s.io/api/admissionregistration/v1beta1", "k8s.io/api/apidiscovery/v2", "k8s.io/api/apidiscovery/v2beta1", "k8s.io/api/apiserverinternal/v1alpha1", "k8s.io/api/apps/v1", "k8s.io/api/apps/v1beta1", "k8s.io/api/apps/v1beta2", "k8s.io/api/authentication/v1", "k8s.io/api/authentication/v1alpha1", "k8s.io/api/authentication/v1beta1", "k8s.io/api/authorization/v1", "k8s.io/api/authorization/v1beta1", "k8s.io/api/autoscaling/v1", "k8s.io/api/autoscaling/v2", "k8s.io/api/autoscaling/v2beta1", "k8s.io/api/autoscaling/v2beta2", "k8s.io/api/batch/v1", "k8s.io/api/batch/v1beta1", "k8s.io/api/certificates/v1", "k8s.io/api/certificates/v1alpha1", "k8s.io/api/certificates/v1beta1", "k8s.io/api/coordination/v1", "k8s.io/api/coordination/v1alpha2", "k8s.io/api/coordination/v1beta1", "k8s.io/api/core/v1", "k8s.io/api/discovery/v1", "k8s.io/api/discovery/v1beta1", "k8s.io/api/events/v1", "k8s.io/api/events/v1beta1", "k8s.io/api/extensions/v1beta1", "k8s.io/api/flowcontrol/v1", "k8s.io/api/flowcontrol/v1beta1", "k8s.io/api/flowcontrol/v1beta2", "k8s.io/api/flowcontrol/v1beta3", "k8s.io/api/imagepolicy/v1alpha1", "k8s.io/api/networking/v1", "k8s.io/api/networking/v1beta1", "k8s.io/api/node/v1", "k8s.io/api/node/v1alpha1", "k8s.io/api/node/v1beta1", "k8s.io/api/policy/v1", "k8s.io/api/policy/v1beta1", "k8s.io/api/rbac/v1", "k8s.io/api/rbac/v1alpha1", "k8s.io/api/rbac/v1beta1", "k8s.io/api/resource/v1", "k8s.io/api/resource/v1alpha3", "k8s.io/api/resource/v1beta1", "k8s.io/api/resource/v1beta2", "k8s.io/api/scheduling/v1", "k8s.io/api/scheduling/v1alpha1", "k8s.io/api/scheduling/v1beta1", "k8s.io/api/storage/v1", "k8s.io/api/storage/v1alpha1", "k8s.io/api/storage/v1beta1", "k8s.io/api/storagemigration/v1beta1", "k8s.io/apiextensions-apiserver/pkg/apihelpers", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1", "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/cel/model", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/defaulting", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/objectmeta", "k8s.io/apiextensions-apiserver/pkg/apiserver/schema/pruning", "k8s.io/apiextensions-apiserver/pkg/apiserver/validation", "k8s.io/apiextensions-apiserver/pkg/controller/openapi/builder", "k8s.io/apiextensions-apiserver/pkg/controller/openapi/v2", "k8s.io/apiextensions-apiserver/pkg/features", "k8s.io/apiextensions-apiserver/pkg/generated/openapi", "k8s.io/apimachinery/pkg/api/equality", "k8s.io/apimachinery/pkg/api/errors", "k8s.io/apimachinery/pkg/api/meta", "k8s.io/apimachinery/pkg/api/meta/testrestmapper", "k8s.io/apimachinery/pkg/api/operation", "k8s.io/apimachinery/pkg/api/resource", "k8s.io/apimachinery/pkg/api/safe", "k8s.io/apimachinery/pkg/api/validate", "k8s.io/apimachinery/pkg/api/validate/constraints", "k8s.io/apimachinery/pkg/api/validate/content", "k8s.io/apimachinery/pkg/api/validation", "k8s.io/apimachinery/pkg/api/validation/path", "k8s.io/apimachinery/pkg/apis/meta/v1", "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured", "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured/unstructuredscheme", "k8s.io/apimachinery/pkg/apis/meta/v1/validation", "k8s.io/apimachinery/pkg/apis/meta/v1beta1", "k8s.io/apimachinery/pkg/apis/meta/v1beta1/validation", "k8s.io/apimachinery/pkg/conversion", "k8s.io/apimachinery/pkg/conversion/queryparams", "k8s.io/apimachinery/pkg/fields", "k8s.io/apimachinery/pkg/labels", "k8s.io/apimachinery/pkg/runtime", "k8s.io/apimachinery/pkg/runtime/schema", "k8s.io/apimachinery/pkg/runtime/serializer", "k8s.io/apimachinery/pkg/runtime/serializer/cbor", "k8s.io/apimachinery/pkg/runtime/serializer/cbor/direct", "k8s.io/apimachinery/pkg/runtime/serializer/json", "k8s.io/apimachinery/pkg/runtime/serializer/protobuf", "k8s.io/apimachinery/pkg/runtime/serializer/recognizer", "k8s.io/apimachinery/pkg/runtime/serializer/streaming", "k8s.io/apimachinery/pkg/runtime/serializer/versioning", "k8s.io/apimachinery/pkg/selection", "k8s.io/apimachinery/pkg/types", "k8s.io/apimachinery/pkg/util/cache", "k8s.io/apimachinery/pkg/util/diff", "k8s.io/apimachinery/pkg/util/dump", "k8s.io/apimachinery/pkg/util/duration", "k8s.io/apimachinery/pkg/util/errors", "k8s.io/apimachinery/pkg/util/framer", "k8s.io/apimachinery/pkg/util/httpstream", "k8s.io/apimachinery/pkg/util/httpstream/wsstream", "k8s.io/apimachinery/pkg/util/intstr", "k8s.io/apimachinery/pkg/util/json", "k8s.io/apimachinery/pkg/util/jsonmergepatch", "k8s.io/apimachinery/pkg/util/managedfields", "k8s.io/apimachinery/pkg/util/mergepatch", "k8s.io/apimachinery/pkg/util/naming", "k8s.io/apimachinery/pkg/util/net", "k8s.io/apimachinery/pkg/util/portforward", "k8s.io/apimachinery/pkg/util/rand", "k8s.io/apimachinery/pkg/util/remotecommand", "k8s.io/apimachinery/pkg/util/runtime", "k8s.io/apimachinery/pkg/util/sets", "k8s.io/apimachinery/pkg/util/strategicpatch", "k8s.io/apimachinery/pkg/util/uuid", "k8s.io/apimachinery/pkg/util/validation", "k8s.io/apimachinery/pkg/util/validation/field", "k8s.io/apimachinery/pkg/util/version", "k8s.io/apimachinery/pkg/util/wait", "k8s.io/apimachinery/pkg/util/yaml", "k8s.io/apimachinery/pkg/version", "k8s.io/apimachinery/pkg/watch", "k8s.io/apimachinery/third_party/forked/golang/json", "k8s.io/apimachinery/third_party/forked/golang/reflect", "k8s.io/apiserver/pkg/admission", "k8s.io/apiserver/pkg/apis/apiserver", "k8s.io/apiserver/pkg/apis/apiserver/install", "k8s.io/apiserver/pkg/apis/apiserver/v1", "k8s.io/apiserver/pkg/apis/apiserver/v1alpha1", "k8s.io/apiserver/pkg/apis/apiserver/v1beta1", "k8s.io/apiserver/pkg/apis/audit", "k8s.io/apiserver/pkg/apis/audit/v1", "k8s.io/apiserver/pkg/apis/cel", "k8s.io/apiserver/pkg/audit", "k8s.io/apiserver/pkg/authentication/serviceaccount", "k8s.io/apiserver/pkg/authentication/user", "k8s.io/apiserver/pkg/authorization/authorizer", "k8s.io/apiserver/pkg/cel", "k8s.io/apiserver/pkg/cel/common", "k8s.io/apiserver/pkg/cel/environment", "k8s.io/apiserver/pkg/cel/library", "k8s.io/apiserver/pkg/cel/metrics", "k8s.io/apiserver/pkg/cel/openapi", "k8s.io/apiserver/pkg/endpoints", "k8s.io/apiserver/pkg/endpoints/deprecation", "k8s.io/apiserver/pkg/endpoints/discovery", "k8s.io/apiserver/pkg/endpoints/handlers", "k8s.io/apiserver/pkg/endpoints/handlers/fieldmanager", "k8s.io/apiserver/pkg/endpoints/handlers/finisher", "k8s.io/apiserver/pkg/endpoints/handlers/metrics", "k8s.io/apiserver/pkg/endpoints/handlers/negotiation", "k8s.io/apiserver/pkg/endpoints/handlers/responsewriters", "k8s.io/apiserver/pkg/endpoints/metrics", "k8s.io/apiserver/pkg/endpoints/openapi", "k8s.io/apiserver/pkg/endpoints/request", "k8s.io/apiserver/pkg/endpoints/responsewriter", "k8s.io/apiserver/pkg/endpoints/warning", "k8s.io/apiserver/pkg/features", "k8s.io/apiserver/pkg/registry/rest", "k8s.io/apiserver/pkg/server/egressselector", "k8s.io/apiserver/pkg/server/egressselector/metrics", "k8s.io/apiserver/pkg/server/routine", "k8s.io/apiserver/pkg/storage", "k8s.io/apiserver/pkg/storage/names", "k8s.io/apiserver/pkg/storageversion", "k8s.io/apiserver/pkg/util/apihelpers", "k8s.io/apiserver/pkg/util/compatibility", "k8s.io/apiserver/pkg/util/dryrun", "k8s.io/apiserver/pkg/util/feature", "k8s.io/apiserver/pkg/util/flushwriter", "k8s.io/apiserver/pkg/util/openapi", "k8s.io/apiserver/pkg/util/webhook", "k8s.io/apiserver/pkg/util/x509metrics", "k8s.io/apiserver/pkg/validation", "k8s.io/apiserver/pkg/warning", "k8s.io/cli-runtime/pkg/genericclioptions", "k8s.io/cli-runtime/pkg/genericiooptions", "k8s.io/cli-runtime/pkg/printers", "k8s.io/cli-runtime/pkg/resource", "k8s.io/client-go/applyconfigurations", "k8s.io/client-go/applyconfigurations/admissionregistration/v1", "k8s.io/client-go/applyconfigurations/admissionregistration/v1alpha1", "k8s.io/client-go/applyconfigurations/admissionregistration/v1beta1", "k8s.io/client-go/applyconfigurations/apiserverinternal/v1alpha1", "k8s.io/client-go/applyconfigurations/apps/v1", "k8s.io/client-go/applyconfigurations/apps/v1beta1", "k8s.io/client-go/applyconfigurations/apps/v1beta2", "k8s.io/client-go/applyconfigurations/autoscaling/v1", "k8s.io/client-go/applyconfigurations/autoscaling/v2", "k8s.io/client-go/applyconfigurations/autoscaling/v2beta1", "k8s.io/client-go/applyconfigurations/autoscaling/v2beta2", "k8s.io/client-go/applyconfigurations/batch/v1", "k8s.io/client-go/applyconfigurations/batch/v1beta1", "k8s.io/client-go/applyconfigurations/certificates/v1", "k8s.io/client-go/applyconfigurations/certificates/v1alpha1", "k8s.io/client-go/applyconfigurations/certificates/v1beta1", "k8s.io/client-go/applyconfigurations/coordination/v1", "k8s.io/client-go/applyconfigurations/coordination/v1alpha2", "k8s.io/client-go/applyconfigurations/coordination/v1beta1", "k8s.io/client-go/applyconfigurations/core/v1", "k8s.io/client-go/applyconfigurations/discovery/v1", "k8s.io/client-go/applyconfigurations/discovery/v1beta1", "k8s.io/client-go/applyconfigurations/events/v1", "k8s.io/client-go/applyconfigurations/events/v1beta1", "k8s.io/client-go/applyconfigurations/extensions/v1beta1", "k8s.io/client-go/applyconfigurations/flowcontrol/v1", "k8s.io/client-go/applyconfigurations/flowcontrol/v1beta1", "k8s.io/client-go/applyconfigurations/flowcontrol/v1beta2", "k8s.io/client-go/applyconfigurations/flowcontrol/v1beta3", "k8s.io/client-go/applyconfigurations/imagepolicy/v1alpha1", "k8s.io/client-go/applyconfigurations/meta/v1", "k8s.io/client-go/applyconfigurations/networking/v1", "k8s.io/client-go/applyconfigurations/networking/v1beta1", "k8s.io/client-go/applyconfigurations/node/v1", "k8s.io/client-go/applyconfigurations/node/v1alpha1", "k8s.io/client-go/applyconfigurations/node/v1beta1", "k8s.io/client-go/applyconfigurations/policy/v1", "k8s.io/client-go/applyconfigurations/policy/v1beta1", "k8s.io/client-go/applyconfigurations/rbac/v1", "k8s.io/client-go/applyconfigurations/rbac/v1alpha1", "k8s.io/client-go/applyconfigurations/rbac/v1beta1", "k8s.io/client-go/applyconfigurations/resource/v1", "k8s.io/client-go/applyconfigurations/resource/v1alpha3", "k8s.io/client-go/applyconfigurations/resource/v1beta1", "k8s.io/client-go/applyconfigurations/resource/v1beta2", "k8s.io/client-go/applyconfigurations/scheduling/v1", "k8s.io/client-go/applyconfigurations/scheduling/v1alpha1", "k8s.io/client-go/applyconfigurations/scheduling/v1beta1", "k8s.io/client-go/applyconfigurations/storage/v1", "k8s.io/client-go/applyconfigurations/storage/v1alpha1", "k8s.io/client-go/applyconfigurations/storage/v1beta1", "k8s.io/client-go/applyconfigurations/storagemigration/v1beta1", "k8s.io/client-go/discovery", "k8s.io/client-go/discovery/cached/disk", "k8s.io/client-go/discovery/cached/memory", "k8s.io/client-go/dynamic", "k8s.io/client-go/features", "k8s.io/client-go/gentype", "k8s.io/client-go/informers", "k8s.io/client-go/informers/admissionregistration", "k8s.io/client-go/informers/admissionregistration/v1", "k8s.io/client-go/informers/admissionregistration/v1alpha1", "k8s.io/client-go/informers/admissionregistration/v1beta1", "k8s.io/client-go/informers/apiserverinternal", "k8s.io/client-go/informers/apiserverinternal/v1alpha1", "k8s.io/client-go/informers/apps", "k8s.io/client-go/informers/apps/v1", "k8s.io/client-go/informers/apps/v1beta1", "k8s.io/client-go/informers/apps/v1beta2", "k8s.io/client-go/informers/autoscaling", "k8s.io/client-go/informers/autoscaling/v1", "k8s.io/client-go/informers/autoscaling/v2", "k8s.io/client-go/informers/autoscaling/v2beta1", "k8s.io/client-go/informers/autoscaling/v2beta2", "k8s.io/client-go/informers/batch", "k8s.io/client-go/informers/batch/v1", "k8s.io/client-go/informers/batch/v1beta1", "k8s.io/client-go/informers/certificates", "k8s.io/client-go/informers/certificates/v1", "k8s.io/client-go/informers/certificates/v1alpha1", "k8s.io/client-go/informers/certificates/v1beta1", "k8s.io/client-go/informers/coordination", "k8s.io/client-go/informers/coordination/v1", "k8s.io/client-go/informers/coordination/v1alpha2", "k8s.io/client-go/informers/coordination/v1beta1", "k8s.io/client-go/informers/core", "k8s.io/client-go/informers/core/v1", "k8s.io/client-go/informers/discovery", "k8s.io/client-go/informers/discovery/v1", "k8s.io/client-go/informers/discovery/v1beta1", "k8s.io/client-go/informers/events", "k8s.io/client-go/informers/events/v1", "k8s.io/client-go/informers/events/v1beta1", "k8s.io/client-go/informers/extensions", "k8s.io/client-go/informers/extensions/v1beta1", "k8s.io/client-go/informers/flowcontrol", "k8s.io/client-go/informers/flowcontrol/v1", "k8s.io/client-go/informers/flowcontrol/v1beta1", "k8s.io/client-go/informers/flowcontrol/v1beta2", "k8s.io/client-go/informers/flowcontrol/v1beta3", "k8s.io/client-go/informers/networking", "k8s.io/client-go/informers/networking/v1", "k8s.io/client-go/informers/networking/v1beta1", "k8s.io/client-go/informers/node", "k8s.io/client-go/informers/node/v1", "k8s.io/client-go/informers/node/v1alpha1", "k8s.io/client-go/informers/node/v1beta1", "k8s.io/client-go/informers/policy", "k8s.io/client-go/informers/policy/v1", "k8s.io/client-go/informers/policy/v1beta1", "k8s.io/client-go/informers/rbac", "k8s.io/client-go/informers/rbac/v1", "k8s.io/client-go/informers/rbac/v1alpha1", "k8s.io/client-go/informers/rbac/v1beta1", "k8s.io/client-go/informers/resource", "k8s.io/client-go/informers/resource/v1", "k8s.io/client-go/informers/resource/v1alpha3", "k8s.io/client-go/informers/resource/v1beta1", "k8s.io/client-go/informers/resource/v1beta2", "k8s.io/client-go/informers/scheduling", "k8s.io/client-go/informers/scheduling/v1", "k8s.io/client-go/informers/scheduling/v1alpha1", "k8s.io/client-go/informers/scheduling/v1beta1", "k8s.io/client-go/informers/storage", "k8s.io/client-go/informers/storage/v1", "k8s.io/client-go/informers/storage/v1alpha1", "k8s.io/client-go/informers/storage/v1beta1", "k8s.io/client-go/informers/storagemigration", "k8s.io/client-go/informers/storagemigration/v1beta1", "k8s.io/client-go/kubernetes", "k8s.io/client-go/kubernetes/scheme", "k8s.io/client-go/kubernetes/typed/admissionregistration/v1", "k8s.io/client-go/kubernetes/typed/admissionregistration/v1alpha1", "k8s.io/client-go/kubernetes/typed/admissionregistration/v1beta1", "k8s.io/client-go/kubernetes/typed/apiserverinternal/v1alpha1", "k8s.io/client-go/kubernetes/typed/apps/v1", "k8s.io/client-go/kubernetes/typed/apps/v1beta1", "k8s.io/client-go/kubernetes/typed/apps/v1beta2", "k8s.io/client-go/kubernetes/typed/authentication/v1", "k8s.io/client-go/kubernetes/typed/authentication/v1alpha1", "k8s.io/client-go/kubernetes/typed/authentication/v1beta1", "k8s.io/client-go/kubernetes/typed/authorization/v1", "k8s.io/client-go/kubernetes/typed/authorization/v1beta1", "k8s.io/client-go/kubernetes/typed/autoscaling/v1", "k8s.io/client-go/kubernetes/typed/autoscaling/v2", "k8s.io/client-go/kubernetes/typed/autoscaling/v2beta1", "k8s.io/client-go/kubernetes/typed/autoscaling/v2beta2", "k8s.io/client-go/kubernetes/typed/batch/v1", "k8s.io/client-go/kubernetes/typed/batch/v1beta1", "k8s.io/client-go/kubernetes/typed/certificates/v1", "k8s.io/client-go/kubernetes/typed/certificates/v1alpha1", "k8s.io/client-go/kubernetes/typed/certificates/v1beta1", "k8s.io/client-go/kubernetes/typed/coordination/v1", "k8s.io/client-go/kubernetes/typed/coordination/v1alpha2", "k8s.io/client-go/kubernetes/typed/coordination/v1beta1", "k8s.io/client-go/kubernetes/typed/core/v1", "k8s.io/client-go/kubernetes/typed/discovery/v1", "k8s.io/client-go/kubernetes/typed/discovery/v1beta1", "k8s.io/client-go/kubernetes/typed/events/v1", "k8s.io/client-go/kubernetes/typed/events/v1beta1", "k8s.io/client-go/kubernetes/typed/extensions/v1beta1", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta1", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta2", "k8s.io/client-go/kubernetes/typed/flowcontrol/v1beta3", "k8s.io/client-go/kubernetes/typed/networking/v1", "k8s.io/client-go/kubernetes/typed/networking/v1beta1", "k8s.io/client-go/kubernetes/typed/node/v1", "k8s.io/client-go/kubernetes/typed/node/v1alpha1", "k8s.io/client-go/kubernetes/typed/node/v1beta1", "k8s.io/client-go/kubernetes/typed/policy/v1", "k8s.io/client-go/kubernetes/typed/policy/v1beta1", "k8s.io/client-go/kubernetes/typed/rbac/v1", "k8s.io/client-go/kubernetes/typed/rbac/v1alpha1", "k8s.io/client-go/kubernetes/typed/rbac/v1beta1", "k8s.io/client-go/kubernetes/typed/resource/v1", "k8s.io/client-go/kubernetes/typed/resource/v1alpha3", "k8s.io/client-go/kubernetes/typed/resource/v1beta1", "k8s.io/client-go/kubernetes/typed/resource/v1beta2", "k8s.io/client-go/kubernetes/typed/scheduling/v1", "k8s.io/client-go/kubernetes/typed/scheduling/v1alpha1", "k8s.io/client-go/kubernetes/typed/scheduling/v1beta1", "k8s.io/client-go/kubernetes/typed/storage/v1", "k8s.io/client-go/kubernetes/typed/storage/v1alpha1", "k8s.io/client-go/kubernetes/typed/storage/v1beta1", "k8s.io/client-go/kubernetes/typed/storagemigration/v1beta1", "k8s.io/client-go/listers", "k8s.io/client-go/listers/admissionregistration/v1", "k8s.io/client-go/listers/admissionregistration/v1alpha1", "k8s.io/client-go/listers/admissionregistration/v1beta1", "k8s.io/client-go/listers/apiserverinternal/v1alpha1", "k8s.io/client-go/listers/apps/v1", "k8s.io/client-go/listers/apps/v1beta1", "k8s.io/client-go/listers/apps/v1beta2", "k8s.io/client-go/listers/autoscaling/v1", "k8s.io/client-go/listers/autoscaling/v2", "k8s.io/client-go/listers/autoscaling/v2beta1", "k8s.io/client-go/listers/autoscaling/v2beta2", "k8s.io/client-go/listers/batch/v1", "k8s.io/client-go/listers/batch/v1beta1", "k8s.io/client-go/listers/certificates/v1", "k8s.io/client-go/listers/certificates/v1alpha1", "k8s.io/client-go/listers/certificates/v1beta1", "k8s.io/client-go/listers/coordination/v1", "k8s.io/client-go/listers/coordination/v1alpha2", "k8s.io/client-go/listers/coordination/v1beta1", "k8s.io/client-go/listers/core/v1", "k8s.io/client-go/listers/discovery/v1", "k8s.io/client-go/listers/discovery/v1beta1", "k8s.io/client-go/listers/events/v1", "k8s.io/client-go/listers/events/v1beta1", "k8s.io/client-go/listers/extensions/v1beta1", "k8s.io/client-go/listers/flowcontrol/v1", "k8s.io/client-go/listers/flowcontrol/v1beta1", "k8s.io/client-go/listers/flowcontrol/v1beta2", "k8s.io/client-go/listers/flowcontrol/v1beta3", "k8s.io/client-go/listers/networking/v1", "k8s.io/client-go/listers/networking/v1beta1", "k8s.io/client-go/listers/node/v1", "k8s.io/client-go/listers/node/v1alpha1", "k8s.io/client-go/listers/node/v1beta1", "k8s.io/client-go/listers/policy/v1", "k8s.io/client-go/listers/policy/v1beta1", "k8s.io/client-go/listers/rbac/v1", "k8s.io/client-go/listers/rbac/v1alpha1", "k8s.io/client-go/listers/rbac/v1beta1", "k8s.io/client-go/listers/resource/v1", "k8s.io/client-go/listers/resource/v1alpha3", "k8s.io/client-go/listers/resource/v1beta1", "k8s.io/client-go/listers/resource/v1beta2", "k8s.io/client-go/listers/scheduling/v1", "k8s.io/client-go/listers/scheduling/v1alpha1", "k8s.io/client-go/listers/scheduling/v1beta1", "k8s.io/client-go/listers/storage/v1", "k8s.io/client-go/listers/storage/v1alpha1", "k8s.io/client-go/listers/storage/v1beta1", "k8s.io/client-go/listers/storagemigration/v1beta1", "k8s.io/client-go/metadata", "k8s.io/client-go/openapi", "k8s.io/client-go/openapi/cached", "k8s.io/client-go/openapi3", "k8s.io/client-go/pkg/apis/clientauthentication", "k8s.io/client-go/pkg/apis/clientauthentication/install", "k8s.io/client-go/pkg/apis/clientauthentication/v1", "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1", "k8s.io/client-go/pkg/version", "k8s.io/client-go/plugin/pkg/client/auth", "k8s.io/client-go/plugin/pkg/client/auth/azure", "k8s.io/client-go/plugin/pkg/client/auth/exec", "k8s.io/client-go/plugin/pkg/client/auth/gcp", "k8s.io/client-go/plugin/pkg/client/auth/oidc", "k8s.io/client-go/rest", "k8s.io/client-go/rest/watch", "k8s.io/client-go/restmapper", "k8s.io/client-go/scale", "k8s.io/client-go/scale/scheme", "k8s.io/client-go/scale/scheme/appsint", "k8s.io/client-go/scale/scheme/appsv1beta1", "k8s.io/client-go/scale/scheme/appsv1beta2", "k8s.io/client-go/scale/scheme/autoscalingv1", "k8s.io/client-go/scale/scheme/extensionsint", "k8s.io/client-go/scale/scheme/extensionsv1beta1", "k8s.io/client-go/testing", "k8s.io/client-go/third_party/forked/golang/template", "k8s.io/client-go/tools/auth", "k8s.io/client-go/tools/cache", "k8s.io/client-go/tools/cache/synctrack", "k8s.io/client-go/tools/clientcmd", "k8s.io/client-go/tools/clientcmd/api", "k8s.io/client-go/tools/clientcmd/api/latest", "k8s.io/client-go/tools/clientcmd/api/v1", "k8s.io/client-go/tools/events", "k8s.io/client-go/tools/leaderelection", "k8s.io/client-go/tools/leaderelection/resourcelock", "k8s.io/client-go/tools/metrics", "k8s.io/client-go/tools/pager", "k8s.io/client-go/tools/record", "k8s.io/client-go/tools/record/util", "k8s.io/client-go/tools/reference", "k8s.io/client-go/tools/watch", "k8s.io/client-go/transport", "k8s.io/client-go/util/apply", "k8s.io/client-go/util/cert", "k8s.io/client-go/util/connrotation", "k8s.io/client-go/util/consistencydetector", "k8s.io/client-go/util/flowcontrol", "k8s.io/client-go/util/homedir", "k8s.io/client-go/util/jsonpath", "k8s.io/client-go/util/keyutil", "k8s.io/client-go/util/retry", "k8s.io/client-go/util/watchlist", "k8s.io/client-go/util/workqueue", "k8s.io/component-base/cli/flag", "k8s.io/component-base/compatibility", "k8s.io/component-base/featuregate", "k8s.io/component-base/metrics", "k8s.io/component-base/metrics/legacyregistry", "k8s.io/component-base/metrics/prometheus/compatversion", "k8s.io/component-base/metrics/prometheus/feature", "k8s.io/component-base/metrics/prometheus/workqueue", "k8s.io/component-base/metrics/prometheusextension", "k8s.io/component-base/tracing", "k8s.io/component-base/tracing/api/v1", "k8s.io/component-base/version", "k8s.io/component-base/zpages/features", "k8s.io/klog/v2", "k8s.io/kube-openapi/pkg/aggregator", "k8s.io/kube-openapi/pkg/builder", "k8s.io/kube-openapi/pkg/builder3", "k8s.io/kube-openapi/pkg/builder3/util", "k8s.io/kube-openapi/pkg/cached", "k8s.io/kube-openapi/pkg/common", "k8s.io/kube-openapi/pkg/common/restfuladapter", "k8s.io/kube-openapi/pkg/handler3", "k8s.io/kube-openapi/pkg/schemaconv", "k8s.io/kube-openapi/pkg/schemamutation", "k8s.io/kube-openapi/pkg/spec3", "k8s.io/kube-openapi/pkg/util", "k8s.io/kube-openapi/pkg/util/proto", "k8s.io/kube-openapi/pkg/util/proto/validation", "k8s.io/kube-openapi/pkg/validation/errors", "k8s.io/kube-openapi/pkg/validation/spec", "k8s.io/kube-openapi/pkg/validation/strfmt", "k8s.io/kube-openapi/pkg/validation/strfmt/bson", "k8s.io/kube-openapi/pkg/validation/validate", "k8s.io/kubectl/pkg/cmd/util", "k8s.io/kubectl/pkg/scheme", "k8s.io/kubectl/pkg/util/i18n", "k8s.io/kubectl/pkg/util/interrupt", "k8s.io/kubectl/pkg/util/openapi", "k8s.io/kubectl/pkg/util/templates", "k8s.io/kubectl/pkg/util/term", "k8s.io/kubectl/pkg/validation", "k8s.io/metrics/pkg/apis/metrics", "k8s.io/metrics/pkg/apis/metrics/v1alpha1", "k8s.io/metrics/pkg/apis/metrics/v1beta1", "k8s.io/metrics/pkg/client/clientset/versioned", "k8s.io/metrics/pkg/client/clientset/versioned/scheme", "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1alpha1", "k8s.io/metrics/pkg/client/clientset/versioned/typed/metrics/v1beta1", "k8s.io/utils/buffer", "k8s.io/utils/clock", "k8s.io/utils/exec", "k8s.io/utils/lru", "k8s.io/utils/net", "k8s.io/utils/path", "k8s.io/utils/ptr", "k8s.io/utils/trace", "oras.land/oras-go/v2", "oras.land/oras-go/v2/content", "oras.land/oras-go/v2/content/memory", "oras.land/oras-go/v2/errdef", "oras.land/oras-go/v2/registry", "oras.land/oras-go/v2/registry/remote", "oras.land/oras-go/v2/registry/remote/auth", "oras.land/oras-go/v2/registry/remote/credentials", "oras.land/oras-go/v2/registry/remote/credentials/trace", "oras.land/oras-go/v2/registry/remote/errcode", "oras.land/oras-go/v2/registry/remote/retry", "sigs.k8s.io/apiserver-network-proxy/konnectivity-client/pkg/client", "sigs.k8s.io/apiserver-network-proxy/konnectivity-client/pkg/client/metrics", "sigs.k8s.io/apiserver-network-proxy/konnectivity-client/pkg/common/metrics", "sigs.k8s.io/apiserver-network-proxy/konnectivity-client/proto/client", "sigs.k8s.io/controller-runtime", "sigs.k8s.io/controller-runtime/pkg/builder", "sigs.k8s.io/controller-runtime/pkg/cache", "sigs.k8s.io/controller-runtime/pkg/certwatcher", "sigs.k8s.io/controller-runtime/pkg/certwatcher/metrics", "sigs.k8s.io/controller-runtime/pkg/client", "sigs.k8s.io/controller-runtime/pkg/client/apiutil", "sigs.k8s.io/controller-runtime/pkg/client/config", "sigs.k8s.io/controller-runtime/pkg/client/fake", "sigs.k8s.io/controller-runtime/pkg/client/interceptor", "sigs.k8s.io/controller-runtime/pkg/cluster", "sigs.k8s.io/controller-runtime/pkg/config", "sigs.k8s.io/controller-runtime/pkg/controller", "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil", "sigs.k8s.io/controller-runtime/pkg/controller/priorityqueue", "sigs.k8s.io/controller-runtime/pkg/conversion", "sigs.k8s.io/controller-runtime/pkg/event", "sigs.k8s.io/controller-runtime/pkg/handler", "sigs.k8s.io/controller-runtime/pkg/healthz", "sigs.k8s.io/controller-runtime/pkg/leaderelection", "sigs.k8s.io/controller-runtime/pkg/log", "sigs.k8s.io/controller-runtime/pkg/log/zap", "sigs.k8s.io/controller-runtime/pkg/manager", "sigs.k8s.io/controller-runtime/pkg/manager/signals", "sigs.k8s.io/controller-runtime/pkg/metrics", "sigs.k8s.io/controller-runtime/pkg/metrics/server", "sigs.k8s.io/controller-runtime/pkg/predicate", "sigs.k8s.io/controller-runtime/pkg/reconcile", "sigs.k8s.io/controller-runtime/pkg/recorder", "sigs.k8s.io/controller-runtime/pkg/scheme", "sigs.k8s.io/controller-runtime/pkg/source", "sigs.k8s.io/controller-runtime/pkg/webhook", "sigs.k8s.io/controller-runtime/pkg/webhook/admission", "sigs.k8s.io/controller-runtime/pkg/webhook/admission/metrics", "sigs.k8s.io/controller-runtime/pkg/webhook/conversion", "sigs.k8s.io/controller-runtime/pkg/webhook/conversion/metrics", "sigs.k8s.io/json", "sigs.k8s.io/kind/pkg/apis/config/defaults", "sigs.k8s.io/kind/pkg/apis/config/v1alpha4", "sigs.k8s.io/kind/pkg/cluster", "sigs.k8s.io/kind/pkg/cluster/constants", "sigs.k8s.io/kind/pkg/cluster/nodes", "sigs.k8s.io/kind/pkg/cluster/nodeutils", "sigs.k8s.io/kind/pkg/cmd", "sigs.k8s.io/kind/pkg/cmd/kind/version", "sigs.k8s.io/kind/pkg/errors", "sigs.k8s.io/kind/pkg/exec", "sigs.k8s.io/kind/pkg/fs", "sigs.k8s.io/kind/pkg/log", "sigs.k8s.io/kustomize/api/filters/annotations", "sigs.k8s.io/kustomize/api/filters/fieldspec", "sigs.k8s.io/kustomize/api/filters/filtersutil", "sigs.k8s.io/kustomize/api/filters/fsslice", "sigs.k8s.io/kustomize/api/filters/iampolicygenerator", "sigs.k8s.io/kustomize/api/filters/imagetag", "sigs.k8s.io/kustomize/api/filters/labels", "sigs.k8s.io/kustomize/api/filters/nameref", "sigs.k8s.io/kustomize/api/filters/namespace", "sigs.k8s.io/kustomize/api/filters/patchjson6902", "sigs.k8s.io/kustomize/api/filters/patchstrategicmerge", "sigs.k8s.io/kustomize/api/filters/prefix", "sigs.k8s.io/kustomize/api/filters/refvar", "sigs.k8s.io/kustomize/api/filters/replacement", "sigs.k8s.io/kustomize/api/filters/replicacount", "sigs.k8s.io/kustomize/api/filters/suffix", "sigs.k8s.io/kustomize/api/filters/valueadd", "sigs.k8s.io/kustomize/api/hasher", "sigs.k8s.io/kustomize/api/ifc", "sigs.k8s.io/kustomize/api/konfig", "sigs.k8s.io/kustomize/api/krusty", "sigs.k8s.io/kustomize/api/kv", "sigs.k8s.io/kustomize/api/provenance", "sigs.k8s.io/kustomize/api/provider", "sigs.k8s.io/kustomize/api/resmap", "sigs.k8s.io/kustomize/api/resource", "sigs.k8s.io/kustomize/api/types", "sigs.k8s.io/kustomize/kyaml/comments", "sigs.k8s.io/kustomize/kyaml/errors", "sigs.k8s.io/kustomize/kyaml/ext", "sigs.k8s.io/kustomize/kyaml/fieldmeta", "sigs.k8s.io/kustomize/kyaml/filesys", "sigs.k8s.io/kustomize/kyaml/fn/runtime/container", "sigs.k8s.io/kustomize/kyaml/fn/runtime/exec", "sigs.k8s.io/kustomize/kyaml/fn/runtime/runtimeutil", "sigs.k8s.io/kustomize/kyaml/kio", "sigs.k8s.io/kustomize/kyaml/kio/kioutil", "sigs.k8s.io/kustomize/kyaml/openapi", "sigs.k8s.io/kustomize/kyaml/openapi/kubernetesapi", "sigs.k8s.io/kustomize/kyaml/openapi/kubernetesapi/v1_21_2", "sigs.k8s.io/kustomize/kyaml/openapi/kustomizationapi", "sigs.k8s.io/kustomize/kyaml/order", "sigs.k8s.io/kustomize/kyaml/resid", "sigs.k8s.io/kustomize/kyaml/runfn", "sigs.k8s.io/kustomize/kyaml/sets", "sigs.k8s.io/kustomize/kyaml/sliceutil", "sigs.k8s.io/kustomize/kyaml/utils", "sigs.k8s.io/kustomize/kyaml/yaml", "sigs.k8s.io/kustomize/kyaml/yaml/merge2", "sigs.k8s.io/kustomize/kyaml/yaml/schema", "sigs.k8s.io/kustomize/kyaml/yaml/walk", "sigs.k8s.io/randfill", "sigs.k8s.io/randfill/bytesource", "sigs.k8s.io/structured-merge-diff/v6/fieldpath", "sigs.k8s.io/structured-merge-diff/v6/merge", "sigs.k8s.io/structured-merge-diff/v6/schema", "sigs.k8s.io/structured-merge-diff/v6/typed", "sigs.k8s.io/structured-merge-diff/v6/value", "sigs.k8s.io/yaml", "sigs.k8s.io/yaml/goyaml.v3", "sigs.k8s.io/yaml/kyaml"] [mod] + [mod."al.essio.dev/pkg/shellescape"] + version = "v1.6.0" + hash = "sha256-dra32NY6DuVXLr7SblL0kF0S6e/Tea5jayWEMaj7K3E=" [mod."cel.dev/expr"] version = "v0.25.1" hash = "sha256-TEdMxFUPK7IZuCXMufwCkbN+ZZIXSQclljIybFZcByo=" @@ -41,9 +44,24 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/Azure/go-autorest/tracing"] version = "v0.6.1" hash = "sha256-nstDZC8Btx78yzqIR4clfu+R93rebUOZalEW1ZaQfIY=" + [mod."github.com/BurntSushi/toml"] + version = "v1.6.0" + hash = "sha256-ptdUJvuc21ixeLt+M5way/na3aCnCO4MYHWulWp8NEY=" + [mod."github.com/MakeNowJust/heredoc"] + version = "v1.0.0" + hash = "sha256-8hKERAVV1Pew84kc9GkW23dcO8uIUx/+tJQLi+oPwqE=" + [mod."github.com/Masterminds/goutils"] + version = "v1.1.1" + hash = "sha256-MEvA5e099GUllILa5EXxa6toQexU1sz6eDZt2tiqpCY=" [mod."github.com/Masterminds/semver/v3"] version = "v3.4.0" hash = "sha256-75kRraVwYVjYLWZvuSlts4Iu28Eh3SpiF0GHc7vCYHI=" + [mod."github.com/Masterminds/sprig/v3"] + version = "v3.3.0" + hash = "sha256-NvFX1xRO5t/u8OI063SDPfqYcZ43AuLI6klA6daPV9I=" + [mod."github.com/Masterminds/squirrel"] + version = "v1.5.4" + hash = "sha256-7UGz8TLcBI9HjU7zPqj9Gjp9Av+43mu0YCBV1mRy34o=" [mod."github.com/Microsoft/go-winio"] version = "v0.6.2" hash = "sha256-tVNWDUMILZbJvarcl/E7tpSnkn7urqgSHa2Eaka5vSU=" @@ -113,6 +131,9 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/aymanbagabas/go-osc52/v2"] version = "v2.0.1" hash = "sha256-6Bp0jBZ6npvsYcKZGHHIUSVSTAMEyieweAX2YAKDjjg=" + [mod."github.com/bahlo/generic-list-go"] + version = "v0.2.0" + hash = "sha256-BIzqwG61hnMDknZOn/5+VX09yemzFzMjhPF48XoALto=" [mod."github.com/beorn7/perks"] version = "v1.0.1" hash = "sha256-h75GUqfwJKngCJQVE5Ao5wnO3cfKD9lSIteoLp/3xJ4=" @@ -122,42 +143,69 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/blang/semver/v4"] version = "v4.0.0" hash = "sha256-dJC22MjnfT5WqJ7x7Tc3Bvpw9tFnBn9HqfWFiM57JVc=" + [mod."github.com/buger/jsonparser"] + version = "v1.1.2" + hash = "sha256-zyB2AEJX1ZuXyZ6vh45KOe9NSyPNQBrqVs45e3mYjo4=" [mod."github.com/cenkalti/backoff/v5"] version = "v5.0.3" hash = "sha256-bKq43PPD8RM6e7HePxHaO27traqm76bkvHcTVTQ+jeY=" [mod."github.com/cespare/xxhash/v2"] version = "v2.3.0" hash = "sha256-7hRlwSR+fos1kx4VZmJ/7snR7zHh8ZFKX+qqqqGcQpY=" + [mod."github.com/chai2010/gettext-go"] + version = "v1.0.2" + hash = "sha256-dwbhL7uAsAWGfX7Qmfa2hm0YClkbbB7ZHqOiPGP/L18=" + [mod."github.com/charmbracelet/bubbles"] + version = "v1.0.0" + hash = "sha256-Vz9QgctlzJqggPwfi48Lbn38ZJXu3Y71byp5uuuzUvU=" [mod."github.com/charmbracelet/bubbletea"] version = "v1.3.10" hash = "sha256-7wr85TLszu1CHNEMv+o4w+r24Z0xdzCgecPv+ZtRX/A=" [mod."github.com/charmbracelet/colorprofile"] - version = "v0.2.3-0.20250311203215-f60798e515dc" - hash = "sha256-D9E/bMOyLXAUVOHA1/6o3i+vVmLfwIMOWib6sU7A6+Q=" + version = "v0.4.1" + hash = "sha256-d/NjM/ybG+bGRRRMMcjbPCFGFS5noZRMaL05Ix5r/II=" [mod."github.com/charmbracelet/lipgloss"] version = "v1.1.0" hash = "sha256-RHsRT2EZ1nDOElxAK+6/DC9XAaGVjDTgPvRh3pyCfY4=" [mod."github.com/charmbracelet/x/ansi"] - version = "v0.10.1" - hash = "sha256-nY4zkUGnuD+Lczwt+NMXdQ38cAsy5mtxzXrFSJmR0E4=" + version = "v0.11.6" + hash = "sha256-UToZIkqXl9MEppcRgbeBqaaMeAzRkGa0w3lVUs6sxWI=" [mod."github.com/charmbracelet/x/cellbuf"] - version = "v0.0.13-0.20250311204145-2c3ea96c31dd" - hash = "sha256-XAhCOt8qJ2vR77lH1ez0IVU1/2CaLTq9jSmrHVg5HHU=" + version = "v0.0.15" + hash = "sha256-0S60XaWhKZG+TB3Kqe1oMn2Okwdq53nym8XayVSHHiM=" [mod."github.com/charmbracelet/x/term"] - version = "v0.2.1" - hash = "sha256-VBkCZLI90PhMasftGw3403IqoV7d3E5WEGAIVrN5xQM=" + version = "v0.2.2" + hash = "sha256-KF7IU1Luxl/sZP6XjomWB2e3lxSUS4/5AahhapGir/4=" [mod."github.com/chrismellard/docker-credential-acr-env"] version = "v0.0.0-20230304212654-82a0ddb27589" hash = "sha256-EWyO62fm/zhWdo4/96bscr3POG/5tKsWXYqp5mTwP0Y=" + [mod."github.com/clipperhouse/displaywidth"] + version = "v0.9.0" + hash = "sha256-9CNyTZPSncKQ7Y0my9DR4WYXDjtDHYNL512D691WDAM=" + [mod."github.com/clipperhouse/stringish"] + version = "v0.1.1" + hash = "sha256-Mp8M1CRbwr6dcJ4BD9tXD5I78ZgCFEm0GDxJv0GYReg=" + [mod."github.com/clipperhouse/uax29/v2"] + version = "v2.5.0" + hash = "sha256-Men4JLhiuEtAx8ZSzId5ciRWhud68o3k/B48ppwyxkM=" [mod."github.com/cloudflare/circl"] version = "v1.6.3" hash = "sha256-XZm4EastgX67Dgm5BpOEW/PY4aLcHM/O8+Xbz26PuTY=" + [mod."github.com/containerd/containerd"] + version = "v1.7.30" + hash = "sha256-JZqT04mKf2AZmNIH8UkAQsFMUnMh9CvbNc6I8KSaRiI=" [mod."github.com/containerd/errdefs"] version = "v1.0.0" hash = "sha256-wMZGoeqvRhuovYCJx0Js4P3qFCNTZ/6Atea/kNYoPMI=" [mod."github.com/containerd/errdefs/pkg"] version = "v0.3.0" hash = "sha256-BILJ0Be4cc8xfvLPylc/Pvwwa+w88+Hd0njzetUCeTg=" + [mod."github.com/containerd/log"] + version = "v0.1.0" + hash = "sha256-vuE6Mie2gSxiN3jTKTZovjcbdBd1YEExb7IBe3GM+9s=" + [mod."github.com/containerd/platforms"] + version = "v0.2.1" + hash = "sha256-XQdg/tnn5uKNzUc/dMmoIS9wgarx7SxaqZ5uJ9ZglA0=" [mod."github.com/containerd/stargz-snapshotter/estargz"] version = "v0.18.2" hash = "sha256-6KS9ObQ1tKXkvvKQy1BmxJ59aisDGvEtqhj1Oo54IRY=" @@ -177,17 +225,17 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. version = "v0.0.0-20241213102144-19d51d7fe467" hash = "sha256-eqH3UKAZ9eOlZjYdN7nWuJ1hFm2JAP1PVbJInQk6OLw=" [mod."github.com/cyphar/filepath-securejoin"] - version = "v0.4.1" - hash = "sha256-NOV6MfbkcQbfhNmfADQw2SJmZ6q1nw0wwg8Pm2tf2DM=" + version = "v0.6.1" + hash = "sha256-obqip8c1c9mjXFznyXF8aDnpcMw7ttzv+e28anCa/v0=" [mod."github.com/davecgh/go-spew"] version = "v1.1.2-0.20180830191138-d8f796af33cc" hash = "sha256-fV9oI51xjHdOmEx6+dlq7Ku2Ag+m/bmbzPo6A4Y74qc=" [mod."github.com/digitorus/pkcs7"] - version = "v0.0.0-20230818184609-3a137a874352" - hash = "sha256-zhgLL+kS2vkOhiK3kkI6yMhr71JOYo/uuxDo1dsC2k0=" + version = "v0.0.0-20250730155240-ffadbf3f398c" + hash = "sha256-7BMEKLqPQcW2ZWcaTsVAreWn/P68cE1Bjru85cytymo=" [mod."github.com/digitorus/timestamp"] - version = "v0.0.0-20231217203849-220c5c2851b7" - hash = "sha256-uNkyMBsdbLN1PiDLHAGWUYf6sZ08ENbxpv9RkNtzaW0=" + version = "v0.0.0-20250524132541-c45532741eea" + hash = "sha256-E24aBH09g5aCHc0pmx1MIAkmcqp7s/l83Rf26iv0tq4=" [mod."github.com/dimchansky/utfbom"] version = "v1.1.1" hash = "sha256-w8KEprK54zJkMat78T6zldjDwvhbc/O8s6pVFzfmg1I=" @@ -212,6 +260,9 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/docker/go-units"] version = "v0.5.0" hash = "sha256-iK/V/jJc+borzqMeqLY+38Qcts2KhywpsTk95++hImE=" + [mod."github.com/dprotaso/go-yit"] + version = "v0.0.0-20250513223454-5ece0c5aa76c" + hash = "sha256-gfGSv6gAAPys2V0vOuXa69QcMTecvuCNRM4DM8qrxDk=" [mod."github.com/dustin/go-humanize"] version = "v1.0.1" hash = "sha256-yuvxYYngpfVkUg9yAmG99IUVmADTQA0tMbBXe0Fq0Mc=" @@ -227,9 +278,15 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/erikgeiser/coninput"] version = "v0.0.0-20211004153227-1c3628e74d0f" hash = "sha256-OWSqN1+IoL73rWXWdbbcahZu8n2al90Y3eT5Z0vgHvU=" + [mod."github.com/evanphx/json-patch"] + version = "v5.9.11+incompatible" + hash = "sha256-1iyZpBaeBLmNkJ3T4A9fAEXEYB9nk9V02ug4pwl5dy0=" [mod."github.com/evanphx/json-patch/v5"] version = "v5.9.11" hash = "sha256-DaWzRi5dIr3U7kJlV3Qm1DWoKh5W+FI2BW/ATXT40J4=" + [mod."github.com/exponent-io/jsonpath"] + version = "v0.0.0-20210407135951-1de76d718b3f" + hash = "sha256-2wgJI2pvkaq2MoeUmLRaTBA8dIoEcwzKvw4qKJlhIec=" [mod."github.com/fatih/color"] version = "v1.18.0" hash = "sha256-pP5y72FSbi4j/BjyVq/XbAOFjzNjMxZt2R/lFFxGWvY=" @@ -242,9 +299,15 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/fxamacker/cbor/v2"] version = "v2.9.0" hash = "sha256-/IZK76MRCrz9XCiilieH5tKaLnIWyPJhwxDoVKB8dFc=" + [mod."github.com/getkin/kin-openapi"] + version = "v0.137.0" + hash = "sha256-9EG4IoEHGBWsZslgNMxupyZOBPMASklE/IbFUWXQGgs=" [mod."github.com/go-chi/chi/v5"] version = "v5.2.5" hash = "sha256-Y1+17ky94849aqk3iKf30F1u+G6K3nzZzLOBSeqIUow=" + [mod."github.com/go-errors/errors"] + version = "v1.4.2" + hash = "sha256-TkRLJlgaVlNxRD9c0ky+CN99tKL4Gx9W06H5a273gPM=" [mod."github.com/go-git/gcfg"] version = "v1.5.1-0.20230307220236-3a3c6141e376" hash = "sha256-f4k0gSYuo0/q3WOoTxl2eFaj7WZpdz29ih6CKc8Ude8=" @@ -254,6 +317,9 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/go-git/go-git/v5"] version = "v5.18.0" hash = "sha256-9n2mTwPvx9d90ZEmezIq6SY7UET/32WNw2xYAp8mQ5Y=" + [mod."github.com/go-gorp/gorp/v3"] + version = "v3.1.0" + hash = "sha256-z8AJoWp3fDJMNMYAi8kJdtzXDv8QyAd6bPYE58b2urs=" [mod."github.com/go-jose/go-jose/v4"] version = "v4.1.4" hash = "sha256-MKoJKXup1jfwOyN8mHXu1CQ8fvFJTaEf3K2LVtNSRhc=" @@ -338,9 +404,9 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/gobuffalo/flect"] version = "v1.0.3" hash = "sha256-gpA1fe9XTjZ9r+yYCysCgXKo1AmYNuNFwWn7ZQ4Ky1M=" - [mod."github.com/gogo/protobuf"] - version = "v1.3.2" - hash = "sha256-pogILFrrk+cAtb0ulqn9+gRZJ7sGnnLLdtqITvxvG6c=" + [mod."github.com/gobwas/glob"] + version = "v0.2.3" + hash = "sha256-hYHMUdwxVkMOjSKjR7UWO0D0juHdI4wL8JEy5plu/Jc=" [mod."github.com/golang-jwt/jwt/v4"] version = "v4.5.2" hash = "sha256-rTSqYEPooi8Uu4aXMW6k9dynOV+URYTGzVmbG3EQ7uo=" @@ -374,9 +440,18 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/google/go-containerregistry/pkg/authn/kubernetes"] version = "v0.0.0-20250225234217-098045d5e61f" hash = "sha256-UZyDwMt9qQw5XHHDOlTyYMRvG1BiDfBHeZLmoMzunB4=" + [mod."github.com/google/ko"] + version = "v0.18.1" + hash = "sha256-BAqB5dfXJ4op5ifojSxS7OaNVaTA6CsG596NRPyHwIM=" [mod."github.com/google/uuid"] version = "v1.6.0" hash = "sha256-VWl9sqUzdOuhW0KzQlv0gwwUQClYkmZwSydHG2sALYw=" + [mod."github.com/gosuri/uitable"] + version = "v0.0.4" + hash = "sha256-/SpsQ7j+3dEDC0UX9C+ZjQ8zY7taoqIOQspTqRb8oLk=" + [mod."github.com/gregjones/httpcache"] + version = "v0.0.0-20190611155906-901d90724c79" + hash = "sha256-AEfenLNBYwZjwHsMG48bpwUyUtjx1BBiK2W5HQruIBc=" [mod."github.com/grpc-ecosystem/grpc-gateway/v2"] version = "v2.28.0" hash = "sha256-QeWb6jN6noeGPCzECgpUSb9YX9LzvKGwImEuX+A03gs=" @@ -392,6 +467,9 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/hashicorp/go-retryablehttp"] version = "v0.7.8" hash = "sha256-4LZwKaFBbpKi9lSq5y6lOlYHU6WMnQdGNMxTd33rN80=" + [mod."github.com/huandu/xstrings"] + version = "v1.5.0" + hash = "sha256-q4F/rzbWMDmOVv07RVApdpfIsRNRByfOUQPEKsTq5BM=" [mod."github.com/in-toto/attestation"] version = "v1.1.2" hash = "sha256-BdRbWCnzMCMyZmo8lkovtvGWQq2qCB7S2XBZWClJ6TM=" @@ -401,12 +479,21 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/inconshreveable/mousetrap"] version = "v1.1.0" hash = "sha256-XWlYH0c8IcxAwQTnIi6WYqq44nOKUylSWxWO/vi+8pE=" + [mod."github.com/invopop/jsonschema"] + version = "v0.14.0" + hash = "sha256-gndZdk5eUqIsFMDsYjcDEbKpY5XCC4sLLZZ55Z4KCHk=" [mod."github.com/jbenet/go-context"] version = "v0.0.0-20150711004518-d14ea06fba99" hash = "sha256-VANNCWNNpARH/ILQV9sCQsBWgyL2iFT+4AHZREpxIWE=" [mod."github.com/jedisct1/go-minisign"] - version = "v0.0.0-20230811132847-661be99b8267" - hash = "sha256-tWufMmbfSlJRLsD1/ye5H+9b/uEQnBCQwORLJ1KwRh8=" + version = "v0.0.0-20241212093149-d2f9f49435c7" + hash = "sha256-2ICJG87R+NNLeW8xPxmqkhRlFsajUd4rAm9PE/GN5lU=" + [mod."github.com/jmoiron/sqlx"] + version = "v1.4.0" + hash = "sha256-0H132+A983nBr2zEyCKsJoBCZlC9pG+ylEcGysxKL4M=" + [mod."github.com/josharian/intern"] + version = "v1.0.0" + hash = "sha256-LJR0QE2vOQ2/2shBbO5Yl8cVPq+NFrE3ers0vu9FRP0=" [mod."github.com/json-iterator/go"] version = "v1.1.12" hash = "sha256-To8A0h+lbfZ/6zM+2PpRpY3+L6725OPC66lffq6fUoM=" @@ -416,15 +503,30 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/klauspost/compress"] version = "v1.18.5" hash = "sha256-H9b5iFJf4XbEnkGQCjGQAJ3aYhVDiolKrDewTbhuzQo=" + [mod."github.com/kubernetes-sigs/kro"] + version = "v0.9.1" + hash = "sha256-fxmbgdU4OCBloYoNu5uJo9pzDohW7uwJiW1622J9Kn8=" + [mod."github.com/lann/builder"] + version = "v0.0.0-20180802200727-47ae307949d0" + hash = "sha256-NDZvsU6T2jVq5pfhp/VoJcMTq8DXKXiEkfZHloOX6c0=" + [mod."github.com/lann/ps"] + version = "v0.0.0-20150810152359-62de8c46ede0" + hash = "sha256-fHIjAtshTJWa67PzzgruqN1LdpQ7Zgc1qpEZWhjQTnU=" [mod."github.com/letsencrypt/boulder"] version = "v0.20260223.0" hash = "sha256-p/AuDyJr7chBqbXT+LLa3ShKX96aC3SsfzR2ekb2+xM=" + [mod."github.com/lib/pq"] + version = "v1.10.9" + hash = "sha256-Gl6dLtL+yk6UrTTWfas43aM4lP/pNa2l7+ITXnjQyKs=" [mod."github.com/liggitt/tabwriter"] version = "v0.0.0-20181228230101-89fcab3d43de" hash = "sha256-b6pLitORwgfGpOHpe45ykj00P17utbDv8bv6MCVoCBM=" [mod."github.com/lucasb-eyer/go-colorful"] - version = "v1.2.0" - hash = "sha256-Gg9dDJFCTaHrKHRR1SrJgZ8fWieJkybljybkI9x0gyE=" + version = "v1.3.0" + hash = "sha256-6BKrJsfmxie+YFAWzTYVPQfrwjQEXRo+J8LY+50C1BU=" + [mod."github.com/mailru/easyjson"] + version = "v0.9.1" + hash = "sha256-3JJVYCnx7m0Prn7bA/6/CmBOrPLOznNuA6h0XTvrT5A=" [mod."github.com/mattn/go-colorable"] version = "v0.1.14" hash = "sha256-JC60PjKj7MvhZmUHTZ9p372FV72I9Mxvli3fivTbxuA=" @@ -435,17 +537,23 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. version = "v0.0.1" hash = "sha256-JlWckeGaWG+bXK8l8WEdZqmSiTwCA8b1qbmBKa/Fj3E=" [mod."github.com/mattn/go-runewidth"] - version = "v0.0.16" - hash = "sha256-NC+ntvwIpqDNmXb7aixcg09il80ygq6JAnW0Gb5b/DQ=" + version = "v0.0.19" + hash = "sha256-GpnbKplhX410Q/eIdknvWbYZgdav1keN+7wNUeOSMHE=" + [mod."github.com/mitchellh/copystructure"] + version = "v1.2.0" + hash = "sha256-VR9cPZvyW62IHXgmMw8ee+hBDThzd2vftgPksQYR/Mc=" [mod."github.com/mitchellh/go-homedir"] version = "v1.1.0" hash = "sha256-oduBKXHAQG8X6aqLEpqZHs5DOKe84u6WkBwi4W6cv3k=" + [mod."github.com/mitchellh/go-wordwrap"] + version = "v1.0.1" + hash = "sha256-fiD7kh5037BjA0vW6A2El0XArkK+4S5iTBjJB43BNYo=" + [mod."github.com/mitchellh/reflectwalk"] + version = "v1.0.2" + hash = "sha256-VX9DPqChm7jPnyrA3RAYgxAFrAhj7TRKIWD/qR9Zr9s=" [mod."github.com/moby/docker-image-spec"] version = "v1.3.1" hash = "sha256-xwSNLmMagzywdGJIuhrWl1r7cIWBYCOMNYbuDDT6Jhs=" - [mod."github.com/moby/sys/sequential"] - version = "v0.6.0" - hash = "sha256-ZNWZuuvn+iDYMsL08IU6wvXC4OfAa7rol4kaCvytZ64=" [mod."github.com/moby/term"] version = "v0.5.2" hash = "sha256-/G20jUZKx36ktmPU/nEw/gX7kRTl1Dbu7zvNBYNt4xU=" @@ -455,6 +563,12 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/modern-go/reflect2"] version = "v1.0.3-0.20250322232337-35a7c28c31ee" hash = "sha256-0pkWWZRB3lGFyzmlxxrm0KWVQo9HNXNafaUu3k+rE1g=" + [mod."github.com/mohae/deepcopy"] + version = "v0.0.0-20170929034955-c48cc78d4826" + hash = "sha256-TQMmKqIYwVhmMVh4RYQkAui97Eyj7poLmcAuDcHXsEk=" + [mod."github.com/monochromegane/go-gitignore"] + version = "v0.0.0-20200626010858-205db1a8cc00" + hash = "sha256-j1Mgb2TUUIiBcXB+slOkjtvcjmqSMEsG5RZYE7vGXOU=" [mod."github.com/muesli/ansi"] version = "v0.0.0-20230316100256-276c6243b2f6" hash = "sha256-qRKn0Bh2yvP0QxeEMeZe11Vz0BPFIkVcleKsPeybKMs=" @@ -470,6 +584,15 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/nozzle/throttler"] version = "v0.0.0-20180817012639-2ea982251481" hash = "sha256-pufLisYZW//uJXtCkobaU0Etnu+ZPQCqaRzRItx65hk=" + [mod."github.com/oapi-codegen/oapi-codegen/v2"] + version = "v2.7.0" + hash = "sha256-Eo8/3dkcOzrLC2pR+F5+rfcRt836YByq8UwnjsqAuD8=" + [mod."github.com/oasdiff/yaml"] + version = "v0.0.9" + hash = "sha256-je1aZ/MUidJRSTfJs8NpbWC63z73MUnU+SD3baiaDgY=" + [mod."github.com/oasdiff/yaml3"] + version = "v0.0.12" + hash = "sha256-/Dcdd+K90WIWqjaomw7MsmlzWATQCxTMlKQkjYRTYc0=" [mod."github.com/oklog/ulid/v2"] version = "v2.1.1" hash = "sha256-kPNLaZMGwGc7ngPCivf/n4Bis219yOkGAaa6mt7+yTY=" @@ -479,6 +602,18 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/opencontainers/image-spec"] version = "v1.1.1" hash = "sha256-bxBjtl+6846Ed3QHwdssOrNvlHV6b+Dn17zPISSQGP8=" + [mod."github.com/pb33f/ordered-map/v2"] + version = "v2.3.1" + hash = "sha256-eDCb6p/b7dhOB2YOshY/0EU+Do3eoUXFR9f1EMrCK8E=" + [mod."github.com/pelletier/go-toml"] + version = "v1.9.5" + hash = "sha256-RJ9K1BTId0Mled7S66iGgxHkZ5JKEIsrrNaEfM8aImc=" + [mod."github.com/perimeterx/marshmallow"] + version = "v1.1.5" + hash = "sha256-fFWjg0FNohDSV0/wUjQ8fBq1g8h6yIqTrHkxqL2Tt0s=" + [mod."github.com/peterbourgon/diskv"] + version = "v2.0.1+incompatible" + hash = "sha256-K4mEVjH0eyxyYHQRxdbmgJT0AJrfucUwGB2BplRRt9c=" [mod."github.com/pjbgf/sha1cd"] version = "v0.3.2" hash = "sha256-jdbiRhU8xc1C5c8m7BSCj71PUXHY3f7TWFfxDKKpUMk=" @@ -512,6 +647,15 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/riywo/loginshell"] version = "v0.0.0-20200815045211-7d26008be1ab" hash = "sha256-keDEue4jkpIVm9GxZYAAIvYlDjk/eilAT/xGanTcHo0=" + [mod."github.com/rubenv/sql-migrate"] + version = "v1.8.1" + hash = "sha256-etogS73ms8b6GoL7WxaU6l5HhnwdITWwbC6ajVP0oRI=" + [mod."github.com/russross/blackfriday/v2"] + version = "v2.1.0" + hash = "sha256-R+84l1si8az5yDqd5CYcFrTyNZ1eSYlpXKq6nFt4OTQ=" + [mod."github.com/santhosh-tekuri/jsonschema/v6"] + version = "v6.0.2" + hash = "sha256-rPRYeV00NRyt6rb+gFJRK1K4TlVxy92cocRK/X9Wef4=" [mod."github.com/sassoftware/relic"] version = "v7.2.1+incompatible" hash = "sha256-vHyTdLRh6OlfoGzVgvx7I0+E6tpE7V43lCQaHD/e8J4=" @@ -524,6 +668,12 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/shibumi/go-pathspec"] version = "v1.3.0" hash = "sha256-ZHLft/o+xyJrUlaCwnCDqbjkPj6iIxlOuA0fFBuwVvM=" + [mod."github.com/shopspring/decimal"] + version = "v1.4.0" + hash = "sha256-U36bC271jQsjuWFF8BfLz4WicxPJUcPHRGxLvTz4Mdw=" + [mod."github.com/sigstore/cosign/v2"] + version = "v2.6.1" + hash = "sha256-//aFi4lORbfwD3Gzd8P5JB1TpBF2I0SCNXpwTLdq99o=" [mod."github.com/sigstore/cosign/v3"] version = "v3.0.5" hash = "sha256-wN5iAfcBCDTvhbvSar4DBw7w1sxIFWcMKv8qkx07mfo=" @@ -551,9 +701,18 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."github.com/skeema/knownhosts"] version = "v1.3.1" hash = "sha256-kjqQDzuncQNTuOYegqVZExwuOt/Z73m2ST7NZFEKixI=" + [mod."github.com/speakeasy-api/jsonpath"] + version = "v0.6.3" + hash = "sha256-p9s0Ya/+C+wqTOhM9ulf4moroRtF4+MLdYJvn21Ad88=" + [mod."github.com/speakeasy-api/openapi"] + version = "v1.19.2" + hash = "sha256-6jaHjxI2A1hIliKLX/L+p9gavu6s7YipDv2qAGk+JJ8=" [mod."github.com/spf13/afero"] version = "v1.15.0" hash = "sha256-LhcezbOqfuBzacytbqck0hNUxi6NbWNhifUc5/9uHQ8=" + [mod."github.com/spf13/cast"] + version = "v1.10.0" + hash = "sha256-dQ6Qqf26IZsa6XsGKP7GDuCj+WmSsBmkBwGTDfue/rk=" [mod."github.com/spf13/cobra"] version = "v1.10.2" hash = "sha256-nbRCTFiDCC2jKK7AHi79n7urYCMP5yDZnWtNVJrDi+k=" @@ -573,23 +732,32 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. version = "v0.0.0-20171023193734-afe73141d399" hash = "sha256-r5XUB1A/doHNd5pu1cL0J8Jwy5IBtc8gQtG5NmKEYPU=" [mod."github.com/transparency-dev/formats"] - version = "v0.0.0-20251017110053-404c0d5b696c" - hash = "sha256-IaDd91Eeh6DasW5UcQaUpYobBwSNJO2nC64rySBs4wI=" + version = "v0.0.0-20251208091212-1378f9e1b1b7" + hash = "sha256-dKlfPlot8RTqmm1Qr3fjjy5+VzvBnMRJSzavLdWwJE8=" [mod."github.com/transparency-dev/merkle"] version = "v0.0.2" hash = "sha256-4KsqpIqgXlypi1X88PekMRfWJ/Y8tuww6DAuXar2+FY=" [mod."github.com/vbatts/tar-split"] version = "v0.12.2" hash = "sha256-6gOHl4puCV9T2EWpFpqMCkV9N2PEPSiWbNZNp20q7iM=" + [mod."github.com/vmware-labs/yaml-jsonpath"] + version = "v0.3.2" + hash = "sha256-BZiEtlTVjwtLFaqYu1005t0yFc1/D1iNeabnsAtSXRY=" [mod."github.com/willabides/kongplete"] version = "v0.4.0" hash = "sha256-PIgYbQo/kbxm5wDBrf2RPZvlfxZK0ndEwrnviISCoxg=" + [mod."github.com/woodsbury/decimal128"] + version = "v1.4.0" + hash = "sha256-iywk2bDtlSY2Lg3n71356eEYezc7wCtLozSGAEz+6FE=" [mod."github.com/x448/float16"] version = "v0.8.4" hash = "sha256-VKzMTMS9pIB/cwe17xPftCSK9Mf4Y6EuBEJlB4by5mE=" [mod."github.com/xanzy/ssh-agent"] version = "v0.3.3" hash = "sha256-l3pGB6IdzcPA/HLk93sSN6NM2pKPy+bVOoacR5RC2+c=" + [mod."github.com/xlab/treeprint"] + version = "v1.2.0" + hash = "sha256-g85HyWGLZuD/TFXZzmXT+u9TA1xIT5escUVhnofsYQI=" [mod."github.com/xo/terminfo"] version = "v0.0.0-20220910002029-abceb7e1c41e" hash = "sha256-GyCDxxMQhXA3Pi/TsWXpA8cX5akEoZV7CFx4RO3rARU=" @@ -602,6 +770,12 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."go.opentelemetry.io/otel"] version = "v1.43.0" hash = "sha256-oRemJUZhA7AzfUoBbRVA32u/XhMpipxLywHoJ1qsHBs=" + [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace"] + version = "v1.43.0" + hash = "sha256-caYRUaQ4DZGYtcUtH5kEkWXezDI4/vZRpUXpet3tQlg=" + [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"] + version = "v1.39.0" + hash = "sha256-7pfSAaoIS1fbtVd9CCx6J4/DHBsmReon6r9Hocb2CCU=" [mod."go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"] version = "v1.43.0" hash = "sha256-wvXfMOb3dIVtNDrsxO+wlH3BJwN70t3p0X2EV/ubPjQ=" @@ -611,12 +785,12 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."go.opentelemetry.io/otel/sdk"] version = "v1.43.0" hash = "sha256-Z1uTuALNhRXStiDl0UvYh9+XE2hd9OYe/bxCSuR78uE=" - [mod."go.opentelemetry.io/otel/sdk/metric"] - version = "v1.43.0" - hash = "sha256-8wIG4fqOYJSqlKRpLFze7HTaIptFq51nQXdMWLeGz2g=" [mod."go.opentelemetry.io/otel/trace"] version = "v1.43.0" hash = "sha256-LLx1PjBGzDwZ3//Gp14R1DCMlnMCzFxnGYqVUz5jTmk=" + [mod."go.opentelemetry.io/proto/otlp"] + version = "v1.10.0" + hash = "sha256-IEnbR38ucFLTcuC2FA+gRvZNq2loUqXgDskSqP3+LUM=" [mod."go.uber.org/multierr"] version = "v1.11.0" hash = "sha256-Lb6rHHfR62Ozg2j2JZy3MKOMKdsfzd1IYTR57r3Mhp0=" @@ -629,6 +803,9 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."go.yaml.in/yaml/v3"] version = "v3.0.4" hash = "sha256-NkGFiDPoCxbr3LFsI6OCygjjkY0rdmg5ggvVVwpyDQ4=" + [mod."go.yaml.in/yaml/v4"] + version = "v4.0.0-rc.2" + hash = "sha256-vLpe6QjO8zGBfBSCkWgrI8uPSABAF+Cc3PJNCcm4ylU=" [mod."golang.org/x/crypto"] version = "v0.50.0" hash = "sha256-vC1BJT7+3UBWLyEE5n3to0NKhMo6m2HGow2HiFgpQLo=" @@ -692,30 +869,33 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."gopkg.in/yaml.v3"] version = "v3.0.1" hash = "sha256-FqL9TKYJ0XkNwJFnq9j0VvJ5ZUU1RvH/52h/f5bkYAU=" + [mod."helm.sh/helm/v3"] + version = "v3.20.2" + hash = "sha256-w06PoPmwmlP0eK8zdqH/0DQi7Mqps/hEi70qkVfKeok=" [mod."k8s.io/api"] version = "v0.35.3" hash = "sha256-MIl5MB5b6QsV/VSWoDDmqx8GdNnfNmAnXIe0DRKo5vI=" [mod."k8s.io/apiextensions-apiserver"] - version = "v0.35.0" - hash = "sha256-RZdGkV4SoCTY022pIzQbeVwaxIYhIpXapLZrD593gh8=" + version = "v0.35.1" + hash = "sha256-RlbBmU7OkKflSQilfQcE1g6/vKweIbdwwfV5QT5fIKI=" [mod."k8s.io/apimachinery"] version = "v0.35.3" hash = "sha256-+dplbHUOfaCaD2E9IS4F3lnjSCr/a4LjTgdB9de92Pw=" [mod."k8s.io/apiserver"] - version = "v0.35.0" - hash = "sha256-jte6rWgLJddOrVPdBavTDM5ivq0nlkdvfxuprWJTMDM=" + version = "v0.35.1" + hash = "sha256-9re1BalM9qOSQCKyXvkm8AgaY0f2RIMvFi5ft6CW8sU=" [mod."k8s.io/cli-runtime"] - version = "v0.34.1" - hash = "sha256-s5yRTk0QmBoV0wSZT5jCJfKTRbBcAjEpCTmm1TxX4jo=" + version = "v0.35.1" + hash = "sha256-vr050jxzAe3UuK1KICq5uF7gJVlRGEVoWJ6HBWF6Lx4=" [mod."k8s.io/client-go"] version = "v0.35.1" hash = "sha256-QEQ7TLUviAXDbvp2s6tT3HZtXy7pLjj5qSDu89iC9ek=" [mod."k8s.io/code-generator"] - version = "v0.35.0" - hash = "sha256-0F8vNVdF/quBPGxOpxBL/GhupnkMoTE8dcZYX57RZKk=" + version = "v0.35.1" + hash = "sha256-O4YtWkH+QY1IBbib1WKO2IeXx8C9oHd3o1gF4U8VE+M=" [mod."k8s.io/component-base"] - version = "v0.35.0" - hash = "sha256-fIAmKs3/T8oHrXBX3sMWr/D1DGhyCsgM+6ZFdaA8BXU=" + version = "v0.35.1" + hash = "sha256-Jh5pJRXV7v2GaWQmEJeOOlDTs7j4HzGqR/6bKLtOxHc=" [mod."k8s.io/gengo/v2"] version = "v2.0.0-20251215205346-5ee0d033ba5b" hash = "sha256-FxD4b+cOzKuXsGI4NpsaUK/YTOMxugMGAh2jY3od3p8=" @@ -725,12 +905,21 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."k8s.io/kube-openapi"] version = "v0.0.0-20260127142750-a19766b6e2d4" hash = "sha256-NS8NvGTX3Ycoc4JU/jwLgtNlD5OOQ5zk2hzvFFSD/jM=" + [mod."k8s.io/kubectl"] + version = "v0.35.1" + hash = "sha256-SpY1TRpPXcQGkl9+UZTMRSctKP0P7aPckmLpWJIArTk=" [mod."k8s.io/metrics"] - version = "v0.34.1" - hash = "sha256-vlk5vnjLmB9LNyw98u7hPWn9dqfTKa9b7O+kV9wijCw=" + version = "v0.35.1" + hash = "sha256-dA5IXkJbAirrvLxnARPLvauNg7LhVy7iODhN1VDT3iI=" [mod."k8s.io/utils"] version = "v0.0.0-20260319190234-28399d86e0b5" hash = "sha256-ER2/AqF5AbVv4lfIDoggmlGfTnNH0cNccDisJqNyXn4=" + [mod."oras.land/oras-go/v2"] + version = "v2.6.0" + hash = "sha256-UwoJIpfocbUEYk25WqXbfysztCaYUXybk9W9EfxtTHg=" + [mod."sigs.k8s.io/apiserver-network-proxy/konnectivity-client"] + version = "v0.34.0" + hash = "sha256-98ScvhhmVxEVAGv9rhk10ceYUadNOuBERtCcdaIb42s=" [mod."sigs.k8s.io/controller-runtime"] version = "v0.23.1" hash = "sha256-iOaYAJgy/Q1Hi6afs5mLtP8K5J8Cs/MlDoGp8wE1GOY=" @@ -740,6 +929,15 @@ cachePackages = ["cel.dev/expr", "cloud.google.com/go/compute/metadata", "dario. [mod."sigs.k8s.io/json"] version = "v0.0.0-20250730193827-2d320260d730" hash = "sha256-y3vUPJYL6oxu/8c0j4vgX6fzqHtVPSCjfyuWkZYf6+I=" + [mod."sigs.k8s.io/kind"] + version = "v0.30.0" + hash = "sha256-BHwrJ6qW4KA8UfM99GBHigM+fk+nxbeM7RoHHztU3cE=" + [mod."sigs.k8s.io/kustomize/api"] + version = "v0.20.1" + hash = "sha256-kcZREdlFsFC7xaMRzvwkE+93lQJBTanImDJjgYMyzRU=" + [mod."sigs.k8s.io/kustomize/kyaml"] + version = "v0.20.1" + hash = "sha256-k87zgSRMFVcph06vqMd4KG9kE1GzllxKu36mWKdSisY=" [mod."sigs.k8s.io/randfill"] version = "v1.0.0" hash = "sha256-xldQxDwW84hmlihdSOFfjXyauhxEWV9KmIDLZMTcYNo=" diff --git a/hack/boilerplate.go.txt b/hack/boilerplate.go.txt new file mode 100644 index 0000000..55b2fca --- /dev/null +++ b/hack/boilerplate.go.txt @@ -0,0 +1,15 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ diff --git a/internal/async/event.go b/internal/async/event.go new file mode 100644 index 0000000..2bf0851 --- /dev/null +++ b/internal/async/event.go @@ -0,0 +1,60 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package async contains infrastructure for performing asynchronous tasks in +// the CLI. +package async + +// Event represents an event that happened during an asynchronous operation. It +// is used to pass information back to callers that are interested in the +// operation's progress. +type Event struct { + // Text is a description of the event. Events with the same text represent + // updates to the status of a single sub-operation. + Text string + // Status is the updated status of the sub-operation. + Status EventStatus +} + +// EventStatus represents the status of an async process. +type EventStatus string + +const ( + // EventStatusStarted indicates that an operation has started. + EventStatusStarted EventStatus = "started" + // EventStatusSuccess indicates that an operation has completed + // successfully. + EventStatusSuccess EventStatus = "success" + // EventStatusFailure indicates that an operation has failed. + EventStatusFailure EventStatus = "failure" +) + +// EventChannel is a channel for sending events. We define our own type for it +// so we can attach useful functions to it. +type EventChannel chan Event + +// SendEvent sends an event to an event channel. It is a no-op if the channel is +// nil. This allows event producers to produce events unconditionally, with +// callers providing an optionally nil channel. +func (ch EventChannel) SendEvent(text string, status EventStatus) { + if ch == nil { + return + } + ch <- Event{ + Text: text, + Status: status, + } +} diff --git a/internal/crd/convert.go b/internal/crd/convert.go new file mode 100644 index 0000000..231a905 --- /dev/null +++ b/internal/crd/convert.go @@ -0,0 +1,169 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package crd contains utilities for working with CRDs. +package crd + +import ( + "fmt" + "slices" + "strings" + + "github.com/spf13/afero" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "k8s.io/apiextensions-apiserver/pkg/controller/openapi/builder" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// ToOpenAPI converts the storage version of a CRD to an OpenAPI spec. The +// version is returned along with the OpenAPI spec. +func ToOpenAPI(crd *extv1.CustomResourceDefinition) (map[string]*spec3.OpenAPI, error) { + modifyCRDManifestFields(crd) + oapis := make(map[string]*spec3.OpenAPI, len(crd.Spec.Versions)) + + if len(crd.Spec.Versions) == 0 { + return nil, errors.New("crd has no versions") + } + + for _, crdVersion := range crd.Spec.Versions { + version := crdVersion.Name + + output, err := builder.BuildOpenAPIV3(crd, version, builder.Options{}) + if err != nil { + return nil, errors.Wrapf(err, "failed to build OpenAPI v3 schema") + } + + groupParts := strings.Split(crd.Spec.Group, ".") + slices.Reverse(groupParts) + reverseGroup := strings.Join(groupParts, ".") + + for name, s := range output.Components.Schemas { + if !strings.HasPrefix(name, reverseGroup+".") { + continue + } + + if fmt.Sprintf("%s.%s.%s", reverseGroup, version, crd.Spec.Names.Kind) == name { + addDefaultAPIVersionAndKind(s, schema.GroupVersionKind{ + Group: crd.Spec.Group, + Version: version, + Kind: crd.Spec.Names.Kind, + }) + } + } + + oapis[version] = output + } + + return oapis, nil +} + +// FilesToOpenAPI converts an on-disk CRD to an OpenAPI spec, and writes the +// OpenAPI spec to a file. The paths to the specs are returned. +func FilesToOpenAPI(fs afero.Fs, bs []byte, path string) ([]string, error) { + var crd extv1.CustomResourceDefinition + if err := yaml.Unmarshal(bs, &crd); err != nil { + return nil, errors.Wrapf(err, "failed to unmarshal CRD file %q", path) + } + + outputs, err := ToOpenAPI(&crd) + if err != nil { + return nil, err + } + + paths := make([]string, 0, len(outputs)) + for version, output := range outputs { + openAPIBytes, err := yaml.Marshal(output) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal OpenAPI output to YAML") + } + + groupFormatted := strings.ReplaceAll(crd.Spec.Group, ".", "_") + kindFormatted := strings.ToLower(crd.Spec.Names.Kind) + openAPIPath := fmt.Sprintf("%s_%s_%s.yaml", groupFormatted, version, kindFormatted) + + if err := afero.WriteFile(fs, openAPIPath, openAPIBytes, 0o644); err != nil { + return nil, errors.Wrapf(err, "failed to write OpenAPI file") + } + + paths = append(paths, openAPIPath) + } + + return paths, nil +} + +func addDefaultAPIVersionAndKind(s *spec.Schema, gvk schema.GroupVersionKind) { + if prop, ok := s.Properties["apiVersion"]; ok { + prop.Default = gvk.GroupVersion().String() + prop.Enum = []any{gvk.GroupVersion().String()} + s.Properties["apiVersion"] = prop + } + if prop, ok := s.Properties["kind"]; ok { + prop.Default = gvk.Kind + prop.Enum = []any{gvk.Kind} + s.Properties["kind"] = prop + } +} + +func modifyCRDManifestFields(crd *extv1.CustomResourceDefinition) { + for i, version := range crd.Spec.Versions { + if version.Schema != nil && version.Schema.OpenAPIV3Schema != nil { + updateSchemaPropertiesXEmbeddedResource(version.Schema.OpenAPIV3Schema) + crd.Spec.Versions[i].Schema.OpenAPIV3Schema.Properties = version.Schema.OpenAPIV3Schema.Properties + } + } +} + +// updateSchemaPropertiesXEmbeddedResource recursively traverses and updates +// schema properties at all depths. +func updateSchemaPropertiesXEmbeddedResource(s *extv1.JSONSchemaProps) { + if s == nil { + return + } + + if s.XEmbeddedResource && s.XPreserveUnknownFields != nil && *s.XPreserveUnknownFields { + s.XEmbeddedResource = false + s.XPreserveUnknownFields = nil + s.Type = "object" + s.AdditionalProperties = &extv1.JSONSchemaPropsOrBool{ + Allows: true, + Schema: nil, + } + } + + for key, property := range s.Properties { + updateSchemaPropertiesXEmbeddedResource(&property) + s.Properties[key] = property + } + + if s.AdditionalProperties != nil && s.AdditionalProperties.Schema != nil { + updateSchemaPropertiesXEmbeddedResource(s.AdditionalProperties.Schema) + } + + if s.Items != nil { + if s.Items.Schema != nil { + updateSchemaPropertiesXEmbeddedResource(s.Items.Schema) + } else if s.Items.JSONSchemas != nil { + for i := range s.Items.JSONSchemas { + updateSchemaPropertiesXEmbeddedResource(&s.Items.JSONSchemas[i]) + } + } + } +} diff --git a/internal/crd/generator.go b/internal/crd/generator.go new file mode 100644 index 0000000..316a37f --- /dev/null +++ b/internal/crd/generator.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package crd + +import ( + "path/filepath" + + "github.com/spf13/afero" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xcrd" + + xpv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" +) + +// createCRDFromXRD creates a xrCRD and claimCRD if possible from the XRD. +func createCRDFromXRD(xrd xpv1.CompositeResourceDefinition) (*apiextensionsv1.CustomResourceDefinition, *apiextensionsv1.CustomResourceDefinition, error) { + var xrCrd, claimCrd *apiextensionsv1.CustomResourceDefinition + + crdGVK := apiextensionsv1.SchemeGroupVersion.WithKind("CustomResourceDefinition") + + xrCrd, err := xcrd.ForCompositeResource(&xrd) + if err != nil { + return nil, nil, errors.Wrapf(err, "cannot derive composite CRD from XRD %q for Composite Resource Claim", xrd.GetName()) + } + if xrCrd != nil { + xrCrd.SetGroupVersionKind(crdGVK) + if xrCrd.Spec.Names.ListKind == "" { + xrCrd.Spec.Names.ListKind = xrCrd.Spec.Names.Kind + "List" + } + } + + if xrd.Spec.ClaimNames != nil { + claimCrd, err = xcrd.ForCompositeResourceClaim(&xrd) + if err != nil { + return nil, nil, errors.Wrapf(err, "cannot derive composite CRD from XRD %q for Composite Resource", xrd.GetName()) + } + } + if claimCrd != nil { + claimCrd.SetGroupVersionKind(crdGVK) + if claimCrd.Spec.Names.ListKind == "" { + claimCrd.Spec.Names.ListKind = claimCrd.Spec.Names.Kind + "List" + } + } + + return claimCrd, xrCrd, nil +} + +// ProcessXRD generates associated CRDs from an XRD. +func ProcessXRD(fs afero.Fs, bs []byte, path, baseFolder string) (string, string, error) { + var xrd xpv1.CompositeResourceDefinition + if err := yaml.Unmarshal(bs, &xrd); err != nil { + return "", "", errors.Wrapf(err, "failed to unmarshal XRD file %q", path) + } + + xrCRD, claimCRD, err := createCRDFromXRD(xrd) + if err != nil { + return "", "", err + } + + var xrPath, claimPath string + + if xrCRD != nil { + xrPath = filepath.Join(baseFolder, xrCRD.Name+".yaml") + xrCRDBytes, err := yaml.Marshal(xrCRD) + if err != nil { + return "", "", errors.Wrap(err, "failed to marshal XR CRD to YAML") + } + if err := afero.WriteFile(fs, xrPath, xrCRDBytes, 0o644); err != nil { + return "", "", err + } + } + + if claimCRD != nil { + claimPath = filepath.Join(baseFolder, claimCRD.Name+".yaml") + claimCRDBytes, err := yaml.Marshal(claimCRD) + if err != nil { + return "", "", errors.Wrap(err, "failed to marshal claim CRD to YAML") + } + if err := afero.WriteFile(fs, claimPath, claimCRDBytes, 0o644); err != nil { + return "", "", err + } + } + + return xrPath, claimPath, nil +} diff --git a/internal/dependency/cache_dir.go b/internal/dependency/cache_dir.go new file mode 100644 index 0000000..a09d7c8 --- /dev/null +++ b/internal/dependency/cache_dir.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dependency + +import ( + "os" + "path/filepath" +) + +// DefaultCacheDir returns the default per-user xpkg cache directory. +func DefaultCacheDir() string { + base, err := os.UserCacheDir() + if err != nil { + base = os.TempDir() + } + return filepath.Join(base, "crossplane", "xpkg") +} diff --git a/internal/dependency/manager.go b/internal/dependency/manager.go new file mode 100644 index 0000000..79c946b --- /dev/null +++ b/internal/dependency/manager.go @@ -0,0 +1,384 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package dependency manages schema generation for Crossplane project +// dependencies. +package dependency + +import ( + "context" + "fmt" + "strings" + "sync" + + "github.com/google/go-containerregistry/pkg/name" + conregv1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/git" + "github.com/crossplane/cli/v2/internal/project/projectfile" + "github.com/crossplane/cli/v2/internal/schemas/generator" + smanager "github.com/crossplane/cli/v2/internal/schemas/manager" + "github.com/crossplane/cli/v2/internal/schemas/runner" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// Manager manages dependencies for a Crossplane project, including fetching +// packages, extracting CRDs, and generating schemas. +type Manager struct { + proj *v1alpha1.Project + projFS afero.Fs + projFile string + schemas *smanager.Manager + + gitCloner git.Cloner + gitAuthProvider git.AuthProvider + + client runtimexpkg.Client + resolver *clixpkg.Resolver + + updateMutex sync.Mutex +} + +// ManagerOption configures the dependency manager. +type ManagerOption func(*managerOptions) + +type managerOptions struct { + projFile string + schemaFS afero.Fs + schemaGenerators []generator.Interface + schemaRunner runner.SchemaRunner + gitAuthProvider git.AuthProvider + client runtimexpkg.Client + resolver *clixpkg.Resolver +} + +// WithProjectFile sets the path to the project file. +func WithProjectFile(path string) ManagerOption { + return func(opts *managerOptions) { + opts.projFile = path + } +} + +// WithSchemaFS sets the filesystem to use for schemas. +func WithSchemaFS(fs afero.Fs) ManagerOption { + return func(opts *managerOptions) { + opts.schemaFS = fs + } +} + +// WithSchemaRunner sets the runner to use when generating schemas. +func WithSchemaRunner(r runner.SchemaRunner) ManagerOption { + return func(opts *managerOptions) { + opts.schemaRunner = r + } +} + +// WithSchemaGenerators sets the schema generators to call. +func WithSchemaGenerators(gs []generator.Interface) ManagerOption { + return func(opts *managerOptions) { + opts.schemaGenerators = gs + } +} + +// WithGitAuthProvider sets the auth provider for git operations. +func WithGitAuthProvider(p git.AuthProvider) ManagerOption { + return func(opts *managerOptions) { + opts.gitAuthProvider = p + } +} + +// WithXpkgClient sets the runtime xpkg.Client used to fetch and parse +// xpkg dependencies. +func WithXpkgClient(c runtimexpkg.Client) ManagerOption { + return func(opts *managerOptions) { + opts.client = c + } +} + +// WithResolver sets the package reference resolver used to translate +// semver constraints into concrete tags before calling Client.Get. +func WithResolver(r *clixpkg.Resolver) ManagerOption { + return func(opts *managerOptions) { + opts.resolver = r + } +} + +// NewManager returns an initialized dependency manager. +func NewManager(proj *v1alpha1.Project, projFS afero.Fs, opts ...ManagerOption) *Manager { + options := &managerOptions{ + projFile: "crossplane-project.yaml", + schemaFS: afero.NewBasePathFs(projFS, proj.Spec.Paths.Schemas), + schemaGenerators: generator.AllLanguages(), + schemaRunner: runner.NewRealSchemaRunner( + runner.WithImageConfig(proj.Spec.ImageConfigs), + ), + gitAuthProvider: &git.HTTPSAuthProvider{}, + } + + for _, opt := range opts { + opt(options) + } + + schemas := smanager.New( + options.schemaFS, + options.schemaGenerators, + options.schemaRunner, + ) + + return &Manager{ + proj: proj, + projFS: projFS, + projFile: options.projFile, + schemas: schemas, + gitCloner: &git.DefaultCloner{}, + gitAuthProvider: options.gitAuthProvider, + client: options.client, + resolver: options.resolver, + } +} + +// ResolveRef resolves a version constraint in an OCI ref to a concrete tag. If +// the ref has no tag, has an exact semver version, or is not a valid +// constraint, it is returned unchanged. +func (m *Manager) ResolveRef(ref string) (name.Reference, error) { + resolved, _, err := m.resolver.Resolve(context.Background(), ref) + return resolved, err +} + +// AddPackage adds a package to the dependency manager. If refresh is set, the +// package's ref will be re-resolved regardless of whether it is cached. +func (m *Manager) AddPackage(ctx context.Context, ref string, refresh bool) (*schema.GroupVersionKind, error) { + return m.addPackage(ctx, ref, refresh) +} + +func (m *Manager) addPackage(ctx context.Context, ref string, refresh bool) (*schema.GroupVersionKind, error) { + resolvedRef, version, err := m.resolver.Resolve(ctx, ref) + if err != nil { + return nil, errors.Wrapf(err, "cannot resolve %s", ref) + } + + pullPolicy := corev1.PullIfNotPresent + if refresh { + pullPolicy = corev1.PullAlways + } + pkg, err := m.client.Get(ctx, resolvedRef.String(), runtimexpkg.WithPullPolicy(pullPolicy)) + if err != nil { + return nil, errors.Wrapf(err, "failed to fetch %s", ref) + } + + gvk, err := runtimeGVKForPackage(pkg) + if err != nil { + return nil, errors.Wrapf(err, "cannot identify package %s", ref) + } + crdFS, err := clixpkg.CRDFilesystem(pkg.Package) + if err != nil { + return nil, errors.Wrapf(err, "cannot extract CRDs from %s", ref) + } + + // Use the resolved version so constraint and exact-version inputs + // collapse to one schema-lock entry. Digest-pinned refs (no resolved + // version) intentionally use a digest-form ID and remain separate. + id := pkg.Source + "@" + pkg.Digest + if version != "" { + id = pkg.Source + ":" + version + } + src := smanager.NewXpkgSource(id, pkg.Digest, crdFS) + if err := m.schemas.Add(ctx, src); err != nil { + return nil, err + } + return gvk, nil +} + +func runtimeGVKForPackage(pkg *runtimexpkg.Package) (*schema.GroupVersionKind, error) { + gvk := pkg.GetMeta().GetObjectKind().GroupVersionKind() + + // NOTE(adamwg): We assume all packages follow the existing Crossplane + // convention where the meta and runtime kinds match, and the meta apiGroup + // the runtime apiGroup prefixed with "meta.". + group := strings.TrimPrefix(gvk.Group, "meta.") + if group == gvk.Group { + return nil, errors.Errorf("package metadata group %s does not start with \"meta.\"", gvk.Group) + } + + return &schema.GroupVersionKind{ + Group: group, + Version: gvk.Version, + Kind: gvk.Kind, + }, nil +} + +// AddDependency adds a dependency, generates schemas for it, and persists the +// dependency to the project file. +func (m *Manager) AddDependency(ctx context.Context, dep *v1alpha1.Dependency) error { + gvk, err := m.addDependencyNoWrite(ctx, dep, false) + if err != nil { + return err + } + + // For xpkg dependencies, fill in the apiVersion and kind. We don't care + // about these for development purposes, but they are required to install an + // xpkg dependency into a cluster at runtime. + if dep.Type == v1alpha1.DependencyTypeXpkg && dep.Xpkg != nil && gvk != nil { + dep.Xpkg.APIVersion = gvk.GroupVersion().String() + dep.Xpkg.Kind = gvk.Kind + } + + m.updateMutex.Lock() + defer m.updateMutex.Unlock() + + upsertDependency(m.proj, *dep) + return projectfile.Update(m.projFS, m.projFile, func(p *v1alpha1.Project) { + upsertDependency(p, *dep) + }) +} + +// AddAll adds all dependencies configured in the project. If ch is non-nil, +// events will be sent for each dependency as it is processed. +func (m *Manager) AddAll(ctx context.Context, ch async.EventChannel) error { + return m.addAll(ctx, ch, false) +} + +// RefreshAll re-resolves every dependency's version constraint against the +// registry. Used by `dependency update-cache`. +func (m *Manager) RefreshAll(ctx context.Context, ch async.EventChannel) error { + return m.addAll(ctx, ch, true) +} + +func (m *Manager) addAll(ctx context.Context, ch async.EventChannel, refresh bool) error { + eg, egCtx := errgroup.WithContext(ctx) + + for i := range m.proj.Spec.Dependencies { + dep := &m.proj.Spec.Dependencies[i] + desc := "Updating dependency " + GetSourceDescription(*dep) + eg.Go(func() error { + ch.SendEvent(desc, async.EventStatusStarted) + if _, err := m.addDependencyNoWrite(egCtx, dep, refresh); err != nil { + ch.SendEvent(desc, async.EventStatusFailure) + return err + } + ch.SendEvent(desc, async.EventStatusSuccess) + return nil + }) + } + + return eg.Wait() +} + +func (m *Manager) addDependencyNoWrite(ctx context.Context, dep *v1alpha1.Dependency, refresh bool) (*schema.GroupVersionKind, error) { + switch { + case dep.Type == v1alpha1.DependencyTypeXpkg: + if dep.Xpkg == nil { + return nil, errors.New("xpkg dependency has no package reference") + } + + // If the version is a digest, format the OCI ref as + // repo@digest. Otherwise, use repo:tag, where tag may be a semver + // constraint. + ref := dep.Xpkg.Package + if _, err := conregv1.NewHash(dep.Xpkg.Version); err == nil { + ref = fmt.Sprintf("%s@%s", ref, dep.Xpkg.Version) + } else if dep.Xpkg.Version != "" { + ref = fmt.Sprintf("%s:%s", ref, dep.Xpkg.Version) + } + + return m.addPackage(ctx, ref, refresh) + case dep.Git != nil: + return nil, m.schemas.Add(ctx, smanager.NewGitSource(*dep, m.gitCloner, m.gitAuthProvider)) + case dep.HTTP != nil: + return nil, m.schemas.Add(ctx, smanager.NewHTTPSource(*dep)) + case dep.K8s != nil: + return nil, m.schemas.Add(ctx, smanager.NewK8sSource(*dep)) + default: + return nil, errors.New("dependency has no source configured") + } +} + +// Clean removes all generated schemas. +func (m *Manager) Clean() error { + return m.projFS.RemoveAll(m.proj.Spec.Paths.Schemas) +} + +// CleanPackages removes the per-user xpkg cache directory at root. +func CleanPackages(root string, fs afero.Fs) error { + if err := fs.RemoveAll(root); err != nil { + return errors.Wrapf(err, "cannot remove xpkg cache at %s", root) + } + return nil +} + +// GetSourceDescription returns a human-readable description of a dependency. +func GetSourceDescription(dep v1alpha1.Dependency) string { + switch { + case dep.Xpkg != nil: + desc := dep.Xpkg.Package + if dep.Xpkg.Version != "" { + desc += ":" + dep.Xpkg.Version + } + return desc + case dep.Git != nil: + desc := dep.Git.Repository + if dep.Git.Ref != "" { + desc += " (" + dep.Git.Ref + ")" + } + if dep.Git.Path != "" { + desc += " at " + dep.Git.Path + } + return desc + case dep.HTTP != nil: + return dep.HTTP.URL + case dep.K8s != nil: + return "Kubernetes API " + dep.K8s.Version + default: + return "unknown source" + } +} + +func upsertDependency(proj *v1alpha1.Project, dep v1alpha1.Dependency) { + for i, existing := range proj.Spec.Dependencies { + if matchesDependency(existing, dep) { + proj.Spec.Dependencies[i] = dep + return + } + } + + proj.Spec.Dependencies = append(proj.Spec.Dependencies, dep) +} + +func matchesDependency(a, b v1alpha1.Dependency) bool { + if a.Type != b.Type { + return false + } + switch { + case a.Xpkg != nil && b.Xpkg != nil: + return a.Xpkg.Package == b.Xpkg.Package + case a.Git != nil && b.Git != nil: + return a.Git.Repository == b.Git.Repository + case a.HTTP != nil && b.HTTP != nil: + return a.HTTP.URL == b.HTTP.URL + case a.K8s != nil && b.K8s != nil: + return true // Only one k8s dep makes sense. + } + return false +} diff --git a/internal/dependency/manager_test.go b/internal/dependency/manager_test.go new file mode 100644 index 0000000..1f2e552 --- /dev/null +++ b/internal/dependency/manager_test.go @@ -0,0 +1,193 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package dependency + +import ( + "context" + "encoding/json" + "io" + "maps" + "slices" + "strings" + "testing" + + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + runtimexpkg "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/schemas/generator" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +const testPackageYAML = `apiVersion: meta.pkg.crossplane.io/v1 +kind: Configuration +metadata: + name: example +spec: + crossplane: + version: ">=v1.14.0" +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + name: things.example.com +spec: + group: example.com + names: + plural: things + kind: Thing + listKind: ThingList + singular: thing + scope: Namespaced + versions: + - name: v1 + served: true + storage: true + schema: + openAPIV3Schema: + type: object +` + +// parsedTestPackage parses testPackageYAML once into a *parser.Package +// that the fake client can hand back from Get. +func parsedTestPackage(t *testing.T) *parser.Package { + t.Helper() + metaScheme, err := runtimexpkg.BuildMetaScheme() + if err != nil { + t.Fatalf("build meta scheme: %v", err) + } + objScheme, err := runtimexpkg.BuildObjectScheme() + if err != nil { + t.Fatalf("build object scheme: %v", err) + } + pkg, err := parser.New(metaScheme, objScheme).Parse(context.Background(), io.NopCloser(strings.NewReader(testPackageYAML))) + if err != nil { + t.Fatalf("parse package: %v", err) + } + return pkg +} + +// fakeClient is a fake xpkg.Client. Get returns a pre-canned Package per ref; +// ListVersions returns a fixed tag list, so a real Resolver can be wired on +// top. +type fakeClient struct { + packages map[string]*runtimexpkg.Package + tags []string +} + +func (f *fakeClient) Get(_ context.Context, ref string, _ ...runtimexpkg.GetOption) (*runtimexpkg.Package, error) { + pkg, ok := f.packages[ref] + if !ok { + return nil, errors.New("not found") + } + return pkg, nil +} + +func (f *fakeClient) ListVersions(_ context.Context, _ string, _ ...runtimexpkg.GetOption) ([]string, error) { + return f.tags, nil +} + +func makePackage(t *testing.T, source, digest, version string) *runtimexpkg.Package { + t.Helper() + return &runtimexpkg.Package{ + Package: parsedTestPackage(t), + Source: source, + Digest: digest, + Version: version, + } +} + +func newTestManager(t *testing.T, fc *fakeClient) (*Manager, afero.Fs) { + t.Helper() + schemaFS := afero.NewMemMapFs() + m := NewManager( + &v1alpha1.Project{ + Spec: v1alpha1.ProjectSpec{ + Paths: &v1alpha1.ProjectPaths{Schemas: "schemas"}, + }, + }, + afero.NewMemMapFs(), + WithSchemaFS(schemaFS), + WithSchemaGenerators([]generator.Interface{}), + WithXpkgClient(fc), + WithResolver(clixpkg.NewResolver(fc)), + ) + return m, schemaFS +} + +func TestManager_AddPackage(t *testing.T) { + tests := map[string]struct { + ref string + tags []string + fetchAt string + wantKey string + }{ + "ConstraintCollapsesToResolvedVersion": { + ref: "pkg.example/foo:>=v0.0.0", + tags: []string{"v0.5.2"}, + fetchAt: "pkg.example/foo:v0.5.2", + wantKey: "xpkg://pkg.example/foo:v0.5.2", + }, + "ExactVersionMatchesResolved": { + ref: "pkg.example/foo:v0.5.2", + tags: []string{"v0.5.2"}, + fetchAt: "pkg.example/foo:v0.5.2", + wantKey: "xpkg://pkg.example/foo:v0.5.2", + }, + "DigestRefUsesDigestForm": { + ref: "pkg.example/foo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", + fetchAt: "pkg.example/foo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", + wantKey: "xpkg://pkg.example/foo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + fc := &fakeClient{ + packages: map[string]*runtimexpkg.Package{ + tc.fetchAt: makePackage(t, "pkg.example/foo", "sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", ""), + }, + tags: tc.tags, + } + m, schemaFS := newTestManager(t, fc) + + if _, err := m.AddPackage(context.Background(), tc.ref, false); err != nil { + t.Fatalf("AddPackage: %v", err) + } + + bs, err := afero.ReadFile(schemaFS, ".lock.json") + if err != nil { + t.Fatalf("read lock: %v", err) + } + var got struct { + Packages map[string]string `json:"packages"` + } + if err := json.Unmarshal(bs, &got); err != nil { + t.Fatalf("unmarshal lock: %v", err) + } + if _, ok := got.Packages[tc.wantKey]; !ok { + t.Errorf("lock has no entry for %q; keys = %v", tc.wantKey, slices.Collect(maps.Keys(got.Packages))) + } + if len(got.Packages) != 1 { + t.Errorf("lock packages = %d, want 1; got %v", len(got.Packages), got.Packages) + } + }) + } +} diff --git a/internal/docker/docker.go b/internal/docker/docker.go index a907964..7bd1e39 100644 --- a/internal/docker/docker.go +++ b/internal/docker/docker.go @@ -19,6 +19,7 @@ limitations under the License. package docker import ( + "archive/tar" "bytes" "context" "encoding/base64" @@ -36,6 +37,7 @@ import ( "github.com/docker/docker/client" "github.com/docker/docker/pkg/stdcopy" "github.com/google/go-containerregistry/pkg/name" + "github.com/spf13/afero" "github.com/crossplane/crossplane-runtime/v2/pkg/errors" ) @@ -149,9 +151,9 @@ func StartContainer(ctx context.Context, name, img string, opts ...StartContaine return "", errors.Wrap(err, "failed to create container") } - for path, tarball := range cfg.copyFiles { - if err := cli.CopyToContainer(ctx, resp.ID, filepath.Clean(path), bytes.NewReader(tarball), container.CopyToContainerOptions{}); err != nil { - return "", errors.Wrapf(err, "failed to copy files to container path %s", path) + for _, cpy := range cfg.copyFiles { + if err := cli.CopyToContainer(ctx, resp.ID, filepath.Clean(cpy.to), bytes.NewReader(cpy.tarball), container.CopyToContainerOptions{}); err != nil { + return "", errors.Wrapf(err, "failed to copy files to container path %s", cpy.to) } } @@ -214,7 +216,12 @@ type startContainerConfig struct { containerConfig *container.Config hostConfig *container.HostConfig networks []string - copyFiles map[string][]byte + copyFiles []copyFilesConfig +} + +type copyFilesConfig struct { + to string + tarball []byte } // StartContainerOption provides optional options for StartContainer. @@ -251,10 +258,10 @@ func StartWithNetworkID(nid string) StartContainerOption { // starting the container. func StartWithCopyFiles(tarball []byte, path string) StartContainerOption { return func(cfg *startContainerConfig) { - if cfg.copyFiles == nil { - cfg.copyFiles = make(map[string][]byte) - } - cfg.copyFiles[path] = tarball + cfg.copyFiles = append(cfg.copyFiles, copyFilesConfig{ + to: path, + tarball: tarball, + }) } } @@ -467,6 +474,77 @@ func RunContainer(ctx context.Context, img string, opts ...RunContainerOption) ( return stdout.Bytes(), stderr.Bytes(), nil } +// CopyFromContainer copies files from a container to an afero filesystem. +func CopyFromContainer(ctx context.Context, cid, path string, fs afero.Fs) error { + cli, err := NewClient() + if err != nil { + return err + } + + reader, _, err := cli.CopyFromContainer(ctx, cid, path) + if err != nil { + return errors.Wrap(err, "failed to copy files from container") + } + + tarReader := tar.NewReader(reader) + + // Limit files to 1GiB to avoid excessive memory usage. + const maxFileSize = 1024 * 1024 * 1024 + + for { + header, err := tarReader.Next() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return errors.Wrap(err, "failed while reading tarball") + } + + cleanedPath := filepath.Clean(strings.TrimPrefix(header.Name, filepath.Base(path)+"/")) + + switch header.Typeflag { + case tar.TypeDir: + if err := fs.MkdirAll(cleanedPath, 0o755); err != nil { + return err + } + case tar.TypeReg: + outFile, err := fs.Create(cleanedPath) + if err != nil { + return err + } + + limitedReader := io.LimitReader(tarReader, maxFileSize) + if _, err := io.Copy(outFile, limitedReader); err != nil { + if cerr := outFile.Close(); cerr != nil { + err = errors.Wrap(cerr, "error while closing file") + } + return err + } + if cerr := outFile.Close(); cerr != nil { + return errors.Wrapf(cerr, "error closing file %s", header.Name) + } + } + } + + return nil +} + +// TarFromContainer retrieves files from a container in a tarball (Docker's +// native file transfer format). +func TarFromContainer(ctx context.Context, cid, path string) ([]byte, error) { + cli, err := NewClient() + if err != nil { + return nil, err + } + + reader, _, err := cli.CopyFromContainer(ctx, cid, path) + if err != nil { + return nil, errors.Wrap(err, "failed to copy files from container") + } + + return io.ReadAll(reader) +} + // NewClient creates a new Docker client configured from environment variables. func NewClient() (*client.Client, error) { cli, err := client.NewClientWithOpts(client.WithAPIVersionNegotiation(), client.FromEnv) diff --git a/internal/filesystem/filesystem.go b/internal/filesystem/filesystem.go new file mode 100644 index 0000000..b8d3767 --- /dev/null +++ b/internal/filesystem/filesystem.go @@ -0,0 +1,470 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package filesystem contains utilities for working with filesystems. +package filesystem + +import ( + "archive/tar" + "bytes" + "io" + "io/fs" + "os" + "path" + "path/filepath" + "strings" + + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// Walk is a replacement for afero.Walk that ensures paths are normalized for +// in-memory filesystem compatibility on Windows. This reimplementation uses +// path.Join instead of filepath.Join to always use forward slashes. +func Walk(fs afero.Fs, root string, walkFn filepath.WalkFunc) error { + root = filepath.ToSlash(root) + if root == "" { + root = "." + } + + info, err := fs.Stat(root) + if err != nil { + return walkFn(root, nil, err) + } + return walk(fs, root, info, walkFn) +} + +func walk(fs afero.Fs, p string, info os.FileInfo, walkFn filepath.WalkFunc) error { + err := walkFn(p, info, nil) + if err != nil { + if info.IsDir() && errors.Is(err, filepath.SkipDir) { + return nil + } + return err + } + + if !info.IsDir() { + return nil + } + + f, err := fs.Open(p) + if err != nil { + return walkFn(p, info, err) + } + defer f.Close() //nolint:errcheck // Can't do anything useful with this error. + + list, err := f.Readdir(-1) + if err != nil { + return walkFn(p, info, err) + } + + for _, fileInfo := range list { + filename := path.Join(p, fileInfo.Name()) + err = walk(fs, filename, fileInfo, walkFn) + if err != nil { + if !fileInfo.IsDir() || !errors.Is(err, filepath.SkipDir) { + return err + } + } + } + return nil +} + +// CopyFilesBetweenFs copies all files from the source filesystem (fromFS) to +// the destination filesystem (toFS). +func CopyFilesBetweenFs(fromFS, toFS afero.Fs) error { + return afero.Walk(fromFS, ".", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + dir := filepath.Dir(path) + if err := toFS.MkdirAll(dir, 0o755); err != nil { + return err + } + + fileData, err := afero.ReadFile(fromFS, path) + if err != nil { + return err + } + return afero.WriteFile(toFS, path, fileData, 0o644) + }) +} + +type fsToTarConfig struct { + symlinkBasePath *string + uidOverride *int + gidOverride *int + excludes []string +} + +// FSToTarOption configures the behavior of FSToTar. +type FSToTarOption func(*fsToTarConfig) + +// WithSymlinkBasePath provides the real base path of the filesystem, for use in +// symlink resolution. +func WithSymlinkBasePath(bp string) FSToTarOption { + return func(opts *fsToTarConfig) { + opts.symlinkBasePath = &bp + } +} + +// WithUIDOverride sets the owner UID to use in the tar archive. +func WithUIDOverride(uid int) FSToTarOption { + return func(opts *fsToTarConfig) { + opts.uidOverride = &uid + } +} + +// WithGIDOverride sets the owner GID to use in the tar archive. +func WithGIDOverride(gid int) FSToTarOption { + return func(opts *fsToTarConfig) { + opts.gidOverride = &gid + } +} + +// WithExcludePrefix excludes files with the given prefix from the tar archive. +func WithExcludePrefix(prefix string) FSToTarOption { + return func(opts *fsToTarConfig) { + opts.excludes = append(opts.excludes, prefix) + } +} + +// FSToTar produces a tarball of all the files in a filesystem. +func FSToTar(f afero.Fs, prefix string, opts ...FSToTarOption) ([]byte, error) { + cfg := &fsToTarConfig{} + for _, opt := range opts { + opt(cfg) + } + + var buf bytes.Buffer + tw := tar.NewWriter(&buf) + prefixHdr := &tar.Header{ + Name: prefix, + Typeflag: tar.TypeDir, + Mode: 0o777, + } + if cfg.uidOverride != nil { + prefixHdr.Uid = *cfg.uidOverride + } + if cfg.gidOverride != nil { + prefixHdr.Gid = *cfg.gidOverride + } + + if err := tw.WriteHeader(prefixHdr); err != nil { + return nil, errors.Wrap(err, "failed to create prefix directory in tar archive") + } + err := Walk(f, ".", func(name string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + for _, prefix := range cfg.excludes { + if strings.HasPrefix(name, prefix) { + return filepath.SkipDir + } + } + + if info.Mode()&os.ModeSymlink != 0 { + if cfg.symlinkBasePath == nil { + return errors.New("cannot follow symlinks unless base path is configured") + } + return addSymlinkToTar(tw, prefix, name, cfg) + } + + return addToTar(tw, prefix, f, name, info, cfg) + }) + if err != nil { + return nil, errors.Wrap(err, "failed to populate tar archive") + } + if err := tw.Close(); err != nil { + return nil, errors.Wrap(err, "failed to close tar archive") + } + + return buf.Bytes(), nil +} + +func addToTar(tw *tar.Writer, prefix string, f afero.Fs, filename string, info fs.FileInfo, cfg *fsToTarConfig) error { + fullPath := path.Join(prefix, filename) + + if info.IsDir() { + if fullPath == prefix { + return nil + } + + h, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + h.Name = fullPath + if cfg.uidOverride != nil { + h.Uid = *cfg.uidOverride + } + if cfg.gidOverride != nil { + h.Gid = *cfg.gidOverride + } + return tw.WriteHeader(h) + } + + if !info.Mode().IsRegular() { + return errors.Errorf("unhandled file mode %v", info.Mode()) + } + + h, err := tar.FileInfoHeader(info, "") + if err != nil { + return err + } + h.Name = fullPath + if cfg.uidOverride != nil { + h.Uid = *cfg.uidOverride + } + if cfg.gidOverride != nil { + h.Gid = *cfg.gidOverride + } + if err := tw.WriteHeader(h); err != nil { + return err + } + + file, err := f.Open(filename) + if err != nil { + return err + } + + _, err = io.Copy(tw, file) + return err +} + +func addSymlinkToTar(tw *tar.Writer, prefix string, symlinkPath string, cfg *fsToTarConfig) error { + osFs := afero.NewOsFs() + + targetPath, err := filepath.EvalSymlinks(filepath.Join(*cfg.symlinkBasePath, symlinkPath)) + if err != nil { + return nil //nolint:nilerr // Symlink target may be missing, safe to skip. + } + + exists, err := afero.Exists(osFs, targetPath) + if err != nil || !exists { + return err + } + + return afero.Walk(osFs, targetPath, func(symlinkedFile string, symlinkedInfo fs.FileInfo, err error) error { + if err != nil { + return err + } + + if symlinkedInfo.IsDir() { + return nil + } + + targetHeader, err := tar.FileInfoHeader(symlinkedInfo, "") + if err != nil { + return err + } + + relativePath, err := filepath.Rel(targetPath, symlinkedFile) + if err != nil { + return err + } + targetHeader.Name = path.Join(prefix, filepath.ToSlash(symlinkPath), filepath.ToSlash(relativePath)) + if cfg.uidOverride != nil { + targetHeader.Uid = *cfg.uidOverride + } + if cfg.gidOverride != nil { + targetHeader.Gid = *cfg.gidOverride + } + + if err := tw.WriteHeader(targetHeader); err != nil { + return err + } + + targetFile, err := osFs.Open(symlinkedFile) + if err != nil { + return err + } + + _, err = io.Copy(tw, targetFile) + return err + }) +} + +// CopyFolder recursively copies directory and all its contents from sourceDir +// to targetDir. +func CopyFolder(fs afero.Fs, sourceDir, targetDir string) error { + return afero.Walk(fs, sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return errors.Wrapf(err, "failed to determine relative path for %s", path) + } + + destPath := filepath.Join(targetDir, relPath) + + if info.IsDir() { + return fs.MkdirAll(destPath, 0o755) + } + + srcFile, err := fs.Open(path) + if err != nil { + return errors.Wrapf(err, "failed to open source file %s", path) + } + + destFile, err := fs.Create(destPath) + if err != nil { + return errors.Wrapf(err, "failed to create destination file %s", destPath) + } + + _, err = io.Copy(destFile, srcFile) + return errors.Wrapf(err, "failed to copy file from %s to %s", path, destPath) + }) +} + +// CopyFileIfExists copies a file from src to dst if the src file exists. +func CopyFileIfExists(fs afero.Fs, src, dst string) error { + exists, err := afero.Exists(fs, src) + if err != nil { + return err + } + if !exists { + return nil + } + + srcFile, err := fs.Open(src) + if err != nil { + return errors.Wrapf(err, "failed to open source file %s", src) + } + + destFile, err := fs.Create(dst) + if err != nil { + return errors.Wrapf(err, "failed to create destination file %s", dst) + } + + _, err = io.Copy(destFile, srcFile) + return errors.Wrapf(err, "failed to copy file from %s to %s", src, dst) +} + +// FindNestedFoldersWithPattern finds nested folders containing files that match +// a specified pattern. +func FindNestedFoldersWithPattern(fs afero.Fs, root string, pattern string) ([]string, error) { + var foldersWithFiles []string + + err := afero.Walk(fs, root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if !info.IsDir() { + return nil + } + + files, err := afero.ReadDir(fs, path) + if err != nil { + return err + } + + for _, f := range files { + if f.IsDir() { + continue + } + + match, _ := filepath.Match(pattern, f.Name()) + if match { + foldersWithFiles = append(foldersWithFiles, path) + break + } + } + + return nil + }) + + return foldersWithFiles, err +} + +// FullPath returns the full path to path within the given filesystem. If fs is +// not an afero.BasePathFs the original path is returned. +func FullPath(fs afero.Fs, path string) string { + bfs, ok := fs.(*afero.BasePathFs) + if ok { + return afero.FullBaseFsPath(bfs, path) + } + return path +} + +// MemOverlay returns a filesystem that uses the given filesystem as a base +// layer but writes changes to an in-memory overlay filesystem. +func MemOverlay(fs afero.Fs) afero.Fs { + return afero.NewBasePathFs(afero.NewCopyOnWriteFs(fs, afero.NewMemMapFs()), "/") +} + +// CreateSymlink creates a symlink in a BasePathFs, potentially to another +// BasePathFs that shares the same underlying filesystem. +func CreateSymlink(targetFS *afero.BasePathFs, targetPath string, sourceFS *afero.BasePathFs, sourcePath string) error { + realTargetPath, err := targetFS.RealPath(targetPath) + if err != nil { + return errors.Wrapf(err, "failed to get real path for targetPath: %s", targetPath) + } + + realSourcePath, err := sourceFS.RealPath(sourcePath) + if err != nil { + return errors.Wrapf(err, "failed to get real path for sourcePath: %s", sourcePath) + } + + symlinkParentDir := filepath.Dir(realTargetPath) + + absSymlinkParentDir, err := filepath.Abs(symlinkParentDir) + if err != nil { + return errors.Wrapf(err, "failed to get absolute path for symlink parent directory: %s", symlinkParentDir) + } + + absRealSourcePath, err := filepath.Abs(realSourcePath) + if err != nil { + return errors.Wrapf(err, "failed to get absolute path for source path: %s", realSourcePath) + } + + relativeSymlinkPath, err := filepath.Rel(absSymlinkParentDir, absRealSourcePath) + if err != nil { + return errors.Wrapf(err, "failed to calculate relative symlink path from %s to %s", absSymlinkParentDir, absRealSourcePath) + } + + symlinkPath := filepath.Join(absSymlinkParentDir, filepath.Base(realTargetPath)) + + if _, err := os.Lstat(symlinkPath); err == nil { + if err := os.Remove(symlinkPath); err != nil { + return errors.Wrapf(err, "failed to remove existing symlink or file at %s", symlinkPath) + } + } + + if err := os.Symlink(relativeSymlinkPath, symlinkPath); err != nil { + baseMsg := "failed to create symlink from " + relativeSymlinkPath + " to " + symlinkPath + if strings.Contains(err.Error(), "A required privilege is not held by the client") { + return errors.Errorf( + "%s: %v\n\nOn Windows, creating symlinks requires either:\n"+ + " 1. Running as Administrator, or\n"+ + " 2. Enabling Developer Mode (Settings > Update & Security > For developers > Developer Mode)", + baseMsg, err, + ) + } + return errors.Wrap(err, baseMsg) + } + + return nil +} diff --git a/internal/git/git.go b/internal/git/git.go new file mode 100644 index 0000000..297637a --- /dev/null +++ b/internal/git/git.go @@ -0,0 +1,252 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package git contains functions to interact with repos. +package git + +import ( + "strings" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/config" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + "github.com/go-git/go-git/v5/plumbing/transport/ssh" + "github.com/go-git/go-git/v5/storage" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// CloneOptions configure for git actions. +type CloneOptions struct { + Repo string + RefName string + Directory string + Path string // Optional path for sparse checkout +} + +// AuthProvider wraps a specific auth method. +type AuthProvider interface { + GetAuthMethod() (transport.AuthMethod, error) +} + +// HTTPSAuthProvider provides authentication for HTTPS repositories. +type HTTPSAuthProvider struct { + Username string + Password string +} + +// GetAuthMethod returns the HTTP BasicAuth transport method. +func (a *HTTPSAuthProvider) GetAuthMethod() (transport.AuthMethod, error) { + if a.Username != "" || a.Password != "" { + return &http.BasicAuth{Username: a.Username, Password: a.Password}, nil + } + // Return nil authenticator to allow anonymous cloning. + return nil, nil +} + +// SSHAuthProvider provides authentication for SSH repositories. +type SSHAuthProvider struct { + Username string + PrivateKeyPath string + Passphrase string +} + +// GetAuthMethod returns the SSH PublicKey transport method. +func (a *SSHAuthProvider) GetAuthMethod() (transport.AuthMethod, error) { + username := a.Username + if username == "" { + username = "git" + } + + authMethod, err := ssh.NewPublicKeysFromFile(username, a.PrivateKeyPath, a.Passphrase) + if err != nil { + return nil, errors.Wrapf(err, "failed to create SSH public key auth method for user %q", username) + } + + return authMethod, nil +} + +// SSHAgentAuthProvider provides authentication using the SSH agent. +type SSHAgentAuthProvider struct { + Username string +} + +// GetAuthMethod returns the SSH agent auth method. +func (a *SSHAgentAuthProvider) GetAuthMethod() (transport.AuthMethod, error) { + username := a.Username + if username == "" { + username = "git" + } + return ssh.NewSSHAgentAuth(username) +} + +// CompositeAuthProvider tries multiple auth providers in order until one succeeds. +type CompositeAuthProvider struct { + Providers []AuthProvider +} + +// GetAuthMethod returns the first successful auth method. +func (c *CompositeAuthProvider) GetAuthMethod() (transport.AuthMethod, error) { + var lastErr error + for _, p := range c.Providers { + method, err := p.GetAuthMethod() + if err == nil { + return method, nil + } + lastErr = err + } + if lastErr != nil { + return nil, lastErr + } + return nil, errors.New("no auth providers configured") +} + +// Cloner can clone git repositories with (optional) authentication. +type Cloner interface { + CloneRepository(store storage.Storer, fs billy.Filesystem, auth AuthProvider, opts CloneOptions) (*plumbing.Reference, error) +} + +// DefaultCloner is the default implementation of Cloner. +type DefaultCloner struct{} + +// CheckSHA256Hash checks if a string is a valid git SHA hash (40 hex characters). +func CheckSHA256Hash(ref string) bool { + if len(ref) != 40 { + return false + } + + for _, c := range ref { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') && (c < 'A' || c > 'F') { + return false + } + } + + return true +} + +func extractBranchName(ref string) string { + if strings.HasPrefix(ref, "refs/heads/") { + return ref[11:] + } + if ref != "" && !strings.HasPrefix(ref, "refs/") { + return ref + } + return "main" +} + +func handleSHACheckout(repoObj *git.Repository, authMethod transport.AuthMethod, sha string, sparsePath string) error { + err := repoObj.Fetch(&git.FetchOptions{ + Auth: authMethod, + RefSpecs: []config.RefSpec{ + config.RefSpec("+refs/heads/*:refs/remotes/origin/*"), + }, + }) + if err != nil && !errors.Is(err, git.NoErrAlreadyUpToDate) { + return errors.Wrap(err, "failed to fetch refs") + } + + worktree, err := repoObj.Worktree() + if err != nil { + return errors.Wrap(err, "failed to get worktree") + } + + checkoutOpts := &git.CheckoutOptions{ + Hash: plumbing.NewHash(sha), + } + if sparsePath != "" { + checkoutOpts.SparseCheckoutDirectories = []string{sparsePath} + } + + if err := worktree.Checkout(checkoutOpts); err != nil { + if sparsePath != "" { + return errors.Wrapf(err, "failed to sparse checkout commit %s with path %q", sha, sparsePath) + } + return errors.Wrapf(err, "failed to checkout commit %s", sha) + } + + return nil +} + +// CloneRepository clones a git repository using the provided CloneOptions and AuthProvider. +func (dc *DefaultCloner) CloneRepository(store storage.Storer, fs billy.Filesystem, auth AuthProvider, opts CloneOptions) (*plumbing.Reference, error) { + authMethod, err := auth.GetAuthMethod() + if err != nil { + return nil, errors.Wrap(err, "failed to get authentication method") + } + + isTag := strings.HasPrefix(opts.RefName, "refs/tags/") + + refToCheck := strings.TrimPrefix(opts.RefName, "refs/heads/") + isSHA := CheckSHA256Hash(refToCheck) + + cloneOptions := &git.CloneOptions{ + URL: opts.Repo, + Depth: 1, + Auth: authMethod, + NoCheckout: opts.Path != "", + Tags: git.NoTags, + } + + if !isSHA { + cloneOptions.ReferenceName = plumbing.ReferenceName(opts.RefName) + cloneOptions.SingleBranch = !isTag + } + + repoObj, err := git.Clone(store, fs, cloneOptions) + if err != nil { + return nil, errors.Wrapf(err, "failed to clone repository from %q", opts.Repo) + } + + if isSHA { + if err := handleSHACheckout(repoObj, authMethod, refToCheck, opts.Path); err != nil { + return nil, err + } + } + + if opts.Path != "" && !isSHA { + worktree, err := repoObj.Worktree() + if err != nil { + return nil, errors.Wrap(err, "failed to get worktree") + } + + var checkoutRef plumbing.ReferenceName + switch { + case isTag: + checkoutRef = plumbing.ReferenceName(opts.RefName) + default: + branchName := extractBranchName(opts.RefName) + checkoutRef = plumbing.ReferenceName("refs/remotes/origin/" + branchName) + } + + checkoutOptions := &git.CheckoutOptions{ + Branch: checkoutRef, + SparseCheckoutDirectories: []string{opts.Path}, + } + + if err := worktree.Checkout(checkoutOptions); err != nil { + return nil, errors.Wrapf(err, "failed to sparse checkout path %q", opts.Path) + } + } + + ref, err := repoObj.Head() + if err != nil { + return nil, errors.Wrapf(err, "failed to get repository's HEAD from %q", opts.Repo) + } + return ref, nil +} diff --git a/internal/kcl/import.go b/internal/kcl/import.go new file mode 100644 index 0000000..722b18e --- /dev/null +++ b/internal/kcl/import.go @@ -0,0 +1,76 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package kcl contains helpers for KCL function generation. +package kcl + +import ( + "path/filepath" + "strings" +) + +// FormatKclImportPath converts a schema directory path under schemas/kcl/ to a +// KCL import path prefixed with "models." and generates a unique alias. +// +// For example, given a path like "kcl/io.example.platform.v1alpha1" (relative +// to the schemas root), this produces: +// +// importPath: "models.io.example.platform.v1alpha1" +// alias: "platformv1alpha1" +// +// The "models." prefix matches the kcl.mod dependency name (models = { path = +// "./model" }) and the symlink created at function generation time. +func FormatKclImportPath(path string, existingAliases map[string]bool) (string, string) { + path = filepath.ToSlash(path) + + // Strip the leading "kcl/" prefix to get the schema-relative path. + const prefix = "kcl/" + if !strings.HasPrefix(path, prefix) { + return "", "" + } + schemaPath := path[len(prefix):] + if schemaPath == "" { + return "", "" + } + + // The import path is "models." + the schema path with slashes converted to + // dots and hyphens to underscores. + importPath := "models." + strings.ReplaceAll(schemaPath, "/", ".") + importPath = strings.ReplaceAll(importPath, "-", "_") + + // Split into components for alias generation. + parts := strings.Split(importPath, ".") + if len(parts) < 2 { + return "", "" + } + + // Default alias is the last two components joined, e.g. "ec2v1beta1". + alias := parts[len(parts)-2] + parts[len(parts)-1] + + // Resolve collisions by adding more context from earlier components. + if existingAliases[alias] { + for i := 3; i <= len(parts); i++ { + alias = strings.Join(parts[len(parts)-i:], "") + if !existingAliases[alias] { + break + } + } + } + + existingAliases[alias] = true + + return importPath, alias +} diff --git a/internal/kcl/import_test.go b/internal/kcl/import_test.go new file mode 100644 index 0000000..81af4ee --- /dev/null +++ b/internal/kcl/import_test.go @@ -0,0 +1,90 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package kcl + +import ( + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestFormatKclImportPath(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + path string + existingAliases map[string]bool + wantImport string + wantAlias string + }{ + "BasicPath": { + path: "kcl/io.upbound.aws.ec2.v1beta1", + existingAliases: map[string]bool{}, + wantImport: "models.io.upbound.aws.ec2.v1beta1", + wantAlias: "ec2v1beta1", + }, + "NestedPath": { + path: "kcl/io/crossplane/contrib/example/v1alpha1", + existingAliases: map[string]bool{}, + wantImport: "models.io.crossplane.contrib.example.v1alpha1", + wantAlias: "examplev1alpha1", + }, + "AliasConflict": { + path: "kcl/io/example/platformref/aws/v1alpha1", + existingAliases: map[string]bool{"awsv1alpha1": true}, + wantImport: "models.io.example.platformref.aws.v1alpha1", + wantAlias: "platformrefawsv1alpha1", + }, + "PathWithHyphens": { + path: "kcl/io/k8s/kube-aggregator/apis/apiregistration/v1", + existingAliases: map[string]bool{}, + wantImport: "models.io.k8s.kube_aggregator.apis.apiregistration.v1", + wantAlias: "apiregistrationv1", + }, + "NoKCLPrefix": { + path: "python/io/example/aws", + existingAliases: map[string]bool{}, + wantImport: "", + wantAlias: "", + }, + "JustKCLPrefix": { + path: "kcl/", + existingAliases: map[string]bool{}, + wantImport: "", + wantAlias: "", + }, + "TopLevelPath": { + path: "kcl/io.example.aws.v1alpha1", + existingAliases: map[string]bool{}, + wantImport: "models.io.example.aws.v1alpha1", + wantAlias: "awsv1alpha1", + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + gotImport, gotAlias := FormatKclImportPath(tc.path, tc.existingAliases) + if diff := cmp.Diff(tc.wantImport, gotImport); diff != "" { + t.Errorf("importPath mismatch (-want +got):\n%s", diff) + } + if diff := cmp.Diff(tc.wantAlias, gotAlias); diff != "" { + t.Errorf("alias mismatch (-want +got):\n%s", diff) + } + }) + } +} diff --git a/internal/project/build.go b/internal/project/build.go new file mode 100644 index 0000000..92628a9 --- /dev/null +++ b/internal/project/build.go @@ -0,0 +1,600 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package project contains logic for building Crossplane projects. +package project + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + "sync" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/examples" + pyaml "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser/yaml" + + xpv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + extv1alpha1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1alpha1" + xpv2 "github.com/crossplane/crossplane/apis/v2/apiextensions/v2" + xpv1alpha1 "github.com/crossplane/crossplane/apis/v2/ops/v1alpha1" + xpmetav1 "github.com/crossplane/crossplane/apis/v2/pkg/meta/v1" + xpkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + + devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/async" + "github.com/crossplane/cli/v2/internal/dependency" + "github.com/crossplane/cli/v2/internal/project/functions" + "github.com/crossplane/cli/v2/internal/schemas/manager" +) + +const ( + // ConfigurationTag is the tag used for the configuration image in the built + // package. + ConfigurationTag = "configuration" +) + +// ImageTagMap is a map of container image tags to images. +type ImageTagMap map[name.Tag]v1.Image + +// Builder is able to build a project into a set of packages. +type Builder interface { + // Build builds a project into a set of packages. It returns a map + // containing images that were built from the project. The returned map will + // always include one image with the ConfigurationTag, which is the + // configuration package built from the APIs found in the project. + Build(ctx context.Context, project *devv1alpha1.Project, projectFS afero.Fs, opts ...BuildOption) (ImageTagMap, error) +} + +// BuilderOption configures a builder. +type BuilderOption func(b *realBuilder) + +// BuildWithFunctionIdentifier sets the function identifier that will be used to +// find function builders for any functions in a project. +func BuildWithFunctionIdentifier(i functions.Identifier) BuilderOption { + return func(b *realBuilder) { + b.functionIdentifier = i + } +} + +// BuildWithMaxConcurrency sets the maximum concurrency for building embedded +// functions. +func BuildWithMaxConcurrency(n uint) BuilderOption { + return func(b *realBuilder) { + b.maxConcurrency = n + } +} + +// BuildWithSchemaManager sets the schema manager that will be used to generate +// language-specific schemas from XRDs before building functions. +func BuildWithSchemaManager(m *manager.Manager) BuilderOption { + return func(b *realBuilder) { + b.schemaManager = m + } +} + +// BuildWithDependencyManager sets the dependency manager that will be used to +// ensure schemas are present for the project's declared dependencies before +// building functions. +func BuildWithDependencyManager(m *dependency.Manager) BuilderOption { + return func(b *realBuilder) { + b.dependencyManager = m + } +} + +// BuildOption configures a build. +type BuildOption func(o *buildOptions) + +type buildOptions struct { + log logging.Logger + projectBasePath string + eventCh async.EventChannel +} + +// BuildWithLogger provides a logger for progress updates during the build. +func BuildWithLogger(l logging.Logger) BuildOption { + return func(o *buildOptions) { + o.log = l + } +} + +// BuildWithEventChannel provides a channel for sending progress events during +// the build. +func BuildWithEventChannel(ch async.EventChannel) BuildOption { + return func(o *buildOptions) { + o.eventCh = ch + } +} + +// BuildWithProjectBasePath sets the real on-disk base path of the project. This +// path will be used for following symlinks. If not set it will be inferred from +// the project FS, which works only when the project FS is an afero.BasePathFs. +func BuildWithProjectBasePath(path string) BuildOption { + return func(o *buildOptions) { + o.projectBasePath = path + } +} + +type realBuilder struct { + functionIdentifier functions.Identifier + maxConcurrency uint + schemaManager *manager.Manager + dependencyManager *dependency.Manager +} + +// Build implements the Builder interface. +func (b *realBuilder) Build(ctx context.Context, project *devv1alpha1.Project, projectFS afero.Fs, opts ...BuildOption) (ImageTagMap, error) { //nolint:gocyclo // This is the main build orchestration. + o := &buildOptions{ + log: logging.NewNopLogger(), + } + for _, opt := range opts { + opt(o) + } + + // Scaffold a configuration based on the metadata in the project. + cfg := &xpmetav1.Configuration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: xpmetav1.SchemeGroupVersion.String(), + Kind: xpmetav1.ConfigurationKind, + }, + ObjectMeta: cfgMetaFromProject(project), + Spec: xpmetav1.ConfigurationSpec{ + MetaSpec: xpmetav1.MetaSpec{ + Crossplane: project.Spec.Crossplane, + DependsOn: runtimeDependencies(project), + }, + }, + } + + // Default to v2 constraint. + if cfg.Spec.Crossplane == nil || cfg.Spec.Crossplane.Version == "" { + cfg.Spec.Crossplane = &xpmetav1.CrossplaneConstraints{ + Version: ">=v2.0.0-rc.0", + } + } + + functionsSource := afero.NewBasePathFs(projectFS, project.Spec.Paths.Functions) + apisSource := projectFS + apiExcludes := []string{ + project.Spec.Paths.Examples, + project.Spec.Paths.Functions, + project.Spec.Paths.Operations, + } + if project.Spec.Paths.APIs != "/" { + apisSource = afero.NewBasePathFs(projectFS, project.Spec.Paths.APIs) + apiExcludes = []string{} + } + + // Not all projects have operations; ignore them if not present. + operationsSource := afero.NewMemMapFs() + opsExist, err := afero.DirExists(projectFS, project.Spec.Paths.Operations) + if err != nil { + return nil, err + } + if opsExist { + operationsSource = afero.NewBasePathFs(projectFS, project.Spec.Paths.Operations) + } + + // Collect resources (XRDs, MRAPs, compositions, and operations). + packageFS := afero.NewMemMapFs() + o.log.Debug("Collecting resources") + o.eventCh.SendEvent("Collecting resources", async.EventStatusStarted) + + apiGVKs := []string{ + xpv1.CompositeResourceDefinitionGroupVersionKind.String(), + xpv2.CompositeResourceDefinitionGroupVersionKind.String(), + xpv1.CompositionGroupVersionKind.String(), + extv1alpha1.ManagedResourceActivationPolicyGroupVersionKind.String(), + } + if err := collectResources(packageFS, apisSource, apiGVKs, apiExcludes); err != nil { + o.eventCh.SendEvent("Collecting resources", async.EventStatusFailure) + return nil, errors.Wrap(err, "failed to collect API resources") + } + + opsGVKs := []string{ + xpv1alpha1.OperationGroupVersionKind.String(), + xpv1alpha1.WatchOperationGroupVersionKind.String(), + xpv1alpha1.CronOperationGroupVersionKind.String(), + } + if err := collectResources(packageFS, operationsSource, opsGVKs, nil); err != nil { + o.eventCh.SendEvent("Collecting resources", async.EventStatusFailure) + return nil, errors.Wrap(err, "failed to collect operation resources") + } + o.eventCh.SendEvent("Collecting resources", async.EventStatusSuccess) + + // Generate schemas for declared dependencies. The dependency manager + // short-circuits sources whose recorded version still matches, so this is + // cheap on the steady-state path. + if b.dependencyManager != nil { + if err := b.dependencyManager.AddAll(ctx, o.eventCh); err != nil { + return nil, errors.Wrap(err, "failed to generate dependency schemas") + } + } + + // Generate language-specific schemas from XRDs. + if b.schemaManager != nil { + o.eventCh.SendEvent("Generating schemas", async.EventStatusStarted) + if _, err := b.schemaManager.Generate(ctx, manager.NewFSSource(project.Spec.Paths.APIs, apisSource)); err != nil { + o.eventCh.SendEvent("Generating schemas", async.EventStatusFailure) + return nil, errors.Wrap(err, "failed to generate schemas") + } + o.eventCh.SendEvent("Generating schemas", async.EventStatusSuccess) + } + + // Find and build embedded functions. + o.log.Debug("Building functions") + imgMap, deps, err := b.buildFunctions(ctx, projectFS, functionsSource, project, o.projectBasePath, o.eventCh) + if err != nil { + return nil, err + } + cfg.Spec.DependsOn = append(cfg.Spec.DependsOn, deps...) + + // Build the configuration package. + o.log.Debug("Building configuration package") + o.eventCh.SendEvent("Building configuration package", async.EventStatusStarted) + + y, err := yaml.Marshal(cfg) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal package metadata") + } + err = afero.WriteFile(packageFS, "/crossplane.yaml", y, 0o644) + if err != nil { + return nil, errors.Wrap(err, "failed to write package metadata") + } + + pp, err := pyaml.New() + if err != nil { + return nil, errors.Wrap(err, "failed to create parser") + } + builder := xpkg.New( + parser.NewFsBackend(packageFS, parser.FsDir("/")), + parser.NewFsBackend(afero.NewBasePathFs(projectFS, project.Spec.Paths.Examples), + parser.FsDir("/"), + parser.FsFilters(parser.SkipNotYAML()), + ), + pp, + examples.New(), + ) + + img, _, err := builder.Build(ctx) + if err != nil { + o.eventCh.SendEvent("Building configuration package", async.EventStatusFailure) + return nil, errors.Wrap(err, "failed to build package") + } + + imgTag, err := name.NewTag(fmt.Sprintf("%s:%s", project.Spec.Repository, ConfigurationTag)) + if err != nil { + o.eventCh.SendEvent("Building configuration package", async.EventStatusFailure) + return nil, errors.Wrap(err, "failed to construct image tag") + } + imgMap[imgTag] = img + + o.eventCh.SendEvent("Building configuration package", async.EventStatusSuccess) + + return imgMap, nil +} + +// buildFunctions builds the embedded functions found in directories at the top +// level of the provided filesystem. +func (b *realBuilder) buildFunctions(ctx context.Context, projectFS, fromFS afero.Fs, project *devv1alpha1.Project, basePath string, eventCh async.EventChannel) (ImageTagMap, []xpmetav1.Dependency, error) { + var ( + imgMap = make(map[name.Tag]v1.Image) + imgMu sync.Mutex + ) + + infos, err := afero.ReadDir(fromFS, "/") + switch { + case os.IsNotExist(err): + return imgMap, nil, nil + case err != nil: + return nil, nil, errors.Wrap(err, "failed to list functions directory") + } + + fnDirs := make([]string, 0, len(infos)) + for _, info := range infos { + if info.IsDir() { + fnDirs = append(fnDirs, info.Name()) + } + } + + deps := make([]xpmetav1.Dependency, len(fnDirs)) + eg, ctx := errgroup.WithContext(ctx) + + sem := make(chan struct{}, b.maxConcurrency) + for i, fnName := range fnDirs { + eg.Go(func() error { + sem <- struct{}{} + defer func() { + <-sem + }() + + eventText := fmt.Sprintf("Building function %s", fnName) + eventCh.SendEvent(eventText, async.EventStatusStarted) + + fnRepo := fmt.Sprintf("%s_%s", project.Spec.Repository, fnName) + fnFS := afero.NewBasePathFs(fromFS, fnName) + fnBasePath := "" + if basePath != "" { + fnBasePath = filepath.Join(basePath, project.Spec.Paths.Functions, fnName) + } + imgs, err := b.buildFunction(ctx, projectFS, fnFS, project, fnName, fnBasePath) + if err != nil { + eventCh.SendEvent(eventText, async.EventStatusFailure) + return errors.Wrapf(err, "failed to build function %q", fnName) + } + + idx, imgs, err := BuildIndex(imgs...) + if err != nil { + return errors.Wrapf(err, "failed to construct index for function image %q", fnName) + } + dgst, err := idx.Digest() + if err != nil { + return errors.Wrapf(err, "failed to get index digest for function image %q", fnName) + } + deps[i] = xpmetav1.Dependency{ + APIVersion: new(xpkgv1.FunctionGroupVersionKind.GroupVersion().String()), + Kind: new(xpkgv1.FunctionKind), + Package: &fnRepo, + Version: dgst.String(), + } + + for _, img := range imgs { + cfgFile, err := img.ConfigFile() + if err != nil { + return errors.Wrapf(err, "failed to get config for function image %q", fnName) + } + + tag := fmt.Sprintf("%s:%s", fnRepo, cfgFile.Architecture) + imgTag, err := name.NewTag(tag) + if err != nil { + return errors.Wrapf(err, "failed to construct tag for function image %q", fnName) + } + imgMu.Lock() + imgMap[imgTag] = img + imgMu.Unlock() + } + + eventCh.SendEvent(eventText, async.EventStatusSuccess) + + return nil + }) + } + + err = eg.Wait() + if err != nil { + return nil, nil, err + } + + return imgMap, deps, nil +} + +// buildFunction builds images for a single function whose source resides in the +// given filesystem. +func (b *realBuilder) buildFunction(ctx context.Context, projectFS, fromFS afero.Fs, project *devv1alpha1.Project, fnName string, basePath string) ([]v1.Image, error) { + fn := &xpmetav1.Function{ + TypeMeta: metav1.TypeMeta{ + APIVersion: xpmetav1.SchemeGroupVersion.String(), + Kind: xpmetav1.FunctionKind, + }, + ObjectMeta: fnMetaFromProject(project, fnName), + Spec: xpmetav1.FunctionSpec{ + MetaSpec: xpmetav1.MetaSpec{ + Capabilities: []string{ + xpmetav1.FunctionCapabilityComposition, + xpmetav1.FunctionCapabilityOperation, + }, + }, + }, + } + metaFS := afero.NewMemMapFs() + y, err := yaml.Marshal(fn) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal function metadata") + } + err = afero.WriteFile(metaFS, "/crossplane.yaml", y, 0o644) + if err != nil { + return nil, errors.Wrap(err, "failed to write function metadata") + } + + examplesParser := parser.NewEchoBackend("") + examplesExist, err := afero.IsDir(fromFS, "/examples") + switch { + case err == nil, os.IsNotExist(err): + default: + return nil, errors.Wrap(err, "failed to check for examples") + } + if examplesExist { + examplesParser = parser.NewFsBackend(fromFS, + parser.FsDir("/examples"), + parser.FsFilters(parser.SkipNotYAML()), + ) + } + + pp, err := pyaml.New() + if err != nil { + return nil, errors.Wrap(err, "failed to create parser") + } + builder := xpkg.New( + parser.NewFsBackend(metaFS, parser.FsDir("/")), + examplesParser, + pp, + examples.New(), + ) + + fnBuilder, err := b.functionIdentifier.Identify(fromFS, project.Spec.ImageConfigs) + if err != nil { + return nil, errors.Wrap(err, "failed to find a builder") + } + + if bfs, ok := fromFS.(*afero.BasePathFs); ok && basePath == "" { + basePath = afero.FullBaseFsPath(bfs, ".") + } + + runtimeImages, err := fnBuilder.Build(ctx, functions.BuildContext{ + ProjectFS: projectFS, + FunctionPath: filepath.Join(project.Spec.Paths.Functions, fnName), + SchemasPath: project.Spec.Paths.Schemas, + Architectures: project.Spec.Architectures, + OSBasePath: basePath, + }) + if err != nil { + return nil, errors.Wrap(err, "failed to build runtime images") + } + + pkgImages := make([]v1.Image, 0, len(runtimeImages)) + + for _, img := range runtimeImages { + pkgImage, _, err := builder.Build(ctx, xpkg.WithBase(img)) + if err != nil { + return nil, errors.Wrap(err, "failed to build function package") + } + pkgImages = append(pkgImages, pkgImage) + } + + return pkgImages, nil +} + +func collectResources(toFS afero.Fs, fromFS afero.Fs, gvks []string, exclude []string) error { + return afero.Walk(fromFS, "/", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + for _, excl := range exclude { + if strings.HasPrefix(path, excl) { + return filepath.SkipDir + } + } + + if info.IsDir() { + return nil + } + ext := filepath.Ext(path) + if ext != ".yaml" && ext != ".yml" { + return nil + } + + var u metav1.TypeMeta + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + err = yaml.Unmarshal(bs, &u) + if err != nil { + return errors.Wrapf(err, "failed to parse file %q", path) + } + + if !slices.Contains(gvks, u.GroupVersionKind().String()) { + return nil + } + + err = toFS.MkdirAll(filepath.Dir(path), 0o755) + if err != nil { + return errors.Wrapf(err, "failed to create directory for %q", path) + } + + err = afero.WriteFile(toFS, path, bs, 0o644) + if err != nil { + return errors.Wrapf(err, "failed to write file %q to package", path) + } + + return nil + }) +} + +// runtimeDependencies extracts the runtime (non-APIOnly) xpkg dependencies +// from a project and converts them to package metadata dependencies for use in +// the built Configuration package. +func runtimeDependencies(proj *devv1alpha1.Project) []xpmetav1.Dependency { + deps := make([]xpmetav1.Dependency, 0, len(proj.Spec.Dependencies)) + for _, d := range proj.Spec.Dependencies { + if d.Type != devv1alpha1.DependencyTypeXpkg { + continue + } + if d.Xpkg == nil || d.Xpkg.APIOnly { + continue + } + + deps = append(deps, xpmetav1.Dependency{ + APIVersion: &d.Xpkg.APIVersion, + Kind: &d.Xpkg.Kind, + Package: &d.Xpkg.Package, + Version: d.Xpkg.Version, + }) + } + return deps +} + +func cfgMetaFromProject(proj *devv1alpha1.Project) metav1.ObjectMeta { + meta := proj.ObjectMeta.DeepCopy() + + if meta.Annotations == nil { + meta.Annotations = make(map[string]string) + } + + meta.Annotations["meta.crossplane.io/maintainer"] = proj.Spec.Maintainer + meta.Annotations["meta.crossplane.io/source"] = proj.Spec.Source + meta.Annotations["meta.crossplane.io/license"] = proj.Spec.License + meta.Annotations["meta.crossplane.io/description"] = proj.Spec.Description + meta.Annotations["meta.crossplane.io/readme"] = proj.Spec.Readme + + return *meta +} + +func fnMetaFromProject(proj *devv1alpha1.Project, fnName string) metav1.ObjectMeta { + meta := proj.ObjectMeta.DeepCopy() + + meta.Name = fmt.Sprintf("%s-%s", meta.Name, fnName) + + if meta.Annotations == nil { + meta.Annotations = make(map[string]string) + } + + meta.Annotations["meta.crossplane.io/maintainer"] = proj.Spec.Maintainer + meta.Annotations["meta.crossplane.io/source"] = proj.Spec.Source + meta.Annotations["meta.crossplane.io/license"] = proj.Spec.License + meta.Annotations["meta.crossplane.io/description"] = fmt.Sprintf("Function %s from project %s", fnName, proj.Name) + + return *meta +} + +// NewBuilder returns a new project builder. +func NewBuilder(opts ...BuilderOption) Builder { + b := &realBuilder{ + functionIdentifier: functions.DefaultIdentifier, + maxConcurrency: 8, + } + + for _, opt := range opts { + opt(b) + } + + return b +} diff --git a/internal/project/certs/cert_generator.go b/internal/project/certs/cert_generator.go new file mode 100644 index 0000000..3e22cdc --- /dev/null +++ b/internal/project/certs/cert_generator.go @@ -0,0 +1,96 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package certs generates certificates for the local dev registry. +package certs + +import ( + "bytes" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// CertificateSigner is the parent's certificate and key that will be used to +// sign the certificate. +type CertificateSigner struct { + certificate *x509.Certificate + key *rsa.PrivateKey + certificatePEM []byte +} + +// CertificateGenerator can return you TLS certificate valid for given domains. +type CertificateGenerator interface { + Generate(c *x509.Certificate, cs *CertificateSigner) (key, crt []byte, err error) +} + +var pkixName = pkix.Name{ //nolint:gochecknoglobals // We treat this as a constant. + CommonName: "Crossplane CLI", + Organization: []string{"Crossplane"}, + Country: []string{"Earth"}, + Province: []string{"Earth"}, + Locality: []string{"Earth"}, +} + +// NewCertGenerator returns a new CertGenerator. +func NewCertGenerator() *CertGenerator { + return &CertGenerator{} +} + +// CertGenerator generates a root CA and key that can be used by client and +// servers. +type CertGenerator struct{} + +// Generate creates TLS Secret with 10 years expiration date that is valid +// for the given domains. +func (*CertGenerator) Generate(cert *x509.Certificate, signer *CertificateSigner) (key []byte, crt []byte, err error) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, nil, errors.Wrap(err, "cannot generate private key") + } + + if signer == nil { + signer = &CertificateSigner{ + certificate: cert, + key: privateKey, + } + } + + certBytes, err := x509.CreateCertificate(rand.Reader, cert, signer.certificate, &privateKey.PublicKey, signer.key) + if err != nil { + return nil, nil, errors.Wrap(err, "cannot create certificate with key") + } + + certPEM := new(bytes.Buffer) + if err := pem.Encode(certPEM, &pem.Block{ + Type: "CERTIFICATE", + Bytes: certBytes, + }); err != nil { + return nil, nil, errors.Wrap(err, "cannot encode cert into PEM") + } + certKeyPEM := new(bytes.Buffer) + if err := pem.Encode(certKeyPEM, &pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(privateKey), + }); err != nil { + return nil, nil, errors.Wrap(err, "cannot encode private key into PEM") + } + return certKeyPEM.Bytes(), certPEM.Bytes(), nil +} diff --git a/internal/project/certs/tls.go b/internal/project/certs/tls.go new file mode 100644 index 0000000..9628d8f --- /dev/null +++ b/internal/project/certs/tls.go @@ -0,0 +1,252 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package certs + +import ( + "context" + "crypto/x509" + "encoding/pem" + "math/big" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/types" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource" +) + +const ( + // RootCACertSecretName is the name of the secret that will store CA + // certificates. The rest of the certificates created per entity will be + // signed by this CA. + RootCACertSecretName = "crossplane-ca" + + // SecretKeyCACert is the secret key of CA certificate. + SecretKeyCACert = "ca.crt" +) + +// TLSCertificateGenerator generates TLS certificate bundles and stores them +// in k8s secrets. +type TLSCertificateGenerator struct { + namespace string + caSecretName string + tlsServerSecretName *string + tlsServerDNSNames []string + certificate CertificateGenerator + log logging.Logger +} + +// TLSCertificateGeneratorOption configures TLSCertificateGenerator behavior. +type TLSCertificateGeneratorOption func(*TLSCertificateGenerator) + +// TLSCertificateGeneratorWithLogger configures the logger. +func TLSCertificateGeneratorWithLogger(log logging.Logger) TLSCertificateGeneratorOption { + return func(g *TLSCertificateGenerator) { + g.log = log + } +} + +// TLSCertificateGeneratorWithServerSecretName sets the server secret name and +// DNS names. +func TLSCertificateGeneratorWithServerSecretName(s string, dnsNames []string) TLSCertificateGeneratorOption { + return func(g *TLSCertificateGenerator) { + g.tlsServerSecretName = &s + g.tlsServerDNSNames = dnsNames + } +} + +// NewTLSCertificateGenerator returns a new TLSCertificateGenerator. +func NewTLSCertificateGenerator(ns, caSecret string, opts ...TLSCertificateGeneratorOption) *TLSCertificateGenerator { + e := &TLSCertificateGenerator{ + namespace: ns, + caSecretName: caSecret, + certificate: NewCertGenerator(), + log: logging.NewNopLogger(), + } + + for _, f := range opts { + f(e) + } + return e +} + +func (e *TLSCertificateGenerator) loadOrGenerateCA(ctx context.Context, kube client.Client, nn types.NamespacedName) (*CertificateSigner, error) { + caSecret := &corev1.Secret{} + + err := kube.Get(ctx, nn, caSecret) + if resource.IgnoreNotFound(err) != nil { + return nil, errors.Wrapf(err, "cannot get TLS secret: %s", nn.Name) + } + + create := true + if err == nil { + create = false + kd := caSecret.Data[corev1.TLSPrivateKeyKey] + cd := caSecret.Data[corev1.TLSCertKey] + if len(kd) != 0 && len(cd) != 0 { + e.log.Info("TLS CA secret is complete.") + return parseCertificateSigner(kd, cd) + } + } + e.log.Info("TLS CA secret is empty or not complete, generating a new CA...") + + a := &x509.Certificate{ + SerialNumber: big.NewInt(2022), + Subject: pkixName, + Issuer: pkixName, + DNSNames: []string{RootCACertSecretName}, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: true, + KeyUsage: x509.KeyUsageCRLSign | x509.KeyUsageCertSign, + BasicConstraintsValid: true, + } + + caKeyByte, caCrtByte, err := e.certificate.Generate(a, nil) + if err != nil { + return nil, errors.Wrap(err, "cannot generate CA certificate") + } + + caSecret.Name = nn.Name + caSecret.Namespace = nn.Namespace + caSecret.Data = map[string][]byte{ + corev1.TLSCertKey: caCrtByte, + corev1.TLSPrivateKeyKey: caKeyByte, + } + if create { + err = kube.Create(ctx, caSecret) + } else { + err = kube.Update(ctx, caSecret) + } + if err != nil { + return nil, errors.Wrapf(err, "cannot create or update secret: %s", nn.Name) + } + + return parseCertificateSigner(caKeyByte, caCrtByte) +} + +func (e *TLSCertificateGenerator) ensureServerCertificate(ctx context.Context, kube client.Client, nn types.NamespacedName, signer *CertificateSigner) error { + sec := &corev1.Secret{} + + err := kube.Get(ctx, nn, sec) + if resource.IgnoreNotFound(err) != nil { + return errors.Wrapf(err, "cannot get TLS secret: %s", nn.Name) + } + + create := true + if err == nil { + create = false + if len(sec.Data[corev1.TLSCertKey]) != 0 || len(sec.Data[corev1.TLSPrivateKeyKey]) != 0 || len(sec.Data[SecretKeyCACert]) != 0 { + e.log.Info("TLS secret contains server certificate.", "secret", nn.Name) + return nil + } + } + e.log.Info("Server certificates are empty or not complete, generating a new pair...", "secret", nn.Name) + dnsNames := e.tlsServerDNSNames + if len(dnsNames) == 0 { + return errors.New("server DNS names are empty, you must provide at least one DNS name") + } + + cert := &x509.Certificate{ + SerialNumber: big.NewInt(2022), + Subject: pkixName, + DNSNames: dnsNames, + NotBefore: time.Now(), + NotAfter: time.Now().AddDate(10, 0, 0), + IsCA: false, + KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageKeyEncipherment | x509.KeyUsageDataEncipherment, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + BasicConstraintsValid: true, + } + + keyData, certData, err := e.certificate.Generate(cert, signer) + if err != nil { + return errors.Wrap(err, "cannot generate tls certificate") + } + + sec.Name = nn.Name + sec.Namespace = nn.Namespace + if sec.Data == nil { + sec.Data = make(map[string][]byte) + } + sec.Data[corev1.TLSCertKey] = certData + sec.Data[corev1.TLSPrivateKeyKey] = keyData + sec.Data[SecretKeyCACert] = signer.certificatePEM + + if create { + err = kube.Create(ctx, sec) + } else { + err = kube.Update(ctx, sec) + } + return errors.Wrapf(err, "cannot create or update secret: %s", nn.Name) +} + +// Run generates the TLS certificate bundle and stores it in k8s secrets. +func (e *TLSCertificateGenerator) Run(ctx context.Context, kube client.Client) error { + if e.tlsServerSecretName == nil { + return nil + } + signer, err := e.loadOrGenerateCA(ctx, kube, types.NamespacedName{ + Name: e.caSecretName, + Namespace: e.namespace, + }) + if err != nil { + return errors.Wrap(err, "cannot load or generate certificate signer") + } + + if e.tlsServerSecretName != nil { + if err := e.ensureServerCertificate(ctx, kube, types.NamespacedName{ + Name: *e.tlsServerSecretName, + Namespace: e.namespace, + }, signer); err != nil { + return errors.Wrap(err, "could not generate server certificate") + } + } + + return nil +} + +func parseCertificateSigner(key, cert []byte) (*CertificateSigner, error) { + block, _ := pem.Decode(key) + if block == nil { + return nil, errors.New("cannot decode key") + } + + sKey, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, errors.Wrap(err, "cannot parse CA key") + } + + block, _ = pem.Decode(cert) + if block == nil { + return nil, errors.New("cannot decode cert") + } + + sCert, err := x509.ParseCertificate(block.Bytes) + if err != nil { + return nil, errors.Wrap(err, "cannot parse CA certificate") + } + + return &CertificateSigner{ + key: sKey, + certificate: sCert, + certificatePEM: cert, + }, nil +} diff --git a/internal/project/controlplane/controlplane.go b/internal/project/controlplane/controlplane.go new file mode 100644 index 0000000..23776f5 --- /dev/null +++ b/internal/project/controlplane/controlplane.go @@ -0,0 +1,642 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package controlplane manages local development control planes. +package controlplane + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "os" + "path" + "path/filepath" + "slices" + + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/layout" + batchv1 "k8s.io/api/batch/v1" + corev1 "k8s.io/api/core/v1" + kerrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/rest" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/kind/pkg/apis/config/defaults" + "sigs.k8s.io/kind/pkg/apis/config/v1alpha4" + kind "sigs.k8s.io/kind/pkg/cluster" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/internal/docker" + "github.com/crossplane/cli/v2/internal/project" + "github.com/crossplane/cli/v2/internal/project/certs" + "github.com/crossplane/cli/v2/internal/project/helm" +) + +const ( + crossplaneNamespace = "crossplane-system" +) + +// DevControlPlane is a local development control plane. +type DevControlPlane interface { + // Info returns human-friendly information about the control plane. + Info() string + // Client returns a controller-runtime client for the control plane. + Client() client.Client + // Kubeconfig returns a kubeconfig for the control plane. + Kubeconfig() clientcmd.ClientConfig + // Teardown tears down the control plane, deleting any resources it may use. + Teardown(ctx context.Context) error + // Sideload sideloads packages into the control plane. + Sideload(ctx context.Context, imgMap project.ImageTagMap, tag name.Tag) error +} + +type localDevControlPlane struct { + name string + kubeconfig clientcmd.ClientConfig + client client.Client + registryDir string + registryContainerID string + registryHostname string +} + +func (l *localDevControlPlane) Info() string { + return fmt.Sprintf("Local dev control plane running in kind cluster %q.", l.name) +} + +func (l *localDevControlPlane) Client() client.Client { + return l.client +} + +func (l *localDevControlPlane) Kubeconfig() clientcmd.ClientConfig { + return l.kubeconfig +} + +func (l *localDevControlPlane) Teardown(ctx context.Context) error { + provider := kind.NewProvider() + + if err := ctx.Err(); err != nil { + return err + } + + if err := provider.Delete(l.name, ""); err != nil { + return errors.Wrap(err, "failed to delete the local control plane") + } + + if err := teardownLocalRegistry(ctx, l.registryContainerID); err != nil { + return errors.Wrap(err, "failed to tear down registry") + } + + _ = os.RemoveAll(l.registryDir) + + return nil +} + +func (l *localDevControlPlane) Sideload(ctx context.Context, imgMap project.ImageTagMap, tag name.Tag) error { + cfgImage, fnImages, err := project.SortImages(imgMap, tag.Repository.Name()) + if err != nil { + return err + } + + for repo, images := range fnImages { + p := filepath.Join(l.registryDir, repo.RepositoryStr()) + if err := os.MkdirAll(p, 0o750); err != nil { + return err + } + + idx, _, err := project.BuildIndex(images...) + if err != nil { + return err + } + + lp, err := layout.Write(p, empty.Index) + if err != nil { + return err + } + + if err := lp.AppendIndex(idx, layout.WithAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": tag.TagStr(), + })); err != nil { + return err + } + } + + p := filepath.Join(l.registryDir, tag.RepositoryStr()) + if err := os.MkdirAll(p, 0o750); err != nil { + return err + } + + lpath, err := layout.Write(p, empty.Index) + if err != nil { + return err + } + + if err := lpath.AppendImage(cfgImage, layout.WithAnnotations(map[string]string{ + "org.opencontainers.image.ref.name": tag.TagStr(), + })); err != nil { + return err + } + + // Make everything world-readable for unprivileged container access. + if err := filepath.WalkDir(l.registryDir, func(path string, d fs.DirEntry, err error) error { + if err != nil { + return err + } + + if d.IsDir() { + return os.Chmod(path, 0o755) //nolint:gosec // Container needs to read the dir. + } + + return os.Chmod(path, 0o644) //nolint:gosec // Container needs to read the file. + }); err != nil { + return errors.Wrap(err, "failed to adjust permissions on sideloaded images") + } + + rewrite := path.Join(l.registryHostname, tag.RepositoryStr()) + imgcfg := &pkgv1beta1.ImageConfig{ + ObjectMeta: metav1.ObjectMeta{ + Name: "local-registry", + }, + Spec: pkgv1beta1.ImageConfigSpec{ + MatchImages: []pkgv1beta1.ImageMatch{{ + Type: pkgv1beta1.Prefix, + Prefix: tag.Repository.Name(), + }}, + RewriteImage: &pkgv1beta1.ImageRewrite{ + Prefix: rewrite, + }, + }, + } + + if err := pkgv1beta1.AddToScheme(l.client.Scheme()); err != nil { + return err + } + if err := l.client.Create(ctx, imgcfg); err != nil && !kerrors.IsAlreadyExists(err) { + return errors.Wrap(err, "failed to create image config") + } + + return nil +} + +// Option configures EnsureLocalDevControlPlane. +type Option func(*config) + +type config struct { + name string + crossplaneVersion string + registryDir string + clusterAdmin bool + log logging.Logger +} + +// WithName sets the name of the local dev control plane. +func WithName(n string) Option { + return func(c *config) { + c.name = n + } +} + +// WithCrossplaneVersion sets the Crossplane version to install. +func WithCrossplaneVersion(v string) Option { + return func(c *config) { + c.crossplaneVersion = v + } +} + +// WithRegistryDir sets the directory for local registry images. +func WithRegistryDir(d string) Option { + return func(c *config) { + c.registryDir = d + } +} + +// WithClusterAdmin sets whether to grant Crossplane cluster admin privileges. +func WithClusterAdmin(enabled bool) Option { + return func(c *config) { + c.clusterAdmin = enabled + } +} + +// WithLogger sets the logger for progress updates. +func WithLogger(l logging.Logger) Option { + return func(c *config) { + c.log = l + } +} + +// EnsureLocalDevControlPlane creates or reuses a local kind-based development +// control plane with Crossplane installed. +func EnsureLocalDevControlPlane(ctx context.Context, opts ...Option) (DevControlPlane, error) { //nolint:gocyclo // Main orchestration function. + cfg := &config{ + clusterAdmin: true, + log: logging.NewNopLogger(), + } + for _, opt := range opts { + opt(cfg) + } + + cfg.log.Debug("Checking Docker connectivity") + if err := docker.Check(ctx); err != nil { + return nil, errors.Wrap(err, "failed to connect to Docker; local dev control planes require a Docker-compatible container runtime") + } + + // kind creates a docker container named -control-plane, and uses the + // name as the container's hostname. Hostnames can be at most 63 characters. + nameLen := len(cfg.name) + nameLen = min(nameLen, 63-len("-control-plane")) + cfg.name = cfg.name[:nameLen] + + cfg.log.Debug("Ensuring kind cluster", "name", cfg.name) + kubeconfig, err := ensureKindCluster(cfg.name) + if err != nil { + return nil, err + } + + restConfig, err := kubeconfig.ClientConfig() + if err != nil { + return nil, errors.Wrap(err, "cannot get rest config") + } + + cl, err := client.New(restConfig, client.Options{}) + if err != nil { + return nil, errors.Wrap(err, "cannot construct control plane client") + } + + // Create the crossplane namespace. + ns := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{ + Name: crossplaneNamespace, + }, + } + if err := cl.Create(ctx, ns); err != nil && !kerrors.IsAlreadyExists(err) { + return nil, errors.Wrap(err, "failed to create crossplane-system namespace") + } + + // Generate a CA and certificate for the local registry. + regName := cfg.name + "-registry" + cfg.log.Debug("Ensuring local registry certificate") + certSecret, ca, err := ensureLocalRegistryCertificate(ctx, cl, regName) + if err != nil { + return nil, errors.Wrap(err, "cannot generate certificate for registry") + } + + // Create a directory to store sideloaded images and spin up a registry + // container that uses it. + registryDir := cfg.registryDir + if registryDir == "" { + registryDir = filepath.Join(os.TempDir(), "crossplane-local-registry") + } + registryDir = filepath.Join(registryDir, cfg.name) + if err := os.MkdirAll(registryDir, 0o755); err != nil { //nolint:gosec // Container needs to read the dir. + return nil, err + } + + cfg.log.Debug("Ensuring local registry container") + cid, err := ensureLocalRegistry(ctx, cl, regName, registryDir, certSecret) + if err != nil { + return nil, err + } + + cfg.log.Debug("Ensuring Crossplane is installed") + if err := ensureCrossplane(restConfig, cfg.crossplaneVersion, ca.Name, cfg.clusterAdmin); err != nil { + return nil, err + } + + cfg.log.Debug("Local dev control plane ready") + return &localDevControlPlane{ + name: cfg.name, + kubeconfig: kubeconfig, + client: cl, + registryDir: registryDir, + registryContainerID: cid, + registryHostname: regName + ":5000", + }, nil +} + +// TeardownLocalDevControlPlane tears down a local dev control plane by name. +// It deletes the kind cluster, stops the registry container, and removes the +// registry data directory. +func TeardownLocalDevControlPlane(ctx context.Context, name string, registryDir string) error { + // Truncate name the same way EnsureLocalDevControlPlane does. + nameLen := len(name) + nameLen = min(nameLen, 63-len("-control-plane")) + name = name[:nameLen] + + provider := kind.NewProvider() + + existing, err := provider.List() + if err != nil { + return errors.Wrap(err, "failed to list kind clusters") + } + if !slices.Contains(existing, name) { + return errors.Errorf("kind cluster %q not found", name) + } + + if err := provider.Delete(name, ""); err != nil { + return errors.Wrap(err, "failed to delete the local control plane") + } + + // Stop and remove the registry container. + regName := name + "-registry" + cid, found, err := docker.GetContainerIDByName(ctx, regName, true) + if err != nil { + return errors.Wrap(err, "failed to look up registry container") + } + if found { + if err := teardownLocalRegistry(ctx, cid); err != nil { + return errors.Wrap(err, "failed to tear down registry") + } + } + + // Remove the registry data directory. + if registryDir == "" { + registryDir = filepath.Join(os.TempDir(), "crossplane-local-registry") + } + registryDir = filepath.Join(registryDir, name) + _ = os.RemoveAll(registryDir) + + return nil +} + +func ensureKindCluster(clusterName string) (clientcmd.ClientConfig, error) { + provider := kind.NewProvider() + + kubeconfigFile, err := os.CreateTemp("", "crossplane-*.kubeconfig") + if err != nil { + return nil, errors.Wrap(err, "failed to create temporary kubeconfig") + } + _ = kubeconfigFile.Close() + defer func() { _ = os.Remove(kubeconfigFile.Name()) }() + + existing, err := provider.List() + if err != nil { + return nil, errors.Wrap(err, "failed to list kind clusters") + } + + if slices.Contains(existing, clusterName) { + if err := provider.ExportKubeConfig(clusterName, kubeconfigFile.Name(), false); err != nil { + return nil, errors.Wrap(err, "failed to get kubeconfig for kind cluster") + } + } else { + if err := createNewKindCluster(provider, clusterName, kubeconfigFile.Name()); err != nil { + return nil, err + } + } + + kubeconfigBytes, err := os.ReadFile(kubeconfigFile.Name()) + if err != nil { + return nil, errors.Wrap(err, "failed to load kubeconfig") + } + + kubeconfig, err := clientcmd.NewClientConfigFromBytes(kubeconfigBytes) + if err != nil { + return nil, errors.Wrap(err, "failed to parse kubeconfig") + } + + return kubeconfig, nil +} + +func createNewKindCluster(provider *kind.Provider, clusterName, kubeconfigPath string) error { + cfg := createKindClusterConfig() + + cfgBytes, err := yaml.Marshal(cfg) + if err != nil { + return errors.Wrap(err, "failed to marshal kind config") + } + + if err := provider.Create( + clusterName, + kind.CreateWithRawConfig(cfgBytes), + kind.CreateWithNodeImage(defaults.Image), + kind.CreateWithDisplayUsage(false), + kind.CreateWithDisplaySalutation(false), + kind.CreateWithKubeconfigPath(kubeconfigPath), + ); err != nil { + return errors.Wrap(err, "failed to create kind cluster") + } + + return nil +} + +func createKindClusterConfig() *v1alpha4.Cluster { + return &v1alpha4.Cluster{ + TypeMeta: v1alpha4.TypeMeta{ + APIVersion: "kind.x-k8s.io/v1alpha4", + Kind: "Cluster", + }, + Nodes: []v1alpha4.Node{{ + Role: v1alpha4.ControlPlaneRole, + }}, + ContainerdConfigPatches: []string{ + "[plugins.\"io.containerd.grpc.v1.cri\".registry]\nconfig_path = \"/etc/containerd/certs.d\"\n", + }, + } +} + +func ensureCrossplane(restConfig *rest.Config, version, caConfigMap string, clusterAdmin bool) error { + mgr, err := helm.NewManager(restConfig, + "crossplane", + "https://charts.crossplane.io/stable", + crossplaneNamespace, + helm.Wait(), + ) + if err != nil { + return errors.Wrap(err, "failed to build new helm manager") + } + + // If crossplane is already installed, check the version. + if v, err := mgr.GetCurrentVersion(); err == nil { + if version != "" && v != version { + return errors.Errorf("existing cluster has wrong crossplane version installed: got %s, want %s", v, version) + } + return nil + } + + values := map[string]any{ + "args": []string{ + "--enable-dependency-version-upgrades", + }, + "registryCaBundleConfig": map[string]string{ + "name": caConfigMap, + "key": certs.SecretKeyCACert, + }, + "rbac": map[string]any{ + "clusterAdmin": clusterAdmin, + }, + } + if err = mgr.Install(version, values); err != nil { + return errors.Wrap(err, "failed to install crossplane") + } + + return nil +} + +func ensureLocalRegistry(ctx context.Context, cl client.Client, regName, dir string, certSecret *corev1.Secret) (string, error) { + const regImage = "ghcr.io/olareg/olareg:edge" + certDir := filepath.Join(dir, ".certs") + + // Check for existing registry container. + existing, found, err := docker.GetContainerIDByName(ctx, regName, true) + if err != nil { + return "", errors.Wrap(err, "failed to look up existing registry container") + } + if found { + //nolint:gosec // We don't do anything dangerous with the CA data. + caData, err := os.ReadFile(filepath.Join(certDir, "ca.crt")) + if err == nil && bytes.Equal(caData, certSecret.Data[certs.SecretKeyCACert]) { + if err := docker.StartContainerByID(ctx, existing); err != nil { + return "", errors.Wrap(err, "failed to start existing registry container") + } + return existing, nil + } + + if err := teardownLocalRegistry(ctx, existing); err != nil { + return "", errors.Wrap(err, "failed to tear down outdated registry") + } + } + + // Write the TLS cert and key files. + if err := os.MkdirAll(certDir, 0o755); err != nil { //nolint:gosec // Container needs to read the dir. + return "", errors.New("failed to create cert directory") + } + if err := os.WriteFile(filepath.Join(certDir, "ca.crt"), certSecret.Data[certs.SecretKeyCACert], 0o644); err != nil { //nolint:gosec // Container needs to read the file. + return "", errors.New("failed to write ca cert") + } + if err := os.WriteFile(filepath.Join(certDir, "tls.crt"), certSecret.Data[corev1.TLSCertKey], 0o644); err != nil { //nolint:gosec // Container needs to read the file. + return "", errors.New("failed to write tls cert") + } + if err := os.WriteFile(filepath.Join(certDir, "tls.key"), certSecret.Data[corev1.TLSPrivateKeyKey], 0o644); err != nil { //nolint:gosec // Container needs to read the file. + return "", errors.New("failed to write tls key") + } + + // Find kind's network. + nid, found, err := docker.GetNetworkIDByName(ctx, "kind") + if err != nil { + return "", errors.Wrap(err, "failed to get kind network ID") + } + if !found { + return "", errors.New("missing kind network") + } + + // Start the registry container. + cid, err := docker.StartContainer(ctx, regName, regImage, + docker.StartWithCommand([]string{"serve", "--dir=/registry-data", "--api-push=false", "--store-ro", "--tls-cert=/registry-data/.certs/tls.crt", "--tls-key=/registry-data/.certs/tls.key"}), + docker.StartWithBindMount(dir, "/registry-data"), + docker.StartWithNetworkID(nid), + ) + if err != nil { + return "", errors.Wrap(err, "failed to start registry container") + } + + // Configure containerd in the cluster to accept the local registry's CA + // certificate. + if err := configureContainerdLocalRegistry(ctx, cl, regName, string(certSecret.Data[certs.SecretKeyCACert])); err != nil { + return "", errors.Wrap(err, "failed to configure registry in kind cluster") + } + + return cid, nil +} + +func teardownLocalRegistry(ctx context.Context, cid string) error { + return errors.Wrap(docker.StopContainerByID(ctx, cid), "failed to stop registry container") +} + +func ensureLocalRegistryCertificate(ctx context.Context, cl client.Client, hostname string) (*corev1.Secret, *corev1.ConfigMap, error) { + const secretName = "local-registry-tls" + + gen := certs.NewTLSCertificateGenerator(crossplaneNamespace, certs.RootCACertSecretName, + certs.TLSCertificateGeneratorWithServerSecretName(secretName, []string{hostname}), + ) + + if err := gen.Run(ctx, cl); err != nil { + return nil, nil, errors.Wrap(err, "failed to generate local registry certificate") + } + + var s corev1.Secret + if err := cl.Get(ctx, types.NamespacedName{Namespace: crossplaneNamespace, Name: secretName}, &s); err != nil { + return nil, nil, errors.Wrap(err, "failed to retrieve local registry certificate") + } + + cm := &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: "local-registry-cert", + Namespace: crossplaneNamespace, + }, + BinaryData: map[string][]byte{ + certs.SecretKeyCACert: s.Data[certs.SecretKeyCACert], + }, + } + if err := cl.Create(ctx, cm); err != nil && !kerrors.IsAlreadyExists(err) { + return nil, nil, errors.Wrap(err, "failed to save local registry ca certificate") + } + + return &s, cm, nil +} + +func configureContainerdLocalRegistry(ctx context.Context, cl client.Client, regName, caCert string) error { + hostsToml := fmt.Sprintf(`server = "https://%s:5000" + +[host."https://%s:5000"] + ca = "ca.crt" +`, regName, regName) + cmd := fmt.Sprintf("mkdir -p /containerd-certs/%s:5000", regName) + cmd += fmt.Sprintf("&& echo '%s' > /containerd-certs/%s:5000/ca.crt", caCert, regName) + cmd += fmt.Sprintf("&& echo '%s' > /containerd-certs/%s:5000/hosts.toml", hostsToml, regName) + j := &batchv1.Job{ + ObjectMeta: metav1.ObjectMeta{ + Name: "configure-kind-registry", + Namespace: "default", + }, + Spec: batchv1.JobSpec{ + Template: corev1.PodTemplateSpec{ + Spec: corev1.PodSpec{ + RestartPolicy: corev1.RestartPolicyNever, + Containers: []corev1.Container{{ + Name: "configurator", + VolumeMounts: []corev1.VolumeMount{{ + Name: "containerd-certs", + MountPath: "/containerd-certs", + }}, + Image: "docker.io/library/alpine:3", + Command: []string{"sh", "-c", cmd}, + }}, + Volumes: []corev1.Volume{{ + Name: "containerd-certs", + VolumeSource: corev1.VolumeSource{ + HostPath: &corev1.HostPathVolumeSource{ + Path: "/etc/containerd/certs.d", + }, + }, + }}, + }, + }, + }, + } + + if err := cl.Create(ctx, j); err != nil && !kerrors.IsAlreadyExists(err) { + return err + } + + return nil +} diff --git a/internal/project/functions/build.go b/internal/project/functions/build.go new file mode 100644 index 0000000..09c1751 --- /dev/null +++ b/internal/project/functions/build.go @@ -0,0 +1,146 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package functions contains functions for building embedded functions. +package functions + +import ( + "context" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" +) + +// Identifier knows how to identify an appropriate builder for a function based +// on its source code. +type Identifier interface { + // Identify returns a suitable builder for the function whose source lives + // in the given filesystem. It returns an error if no such builder is + // available. + Identify(fromFS afero.Fs, imageConfigs []pkgv1beta1.ImageConfig) (Builder, error) +} + +type realIdentifier struct{} + +// DefaultIdentifier is the default builder identifier, suitable for production +// use. +// +//nolint:gochecknoglobals // we want to keep this global +var DefaultIdentifier Identifier = realIdentifier{} + +func (realIdentifier) Identify(fromFS afero.Fs, imageConfigs []pkgv1beta1.ImageConfig) (Builder, error) { + builders := []Builder{ + newKCLBuilder(imageConfigs), + newPythonBuilder(imageConfigs), + newGoBuilder(imageConfigs), + newGoTemplatingBuilder(imageConfigs), + } + for _, b := range builders { + ok, err := b.match(fromFS) + if err != nil { + return nil, errors.Wrapf(err, "builder %q returned an error", b.Name()) + } + if ok { + return b, nil + } + } + + return nil, errors.New("no suitable builder found") +} + +// BuildContext bundles the inputs that function builders work from. Each +// builder slices the parts of the project it needs: the function +// subdirectory for Go/KCL/go-templating, plus the schemas dir for Python. +type BuildContext struct { + // ProjectFS is the project root filesystem. + ProjectFS afero.Fs + // FunctionPath is the function's path relative to ProjectFS root, + // e.g. "functions/my-fn". + FunctionPath string + // SchemasPath is the schemas dir relative to ProjectFS root, e.g. + // "schemas". Used by Python to stage schemas/python/ alongside the + // function source so the relative path-dep resolves at build time. + SchemasPath string + // Architectures is the list of architectures to build for. + Architectures []string + // OSBasePath is the absolute on-disk path of the function directory. + // Used by FSToTar to resolve symlinks. + OSBasePath string +} + +// FunctionFS returns a filesystem rooted at the function's source directory. +func (c BuildContext) FunctionFS() afero.Fs { + return afero.NewBasePathFs(c.ProjectFS, c.FunctionPath) +} + +// Builder knows how to build a particular kind of function. +type Builder interface { + // Name returns a name for this builder. + Name() string + // Build builds the function described by the given context, returning + // an image for each architecture. This image will *not* include + // package metadata; it's just the runtime image for the function. + Build(ctx context.Context, c BuildContext) ([]v1.Image, error) + // match returns true if this builder can build the function whose source + // lives in the given filesystem. + match(fromFS afero.Fs) (bool, error) +} + +type nopIdentifier struct{} + +// FakeIdentifier is an identifier that always returns a fake builder. This is +// for use in tests where we don't want to do real builds. +// +//nolint:gochecknoglobals // we want to keep this global +var FakeIdentifier Identifier = nopIdentifier{} + +func (nopIdentifier) Identify(_ afero.Fs, _ []pkgv1beta1.ImageConfig) (Builder, error) { + return &fakeBuilder{}, nil +} + +type fakeBuilder struct{} + +func (b *fakeBuilder) Name() string { + return "fake" +} + +func (b *fakeBuilder) match(_ afero.Fs) (bool, error) { + return true, nil +} + +func (b *fakeBuilder) Build(_ context.Context, c BuildContext) ([]v1.Image, error) { + images := make([]v1.Image, len(c.Architectures)) + for i, arch := range c.Architectures { + baseImg := empty.Image + cfg := &v1.ConfigFile{ + OS: "linux", + Architecture: arch, + } + img, err := mutate.ConfigFile(baseImg, cfg) + if err != nil { + return nil, err + } + images[i] = img + } + + return images, nil +} diff --git a/internal/project/functions/go.go b/internal/project/functions/go.go new file mode 100644 index 0000000..f100829 --- /dev/null +++ b/internal/project/functions/go.go @@ -0,0 +1,136 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package functions + +import ( + "context" + "io" + "log" + "net/http" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/ko/pkg/build" + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// goBuilder builds functions written in Go using ko. +type goBuilder struct { + baseImage string + transport http.RoundTripper + configStore xpkg.ConfigStore +} + +func (b *goBuilder) Name() string { + return "go" +} + +func (b *goBuilder) match(fromFS afero.Fs) (bool, error) { + return afero.Exists(fromFS, "go.mod") +} + +func (b *goBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Image, error) { + // ko logs using the Go standard library global logger without providing + // any option to disable output. Disable output while we do our builds. + log.SetOutput(io.Discard) + + platforms := make([]string, len(c.Architectures)) + for i, arch := range c.Architectures { + platforms[i] = "linux/" + arch + } + + builder, err := build.NewGo(ctx, c.OSBasePath, + build.WithBaseImages(func(ctx context.Context, _ string) (name.Reference, build.Result, error) { + baseImage := b.baseImage + _, rewritten, err := b.configStore.RewritePath(ctx, baseImage) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to rewrite base image") + } + if rewritten != "" { + baseImage = rewritten + } + + ref, err := name.ParseReference(baseImage, name.StrictValidation) + if err != nil { + return nil, nil, err + } + img, err := remote.Index(ref, remote.WithTransport(b.transport), remote.WithAuthFromKeychain(authn.DefaultKeychain)) + return ref, img, err + }), + build.WithPlatforms(platforms...), + ) + if err != nil { + return nil, errors.Wrap(err, "failed to construct ko builder") + } + builder, err = build.NewCaching(builder) + if err != nil { + return nil, errors.Wrap(err, "failed to construct caching builder") + } + + path, err := builder.QualifyImport(".") + if err != nil { + return nil, errors.Wrap(err, "failed to determine go module path for function") + } + + res, err := builder.Build(ctx, path) + if err != nil { + return nil, errors.Wrap(err, "failed to build function") + } + + var imgs []v1.Image + switch out := res.(type) { + case v1.ImageIndex: + idx, err := out.IndexManifest() + if err != nil { + return nil, errors.Wrap(err, "failed to get index manifest") + } + + imgs = make([]v1.Image, len(idx.Manifests)) + for i, desc := range idx.Manifests { + img, err := out.Image(desc.Digest) + if err != nil { + return nil, errors.Wrapf(err, "failed to get image %v from index", desc.Digest) + } + imgs[i] = img + } + + case v1.Image: + imgs = []v1.Image{out} + + default: + return nil, errors.Errorf("ko builder returned unexpected type %T", res) + } + + return imgs, nil +} + +func newGoBuilder(imageConfigs []pkgv1beta1.ImageConfig) *goBuilder { + return &goBuilder{ + baseImage: "gcr.io/distroless/static-debian12@sha256:a9329520abc449e3b14d5bc3a6ffae065bdde0f02667fa10880c49b35c109fd1", + transport: http.DefaultTransport, + configStore: clixpkg.NewStaticImageConfigStore(imageConfigs), + } +} diff --git a/internal/project/functions/go_templating.go b/internal/project/functions/go_templating.go new file mode 100644 index 0000000..ce5a771 --- /dev/null +++ b/internal/project/functions/go_templating.go @@ -0,0 +1,159 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package functions + +import ( + "bytes" + "context" + "io" + "io/fs" + "net/http" + "path/filepath" + "slices" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/internal/filesystem" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// goTemplatingBuilder builds "functions" written in go templating by injecting +// their code into a function-go-templating base image. +type goTemplatingBuilder struct { + baseImage string + transport http.RoundTripper + configStore xpkg.ConfigStore +} + +func (b *goTemplatingBuilder) Name() string { + return "go-templating" +} + +func (b *goTemplatingBuilder) match(fromFS afero.Fs) (bool, error) { + goTemplatingExtensions := []string{ + ".gotmpl", + ".tmpl", + } + + matches := false + err := afero.Walk(fromFS, ".", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.Mode().IsDir() { + return nil + } + + if !info.Mode().IsRegular() { + matches = false + return fs.SkipAll + } + + if !slices.Contains(goTemplatingExtensions, filepath.Ext(path)) { + matches = false + return fs.SkipAll + } + + matches = true + return nil + }) + + if errors.Is(err, fs.SkipAll) { + err = nil + } + + return matches, err +} + +func (b *goTemplatingBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Image, error) { + baseImage := b.baseImage + _, rewritten, err := b.configStore.RewritePath(ctx, b.baseImage) + if err != nil { + return nil, errors.Wrap(err, "failed to rewrite base image") + } + if rewritten != "" { + baseImage = rewritten + } + + baseRef, err := name.NewTag(baseImage) + if err != nil { + return nil, errors.Wrap(err, "failed to parse go-templating base image tag") + } + + fnFS := c.FunctionFS() + + images := make([]v1.Image, len(c.Architectures)) + eg, _ := errgroup.WithContext(ctx) + for i, arch := range c.Architectures { + eg.Go(func() error { + baseImg, err := baseImageForArch(baseRef, arch, b.transport) + if err != nil { + return errors.Wrap(err, "failed to fetch go-templating base image") + } + + src, err := filesystem.FSToTar(fnFS, "/src", + filesystem.WithSymlinkBasePath(c.OSBasePath), + ) + if err != nil { + return errors.Wrap(err, "failed to tar layer contents") + } + + codeLayer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(src)), nil + }) + if err != nil { + return errors.Wrap(err, "failed to create code layer") + } + + img, err := mutate.AppendLayers(baseImg, codeLayer) + if err != nil { + return errors.Wrap(err, "failed to add code to image") + } + + img, err = setImageEnvvars(img, map[string]string{ + "FUNCTION_GO_TEMPLATING_DEFAULT_SOURCE": "/src", + }) + if err != nil { + return errors.Wrap(err, "failed to configure go-templating source path") + } + + images[i] = img + return nil + }) + } + + return images, eg.Wait() +} + +func newGoTemplatingBuilder(imageConfigs []pkgv1beta1.ImageConfig) *goTemplatingBuilder { + return &goTemplatingBuilder{ + transport: http.DefaultTransport, + baseImage: "xpkg.crossplane.io/crossplane-contrib/function-go-templating:v0.12.0", + configStore: clixpkg.NewStaticImageConfigStore(imageConfigs), + } +} diff --git a/internal/project/functions/kcl.go b/internal/project/functions/kcl.go new file mode 100644 index 0000000..d92cbe8 --- /dev/null +++ b/internal/project/functions/kcl.go @@ -0,0 +1,213 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package functions + +import ( + "bytes" + "context" + "fmt" + "io" + "net/http" + "slices" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/internal/filesystem" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +const ( + crossplaneFunctionRunnerUID = 2000 + crossplaneFunctionRunnerGID = 2000 +) + +// kclBuilder builds functions written in KCL by injecting their code into a +// function-kcl base image. +type kclBuilder struct { + baseImage string + transport http.RoundTripper + configStore xpkg.ConfigStore +} + +func (b *kclBuilder) Name() string { + return "kcl" +} + +func (b *kclBuilder) match(fromFS afero.Fs) (bool, error) { + return afero.Exists(fromFS, "kcl.mod") +} + +func (b *kclBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Image, error) { + baseImage := b.baseImage + _, rewritten, err := b.configStore.RewritePath(ctx, b.baseImage) + if err != nil { + return nil, errors.Wrap(err, "failed to rewrite KCL base image") + } + if rewritten != "" { + baseImage = rewritten + } + + baseRef, err := name.ParseReference(baseImage, name.StrictValidation) + if err != nil { + return nil, errors.Wrap(err, "failed to parse KCL base image tag") + } + + fnFS := c.FunctionFS() + + images := make([]v1.Image, len(c.Architectures)) + eg, _ := errgroup.WithContext(ctx) + for i, arch := range c.Architectures { + eg.Go(func() error { + baseImg, err := baseImageForArch(baseRef, arch, b.transport) + if err != nil { + return errors.Wrap(err, "failed to fetch KCL base image") + } + + src, err := filesystem.FSToTar(fnFS, "/src", + filesystem.WithSymlinkBasePath(c.OSBasePath), + filesystem.WithUIDOverride(crossplaneFunctionRunnerUID), + filesystem.WithGIDOverride(crossplaneFunctionRunnerGID), + ) + if err != nil { + return errors.Wrap(err, "failed to tar layer contents") + } + + codeLayer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(src)), nil + }) + if err != nil { + return errors.Wrap(err, "failed to create code layer") + } + + img, err := mutate.AppendLayers(baseImg, codeLayer) + if err != nil { + return errors.Wrap(err, "failed to add code to image") + } + + img, err = setImageEnvvars(img, map[string]string{ + "FUNCTION_KCL_DEFAULT_SOURCE": "/src", + "KCL_PKG_PATH": "/src", + }) + if err != nil { + return errors.Wrap(err, "failed to configure KCL source path") + } + + images[i] = img + return nil + }) + } + + return images, eg.Wait() +} + +// baseImageForArch pulls the image with the given ref, and returns a version of +// it suitable for use as a function base image. Package and examples layers +// will be removed if present. +func baseImageForArch(ref name.Reference, arch string, transport http.RoundTripper) (v1.Image, error) { + img, err := remote.Image(ref, remote.WithPlatform(v1.Platform{ + OS: "linux", + Architecture: arch, + }), remote.WithTransport(transport), remote.WithAuthFromKeychain(authn.DefaultKeychain)) + if err != nil { + return nil, errors.Wrap(err, "failed to pull image") + } + + cfg, err := img.ConfigFile() + if err != nil { + return nil, errors.Wrap(err, "failed to get config from image") + } + if cfg.Architecture != arch { + return nil, errors.Errorf("image not available for architecture %q", arch) + } + + mfst, err := img.Manifest() + if err != nil { + return nil, errors.Wrap(err, "failed to get manifest from image") + } + baseImage := empty.Image + cfg.RootFS = v1.RootFS{} + cfg.History = nil + baseImage, err = mutate.ConfigFile(baseImage, cfg) + if err != nil { + return nil, errors.Wrap(err, "failed to add configuration to base image") + } + for _, desc := range mfst.Layers { + if isNonBaseLayer(desc) { + continue + } + l, err := img.LayerByDigest(desc.Digest) + if err != nil { + return nil, errors.Wrap(err, "failed to get layer from image") + } + baseImage, err = mutate.AppendLayers(baseImage, l) + if err != nil { + return nil, errors.Wrap(err, "failed to add layer to base image") + } + } + + return baseImage, nil +} + +func isNonBaseLayer(desc v1.Descriptor) bool { + nonBaseLayerAnns := []string{ + xpkg.PackageAnnotation, + xpkg.ExamplesAnnotation, + } + + ann := desc.Annotations[xpkg.AnnotationKey] + return slices.Contains(nonBaseLayerAnns, ann) +} + +func setImageEnvvars(image v1.Image, envVars map[string]string) (v1.Image, error) { + cfgFile, err := image.ConfigFile() + if err != nil { + return nil, errors.Wrap(err, "failed to get config file") + } + cfg := cfgFile.Config + + for k, v := range envVars { + cfg.Env = append(cfg.Env, fmt.Sprintf("%s=%s", k, v)) + } + + image, err = mutate.Config(image, cfg) + if err != nil { + return nil, errors.Wrap(err, "failed to set config") + } + + return image, nil +} + +func newKCLBuilder(imageConfigs []v1beta1.ImageConfig) *kclBuilder { + return &kclBuilder{ + baseImage: "xpkg.crossplane.io/crossplane-contrib/function-kcl:v0.12.1", + transport: http.DefaultTransport, + configStore: clixpkg.NewStaticImageConfigStore(imageConfigs), + } +} diff --git a/internal/project/functions/python.go b/internal/project/functions/python.go new file mode 100644 index 0000000..78a6220 --- /dev/null +++ b/internal/project/functions/python.go @@ -0,0 +1,247 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package functions + +import ( + "bytes" + "context" + "io" + "net/http" + "path" + "path/filepath" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/internal/docker" + "github.com/crossplane/cli/v2/internal/filesystem" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +const ( + // pythonBuildImage is the image in which we build the function. Its python + // version must match the python version of pythonRuntimeImage. + pythonBuildImage = "docker.io/library/debian:13-slim" + // pythonRuntimeImage is the distroless base used at runtime. + pythonRuntimeImage = "gcr.io/distroless/python3-debian13:nonroot" + // pythonBuildScript is the shell pipeline that runs in the build + // container. Mirrors function-template-python's Dockerfile: install hatch + // in a throwaway venv, build a wheel, install the wheel into a fresh venv + // at /fn. + // + // TODO(adamwg): We should build an image with python3 and python3-venv + // pre-installed so we don't have to install them for every build. + pythonBuildScript = `set -euo pipefail +export DEBIAN_FRONTEND=noninteractive +apt-get update +apt-get install -y --no-install-recommends python3 python3-venv +python3 -m venv /build +/build/bin/pip install --quiet hatch +/build/bin/hatch build -t wheel /whl +python3 -m venv /fn +/fn/bin/pip install --quiet /whl/*.whl +` +) + +// pythonBuilder builds Python composition functions. +// +// A Python embedded function is a full crossplane-function-sdk-python project +// (pyproject.toml + function/). We build it the same way function-template- +// python's Dockerfile does: in a throwaway debian build container we run +// `hatch build` to produce a wheel, install it into a fresh venv, then copy +// that venv onto a distroless python base. +type pythonBuilder struct { + buildImage string + runtimeImage string + transport http.RoundTripper + configStore xpkg.ConfigStore +} + +func (b *pythonBuilder) Name() string { + return "python" +} + +func (b *pythonBuilder) match(fromFS afero.Fs) (bool, error) { + hasPyproject, err := afero.Exists(fromFS, "pyproject.toml") + if err != nil { + return false, err + } + hasFnDir, err := afero.DirExists(fromFS, "function") + if err != nil { + return false, err + } + return hasPyproject && hasFnDir, nil +} + +func (b *pythonBuilder) Build(ctx context.Context, c BuildContext) ([]v1.Image, error) { + if err := docker.Check(ctx); err != nil { + return nil, errors.Wrap(err, "python builds require a Docker-compatible container runtime") + } + + venvTar, err := b.buildVenv(ctx, c) + if err != nil { + return nil, err + } + + runtimeImage := b.runtimeImage + _, rewritten, err := b.configStore.RewritePath(ctx, b.runtimeImage) + if err != nil { + return nil, errors.Wrap(err, "failed to rewrite runtime image") + } + if rewritten != "" { + runtimeImage = rewritten + } + + runtimeRef, err := name.ParseReference(runtimeImage) + if err != nil { + return nil, errors.Wrap(err, "failed to parse python runtime base image") + } + + images := make([]v1.Image, len(c.Architectures)) + eg, _ := errgroup.WithContext(ctx) + for i, arch := range c.Architectures { + eg.Go(func() error { + baseImg, err := baseImageForArch(runtimeRef, arch, b.transport) + if err != nil { + return errors.Wrap(err, "failed to fetch python runtime base image") + } + + venvLayer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + return io.NopCloser(bytes.NewReader(venvTar)), nil + }) + if err != nil { + return errors.Wrap(err, "failed to create venv layer") + } + + img, err := mutate.AppendLayers(baseImg, venvLayer) + if err != nil { + return errors.Wrap(err, "failed to append venv layer") + } + + img, err = configurePythonImage(img) + if err != nil { + return errors.Wrap(err, "failed to configure python image") + } + + images[i] = img + return nil + }) + } + + return images, eg.Wait() +} + +// buildVenv runs the build container against the function source and returns a +// tar of /fn suitable for use as an image layer (entries are rooted at +// /fn/...). +// +// The function source is staged at / and, if a python schemas +// tree exists, //python/ — preserving the project's relative +// layout so that pip resolves the schemas path-dep from pyproject.toml. +func (b *pythonBuilder) buildVenv(ctx context.Context, c BuildContext) ([]byte, error) { + fnFS := c.FunctionFS() + // Exclude any venv the user might have created in the function directory + // for local development, since (a) we don't need it, and (b) it will + // contain symlinks, which we can't tar up. + fnTar, err := filesystem.FSToTar(fnFS, c.FunctionPath, filesystem.WithExcludePrefix(".venv")) + if err != nil { + return nil, errors.Wrap(err, "failed to tar function source") + } + + pySchemasRel := path.Join(c.SchemasPath, "python") + pySchemasFS := afero.NewBasePathFs(c.ProjectFS, pySchemasRel) + hasPySchemas, _ := afero.DirExists(pySchemasFS, ".") + var schemasTar []byte + if hasPySchemas { + schemasTar, err = filesystem.FSToTar(pySchemasFS, pySchemasRel) + if err != nil { + return nil, errors.Wrap(err, "failed to tar python schemas") + } + } + + buildImage := b.buildImage + _, rewritten, err := b.configStore.RewritePath(ctx, b.buildImage) + if err != nil { + return nil, errors.Wrap(err, "failed to rewrite build image") + } + if rewritten != "" { + buildImage = rewritten + } + + opts := []docker.StartContainerOption{ + docker.StartWithCopyFiles(fnTar, "/"), + docker.StartWithCommand([]string{"sh", "-c", pythonBuildScript}), + docker.StartWithWorkingDirectory("/" + filepath.ToSlash(c.FunctionPath)), + } + if schemasTar != nil { + opts = append(opts, docker.StartWithCopyFiles(schemasTar, "/")) + } + + cid, err := docker.StartContainer(ctx, "", buildImage, opts...) + if err != nil { + return nil, errors.Wrap(err, "failed to start python build container") + } + defer func() { + _ = docker.StopContainerByID(ctx, cid) + }() + + if err := docker.WaitForContainerByID(ctx, cid); err != nil { + return nil, errors.Wrap(err, "python build container failed") + } + + return docker.TarFromContainer(ctx, cid, "/fn") +} + +// configurePythonImage sets the runtime configuration on the final image to +// match function-template-python: nonroot user, the function entrypoint, and +// the gRPC port. +func configurePythonImage(img v1.Image) (v1.Image, error) { + cfgFile, err := img.ConfigFile() + if err != nil { + return nil, errors.Wrap(err, "failed to get config file") + } + cfg := cfgFile.Config + + cfg.Entrypoint = []string{"/fn/bin/function"} + cfg.Cmd = nil + cfg.WorkingDir = "/" + cfg.User = "nonroot:nonroot" + if cfg.ExposedPorts == nil { + cfg.ExposedPorts = map[string]struct{}{} + } + cfg.ExposedPorts["9443/tcp"] = struct{}{} + + return mutate.Config(img, cfg) +} + +func newPythonBuilder(imageConfigs []pkgv1beta1.ImageConfig) *pythonBuilder { + return &pythonBuilder{ + buildImage: pythonBuildImage, + runtimeImage: pythonRuntimeImage, + transport: http.DefaultTransport, + configStore: clixpkg.NewStaticImageConfigStore(imageConfigs), + } +} diff --git a/internal/project/helm/helm.go b/internal/project/helm/helm.go new file mode 100644 index 0000000..f41dd5b --- /dev/null +++ b/internal/project/helm/helm.go @@ -0,0 +1,233 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package helm implements a helm chart installer. +package helm + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/spf13/afero" + "helm.sh/helm/v3/pkg/action" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chart/loader" + "helm.sh/helm/v3/pkg/cli" + "helm.sh/helm/v3/pkg/release" + "helm.sh/helm/v3/pkg/storage/driver" + "k8s.io/client-go/rest" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" +) + +const ( + helmDriverSecret = "secret" + defaultCacheDir = ".cache/crossplane/charts" + allVersions = ">0.0.0-0" + waitTimeout = 10 * time.Minute +) + +// Manager installs and manages Helm charts in a Kubernetes cluster. +type Manager struct { + repoURL string + chartRef string + chartName string + releaseName string + namespace string + cacheDir string + wait bool + log logging.Logger + fs afero.Fs + + pullClient *puller + getClient helmGetter + installClient helmInstaller +} + +type helmGetter interface { + Run(ref string) (*release.Release, error) +} + +type helmInstaller interface { + Run(ch *chart.Chart, values map[string]any) (*release.Release, error) +} + +// ManagerOption configures a Manager. +type ManagerOption func(*Manager) + +// Wait configures the manager to wait for operations to complete. +func Wait() ManagerOption { + return func(m *Manager) { + m.wait = true + } +} + +// WithLogger sets the logger for the manager. +func WithLogger(l logging.Logger) ManagerOption { + return func(m *Manager) { + m.log = l + } +} + +type puller struct { + *action.Pull +} + +// NewManager builds a helm install manager. +func NewManager(config *rest.Config, chartName, repoURL, namespace string, opts ...ManagerOption) (*Manager, error) { + m := &Manager{ + repoURL: repoURL, + chartRef: chartName, + chartName: chartName, + releaseName: chartName, + namespace: namespace, + log: logging.NewNopLogger(), + fs: afero.NewOsFs(), + } + for _, o := range opts { + o(m) + } + + home, err := os.UserHomeDir() + if err != nil { + return nil, err + } + m.cacheDir = filepath.Join(home, defaultCacheDir) + + actionConfig := new(action.Configuration) + if err := actionConfig.Init(newRESTClientGetter(config, namespace), namespace, helmDriverSecret, func(format string, v ...any) { + m.log.Debug(fmt.Sprintf(format, v...)) + }); err != nil { + return nil, err + } + + if _, err := m.fs.Stat(m.cacheDir); err != nil { + if !os.IsNotExist(err) { + return nil, err + } + if err := m.fs.MkdirAll(m.cacheDir, 0o755); err != nil { + return nil, err + } + } + + // Pull Client + p := action.NewPullWithOpts(action.WithConfig(&action.Configuration{})) + p.DestDir = m.cacheDir + p.Devel = true + p.Settings = &cli.EnvSettings{} + p.RepoURL = repoURL + m.pullClient = &puller{Pull: p} + + // Get Client + m.getClient = action.NewGet(actionConfig) + + // Install Client + ic := action.NewInstall(actionConfig) + ic.Namespace = namespace + ic.CreateNamespace = false + ic.ReleaseName = chartName + ic.Wait = m.wait + ic.Timeout = waitTimeout + m.installClient = ic + + return m, nil +} + +// GetCurrentVersion gets the current version of the chart in the cluster. +func (m *Manager) GetCurrentVersion() (string, error) { + r, err := m.getClient.Run(m.chartName) + if err != nil { + return "", errors.Wrapf(err, "could not identify installed release for %s in namespace %s", m.chartName, m.namespace) + } + if r == nil || r.Chart == nil || r.Chart.Metadata == nil { + return "", errors.New("could not identify current version") + } + return r.Chart.Metadata.Version, nil +} + +// Install installs the chart in the cluster. +func (m *Manager) Install(version string, parameters map[string]any) error { + // Make sure no version is already installed. + current, err := m.GetCurrentVersion() + if err == nil { + return errors.Errorf("chart already installed with version %s", current) + } + if !errors.Is(err, driver.ErrReleaseNotFound) { + // Some other error getting the current version - check if it's because + // the release wasn't found. + if !strings.Contains(err.Error(), "not found") { + return errors.Wrap(err, "could not verify that chart is not already installed") + } + } + + helmChart, err := m.pullAndLoad(version) + if err != nil { + return err + } + + _, err = m.installClient.Run(helmChart, parameters) + return err +} + +func (m *Manager) pullAndLoad(version string) (*chart.Chart, error) { + if version != "" { + fileName := filepath.Join(m.cacheDir, fmt.Sprintf("%s-%s.tgz", m.chartName, version)) + if _, err := m.fs.Stat(fileName); err != nil { + m.pullClient.DestDir = m.cacheDir + m.pullClient.Version = version + if _, err := m.pullClient.Run(m.chartRef); err != nil { + return nil, errors.Wrap(err, "could not pull chart") + } + } + return loader.Load(fileName) + } + + tmp, err := afero.TempDir(m.fs, m.cacheDir, "") + if err != nil { + return nil, err + } + defer func() { + if err := m.fs.RemoveAll(tmp); err != nil { + m.log.Debug("failed to clean up temporary directory", "error", err) + } + }() + m.pullClient.DestDir = tmp + m.pullClient.Version = allVersions + if _, err := m.pullClient.Run(m.chartRef); err != nil { + return nil, errors.Wrap(err, "could not pull chart") + } + files, err := afero.ReadDir(m.fs, tmp) + if err != nil { + return nil, errors.Wrap(err, "could not identify chart pulled as latest") + } + if len(files) != 1 { + return nil, errors.Errorf("corrupt chart tmp directory, consider removing cache (%s)", m.cacheDir) + } + tmpFileName := filepath.Join(tmp, files[0].Name()) + c, err := loader.Load(tmpFileName) + if err != nil { + return nil, err + } + fileName := filepath.Join(m.cacheDir, fmt.Sprintf("%s-%s.tgz", m.chartName, c.Metadata.Version)) + if err := m.fs.Rename(tmpFileName, fileName); err != nil { + return nil, errors.Wrap(err, "could not move latest pulled chart to cache") + } + return c, nil +} diff --git a/internal/project/helm/restclientgetter.go b/internal/project/helm/restclientgetter.go new file mode 100644 index 0000000..06c6b9a --- /dev/null +++ b/internal/project/helm/restclientgetter.go @@ -0,0 +1,75 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package helm + +import ( + "k8s.io/apimachinery/pkg/api/meta" + "k8s.io/client-go/discovery" + "k8s.io/client-go/discovery/cached/memory" + "k8s.io/client-go/rest" + "k8s.io/client-go/restmapper" + "k8s.io/client-go/tools/clientcmd" +) + +type restClientGetter struct { + namespace string + config *rest.Config +} + +func newRESTClientGetter(config *rest.Config, namespace string) *restClientGetter { + return &restClientGetter{ + namespace: namespace, + config: config, + } +} + +// ToRESTConfig returns the underlying REST config. +func (c *restClientGetter) ToRESTConfig() (*rest.Config, error) { + return c.config, nil +} + +// ToDiscoveryClient builds a new discovery client. +func (c *restClientGetter) ToDiscoveryClient() (discovery.CachedDiscoveryInterface, error) { + config, err := c.ToRESTConfig() + if err != nil { + return nil, err + } + config.Burst = 300 + config.QPS = 50 + discoveryClient, _ := discovery.NewDiscoveryClientForConfig(config) + return memory.NewMemCacheClient(discoveryClient), nil +} + +// ToRESTMapper builds a new REST mapper. +func (c *restClientGetter) ToRESTMapper() (meta.RESTMapper, error) { + discoveryClient, err := c.ToDiscoveryClient() + if err != nil { + return nil, err + } + mapper := restmapper.NewDeferredDiscoveryRESTMapper(discoveryClient) + expander := restmapper.NewShortcutExpander(mapper, discoveryClient, nil) + return expander, nil +} + +// ToRawKubeConfigLoader loads a new raw kubeconfig. +func (c *restClientGetter) ToRawKubeConfigLoader() clientcmd.ClientConfig { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + loadingRules.DefaultClientConfig = &clientcmd.DefaultClientConfig + overrides := &clientcmd.ConfigOverrides{ClusterDefaults: clientcmd.ClusterDefaults} + overrides.Context.Namespace = c.namespace + return clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, overrides) +} diff --git a/internal/project/image.go b/internal/project/image.go new file mode 100644 index 0000000..bc517b7 --- /dev/null +++ b/internal/project/image.go @@ -0,0 +1,140 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "slices" + "strings" + + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/types" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" +) + +// AnnotateImage reads in the layers of the given v1.Image and annotates the +// xpkg layers with their corresponding annotations, returning a new v1.Image +// containing the annotation details. +func AnnotateImage(i v1.Image) (v1.Image, error) { + cfgFile, err := i.ConfigFile() + if err != nil { + return nil, err + } + + layers, err := i.Layers() + if err != nil { + return nil, err + } + + addendums := make([]mutate.Addendum, 0) + + for _, l := range layers { + d, err := l.Digest() + if err != nil { + return nil, err + } + if annotation, ok := cfgFile.Config.Labels[xpkg.Label(d.String())]; ok { + addendums = append(addendums, mutate.Addendum{ + Layer: l, + MediaType: types.DockerLayer, + Annotations: map[string]string{ + xpkg.AnnotationKey: annotation, + }, + }) + continue + } + addendums = append(addendums, mutate.Addendum{ + Layer: l, + MediaType: types.DockerLayer, + }) + } + + if len(addendums) == 0 { + return i, nil + } + + img := empty.Image + for _, a := range addendums { + img, err = mutate.Append(img, a) + if err != nil { + return nil, errors.Wrap(err, "failed to build annotated image") + } + } + + img, err = mutate.ConfigFile(img, cfgFile) + if err != nil { + return nil, err + } + + img = mutate.MediaType(img, types.DockerManifestSchema2) + img = mutate.ConfigMediaType(img, types.DockerConfigJSON) + + return img, nil +} + +// BuildIndex applies annotations to each of the given images and then generates +// an index for them. The annotated images are returned so that a caller can +// push them before pushing the index, since the passed images may not match the +// annotated images. +func BuildIndex(imgs ...v1.Image) (v1.ImageIndex, []v1.Image, error) { + adds := make([]mutate.IndexAddendum, 0, len(imgs)) + images := make([]v1.Image, 0, len(imgs)) + for _, img := range imgs { + aimg, err := AnnotateImage(img) + if err != nil { + return nil, nil, err + } + images = append(images, aimg) + mt, err := aimg.MediaType() + if err != nil { + return nil, nil, err + } + + conf, err := aimg.ConfigFile() + if err != nil { + return nil, nil, err + } + + adds = append(adds, mutate.IndexAddendum{ + Add: aimg, + Descriptor: v1.Descriptor{ + MediaType: mt, + Platform: &v1.Platform{ + Architecture: conf.Architecture, + OS: conf.OS, + OSVersion: conf.OSVersion, + }, + }, + }) + } + + var sortErr error + slices.SortFunc(adds, func(a, b mutate.IndexAddendum) int { + dgstA, errA := a.Add.Digest() + dgstB, errB := b.Add.Digest() + sortErr = errors.Join(errA, errB) + return strings.Compare(dgstA.String(), dgstB.String()) + }) + if sortErr != nil { + return nil, nil, sortErr + } + + return mutate.AppendManifests(empty.Index, adds...), images, nil +} diff --git a/internal/project/install.go b/internal/project/install.go new file mode 100644 index 0000000..f91231b --- /dev/null +++ b/internal/project/install.go @@ -0,0 +1,346 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "context" + "encoding/json" + "math" + "time" + + "github.com/Masterminds/semver/v3" + "github.com/google/go-containerregistry/pkg/name" + apierrors "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/wait" + "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/logging" + "github.com/crossplane/crossplane-runtime/v2/pkg/resource" + + xpv2 "github.com/crossplane/crossplane/apis/v2/core/v2" + xpkgv1 "github.com/crossplane/crossplane/apis/v2/pkg/v1" + xpkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" +) + +// InstallConfiguration installs a Configuration package on the target control +// plane and waits for it and all its dependencies to become healthy. +func InstallConfiguration(ctx context.Context, cl client.Client, cfgName string, tag name.Tag, logger logging.Logger) error { + pkgSource := tag.String() + cfg := &xpkgv1.Configuration{ + TypeMeta: metav1.TypeMeta{ + APIVersion: xpkgv1.SchemeGroupVersion.String(), + Kind: xpkgv1.ConfigurationKind, + }, + ObjectMeta: metav1.ObjectMeta{ + Name: cfgName, + }, + Spec: xpkgv1.ConfigurationSpec{ + PackageSpec: xpkgv1.PackageSpec{ + Package: pkgSource, + }, + }, + } + + logger.Debug("Installing configuration package") + + err := retryWithBackoff(ctx, 2*time.Second, func(ctx context.Context) (bool, error) { + //nolint:staticcheck // TODO(adamwg): Migrate to cl.Apply. + err := cl.Patch(ctx, cfg, client.Apply, client.ForceOwnership, client.FieldOwner("crossplane-cli")) + if err != nil { + if isRetryableServerError(err) { + return false, nil + } + return false, err + } + return true, nil + }) + if err != nil { + return err + } + + logger.Debug("Waiting for packages to be ready") + return waitForPackagesReady(ctx, cl, cfg) +} + +func isRetryableServerError(err error) bool { + if apierrors.IsTimeout(err) || + apierrors.IsInternalError(err) || + apierrors.IsServerTimeout(err) { + return true + } + + var statusErr *apierrors.StatusError + if errors.As(err, &statusErr) { + reason := statusErr.ErrStatus.Reason + if reason == metav1.StatusReasonServiceUnavailable { + return true + } + } + + return false +} + +func waitForPackagesReady(ctx context.Context, cl client.Client, cfg *xpkgv1.Configuration) error { + nn := types.NamespacedName{ + Name: "lock", + } + var lock xpkgv1beta1.Lock + + return retryWithBackoff(ctx, 5*time.Second, func(ctx context.Context) (bool, error) { + cfgRev, revFound, err := getCurrentRevision(ctx, cl, cfg) + if err != nil { + return false, err + } + if !revFound { + return false, nil + } + + if cfgRev.GetSource() != cfg.GetSource() { + return false, nil + } + + if !packageHasHealthyConditions(cfgRev) { + return false, nil + } + + if err := cl.Get(ctx, nn, &lock); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, errors.Wrap(err, "failed to get lock") + } + + var cfgPkg *xpkgv1beta1.LockPackage + for _, pkg := range lock.Packages { + if pkg.Name == cfgRev.Name { + cfgPkg = &pkg + break + } + } + if cfgPkg == nil { + return false, nil + } + + healthy, err := allDepsHealthy(ctx, cl, lock, *cfgPkg) + if err != nil { + return false, err + } + + return healthy, nil + }) +} + +func getCurrentRevision(ctx context.Context, cl client.Client, cfg *xpkgv1.Configuration) (*xpkgv1.ConfigurationRevision, bool, error) { + cfgNN := types.NamespacedName{ + Name: cfg.Name, + } + if err := cl.Get(ctx, cfgNN, cfg); err != nil { + return nil, false, errors.Wrap(err, "failed to get configuration") + } + + if cfg.Status.CurrentRevision == "" { + return nil, false, nil + } + + revNN := types.NamespacedName{ + Name: cfg.Status.CurrentRevision, + } + var cfgRev xpkgv1.ConfigurationRevision + if err := cl.Get(ctx, revNN, &cfgRev); err != nil { + if apierrors.IsNotFound(err) { + return nil, false, nil + } + return nil, false, errors.Wrap(err, "failed to get configuration revision") + } + + return &cfgRev, true, nil +} + +func allDepsHealthy(ctx context.Context, cl client.Client, lock xpkgv1beta1.Lock, pkg xpkgv1beta1.LockPackage) (bool, error) { + for _, dep := range pkg.Dependencies { + depPkg, found := lookupLockPackage(lock.Packages, dep.Package, dep.Constraints) + if !found { + return false, nil + } + healthy, err := packageIsHealthy(ctx, cl, depPkg) + if err != nil { + return false, err + } + if !healthy { + return false, nil + } + } + + return true, nil +} + +func lookupLockPackage(pkgs []xpkgv1beta1.LockPackage, source, constraint string) (xpkgv1beta1.LockPackage, bool) { + for _, pkg := range pkgs { + if !sourcesEqual(pkg.Source, source) { + continue + } + + vc, err := semver.NewConstraint(constraint) + if err != nil { + if pkg.Version == constraint { + return pkg, true + } + } + pv, err := semver.NewVersion(pkg.Version) + if err != nil { + continue + } + if vc.Check(pv) { + return pkg, true + } + } + return xpkgv1beta1.LockPackage{}, false +} + +func sourcesEqual(a, b string) bool { + ra, err := name.NewRepository(a, name.StrictValidation) + if err != nil { + return false + } + rb, err := name.NewRepository(b, name.StrictValidation) + if err != nil { + return false + } + + return ra.String() == rb.String() +} + +func packageIsHealthy(ctx context.Context, cl client.Client, lpkg xpkgv1beta1.LockPackage) (bool, error) { + var pkg xpkgv1.PackageRevision + + if lpkg.Kind != nil { + switch *lpkg.Kind { + case xpkgv1.ConfigurationKind: + pkg = &xpkgv1.ConfigurationRevision{} + case xpkgv1.ProviderKind: + pkg = &xpkgv1.ProviderRevision{} + case xpkgv1.FunctionKind: + pkg = &xpkgv1.FunctionRevision{} + } + } + + if lpkg.Type != nil { + switch *lpkg.Type { + case xpkgv1beta1.ConfigurationPackageType: + pkg = &xpkgv1.ConfigurationRevision{} + case xpkgv1beta1.ProviderPackageType: + pkg = &xpkgv1.ProviderRevision{} + case xpkgv1beta1.FunctionPackageType: + pkg = &xpkgv1.FunctionRevision{} + } + } + + err := cl.Get(ctx, types.NamespacedName{Name: lpkg.Name}, pkg) + if err != nil { + return false, err + } + + return packageHasHealthyConditions(pkg), nil +} + +func packageHasHealthyConditions(pkg xpkgv1.PackageRevision) bool { + v1Healthy := resource.IsConditionTrue(pkg.GetCondition(xpv2.TypeHealthy)) + v2Healthy := resource.IsConditionTrue(pkg.GetCondition(xpkgv1.TypeRevisionHealthy)) + + if _, ok := pkg.(xpkgv1.PackageRevisionWithRuntime); ok { + v2Healthy = v2Healthy && resource.IsConditionTrue(pkg.GetCondition(xpkgv1.TypeRuntimeHealthy)) + } + + return v1Healthy || v2Healthy +} + +// ApplyResources installs arbitrary resources to the target control plane. +func ApplyResources(ctx context.Context, cl client.Client, resources []runtime.RawExtension) error { + for _, raw := range resources { + if len(raw.Raw) == 0 { + return errors.New("encountered an invalid or empty raw resource") + } + + obj := &unstructured.Unstructured{} + if err := json.Unmarshal(raw.Raw, obj); err != nil { + return errors.Wrap(err, "failed to unmarshal resource") + } + + if err := retryWithBackoff(ctx, 2*time.Second, func(ctx context.Context) (bool, error) { + //nolint:staticcheck // TODO(adamwg): Migrate to cl.Apply. + err := cl.Patch(ctx, obj, client.Apply, client.ForceOwnership, client.FieldOwner("crossplane-cli")) + if err != nil { + if isPermanentError(err) { + return false, err + } + return false, nil + } + return true, nil + }); err != nil { + return errors.Wrapf(err, "failed to apply resource %s/%s", + obj.GetKind(), obj.GetName()) + } + } + return nil +} + +func isPermanentError(err error) bool { + if apierrors.IsBadRequest(err) || + apierrors.IsInvalid(err) || + apierrors.IsMethodNotSupported(err) || + apierrors.IsNotAcceptable(err) || + apierrors.IsUnsupportedMediaType(err) || + apierrors.IsUnauthorized(err) || + apierrors.IsForbidden(err) || + apierrors.IsRequestEntityTooLargeError(err) { + return true + } + + return false +} + +func retryWithBackoff(ctx context.Context, maxWait time.Duration, fn func(ctx context.Context) (bool, error)) error { + backoff := wait.Backoff{ + Duration: 500 * time.Millisecond, + Factor: 2.0, + Jitter: 0.1, + Cap: maxWait, + Steps: math.MaxInt32, + } + + for { + done, err := fn(ctx) + if err != nil { + return err + } + if done { + return nil + } + + sleep := backoff.Step() + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(sleep): + } + } +} diff --git a/internal/project/projectfile/projectfile.go b/internal/project/projectfile/projectfile.go new file mode 100644 index 0000000..7e86213 --- /dev/null +++ b/internal/project/projectfile/projectfile.go @@ -0,0 +1,99 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package projectfile reads and writes Crossplane project files. +package projectfile + +import ( + "github.com/spf13/afero" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" +) + +const ( + // APIVersion is the supported API version for project files. + APIVersion = "dev.crossplane.io/v1alpha1" + // Kind is the supported Kind for project files. + Kind = "Project" +) + +// Parse parses and validates the project file, returning a Project with +// defaults applied. +func Parse(projFS afero.Fs, projFilePath string) (*v1alpha1.Project, error) { + proj, err := ParseWithoutDefaults(projFS, projFilePath) + if err != nil { + return nil, err + } + + proj.Default() + + return proj, nil +} + +// ParseWithoutDefaults parses and validates the project file without applying +// defaults. Use this when reading a project file that will be modified and +// written back, to avoid persisting default values the user omitted. +func ParseWithoutDefaults(projFS afero.Fs, projFilePath string) (*v1alpha1.Project, error) { + bs, err := afero.ReadFile(projFS, projFilePath) + if err != nil { + return nil, errors.Wrapf(err, "failed to read project file %q", projFilePath) + } + + var tm metav1.TypeMeta + if err := yaml.Unmarshal(bs, &tm); err != nil { + return nil, errors.Wrap(err, "failed to parse project file") + } + + if tm.APIVersion != APIVersion { + return nil, errors.Errorf("unsupported project apiVersion %q, expected %q", tm.APIVersion, APIVersion) + } + if tm.Kind != Kind { + return nil, errors.Errorf("unsupported project kind %q, expected %q", tm.Kind, Kind) + } + + var proj v1alpha1.Project + if err := yaml.Unmarshal(bs, &proj); err != nil { + return nil, errors.Wrap(err, "failed to parse project file") + } + + if err := proj.Validate(); err != nil { + return nil, errors.Wrap(err, "invalid project file") + } + + return &proj, nil +} + +// Update reads a project file without applying defaults, applies the given +// mutation function, and writes the result back. This allows the project to be +// updated on disk without injecting defaults. Note that the file will be +// reformatted by the YAML serializer (fields will be re-ordered and comments +// will be lost). +func Update(projFS afero.Fs, projFile string, fn func(*v1alpha1.Project)) error { + proj, err := ParseWithoutDefaults(projFS, projFile) + if err != nil { + return err + } + fn(proj) + bs, err := yaml.Marshal(proj) + if err != nil { + return errors.Wrap(err, "failed to marshal project") + } + return afero.WriteFile(projFS, projFile, bs, 0o644) +} diff --git a/internal/project/sort.go b/internal/project/sort.go new file mode 100644 index 0000000..cd38d42 --- /dev/null +++ b/internal/project/sort.go @@ -0,0 +1,53 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package project + +import ( + "fmt" + + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// SortImages analyzes an image map produced by the project builder and picks +// out the configuration and function images from it. The function images are +// grouped together by function, so that multi-arch indexes can be produced +// based on the returned map. +func SortImages(imgMap ImageTagMap, repo string) (cfgImage v1.Image, fnImages map[name.Repository][]v1.Image, err error) { + cfgTag, err := name.NewTag(fmt.Sprintf("%s:%s", repo, ConfigurationTag), name.StrictValidation) + if err != nil { + return nil, nil, errors.Wrap(err, "failed to construct configuration tag") + } + + fnImages = make(map[name.Repository][]v1.Image) + for tag, image := range imgMap { + if tag == cfgTag { + cfgImage = image + continue + } + + fnImages[tag.Repository] = append(fnImages[tag.Repository], image) + } + + if cfgImage == nil { + return nil, nil, errors.New("failed to find configuration image") + } + + return cfgImage, fnImages, nil +} diff --git a/internal/schemas/generator/go.go b/internal/schemas/generator/go.go new file mode 100644 index 0000000..a850cf1 --- /dev/null +++ b/internal/schemas/generator/go.go @@ -0,0 +1,1363 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "go/ast" + "go/format" + "go/parser" + "go/printer" + "go/token" + "io/fs" + "maps" + "path/filepath" + "slices" + "strings" + "sync" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/oapi-codegen/oapi-codegen/v2/pkg/codegen" + "github.com/spf13/afero" + "golang.org/x/tools/go/ast/astutil" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + xpv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/crd" + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +// K8s package constants. +const ( + k8sPkgMetaV1 = "io.k8s.apimachinery.pkg.apis.meta.v1" + k8sPkgRuntime = "io.k8s.apimachinery.pkg.runtime" + k8sPkgCoreV1 = "io.k8s.api.core.v1" + k8sPkgIntStr = "io.k8s.apimachinery.pkg.util.intstr" + k8sPkgResource = "io.k8s.apimachinery.pkg.api.resource" + k8sPkgAutoscalingV1 = "io.k8s.api.autoscaling.v1" + + k8sPkgNameAutoscaling = "autoscaling" +) + +// goModContents is the contents of the go.mod we write for our generated models +// module. All generated models share the same module so that we can generate a +// single dependency from embedded Go functions. We always resolve this +// dependency via a replace statement, so `dev.upbound.io/models` is never +// actually used as a URL, just an identifier. +const goModContents = `module dev.upbound.io/models + +go 1.23 + +require github.com/oapi-codegen/runtime v1.1.0 + +require ( + github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect + github.com/google/uuid v1.4.0 // indirect +) +` + +// goSumContents is the contents of the go.sum we write for our generated models +// module alongside the go.mod. +const goSumContents = `github.com/RaveNoX/go-jsoncommentstrip v1.0.0/go.mod h1:78ihd09MekBnJnxpICcwzCMzGrKSKYe4AqU6PDYYpjk= +github.com/apapsch/go-jsonmerge/v2 v2.0.0 h1:axGnT1gRIfimI7gJifB699GoE/oq+F2MU7Dml6nw9rQ= +github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP2+08jFMw88y4klk= +github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= +github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE= +github.com/oapi-codegen/runtime v1.1.0 h1:rJpoNUawn5XTvekgfkvSZr0RqEnoYpFkyvrzfWeFKWM= +github.com/oapi-codegen/runtime v1.1.0/go.mod h1:BeSfBkWWWnAnGdyS+S/GnlbmHKzf8/hwkvelJZDeKA8= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spkg/bom v0.0.0-20160624110644-59b7046e48ad/go.mod h1:qLr4V1qq6nMqFKkMo8ZTx3f+BZEkzsRUY10Xsm2mwU0= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= +github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +` + +// goImportsTemplate replaces the default import template for oapi-codegen, +// since it contains many imports we don't use and will thus result in code that +// doesn't compile. +const goImportsTemplate = `// Package {{.PackageName}} contains generated models. +// +// Code generated by {{.ModuleName}} version {{.Version}} DO NOT EDIT. +package {{.PackageName}} + +import ( + "encoding/json" + "fmt" + "time" + + "github.com/oapi-codegen/runtime" + + {{- range .ExternalImports}} + {{ . }} + {{- end}} + {{- range .AdditionalImports}} + {{.Alias}} "{{.Package}}" + {{- end}} +) + +// Use the following to avoid unused import errors. +var ( + _ *time.Time = nil + _ json.RawMessage = nil + _ = fmt.Errorf + _ = runtime.JSONMerge +) +` + +type goGenerator struct{} + +func (goGenerator) Language() string { + return "go" +} + +// GenerateFromCRD generates Go schemas for the CRDs in the given filesystem. +func (goGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + openAPIs, err := goCollectOpenAPIs(fromFS) + if err != nil { + return nil, err + } + + if len(openAPIs) == 0 { + // Return nil if no specs were generated + return nil, nil + } + + // Initialize the schema filesystem + schemaFS, err := initializeSchemaFS() + if err != nil { + return nil, err + } + + // Extract shared k8s schemas and generate separate files for each package. + // We have to do this before generating the other models below because + // the code below replaces the k8s models with references to these shared + // ones in-place in the spec. + k8sSchemasByPackage := make(map[string]map[string]*spec.Schema) + + // Collect all K8s schemas from all OpenAPI specs, grouped by package + for _, oapi := range openAPIs { + packagedSchemas := goExtractK8sSchemas(oapi.spec) + for pkg, schemas := range packagedSchemas { + if k8sSchemasByPackage[pkg] == nil { + k8sSchemasByPackage[pkg] = make(map[string]*spec.Schema) + } + maps.Copy(k8sSchemasByPackage[pkg], schemas) + } + } + + // Generate separate files for each K8s package + for pkg, schemas := range k8sSchemasByPackage { + if len(schemas) == 0 { + continue + } + + // Create a spec for this package + pkgSpec := &spec3.OpenAPI{ + Version: "3.0.0", + Components: &spec3.Components{ + Schemas: schemas, + }, + } + + // Determine the group, kind, and version from the package name + var group, kind, version string + switch pkg { + case k8sPkgMetaV1: + group = "meta.k8s.io" + kind = "meta" + version = "v1" + case k8sPkgAutoscalingV1: + group = k8sPkgNameAutoscaling + kind = k8sPkgNameAutoscaling + version = "v1" + } + + // For K8s packages that reference meta.v1, we need to use the correct + // meta import path. The meta.v1 package uses goReferenceK8sTypes (core path) + // because self-references get stripped. Other packages like autoscaling + // use goReferenceK8sTypesForCRDs (non-core path) to reference the CRD + // meta.v1 package at dev.upbound.io/models/io/k8s/meta/v1. + refMutator := goReferenceK8sTypes + if pkg != k8sPkgMetaV1 { + refMutator = goReferenceK8sTypesForCRDs + } + + code, err := generateGo(pkgSpec, version, + goRenameTypes, + goRenameEnums, + goReplaceNumberWithInt, + goRemoveRequired, + refMutator, + ) + if err != nil { + return nil, err + } + + // shorten the auto‑generated K8s type names + code, err = fixK8sTypeNames(code) + if err != nil { + return nil, err + } + + // remove the self‑import (e.g. meta/v1 importing itself) + code, err = removeSelfImports(code, pkg) + if err != nil { + return nil, err + } + + if err := writeGoCode(schemaFS, group, kind, version, code); err != nil { + return nil, err + } + } + + // Generate models for the non-k8s schemas. + for _, oapi := range openAPIs { + code, err := generateGo(oapi.spec, oapi.version, + goRenameTypes, + goRenameEnums, + goReplaceNumberWithInt, + goRemoveRequired, + goReferenceK8sTypesForCRDs, + goRemoveK8s, + goKeepOnlyComponents, + ) + if err != nil { + return nil, err + } + + if err := writeGoCode(schemaFS, oapi.crd.Spec.Group, oapi.crd.Spec.Names.Kind, oapi.version, code); err != nil { + return nil, err + } + } + + return schemaFS, nil +} + +type goOpenAPI struct { + crd *extv1.CustomResourceDefinition + version string + spec *spec3.OpenAPI +} + +func goCollectOpenAPIs(fromFS afero.Fs) ([]goOpenAPI, error) { //nolint:gocognit // Hard to split this up, and it's not too long to read. + crdFS := afero.NewMemMapFs() + baseFolder := "workdir" + + if err := crdFS.MkdirAll(baseFolder, 0o755); err != nil { + return nil, err + } + + var openAPIs []goOpenAPI + return openAPIs, afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + // Ignore files without yaml extensions. + ext := filepath.Ext(path) + if ext != ".yaml" && ext != ".yml" { + return nil + } + + var u metav1.TypeMeta + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + err = yaml.Unmarshal(bs, &u) + if err != nil { + return errors.Wrapf(err, "failed to parse file %q", path) + } + + switch u.GroupVersionKind().Kind { + case xpv1.CompositeResourceDefinitionKind: + // Process the XRD and get the paths + xrPath, claimPath, err := crd.ProcessXRD(crdFS, bs, path, baseFolder) + if err != nil { + return err + } + + if xrPath != "" { + bs, err := afero.ReadFile(crdFS, xrPath) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + + var c extv1.CustomResourceDefinition + if err := yaml.Unmarshal(bs, &c); err != nil { + return errors.Wrapf(err, "failed to unmarshal CRD file %q", path) + } + + oapis, err := crd.ToOpenAPI(&c) + if err != nil { + return err + } + for version, oapi := range oapis { + openAPIs = append(openAPIs, goOpenAPI{spec: oapi, version: version, crd: &c}) + } + } + if claimPath != "" { + bs, err := afero.ReadFile(crdFS, claimPath) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + + var c extv1.CustomResourceDefinition + if err := yaml.Unmarshal(bs, &c); err != nil { + return errors.Wrapf(err, "failed to unmarshal CRD file %q", path) + } + + oapis, err := crd.ToOpenAPI(&c) + if err != nil { + return err + } + for version, oapi := range oapis { + openAPIs = append(openAPIs, goOpenAPI{spec: oapi, version: version, crd: &c}) + } + } + + case "CustomResourceDefinition": + var c extv1.CustomResourceDefinition + if err := yaml.Unmarshal(bs, &c); err != nil { + return errors.Wrapf(err, "failed to unmarshal CRD file %q", path) + } + + oapis, err := crd.ToOpenAPI(&c) + if err != nil { + return err + } + for version, oapi := range oapis { + openAPIs = append(openAPIs, goOpenAPI{spec: oapi, version: version, crd: &c}) + } + } + return nil + }) +} + +// generateGoMutex prevents concurrent calls to `codegen.Generate` in +// `generateGo`, since `codegen.Generate` is not concurrency safe. +var generateGoMutex sync.Mutex //nolint:gochecknoglobals // Must be global. + +func generateGo(s *spec3.OpenAPI, version string, mutators ...func(*spec3.OpenAPI)) (string, error) { + for _, mut := range mutators { + mut(s) + } + + // Round-trip through JSON to convert the spec to the kin library used by + // oapi-codegen. + bs, err := json.Marshal(s) + if err != nil { + return "", errors.Wrap(err, "failed to marshal OpenAPI spec") + } + ld := openapi3.NewLoader() + oapiInput, err := ld.LoadFromData(bs) + if err != nil { + return "", errors.Wrap(err, "failed to parse OpenAPI spec") + } + + // Generate code! + generateGoMutex.Lock() + defer generateGoMutex.Unlock() + goCode, err := codegen.Generate(oapiInput, codegen.Configuration{ + PackageName: version, + Generate: codegen.GenerateOptions{ + Models: true, + }, + OutputOptions: codegen.OutputOptions{ + SkipPrune: true, + NameNormalizer: string(codegen.NameNormalizerFunctionToCamelCaseWithInitialisms), + SkipFmt: true, + UserTemplates: map[string]string{ + "imports.tmpl": goImportsTemplate, + }, + }, + }) + if err != nil { + return "", errors.Wrap(err, "failed to generate go code from OpenAPI schema") + } + + // Post-process to fix missing imports for map value types + goCode, err = fixMissingImports(goCode) + if err != nil { + return "", errors.Wrap(err, "failed to fix missing imports") + } + + goCodeBytes, err := format.Source([]byte(goCode)) + if err != nil { + return "", errors.Wrap(err, "failed to format go code") + } + + return string(goCodeBytes), nil +} + +// goExtractK8sSchemas returns all k8s schemas from the given OpenAPI +// spec, grouped by their package. +func goExtractK8sSchemas(s *spec3.OpenAPI) map[string]map[string]*spec.Schema { + ret := make(map[string]map[string]*spec.Schema) + + // Define the K8s packages we want to extract + k8sPackages := []string{ + k8sPkgMetaV1, + k8sPkgRuntime, + k8sPkgCoreV1, + k8sPkgIntStr, + k8sPkgResource, + k8sPkgAutoscalingV1, + } + + // Initialize the map for each package + for _, pkg := range k8sPackages { + ret[pkg] = make(map[string]*spec.Schema) + } + + // Group schemas by their package + for name, schema := range s.Components.Schemas { + for _, pkg := range k8sPackages { + if strings.Contains(name, pkg) { + ret[pkg][name] = schema + break + } + } + } + + // Remove empty groups + for pkg := range ret { + if len(ret[pkg]) == 0 { + delete(ret, pkg) + } + } + + return ret +} + +func writeGoCode(schemaFS afero.Fs, group, kind, version, code string) error { + goPath := filepath.Join("models", goSchemaPath(group, kind, version)) + dir := filepath.Dir(goPath) + if err := schemaFS.MkdirAll(dir, 0o755); err != nil { + return errors.Wrap(err, "failed to create directory for schemas") + } + + f, err := schemaFS.Create(goPath) + if err != nil { + return errors.Wrap(err, "failed to create go schema file") + } + if _, err := f.WriteString(code); err != nil { + return errors.Wrap(err, "failed to write go code to file") + } + _ = f.Close() + + return nil +} + +func goSchemaPath(group, kind, version string) string { + // Our Go files will live in directories based on the CRD group and + // version. The filename is the singular kind of the CRD. + // + // Example: Kind "Bucket" in group "platform.example.com/v1alpha1" becomes + // com/example/platform/v1alpha1/bucket.go. + // Special case: meta.core.k8s.io becomes io/k8s/core/meta/v1 for built-in K8s types + if group == "meta.core.k8s.io" { + return filepath.Join("io", "k8s", "core", "meta", version, strings.ToLower(kind)+".go") + } + + // Handle specific K8s groups that should be under io/k8s/ + switch group { + case "apps", k8sPkgNameAutoscaling, "batch", "policy": + return filepath.Join("io", "k8s", group, version, strings.ToLower(kind)+".go") + } + + path := strings.Split(group, ".") + slices.Reverse(path) + path = append(path, version, strings.ToLower(kind)+".go") + + return filepath.Join(path...) +} + +// goRenameTypes adds annotations to schemas to cause oapi-codegen to generate +// nice type names. +func goRenameTypes(s *spec3.OpenAPI) { + for name, schema := range s.Components.Schemas { + goName := goFixName(name) + if goName == "" { + delete(s.Components.Schemas, name) + } + goRenameSchemaType(goName, schema) + goRenamePropertyTypes(goName, schema.Properties) + } +} + +func goRenamePropertyTypes(baseName string, props map[string]spec.Schema) { + for name, prop := range props { + goName := goFixName(baseName + strings.ToUpper(string(name[0])) + name[1:]) + + goRenameSchemaType(goName, &prop) + goRenamePropertyTypes(goName, prop.Properties) + + if prop.Items != nil { + goRenameSchemaType(goName+"Item", prop.Items.Schema) + goRenamePropertyTypes(goName+"Item", prop.Items.Schema.Properties) + } + + props[name] = prop + } +} + +func goFixName(name string) string { + generateGoMutex.Lock() + defer generateGoMutex.Unlock() + + lastDot := strings.LastIndex(name, ".") + if lastDot == -1 { + return codegen.ToCamelCaseWithInitialisms(name) + } + genName := codegen.SchemaNameToTypeName(name) + prefix := codegen.SchemaNameToTypeName(name[:lastDot]) + return codegen.ToCamelCaseWithInitialisms(strings.TrimPrefix(genName, prefix)) +} + +func goRenameSchemaType(name string, schema *spec.Schema) { + schema.AddExtension("x-go-type-name", name) + schema.AddExtension("x-oapi-codegen-only-honour-go-name", true) +} + +// goRenameEnums names enum values unambiguously so different generated models +// can live in the same package. +func goRenameEnums(s *spec3.OpenAPI) { + for name, schema := range s.Components.Schemas { + goName := goFixName(name) + if goName == "" { + delete(s.Components.Schemas, name) + } + goRenameEnumValues(goName, schema) + goRenamePropertyEnums(goName, schema.Properties) + } +} + +func goRenamePropertyEnums(baseName string, props map[string]spec.Schema) { + for name, prop := range props { + goName := goFixName(baseName + strings.ToUpper(string(name[0])) + name[1:]) + + goRenameEnumValues(goName, &prop) + goRenamePropertyEnums(goName, prop.Properties) + + if prop.Items != nil { + goRenameEnumValues(goName+"Item", prop.Items.Schema) + goRenamePropertyEnums(goName+"Item", prop.Items.Schema.Properties) + } + + props[name] = prop + } +} + +func goRenameEnumValues(typeName string, schema *spec.Schema) { + if schema.Enum == nil { + return + } + + newNames := make([]string, len(schema.Enum)) + for i, oldName := range schema.Enum { + s, ok := oldName.(string) + if !ok { + // This should always be true, but we'd rather not panic, so ignore + // any non-string enums. + continue + } + newNames[i] = typeName + s + } + + schema.AddExtension("x-enum-varnames", newNames) +} + +// goReplaceNumberWithInt adds annotations to schemas to cause oapi-codegen to +// generate int type fields instead of floats for numbers. +func goReplaceNumberWithInt(s *spec3.OpenAPI) { + for _, schema := range s.Components.Schemas { + goRetypeSchema(schema, "number", "int") + goRetypeProperties(schema.Properties, "number", "int") + } +} + +func goRetypeProperties(props map[string]spec.Schema, oldType, newType string) { + for name, prop := range props { + goRetypeSchema(&prop, oldType, newType) + if prop.Items != nil { + goRetypeSchema(prop.Items.Schema, oldType, newType) + goRetypeProperties(prop.Items.Schema.Properties, oldType, newType) + } + props[name] = prop + } +} + +func goRetypeSchema(schema *spec.Schema, oldType, newType string) { + if schema.Type.Contains(oldType) { + schema.AddExtension("x-go-type", newType) + } +} + +// goRemoveRequired removes the required fields from schemas. We want all fields +// in our generated models to be optional (so functions can set only the fields +// they wish to own). +func goRemoveRequired(s *spec3.OpenAPI) { + for _, schema := range s.Components.Schemas { + schema.Required = nil + goRemovePropertiesRequired(schema.Properties) + if schema.Items != nil { + goRemovePropertiesRequired(schema.Items.Schema.Properties) + } + } +} + +func goRemovePropertiesRequired(props map[string]spec.Schema) { + for name, prop := range props { + prop.Required = nil + goRemovePropertiesRequired(prop.Properties) + if prop.Items != nil { + prop.Items.Schema.Required = nil + goRemovePropertiesRequired(prop.Items.Schema.Properties) + } + + props[name] = prop + } +} + +// goReferenceK8sTypes converts all references to k8s meta/v1 schemas in the +// given spec to references to the shared Go models we generate for the k8s +// schemas. +func goReferenceK8sTypes(s *spec3.OpenAPI) { + for _, schema := range s.Components.Schemas { + goReferenceK8sType(schema) + goReferenceK8sTypesProperties(schema.Properties) + } +} + +// goReferenceK8sTypesForCRDs is like goReferenceK8sTypes but uses different +// import paths appropriate for CRDs. For CRDs, we only need to handle meta.v1 +// differently since CRDs might use meta.k8s.io group. +func goReferenceK8sTypesForCRDs(s *spec3.OpenAPI) { + for _, schema := range s.Components.Schemas { + goReferenceK8sTypeWithMetaPath(schema, false) + goReferenceK8sTypesPropertiesWithMetaPath(schema.Properties, false) + } +} + +func goReferenceK8sType(schema *spec.Schema) { + goReferenceK8sTypeWithMetaPath(schema, true) +} + +func goReferenceK8sTypeWithMetaPath(schema *spec.Schema, useCorePath bool) { + // Helper function to check if a reference is a k8s type + isK8sRef := func(ref string) bool { + return strings.Contains(ref, k8sPkgMetaV1) || + strings.Contains(ref, k8sPkgCoreV1) || + strings.Contains(ref, k8sPkgRuntime) || + strings.Contains(ref, k8sPkgIntStr) || + strings.Contains(ref, k8sPkgResource) || + strings.Contains(ref, k8sPkgAutoscalingV1) + } + + // Handle direct reference + ref := schema.Ref.String() + if isK8sRef(ref) { + tryReplaceK8sTypeWithMetaPath(schema, ref, useCorePath) + // Clear the original reference after replacement + schema.Ref = spec.Ref{} + } + + // Handle AllOf - if all schemas in AllOf are k8s refs, we can replace the whole schema + allK8s := true + for _, one := range schema.AllOf { + if one.Ref.String() == "" || !isK8sRef(one.Ref.String()) { + allK8s = false + break + } + } + + if allK8s && len(schema.AllOf) > 0 { + // Use the first AllOf ref for the replacement + ref := schema.AllOf[0].Ref.String() + tryReplaceK8sTypeWithMetaPath(schema, ref, useCorePath) + schema.AllOf = nil + } else { + // Process each AllOf individually + for i := range schema.AllOf { + goReferenceK8sTypeWithMetaPath(&schema.AllOf[i], useCorePath) + } + } + + // Also check OneOf and AnyOf + for i := range schema.OneOf { + goReferenceK8sTypeWithMetaPath(&schema.OneOf[i], useCorePath) + } + for i := range schema.AnyOf { + goReferenceK8sTypeWithMetaPath(&schema.AnyOf[i], useCorePath) + } +} + +func goReferenceK8sTypesProperties(props map[string]spec.Schema) { + goReferenceK8sTypesPropertiesWithMetaPath(props, true) +} + +func goReferenceK8sTypesPropertiesWithMetaPath(props map[string]spec.Schema, useCorePath bool) { + for name, prop := range props { + goReferenceK8sTypeWithMetaPath(&prop, useCorePath) + goReferenceK8sTypesPropertiesWithMetaPath(prop.Properties, useCorePath) + if prop.Items != nil { + goReferenceK8sTypeWithMetaPath(prop.Items.Schema, useCorePath) + goReferenceK8sTypesPropertiesWithMetaPath(prop.Items.Schema.Properties, useCorePath) + } + if prop.AdditionalProperties != nil && prop.AdditionalProperties.Schema != nil { + goReferenceK8sTypeWithMetaPath(prop.AdditionalProperties.Schema, useCorePath) + goReferenceK8sTypesPropertiesWithMetaPath(prop.AdditionalProperties.Schema.Properties, useCorePath) + } + + props[name] = prop + } +} + +func tryReplaceK8sTypeWithMetaPath(schema *spec.Schema, ref string, useCorePath bool) { + lastDot := strings.LastIndex(ref, ".") + if lastDot == -1 { + return + } + t := ref[lastDot+1:] + + // Determine the correct alias and path for meta.v1 + metaAlias := "metacorev1" + metaPath := "dev.upbound.io/models/io/k8s/core/meta/v1" + if !useCorePath { + metaAlias = "metav1" + metaPath = "dev.upbound.io/models/io/k8s/meta/v1" + } + + mapping := []struct { + contains string + alias string + importPath string + }{ + { + contains: k8sPkgMetaV1, + alias: metaAlias, + importPath: metaPath, + }, + { + contains: k8sPkgCoreV1, + alias: "corev1", + importPath: "dev.upbound.io/models/io/k8s/core/v1", + }, + { + contains: k8sPkgRuntime, + alias: "runtimev1", + importPath: "dev.upbound.io/models/io/k8s/runtime/v1", + }, + { + contains: k8sPkgIntStr, + alias: "intstrv1", + importPath: "dev.upbound.io/models/io/k8s/util/v1", + }, + { + contains: k8sPkgResource, + alias: "resourcev1", + importPath: "dev.upbound.io/models/io/k8s/resource/v1", + }, + { + contains: k8sPkgAutoscalingV1, + alias: "autoscalingv1", + importPath: "dev.upbound.io/models/io/k8s/autoscaling/v1", + }, + } + + for _, m := range mapping { + if strings.Contains(ref, m.contains) { + schema.AddExtension("x-go-type", m.alias+"."+t) + schema.AddExtension("x-go-type-import", map[string]string{ + "path": m.importPath, + "name": m.alias, + }) + return + } + } +} + +// goRemoveK8s removes all k8s schemas from the given OpenAPI spec, so +// that we can generate models for them separately and share them across all our +// other generated models. +func goRemoveK8s(s *spec3.OpenAPI) { + for name := range s.Components.Schemas { + if strings.HasPrefix(name, k8sPkgMetaV1) || + strings.HasPrefix(name, k8sPkgRuntime) || + strings.HasPrefix(name, k8sPkgCoreV1) || + strings.HasPrefix(name, k8sPkgIntStr) || + strings.HasPrefix(name, k8sPkgResource) || + strings.HasPrefix(name, k8sPkgAutoscalingV1) { + delete(s.Components.Schemas, name) + } + } +} + +// goKeepOnlyComponents leaves only the "components" portion of the OpenAPI spec +// in place. This lets us make oapi-codegen generate code only for schemas and +// not a full REST client. +func goKeepOnlyComponents(s *spec3.OpenAPI) { + *s = spec3.OpenAPI{ + Version: s.Version, + Info: s.Info, + Components: s.Components, + } +} + +// goAddDefaults adds default values for apiVersion and kind properties based on +// x-kubernetes-group-version-kind extension. +func goAddDefaults(s *spec3.OpenAPI) { + if s.Components == nil || s.Components.Schemas == nil { + return + } + + for _, schema := range s.Components.Schemas { + processSchemaDefaults(schema) + } +} + +func processSchemaDefaults(schema *spec.Schema) { + // Look for x-kubernetes-group-version-kind extension + rawExt, ok := schema.Extensions["x-kubernetes-group-version-kind"] + if !ok { + return + } + + // Convert the extension to a usable format + gvkList := extractGVKList(rawExt) + if len(gvkList) == 0 { + return + } + + // Extract group, version, and kind from the first GVK + group, version, kind := extractGVKInfo(gvkList[0]) + + // Construct apiVersion + apiVersion := constructAPIVersion(group, version) + + // Add defaults to properties + addSchemaPropertyDefaultsGo(schema, apiVersion, kind) +} + +func extractGVKList(rawExt any) []map[string]any { + var gvkList []map[string]any + switch ext := rawExt.(type) { + case []any: + for _, item := range ext { + if gvk, ok := item.(map[string]any); ok { + gvkList = append(gvkList, gvk) + } + } + case []map[string]any: + gvkList = ext + } + return gvkList +} + +func extractGVKInfo(gvk map[string]any) (group, version, kind string) { + if g, ok := gvk["group"].(string); ok { + group = g + } + if v, ok := gvk["version"].(string); ok { + version = v + } + if k, ok := gvk["kind"].(string); ok { + kind = k + } + return group, version, kind +} + +func constructAPIVersion(group, version string) string { + if group != "" { + return group + "/" + version + } + return version +} + +func addSchemaPropertyDefaultsGo(schema *spec.Schema, apiVersion, kind string) { + if schema.Properties == nil { + return + } + + // Add default to apiVersion property + if propSchema, ok := schema.Properties["apiVersion"]; ok { + propSchema.Default = apiVersion + propSchema.Enum = []any{apiVersion} + schema.Properties["apiVersion"] = propSchema + } + + // Add default to kind property + if propSchema, ok := schema.Properties["kind"]; ok { + propSchema.Default = kind + propSchema.Enum = []any{kind} + schema.Properties["kind"] = propSchema + } +} + +// removeSelfImports removes self-imports and removes +// the package prefix from types that would use the self-import. +func removeSelfImports(code string, pkg string) (string, error) { + selfImports := map[string]struct { + Alias, Path string + }{ + k8sPkgMetaV1: {"metacorev1", "dev.upbound.io/models/io/k8s/core/meta/v1"}, + k8sPkgCoreV1: {"corev1", "dev.upbound.io/models/io/k8s/core/v1"}, + k8sPkgRuntime: {"runtimev1", "dev.upbound.io/models/io/k8s/runtime/v1"}, + k8sPkgIntStr: {"intstrv1", "dev.upbound.io/models/io/k8s/util/v1"}, + k8sPkgResource: {"resourcev1", "dev.upbound.io/models/io/k8s/resource/v1"}, + k8sPkgAutoscalingV1: {"autoscalingv1", "dev.upbound.io/models/io/k8s/autoscaling/v1"}, + } + + info, ok := selfImports[pkg] + if !ok { + return code, nil // nothing to strip + } + + // parse + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", code, parser.ParseComments) + if err != nil { + return "", errors.Wrap(err, "parsing Go code") + } + + // delete the import (works for both named & unnamed imports) + astutil.DeleteImport(fset, f, info.Path) + astutil.DeleteNamedImport(fset, f, info.Alias, info.Path) + + // strip selectors: transform `alias.Thing` → `Thing` + astutil.Apply(f, nil, func(c *astutil.Cursor) bool { + if sel, ok := c.Node().(*ast.SelectorExpr); ok { + if pkgIdent, ok := sel.X.(*ast.Ident); ok && pkgIdent.Name == info.Alias { + // replace the selector expr with just the identifier + c.Replace(&ast.Ident{Name: sel.Sel.Name, NamePos: sel.Sel.NamePos}) + } + } + return true + }) + + var buf strings.Builder + if err := format.Node(&buf, fset, f); err != nil { + return "", errors.Wrap(err, "formatting Go code") + } + return buf.String(), nil +} + +// fixMissingImports add missing imports for K8s types. +func fixMissingImports(code string) (string, error) { + // 1. Define the k8s imports you might need + k8sImports := map[string]string{ + "metacorev1": "dev.upbound.io/models/io/k8s/core/meta/v1", + "metav1": "dev.upbound.io/models/io/k8s/meta/v1", + "corev1": "dev.upbound.io/models/io/k8s/core/v1", + "resourcev1": "dev.upbound.io/models/io/k8s/resource/v1", + "runtimev1": "dev.upbound.io/models/io/k8s/runtime/v1", + "intstrv1": "dev.upbound.io/models/io/k8s/util/v1", + "autoscalingv1": "dev.upbound.io/models/io/k8s/autoscaling/v1", + } + + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", code, parser.ParseComments) + if err != nil { + return "", fmt.Errorf("parsing failed: %w", err) + } + + needed := map[string]bool{} + ast.Inspect(f, func(n ast.Node) bool { + if sel, ok := n.(*ast.SelectorExpr); ok { + if pkg, ok := sel.X.(*ast.Ident); ok { + if _, known := k8sImports[pkg.Name]; known { + needed[pkg.Name] = true + } + } + } + return true + }) + + for alias, path := range k8sImports { + if !needed[alias] { + continue + } + // AddNamedImport will do nothing if the import (with that alias) is already present + astutil.AddNamedImport(fset, f, alias, path) + } + + var buf bytes.Buffer + if err := printer.Fprint(&buf, fset, f); err != nil { + return "", fmt.Errorf("printing AST failed: %w", err) + } + return buf.String(), nil +} + +// fixK8sTypeNames uses AST manipulation to replace long K8s type names with short ones. +func fixK8sTypeNames(code string) (string, error) { + // Parse the code + fset := token.NewFileSet() + f, err := parser.ParseFile(fset, "", code, parser.ParseComments) + if err != nil { + return "", errors.Wrap(err, "failed to parse Go code") + } + + replacements := map[string]string{ + "IoK8SApimachineryPkgApisMetaV1Time": "Time", + "IoK8SApimachineryPkgApisMetaV1MicroTime": "MicroTime", + "IoK8SApimachineryPkgAPIResourceQuantity": "Quantity", + "IoK8SApimachineryPkgUtilIntstrIntOrString": "IntOrString", + } + + // Walk the AST and replace type names + ast.Inspect(f, func(n ast.Node) bool { + if x, ok := n.(*ast.Ident); ok { + if newName, ok := replacements[x.Name]; ok { + x.Name = newName + } + } + return true + }) + + // Format and return the modified code + var buf strings.Builder + if err := format.Node(&buf, fset, f); err != nil { + return "", errors.Wrap(err, "failed to format Go code") + } + + return buf.String(), nil +} + +// GenerateFromOpenAPI generates Go schemas for the OpenAPI docs in the given filesystem. +func (goGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + // Walk through filesystem to collect OpenAPI specs + openAPISpecs, err := collectOpenAPISpecs(fromFS) + if err != nil { + return nil, err + } + + if len(openAPISpecs) == 0 { + // Return nil if no specs were generated + return nil, nil + } + + // Initialize the schema filesystem + schemaFS, err := initializeSchemaFS() + if err != nil { + return nil, err + } + + // Generate K8s shared schemas + if err := generateK8sSharedSchemas(openAPISpecs, schemaFS); err != nil { + return nil, err + } + + // Generate models for the rest + if err := generateModelsWithGVK(openAPISpecs, schemaFS); err != nil { + return nil, err + } + + return schemaFS, nil +} + +// collectOpenAPISpecs walks through the filesystem to find and parse OpenAPI JSON files. +func collectOpenAPISpecs(fromFS afero.Fs) ([]*spec3.OpenAPI, error) { + var openAPISpecs []*spec3.OpenAPI + + err := afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + // Only process JSON files + if !strings.HasSuffix(strings.ToLower(path), ".json") { + return nil + } + + // Read the file content + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + + // Parse as OpenAPI spec + var spec spec3.OpenAPI + if err := json.Unmarshal(bs, &spec); err != nil { + // Skip files that aren't valid OpenAPI specs + return nil //nolint:nilerr // See comment above. + } + + // Check if it has components/schemas + if spec.Components == nil || len(spec.Components.Schemas) == 0 { + return nil + } + + openAPISpecs = append(openAPISpecs, &spec) + return nil + }) + + return openAPISpecs, err +} + +// initializeSchemaFS creates and initializes the schema filesystem with go.mod and go.sum. +func initializeSchemaFS() (afero.Fs, error) { + schemaFS := afero.NewMemMapFs() + if err := schemaFS.Mkdir("models", 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create models directory") + } + + modf, err := schemaFS.Create("models/go.mod") + if err != nil { + return nil, errors.Wrap(err, "failed to create go.mod") + } + if _, err := modf.WriteString(goModContents); err != nil { + return nil, errors.Wrap(err, "failed to write go.mod") + } + + sumf, err := schemaFS.Create("models/go.sum") + if err != nil { + return nil, errors.Wrap(err, "failed to create go.sum") + } + if _, err := sumf.WriteString(goSumContents); err != nil { + return nil, errors.Wrap(err, "failed to write go.sum") + } + + return schemaFS, nil +} + +// generateK8sSharedSchemas extracts and generates shared K8s schemas. +func generateK8sSharedSchemas(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs) error { + k8sSchemasByPackage := make(map[string]map[string]*spec.Schema) + + // Collect all K8s schemas from all OpenAPI specs, grouped by package + for _, openAPISpec := range openAPISpecs { + packagedSchemas := goExtractK8sSchemas(openAPISpec) + for pkg, schemas := range packagedSchemas { + if k8sSchemasByPackage[pkg] == nil { + k8sSchemasByPackage[pkg] = make(map[string]*spec.Schema) + } + maps.Copy(k8sSchemasByPackage[pkg], schemas) + } + } + + // Generate separate files for each K8s package + for pkg, schemas := range k8sSchemasByPackage { + if len(schemas) == 0 { + continue + } + + if err := generateK8sPackageCode(pkg, schemas, schemaFS); err != nil { + return err + } + } + + return nil +} + +// generateK8sPackageCode generates code for a single K8s package. +func generateK8sPackageCode(pkg string, schemas map[string]*spec.Schema, schemaFS afero.Fs) error { + // Create a spec for this package + pkgSpec := &spec3.OpenAPI{ + Version: "3.0.0", + Components: &spec3.Components{ + Schemas: schemas, + }, + } + + // Determine the group, kind, and version from the package name + group, kind, version := getK8sPackageInfo(pkg) + + code, err := generateGo(pkgSpec, version, + goRenameTypes, + goRenameEnums, + goReplaceNumberWithInt, + goRemoveRequired, + goReferenceK8sTypes, + goAddDefaults, + ) + if err != nil { + return err + } + + // Fix k8s type names to use short names + code, err = fixK8sTypeNames(code) + if err != nil { + return errors.Wrap(err, "failed to fix K8s type names") + } + // Remove self-imports from k8s packages + code, err = removeSelfImports(code, pkg) + if err != nil { + return errors.Wrap(err, "failed to remove self imports") + } + + return writeGoCode(schemaFS, group, kind, version, code) +} + +// getK8sPackageInfo returns group, kind, and version for a K8s package. +func getK8sPackageInfo(pkg string) (group, kind, version string) { + switch pkg { + case k8sPkgMetaV1: + return "meta.core.k8s.io", "meta", "v1" + case k8sPkgRuntime: + return "runtime.k8s.io", "runtime", "v1" + case k8sPkgCoreV1: + return "core.k8s.io", "core", "v1" + case k8sPkgIntStr: + return "util.k8s.io", "intstr", "v1" + case k8sPkgResource: + return "resource.k8s.io", "resource", "v1" + case k8sPkgAutoscalingV1: + return k8sPkgNameAutoscaling, k8sPkgNameAutoscaling, "v1" + default: + return "", "", "" + } +} + +// generateModelsWithGVK generates models for schemas with GVK information. +func generateModelsWithGVK(openAPISpecs []*spec3.OpenAPI, schemaFS afero.Fs) error { + for _, openAPISpec := range openAPISpecs { + gvkGroups := groupSchemasByGVK(openAPISpec) + + for gvkKey, schemas := range gvkGroups { + if err := generateGVKGroupCode(gvkKey, schemas, openAPISpec, schemaFS); err != nil { + return err + } + } + } + return nil +} + +// groupSchemasByGVK groups schemas by their GVK information. +func groupSchemasByGVK(openAPISpec *spec3.OpenAPI) map[string]map[string]*spec.Schema { + gvkGroups := make(map[string]map[string]*spec.Schema) + + for name, schema := range openAPISpec.Components.Schemas { + gvkKey := extractGVKKey(schema) + if gvkKey == "" { + continue + } + + if gvkGroups[gvkKey] == nil { + gvkGroups[gvkKey] = make(map[string]*spec.Schema) + } + gvkGroups[gvkKey][name] = schema + } + + return gvkGroups +} + +// extractGVKKey extracts the GVK key from a schema's extensions. +func extractGVKKey(schema *spec.Schema) string { + gvkExt, ok := schema.Extensions["x-kubernetes-group-version-kind"] + if !ok { + return "" + } + + gvkList, ok := gvkExt.([]any) + if !ok || len(gvkList) == 0 { + return "" + } + + gvk, ok := gvkList[0].(map[string]any) + if !ok { + return "" + } + + group, ok := gvk["group"].(string) + if !ok { + return "" + } + + version, ok := gvk["version"].(string) + if !ok || version == "" { + return "" + } + + // Skip core group as it's already created upfront + if group == "core" || group == "" { + return "" + } + + return group + "/" + version +} + +// generateGVKGroupCode generates code for a GVK group. +func generateGVKGroupCode(gvkKey string, schemas map[string]*spec.Schema, openAPISpec *spec3.OpenAPI, schemaFS afero.Fs) error { + parts := strings.Split(gvkKey, "/") + group, version := parts[0], parts[1] + + // Extract the kind from the group name for file naming + // For groups like "authentication.k8s.io", use "authentication" as the kind + // For groups like "policy", use "policy" as the kind + kind := group + if before, _, ok := strings.Cut(group, "."); ok { + kind = before + } + + groupSpec := &spec3.OpenAPI{ + Version: "3.0.0", + Components: &spec3.Components{ + Schemas: make(map[string]*spec.Schema), + }, + } + + // Add the main schemas for this GVK group + maps.Copy(groupSpec.Components.Schemas, schemas) + + // Add all other schemas from the same spec that might be referenced + // but don't have GVK extensions (like TokenRequestSpec, etc.) + // Add schemas that don't have GVK extensions (supporting types) + maps.Copy(groupSpec.Components.Schemas, openAPISpec.Components.Schemas) + + code, err := generateGo(groupSpec, version, + goRenameTypes, + goRenameEnums, + goReplaceNumberWithInt, + goRemoveRequired, + goReferenceK8sTypes, + goRemoveK8s, + goKeepOnlyComponents, + goAddDefaults, + ) + if err != nil { + return err + } + + return writeGoCode(schemaFS, group, kind, version, code) +} diff --git a/internal/schemas/generator/interface.go b/internal/schemas/generator/interface.go new file mode 100644 index 0000000..06ef60f --- /dev/null +++ b/internal/schemas/generator/interface.go @@ -0,0 +1,44 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package generator generates language-specific schemas for Crossplane and +// Kubernetes resources. +package generator + +import ( + "context" + + "github.com/spf13/afero" + + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +// Interface generates schemas for a specific language. +type Interface interface { + Language() string + GenerateFromCRD(ctx context.Context, fs afero.Fs, runner runner.SchemaRunner) (afero.Fs, error) + GenerateFromOpenAPI(ctx context.Context, fs afero.Fs, runner runner.SchemaRunner) (afero.Fs, error) +} + +// AllLanguages returns generators for all supported languages. +func AllLanguages() []Interface { + return []Interface{ + &goGenerator{}, + &jsonGenerator{}, + &kclGenerator{}, + &pythonGenerator{}, + } +} diff --git a/internal/schemas/generator/json.go b/internal/schemas/generator/json.go new file mode 100644 index 0000000..cee0d07 --- /dev/null +++ b/internal/schemas/generator/json.go @@ -0,0 +1,205 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "context" + "encoding/json" + "io/fs" + "maps" + "path/filepath" + "strings" + + "github.com/invopop/jsonschema" + "github.com/spf13/afero" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +type jsonGenerator struct{} + +func (jsonGenerator) Language() string { + return "json" +} + +// GenerateFromCRD generates jsonschemas for the CRDs in the given filesystem. +func (jsonGenerator) GenerateFromCRD(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + openAPIs, err := goCollectOpenAPIs(fromFS) + if err != nil { + return nil, err + } + + if len(openAPIs) == 0 { + return nil, nil + } + + schemaFS := afero.NewMemMapFs() + if err := schemaFS.Mkdir("models", 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create models directory") + } + + schemas := make(map[string]*spec.Schema) + for _, oapi := range openAPIs { + maps.Copy(schemas, oapi.spec.Components.Schemas) + } + + for name, schema := range schemas { + jschema, err := oapiSchemaToJSONSchema(schema) + if err != nil { + return nil, errors.Wrapf(err, "failed to generate jsonschema for %s", name) + } + + bs, err := json.Marshal(jschema) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal jsonschema for %s", name) + } + + fname := filepath.Join("models", strings.ReplaceAll(name, ".", "-")+".schema.json") + if err := afero.WriteFile(schemaFS, fname, bs, 0o644); err != nil { + return nil, errors.Wrapf(err, "failed to write jsonschema for %s", name) + } + } + + return schemaFS, nil +} + +func oapiSchemaToJSONSchema(s *spec.Schema) (*jsonschema.Schema, error) { + bs, err := json.Marshal(s) + if err != nil { + return nil, err + } + + var conv jsonschema.Schema + if err := json.Unmarshal(bs, &conv); err != nil { + return nil, err + } + + return mutateJSONSchema(&conv), nil +} + +func mutateJSONSchema(s *jsonschema.Schema) *jsonschema.Schema { + if s.Type == "object" && s.AdditionalProperties == nil { + s.AdditionalProperties = jsonschema.FalseSchema + } + + if after, ok := strings.CutPrefix(s.Ref, "#/components/schemas/"); ok { + s.Ref = after + s.Ref = strings.ReplaceAll(s.Ref, ".", "-") + s.Ref += ".schema.json" + } + + for i, schema := range s.AllOf { + s.AllOf[i] = mutateJSONSchema(schema) + } + for i, schema := range s.AnyOf { + s.AnyOf[i] = mutateJSONSchema(schema) + } + for i, schema := range s.OneOf { + s.OneOf[i] = mutateJSONSchema(schema) + } + if s.Not != nil { + s.Not = mutateJSONSchema(s.Not) + } + + if s.Items != nil { + s.Items = mutateJSONSchema(s.Items) + } + + if s.AdditionalProperties != nil { + s.AdditionalProperties = mutateJSONSchema(s.AdditionalProperties) + } + + for prop := s.Properties.Oldest(); prop != nil; prop = prop.Next() { + s.Properties.Set(prop.Key, mutateJSONSchema(prop.Value)) + } + + return s +} + +// GenerateFromOpenAPI generates jsonschemas from OpenAPI v3 specs. +func (jsonGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + var openAPISpecs []*spec3.OpenAPI + err := afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + if filepath.Ext(path) != ".json" { + return nil + } + + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read OpenAPI file %q", path) + } + + var openAPI spec3.OpenAPI + if err := json.Unmarshal(bs, &openAPI); err != nil { + return nil //nolint:nilerr // Skip invalid files. + } + + if openAPI.Components != nil && len(openAPI.Components.Schemas) > 0 { + openAPISpecs = append(openAPISpecs, &openAPI) + } + + return nil + }) + if err != nil { + return nil, errors.Wrap(err, "failed to walk OpenAPI filesystem") + } + + if len(openAPISpecs) == 0 { + return nil, nil + } + + schemaFS := afero.NewMemMapFs() + if err := schemaFS.Mkdir("models", 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create models directory") + } + + schemas := make(map[string]*spec.Schema) + for _, oapi := range openAPISpecs { + maps.Copy(schemas, oapi.Components.Schemas) + } + + for name, schema := range schemas { + jschema, err := oapiSchemaToJSONSchema(schema) + if err != nil { + return nil, errors.Wrapf(err, "failed to generate jsonschema for %s", name) + } + + bs, err := json.Marshal(jschema) + if err != nil { + return nil, errors.Wrapf(err, "failed to marshal jsonschema for %s", name) + } + + fname := filepath.Join("models", strings.ReplaceAll(name, ".", "-")+".schema.json") + if err := afero.WriteFile(schemaFS, fname, bs, 0o644); err != nil { + return nil, errors.Wrapf(err, "failed to write jsonschema for %s", name) + } + } + + return schemaFS, nil +} diff --git a/internal/schemas/generator/kcl.go b/internal/schemas/generator/kcl.go new file mode 100644 index 0000000..4aa69db --- /dev/null +++ b/internal/schemas/generator/kcl.go @@ -0,0 +1,902 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "context" + "encoding/json" + "fmt" + "io" + "io/fs" + "maps" + "os" + "path/filepath" + "regexp" + "slices" + "sort" + "strings" + + "github.com/spf13/afero" + "golang.org/x/text/cases" + "golang.org/x/text/language" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/kube-openapi/pkg/spec3" + "k8s.io/kube-openapi/pkg/validation/spec" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + xpv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + xcrd "github.com/crossplane/cli/v2/internal/crd" + "github.com/crossplane/cli/v2/internal/filesystem" + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +const ( + kclModelsFolder = "models" + kclAdoptModelsStructure = "sorted" + kclImage = "docker.io/kcllang/kcl:v0.11.2" +) + +type kclGenerator struct{} + +func (kclGenerator) Language() string { + return "kcl" +} + +// GenerateFromCRD generates KCL schema files from the XRDs and CRDs fromFS. +func (kclGenerator) GenerateFromCRD(ctx context.Context, fromFS afero.Fs, generator runner.SchemaRunner) (afero.Fs, error) { //nolint:gocognit // generate kcl schemas + crdFS := afero.NewMemMapFs() + schemaFS := afero.NewMemMapFs() + baseFolder := "workdir" + + if err := crdFS.MkdirAll(baseFolder, 0o755); err != nil { + return nil, err + } + + var crdPaths []string + + if err := afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + ext := filepath.Ext(path) + if ext != ".yaml" && ext != ".yml" { + return nil + } + + var u metav1.TypeMeta + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + err = yaml.Unmarshal(bs, &u) + if err != nil { + return errors.Wrapf(err, "failed to parse file %q", path) + } + + switch u.GroupVersionKind().Kind { + case xpv1.CompositeResourceDefinitionKind: + xrPath, claimPath, err := xcrd.ProcessXRD(crdFS, bs, path, baseFolder) + if err != nil { + return err + } + + if xrPath != "" { + crdPaths = append(crdPaths, xrPath) + } + if claimPath != "" { + crdPaths = append(crdPaths, claimPath) + } + + case "CustomResourceDefinition": + if err := afero.WriteFile(crdFS, filepath.Join(baseFolder, path), bs, 0o644); err != nil { + return err + } + crdPaths = append(crdPaths, filepath.Join(baseFolder, path)) + } + + return nil + }); err != nil { + return nil, err + } + + if len(crdPaths) == 0 { + return nil, nil + } + + if err := generator.Generate( + ctx, + crdFS, + baseFolder, + "", + kclImage, + []string{ + "sh", "-c", + `find . -name "*.yaml" -exec kcl import -m crd -s {} \;`, + }, + ); err != nil { + return nil, err + } + + if err := transformStructureKcl(crdFS, kclModelsFolder, kclAdoptModelsStructure); err != nil { + return nil, err + } + + if err := filesystem.CopyFilesBetweenFs(afero.NewBasePathFs(crdFS, kclAdoptModelsStructure), afero.NewBasePathFs(schemaFS, kclModelsFolder)); err != nil { + return nil, err + } + + return schemaFS, nil +} + +func transformStructureKcl(fs afero.Fs, sourceDir, targetDir string) error { //nolint:gocognit // transform kcl schemas + if err := filesystem.CopyFileIfExists(fs, filepath.Join(sourceDir, "kcl.mod"), filepath.Join(targetDir, "kcl.mod")); err != nil { + return errors.Wrap(err, "failed to copy kcl.mod") + } + + if err := filesystem.CopyFileIfExists(fs, filepath.Join(sourceDir, "kcl.mod.lock"), filepath.Join(targetDir, "kcl.mod.lock")); err != nil { + return errors.Wrap(err, "failed to copy kcl.mod.lock") + } + + objectMetaPath := filepath.Join(sourceDir, "k8s", "apimachinery", "pkg", "apis", "meta", "v1", "object_meta.k") + managedFieldsEntryPath := filepath.Join(sourceDir, "k8s", "apimachinery", "pkg", "apis", "meta", "v1", "managed_fields_entry.k") + + if _, err := fs.Stat(objectMetaPath); err == nil { + content, err := afero.ReadFile(fs, objectMetaPath) + if err != nil { + return errors.Wrapf(err, "failed to read %s", objectMetaPath) + } + + updatedContent := strings.ReplaceAll(string(content), "managedFields?: [ManagedFieldsEntry]", "managedFields?: any") + + if err := afero.WriteFile(fs, objectMetaPath, []byte(updatedContent), 0o644); err != nil { + return errors.Wrapf(err, "failed to update %s", objectMetaPath) + } + } + + if _, err := fs.Stat(managedFieldsEntryPath); err == nil { + if err := fs.Remove(managedFieldsEntryPath); err != nil { + return errors.Wrapf(err, "failed to remove %s", managedFieldsEntryPath) + } + } + + k8sSourcePath := filepath.Join(sourceDir, "k8s") + if err := filesystem.CopyFolder(fs, k8sSourcePath, filepath.Join(targetDir, "k8s")); err != nil { + return errors.Wrap(err, "failed to copy k8s directory") + } + + if err := afero.Walk(fs, sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() || strings.HasPrefix(path, filepath.Join(sourceDir, "k8s")) { + return nil + } + + filename := info.Name() + parts := strings.Split(filename, "_") + + var versionIndex int + foundVersion := false + + for i, part := range parts { + if isAPIVersion(part) { + versionIndex = i + foundVersion = true + break + } + } + + if !foundVersion || versionIndex == 0 { + return nil + } + + reversedParts := parts[:versionIndex] + slices.Reverse(reversedParts) + reversedParts = append(reversedParts, parts[versionIndex]) + + newDir := filepath.Join(targetDir, filepath.Join(reversedParts...)) + + if err := fs.MkdirAll(newDir, 0o755); err != nil { + return errors.Wrapf(err, "failed to create directory %s", newDir) + } + + transformedName := strings.ReplaceAll(strings.Join(parts[versionIndex+1:], ""), "_", "") + transformedName = strings.ReplaceAll(transformedName, "swagger", "") + + newFilePath := filepath.Join(newDir, transformedName) + + srcFile, err := fs.Open(path) + if err != nil { + return errors.Wrapf(err, "failed to open source file %s", path) + } + + destFile, err := fs.Create(newFilePath) + if err != nil { + return errors.Wrapf(err, "failed to create destination file %s", newFilePath) + } + + _, err = io.Copy(destFile, srcFile) + if err != nil { + return errors.Wrapf(err, "failed to copy file from %s to %s", path, newFilePath) + } + + return nil + }); err != nil { + return errors.Wrap(err, "error processing directory") + } + + return nil +} + +// GenerateFromOpenAPI generates KCL schema files from OpenAPI v3 specifications. +func (kclGenerator) GenerateFromOpenAPI(_ context.Context, fromFS afero.Fs, _ runner.SchemaRunner) (afero.Fs, error) { + schemaFS := afero.NewMemMapFs() + + if err := schemaFS.MkdirAll(kclModelsFolder, 0o755); err != nil { + return nil, errors.Wrap(err, "failed to create models directory") + } + + openAPISpecs := make(map[string]*spec3.OpenAPI) + + if err := afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + if !strings.HasSuffix(strings.ToLower(path), ".json") { + return nil + } + + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + + var openAPI spec3.OpenAPI + if err := json.Unmarshal(bs, &openAPI); err != nil { + return nil //nolint:nilerr // Skip invalid files. + } + + if openAPI.Components != nil && len(openAPI.Components.Schemas) > 0 { + openAPISpecs[path] = &openAPI + } + + return nil + }); err != nil { + return nil, errors.Wrap(err, "failed to walk OpenAPI filesystem") + } + + if len(openAPISpecs) == 0 { + return nil, nil + } + + allSchemas := make(map[string]*spec.Schema) + for _, oapi := range openAPISpecs { + addKCLDefaults(oapi) + + if oapi.Components != nil { + maps.Copy(allSchemas, oapi.Components.Schemas) + } + } + + for name, schema := range allSchemas { + kclContent := generateKCLFile(name, schema, allSchemas) + + filename := filepath.Join(kclModelsFolder, toKCLFileName(name)) + + dir := filepath.Dir(filename) + if err := schemaFS.MkdirAll(dir, 0o755); err != nil { + return nil, errors.Wrapf(err, "failed to create directory for %s", name) + } + + if err := afero.WriteFile(schemaFS, filename, []byte(kclContent), 0o644); err != nil { + return nil, errors.Wrapf(err, "failed to write KCL schema for %s", name) + } + } + + kclModContent := `[package] +name = "models" +edition = "v0.10.0" +version = "0.0.1" +` + if err := afero.WriteFile(schemaFS, filepath.Join(kclModelsFolder, "kcl.mod"), []byte(kclModContent), 0o644); err != nil { + return nil, errors.Wrap(err, "failed to write kcl.mod") + } + + return schemaFS, nil +} + +func toKCLFileName(name string) string { + parts := strings.Split(name, ".") + if len(parts) <= 1 { + return name + ".k" + } + + path := filepath.Join(parts[:len(parts)-1]...) + filename := parts[len(parts)-1] + ".k" + + return filepath.Join(path, filename) +} + +func extractSchemaName(ref string) string { + if ref == "" { + return "" + } + parts := strings.Split(ref, "/") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return "" +} + +func extractSimpleName(fullName string) string { + parts := strings.Split(fullName, ".") + if len(parts) > 0 { + return parts[len(parts)-1] + } + return fullName +} + +func handleAllOfType(schema *spec.Schema, currentSchemaName string) (string, bool) { + if len(schema.AllOf) == 0 { + return "", false + } + + for _, allOfSchema := range schema.AllOf { + if allOfSchema.Ref.String() != "" { + if kclType := processSchemaReference(allOfSchema.Ref.String(), currentSchemaName); kclType != "" { + return kclType, true + } + } + } + return "", false +} + +func processSchemaReference(ref string, currentSchemaName string) string { + refName := extractSchemaName(ref) + if refName == "" { + return "" + } + + if strings.HasSuffix(refName, "IntOrString") { + return "int | str" + } + if strings.HasSuffix(refName, "Quantity") { + return "str" + } + if strings.HasSuffix(refName, "Time") { + return "str" + } + if strings.HasSuffix(refName, "RawExtension") { + return "any" + } + return formatTypeReference(refName, currentSchemaName) +} + +func handleArrayType(schema *spec.Schema, allSchemas map[string]*spec.Schema, currentSchemaName string) (string, bool) { + if !schema.Type.Contains("array") || schema.Items == nil || schema.Items.Schema == nil { + return "", false + } + + itemType := convertOpenAPITypeToKCL(schema.Items.Schema, allSchemas, currentSchemaName) + return "[" + itemType + "]", true +} + +func handleObjectType(schema *spec.Schema, allSchemas map[string]*spec.Schema, currentSchemaName string) (string, bool) { + if !schema.Type.Contains("object") { + return "", false + } + + if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { + valueType := convertOpenAPITypeToKCL(schema.AdditionalProperties.Schema, allSchemas, currentSchemaName) + return "{str:" + valueType + "}", true + } + if len(schema.Properties) == 0 { + return "{str: any}", true + } + return "dict", true +} + +func convertOpenAPITypeToKCL(schema *spec.Schema, allSchemas map[string]*spec.Schema, currentSchemaName string) string { + if schema == nil { + return "any" + } + + if kclType, found := handleAllOfType(schema, currentSchemaName); found { + return kclType + } + + if schema.Ref.String() != "" { + if kclType := processSchemaReference(schema.Ref.String(), currentSchemaName); kclType != "" { + return kclType + } + } + + if kclType, found := handleArrayType(schema, allSchemas, currentSchemaName); found { + return kclType + } + + if kclType, found := handleObjectType(schema, allSchemas, currentSchemaName); found { + return kclType + } + + switch { + case schema.Type.Contains("string"): + return "str" + case schema.Type.Contains("integer"): + return "int" + case schema.Type.Contains("number"): + return "float" + case schema.Type.Contains("boolean"): + return "bool" + default: + return "any" + } +} + +func formatTypeReference(refName, currentSchemaName string) string { + if strings.HasPrefix(refName, "io.k8s.api.") { + lastDot := strings.LastIndex(refName, ".") + if lastDot > 0 { + packagePath := refName[:lastDot] + if strings.Count(packagePath, ".") >= 4 { + typeName := refName[lastDot+1:] + + if strings.HasPrefix(currentSchemaName, packagePath+".") { + return typeName + } + + alias := getImportAlias(packagePath) + return alias + "." + typeName + } + } + } + + if typeName, ok := strings.CutPrefix(refName, "io.k8s.apimachinery.pkg.apis.meta.v1."); ok { + if strings.HasPrefix(currentSchemaName, "io.k8s.apimachinery.pkg.apis.meta.v1.") { + return typeName + } + return "v1." + typeName + } + + if typeName, ok := strings.CutPrefix(refName, "io.k8s.apimachinery.pkg.runtime."); ok { + if strings.HasPrefix(currentSchemaName, "io.k8s.apimachinery.pkg.runtime.") { + return typeName + } + return "runtime." + typeName + } + + if strings.HasPrefix(refName, "io.k8s.apimachinery.pkg.") { + parts := strings.Split(refName, ".") + if len(parts) > 1 { + packagePath := strings.Join(parts[:len(parts)-1], ".") + typeName := parts[len(parts)-1] + + if strings.HasPrefix(currentSchemaName, packagePath+".") { + return typeName + } + + alias := getImportAlias(packagePath) + return alias + "." + typeName + } + } + + return extractSimpleName(refName) +} + +func getImportAlias(packagePath string) string { + if packagePath == "io.k8s.apimachinery.pkg.apis.meta.v1" { + return "v1" + } + + if packagePath == "io.k8s.apimachinery.pkg.runtime" { + return "runtime" + } + + if strings.HasPrefix(packagePath, "io.k8s.api.") { + parts := strings.Split(packagePath, ".") + if len(parts) >= 5 { + group := parts[3] + version := parts[4] + caser := cases.Title(language.English) + versionTitle := caser.String(version) + return group + versionTitle + } + } + + if strings.HasPrefix(packagePath, "io.k8s.apimachinery.pkg.") { + parts := strings.Split(packagePath, ".") + if len(parts) >= 5 { + if parts[4] == "apis" && len(parts) >= 6 { + group := parts[len(parts)-2] + version := parts[len(parts)-1] + caser := cases.Title(language.English) + versionTitle := caser.String(version) + return group + versionTitle + } + return parts[4] + } + } + + parts := strings.Split(packagePath, ".") + if len(parts) >= 2 { + lastPart := parts[len(parts)-1] + secondLastPart := parts[len(parts)-2] + caser := cases.Title(language.English) + lastPartTitle := caser.String(lastPart) + return secondLastPart + lastPartTitle + } + return "unknown" +} + +func processSchemaProperties(schema *spec.Schema) (map[string]*spec.Schema, map[string]bool, []string) { + properties := make(map[string]*spec.Schema) + requiredSet := make(map[string]bool) + + for _, req := range schema.Required { + requiredSet[req] = true + } + + if len(schema.AllOf) > 0 { + for _, allOfSchema := range schema.AllOf { + if allOfSchema.Properties != nil { + for k, v := range allOfSchema.Properties { + propCopy := v + properties[k] = &propCopy + } + } + for _, req := range allOfSchema.Required { + requiredSet[req] = true + } + } + } + + if schema.Properties != nil { + for k, v := range schema.Properties { + propCopy := v + properties[k] = &propCopy + } + } + + propNames := make([]string, 0, len(properties)) + for name := range properties { + propNames = append(propNames, name) + } + sort.Strings(propNames) + + return properties, requiredSet, propNames +} + +func generateDocStringHeader(sb *strings.Builder, schema *spec.Schema) { + if schema.Description != "" || len(schema.Properties) > 0 { + sb.WriteString(" \"\"\"\n") + + if schema.Description != "" { + lines := strings.SplitSeq(strings.TrimSpace(schema.Description), "\n") + for line := range lines { + sb.WriteString(" " + strings.TrimSpace(line) + "\n") + } + } + } +} + +func generateAttributesDocumentation(sb *strings.Builder, propNames []string, properties map[string]*spec.Schema, requiredSet map[string]bool, allSchemas map[string]*spec.Schema, currentSchemaName string) { + if len(propNames) == 0 { + return + } + + sb.WriteString("\n Attributes\n") + sb.WriteString(" ----------\n") + + for _, propName := range propNames { + prop := properties[propName] + + docPropName := propName + if propName == "type" { + docPropName = "$type" + } + + sb.WriteString(" " + docPropName + " : ") + sb.WriteString(convertOpenAPITypeToKCL(prop, allSchemas, currentSchemaName)) + + if prop.Default != nil { + sb.WriteString(", default is ") + sb.WriteString(formatDefaultValue(prop.Default)) + } else { + sb.WriteString(", default is Undefined") + } + + if requiredSet[propName] { + sb.WriteString(", required") + } else { + sb.WriteString(", optional") + } + sb.WriteString("\n") + + if prop.Description != "" { + lines := strings.SplitSeq(strings.TrimSpace(prop.Description), "\n") + for line := range lines { + sb.WriteString(" " + strings.TrimSpace(line) + "\n") + } + } + } +} + +func generatePropertyField(sb *strings.Builder, propName string, prop *spec.Schema, requiredSet map[string]bool, allSchemas map[string]*spec.Schema, currentSchemaName string) { + fieldName := propName + if propName == "type" { + fieldName = "$type" + } + + sb.WriteString("\n") + sb.WriteString(" " + fieldName) + + if !requiredSet[propName] { + sb.WriteString("?") + } + + sb.WriteString(": ") + propType := convertOpenAPITypeToKCL(prop, allSchemas, currentSchemaName) + sb.WriteString(propType) + + if prop.Default != nil { + sb.WriteString(" = ") + sb.WriteString(formatDefaultValue(prop.Default)) + } +} + +func generateKCLSchema(name string, schema *spec.Schema, allSchemas map[string]*spec.Schema, currentSchemaName string) string { + var sb strings.Builder + + sb.WriteString("schema " + name + ":\n") + + generateDocStringHeader(&sb, schema) + + properties, requiredSet, propNames := processSchemaProperties(schema) + + generateAttributesDocumentation(&sb, propNames, properties, requiredSet, allSchemas, currentSchemaName) + + if schema.Description != "" || len(schema.Properties) > 0 { + sb.WriteString(" \"\"\"\n\n") + } + + for _, propName := range propNames { + prop := properties[propName] + generatePropertyField(&sb, propName, prop, requiredSet, allSchemas, currentSchemaName) + } + + return sb.String() +} + +func formatDefaultValue(value any) string { + switch v := value.(type) { + case string: + return fmt.Sprintf("%q", v) + case bool: + if v { + return "True" + } + return "False" + case nil: + return "None" + case map[string]any: + if len(v) == 0 { + return "{}" + } + return fmt.Sprintf("%v", v) + case []any: + if len(v) == 0 { + return "[]" + } + return fmt.Sprintf("%v", v) + default: + str := fmt.Sprintf("%v", v) + if str == "map[]" { + return "{}" + } + if str == "[]" { + return "[]" + } + return str + } +} + +func generateKCLFile(fullSchemaName string, schema *spec.Schema, allSchemas map[string]*spec.Schema) string { + name := extractSimpleName(fullSchemaName) + var sb strings.Builder + + sb.WriteString(`""" +This file was generated by crossplane. DO NOT EDIT. +""" +`) + + imports := make(map[string]bool) + visited := make(map[*spec.Schema]bool) + + if schema.Properties != nil { + for _, prop := range schema.Properties { + checkForImports(&prop, imports, visited) + } + } + + for _, allOfSchema := range schema.AllOf { + if allOfSchema.Ref.String() != "" { + refName := extractSchemaName(allOfSchema.Ref.String()) + addImportIfNeeded(refName, imports) + } + } + + if fullSchemaName != "" { + lastDot := strings.LastIndex(fullSchemaName, ".") + if lastDot > 0 { + currentPackage := fullSchemaName[:lastDot] + delete(imports, currentPackage) + } + } + + for imp := range imports { + alias := getImportAlias(imp) + sb.WriteString("import " + imp + " as " + alias + "\n") + } + if len(imports) > 0 { + sb.WriteString("\n") + } + + sb.WriteString(generateKCLSchema(name, schema, allSchemas, fullSchemaName)) + + return sb.String() +} + +func checkForImports(schema *spec.Schema, imports map[string]bool, visited map[*spec.Schema]bool) { + if schema == nil { + return + } + + if visited[schema] { + return + } + visited[schema] = true + + if len(schema.AllOf) > 0 { + for _, allOfSchema := range schema.AllOf { + if allOfSchema.Ref.String() != "" { + refName := extractSchemaName(allOfSchema.Ref.String()) + if !strings.HasSuffix(refName, "IntOrString") && !strings.HasSuffix(refName, "RawExtension") && !strings.HasSuffix(refName, "Quantity") && !strings.HasSuffix(refName, "Time") { + addImportIfNeeded(refName, imports) + } + } + } + } + + if schema.Ref.String() != "" { + refName := extractSchemaName(schema.Ref.String()) + if !strings.HasSuffix(refName, "IntOrString") && !strings.HasSuffix(refName, "RawExtension") && !strings.HasSuffix(refName, "Quantity") && !strings.HasSuffix(refName, "Time") { + addImportIfNeeded(refName, imports) + } + return + } + + if schema.Items != nil && schema.Items.Schema != nil { + checkForImports(schema.Items.Schema, imports, visited) + } + + if schema.Properties != nil { + for _, prop := range schema.Properties { + checkForImports(&prop, imports, visited) + } + } + + if schema.AdditionalProperties != nil && schema.AdditionalProperties.Schema != nil { + checkForImports(schema.AdditionalProperties.Schema, imports, visited) + } +} + +func addImportIfNeeded(refName string, imports map[string]bool) { + if refName == "" { + return + } + + if strings.HasPrefix(refName, "io.k8s.") { + lastDot := strings.LastIndex(refName, ".") + if lastDot > 0 { + packagePath := refName[:lastDot] + imports[packagePath] = true + } + } +} + +func addKCLDefaults(s *spec3.OpenAPI) { + if s.Components == nil || s.Components.Schemas == nil { + return + } + + for _, schema := range s.Components.Schemas { + processKCLSchemaDefaults(schema) + } +} + +func processKCLSchemaDefaults(schema *spec.Schema) { + rawExt, ok := schema.Extensions["x-kubernetes-group-version-kind"] + if !ok { + return + } + + gvkList := extractGVKList(rawExt) + if len(gvkList) == 0 { + return + } + + group, version, kind := extractGVKInfo(gvkList[0]) + apiVersion := constructAPIVersion(group, version) + addSchemaPropertyDefaultsKcl(schema, apiVersion, kind) +} + +func addSchemaPropertyDefaultsKcl(schema *spec.Schema, apiVersion, kind string) { + if schema.Properties == nil { + return + } + + if _, ok := schema.Properties["apiVersion"]; ok { + propSchema := schema.Properties["apiVersion"] + propSchema.Default = apiVersion + propSchema.Enum = []any{apiVersion} + schema.Properties["apiVersion"] = propSchema + } + + if _, ok := schema.Properties["kind"]; ok { + propSchema := schema.Properties["kind"] + propSchema.Default = kind + propSchema.Enum = []any{kind} + schema.Properties["kind"] = propSchema + } + + hasAPIVersion := false + hasKind := false + for _, req := range schema.Required { + if req == "apiVersion" { + hasAPIVersion = true + } + if req == "kind" { + hasKind = true + } + } + if !hasAPIVersion { + schema.Required = append(schema.Required, "apiVersion") + } + if !hasKind { + schema.Required = append(schema.Required, "kind") + } +} + +// isAPIVersion heuristically determines whether its argument is a Kubernetes +// API version. +func isAPIVersion(s string) bool { + re := regexp.MustCompile("^v[1-9][0-9]*((alpha|beta)[1-9][0-9]*)?$") + return re.MatchString(s) +} diff --git a/internal/schemas/generator/kcl_test.go b/internal/schemas/generator/kcl_test.go new file mode 100644 index 0000000..a6a40f2 --- /dev/null +++ b/internal/schemas/generator/kcl_test.go @@ -0,0 +1,49 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import "testing" + +func TestIsAPIVersion(t *testing.T) { + t.Parallel() + + tcs := map[string]bool{ + "v1": true, + "v10": true, + "v01": false, + "v1alpha1": true, + "v1alpha10": true, + "v10alpha1": true, + "v10alpha10": true, + "v01alpha1": false, + "v1alpha01": false, + "notaversion": false, + "v1.2.3": false, + "v1zeta1": false, + } + + for in, want := range tcs { + t.Run(in, func(t *testing.T) { + t.Parallel() + + got := isAPIVersion(in) + if got != want { + t.Errorf("isAPIVersion(...): got %v want %v", got, want) + } + }) + } +} diff --git a/internal/schemas/generator/python.go b/internal/schemas/generator/python.go new file mode 100644 index 0000000..fe5659c --- /dev/null +++ b/internal/schemas/generator/python.go @@ -0,0 +1,871 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generator + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "slices" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/spf13/afero" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + xpv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1" + + "github.com/crossplane/cli/v2/internal/crd" + "github.com/crossplane/cli/v2/internal/filesystem" + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +const ( + pythonModelsFolder = "models" + pythonAdoptModelsStructure = "sorted" + pythonGeneratedFolder = "models/workdir" + pythonImage = "xpkg.upbound.io/upbound/datamodel-code-generator:v0.31.2" +) + +var importRE = regexp.MustCompile(`^(from\s+)(\.*)([^\s]+)(.*)`) + +type pythonGenerator struct{} + +func (pythonGenerator) Language() string { + return "python" +} + +// GenerateFromCRD generates Python schema files from the XRDs and CRDs fromFS. +func (p pythonGenerator) GenerateFromCRD(ctx context.Context, fromFS afero.Fs, generator runner.SchemaRunner) (afero.Fs, error) { //nolint:gocognit // generation of schemas for python + crdFS := afero.NewMemMapFs() + schemaFS := afero.NewMemMapFs() + baseFolder := "workdir" + + if err := crdFS.MkdirAll(baseFolder, 0o755); err != nil { + return nil, err + } + + var openAPIPaths []string + + // Walk the virtual filesystem to find and process target files + if err := afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + // Ignore files without yaml extensions. + ext := filepath.Ext(path) + if ext != ".yaml" && ext != ".yml" { + return nil + } + + var u metav1.TypeMeta + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + err = yaml.Unmarshal(bs, &u) + if err != nil { + return errors.Wrapf(err, "failed to parse file %q", path) + } + + switch u.GroupVersionKind().Kind { + case xpv1.CompositeResourceDefinitionKind: + // Process the XRD and get the paths + xrPath, claimPath, err := crd.ProcessXRD(crdFS, bs, path, baseFolder) + if err != nil { + return err + } + + if xrPath != "" { + bs, err := afero.ReadFile(crdFS, xrPath) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + paths, err := crd.FilesToOpenAPI(crdFS, bs, xrPath) + if err != nil { + return err + } + openAPIPaths = append(openAPIPaths, paths...) + } + if claimPath != "" { + bs, err := afero.ReadFile(crdFS, claimPath) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + paths, err := crd.FilesToOpenAPI(crdFS, bs, claimPath) + if err != nil { + return err + } + openAPIPaths = append(openAPIPaths, paths...) + } + + case "CustomResourceDefinition": + paths, err := crd.FilesToOpenAPI(crdFS, bs, path) + if err != nil { + return err + } + openAPIPaths = append(openAPIPaths, paths...) + } + return nil + }); err != nil { + return nil, err + } + + if len(openAPIPaths) == 0 { + // Return nil if no files were generated + return nil, nil + } + + // Generate Python schemas using common function + if err := p.generatePythonSchemas(ctx, crdFS, baseFolder, generator); err != nil { + return nil, err + } + + // reorganization alignment https://github.com/koxudaxi/datamodel-code-generator/issues/2097 + if err := postTransformCRD(crdFS, pythonGeneratedFolder, pythonAdoptModelsStructure); err != nil { + return nil, err + } + + // Copy only the files from pythonAdoptModelsStructure into the schemaFs + if err := filesystem.CopyFilesBetweenFs(afero.NewBasePathFs(crdFS, pythonAdoptModelsStructure), afero.NewBasePathFs(schemaFS, pythonModelsFolder)); err != nil { + return nil, err + } + + return schemaFS, nil +} + +// GenerateFromOpenAPI generates Python schema files from OpenAPI specifications in fromFS. +func (p pythonGenerator) GenerateFromOpenAPI(ctx context.Context, fromFS afero.Fs, generator runner.SchemaRunner) (afero.Fs, error) { + openapiFS := afero.NewMemMapFs() + schemaFS := afero.NewMemMapFs() + baseFolder := "workdir" + + if err := openapiFS.MkdirAll(baseFolder, 0o755); err != nil { + return nil, err + } + + var openapiPaths []string + + // Walk the virtual filesystem to find and process OpenAPI JSON files + if err := afero.Walk(fromFS, "", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + + if info.IsDir() { + return nil + } + + // Only process JSON files + if !strings.HasSuffix(strings.ToLower(path), ".json") { + return nil + } + + // Read the file content + bs, err := afero.ReadFile(fromFS, path) + if err != nil { + return errors.Wrapf(err, "failed to read file %q", path) + } + + // Parse the OpenAPI document once + loader := openapi3.NewLoader() + doc, err := loader.LoadFromData(bs) + if err != nil { + // If parsing fails, use original content + processedContent := bs + targetPath := filepath.Join(baseFolder, path) + if err := openapiFS.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return errors.Wrapf(err, "failed to create directory for %q", targetPath) + } + if err := afero.WriteFile(openapiFS, targetPath, processedContent, 0o644); err != nil { + return errors.Wrapf(err, "failed to write file %q", targetPath) + } + return nil + } + + // Check if we should skip this file based on its pattern + if shouldSkipOpenAPIFile(doc) { + return nil + } + + // Process the OpenAPI content to add default values + processedDoc := processOpenAPIContent(doc) + processedContent, err := processedDoc.MarshalJSON() + if err != nil { + // If marshaling fails, use original content + processedContent = bs + } + + // Write OpenAPI file to working directory + targetPath := filepath.Join(baseFolder, path) + if err := openapiFS.MkdirAll(filepath.Dir(targetPath), 0o755); err != nil { + return err + } + if err := afero.WriteFile(openapiFS, targetPath, processedContent, 0o644); err != nil { + return err + } + openapiPaths = append(openapiPaths, targetPath) + + return nil + }); err != nil { + return nil, err + } + + if len(openapiPaths) == 0 { + // Return nil if no files were generated + return nil, nil + } + + // Generate Python schemas using common function + if err := p.generatePythonSchemas(ctx, openapiFS, baseFolder, generator); err != nil { + return nil, err + } + + if err := postTransformOpenAPI(openapiFS, pythonGeneratedFolder, pythonAdoptModelsStructure); err != nil { + return nil, err + } + + // Copy the generated models to the schema filesystem + if err := filesystem.CopyFilesBetweenFs(afero.NewBasePathFs(openapiFS, pythonAdoptModelsStructure), afero.NewBasePathFs(schemaFS, pythonModelsFolder)); err != nil { + return nil, err + } + + return schemaFS, nil +} + +// generatePythonSchemas runs the datamodel code generator with common arguments. +func (p pythonGenerator) generatePythonSchemas(ctx context.Context, inputFS afero.Fs, baseFolder string, generator runner.SchemaRunner) error { + return generator.Generate( + ctx, + inputFS, + baseFolder, + "", + pythonImage, + []string{ + "--input-file-type", + "openapi", + "--disable-timestamp", + "--input", + ".", + "--output-model-type", + "pydantic_v2.BaseModel", + "--target-python-version", + "3.12", + "--use-field-description", + "--enum-field-as-literal", + "all", + "--use-one-literal-as-default", + "--output", + pythonModelsFolder, + }, + ) +} + +// postTransformCRD combines the reorganization of Python files and the adjustment of import paths into one pass. +func postTransformCRD(fs afero.Fs, sourceDir, targetDir string) error { //nolint:gocognit // we need this python transforms + v1MetaCopied := false // Flag to track if v1.py has already been moved + createdInitFiles := make(map[string]bool) + + // Walk through the source directory to handle both reorganization and import adjustment + return afero.Walk(fs, sourceDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return errors.Wrapf(err, "walking path %s", path) + } + + // If this is the `v1.py` file within `k8s/apimachinery/pkg/apis/meta`, move it once + if info.Name() == "v1.py" && strings.Contains(path, filepath.Join("io", "k8s", "apimachinery", "pkg", "apis", "meta")) { + if !v1MetaCopied { + destDir := filepath.Join(targetDir, "io", "k8s", "apimachinery", "pkg", "apis", "meta") + destPath := filepath.Join(destDir, "v1.py") + + // Read file content and write it to the new destination + data, err := afero.ReadFile(fs, path) + if err != nil { + return errors.Wrapf(err, "failed to read %s", path) + } + + // Get the source file's permissions + fileInfo, err := fs.Stat(path) + if err != nil { + return errors.Wrapf(err, "failed to get file info for %s", path) + } + + // Use the source file's permissions instead of os.ModePerm + if err := afero.WriteFile(fs, destPath, data, fileInfo.Mode()); err != nil { + return errors.Wrapf(err, "failed to write %s", destPath) + } + + // Create __init__.py in the same directory if it doesn't exist + initFilePath := filepath.Join(destDir, "__init__.py") + if err := afero.WriteFile(fs, initFilePath, []byte(""), os.ModePerm); err != nil { + return errors.Wrapf(err, "failed to create __init__.py in %s", destDir) + } + + v1MetaCopied = true // Ensure we copy it only once + } + return nil // Skip subsequent meta v1.py files + } + + // Process only schema files + isDir := info.IsDir() + isNotPythonFile := filepath.Ext(info.Name()) != ".py" + // Define the path segment to skip + skipPathSegment := filepath.Join("io", "k8s", "apimachinery", "pkg", "apis", "meta") + isInSkipPath := strings.Contains(filepath.ToSlash(path), skipPathSegment) + isInitFile := info.Name() == "__init__.py" + + if isDir || isNotPythonFile || isInSkipPath || isInitFile { + return nil + } + + // Process the reorganization logic + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return errors.Wrap(err, "calculating relative path") + } + dirSegments := strings.Split(filepath.ToSlash(filepath.Dir(relPath)), "/") + + // Extract API version and segments before it + var apiVersion, rootFolder string + var preVersionSegments []string + for _, dirSegment := range dirSegments { + for subSegment := range strings.SplitSeq(dirSegment, "_") { + if isAPIVersion(subSegment) { + apiVersion = subSegment + rootFolder = dirSegment + break + } + preVersionSegments = append(preVersionSegments, subSegment) + } + if apiVersion != "" { + break + } + } + + // If no known API version is found, default to "unknown" + if apiVersion == "" || rootFolder == "" { + apiVersion = "unknown" + } + + // Build the destination directory + slices.Reverse(preVersionSegments) + orderedPath := filepath.Join(preVersionSegments...) + rootWithoutVersion := strings.ReplaceAll(rootFolder, apiVersion, "") + rootParts := strings.Split(rootWithoutVersion, "_") + kind := rootParts[len(rootParts)-1] // Extract the kind + + // Prepare destination path + newFileName := fmt.Sprintf("%s.py", apiVersion) + // Check if orderedPath already ends with kind to avoid duplication (e.g., gateway/gateway) + var destinationDir string + if orderedPath != "" && filepath.Base(orderedPath) == kind { + // orderedPath already ends with kind, don't append it again + destinationDir = filepath.Join(targetDir, orderedPath) + } else { + // Append kind to the path + destinationDir = filepath.Join(targetDir, orderedPath, kind) + } + destinationPath := filepath.Join(destinationDir, newFileName) + + // Create the destination directory + if err := fs.MkdirAll(destinationDir, os.ModePerm); err != nil { + return errors.Wrapf(err, "creating directory %s", destinationDir) + } + + // Read the file content and move it + data, err := afero.ReadFile(fs, path) + if err != nil { + return errors.Wrapf(err, "reading file %s", path) + } + if err := afero.WriteFile(fs, destinationPath, data, os.ModePerm); err != nil { + return errors.Wrapf(err, "writing file %s", destinationPath) + } + if err := fs.Remove(path); err != nil { + return errors.Wrapf(err, "deleting original file %s", path) + } + + // Ensure an __init__.py is created in the destination directory if it doesn't exist + initFilePath := filepath.Join(destinationDir, "__init__.py") + if !createdInitFiles[destinationDir] { + if err := afero.WriteFile(fs, initFilePath, []byte(""), os.ModePerm); err != nil { + return errors.Wrapf(err, "creating __init__.py in %s", destinationDir) + } + createdInitFiles[destinationDir] = true + } + + // Adjust the imports for the moved file + if err := adjustImportsInFile(fs, destinationPath); err != nil { + return errors.Wrapf(err, "adjusting imports in %s", destinationPath) + } + + return nil + }) +} + +// adjustImportsInFile modifies the import statements in the file to ensure correct depth. +func adjustImportsInFile(fs afero.Fs, filePath string) error { + // Count the number of directories deep the file is + depth := strings.Count(filePath, string(os.PathSeparator)) + + // Read the file content + fileContent, err := afero.ReadFile(fs, filePath) + if err != nil { + return errors.Wrapf(err, "error reading file %s", filePath) + } + + // Modify the file line by line to adjust the specific imports + modifiedContent := []string{} + scanner := bufio.NewScanner(strings.NewReader(string(fileContent))) + for scanner.Scan() { + line := scanner.Text() + // Adjust imports that contain `k8s.apimachinery.pkg.apis.meta` + if strings.Contains(line, "k8s.apimachinery.pkg.apis.meta") { + // Use adjustLeadingDots for CRD context + line = adjustLeadingDots(line, depth) + } + modifiedContent = append(modifiedContent, line) + } + + // Write back the modified file content + if err := afero.WriteFile(fs, filePath, []byte(strings.Join(modifiedContent, "\n")), os.ModePerm); err != nil { + return errors.Wrapf(err, "error writing modified file %s", filePath) + } + + return nil +} + +// Adjusts the number of leading dots in the `io.k8s.apimachinery.pkg.apis.meta` import statement +// based on the file's depth. +func adjustLeadingDots(importLine string, depth int) string { + dotPart := "" + // Check for either `io.k8s.apimachinery.pkg.apis.meta` or `k8s.apimachinery.pkg.apis.meta` + var basePath string + if strings.Contains(importLine, "io.k8s.apimachinery.pkg.apis.meta") { + basePath = "io.k8s.apimachinery.pkg.apis.meta" + // Add the correct number of leading dots based on depth + dotPart = strings.Repeat(".", depth) + } else if strings.Contains(importLine, "k8s.apimachinery.pkg.apis.meta") { + basePath = "k8s.apimachinery.pkg.apis.meta" + // Add the correct number of leading dots based on depth - 1 because "io" is same base-folder + if depth > 1 { + dotPart = strings.Repeat(".", depth-1) + } else { + dotPart = "" + } + } + + // Process the line if a valid base path is found + if basePath != "" { + // Split the line into parts: the leading dots + the import path + parts := strings.SplitN(importLine, basePath, 2) + + // Construct the new line with the correct leading dots + return "from " + dotPart + basePath + parts[1] + } + + return importLine +} + +// shouldSkipOpenAPIFile checks if the OpenAPI file should be skipped. +func shouldSkipOpenAPIFile(doc *openapi3.T) bool { + if doc.Components == nil { + return false + } + + for _, schemaRef := range doc.Components.Schemas { + if schemaRef == nil || schemaRef.Value == nil { + continue + } + ext, ok := schemaRef.Value.Extensions["x-kubernetes-group-version-kind"] + if !ok { + continue + } + + extBytes, err := json.Marshal(ext) + if err != nil { + continue + } + + var gvkList []map[string]any + if err := json.Unmarshal(extBytes, &gvkList); err != nil { + continue + } + + for _, gvk := range gvkList { + if kindRaw, ok := gvk["kind"]; ok { + if kind, ok := kindRaw.(string); ok { + if kind == "APIVersions" || kind == "APIGroup" { + return true + } + } + } + } + } + + return false +} + +// processOpenAPIContent processes OpenAPI content to add default values for apiVersion and kind. +func processOpenAPIContent(doc *openapi3.T) *openapi3.T { //nolint:gocognit // set default apiVersion and kind. + if doc.Components == nil { + return doc + } + + for _, schemaRef := range doc.Components.Schemas { + if schemaRef == nil || schemaRef.Value == nil { + continue + } + schema := schemaRef.Value + + // Look for x-kubernetes-group-version-kind extension + rawExt, ok := schema.Extensions["x-kubernetes-group-version-kind"] + if !ok { + continue + } + + rawBytes, err := json.Marshal(rawExt) + if err != nil { + continue + } + + var gvkList []map[string]any + if err := json.Unmarshal(rawBytes, &gvkList); err != nil { + continue + } + + if len(gvkList) == 0 { + continue + } + + gvk := gvkList[0] + group := "" + if g, ok := gvk["group"].(string); ok { + group = g + } + version := "" + if v, ok := gvk["version"].(string); ok { + version = v + } + kind := "" + if k, ok := gvk["kind"].(string); ok { + kind = k + } + + apiVersion := version + if group != "" { + apiVersion = group + "/" + version + } + + // Add defaults to properties + if schema.Properties != nil { + // Add default to apiVersion property + if propSchemaRef, ok := schema.Properties["apiVersion"]; ok { + if propSchemaRef != nil && propSchemaRef.Value != nil { + propSchemaRef.Value.Default = apiVersion + } + } + // Add default to kind property + if propSchemaRef, ok := schema.Properties["kind"]; ok { + if propSchemaRef != nil && propSchemaRef.Value != nil { + propSchemaRef.Value.Default = kind + } + } + } + } + + // Return the modified document + return doc +} + +// fixAliasedTypesInFile replaces bool_aliased and int_aliased with bool and int in Python files. +func fixAliasedTypesInFile(fs afero.Fs, filePath string) error { + // Read the file content + fileContent, err := afero.ReadFile(fs, filePath) + if err != nil { + return errors.Wrapf(err, "reading file %s", filePath) + } + + // Replace bool_aliased with bool and int_aliased with int + // https://github.com/koxudaxi/datamodel-code-generator/issues/2431 + content := string(fileContent) + content = strings.ReplaceAll(content, "bool_aliased", "bool") + content = strings.ReplaceAll(content, "int_aliased", "int") + + // Write back the modified content + if err := afero.WriteFile(fs, filePath, []byte(content), os.ModePerm); err != nil { + return errors.Wrapf(err, "writing modified file %s", filePath) + } + + return nil +} + +// postTransformOpenAPI consolidates the generated OpenAPI Python files into a unified structure. +func postTransformOpenAPI(fs afero.Fs, sourceDir, targetDir string) error { + createdInitDirs := make(map[string]bool) + + return afero.Walk(fs, sourceDir, func(path string, info os.FileInfo, walkErr error) error { + if walkErr != nil { + return errors.Wrapf(walkErr, "walking path %s", path) + } + + if shouldSkipFile(info) { + return nil + } + + relPath, err := filepath.Rel(sourceDir, path) + if err != nil { + return errors.Wrap(err, "calculating relative path") + } + + _, normalizedParts, include := normalizeAndFilterPath(relPath) + if !include { + return nil + } + + destPath, destDir := computeDestPath(targetDir, normalizedParts) + + // Special handling for io/k8s/apimachinery/pkg/apis/meta/v1.py + if isMetaV1File(destPath) { + destPath, destDir = transformMetaV1Path(targetDir, destPath) + } + + if err := copyFileWithInit(fs, path, destPath, destDir, createdInitDirs); err != nil { + return err + } + + if err := postProcessFile(fs, destPath); err != nil { + return err + } + + // Transform meta imports after postProcessFile + if err := transformMetaImportsInFile(fs, destPath); err != nil { + return err + } + + return nil + }) +} + +func shouldSkipFile(info os.FileInfo) bool { + if info.IsDir() || info.Name() == "__init__.py" || filepath.Ext(info.Name()) != ".py" { + return true + } + return false +} + +func normalizeAndFilterPath(relPath string) (openapiFolder string, normalizedParts []string, include bool) { + parts := strings.Split(filepath.ToSlash(relPath), "/") + if len(parts) == 0 { + return "", nil, false + } + + // Identify the OpenAPI folder + for _, part := range parts { + if strings.HasSuffix(part, "_openapi") { + openapiFolder = part + break + } + } + + // Locate io/k8s onwards + var foundIO bool + for i, part := range parts { + if part == "io" && i+1 < len(parts) && parts[i+1] == "k8s" { + normalizedParts = parts[i:] + foundIO = true + break + } + } + if !foundIO { + return "", nil, false + } + + // Filtering rules + if len(normalizedParts) >= 3 && normalizedParts[2] == "apimachinery" { + if openapiFolder != "api__v1_openapi" { + return "", nil, false + } + } + + if openapiFolder != "" && strings.HasPrefix(openapiFolder, "apis__") { + segments := strings.Split(openapiFolder, "__") + if len(segments) >= 2 { + apiGroup := segments[1] + if len(normalizedParts) >= 4 && normalizedParts[2] == "api" { + fileAPIGroup := normalizedParts[3] + if (fileAPIGroup == "core" || fileAPIGroup == "authentication" || + fileAPIGroup == k8sPkgNameAutoscaling || fileAPIGroup == "policy") && + fileAPIGroup != apiGroup { + return "", nil, false + } + } + } + } + + return openapiFolder, normalizedParts, true +} + +func computeDestPath(targetDir string, normalizedParts []string) (string, string) { + destPath := filepath.Join(append([]string{targetDir}, normalizedParts...)...) + destDir := filepath.Dir(destPath) + return destPath, destDir +} + +func copyFileWithInit(fs afero.Fs, srcPath, destPath, destDir string, created map[string]bool) error { + if err := fs.MkdirAll(destDir, os.ModePerm); err != nil { + return errors.Wrapf(err, "creating directory %s", destDir) + } + + data, err := afero.ReadFile(fs, srcPath) + if err != nil { + return errors.Wrapf(err, "reading %s", srcPath) + } + + if err := afero.WriteFile(fs, destPath, data, os.ModePerm); err != nil { + return errors.Wrapf(err, "writing %s", destPath) + } + + if !created[destDir] { + initPath := filepath.Join(destDir, "__init__.py") + if err := afero.WriteFile(fs, initPath, []byte(""), os.ModePerm); err != nil { + return errors.Wrapf(err, "creating __init__.py in %s", destDir) + } + created[destDir] = true + } + + return nil +} + +func postProcessFile(fs afero.Fs, path string) error { + if err := adjustImportsInFile(fs, path); err != nil { + return errors.Wrapf(err, "adjusting imports") + } + if err := fixAliasedTypesInFile(fs, path); err != nil { + return errors.Wrapf(err, "fixing aliased types") + } + return nil +} + +// isMetaV1File returns true if path ends in "apis/meta/v1.py".. +func isMetaV1File(path string) bool { + // filepath.ToSlash makes sure we use "/" even on Windows + return strings.HasSuffix(filepath.ToSlash(path), "apis/meta/v1.py") +} + +// transformMetaV1Path replaces "/apis/meta/v1.py" with "/apis/core/meta/v1.py". +func transformMetaV1Path(targetDir, inPath string) (destPath, destDir string) { + // Get the relative part after targetDir + rel, _ := filepath.Rel(targetDir, inPath) + rel = filepath.ToSlash(rel) + + // Replace apis/meta/v1.py with apis/core/meta/v1.py + newRel := strings.Replace(rel, + "apis/meta/v1.py", + "apis/core/meta/v1.py", + 1, + ) + + // Build the full destination path + destPath = filepath.Join(targetDir, filepath.FromSlash(newRel)) + destDir = filepath.Dir(destPath) + return +} + +// transformMetaImport turns "apis.meta" → "apis.core.meta" in OpenAPI contexts. +// otherwise meta schemas are overridden from crds which are different. +func transformMetaImport(importLine string) string { + parts := importRE.FindStringSubmatch(importLine) + if parts == nil { + return importLine + } + prefix, dots, modPath, suffix := parts[1], parts[2], parts[3], parts[4] + + // only tweak if it's actually a meta import + if !strings.Contains(modPath, "apis.meta") { + return importLine + } + + newPath := strings.Replace(modPath, "apis.meta", "apis.core.meta", 1) + return prefix + dots + newPath + suffix +} + +// transformMetaImportsInFile transforms imports of meta.v1 to core.meta.v1.. +func transformMetaImportsInFile(fs afero.Fs, filePath string) error { + // Read the file content + fileContent, err := afero.ReadFile(fs, filePath) + if err != nil { + return errors.Wrapf(err, "error reading file %s", filePath) + } + + // Check if this is the core/meta/v1.py file + isCoreMeta := strings.HasSuffix(filepath.ToSlash(filePath), "core/meta/v1.py") + + // Modify the file line by line to transform meta imports + modifiedContent := []string{} + scanner := bufio.NewScanner(strings.NewReader(string(fileContent))) + for scanner.Scan() { + line := scanner.Text() + // Transform imports that contain apis.meta + if strings.Contains(line, "apis.meta") { + line = transformMetaImport(line) + } + // If this is the core/meta/v1.py file, add one more dot to relative imports + if isCoreMeta { + line = adjustRelativeImportsForCoreMeta(line) + } + modifiedContent = append(modifiedContent, line) + } + + // Write back the modified file content + if err := afero.WriteFile(fs, filePath, []byte(strings.Join(modifiedContent, "\n")), os.ModePerm); err != nil { + return errors.Wrapf(err, "error writing modified file %s", filePath) + } + + return nil +} + +// adjustRelativeImportsForCoreMeta adds one more dot to relative imports in core/meta/v1.py.. +func adjustRelativeImportsForCoreMeta(line string) string { + // Use regex to match relative imports + matches := importRE.FindStringSubmatch(line) + if matches == nil { + return line + } + + prefix, dots, modPath, suffix := matches[1], matches[2], matches[3], matches[4] + + // Only adjust if it's a relative import (has dots) + if len(dots) > 0 { + // Add one more dot since we're one directory deeper + dots = "." + dots + return prefix + dots + modPath + suffix + } + + return line +} diff --git a/internal/schemas/manager/lock.go b/internal/schemas/manager/lock.go new file mode 100644 index 0000000..e4387d9 --- /dev/null +++ b/internal/schemas/manager/lock.go @@ -0,0 +1,31 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manager + +const lockFileName = ".lock.json" + +// lock tracks the versions of sources whose schemas are present in the +// manager. It is persisted to the manager's filesystem. +type lock struct { + Packages map[string]string `json:"packages"` +} + +func newLock() *lock { + return &lock{ + Packages: make(map[string]string), + } +} diff --git a/internal/schemas/manager/manager.go b/internal/schemas/manager/manager.go new file mode 100644 index 0000000..ef975e7 --- /dev/null +++ b/internal/schemas/manager/manager.go @@ -0,0 +1,249 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package manager implements a schema manager for use in Crossplane projects. +package manager + +import ( + "context" + "encoding/json" + "io/fs" + "path/filepath" + "sync" + + "github.com/invopop/jsonschema" + "github.com/spf13/afero" + "golang.org/x/sync/errgroup" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/internal/filesystem" + "github.com/crossplane/cli/v2/internal/schemas/generator" + "github.com/crossplane/cli/v2/internal/schemas/runner" +) + +// Manager is a schema manager. It manages a directory of schemas, generating +// new schemas only when necessary. +type Manager struct { + fs afero.Fs + generators []generator.Interface + runner runner.SchemaRunner + + lockMu sync.RWMutex +} + +// Add ensures schemas for resources in the given source are present in the +// managed directory. +func (m *Manager) Add(ctx context.Context, source Source) error { + version, err := source.Version(ctx) + if err != nil { + return err + } + + existing, err := m.currentVersion(source.ID()) + if err != nil { + return err + } + if existing == version { + return nil + } + + _, err = m.Generate(ctx, source) + return err +} + +// Generate generates and returns schemas using the manager's generators, and +// adds them to the manager. Unlike Add, Generate will always generate schemas, +// regardless of whether they're already present in the manager. +func (m *Manager) Generate(ctx context.Context, source Source) (map[string]afero.Fs, error) { + version, err := source.Version(ctx) + if err != nil { + return nil, err + } + + fromFS, err := source.Resources(ctx) + if err != nil { + return nil, err + } + + schemas := make(map[string]afero.Fs) + eg, egCtx := errgroup.WithContext(ctx) + sourceType := source.Type() + for _, gen := range m.generators { + eg.Go(func() error { + var schemaFS afero.Fs + var err error + + switch sourceType { + case SourceTypeCRD: + schemaFS, err = gen.GenerateFromCRD(egCtx, fromFS, m.runner) + case SourceTypeOpenAPI: + schemaFS, err = gen.GenerateFromOpenAPI(egCtx, fromFS, m.runner) + default: + return errors.Errorf("unsupported source type %q", sourceType) + } + if err != nil { + return err + } + + if schemaFS != nil { + schemas[gen.Language()] = schemaFS + } + + return nil + }) + } + if err := eg.Wait(); err != nil { + return nil, err + } + + // Copy generated schemas into our schema repository. Generators produce + // output into models/ — we strip that prefix by copying from models/ into + // the language directory. + for lang, genFS := range schemas { + langFS := afero.NewBasePathFs(m.fs, lang) + + // Try to copy from models/ subdirectory first (generators put output there). + modelsFS := afero.NewBasePathFs(genFS, "models") + hasModels := false + if fi, err := modelsFS.Stat("."); err == nil && fi.IsDir() { + hasModels = true + } + + if hasModels { + if err := filesystem.CopyFilesBetweenFs(modelsFS, langFS); err != nil { + return nil, err + } + } else { + if err := filesystem.CopyFilesBetweenFs(genFS, langFS); err != nil { + return nil, err + } + } + + if err := postProcessForLanguage(lang, langFS); err != nil { + return nil, err + } + } + + return schemas, m.updateVersion(source.ID(), version) +} + +func postProcessForLanguage(language string, langFS afero.Fs) error { + switch language { + case "json": + if err := jsonBuildIndexSchema(langFS); err != nil { + return errors.Wrap(err, "failed to build index schema for JSON") + } + return nil + + default: + return nil + } +} + +func jsonBuildIndexSchema(langFS afero.Fs) error { + schemas, err := afero.Glob(langFS, "*.schema.json") + if err != nil { + return err + } + + metaFile := "index.schema.json" + var metaSchema jsonschema.Schema + for _, schema := range schemas { + if schema == metaFile { + continue + } + metaSchema.AnyOf = append(metaSchema.AnyOf, &jsonschema.Schema{ + Ref: filepath.Base(schema), + }) + } + bs, err := json.Marshal(metaSchema) + if err != nil { + return err + } + + return afero.WriteFile(langFS, metaFile, bs, 0o644) +} + +func (m *Manager) currentVersion(id string) (string, error) { + m.lockMu.RLock() + defer m.lockMu.RUnlock() + + l, err := m.getLock() + if err != nil { + return "", err + } + + return l.Packages[id], nil +} + +func (m *Manager) updateVersion(id, version string) error { + m.lockMu.Lock() + defer m.lockMu.Unlock() + + l, err := m.getLock() + if err != nil { + return err + } + + l.Packages[id] = version + + return m.updateLock(l) +} + +func (m *Manager) getLock() (*lock, error) { + lf, err := m.fs.Open(lockFileName) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return newLock(), nil + } + return nil, err + } + defer func() { _ = lf.Close() }() + + var l lock + if err := json.NewDecoder(lf).Decode(&l); err != nil { + return nil, err + } + + return &l, nil +} + +func (m *Manager) updateLock(l *lock) error { + if err := m.fs.MkdirAll("/", 0o750); err != nil { + return errors.Wrap(err, "failed to ensure schema directory exists") + } + + bs, err := json.Marshal(l) + if err != nil { + return errors.Wrap(err, "failed to serialize schema lock") + } + + if err := afero.WriteFile(m.fs, lockFileName, bs, 0o600); err != nil { + return errors.Wrap(err, "failed to write schema lock file") + } + + return nil +} + +// New returns an initialized manager. +func New(fs afero.Fs, gens []generator.Interface, r runner.SchemaRunner) *Manager { + return &Manager{ + fs: fs, + generators: gens, + runner: r, + } +} diff --git a/internal/schemas/manager/source.go b/internal/schemas/manager/source.go new file mode 100644 index 0000000..b25f8bd --- /dev/null +++ b/internal/schemas/manager/source.go @@ -0,0 +1,542 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package manager + +import ( + "context" + "crypto/sha256" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "path" + "path/filepath" + "slices" + "strings" + "time" + + "github.com/go-git/go-billy/v5" + "github.com/go-git/go-billy/v5/helper/iofs" + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + + "github.com/crossplane/cli/v2/apis/dev/v1alpha1" + "github.com/crossplane/cli/v2/internal/filesystem" + "github.com/crossplane/cli/v2/internal/git" +) + +// SourceType represents the type of source. +type SourceType string + +const ( + // SourceTypeCRD indicates a source containing CRDs/XRDs. + SourceTypeCRD SourceType = "crd" + // SourceTypeOpenAPI indicates a source containing OpenAPI specifications. + SourceTypeOpenAPI SourceType = "openapi" +) + +// Source is a source of resources for which schemas can be generated. +type Source interface { + // ID returns a unique identifier for this source. + ID() string + // Version returns a revision identifier for this source. + Version(ctx context.Context) (string, error) + // Resources returns a filesystem containing resources for which schemas + // need to be generated. + Resources(ctx context.Context) (afero.Fs, error) + // Type returns the type of source. + Type() SourceType +} + +// calculateFilesystemHash calculates a SHA256 hash of the filesystem contents. +func calculateFilesystemHash(filesystem afero.Fs, sourceType SourceType) (string, error) { + h := sha256.New() + + var extensions []string + switch sourceType { + case SourceTypeCRD: + extensions = []string{".yaml", ".yml"} + case SourceTypeOpenAPI: + extensions = []string{".json"} + default: + extensions = []string{".yaml", ".yml", ".json"} + } + + if err := afero.Walk(filesystem, ".", func(path string, info fs.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + + ext := strings.ToLower(filepath.Ext(path)) + if !slices.Contains(extensions, ext) { + return nil + } + + f, err := filesystem.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + + if _, err := io.Copy(h, f); err != nil { + return err + } + + return nil + }); err != nil { + return "", err + } + + return fmt.Sprintf("%x", h.Sum(nil)), nil +} + +// fsSource is a resource source backed by a filesystem. +type fsSource struct { + id string + fs afero.Fs +} + +func (f *fsSource) ID() string { + return "fs://" + f.id +} + +func (f *fsSource) Version(_ context.Context) (string, error) { + return calculateFilesystemHash(f.fs, SourceTypeCRD) +} + +func (f *fsSource) Resources(_ context.Context) (afero.Fs, error) { + return f.fs, nil +} + +func (f *fsSource) Type() SourceType { + return SourceTypeCRD +} + +// NewFSSource returns a new filesystem-backed resource source. The id should +// be a stable, location-independent identifier (e.g. a project-relative path) +// since it is persisted in the schema manager's lockfile. +func NewFSSource(id string, fs afero.Fs) Source { + return &fsSource{id: id, fs: fs} +} + +// xpkgSource is a source backed by extracted CRDs from an xpkg. Unlike the +// up CLI, this always generates client-side (never uses pre-packaged schemas). +type xpkgSource struct { + id string + version string + crdFS afero.Fs +} + +func (s *xpkgSource) ID() string { + return "xpkg://" + s.id +} + +func (s *xpkgSource) Version(_ context.Context) (string, error) { + return s.version, nil +} + +func (s *xpkgSource) Resources(_ context.Context) (afero.Fs, error) { + return s.crdFS, nil +} + +func (s *xpkgSource) Type() SourceType { + return SourceTypeCRD +} + +// NewXpkgSource returns a new xpkg-backed resource source. The crdFS should +// contain extracted CRD YAML files from the package. +func NewXpkgSource(id, version string, crdFS afero.Fs) Source { + return &xpkgSource{ + id: id, + version: version, + crdFS: crdFS, + } +} + +const maxCloneAttempts = 3 + +// gitSource is a resource source that fetches directly from git repositories. +type gitSource struct { + gitRef *v1alpha1.GitDependency + cloner git.Cloner + authProvider git.AuthProvider + sourceType SourceType + fs afero.Fs + commitSHA string +} + +func (g *gitSource) ID() string { + id := fmt.Sprintf("git://%s", g.gitRef.Repository) + if g.gitRef.Path != "" { + id = fmt.Sprintf("%s/%s", id, g.gitRef.Path) + } + return id +} + +func (g *gitSource) Version(ctx context.Context) (string, error) { + if g.commitSHA != "" { + return g.commitSHA, nil + } + + if _, err := g.Resources(ctx); err != nil { + return "", err + } + return g.commitSHA, nil +} + +func (g *gitSource) Resources(_ context.Context) (afero.Fs, error) { + if g.fs != nil { + return g.fs, nil + } + + ref := g.normalizeRef(g.gitRef.Ref) + + var memFS billy.Filesystem + var lastErr error + + for attempt := 1; attempt <= maxCloneAttempts; attempt++ { + memFS = memfs.New() + + headRef, err := g.cloner.CloneRepository( + memory.NewStorage(), + memFS, + g.authProvider, + git.CloneOptions{ + Repo: g.gitRef.Repository, + RefName: ref, + Path: g.gitRef.Path, + }, + ) + if err != nil { + lastErr = errors.Wrapf(err, "clone attempt %d failed", attempt) + continue + } + + if headRef != nil { + g.commitSHA = headRef.Hash().String() + } + + if err := g.verifyClone(memFS, g.gitRef.Path); err != nil { + lastErr = errors.Wrapf(err, "verification failed after attempt %d", attempt) + continue + } + + lastErr = nil + break + } + + if lastErr != nil { + return nil, errors.Wrapf(lastErr, "failed to clone repository %s after %d attempts", g.gitRef.Repository, maxCloneAttempts) + } + + resultFS := afero.NewMemMapFs() + + if err := filesystem.CopyFilesBetweenFs(afero.FromIOFS{FS: iofs.New(memFS)}, resultFS); err != nil { + return nil, errors.Wrap(err, "failed to copy files from git repository") + } + + g.fs = resultFS + return g.fs, nil +} + +func (g *gitSource) Type() SourceType { + return g.sourceType +} + +func (g *gitSource) normalizeRef(ref string) string { + if ref == "" { + return "refs/heads/main" + } + + if git.CheckSHA256Hash(ref) { + return ref + } + + if len(ref) > 5 && ref[:5] == "refs/" { + return ref + } + + if isVersionTag(ref) { + return "refs/tags/" + ref + } + + return "refs/heads/" + ref +} + +func (g *gitSource) verifyClone(fs billy.Filesystem, path string) error { + files, err := fs.ReadDir(path) + if err != nil { + return errors.Wrapf(err, "cannot read cloned path %s", path) + } + + if len(files) == 0 { + return errors.Errorf("no files found in cloned path %s", path) + } + + return nil +} + +func isVersionTag(ref string) bool { + if len(ref) == 0 { + return false + } + + if ref[0] == 'v' && len(ref) > 1 && ref[1] >= '0' && ref[1] <= '9' { + return true + } + + return ref[0] >= '0' && ref[0] <= '9' +} + +// NewGitSource returns a new git-backed resource source. +func NewGitSource(dep v1alpha1.Dependency, cloner git.Cloner, authProvider git.AuthProvider) Source { + sourceType := SourceTypeCRD + if dep.Type == v1alpha1.DependencyTypeK8s { + sourceType = SourceTypeOpenAPI + } + + return &gitSource{ + gitRef: dep.Git, + cloner: cloner, + authProvider: authProvider, + sourceType: sourceType, + } +} + +const ( + defaultHTTPTimeout = 1 * time.Minute + maxHTTPSize = 100 * 1024 * 1024 +) + +// httpSource is a resource source that fetches from HTTP/HTTPS URLs. +type httpSource struct { + httpRef *v1alpha1.HTTPDependency + client *http.Client + sourceType SourceType + fs afero.Fs +} + +func (h *httpSource) ID() string { + return fmt.Sprintf("http://%s", h.httpRef.URL) +} + +func (h *httpSource) Version(ctx context.Context) (string, error) { + if h.fs != nil { + return h.calculateHash() + } + + if _, err := h.Resources(ctx); err != nil { + return "", err + } + return h.calculateHash() +} + +func (h *httpSource) Resources(ctx context.Context) (afero.Fs, error) { + if h.fs != nil { + return h.fs, nil + } + + u, err := url.Parse(h.httpRef.URL) + if err != nil { + return nil, errors.Wrap(err, "invalid URL") + } + + if u.Scheme != "http" && u.Scheme != "https" { + return nil, errors.Errorf("unsupported URL scheme: %s", u.Scheme) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, h.httpRef.URL, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + + resp, err := h.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to fetch URL") + } + defer resp.Body.Close() //nolint:errcheck // nothing to do here + + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("unexpected status code: %d", resp.StatusCode) + } + + if resp.ContentLength > maxHTTPSize { + return nil, errors.Errorf("content too large: %d bytes (max: %d)", resp.ContentLength, maxHTTPSize) + } + + limitedReader := io.LimitReader(resp.Body, maxHTTPSize) + content, err := io.ReadAll(limitedReader) + if err != nil { + return nil, errors.Wrap(err, "failed to read response body") + } + + resultFS := afero.NewMemMapFs() + filename := h.getFilename(u) + + if err := afero.WriteFile(resultFS, filename, content, 0o644); err != nil { + return nil, errors.Wrap(err, "failed to write content to filesystem") + } + + h.fs = resultFS + return h.fs, nil +} + +func (h *httpSource) Type() SourceType { + return h.sourceType +} + +func (h *httpSource) calculateHash() (string, error) { + return calculateFilesystemHash(h.fs, h.sourceType) +} + +func (h *httpSource) getFilename(u *url.URL) string { + filename := path.Base(u.Path) + + if filename == "" || filename == "." || filename == "/" { + switch { + case strings.Contains(u.String(), "yaml") || strings.Contains(u.String(), "yml"): + filename = "crd.yaml" + case h.sourceType == SourceTypeOpenAPI: + filename = "openapi.json" + default: + filename = "crd" + } + } + + return filename +} + +// NewHTTPSource returns a new HTTP-backed resource source. +func NewHTTPSource(dep v1alpha1.Dependency) Source { + sourceType := SourceTypeCRD + if dep.Type == v1alpha1.DependencyTypeK8s { + sourceType = SourceTypeOpenAPI + } + + return &httpSource{ + httpRef: dep.HTTP, + client: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + sourceType: sourceType, + } +} + +// k8sOpenAPISource is a source that generates OpenAPI schemas for Kubernetes +// built-in APIs by downloading the swagger spec. +type k8sOpenAPISource struct { + k8sRef *v1alpha1.K8sDependency + client *http.Client + fs afero.Fs +} + +func (k *k8sOpenAPISource) ID() string { + return fmt.Sprintf("k8s://%s", k.k8sRef.Version) +} + +func (k *k8sOpenAPISource) Version(_ context.Context) (string, error) { + return k.k8sRef.Version, nil +} + +func (k *k8sOpenAPISource) Resources(ctx context.Context) (afero.Fs, error) { + if k.fs != nil { + return k.fs, nil + } + + // Download the OpenAPI v3 spec from the Kubernetes repo + specURL := fmt.Sprintf("https://raw.githubusercontent.com/kubernetes/kubernetes/v%s/api/openapi-spec/v3/api__v1_openapi.json", strings.TrimPrefix(k.k8sRef.Version, "v")) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, specURL, nil) + if err != nil { + return nil, errors.Wrap(err, "failed to create request") + } + + resp, err := k.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "failed to fetch Kubernetes OpenAPI spec") + } + defer resp.Body.Close() //nolint:errcheck // nothing to do + + if resp.StatusCode != http.StatusOK { + return nil, errors.Errorf("failed to download Kubernetes OpenAPI spec: HTTP %d", resp.StatusCode) + } + + content, err := io.ReadAll(io.LimitReader(resp.Body, maxHTTPSize)) + if err != nil { + return nil, errors.Wrap(err, "failed to read Kubernetes OpenAPI spec") + } + + resultFS := afero.NewMemMapFs() + if err := afero.WriteFile(resultFS, "api__v1_openapi.json", content, 0o644); err != nil { + return nil, errors.Wrap(err, "failed to write OpenAPI spec") + } + + // Also try to download the apis specs + apisGroups := []string{ + "apis__apps__v1", + "apis__autoscaling__v1", + "apis__batch__v1", + "apis__networking.k8s.io__v1", + "apis__policy__v1", + "apis__rbac.authorization.k8s.io__v1", + "apis__storage.k8s.io__v1", + } + + for _, group := range apisGroups { + groupURL := fmt.Sprintf("https://raw.githubusercontent.com/kubernetes/kubernetes/v%s/api/openapi-spec/v3/%s_openapi.json", strings.TrimPrefix(k.k8sRef.Version, "v"), group) + groupReq, err := http.NewRequestWithContext(ctx, http.MethodGet, groupURL, nil) + if err != nil { + continue + } + + groupResp, err := k.client.Do(groupReq) + if err != nil { + continue + } + + if groupResp.StatusCode == http.StatusOK { + groupContent, err := io.ReadAll(io.LimitReader(groupResp.Body, maxHTTPSize)) + if err == nil { + _ = afero.WriteFile(resultFS, group+"_openapi.json", groupContent, 0o644) + } + } + _ = groupResp.Body.Close() + } + + k.fs = resultFS + return k.fs, nil +} + +func (k *k8sOpenAPISource) Type() SourceType { + return SourceTypeOpenAPI +} + +// NewK8sSource returns a source for Kubernetes built-in APIs. +func NewK8sSource(dep v1alpha1.Dependency) Source { + return &k8sOpenAPISource{ + k8sRef: dep.K8s, + client: &http.Client{ + Timeout: defaultHTTPTimeout, + }, + } +} diff --git a/internal/schemas/runner/run.go b/internal/schemas/runner/run.go new file mode 100644 index 0000000..1157d49 --- /dev/null +++ b/internal/schemas/runner/run.go @@ -0,0 +1,164 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package runner contains functions for handling containers for schema +// generation. +package runner + +import ( + "context" + "os" + "strings" + + "github.com/spf13/afero" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" + + "github.com/crossplane/cli/v2/internal/docker" + "github.com/crossplane/cli/v2/internal/filesystem" + clixpkg "github.com/crossplane/cli/v2/internal/xpkg" +) + +// SchemaRunner defines an interface for schema generation. +type SchemaRunner interface { + Generate(ctx context.Context, fs afero.Fs, folder string, basePath string, imageName string, args []string, options ...Option) error +} + +// RealSchemaRunner implements the SchemaRunner interface. +type RealSchemaRunner struct { + configStore xpkg.ConfigStore +} + +// NewRealSchemaRunner creates a new RealSchemaRunner. +func NewRealSchemaRunner(opts ...ROption) *RealSchemaRunner { + r := &RealSchemaRunner{} + for _, o := range opts { + o(r) + } + return r +} + +// ROption configures the SchemaRunner. +type ROption func(*RealSchemaRunner) + +// WithImageConfig adds image rewriting rules to the SchemaRunner. +func WithImageConfig(cfgs []pkgv1beta1.ImageConfig) ROption { + return func(r *RealSchemaRunner) { + r.configStore = clixpkg.NewStaticImageConfigStore(cfgs) + } +} + +// GenerateOptions holds optional parameters for Generate. +type GenerateOptions struct { + CopyToPath string + CopyFromPath string + WorkDirectory string +} + +// Option is a function that modifies GenerateOptions. +type Option func(*GenerateOptions) + +// WithCopyToPath sets the CopyToPath option. +func WithCopyToPath(path string) Option { + return func(o *GenerateOptions) { + o.CopyToPath = path + } +} + +// WithCopyFromPath sets the CopyFromPath option. +func WithCopyFromPath(path string) Option { + return func(o *GenerateOptions) { + o.CopyFromPath = path + } +} + +// WithWorkDirectory sets the WorkDirectory option. +func WithWorkDirectory(dir string) Option { + return func(o *GenerateOptions) { + o.WorkDirectory = dir + } +} + +// DefaultGenerateOptions provides default values. +func DefaultGenerateOptions() GenerateOptions { + return GenerateOptions{ + CopyToPath: "/data/input", + CopyFromPath: "/data/input", + WorkDirectory: "/data/input", + } +} + +// Generate runs the containerized language tool for schema generation. +func (r RealSchemaRunner) Generate(ctx context.Context, fromFS afero.Fs, baseFolder, basePath, imageName string, command []string, options ...Option) error { + if err := docker.Check(ctx); err != nil { + return errors.Wrap(err, "failed to connect to Docker; schema generation requires a Docker-compatible container runtime") + } + + _, rewritten, err := r.configStore.RewritePath(ctx, imageName) + if err != nil { + return errors.Wrap(err, "failed to rewrite image ref") + } + if rewritten != "" { + imageName = rewritten + } + + o := DefaultGenerateOptions() + for _, opt := range options { + opt(&o) + } + + var opts []filesystem.FSToTarOption + if basePath != "" { + opts = append(opts, filesystem.WithSymlinkBasePath(basePath)) + } + tarBuffer, err := filesystem.FSToTar(fromFS, baseFolder, opts...) + if err != nil { + return errors.Wrapf(err, "failed to create tar from fs") + } + + var envVars []string + for _, e := range os.Environ() { + if strings.HasPrefix(e, "CROSSPLANE_") { + envVars = append(envVars, e) + } + } + + cid, err := docker.StartContainer(ctx, "", imageName, + docker.StartWithCopyFiles(tarBuffer, o.CopyToPath), + docker.StartWithCommand(command), + docker.StartWithEnv(envVars...), + docker.StartWithWorkingDirectory(o.WorkDirectory), + ) + if err != nil { + return err + } + + defer func() { + _ = docker.StopContainerByID(ctx, cid) + }() + + if err := docker.WaitForContainerByID(ctx, cid); err != nil { + return err + } + + return errors.Wrapf( + docker.CopyFromContainer(ctx, cid, o.CopyFromPath, fromFS), + "failed to copy tar from container", + ) +} diff --git a/internal/terminal/spinner.go b/internal/terminal/spinner.go new file mode 100644 index 0000000..877285b --- /dev/null +++ b/internal/terminal/spinner.go @@ -0,0 +1,447 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package terminal contains utilities for terminal interaction. +package terminal + +import ( + "fmt" + "io" + "os" + "os/signal" + "strings" + "sync" + "syscall" + "time" + + bspinner "github.com/charmbracelet/bubbles/spinner" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + + "github.com/crossplane/cli/v2/internal/async" +) + +var ( + // Crossplane teal for dark backgrounds, dark blue for light backgrounds. + + //nolint:gochecknoglobals // This is effectively a const. + accentColor = lipgloss.AdaptiveColor{Dark: "#35D0BA", Light: "#183D54"} + //nolint:gochecknoglobals // This is effectively a const. + accentStyle = lipgloss.NewStyle().Foreground(accentColor) +) + +// SpinnerPrinter prints spinners to the console. +type SpinnerPrinter interface { + // NewSuccessSpinner returns a new success spinner. + NewSuccessSpinner(msg string) *SuccessSpinner + + // WrapWithSuccessSpinner adds spinners around message and run function. + WrapWithSuccessSpinner(msg string, f func() error) error + + // WrapAsyncWithSuccessSpinners runs a given function in a separate + // goroutine, consuming events from its event channel and using them to + // display a set of spinners on the terminal. One spinner will be generated + // for each unique event text received. A success/failure indicator will be + // displayed when each event completes. + WrapAsyncWithSuccessSpinners(f func(ch async.EventChannel) error) error +} + +type defaultSpinnerPrinter struct { + pretty bool + out io.Writer +} + +// NewSpinnerPrinter returns a new SpinnerPrinter. If pretty is true, animated +// spinners will be used; otherwise plain text output will be used. +func NewSpinnerPrinter(out io.Writer, pretty bool) SpinnerPrinter { + return &defaultSpinnerPrinter{ + pretty: pretty, + out: out, + } +} + +func (p *defaultSpinnerPrinter) NewSuccessSpinner(msg string) *SuccessSpinner { + return newSuccessSpinner(p.out, msg) +} + +func (p *defaultSpinnerPrinter) WrapWithSuccessSpinner(msg string, f func() error) error { + if p.pretty { + return p.wrapPretty(msg, f) + } + return p.wrapPlain(msg, f) +} + +func (p *defaultSpinnerPrinter) wrapPretty(msg string, f func() error) error { + sp := newSuccessSpinner(p.out, msg) + sp.Start() + + err := f() + + if err != nil { + sp.Fail() + } else { + sp.Success() + } + + return err +} + +func (p *defaultSpinnerPrinter) wrapPlain(msg string, f func() error) error { + _, _ = fmt.Fprintln(p.out, msg+"...") + + err := f() + + ind := "✓" + if err != nil { + ind = "✗" + } + _, _ = fmt.Fprintf(p.out, "%s %s\n", ind, msg) + + return err +} + +func (p *defaultSpinnerPrinter) WrapAsyncWithSuccessSpinners(fn func(ch async.EventChannel) error) error { + if p.pretty { + return p.asyncPretty(fn) + } + + return p.asyncPlain(fn) +} + +func (p *defaultSpinnerPrinter) asyncPretty(fn func(ch async.EventChannel) error) error { + var ( + updateChan = make(async.EventChannel, 10) + doneChan = make(chan error, 1) + ) + + go func() { + err := fn(updateChan) + close(updateChan) + doneChan <- err + }() + multi := &MultiSpinner{ + out: p.out, + } + multi.Start() + + for update := range updateChan { + switch update.Status { + case async.EventStatusStarted: + multi.Add(update.Text) + case async.EventStatusSuccess: + multi.Success(update.Text) + case async.EventStatusFailure: + multi.Fail(update.Text) + } + } + err := <-doneChan + + multi.Stop() + return err +} + +func (p *defaultSpinnerPrinter) asyncPlain(fn func(ch async.EventChannel) error) error { + var ( + updateChan = make(async.EventChannel, 10) + doneChan = make(chan error, 1) + ) + + go func() { + err := fn(updateChan) + close(updateChan) + doneChan <- err + }() + + statusMap := make(map[string]string) + printed := make(map[string]bool) + + for update := range updateChan { + prevStatus := statusMap[update.Text] + switch update.Status { + case async.EventStatusStarted: + if !printed[update.Text] { + _, _ = fmt.Fprintln(p.out, update.Text+"...") + printed[update.Text] = true + statusMap[update.Text] = "started" + } + case async.EventStatusSuccess: + if prevStatus != "success" { + _, _ = fmt.Fprintln(p.out, "✓ "+update.Text) + statusMap[update.Text] = "success" + } + case async.EventStatusFailure: + if prevStatus != "failure" { + _, _ = fmt.Fprintln(p.out, "✗ "+update.Text) + statusMap[update.Text] = "failure" + } + } + } + + return <-doneChan +} + +// MultiSpinner is a collection of independent spinners that get displayed +// together. Spinners can be dynamically added. +type MultiSpinner struct { + spinners []*SuccessSpinner + mu sync.Mutex + program *tea.Program + out io.Writer +} + +type tickMsg time.Time + +func tick(t time.Time) tea.Msg { + return tickMsg(t) +} + +// Init satisfies tea.Model. +func (m *MultiSpinner) Init() tea.Cmd { + return tea.Tick(bspinner.Dot.FPS, tick) +} + +// Update satisfies tea.Model. +func (m *MultiSpinner) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := msg.(tickMsg); !ok { + return m, nil + } + + for _, sp := range m.spinners { + _, _ = sp.Update(msg) + } + + return m, tea.Tick(bspinner.Dot.FPS, tick) +} + +// View satisfies tea.Model. +func (m *MultiSpinner) View() string { + m.mu.Lock() + defer m.mu.Unlock() + + views := make([]string, len(m.spinners)) + for i, sp := range m.spinners { + views[i] = sp.View() + } + + return strings.Join(views, "\n") + "\n" +} + +// Add adds a spinner to the multi-spinner. +func (m *MultiSpinner) Add(title string) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, sp := range m.spinners { + if sp.title == title { + return + } + } + + m.spinners = append(m.spinners, newSuccessSpinner(m.out, title)) +} + +// Success marks an existing spinner in the multi-spinner as having succeeded. +func (m *MultiSpinner) Success(title string) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, sp := range m.spinners { + if sp.title != title { + continue + } + sp.setSuccess(true) + return + } +} + +// Fail marks an existing spinner in the multi-spinner as having failed. +func (m *MultiSpinner) Fail(title string) { + m.mu.Lock() + defer m.mu.Unlock() + + for _, sp := range m.spinners { + if sp.title != title { + continue + } + sp.setSuccess(false) + return + } +} + +// Start starts the spinners. +func (m *MultiSpinner) Start() { + m.program = tea.NewProgram(m, + tea.WithInput(nil), + tea.WithoutSignalHandler(), + tea.WithOutput(m.out), + ) + + go runProgramWithSignalHandler(m.program) +} + +func runProgramWithSignalHandler(p *tea.Program) { + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + defer func() { + signal.Stop(sigCh) + close(sigCh) + }() + go func() { + _, ok := <-sigCh + if ok { + _ = p.ReleaseTerminal() + os.Exit(130) + } + }() + + _, _ = p.Run() +} + +// Stop stops the spinners. +func (m *MultiSpinner) Stop() { + if m.program == nil { + return + } + + m.program.Send(tick(time.Now())) + m.program.Quit() + m.program.Wait() +} + +// SuccessSpinner is a spinner that can be marked as successful or failed and +// updates its view accordingly. It is used by MultiSpinner, but can also be +// used as a standalone spinner. +type SuccessSpinner struct { + title string + out io.Writer + + success *bool + spinner bspinner.Model + log []string + mu sync.Mutex + + program *tea.Program +} + +func newSuccessSpinner(w io.Writer, msg string) *SuccessSpinner { + return &SuccessSpinner{ + title: msg, + out: w, + spinner: bspinner.New( + bspinner.WithSpinner(bspinner.Dot), + bspinner.WithStyle(accentStyle), + ), + } +} + +// Init satisfies tea.Model. +func (ss *SuccessSpinner) Init() tea.Cmd { + return tea.Tick(bspinner.Dot.FPS, tick) +} + +// Update satisfies tea.Model. +func (ss *SuccessSpinner) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + ss.mu.Lock() + defer ss.mu.Unlock() + + if _, ok := msg.(tickMsg); !ok { + return ss, nil + } + ss.spinner, _ = ss.spinner.Update(ss.spinner.Tick()) + + return ss, tea.Tick(bspinner.Dot.FPS, tick) +} + +// View satisfies tea.Model. +func (ss *SuccessSpinner) View() string { + ss.mu.Lock() + defer ss.mu.Unlock() + + ind := ss.spinner.View() + if ss.success != nil { + ind = accentStyle.Render("✓") + if !*ss.success { + ind = accentStyle.Render("✗") + } + } + + view := fmt.Sprintf("%s %s", ind, ss.title) + if len(ss.log) > 0 { + view += "\n" + strings.Join(ss.log, "\n") + "\n" + } + + return view +} + +// UpdateText updates the spinner's text. +func (ss *SuccessSpinner) UpdateText(msg string) { + ss.mu.Lock() + defer ss.mu.Unlock() + + ss.title = msg +} + +// Success marks the spinner as having succeeded. +func (ss *SuccessSpinner) Success() { + ss.setSuccess(true) + ss.stop() +} + +// Fail marks the spinner as having failed. +func (ss *SuccessSpinner) Fail() { + ss.setSuccess(false) + ss.stop() +} + +func (ss *SuccessSpinner) setSuccess(v bool) { + ss.mu.Lock() + defer ss.mu.Unlock() + ss.success = &v +} + +// Logf adds a formatted message to the log printed under the spinner. +func (ss *SuccessSpinner) Logf(format string, args ...any) { + ss.mu.Lock() + defer ss.mu.Unlock() + + ss.log = append(ss.log, fmt.Sprintf("ℹ️ "+format, args...)) +} + +// Start starts the spinner. +func (ss *SuccessSpinner) Start() { + ss.program = tea.NewProgram(ss, + tea.WithOutput(ss.out), + tea.WithInput(nil), + tea.WithoutSignalHandler(), + ) + + go runProgramWithSignalHandler(ss.program) +} + +func (ss *SuccessSpinner) stop() { + if ss.program == nil { + return + } + + ss.program.Send(tick(time.Now())) + ss.program.Quit() + ss.program.Wait() + + _, _ = fmt.Fprintln(ss.out, ss.View()) +} diff --git a/internal/xpkg/client.go b/internal/xpkg/client.go new file mode 100644 index 0000000..62faf50 --- /dev/null +++ b/internal/xpkg/client.go @@ -0,0 +1,102 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "github.com/spf13/afero" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client/fake" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/signature" + + pkgv1beta1 "github.com/crossplane/crossplane/apis/v2/pkg/v1beta1" +) + +type options struct { + cacheFs afero.Fs + cacheDir string + imageConfigs []pkgv1beta1.ImageConfig +} + +// ClientOption configures a new client. +type ClientOption func(*options) + +// WithCacheDir configures the cache filesystem and directory for the client. If +// not provided, a non-caching client will be returned. +func WithCacheDir(fs afero.Fs, path string) ClientOption { + return func(o *options) { + o.cacheFs = fs + o.cacheDir = path + } +} + +// WithImageConfigs injects image configs for the client. +func WithImageConfigs(ics []pkgv1beta1.ImageConfig) ClientOption { + return func(o *options) { + o.imageConfigs = ics + } +} + +// NewClient assembles an xpkg.Client. +func NewClient(fetcher xpkg.Fetcher, opts ...ClientOption) (xpkg.Client, error) { + o := &options{} + for _, opt := range opts { + opt(o) + } + + metaScheme, err := xpkg.BuildMetaScheme() + if err != nil { + return nil, errors.Wrap(err, "cannot build package meta scheme") + } + objScheme, err := xpkg.BuildObjectScheme() + if err != nil { + return nil, errors.Wrap(err, "cannot build package object scheme") + } + + var cache xpkg.PackageCache = xpkg.NewNopCache() + if o.cacheDir != "" { + if err := o.cacheFs.MkdirAll(o.cacheDir, 0o755); err != nil { + return nil, errors.Wrapf(err, "cannot create xpkg cache directory %s", o.cacheDir) + } + cache = xpkg.NewFsPackageCache(o.cacheDir, o.cacheFs) + } + + client := xpkg.NewCachedClient( + fetcher, + parser.New(metaScheme, objScheme), + cache, + NewStaticImageConfigStore(o.imageConfigs), + signature.NopValidator{}, + ) + + return client, nil +} + +// NewStaticImageConfigStore returns an xpkg.ConfigStore that uses the given set +// of ImageConfigs. +func NewStaticImageConfigStore(imageConfigs []pkgv1beta1.ImageConfig) xpkg.ConfigStore { + objs := make([]runtime.Object, len(imageConfigs)) + for i := range imageConfigs { + objs[i] = &imageConfigs[i] + } + configClient := fake.NewFakeClient(objs...) + _ = pkgv1beta1.AddToScheme(configClient.Scheme()) + return xpkg.NewImageConfigStore(configClient, "") +} diff --git a/internal/xpkg/doc.go b/internal/xpkg/doc.go new file mode 100644 index 0000000..9405348 --- /dev/null +++ b/internal/xpkg/doc.go @@ -0,0 +1,19 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package xpkg contains CLI-specific functionality for working with Crossplane +// packages. +package xpkg diff --git a/internal/xpkg/fetcher.go b/internal/xpkg/fetcher.go new file mode 100644 index 0000000..cacf0f6 --- /dev/null +++ b/internal/xpkg/fetcher.go @@ -0,0 +1,106 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "net/http" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + v1 "github.com/google/go-containerregistry/pkg/v1" + "github.com/google/go-containerregistry/pkg/v1/remote" +) + +// RemoteFetcher implements a local (non-Kubernetes) xpkg.Fetcher. Pull secret +// arguments are accepted but ignored since there is no Kubernetes API to +// resolve them against in the CLI context. +type RemoteFetcher struct { + keychain authn.Keychain + transport http.RoundTripper + userAgent string +} + +// RemoteFetcherOption configures a RemoteFetcher. +type RemoteFetcherOption func(*RemoteFetcher) + +// WithKeychain sets the authn.Keychain used to authenticate registry +// requests. Defaults to authn.DefaultKeychain. +func WithKeychain(k authn.Keychain) RemoteFetcherOption { + return func(f *RemoteFetcher) { f.keychain = k } +} + +// WithUserAgent sets the User-Agent header sent on registry requests. +func WithUserAgent(ua string) RemoteFetcherOption { + return func(f *RemoteFetcher) { f.userAgent = ua } +} + +// WithTransport sets the http.RoundTripper used for registry requests. +// Defaults to remote.DefaultTransport. +func WithTransport(t http.RoundTripper) RemoteFetcherOption { + return func(f *RemoteFetcher) { f.transport = t } +} + +// NewRemoteFetcher returns a RemoteFetcher with the given options applied. +func NewRemoteFetcher(opts ...RemoteFetcherOption) *RemoteFetcher { + f := &RemoteFetcher{ + keychain: authn.DefaultKeychain, + transport: remote.DefaultTransport, + } + for _, o := range opts { + o(f) + } + return f +} + +// Fetch retrieves a package image from the registry. +func (f *RemoteFetcher) Fetch(ctx context.Context, ref name.Reference, _ ...string) (v1.Image, error) { + return remote.Image(ref, f.commonOpts(ctx)...) +} + +// Head retrieves an image descriptor, falling back to a GET if the registry +// rejects HEAD. +func (f *RemoteFetcher) Head(ctx context.Context, ref name.Reference, _ ...string) (*v1.Descriptor, error) { + d, err := remote.Head(ref, f.commonOpts(ctx)...) + if err == nil && d != nil { + return d, nil + } + + rd, gerr := remote.Get(ref, f.commonOpts(ctx)...) + if gerr != nil { + if err != nil { + return nil, err + } + return nil, gerr + } + + return &rd.Descriptor, nil +} + +// Tags lists tags for a package source. +func (f *RemoteFetcher) Tags(ctx context.Context, ref name.Reference, _ ...string) ([]string, error) { + return remote.List(ref.Context(), f.commonOpts(ctx)...) +} + +func (f *RemoteFetcher) commonOpts(ctx context.Context) []remote.Option { + return []remote.Option{ + remote.WithAuthFromKeychain(f.keychain), + remote.WithTransport(f.transport), + remote.WithContext(ctx), + remote.WithUserAgent(f.userAgent), + } +} diff --git a/internal/xpkg/metadata.go b/internal/xpkg/metadata.go new file mode 100644 index 0000000..c42b322 --- /dev/null +++ b/internal/xpkg/metadata.go @@ -0,0 +1,63 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "fmt" + + "github.com/spf13/afero" + apiextv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + apiextv1beta1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1beta1" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/yaml" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg/parser" +) + +// CRDFilesystem writes each CRD object in the package to a separate +// YAML file in an in-memory filesystem. Files are named +// ..yaml so the schema generator sees per-CRD inputs. +// Non-CRD objects in the package are skipped. +func CRDFilesystem(pkg *parser.Package) (afero.Fs, error) { + fs := afero.NewMemMapFs() + for _, obj := range pkg.GetObjects() { + name, ok := crdFilename(obj) + if !ok { + continue + } + bs, err := yaml.Marshal(obj) + if err != nil { + return nil, errors.Wrapf(err, "cannot marshal CRD %s", name) + } + if err := afero.WriteFile(fs, name, bs, 0o644); err != nil { + return nil, errors.Wrapf(err, "cannot write CRD %s", name) + } + } + return fs, nil +} + +func crdFilename(obj runtime.Object) (string, bool) { + switch c := obj.(type) { + case *apiextv1.CustomResourceDefinition: + return fmt.Sprintf("%s.%s.yaml", c.Spec.Names.Plural, c.Spec.Group), true + case *apiextv1beta1.CustomResourceDefinition: + return fmt.Sprintf("%s.%s.yaml", c.Spec.Names.Plural, c.Spec.Group), true + default: + return "", false + } +} diff --git a/internal/xpkg/resolver.go b/internal/xpkg/resolver.go new file mode 100644 index 0000000..0dbf666 --- /dev/null +++ b/internal/xpkg/resolver.go @@ -0,0 +1,117 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "sort" + "strings" + + "github.com/Masterminds/semver/v3" + "github.com/google/go-containerregistry/pkg/name" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" +) + +// Resolver translates a CLI-style package reference into a fully qualified OCI +// ref, returning the resolved semantic version or tag where applicable. +// +// It handles four shapes: +// +// - pkg@digest → returned unchanged, version="" +// - pkg: → returned unchanged, version= +// - pkg: → tags listed via ListVersions, highest match wins +// +// Opaque (non-semver, non-constraint) tags are returned unchanged with +// version=tag. +type Resolver struct { + client xpkg.Client +} + +// NewResolver returns a Resolver backed by client. +func NewResolver(client xpkg.Client) *Resolver { + return &Resolver{client: client} +} + +// Resolve returns the resolved reference and the exact version tag extracted +// from it (empty for digest refs and bare sources). +func (r *Resolver) Resolve(ctx context.Context, ref string) (name.Reference, string, error) { + // If the ref is specified by digest it doesn't need to be resolved. Return + // it verbatim. + if dgst, err := name.NewDigest(ref, name.StrictValidation); err == nil { + return dgst, "", nil + } + + // The registry part of a ref can contain a colon (for a port number), so + // look for the *last* colon, which should separate the repository from the + // tag. + parts := strings.Split(ref, ":") + if len(parts) < 2 { + return nil, "", errors.Errorf("ref %s is missing a version", ref) + } + + tagOrConstraint := parts[len(parts)-1] + repo, err := name.NewRepository(strings.Join(parts[:len(parts)-1], ":"), name.StrictValidation) + if err != nil { + return nil, "", errors.Wrapf(err, "ref %s has an invalid repository", ref) + } + + sc, err := semver.NewConstraint(tagOrConstraint) + if err != nil { + // Not a constraint - treat as an opaque tag. + tag, err := name.NewTag(ref, name.StrictValidation) + if err != nil { + return nil, "", errors.Wrapf(err, "invalid ref %s", ref) + } + return tag, tag.TagStr(), nil + } + + tags, err := r.client.ListVersions(ctx, repo.Name()) + if err != nil { + return nil, "", errors.Wrapf(err, "cannot list versions for %s", repo) + } + v := highestSatisfying(tags, sc) + if v == "" { + return nil, "", errors.Errorf("cannot find version to satisfy constraint %s", sc) + } + + return repo.Tag(v), v, nil +} + +// highestSatisfying returns the original-form string of the highest +// version in tags that satisfies c, or "" if none matches. +func highestSatisfying(tags []string, c *semver.Constraints) string { + vs := make(semver.Collection, 0, len(tags)) + for _, t := range tags { + v, err := semver.NewVersion(t) + if err != nil { + continue + } + vs = append(vs, v) + } + + sort.Sort(sort.Reverse(vs)) + + for _, v := range vs { + if c.Check(v) { + return v.Original() + } + } + + return "" +} diff --git a/internal/xpkg/resolver_test.go b/internal/xpkg/resolver_test.go new file mode 100644 index 0000000..bccb90b --- /dev/null +++ b/internal/xpkg/resolver_test.go @@ -0,0 +1,151 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xpkg + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/google/go-containerregistry/pkg/name" + + "github.com/crossplane/crossplane-runtime/v2/pkg/xpkg" +) + +// fakeClient is a fake xpkg.Client whose ListVersions returns a fixed +// list of tags and whose Get is unused by the resolver. +type fakeClient struct { + tags []string + listErr error +} + +func (f *fakeClient) Get(_ context.Context, _ string, _ ...xpkg.GetOption) (*xpkg.Package, error) { + return nil, nil +} + +func (f *fakeClient) ListVersions(_ context.Context, _ string, _ ...xpkg.GetOption) ([]string, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.tags, nil +} + +func TestResolver_Resolve(t *testing.T) { + tags := []string{"v1.0.0", "v1.1.0", "v2.0.0", "latest", "invalid"} + + type args struct { + ref string + tags []string + } + + type want struct { + ref name.Reference + version string + err error + } + + tests := map[string]struct { + args args + want want + }{ + "DigestRef": { + args: args{ + ref: "pkg.example/foo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03", + }, + want: want{ + ref: name.MustParseReference("pkg.example/foo@sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be03"), + }, + }, + "ExactSemver": { + args: args{ + ref: "pkg.example/foo:v1.0.0", + tags: tags, + }, + want: want{ + ref: name.MustParseReference("pkg.example/foo:v1.0.0"), + version: "v1.0.0", + }, + }, + "OpaqueTag": { + args: args{ + ref: "pkg.example/foo:latest", + }, + want: want{ + ref: name.MustParseReference("pkg.example/foo:latest"), + version: "latest", + }, + }, + "ConstraintRange": { + args: args{ + ref: "pkg.example/foo:>=v1.0.0, =v3.0.0", + tags: tags, + }, + want: want{ + err: cmpopts.AnyError, + }, + }, + } + + for tname, tc := range tests { + t.Run(tname, func(t *testing.T) { + r := NewResolver(&fakeClient{tags: tc.args.tags}) + gotRef, gotVer, err := r.Resolve(context.Background(), tc.args.ref) + if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { + t.Errorf("Resolve(...), -want error, +got error:\n%s", diff) + } + + // The name types have some embedded unexported fields that aren't + // comparable, so we have to ignore them. + ignoreUnexported := cmpopts.IgnoreUnexported(name.Registry{}, name.Repository{}, name.Tag{}, name.Digest{}) + if diff := cmp.Diff(tc.want.ref, gotRef, ignoreUnexported); diff != "" { + t.Errorf("Resolve(...), -want ref +got ref:\n%s", diff) + } + if diff := cmp.Diff(tc.want.version, gotVer); diff != "" { + t.Errorf("Resolve(...), -want verfsion +got versfion:\n%s", diff) + } + }) + } +} diff --git a/internal/xrd/infer.go b/internal/xrd/infer.go new file mode 100644 index 0000000..f14b964 --- /dev/null +++ b/internal/xrd/infer.go @@ -0,0 +1,132 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Package xrd contains utilities for working with CompositeResourceDefinitions. +package xrd + +import ( + "fmt" + "maps" + + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +// InferProperties infers JSON schema properties from a map of values. +func InferProperties(spec map[string]any) (map[string]extv1.JSONSchemaProps, error) { + properties := make(map[string]extv1.JSONSchemaProps) + + for key, value := range spec { + strKey := fmt.Sprintf("%v", key) + inferredProp, err := inferProperty(value) + if err != nil { + return nil, errors.Wrapf(err, "error inferring property for key '%s'", strKey) + } + properties[strKey] = inferredProp + } + + return properties, nil +} + +// inferArrayProperty handles array type inference with property merging for objects. +func inferArrayProperty(v []any) (extv1.JSONSchemaProps, error) { + if len(v) == 0 { + return extv1.JSONSchemaProps{ + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "object", + }, + }, + }, nil + } + + firstElemSchema, err := inferProperty(v[0]) + if err != nil { + return extv1.JSONSchemaProps{}, err + } + + mergedProperties := make(map[string]extv1.JSONSchemaProps) + if firstElemSchema.Type == "object" { + maps.Copy(mergedProperties, firstElemSchema.Properties) + } + + for _, elem := range v { + elemSchema, err := inferProperty(elem) + if err != nil { + return extv1.JSONSchemaProps{}, err + } + if elemSchema.Type != firstElemSchema.Type { + return extv1.JSONSchemaProps{}, errors.New("mixed types detected in array") + } + if elemSchema.Type == "object" { + maps.Copy(mergedProperties, elemSchema.Properties) + } + } + + resultSchema := firstElemSchema + if firstElemSchema.Type == "object" && len(mergedProperties) > 0 { + resultSchema.Properties = mergedProperties + } + + return extv1.JSONSchemaProps{ + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &resultSchema, + }, + }, nil +} + +func inferProperty(value any) (extv1.JSONSchemaProps, error) { + if value == nil { + return extv1.JSONSchemaProps{ + Type: "string", + }, nil + } + + switch v := value.(type) { + case string: + return extv1.JSONSchemaProps{ + Type: "string", + }, nil + case int, int32, int64: + return extv1.JSONSchemaProps{ + Type: "integer", + }, nil + case float32, float64: + return extv1.JSONSchemaProps{ + Type: "number", + }, nil + case bool: + return extv1.JSONSchemaProps{ + Type: "boolean", + }, nil + case map[string]any: + inferredProps, err := InferProperties(v) + if err != nil { + return extv1.JSONSchemaProps{}, errors.Wrap(err, "error inferring properties for object") + } + return extv1.JSONSchemaProps{ + Type: "object", + Properties: inferredProps, + }, nil + case []any: + return inferArrayProperty(v) + default: + return extv1.JSONSchemaProps{}, errors.Errorf("unknown type: %T", value) + } +} diff --git a/internal/xrd/infer_test.go b/internal/xrd/infer_test.go new file mode 100644 index 0000000..900b8b0 --- /dev/null +++ b/internal/xrd/infer_test.go @@ -0,0 +1,247 @@ +/* +Copyright 2026 The Crossplane Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package xrd + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + extv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + + "github.com/crossplane/crossplane-runtime/v2/pkg/errors" +) + +func TestInferProperty(t *testing.T) { + type want struct { + output extv1.JSONSchemaProps + err error + } + + cases := map[string]struct { + input any + want want + }{ + "StringType": { + input: "hello", + want: want{ + output: extv1.JSONSchemaProps{Type: "string"}, + }, + }, + "IntegerType": { + input: 42, + want: want{ + output: extv1.JSONSchemaProps{Type: "integer"}, + }, + }, + "FloatType": { + input: 3.14, + want: want{ + output: extv1.JSONSchemaProps{Type: "number"}, + }, + }, + "BooleanType": { + input: true, + want: want{ + output: extv1.JSONSchemaProps{Type: "boolean"}, + }, + }, + "ObjectType": { + input: map[string]any{ + "key": "value", + }, + want: want{ + output: extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "key": {Type: "string"}, + }, + }, + }, + }, + "ArrayTypeWithElements": { + input: []any{"one", "two"}, + want: want{ + output: extv1.JSONSchemaProps{ + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{Type: "string"}, + }, + }, + }, + }, + "ArrayTypeEmpty": { + input: []any{}, + want: want{ + output: extv1.JSONSchemaProps{ + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{Type: "object"}, + }, + }, + }, + }, + "NilValue": { + input: nil, + want: want{ + output: extv1.JSONSchemaProps{Type: "string"}, + }, + }, + "ArrayWithMixedTypes": { + input: []any{1, "2", true}, + want: want{ + output: extv1.JSONSchemaProps{}, + err: errors.New("mixed types detected in array"), + }, + }, + "ArrayOfObjectsWithOptionalFields": { + input: []any{ + map[string]any{ + "name": "aks-subnet", + "cidr": "10.0.1.0/24", + "serviceEndpoints": []any{"Microsoft.ContainerRegistry"}, + }, + map[string]any{ + "name": "database-subnet", + "cidr": "10.0.2.0/24", + "delegation": "Microsoft.DBforMySQL/flexibleServers", + "serviceEndpoints": []any{"Microsoft.Storage"}, + }, + }, + want: want{ + output: extv1.JSONSchemaProps{ + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{ + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "name": {Type: "string"}, + "cidr": {Type: "string"}, + "serviceEndpoints": { + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{Type: "string"}, + }, + }, + "delegation": {Type: "string"}, + }, + }, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := inferProperty(tc.input) + + if err != nil || tc.want.err != nil { + if err == nil || tc.want.err == nil || err.Error() != tc.want.err.Error() { + t.Errorf("inferProperty() error = %v, wantErr %v", err, tc.want.err) + } + return + } + + if diff := cmp.Diff(got, tc.want.output); diff != "" { + t.Errorf("inferProperty() -got, +want:\n%s", diff) + } + }) + } +} + +func TestInferProperties(t *testing.T) { + type want struct { + output map[string]extv1.JSONSchemaProps + err error + } + + cases := map[string]struct { + input map[string]any + want want + }{ + "SimpleObject": { + input: map[string]any{ + "key1": "value1", + "key2": 42, + }, + want: want{ + output: map[string]extv1.JSONSchemaProps{ + "key1": {Type: "string"}, + "key2": {Type: "integer"}, + }, + }, + }, + "NestedObject": { + input: map[string]any{ + "nested": map[string]any{ + "key": true, + }, + }, + want: want{ + output: map[string]extv1.JSONSchemaProps{ + "nested": { + Type: "object", + Properties: map[string]extv1.JSONSchemaProps{ + "key": {Type: "boolean"}, + }, + }, + }, + }, + }, + "ArrayInObject": { + input: map[string]any{ + "array": []any{"a", "b"}, + }, + want: want{ + output: map[string]extv1.JSONSchemaProps{ + "array": { + Type: "array", + Items: &extv1.JSONSchemaPropsOrArray{ + Schema: &extv1.JSONSchemaProps{Type: "string"}, + }, + }, + }, + }, + }, + "ObjectWithMixedArray": { + input: map[string]any{ + "array": []any{1, "2"}, + }, + want: want{ + output: nil, + err: errors.New("error inferring property for key 'array': mixed types detected in array"), + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + got, err := InferProperties(tc.input) + + if err != nil || tc.want.err != nil { + if err == nil || tc.want.err == nil || err.Error() != tc.want.err.Error() { + t.Errorf("InferProperties() error = %v, wantErr %v", err, tc.want.err) + } + return + } + + if diff := cmp.Diff(got, tc.want.output); diff != "" { + t.Errorf("InferProperties() -got, +want:\n%s", diff) + } + }) + } +}