From 1ddbe68bc2c28c3990af8d8d22d6309731437130 Mon Sep 17 00:00:00 2001 From: Aleksei Sviridkin Date: Tue, 17 Feb 2026 23:23:10 +0300 Subject: [PATCH] feat(operator): add --install-crds flag with embedded CRD manifests Embed Package and PackageSource CRDs in the operator binary using Go embed, following the same pattern as --install-flux. The operator applies CRDs at startup using server-side apply, ensuring they are updated on every operator restart/upgrade. This addresses the CRD lifecycle concern: Helm crds/ directory handles initial install, while the operator manages updates on subsequent deployments. Co-Authored-By: Claude Signed-off-by: Aleksei Sviridkin --- cmd/cozystack-operator/main.go | 16 ++ internal/crdinstall/install.go | 144 ++++++++++ internal/crdinstall/manifests.embed.go | 50 ++++ internal/crdinstall/manifests/.gitattributes | 1 + .../manifests/cozystack.io_packages.yaml | 171 ++++++++++++ .../cozystack.io_packagesources.yaml | 250 ++++++++++++++++++ 6 files changed, 632 insertions(+) create mode 100644 internal/crdinstall/install.go create mode 100644 internal/crdinstall/manifests.embed.go create mode 100644 internal/crdinstall/manifests/.gitattributes create mode 100644 internal/crdinstall/manifests/cozystack.io_packages.yaml create mode 100644 internal/crdinstall/manifests/cozystack.io_packagesources.yaml diff --git a/cmd/cozystack-operator/main.go b/cmd/cozystack-operator/main.go index 188ce160..f365a076 100644 --- a/cmd/cozystack-operator/main.go +++ b/cmd/cozystack-operator/main.go @@ -50,6 +50,7 @@ import ( "sigs.k8s.io/controller-runtime/pkg/webhook" "github.com/cozystack/cozystack/internal/cozyvaluesreplicator" + "github.com/cozystack/cozystack/internal/crdinstall" "github.com/cozystack/cozystack/internal/fluxinstall" "github.com/cozystack/cozystack/internal/operator" "github.com/cozystack/cozystack/internal/telemetry" @@ -77,6 +78,7 @@ func main() { var probeAddr string var secureMetrics bool var enableHTTP2 bool + var installCRDs bool var installFlux bool var disableTelemetry bool var telemetryEndpoint string @@ -97,6 +99,7 @@ func main() { "If set the metrics endpoint is served securely") flag.BoolVar(&enableHTTP2, "enable-http2", false, "If set, HTTP/2 will be enabled for the metrics and webhook servers") + flag.BoolVar(&installCRDs, "install-crds", false, "Install Cozystack CRDs before starting reconcile loop") flag.BoolVar(&installFlux, "install-flux", false, "Install Flux components before starting reconcile loop") flag.BoolVar(&disableTelemetry, "disable-telemetry", false, "Disable telemetry collection") @@ -177,6 +180,19 @@ func main() { os.Exit(1) } + // Install Cozystack CRDs before starting reconcile loop + if installCRDs { + setupLog.Info("Installing Cozystack CRDs before starting reconcile loop") + installCtx, installCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer installCancel() + + if err := crdinstall.Install(installCtx, directClient, crdinstall.WriteEmbeddedManifests); err != nil { + setupLog.Error(err, "failed to install CRDs") + os.Exit(1) + } + setupLog.Info("CRD installation completed successfully") + } + // Install Flux before starting reconcile loop if installFlux { setupLog.Info("Installing Flux components before starting reconcile loop") diff --git a/internal/crdinstall/install.go b/internal/crdinstall/install.go new file mode 100644 index 00000000..fb6e4588 --- /dev/null +++ b/internal/crdinstall/install.go @@ -0,0 +1,144 @@ +/* +Copyright 2025 The Cozystack 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 crdinstall + +import ( + "bufio" + "bytes" + "context" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + k8syaml "k8s.io/apimachinery/pkg/util/yaml" + "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/log" +) + +// Install applies Cozystack CRDs using embedded manifests. +// It extracts the manifests and applies them to the cluster using server-side apply. +func Install(ctx context.Context, k8sClient client.Client, writeEmbeddedManifests func(string) error) error { + logger := log.FromContext(ctx) + + tmpDir, err := os.MkdirTemp("", "crd-install-*") + if err != nil { + return fmt.Errorf("failed to create temp directory: %w", err) + } + defer os.RemoveAll(tmpDir) + + manifestsDir := filepath.Join(tmpDir, "manifests") + if err := os.MkdirAll(manifestsDir, 0755); err != nil { + return fmt.Errorf("failed to create manifests directory: %w", err) + } + + if err := writeEmbeddedManifests(manifestsDir); err != nil { + return fmt.Errorf("failed to extract embedded manifests: %w", err) + } + + entries, err := os.ReadDir(manifestsDir) + if err != nil { + return fmt.Errorf("failed to read manifests directory: %w", err) + } + + var manifestFiles []string + for _, entry := range entries { + if strings.HasSuffix(entry.Name(), ".yaml") { + manifestFiles = append(manifestFiles, filepath.Join(manifestsDir, entry.Name())) + } + } + + if len(manifestFiles) == 0 { + return fmt.Errorf("no YAML manifest files found in directory") + } + + var objects []*unstructured.Unstructured + for _, manifestPath := range manifestFiles { + objs, err := parseManifests(manifestPath) + if err != nil { + return fmt.Errorf("failed to parse manifests from %s: %w", manifestPath, err) + } + objects = append(objects, objs...) + } + + if len(objects) == 0 { + return fmt.Errorf("no objects found in manifests") + } + + logger.Info("Applying Cozystack CRDs", "count", len(objects)) + for _, obj := range objects { + patchOptions := &client.PatchOptions{ + FieldManager: "cozystack-operator", + Force: func() *bool { b := true; return &b }(), + } + + if err := k8sClient.Patch(ctx, obj, client.Apply, patchOptions); err != nil { + return fmt.Errorf("failed to apply CRD %s: %w", obj.GetName(), err) + } + logger.Info("Applied CRD", "name", obj.GetName()) + } + + logger.Info("CRD installation completed successfully") + return nil +} + +func parseManifests(manifestPath string) ([]*unstructured.Unstructured, error) { + data, err := os.ReadFile(manifestPath) + if err != nil { + return nil, fmt.Errorf("failed to read manifest file: %w", err) + } + + return readYAMLObjects(bytes.NewReader(data)) +} + +func readYAMLObjects(reader io.Reader) ([]*unstructured.Unstructured, error) { + var objects []*unstructured.Unstructured + yamlReader := k8syaml.NewYAMLReader(bufio.NewReader(reader)) + + for { + doc, err := yamlReader.Read() + if err != nil { + if err == io.EOF { + break + } + return nil, fmt.Errorf("failed to read YAML document: %w", err) + } + + if len(bytes.TrimSpace(doc)) == 0 { + continue + } + + obj := &unstructured.Unstructured{} + decoder := k8syaml.NewYAMLOrJSONDecoder(bytes.NewReader(doc), len(doc)) + if err := decoder.Decode(obj); err != nil { + if err == io.EOF { + continue + } + return nil, fmt.Errorf("failed to decode YAML document: %w", err) + } + + if obj.GetKind() == "" { + continue + } + + objects = append(objects, obj) + } + + return objects, nil +} diff --git a/internal/crdinstall/manifests.embed.go b/internal/crdinstall/manifests.embed.go new file mode 100644 index 00000000..dd578db6 --- /dev/null +++ b/internal/crdinstall/manifests.embed.go @@ -0,0 +1,50 @@ +/* +Copyright 2025 The Cozystack 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 crdinstall + +import ( + "embed" + "fmt" + "io/fs" + "os" + "path" +) + +//go:embed manifests/*.yaml +var embeddedCRDManifests embed.FS + +// WriteEmbeddedManifests extracts embedded CRD manifests to a directory. +func WriteEmbeddedManifests(dir string) error { + manifests, err := fs.ReadDir(embeddedCRDManifests, "manifests") + if err != nil { + return fmt.Errorf("failed to read embedded manifests: %w", err) + } + + for _, manifest := range manifests { + data, err := fs.ReadFile(embeddedCRDManifests, path.Join("manifests", manifest.Name())) + if err != nil { + return fmt.Errorf("failed to read file %s: %w", manifest.Name(), err) + } + + outputPath := path.Join(dir, manifest.Name()) + if err := os.WriteFile(outputPath, data, 0666); err != nil { + return fmt.Errorf("failed to write file %s: %w", outputPath, err) + } + } + + return nil +} diff --git a/internal/crdinstall/manifests/.gitattributes b/internal/crdinstall/manifests/.gitattributes new file mode 100644 index 00000000..f581feca --- /dev/null +++ b/internal/crdinstall/manifests/.gitattributes @@ -0,0 +1 @@ +*.yaml linguist-generated diff --git a/internal/crdinstall/manifests/cozystack.io_packages.yaml b/internal/crdinstall/manifests/cozystack.io_packages.yaml new file mode 100644 index 00000000..cb7e2763 --- /dev/null +++ b/internal/crdinstall/manifests/cozystack.io_packages.yaml @@ -0,0 +1,171 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: packages.cozystack.io +spec: + group: cozystack.io + names: + kind: Package + listKind: PackageList + plural: packages + shortNames: + - pkg + - pkgs + singular: package + scope: Cluster + versions: + - additionalPrinterColumns: + - description: Selected variant + jsonPath: .spec.variant + name: Variant + type: string + - description: Ready status + jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - description: Ready message + jsonPath: .status.conditions[?(@.type=='Ready')].message + name: Status + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: Package is the Schema for the packages API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: PackageSpec defines the desired state of Package + properties: + components: + additionalProperties: + description: PackageComponent defines overrides for a specific component + properties: + enabled: + description: |- + Enabled indicates whether this component should be installed + If false, the component will be disabled even if it's defined in the PackageSource + type: boolean + values: + description: |- + Values contains Helm chart values as a JSON object + These values will be merged with the default values from the PackageSource + x-kubernetes-preserve-unknown-fields: true + type: object + description: |- + Components is a map of release name to component overrides + Allows overriding values and enabling/disabling specific components from the PackageSource + type: object + ignoreDependencies: + description: |- + IgnoreDependencies is a list of package source dependencies to ignore + Dependencies listed here will not be installed even if they are specified in the PackageSource + items: + type: string + type: array + variant: + description: |- + Variant is the name of the variant to use from the PackageSource + If not specified, defaults to "default" + type: string + type: object + status: + description: PackageStatus defines the observed state of Package + properties: + conditions: + description: Conditions represents the latest available observations + of a Package's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + dependencies: + additionalProperties: + description: DependencyStatus represents the readiness status of + a dependency + properties: + ready: + description: Ready indicates whether the dependency is ready + type: boolean + required: + - ready + type: object + description: |- + Dependencies tracks the readiness status of each dependency + Key is the dependency package name, value indicates if the dependency is ready + type: object + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/internal/crdinstall/manifests/cozystack.io_packagesources.yaml b/internal/crdinstall/manifests/cozystack.io_packagesources.yaml new file mode 100644 index 00000000..0acfcdd2 --- /dev/null +++ b/internal/crdinstall/manifests/cozystack.io_packagesources.yaml @@ -0,0 +1,250 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.16.4 + name: packagesources.cozystack.io +spec: + group: cozystack.io + names: + kind: PackageSource + listKind: PackageSourceList + plural: packagesources + shortNames: + - pks + singular: packagesource + scope: Cluster + versions: + - additionalPrinterColumns: + - description: Package variants (comma-separated) + jsonPath: .status.variants + name: Variants + type: string + - description: Ready status + jsonPath: .status.conditions[?(@.type=='Ready')].status + name: Ready + type: string + - description: Ready message + jsonPath: .status.conditions[?(@.type=='Ready')].message + name: Status + type: string + name: v1alpha1 + schema: + openAPIV3Schema: + description: PackageSource is the Schema for the packagesources API + properties: + apiVersion: + description: |- + APIVersion defines the versioned schema of this representation of an object. + Servers should convert recognized schemas to the latest internal value, and + may reject unrecognized values. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources + type: string + kind: + description: |- + Kind is a string value representing the REST resource this object represents. + Servers may infer this from the endpoint the client submits requests to. + Cannot be updated. + In CamelCase. + More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds + type: string + metadata: + type: object + spec: + description: PackageSourceSpec defines the desired state of PackageSource + properties: + sourceRef: + description: SourceRef is the source reference for the package source + charts + properties: + kind: + description: Kind of the source reference + enum: + - GitRepository + - OCIRepository + type: string + name: + description: Name of the source reference + type: string + namespace: + description: Namespace of the source reference + type: string + path: + description: |- + Path is the base path where packages are located in the source. + For GitRepository, defaults to "packages" if not specified. + For OCIRepository, defaults to empty string (root) if not specified. + type: string + required: + - kind + - name + - namespace + type: object + variants: + description: |- + Variants is a list of package source variants + Each variant defines components, applications, dependencies, and libraries for a specific configuration + items: + description: Variant defines a single variant configuration + properties: + components: + description: Components is a list of Helm releases to be installed + as part of this variant + items: + description: Component defines a single Helm release component + within a package source + properties: + install: + description: Install defines installation parameters for + this component + properties: + dependsOn: + description: DependsOn is a list of component names + that must be installed before this component + items: + type: string + type: array + namespace: + description: Namespace is the Kubernetes namespace + where the release will be installed + type: string + privileged: + description: Privileged indicates whether this release + requires privileged access + type: boolean + releaseName: + description: |- + ReleaseName is the name of the HelmRelease resource that will be created + If not specified, defaults to the component Name field + type: string + type: object + libraries: + description: |- + Libraries is a list of library names that this component depends on + These libraries must be defined at the variant level + items: + type: string + type: array + name: + description: Name is the unique identifier for this component + within the package source + type: string + path: + description: Path is the path to the Helm chart directory + type: string + valuesFiles: + description: ValuesFiles is a list of values file names + to use + items: + type: string + type: array + required: + - name + - path + type: object + type: array + dependsOn: + description: |- + DependsOn is a list of package source dependencies + For example: "cozystack.networking" + items: + type: string + type: array + libraries: + description: Libraries is a list of Helm library charts used + by components in this variant + items: + description: Library defines a Helm library chart + properties: + name: + description: Name is the optional name for library placed + in charts + type: string + path: + description: Path is the path to the library chart directory + type: string + required: + - path + type: object + type: array + name: + description: Name is the unique identifier for this variant + type: string + required: + - name + type: object + type: array + type: object + status: + description: PackageSourceStatus defines the observed state of PackageSource + properties: + conditions: + description: Conditions represents the latest available observations + of a PackageSource's state + items: + description: Condition contains details for one aspect of the current + state of this API Resource. + properties: + lastTransitionTime: + description: |- + lastTransitionTime is the last time the condition transitioned from one status to another. + This should be when the underlying condition changed. If that is not known, then using the time when the API field changed is acceptable. + format: date-time + type: string + message: + description: |- + message is a human readable message indicating details about the transition. + This may be an empty string. + maxLength: 32768 + type: string + observedGeneration: + description: |- + observedGeneration represents the .metadata.generation that the condition was set based upon. + For instance, if .metadata.generation is currently 12, but the .status.conditions[x].observedGeneration is 9, the condition is out of date + with respect to the current state of the instance. + format: int64 + minimum: 0 + type: integer + reason: + description: |- + reason contains a programmatic identifier indicating the reason for the condition's last transition. + Producers of specific condition types may define expected values and meanings for this field, + and whether the values are considered a guaranteed API. + The value should be a CamelCase string. + This field may not be empty. + maxLength: 1024 + minLength: 1 + pattern: ^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$ + type: string + status: + description: status of the condition, one of True, False, Unknown. + enum: + - "True" + - "False" + - Unknown + type: string + type: + description: type of condition in CamelCase or in foo.example.com/CamelCase. + maxLength: 316 + pattern: ^([a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*/)?(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])$ + type: string + required: + - lastTransitionTime + - message + - reason + - status + - type + type: object + type: array + variants: + description: |- + Variants is a comma-separated list of package variant names + This field is populated by the controller based on spec.variants keys + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {}