diff --git a/cmd/crd-puller/.gitignore b/cmd/crd-puller/.gitignore deleted file mode 100644 index d2cedd7..0000000 --- a/cmd/crd-puller/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -/crd-puller -*.yaml diff --git a/cmd/crd-puller/README.md b/cmd/crd-puller/README.md deleted file mode 100644 index ef13c76..0000000 --- a/cmd/crd-puller/README.md +++ /dev/null @@ -1,18 +0,0 @@ -# CRD Puller - -The `crd-puller` can be used for testing and development in order to export a -CustomResourceDefinition for any Group/Kind (GK) in a Kubernetes cluster. - -The main difference between this and kcp's own `crd-puller` is that this one -works based on GKs and not resources (i.e. on `apps/Deployment` instead of -`apps.deployments`). This is more useful since a PublishedResource publishes a -specific Kind and version. Also, this puller pulls all available versions, not -just the preferred version. - -## Usage - -```shell -export KUBECONFIG=/path/to/kubeconfig - -./crd-puller Deployment.apps.k8s.io -``` diff --git a/cmd/crd-puller/main.go b/cmd/crd-puller/main.go deleted file mode 100644 index 2455849..0000000 --- a/cmd/crd-puller/main.go +++ /dev/null @@ -1,78 +0,0 @@ -/* -Copyright 2025 The KCP Authors. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -package main - -import ( - "context" - "fmt" - "log" - - "github.com/spf13/pflag" - - "github.com/kcp-dev/api-syncagent/internal/discovery" - - "k8s.io/apimachinery/pkg/runtime/schema" - "k8s.io/client-go/tools/clientcmd" - "sigs.k8s.io/yaml" -) - -var ( - kubeconfigPath string -) - -func main() { - ctx := context.Background() - - pflag.StringVar(&kubeconfigPath, "kubeconfig", "", "Path to the kubeconfig file to use (defaults to $KUBECONFIG)") - pflag.Parse() - - if pflag.NArg() == 0 { - log.Fatal("No argument given. Please specify a GroupKind in the form 'Kind.apigroup.com' (case-sensitive) to pull.") - } - - gk := schema.ParseGroupKind(pflag.Arg(0)) - - loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() - loadingRules.ExplicitPath = kubeconfigPath - - startingConfig, err := loadingRules.GetStartingConfig() - if err != nil { - log.Fatalf("Failed to load Kubernetes configuration: %v.", err) - } - - config, err := clientcmd.NewDefaultClientConfig(*startingConfig, nil).ClientConfig() - if err != nil { - log.Fatalf("Failed to load Kubernetes configuration: %v.", err) - } - - discoveryClient, err := discovery.NewClient(config) - if err != nil { - log.Fatalf("Failed to create discovery client: %v.", err) - } - - crd, err := discoveryClient.RetrieveCRD(ctx, gk) - if err != nil { - log.Fatalf("Failed to pull CRD: %v.", err) - } - - enc, err := yaml.Marshal(crd) - if err != nil { - log.Fatalf("Failed to encode CRD as YAML: %v.", err) - } - - fmt.Println(string(enc)) -} diff --git a/cmd/pubres-toolkit/.gitignore b/cmd/pubres-toolkit/.gitignore new file mode 100644 index 0000000..3cebe90 --- /dev/null +++ b/cmd/pubres-toolkit/.gitignore @@ -0,0 +1,2 @@ +/pubres-toolkit +*.yaml diff --git a/cmd/pubres-toolkit/README.md b/cmd/pubres-toolkit/README.md new file mode 100644 index 0000000..f76f26b --- /dev/null +++ b/cmd/pubres-toolkit/README.md @@ -0,0 +1,4 @@ +# PublishedResource Toolkit + +The `pubres-toolkit` can be used for testing and development in order to +simulate how the agent would process a PublishedResource. diff --git a/cmd/pubres-toolkit/cmd/crd/command.go b/cmd/pubres-toolkit/cmd/crd/command.go new file mode 100644 index 0000000..e901330 --- /dev/null +++ b/cmd/pubres-toolkit/cmd/crd/command.go @@ -0,0 +1,128 @@ +/* +Copyright 2021 The KCP 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 ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/kcp-dev/api-syncagent/cmd/pubres-toolkit/util" + "github.com/kcp-dev/api-syncagent/internal/discovery" + "github.com/kcp-dev/api-syncagent/internal/kcp" + "github.com/kcp-dev/api-syncagent/internal/projection" + + "sigs.k8s.io/yaml" +) + +func NewCommand(ctx context.Context) *cobra.Command { + opts := newDefaultOptions() + + cmd := &cobra.Command{ + Use: "crd [--no-projection] [--ars] pubres.yaml", + Short: "Outputs a CRD based on a PublishedResource", + RunE: func(c *cobra.Command, args []string) error { + if err := opts.Complete(); err != nil { + return err + } + + if err := opts.Validate(); err != nil { + return err + } + + if len(args) != 1 { + return errors.New("expected exactly one argument") + } + + return run(ctx, opts, args[0]) + }, + } + + opts.AddFlags(cmd.Flags()) + + return cmd +} + +func run(ctx context.Context, o *options, pubResFile string) error { + // load the given PubRes + pubResource, err := util.ReadPublishedResourceFile(pubResFile) + if err != nil { + return fmt.Errorf("failed to read %q: %w", pubResFile, err) + } + + // parse kubeconfig + kubeconfig, err := util.ReadKubeconfig(o.KubeconfigFile, o.Context) + if err != nil { + return fmt.Errorf("invalid kubeconfig: %w", err) + } + + clientConfig, err := kubeconfig.ClientConfig() + if err != nil { + return err + } + + client, err := discovery.NewClient(clientConfig) + if err != nil { + return fmt.Errorf("failed to create discovery client: %w", err) + } + + // find the CRD that the PublishedResource is referring to + localGK := projection.PublishedResourceSourceGK(pubResource) + + // fetch the original, full CRD from the cluster + crd, err := client.RetrieveCRD(ctx, localGK) + if err != nil { + return fmt.Errorf("failed to discover CRD defined in PublishedResource: %w", err) + } + + // project the CRD (i.e. strip unwanted versions, rename values etc.) + if !o.NoProjection { + crd, err = projection.ProjectCRD(crd, pubResource) + if err != nil { + return fmt.Errorf("failed to apply projection rules: %w", err) + } + } + + // output the CRD right away if desired + if !o.APIResourceSchema { + enc, err := yaml.Marshal(crd) + if err != nil { + return fmt.Errorf("failed to encode CRD as YAML: %w", err) + } + + fmt.Println(string(enc)) + return nil + } + + // convert to APIResourceSchema otherwise + arsName := kcp.GetAPIResourceSchemaName(crd) + ars, err := kcp.CreateAPIResourceSchema(crd, arsName, "pubres-toolkit") + if err != nil { + return fmt.Errorf("failed to create APIResourceSchema: %w", err) + } + + enc, err := yaml.Marshal(ars) + if err != nil { + return fmt.Errorf("failed to encode APIResourceSchema as YAML: %w", err) + } + + fmt.Println(string(enc)) + + return nil +} diff --git a/cmd/pubres-toolkit/cmd/crd/options.go b/cmd/pubres-toolkit/cmd/crd/options.go new file mode 100644 index 0000000..a5cae9e --- /dev/null +++ b/cmd/pubres-toolkit/cmd/crd/options.go @@ -0,0 +1,66 @@ +/* +Copyright 2022 The KCP 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 ( + "fmt" + "os" + + "github.com/spf13/pflag" + + utilerrors "k8s.io/apimachinery/pkg/util/errors" +) + +type options struct { + KubeconfigFile string + Context string + + NoProjection bool + APIResourceSchema bool +} + +func newDefaultOptions() *options { + return &options{} +} + +func (o *options) AddFlags(flags *pflag.FlagSet) { + flags.BoolVar(&o.NoProjection, "no-projection", o.NoProjection, "Disables projecting the GVK of the selected CRD") + flags.BoolVar(&o.APIResourceSchema, "ars", o.APIResourceSchema, "Outputs an APIResourceSchema instead of a CustomResourceDefinition") + + flags.StringVar(&o.Context, "context", o.Context, "Name of the context in the kubeconfig file to use") + flags.StringVar(&o.KubeconfigFile, "kubeconfig", o.KubeconfigFile, "The kubeconfig file of the cluster to read CRDs from (defaults to $KUBECONFIG).") +} + +func (o *options) Complete() error { + errs := []error{} + + if len(o.KubeconfigFile) == 0 { + o.KubeconfigFile = os.Getenv("KUBECONFIG") + } + + return utilerrors.NewAggregate(errs) +} + +func (o *options) Validate() error { + errs := []error{} + + if len(o.KubeconfigFile) == 0 { + errs = append(errs, fmt.Errorf("--kubeconfig or $KUBECONFIG are required for this command")) + } + + return utilerrors.NewAggregate(errs) +} diff --git a/cmd/pubres-toolkit/main.go b/cmd/pubres-toolkit/main.go new file mode 100644 index 0000000..8881d33 --- /dev/null +++ b/cmd/pubres-toolkit/main.go @@ -0,0 +1,49 @@ +/* +Copyright 2025 The KCP Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package main + +import ( + "context" + "os" + + "github.com/spf13/cobra" + + crdcommand "github.com/kcp-dev/api-syncagent/cmd/pubres-toolkit/cmd/crd" + + "k8s.io/component-base/cli" + "k8s.io/component-base/version" +) + +func main() { + ctx := context.Background() + cmd := &cobra.Command{ + Use: "pubres-toolkit", + Short: "Toolkit for PublishedResources", + SilenceUsage: true, + SilenceErrors: true, + } + + cmd.AddCommand(crdcommand.NewCommand(ctx)) + + if v := version.Get().String(); len(v) == 0 { + cmd.Version = "" + } else { + cmd.Version = v + } + + os.Exit(cli.Run(cmd)) +} diff --git a/cmd/pubres-toolkit/util/kubeconfig.go b/cmd/pubres-toolkit/util/kubeconfig.go new file mode 100644 index 0000000..47d4614 --- /dev/null +++ b/cmd/pubres-toolkit/util/kubeconfig.go @@ -0,0 +1,36 @@ +/* +Copyright 2025 The KCP 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 util + +import "k8s.io/client-go/tools/clientcmd" + +func ReadKubeconfig(filename, context string) (clientcmd.ClientConfig, error) { + loadingRules := clientcmd.NewDefaultClientConfigLoadingRules() + loadingRules.ExplicitPath = filename + + startingConfig, err := loadingRules.GetStartingConfig() + if err != nil { + return nil, err + } + + overrides := &clientcmd.ConfigOverrides{ + CurrentContext: context, + } + + clientConfig := clientcmd.NewDefaultClientConfig(*startingConfig, overrides) + return clientConfig, nil +} diff --git a/cmd/pubres-toolkit/util/pubres.go b/cmd/pubres-toolkit/util/pubres.go new file mode 100644 index 0000000..48a66c4 --- /dev/null +++ b/cmd/pubres-toolkit/util/pubres.go @@ -0,0 +1,47 @@ +/* +Copyright 2025 The KCP 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 util + +import ( + "os" + + syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/util/yaml" +) + +func ReadPublishedResourceFile(filename string) (*syncagentv1alpha1.PublishedResource, error) { + f, err := os.Open(filename) + if err != nil { + return nil, err + } + + u := &unstructured.Unstructured{} + dec := yaml.NewYAMLOrJSONDecoder(f, 1024) + if err := dec.Decode(u); err != nil { + return nil, err + } + + pr := &syncagentv1alpha1.PublishedResource{} + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(u.UnstructuredContent(), pr); err != nil { + return nil, err + } + + return pr, nil +} diff --git a/go.mod b/go.mod index 614fca9..6d011f1 100644 --- a/go.mod +++ b/go.mod @@ -24,6 +24,7 @@ require ( github.com/kcp-dev/logicalcluster/v3 v3.0.5 github.com/openshift-eng/openshift-goimports v0.0.0-20230304234052-c70783e636f2 github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 + github.com/spf13/cobra v1.9.1 github.com/spf13/pflag v1.0.6 github.com/tidwall/gjson v1.18.0 github.com/tidwall/sjson v1.2.5 @@ -35,6 +36,7 @@ require ( k8s.io/apiserver v0.31.6 k8s.io/client-go v0.31.6 k8s.io/code-generator v0.31.6 + k8s.io/component-base v0.31.6 k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 k8s.io/utils v0.0.0-20240711033017-18e509b52bc8 sigs.k8s.io/controller-runtime v0.18.3 @@ -110,7 +112,6 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.14.0 // indirect github.com/spf13/cast v1.7.1 // indirect - github.com/spf13/cobra v1.9.1 // indirect github.com/spf13/viper v1.20.1 // indirect github.com/stoewer/go-strcase v1.3.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect @@ -151,7 +152,6 @@ require ( gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect - k8s.io/component-base v0.31.6 // indirect k8s.io/gengo/v2 v2.0.0-20240826214909-a7b603a56eb7 // indirect k8s.io/klog/v2 v2.130.1 // indirect k8s.io/kubernetes v1.31.6 // indirect diff --git a/internal/controller/apiresourceschema/controller.go b/internal/controller/apiresourceschema/controller.go index 3655867..ebcbe75 100644 --- a/internal/controller/apiresourceschema/controller.go +++ b/internal/controller/apiresourceschema/controller.go @@ -25,8 +25,8 @@ import ( "go.uber.org/zap" "github.com/kcp-dev/api-syncagent/internal/controllerutil/predicate" - "github.com/kcp-dev/api-syncagent/internal/crypto" "github.com/kcp-dev/api-syncagent/internal/discovery" + "github.com/kcp-dev/api-syncagent/internal/kcp" "github.com/kcp-dev/api-syncagent/internal/projection" syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" @@ -166,7 +166,7 @@ func (r *Reconciler) reconcile(ctx context.Context, log *zap.SugaredLogger, pubR } // generate a unique name for this exact state of the CRD - arsName := r.getAPIResourceSchemaName(projectedCRD) + arsName := kcp.GetAPIResourceSchemaName(projectedCRD) // ensure ARS exists (don't try to reconcile it, it's basically entirely immutable) wsCtx := kontext.WithCluster(ctx, r.lcName) @@ -174,7 +174,14 @@ func (r *Reconciler) reconcile(ctx context.Context, log *zap.SugaredLogger, pubR err = r.kcpClient.Get(wsCtx, types.NamespacedName{Name: arsName}, ars, &ctrlruntimeclient.GetOptions{}) if apierrors.IsNotFound(err) { - if err := r.createAPIResourceSchema(wsCtx, log, projectedCRD, arsName); err != nil { + ars, err := kcp.CreateAPIResourceSchema(projectedCRD, arsName, r.agentName) + if err != nil { + return nil, fmt.Errorf("failed to create APIResourceSchema: %w", err) + } + + log.With("name", arsName).Info("Creating APIResourceSchema…") + + if err := r.kcpClient.Create(wsCtx, ars); err != nil { return nil, fmt.Errorf("failed to create APIResourceSchema: %w", err) } } else if err != nil { @@ -196,48 +203,3 @@ func (r *Reconciler) reconcile(ctx context.Context, log *zap.SugaredLogger, pubR return nil, nil } - -func (r *Reconciler) createAPIResourceSchema(ctx context.Context, log *zap.SugaredLogger, projectedCRD *apiextensionsv1.CustomResourceDefinition, arsName string) error { - // prefix is irrelevant as the name is overridden later - converted, err := kcpdevv1alpha1.CRDToAPIResourceSchema(projectedCRD, "irrelevant") - if err != nil { - return fmt.Errorf("failed to convert CRD: %w", err) - } - - ars := &kcpdevv1alpha1.APIResourceSchema{} - ars.Name = arsName - ars.Annotations = map[string]string{ - syncagentv1alpha1.SourceGenerationAnnotation: fmt.Sprintf("%d", projectedCRD.Generation), - syncagentv1alpha1.AgentNameAnnotation: r.agentName, - } - ars.Labels = map[string]string{ - syncagentv1alpha1.AgentNameLabel: r.agentName, - } - ars.Spec.Group = converted.Spec.Group - ars.Spec.Names = converted.Spec.Names - ars.Spec.Scope = converted.Spec.Scope - ars.Spec.Versions = converted.Spec.Versions - - if len(converted.Spec.Versions) > 1 { - ars.Spec.Conversion = &kcpdevv1alpha1.CustomResourceConversion{ - // as of kcp 0.27, there is no constant for this - Strategy: kcpdevv1alpha1.ConversionStrategyType("None"), - } - } - - log.With("name", arsName).Info("Creating APIResourceSchema…") - - return r.kcpClient.Create(ctx, ars) -} - -// getAPIResourceSchemaName generates the name for the ARS in kcp. Note that -// kcp requires, just like CRDs, that ARS are named following a specific pattern. -func (r *Reconciler) getAPIResourceSchemaName(crd *apiextensionsv1.CustomResourceDefinition) string { - crd = crd.DeepCopy() - crd.Spec.Conversion = nil - - checksum := crypto.Hash(crd.Spec) - - // include a leading "v" to prevent SHA-1 hashes with digits to break the name - return fmt.Sprintf("v%s.%s.%s", checksum[:8], crd.Spec.Names.Plural, crd.Spec.Group) -} diff --git a/internal/kcp/apiresourceschema.go b/internal/kcp/apiresourceschema.go new file mode 100644 index 0000000..465f080 --- /dev/null +++ b/internal/kcp/apiresourceschema.go @@ -0,0 +1,77 @@ +/* +Copyright 2025 The KCP 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 kcp + +import ( + "fmt" + + "github.com/kcp-dev/api-syncagent/internal/crypto" + syncagentv1alpha1 "github.com/kcp-dev/api-syncagent/sdk/apis/syncagent/v1alpha1" + + kcpdevv1alpha1 "github.com/kcp-dev/kcp/sdk/apis/apis/v1alpha1" + + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func CreateAPIResourceSchema(crd *apiextensionsv1.CustomResourceDefinition, name string, agentName string) (*kcpdevv1alpha1.APIResourceSchema, error) { + // prefix is irrelevant as the name is overridden later + converted, err := kcpdevv1alpha1.CRDToAPIResourceSchema(crd, "irrelevant") + if err != nil { + return nil, fmt.Errorf("failed to convert CRD: %w", err) + } + + ars := &kcpdevv1alpha1.APIResourceSchema{} + ars.TypeMeta = metav1.TypeMeta{ + APIVersion: kcpdevv1alpha1.SchemeGroupVersion.String(), + Kind: "APIResourceSchema", + } + + ars.Name = name + ars.Annotations = map[string]string{ + syncagentv1alpha1.SourceGenerationAnnotation: fmt.Sprintf("%d", crd.Generation), + syncagentv1alpha1.AgentNameAnnotation: agentName, + } + ars.Labels = map[string]string{ + syncagentv1alpha1.AgentNameLabel: agentName, + } + ars.Spec.Group = converted.Spec.Group + ars.Spec.Names = converted.Spec.Names + ars.Spec.Scope = converted.Spec.Scope + ars.Spec.Versions = converted.Spec.Versions + + if len(converted.Spec.Versions) > 1 { + ars.Spec.Conversion = &kcpdevv1alpha1.CustomResourceConversion{ + // as of kcp 0.27, there is no constant for this + Strategy: kcpdevv1alpha1.ConversionStrategyType("None"), + } + } + + return ars, nil +} + +// GetAPIResourceSchemaName generates the name for the ARS in kcp. Note that +// kcp requires, just like CRDs, that ARS are named following a specific pattern. +func GetAPIResourceSchemaName(crd *apiextensionsv1.CustomResourceDefinition) string { + crd = crd.DeepCopy() + crd.Spec.Conversion = nil + + checksum := crypto.Hash(crd.Spec) + + // include a leading "v" to prevent SHA-1 hashes with digits to break the name + return fmt.Sprintf("v%s.%s.%s", checksum[:8], crd.Spec.Names.Plural, crd.Spec.Group) +}