From f3eaedf92db42598b021a4728303ed2eada35595 Mon Sep 17 00:00:00 2001 From: cuisongliu Date: Tue, 18 Nov 2025 23:25:57 +0800 Subject: [PATCH] =?UTF-8?q?fix(license):=20update=20CRD=20version=20and=20?= =?UTF-8?q?add=20additional=20printer=20columns=20f=E2=80=A6=20(#6212)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(license): update CRD version and add additional printer columns for license status (#6207) * feat(license): implement license controller with Helm chart and RBAC configuration * fix(license): correct logic for extending default license for devbox users * fix(license): update license validation to use context for improved handling * fix(license): change EncryptionKey to encryptionKey and add getter function * fix(license): extend default license expiration for devbox users to 50 years (cherry picked from commit 2df0bdf85407bfee07685c95c1916849f6d6f713) --- .github/workflows/controllers.yml | 10 +- .github/workflows/services.yml | 1 - controllers/job/init/Makefile | 1 - controllers/license/Makefile | 4 +- controllers/license/api/v1/license_types.go | 16 +- controllers/license/cmd/manager/main.go | 133 ++++--- .../crd/bases/license.sealos.io_licenses.yaml | 31 +- controllers/license/config/rbac/role.yaml | 14 - controllers/license/deploy/Kubefile | 8 +- .../license/deploy/charts/license/.helmignore | 23 ++ .../license/deploy/charts/license/Chart.yaml | 24 ++ .../crds/license.sealos.io_licenses.yaml | 80 +++++ .../deploy/charts/license/templates/NOTES.txt | 0 .../charts/license/templates/_helpers.tpl | 62 ++++ .../deploy/charts/license/templates/cert.yaml | 27 ++ .../charts/license/templates/clusterrole.yaml | 100 ++++++ .../charts/license/templates/deployment.yaml | 91 +++++ .../deploy/charts/license/templates/role.yaml | 24 ++ .../charts/license/templates/service.yaml | 17 + .../license/templates/serviceaccount.yaml | 14 + .../license/deploy/charts/license/values.yaml | 109 ++++++ controllers/license/deploy/entrypoint.sh | 6 + .../{manifests => uninstall}/deploy.yaml | 40 +-- .../deploy/{manifests => uninstall}/rbac.yaml | 0 .../internal/controller/license_activator.go | 70 ++-- .../internal/controller/license_controller.go | 330 ++++++++++++++---- .../internal/controller/license_validator.go | 23 +- .../license/internal/controller/suite_test.go | 14 +- .../license/internal/util/claims/claims.go | 22 +- .../internal/util/claims/claims_test.go | 32 -- .../license/internal/util/cluster/cluster.go | 2 +- .../internal/util/clusterid/cluster_id.go | 25 +- .../internal/util/database/database.go | 96 ----- .../internal/util/database/database_test.go | 17 - .../license/internal/util/errors/errors.go | 9 +- controllers/license/internal/util/key/key.go | 8 +- .../license/internal/util/license/validate.go | 78 ++++- .../internal/util/license/validate_test.go | 114 ++++-- .../license/internal/util/meta/meta.go | 41 --- controllers/objectstorage/Makefile | 1 - controllers/pkg/crypto/crypto.go | 30 ++ .../.github/workflows/close-pr.yml | 14 + .../lvscare/.github/workflows/close-pr.yml | 14 + 43 files changed, 1266 insertions(+), 509 deletions(-) create mode 100644 controllers/license/deploy/charts/license/.helmignore create mode 100644 controllers/license/deploy/charts/license/Chart.yaml create mode 100644 controllers/license/deploy/charts/license/crds/license.sealos.io_licenses.yaml create mode 100644 controllers/license/deploy/charts/license/templates/NOTES.txt create mode 100644 controllers/license/deploy/charts/license/templates/_helpers.tpl create mode 100644 controllers/license/deploy/charts/license/templates/cert.yaml create mode 100644 controllers/license/deploy/charts/license/templates/clusterrole.yaml create mode 100644 controllers/license/deploy/charts/license/templates/deployment.yaml create mode 100644 controllers/license/deploy/charts/license/templates/role.yaml create mode 100644 controllers/license/deploy/charts/license/templates/service.yaml create mode 100644 controllers/license/deploy/charts/license/templates/serviceaccount.yaml create mode 100644 controllers/license/deploy/charts/license/values.yaml create mode 100644 controllers/license/deploy/entrypoint.sh rename controllers/license/deploy/{manifests => uninstall}/deploy.yaml (90%) rename controllers/license/deploy/{manifests => uninstall}/rbac.yaml (100%) delete mode 100644 controllers/license/internal/util/database/database.go delete mode 100644 controllers/license/internal/util/database/database_test.go delete mode 100644 controllers/license/internal/util/meta/meta.go diff --git a/.github/workflows/controllers.yml b/.github/workflows/controllers.yml index 12c028fcd..5d20ee06f 100644 --- a/.github/workflows/controllers.yml +++ b/.github/workflows/controllers.yml @@ -54,7 +54,6 @@ env: # Common versions GO_VERSION: "1.24" DEFAULT_OWNER: "labring" - CRYPTOKEY: ${{ secrets.CONTROLLER_BUILD_CRYPTOKEY }} LICENSE_KEY: ${{ secrets.LICENSE_KEY }} ALIYUN_REGISTRY: ${{ secrets.ALIYUN_REGISTRY }} ALIYUN_REPO_PREFIX: ${{ secrets.ALIYUN_REPO_PREFIX && secrets.ALIYUN_REPO_PREFIX || secrets.ALIYUN_USERNAME && format('{0}/{1}', secrets.ALIYUN_REGISTRY, secrets.ALIYUN_USERNAME) || '' }} @@ -304,7 +303,14 @@ jobs: run: | OLD_DOCKER_IMAGE_NAME="${OLD_DOCKER_REPO}:latest" NEW_DOCKER_IMAGE_NAME="${NEW_DOCKER_REPO}:${TAG_NAME}" - sudo sed -i "s;${OLD_DOCKER_IMAGE_NAME};${NEW_DOCKER_IMAGE_NAME};" manifests/* + if [[ -d manifests ]]; then + echo "Found manifests" + sudo sed -i "s;${OLD_DOCKER_IMAGE_NAME};${NEW_DOCKER_IMAGE_NAME};" manifests/* + fi + if [[ -f charts/${{ matrix.module.path }}/values.yaml ]]; then + echo "Found charts charts/${{ matrix.module.path }}/values.yaml" + sed -i "s;${OLD_DOCKER_IMAGE_NAME};${NEW_DOCKER_IMAGE_NAME};" charts/${{ matrix.module.path }}/values.yaml + fi - name: Build ${{ matrix.module.name }}-controller cluster image working-directory: controllers/${{ matrix.module.path }}/deploy diff --git a/.github/workflows/services.yml b/.github/workflows/services.yml index 3d7d01579..15147b295 100644 --- a/.github/workflows/services.yml +++ b/.github/workflows/services.yml @@ -53,7 +53,6 @@ env: # Common versions GO_VERSION: "1.22" DEFAULT_OWNER: "labring" - CRYPTOKEY: ${{ secrets.CONTROLLER_BUILD_CRYPTOKEY }} ALIYUN_REGISTRY: ${{ secrets.ALIYUN_REGISTRY }} ALIYUN_REPO_PREFIX: ${{ secrets.ALIYUN_REPO_PREFIX && secrets.ALIYUN_REPO_PREFIX || secrets.ALIYUN_USERNAME && format('{0}/{1}', secrets.ALIYUN_REGISTRY, secrets.ALIYUN_USERNAME) || '' }} diff --git a/controllers/job/init/Makefile b/controllers/job/init/Makefile index 49d5c8ad1..ba0a6fdd5 100644 --- a/controllers/job/init/Makefile +++ b/controllers/job/init/Makefile @@ -51,7 +51,6 @@ CONTROLLER_PKG=github.com/labring/sealos/controllers/pkg CONTROLLER_LICENSE=github.com/labring/sealos/controllers/license/internal/controller build: fmt vet ## Build manager binary. LD_FLAGS="-s -w"; \ - [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+=" -X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}"; \ CGO_ENABLED=0 GOOS=linux go build -ldflags "$${LD_FLAGS}" -trimpath -o bin/preset-${GOARCH} cmd/preset/main.go && chmod +x bin/preset-${GOARCH} && cp bin/preset-${GOARCH} bin/manager diff --git a/controllers/license/Makefile b/controllers/license/Makefile index c71b79652..55a95464f 100644 --- a/controllers/license/Makefile +++ b/controllers/license/Makefile @@ -44,6 +44,7 @@ help: ## Display this help. .PHONY: manifests manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases + cp -r config/crd/bases/* deploy/charts/license/crds/ .PHONY: generate generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. @@ -69,8 +70,7 @@ CONTROLLER_PKG=github.com/labring/sealos/controllers/pkg CONTROLLER_LICENSE=github.com/labring/sealos/controllers/license/internal build: manifests generate fmt vet ## Build manager binary. LD_FLAGS="-s -w"; \ - [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+=" -X ${CONTROLLER_PKG}/crypto.encryptionKey=${CRYPTOKEY} -X ${CONTROLLER_PKG}/database.cryptoKey=${CRYPTOKEY}"; \ - [ -n "$(LICENSE_KEY)" ] && LD_FLAGS+=" -X ${CONTROLLER_LICENSE}/util/key.EncryptionKey=${LICENSE_KEY}"; \ + [ -n "$(LICENSE_KEY)" ] && LD_FLAGS+=" -X ${CONTROLLER_LICENSE}/util/key.encryptionKey=${LICENSE_KEY}"; \ CGO_ENABLED=0 GOOS=linux GOARCH=${TARGETARCH} go build -ldflags "$${LD_FLAGS}" -trimpath -o bin/manager cmd/manager/main.go diff --git a/controllers/license/api/v1/license_types.go b/controllers/license/api/v1/license_types.go index 2a28dc062..696e32929 100644 --- a/controllers/license/api/v1/license_types.go +++ b/controllers/license/api/v1/license_types.go @@ -23,13 +23,11 @@ import ( type LicenseType string const ( - AccountLicenseType LicenseType = "Account" ClusterLicenseType LicenseType = "Cluster" ) // LicenseSpec defines the desired state of License type LicenseSpec struct { - //+kubebuilder:validation:Enum=Account;Cluster Type LicenseType `json:"type,omitempty"` Token string `json:"token,omitempty"` } @@ -37,9 +35,12 @@ type LicenseSpec struct { type LicenseStatusPhase string const ( - LicenseStatusPhasePending LicenseStatusPhase = "Pending" - LicenseStatusPhaseFailed LicenseStatusPhase = "Failed" - LicenseStatusPhaseActive LicenseStatusPhase = "Active" + LicenseStatusPhasePending LicenseStatusPhase = "Pending" + LicenseStatusPhaseFailed LicenseStatusPhase = "Failed" + LicenseStatusPhaseActive LicenseStatusPhase = "Active" + LicenseStatusPhaseExpired LicenseStatusPhase = "Expired" + LicenseStatusPhaseInvalid LicenseStatusPhase = "Invalid" + LicenseStatusPhaseMismatch LicenseStatusPhase = "Mismatch" ) type ValidationCode int @@ -54,10 +55,9 @@ const ( // LicenseStatus defines the observed state of License type LicenseStatus struct { - //+kubebuilder:validation:Enum=Pending;Failed;Active + //+kubebuilder:validation:Enum=Pending;Failed;Active;Expired;Invalid;Mismatch //+kubebuilder:default=Pending Phase LicenseStatusPhase `json:"phase,omitempty"` - ValidationCode ValidationCode `json:"validationCode,omitempty"` Reason string `json:"reason,omitempty"` ActivationTime metav1.Time `json:"activationTime,omitempty"` ExpirationTime metav1.Time `json:"expirationTime,omitempty"` @@ -65,6 +65,8 @@ type LicenseStatus struct { //+kubebuilder:object:root=true //+kubebuilder:subresource:status +// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase" +// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp" // License is the Schema for the licenses API type License struct { diff --git a/controllers/license/cmd/manager/main.go b/controllers/license/cmd/manager/main.go index a20494a5e..e448ae151 100644 --- a/controllers/license/cmd/manager/main.go +++ b/controllers/license/cmd/manager/main.go @@ -17,29 +17,22 @@ limitations under the License. package main import ( - "context" + "crypto/tls" "flag" "os" - database2 "github.com/labring/sealos/controllers/pkg/database" - "github.com/labring/sealos/controllers/pkg/database/cockroach" - - _ "k8s.io/client-go/plugin/pkg/client/auth" - - "k8s.io/apimachinery/pkg/runtime" - utilruntime "k8s.io/apimachinery/pkg/util/runtime" - clientgoscheme "k8s.io/client-go/kubernetes/scheme" - ctrl "sigs.k8s.io/controller-runtime" - "sigs.k8s.io/controller-runtime/pkg/healthz" - "sigs.k8s.io/controller-runtime/pkg/log/zap" - metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" - - accountv1 "github.com/labring/sealos/controllers/account/api/v1" licensev1 "github.com/labring/sealos/controllers/license/api/v1" "github.com/labring/sealos/controllers/license/internal/controller" utilid "github.com/labring/sealos/controllers/license/internal/util/clusterid" - "github.com/labring/sealos/controllers/license/internal/util/database" - //+kubebuilder:scaffold:imports + "k8s.io/apimachinery/pkg/runtime" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" + _ "k8s.io/client-go/plugin/pkg/client/auth" + ctrl "sigs.k8s.io/controller-runtime" + "sigs.k8s.io/controller-runtime/pkg/healthz" + "sigs.k8s.io/controller-runtime/pkg/log/zap" + "sigs.k8s.io/controller-runtime/pkg/metrics/filters" + metricsserver "sigs.k8s.io/controller-runtime/pkg/metrics/server" ) var ( @@ -50,7 +43,6 @@ var ( func init() { utilruntime.Must(clientgoscheme.AddToScheme(scheme)) - utilruntime.Must(accountv1.AddToScheme(scheme)) utilruntime.Must(licensev1.AddToScheme(scheme)) //+kubebuilder:scaffold:scheme } @@ -59,12 +51,33 @@ func main() { var metricsAddr string var enableLeaderElection bool var probeAddr string + var secureMetrics bool + var enableHTTP2 bool + var tlsOpts []func(*tls.Config) - flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") - flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") + flag.StringVar( + &metricsAddr, + "metrics-bind-address", + ":8080", + "The address the metric endpoint binds to.", + ) + flag.StringVar( + &probeAddr, + "health-probe-bind-address", + ":8081", + "The address the probe endpoint binds to.", + ) flag.BoolVar(&enableLeaderElection, "leader-elect", false, "Enable leader election for controller manager. "+ "Enabling this will ensure there is only one active controller manager.") + flag.BoolVar( + &secureMetrics, + "metrics-secure", + true, + "If set, the metrics endpoint is served securely via HTTPS. Use --metrics-secure=false to use HTTP instead.", + ) + flag.BoolVar(&enableHTTP2, "enable-http2", false, + "If set, HTTP/2 will be enabled for the metrics and webhook servers") opts := zap.Options{ Development: true, } @@ -73,11 +86,45 @@ func main() { ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) + // if the enable-http2 flag is false (the default), http/2 should be disabled + // due to its vulnerabilities. More specifically, disabling http/2 will + // prevent from being vulnerable to the HTTP/2 Stream Cancellation and + // Rapid Reset CVEs. For more information see: + // - https://github.com/advisories/GHSA-qppj-fm5r-hxr3 + // - https://github.com/advisories/GHSA-4374-p667-p6c8 + disableHTTP2 := func(c *tls.Config) { + setupLog.Info("disabling http/2") + c.NextProtos = []string{"http/1.1"} + } + + if !enableHTTP2 { + tlsOpts = append(tlsOpts, disableHTTP2) + } + // Metrics endpoint is enabled in 'config/default/kustomization.yaml'. The Metrics options configure the server. + // More info: + // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/server + // - https://book.kubebuilder.io/reference/metrics.html + metricsServerOptions := metricsserver.Options{ + BindAddress: metricsAddr, + SecureServing: secureMetrics, + TLSOpts: tlsOpts, + } + + if secureMetrics { + // FilterProvider is used to protect the metrics endpoint with authn/authz. + // These configurations ensure that only authorized users and service accounts + // can access the metrics endpoint. The RBAC are configured in 'config/rbac/kustomization.yaml'. More info: + // https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.19.1/pkg/metrics/filters#WithAuthenticationAndAuthorization + metricsServerOptions.FilterProvider = filters.WithAuthenticationAndAuthorization + + // TODO(user): If CertDir, CertName, and KeyName are not specified, controller-runtime will automatically + // generate self-signed certificates for the metrics server. While convenient for development and testing, + // this setup is not recommended for production. + } + mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ - Scheme: scheme, - Metrics: metricsserver.Options{ - BindAddress: metricsAddr, - }, + Scheme: scheme, + Metrics: metricsServerOptions, HealthProbeBindAddress: probeAddr, LeaderElection: enableLeaderElection, LeaderElectionID: "90cbb0a1.sealos.io", @@ -97,34 +144,30 @@ func main() { setupLog.Error(err, "unable to start manager") os.Exit(1) } - - db, err := database.New(context.Background(), os.Getenv("MONGO_URI")) - if err != nil { - setupLog.Error(err, "unable to connect to database") - os.Exit(1) - } - defer db.Disconnect(context.Background()) - - clusterID, err := utilid.GetClusterID(mgr.GetConfig()) + ctx := ctrl.SetupSignalHandler() + clusterID, err := utilid.GetClusterID(ctx, mgr.GetConfig()) if err != nil { setupLog.Error(err, "unable to get cluster id") os.Exit(1) } - setupLog.Info("cluster id", "id", clusterID) - - accountDB, err := cockroach.NewCockRoach(os.Getenv(database2.GlobalCockroachURI), os.Getenv(database2.LocalCockroachURI)) + createTime, err := utilid.GetClusterCreateTime(ctx, mgr.GetConfig()) if err != nil { - setupLog.Error(err, "unable to connect to cockroach") + setupLog.Error(err, "unable to get cluster create time") os.Exit(1) } - defer func() { - err := accountDB.Close() - if err != nil { - setupLog.Error(err, "unable to disconnect from cockroach") - } - }() + setupLog.Info( + "cluster id and create time", + "id", + clusterID, + "createTime", + createTime.String(), + ) - if err = (&controller.LicenseReconciler{ClusterID: clusterID}).SetupWithManager(mgr, accountDB); err != nil { + reconciler := &controller.LicenseReconciler{ + ClusterID: clusterID, + CreateTimestamp: *createTime, + } + if err = reconciler.SetupWithManager(mgr); err != nil { setupLog.Error(err, "unable to create controller", "controller", "License") os.Exit(1) } @@ -140,7 +183,7 @@ func main() { } setupLog.Info("starting manager") - if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil { + if err := mgr.Start(ctx); err != nil { setupLog.Error(err, "problem running manager") os.Exit(1) } diff --git a/controllers/license/config/crd/bases/license.sealos.io_licenses.yaml b/controllers/license/config/crd/bases/license.sealos.io_licenses.yaml index 244b674f9..676704216 100644 --- a/controllers/license/config/crd/bases/license.sealos.io_licenses.yaml +++ b/controllers/license/config/crd/bases/license.sealos.io_licenses.yaml @@ -1,17 +1,3 @@ -# Copyright © 2024 sealos. -# -# 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. - --- apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition @@ -28,7 +14,14 @@ spec: singular: license scope: Namespaced versions: - - name: v1 + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 schema: openAPIV3Schema: description: License is the Schema for the licenses API @@ -56,9 +49,6 @@ spec: token: type: string type: - enum: - - Account - - Cluster type: string type: object status: @@ -76,11 +66,12 @@ spec: - Pending - Failed - Active + - Expired + - Invalid + - Mismatch type: string reason: type: string - validationCode: - type: integer type: object type: object served: true diff --git a/controllers/license/config/rbac/role.yaml b/controllers/license/config/rbac/role.yaml index b55a4a1fe..26ce2ca05 100644 --- a/controllers/license/config/rbac/role.yaml +++ b/controllers/license/config/rbac/role.yaml @@ -1,17 +1,3 @@ -# Copyright © 2024 sealos. -# -# 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. - --- apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole diff --git a/controllers/license/deploy/Kubefile b/controllers/license/deploy/Kubefile index ae18163ec..ddf4953b2 100644 --- a/controllers/license/deploy/Kubefile +++ b/controllers/license/deploy/Kubefile @@ -3,8 +3,6 @@ FROM scratch USER 65532:65532 COPY registry registry -COPY manifests manifests - -ENV MONGO_URI="mongodb://mongo:27017/resources" - -CMD ["kubectl apply -f manifests"] +COPY charts charts +COPY entrypoint.sh entrypoint.sh +CMD ["bash entrypoint.sh"] diff --git a/controllers/license/deploy/charts/license/.helmignore b/controllers/license/deploy/charts/license/.helmignore new file mode 100644 index 000000000..0e8a0eb36 --- /dev/null +++ b/controllers/license/deploy/charts/license/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/controllers/license/deploy/charts/license/Chart.yaml b/controllers/license/deploy/charts/license/Chart.yaml new file mode 100644 index 000000000..ddcc10ed2 --- /dev/null +++ b/controllers/license/deploy/charts/license/Chart.yaml @@ -0,0 +1,24 @@ +apiVersion: v2 +name: license +description: A Helm chart for Kubernetes + +# A chart can be either an 'application' or a 'library' chart. +# +# Application charts are a collection of templates that can be packaged into versioned archives +# to be deployed. +# +# Library charts provide useful utilities or functions for the chart developer. They're included as +# a dependency of application charts to inject those utilities and functions into the rendering +# pipeline. Library charts do not define any templates and therefore cannot be deployed. +type: application + +# This is the chart version. This version number should be incremented each time you make changes +# to the chart and its templates, including the app version. +# Versions are expected to follow Semantic Versioning (https://semver.org/) +version: 0.1.0 + +# This is the version number of the application being deployed. This version number should be +# incremented each time you make changes to the application. Versions are not expected to +# follow Semantic Versioning. They should reflect the version the application is using. +# It is recommended to use it with quotes. +appVersion: "1.16.0" diff --git a/controllers/license/deploy/charts/license/crds/license.sealos.io_licenses.yaml b/controllers/license/deploy/charts/license/crds/license.sealos.io_licenses.yaml new file mode 100644 index 000000000..676704216 --- /dev/null +++ b/controllers/license/deploy/charts/license/crds/license.sealos.io_licenses.yaml @@ -0,0 +1,80 @@ +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + controller-gen.kubebuilder.io/version: v0.14.0 + name: licenses.license.sealos.io +spec: + group: license.sealos.io + names: + kind: License + listKind: LicenseList + plural: licenses + singular: license + scope: Namespaced + versions: + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 + schema: + openAPIV3Schema: + description: License is the Schema for the licenses 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: LicenseSpec defines the desired state of License + properties: + token: + type: string + type: + type: string + type: object + status: + description: LicenseStatus defines the observed state of License + properties: + activationTime: + format: date-time + type: string + expirationTime: + format: date-time + type: string + phase: + default: Pending + enum: + - Pending + - Failed + - Active + - Expired + - Invalid + - Mismatch + type: string + reason: + type: string + type: object + type: object + served: true + storage: true + subresources: + status: {} diff --git a/controllers/license/deploy/charts/license/templates/NOTES.txt b/controllers/license/deploy/charts/license/templates/NOTES.txt new file mode 100644 index 000000000..e69de29bb diff --git a/controllers/license/deploy/charts/license/templates/_helpers.tpl b/controllers/license/deploy/charts/license/templates/_helpers.tpl new file mode 100644 index 000000000..96c09fec2 --- /dev/null +++ b/controllers/license/deploy/charts/license/templates/_helpers.tpl @@ -0,0 +1,62 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "license.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "license.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "license.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "license.labels" -}} +helm.sh/chart: {{ include "license.chart" . }} +{{ include "license.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "license.selectorLabels" -}} +app.kubernetes.io/name: {{ include "license.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* +Create the name of the service account to use +*/}} +{{- define "license.serviceAccountName" -}} +{{- if .Values.serviceAccount.create }} +{{- default (include "license.fullname" .) .Values.serviceAccount.name }} +{{- else }} +{{- default "default" .Values.serviceAccount.name }} +{{- end }} +{{- end }} diff --git a/controllers/license/deploy/charts/license/templates/cert.yaml b/controllers/license/deploy/charts/license/templates/cert.yaml new file mode 100644 index 000000000..446df8abe --- /dev/null +++ b/controllers/license/deploy/charts/license/templates/cert.yaml @@ -0,0 +1,27 @@ +{{- if .Values.metrics.enabled }} +apiVersion: cert-manager.io/v1 +kind: Issuer +metadata: + labels: + {{- include "license.labels" . | nindent 4 }} + name: selfsigned-issuer +spec: + selfSigned: {} +--- +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + labels: + {{- include "license.labels" . | nindent 4 }} + name: metrics-certs +spec: + privateKey: + rotationPolicy: Always + dnsNames: + - {{ include "license.fullname" . }}-metrics.{{.Release.Namespace}}.svc + - {{ include "license.fullname" . }}-metrics.{{.Release.Namespace}}.svc.cluster.local + issuerRef: + kind: Issuer + name: selfsigned-issuer + secretName: {{.Values.metrics.secretName}} +{{- end }} \ No newline at end of file diff --git a/controllers/license/deploy/charts/license/templates/clusterrole.yaml b/controllers/license/deploy/charts/license/templates/clusterrole.yaml new file mode 100644 index 000000000..a3a7b21ca --- /dev/null +++ b/controllers/license/deploy/charts/license/templates/clusterrole.yaml @@ -0,0 +1,100 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: license-controller-clusterrole +rules: +- apiGroups: [ "" ] + resources: [ "namespaces" ] + resourceNames: [ "kube-system" ] + verbs: [ "get" ] +- apiGroups: + - "" + resources: + - nodes + verbs: + - get + - list + - watch +- apiGroups: + - license.sealos.io + resources: + - licenses + verbs: + - create + - delete + - get + - list + - patch + - update + - watch +- apiGroups: + - license.sealos.io + resources: + - licenses/finalizers + verbs: + - update +- apiGroups: + - license.sealos.io + resources: + - licenses/status + verbs: + - get + - patch + - update +- apiGroups: + - apiextensions.k8s.io + resources: + - customresourcedefinitions + resourceNames: + - devboxes.devbox.sealos.io + verbs: + - get +- nonResourceURLs: + - /metrics + verbs: + - get +- apiGroups: + - authentication.k8s.io + resources: + - tokenreviews + - subjectaccessreviews + verbs: + - create +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRoleBinding +metadata: + name: license-controller-clusterrolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: license-controller-clusterrole +subjects: + - kind: ServiceAccount + name: {{ include "license.serviceAccountName" . }} + namespace: {{.Release.Namespace}} +--- +## ClusterRole for license frontend to get node info +apiVersion: rbac.authorization.k8s.io/v1 +kind: ClusterRole +metadata: + name: license-manager-role +rules: +- apiGroups: [ "" ] + resources: [ "namespaces" ] + resourceNames: [ "kube-system" ] + verbs: [ "get" ] +- apiGroups: + - "" + resources: + - nodes + verbs: + - get + - list + - watch +- apiGroups: + - license.sealos.io + resources: + - licenses + verbs: + - "*" diff --git a/controllers/license/deploy/charts/license/templates/deployment.yaml b/controllers/license/deploy/charts/license/templates/deployment.yaml new file mode 100644 index 000000000..337abedd1 --- /dev/null +++ b/controllers/license/deploy/charts/license/templates/deployment.yaml @@ -0,0 +1,91 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "license.fullname" . }} + labels: + {{- include "license.labels" . | nindent 4 }} +spec: + replicas: {{ .Values.replicaCount }} + selector: + matchLabels: + {{- include "license.selectorLabels" . | nindent 6 }} + template: + metadata: + {{- with .Values.podAnnotations }} + annotations: + {{- toYaml . | nindent 8 }} + {{- end }} + labels: + {{- include "license.labels" . | nindent 8 }} + {{- with .Values.podLabels }} + {{- toYaml . | nindent 8 }} + {{- end }} + spec: + {{- with .Values.imagePullSecrets }} + imagePullSecrets: + {{- toYaml . | nindent 8 }} + {{- end }} + serviceAccountName: {{ include "license.serviceAccountName" . }} + securityContext: + {{- toYaml .Values.podSecurityContext | nindent 8 }} + containers: + - name: {{ .Chart.Name }} + command: + - /manager + args: + - --leader-elect + - --health-probe-bind-address=:8081 + {{- if .Values.metrics.enabled }} + - --metrics-secure=true + - --metrics-bind-address=:8443 + {{- end }} + securityContext: + {{- toYaml .Values.securityContext | nindent 12 }} + image: "{{ .Values.image }}" + imagePullPolicy: Always + livenessProbe: + {{- toYaml .Values.livenessProbe | nindent 12 }} + readinessProbe: + {{- toYaml .Values.readinessProbe | nindent 12 }} + resources: + {{- toYaml .Values.resources | nindent 12 }} + + volumeMounts: + {{- with .Values.volumeMounts }} + {{- toYaml . | nindent 12 }} + {{- end }} + {{- if .Values.metrics.enabled }} + - name: metrics-certs + mountPath: {{.Values.metrics.certPath}} + readOnly: true + {{- end }} + + volumes: + {{- if .Values.metrics.enabled }} + - name: metrics-certs + secret: + secretName: {{.Values.metrics.secretName}} + optional: false + items: + - key: ca.crt + path: ca.crt + - key: tls.crt + path: tls.crt + - key: tls.key + path: tls.key + {{- end }} + {{- with .Values.volumes }} + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.nodeSelector }} + nodeSelector: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.affinity }} + affinity: + {{- toYaml . | nindent 8 }} + {{- end }} + {{- with .Values.tolerations }} + tolerations: + {{- toYaml . | nindent 8 }} + {{- end }} diff --git a/controllers/license/deploy/charts/license/templates/role.yaml b/controllers/license/deploy/charts/license/templates/role.yaml new file mode 100644 index 000000000..dc602384a --- /dev/null +++ b/controllers/license/deploy/charts/license/templates/role.yaml @@ -0,0 +1,24 @@ +apiVersion: rbac.authorization.k8s.io/v1 +kind: Role +metadata: + name: license-controller-role +rules: +- apiGroups: + - '*' + resources: + - '*' + verbs: + - '*' +--- +apiVersion: rbac.authorization.k8s.io/v1 +kind: RoleBinding +metadata: + name: license-controller-clusterrolebinding +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: Role + name: license-controller-role +subjects: + - kind: ServiceAccount + name: {{ include "license.serviceAccountName" . }} + namespace: {{.Release.Namespace}} \ No newline at end of file diff --git a/controllers/license/deploy/charts/license/templates/service.yaml b/controllers/license/deploy/charts/license/templates/service.yaml new file mode 100644 index 000000000..89e062136 --- /dev/null +++ b/controllers/license/deploy/charts/license/templates/service.yaml @@ -0,0 +1,17 @@ +{{- if .Values.metrics.enabled }} +apiVersion: v1 +kind: Service +metadata: + name: {{ include "license.fullname" . }}-metrics + labels: + {{- include "license.labels" . | nindent 4 }} +spec: + type: {{ .Values.service.type }} + ports: + - port: {{ .Values.service.port }} + targetPort: 8443 + protocol: TCP + name: https + selector: + {{- include "license.selectorLabels" . | nindent 4 }} +{{- end }} \ No newline at end of file diff --git a/controllers/license/deploy/charts/license/templates/serviceaccount.yaml b/controllers/license/deploy/charts/license/templates/serviceaccount.yaml new file mode 100644 index 000000000..f4ee7869e --- /dev/null +++ b/controllers/license/deploy/charts/license/templates/serviceaccount.yaml @@ -0,0 +1,14 @@ +{{- if .Values.serviceAccount.create -}} +apiVersion: v1 +kind: ServiceAccount +metadata: + name: {{ include "license.serviceAccountName" . }} + labels: + {{- include "license.labels" . | nindent 4 }} + {{- with .Values.serviceAccount.annotations }} + annotations: + {{- toYaml . | nindent 4 }} + {{- end }} +automountServiceAccountToken: {{ .Values.serviceAccount.automount }} +{{- end }} +--- diff --git a/controllers/license/deploy/charts/license/values.yaml b/controllers/license/deploy/charts/license/values.yaml new file mode 100644 index 000000000..1b46fd763 --- /dev/null +++ b/controllers/license/deploy/charts/license/values.yaml @@ -0,0 +1,109 @@ +# Default values for license. +# This is a YAML-formatted file. +# Declare variables to be passed into your templates. + +# This will set the replicaset count more information can be found here: https://kubernetes.io/docs/concepts/workloads/controllers/replicaset/ +replicaCount: 1 + +# This sets the container image more information can be found here: https://kubernetes.io/docs/concepts/containers/images/ +image: ghcr.io/labring/sealos-license-controller:latest + +# This is for the secretes for pulling an image from a private repository more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/pull-image-private-registry/ +imagePullSecrets: [] +# This is to override the chart name. +nameOverride: "" +fullnameOverride: "" + +#This section builds out the service account more information can be found here: https://kubernetes.io/docs/concepts/security/service-accounts/ +serviceAccount: + # Specifies whether a service account should be created + create: true + # Automatically mount a ServiceAccount's API credentials? + automount: true + # Annotations to add to the service account + annotations: {} + # The name of the service account to use. + # If not set and create is true, a name is generated using the fullname template + name: "" + +# This is for setting Kubernetes Annotations to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/annotations/ +podAnnotations: {} +# This is for setting Kubernetes Labels to a Pod. +# For more information checkout: https://kubernetes.io/docs/concepts/overview/working-with-objects/labels/ +podLabels: {} + +podSecurityContext: + runAsNonRoot: true + # fsGroup: 2000 + +securityContext: + readOnlyRootFilesystem: true + allowPrivilegeEscalation: false + capabilities: + drop: + - "ALL" + +# This is for setting up a service more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/ +service: + # This sets the service type more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#publishing-services-service-types + type: ClusterIP + # This sets the ports more information can be found here: https://kubernetes.io/docs/concepts/services-networking/service/#field-spec-ports + port: 80 + +resources: + limits: + cpu: 500m + memory: 128Mi + requests: + cpu: 10m + memory: 64Mi + # We usually recommend not to specify default resources and to leave this as a conscious + # choice for the user. This also increases chances charts run on environments with little + # resources, such as Minikube. If you do want to specify resources, uncomment the following + # lines, adjust them as necessary, and remove the curly braces after 'resources:'. + # limits: + # cpu: 100m + # memory: 128Mi + # requests: + # cpu: 100m + # memory: 128Mi + +# This is to setup the liveness and readiness probes more information can be found here: https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/ +livenessProbe: + httpGet: + path: /healthz + port: 8081 + initialDelaySeconds: 15 + periodSeconds: 20 +readinessProbe: + httpGet: + path: /readyz + port: 8081 + initialDelaySeconds: 5 + periodSeconds: 10 +# Additional volumes on the output Deployment definition. +volumes: [] +# - name: foo +# secret: +# secretName: mysecret +# optional: false + +# Additional volumeMounts on the output Deployment definition. +volumeMounts: [] +# - name: foo +# mountPath: "/etc/foo" +# readOnly: true + +nodeSelector: {} + +tolerations: [] + +affinity: {} + +metrics: + # Enable metrics endpoint + enabled: false + # Path to the metrics certificate + certPath: /tmp/k8s-metrics-server/metrics-certs + secretName: metrics-server-cert \ No newline at end of file diff --git a/controllers/license/deploy/entrypoint.sh b/controllers/license/deploy/entrypoint.sh new file mode 100644 index 000000000..6f2c469dd --- /dev/null +++ b/controllers/license/deploy/entrypoint.sh @@ -0,0 +1,6 @@ +#!/bin/bash +HELM_OPTS=${HELM_OPTS:-""} +kubectl delete --ignore-not-found -f uninstall +kubectl delete clusterrole license-manager-role --ignore-not-found +helm upgrade -i license -n license-system --create-namespace ./charts/license ${HELM_OPTS} +helm show crds ./charts/license | kubectl apply -f - --server-side --force-conflicts diff --git a/controllers/license/deploy/manifests/deploy.yaml b/controllers/license/deploy/uninstall/deploy.yaml similarity index 90% rename from controllers/license/deploy/manifests/deploy.yaml rename to controllers/license/deploy/uninstall/deploy.yaml index 173f6061c..b35d48c2c 100644 --- a/controllers/license/deploy/manifests/deploy.yaml +++ b/controllers/license/deploy/uninstall/deploy.yaml @@ -29,8 +29,7 @@ apiVersion: apiextensions.k8s.io/v1 kind: CustomResourceDefinition metadata: annotations: - controller-gen.kubebuilder.io/version: v0.8.0 - creationTimestamp: null + controller-gen.kubebuilder.io/version: v0.14.0 name: licenses.license.sealos.io spec: group: license.sealos.io @@ -41,20 +40,32 @@ spec: singular: license scope: Namespaced versions: - - name: v1 + - additionalPrinterColumns: + - jsonPath: .status.phase + name: Phase + type: string + - jsonPath: .metadata.creationTimestamp + name: Age + type: date + name: v1 schema: openAPIV3Schema: description: License is the Schema for the licenses 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' + 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' + 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 @@ -65,7 +76,6 @@ spec: type: string type: enum: - - Account - Cluster type: string type: object @@ -95,12 +105,6 @@ spec: storage: true subresources: status: {} -status: - acceptedNames: - kind: "" - plural: "" - conditions: [] - storedVersions: [] --- apiVersion: v1 kind: ServiceAccount @@ -163,7 +167,6 @@ rules: apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: - creationTimestamp: null name: license-manager-role rules: - apiGroups: @@ -381,9 +384,6 @@ spec: - --leader-elect command: - /manager - envFrom: - - configMapRef: - name: account-manager-env image: ghcr.io/labring/sealos-license-controller:latest livenessProbe: httpGet: diff --git a/controllers/license/deploy/manifests/rbac.yaml b/controllers/license/deploy/uninstall/rbac.yaml similarity index 100% rename from controllers/license/deploy/manifests/rbac.yaml rename to controllers/license/deploy/uninstall/rbac.yaml diff --git a/controllers/license/internal/controller/license_activator.go b/controllers/license/internal/controller/license_activator.go index 1bf796145..c523e9171 100644 --- a/controllers/license/internal/controller/license_activator.go +++ b/controllers/license/internal/controller/license_activator.go @@ -16,65 +16,45 @@ package controller import ( "context" - "fmt" - "strings" "time" - metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" - "sigs.k8s.io/controller-runtime/pkg/client" - licensev1 "github.com/labring/sealos/controllers/license/api/v1" - claimsutil "github.com/labring/sealos/controllers/license/internal/util/claims" licenseutil "github.com/labring/sealos/controllers/license/internal/util/license" - count "github.com/labring/sealos/controllers/pkg/account" - database2 "github.com/labring/sealos/controllers/pkg/database" - types2 "github.com/labring/sealos/controllers/pkg/types" - "github.com/labring/sealos/controllers/pkg/utils/logger" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" + "sigs.k8s.io/controller-runtime/pkg/client" ) type LicenseActivator struct { client.Client - accountDB database2.AccountV2 } -func (l *LicenseActivator) Active(license *licensev1.License) error { - // TODO mv to active function - if license.Spec.Type == licensev1.AccountLicenseType { - if err := l.Recharge(license); err != nil { - return fmt.Errorf("recharge account failed: %w", err) +func (r *LicenseActivator) updateStatus( + ctx context.Context, + nn types.NamespacedName, + status *licensev1.LicenseStatus, +) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + original := &licensev1.License{} + if err := r.Get(ctx, nn, original); err != nil { + return err } - } + original.Status = *status + return r.Client.Status().Update(ctx, original) + }) +} + +func (r *LicenseActivator) Active(ctx context.Context, license *licensev1.License) error { exp, err := licenseutil.GetLicenseExpireTime(license) if err != nil { return err } - license.Status.ExpirationTime = metav1.NewTime(exp) - license.Status.ActivationTime = metav1.NewTime(time.Now()) - license.Status.Phase = licensev1.LicenseStatusPhaseActive + updateStatus := &license.Status + updateStatus.Phase = licensev1.LicenseStatusPhaseActive + updateStatus.Reason = "License activated successfully" + updateStatus.ExpirationTime = metav1.NewTime(exp) + updateStatus.ActivationTime = metav1.NewTime(time.Now()) - if err := l.Status().Update(context.Background(), license); err != nil { - return fmt.Errorf("update license status failed: %w", err) - } - return nil -} - -func (l *LicenseActivator) Recharge(license *licensev1.License) error { - claims, err := licenseutil.GetClaims(license) - if err != nil { - return err - } - - var data = &claimsutil.AccountClaimData{} - if err := claims.Data.SwitchToAccountData(data); err != nil { - return err - } - owner := GetNameByNameSpace(license.Namespace) - - logger.Info("recharge account", "crName", owner, "amount", data.Amount) - - return l.accountDB.AddBalance(&types2.UserQueryOpts{Owner: owner}, data.Amount*count.CurrencyUnit) -} - -func GetNameByNameSpace(ns string) string { - return strings.TrimPrefix(ns, "ns-") + return r.updateStatus(ctx, client.ObjectKeyFromObject(license), updateStatus) } diff --git a/controllers/license/internal/controller/license_controller.go b/controllers/license/internal/controller/license_controller.go index a17ea0d4f..22caa0c87 100644 --- a/controllers/license/internal/controller/license_controller.go +++ b/controllers/license/internal/controller/license_controller.go @@ -18,20 +18,27 @@ package controller import ( "context" + "errors" "fmt" "time" "github.com/go-logr/logr" - + licensev1 "github.com/labring/sealos/controllers/license/api/v1" + licenseutil "github.com/labring/sealos/controllers/license/internal/util/license" + 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/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/client-go/util/retry" ctrl "sigs.k8s.io/controller-runtime" "sigs.k8s.io/controller-runtime/pkg/builder" "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" + "sigs.k8s.io/controller-runtime/pkg/manager" "sigs.k8s.io/controller-runtime/pkg/predicate" - - licensev1 "github.com/labring/sealos/controllers/license/api/v1" - licenseutil "github.com/labring/sealos/controllers/license/internal/util/license" - database2 "github.com/labring/sealos/controllers/pkg/database" ) // LicenseReconciler reconciles a License object @@ -39,15 +46,29 @@ type LicenseReconciler struct { client.Client Scheme *runtime.Scheme Logger logr.Logger - //finalizer *ctrlsdk.Finalizer + // finalizer *ctrlsdk.Finalizer - ClusterID string + ClusterID string + CreateTimestamp metav1.Time validator *LicenseValidator activator *LicenseActivator } -var requeueRes = ctrl.Result{RequeueAfter: time.Minute} +var ( + requeueRes = ctrl.Result{RequeueAfter: time.Minute} + longRequeueRes = ctrl.Result{RequeueAfter: 30 * time.Minute} + immediateRequeue = ctrl.Result{Requeue: true} +) + +const ( + licenseFinalizer = "license.sealos.io/finalizer" + licenseProtectionFinalizer = "license.sealos.io/default-license" + defaultLicenseName = "default" + defaultLicenseNamespace = "ns-admin" +) + +var defaultLicenseDuration = 30 * 24 * time.Hour // +kubebuilder:rbac:groups=license.sealos.io,resources=licenses,verbs=get;list;watch;create;update;patch;delete // +kubebuilder:rbac:groups=license.sealos.io,resources=licenses/status,verbs=get;update;patch @@ -60,75 +81,115 @@ func (r *LicenseReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct if err := r.Get(ctx, req.NamespacedName, license); err != nil { return ctrl.Result{}, client.IgnoreNotFound(err) } - if !license.DeletionTimestamp.IsZero() { + + if license.DeletionTimestamp.IsZero() { + // Ensure required finalizers exist; reconcile only after they are set. + addedFinalizer := false + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := &licensev1.License{} + if err := r.Get(ctx, req.NamespacedName, latest); err != nil { + return err + } + changed := controllerutil.AddFinalizer(latest, licenseFinalizer) + if isDefaultLicense(latest) { + changed = controllerutil.AddFinalizer(latest, licenseProtectionFinalizer) || changed + } + if !changed { + return nil + } + addedFinalizer = true + return r.Update(ctx, latest) + }); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to add finalizer: %w", err) + } + if addedFinalizer { + // Requeue immediately so the reconciler can proceed with the next step. + return immediateRequeue, nil + } + } else { + if controllerutil.ContainsFinalizer(license, licenseProtectionFinalizer) && isDefaultLicense(license) { + r.Logger.Info("deletion blocked by protection finalizer", "license", req.NamespacedName) + return ctrl.Result{}, nil + } + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := &licensev1.License{} + if err := r.Get(ctx, req.NamespacedName, latest); err != nil { + return err + } + if controllerutil.RemoveFinalizer(latest, licenseFinalizer) { + return r.Update(ctx, latest) + } + return nil + }); err != nil { + return ctrl.Result{}, fmt.Errorf("failed to remove finalizer: %w", err) + } return ctrl.Result{}, nil } + if isDefaultLicense(license) { + if err := r.ensureAndUpdateDefaultLicense(ctx, license); err != nil { + return ctrl.Result{}, err + } + return longRequeueRes, nil + } return r.reconcile(ctx, license) } -func (r *LicenseReconciler) reconcile(ctx context.Context, license *licensev1.License) (ctrl.Result, error) { - r.Logger.V(1).Info("reconcile for license", "license", license.Namespace+"/"+license.Name) - // check if license is valid - valid, err := r.validator.Validate(license) - if err != nil { - r.Logger.V(1).Error(err, "failed to validate license") +func (r *LicenseReconciler) reconcile( + ctx context.Context, + license *licensev1.License, +) (ctrl.Result, error) { + nsName := fmt.Sprintf("%s/%s", license.Namespace, license.Name) + r.Logger.V(1).Info("reconcile for license", "license", nsName) + + if err := r.validator.Validate(ctx, license); err != nil { + // Extract validation code if it's a ValidationError + var reason string + + var validationErr licenseutil.ValidationError + var phase licensev1.LicenseStatusPhase + if errors.As(err, &validationErr) { + reason = validationErr.Message + switch validationErr.Code { + case licensev1.ValidationExpired: + phase = licensev1.LicenseStatusPhaseExpired + case licensev1.ValidationClusterInfoMismatch: + phase = licensev1.LicenseStatusPhaseInvalid + case licensev1.ValidationClusterIDMismatch: + phase = licensev1.LicenseStatusPhaseMismatch + default: + phase = licensev1.LicenseStatusPhaseFailed + } + } else { + // Handle non-ValidationError + reason = fmt.Sprintf("license validation failed: %v", err) + phase = licensev1.LicenseStatusPhaseFailed + } + updateStatus := &license.Status + updateStatus.Phase = phase + updateStatus.Reason = reason + updateStatus.ActivationTime = license.Status.ActivationTime + updateStatus.ExpirationTime = license.Status.ExpirationTime + if updateErr := r.updateStatus(ctx, client.ObjectKeyFromObject(license), updateStatus); updateErr != nil { + r.Logger.V(1). + Error(updateErr, "failed to update license status after validation failure", "license", nsName) + return requeueRes, updateErr + } + r.Logger.V(1).Error(err, "failed to validate license", "license", nsName) return requeueRes, err } - claims, err := licenseutil.GetClaims(license) - if err != nil { - return ctrl.Result{}, err - } - - switch valid { - case licensev1.ValidationClusterIDMismatch: - license.Status.Phase = licensev1.LicenseStatusPhaseFailed - license.Status.ValidationCode = valid - license.Status.Reason = fmt.Sprintf("cluster id mismatch, license cluster id: %s, cluster id: %s", claims.ClusterID, r.ClusterID) - r.Logger.V(1).Info("cluster id mismatch", "license", license.Namespace+"/"+license.Name, "cluster id", r.ClusterID, "license cluster id", claims.ClusterID) - _ = r.Status().Update(ctx, license) - return requeueRes, nil - case licensev1.ValidationClusterInfoMismatch: - license.Status.Phase = licensev1.LicenseStatusPhaseFailed - license.Status.ValidationCode = valid - license.Status.Reason = fmt.Sprintf("cluster info mismatch, license cluster info: %v", claims.Data) - r.Logger.V(1).Info("cluster info mismatch", "license", license.Namespace+"/"+license.Name, "cluster info", claims.Data) - _ = r.Status().Update(ctx, license) - return requeueRes, nil - case licensev1.ValidationExpired: - license.Status.Phase = licensev1.LicenseStatusPhaseFailed - license.Status.ValidationCode = valid - license.Status.Reason = "license is expired" - r.Logger.V(1).Info("license is invalid", "license", license.Namespace+"/"+license.Name, "reason", valid) - _ = r.Status().Update(ctx, license) - return requeueRes, nil - case licensev1.ValidationError: - license.Status.Phase = licensev1.LicenseStatusPhaseFailed - license.Status.ValidationCode = valid - license.Status.Reason = "failed to validate license" - r.Logger.V(1).Info("failed to validate license", "license", license.Namespace+"/"+license.Name) - _ = r.Status().Update(ctx, license) - return requeueRes, nil - case licensev1.ValidationSuccess: - // do nothing - default: - r.Logger.V(1).Info("unknown validation code", "code", valid) - } - - if license.Spec.Type == licensev1.AccountLicenseType && license.Status.Phase == licensev1.LicenseStatusPhaseActive { - r.Logger.V(1).Info("license is active, skip reconcile", "license", license.Namespace+"/"+license.Name) - return requeueRes, nil - } - - if err := r.activator.Active(license); err != nil { - r.Logger.V(1).Error(err, "failed to active license") + if err := r.activator.Active(ctx, license); err != nil { + // Update license status to failed when activation fails + r.Logger.V(1).Error(err, "failed to activate license", "license", nsName) return requeueRes, err } - return ctrl.Result{RequeueAfter: time.Minute * 30}, nil + r.Logger.V(1). + Info("license activated successfully", "license", nsName, "phase", license.Status.Phase) + return longRequeueRes, nil } // SetupWithManager sets up the controller with the Manager. -func (r *LicenseReconciler) SetupWithManager(mgr ctrl.Manager, accountDB database2.AccountV2) error { +func (r *LicenseReconciler) SetupWithManager(mgr ctrl.Manager) error { r.Logger = mgr.GetLogger().WithName("controller").WithName("License") r.Client = mgr.GetClient() @@ -139,12 +200,149 @@ func (r *LicenseReconciler) SetupWithManager(mgr ctrl.Manager, accountDB databas } r.activator = &LicenseActivator{ - Client: r.Client, - accountDB: accountDB, + Client: r.Client, } + if err := mgr.Add(manager.RunnableFunc(func(ctx context.Context) error { + ticker := time.NewTicker(5 * time.Minute) + defer ticker.Stop() + if err := r.ensureAndUpdateDefaultLicense(ctx, nil); err != nil { + r.Logger.Error(err, "periodic default license ensure failed") + } + for { + select { + case <-ctx.Done(): + return nil + case <-ticker.C: + if err := r.ensureAndUpdateDefaultLicense(ctx, nil); err != nil { + r.Logger.Error(err, "periodic default license ensure failed") + } + } + } + })); err != nil { + return err + } // reconcile on generation change return ctrl.NewControllerManagedBy(mgr). + WithOptions(controller.Options{MaxConcurrentReconciles: 10}). For(&licensev1.License{}, builder.WithPredicates(predicate.And(predicate.GenerationChangedPredicate{}))). Complete(r) } + +func isDefaultLicense(license *licensev1.License) bool { + return license.Name == defaultLicenseName && license.Namespace == defaultLicenseNamespace +} + +func (r *LicenseReconciler) ensureAndUpdateDefaultLicense( + ctx context.Context, + cached *licensev1.License, +) error { + ns := defaultLicenseNamespace + namespacedName := types.NamespacedName{ + Name: defaultLicenseName, + Namespace: ns, + } + + license := cached + var err error + if license == nil { + license = &licensev1.License{} + err = r.Get(ctx, namespacedName, license) + } + + switch { + case err == nil: + // ensure finalizers + if err := retry.RetryOnConflict(retry.DefaultRetry, func() error { + latest := &licensev1.License{} + if err := r.Get(ctx, namespacedName, latest); err != nil { + return err + } + changed := controllerutil.AddFinalizer(latest, licenseFinalizer) + if controllerutil.AddFinalizer(latest, licenseProtectionFinalizer) { + changed = true + } + if !changed { + return nil + } + return r.Update(ctx, latest) + }); err != nil { + return err + } + case apierrors.IsNotFound(err): + license = &licensev1.License{ + ObjectMeta: metav1.ObjectMeta{ + Name: defaultLicenseName, + Namespace: ns, + Finalizers: []string{ + licenseFinalizer, + licenseProtectionFinalizer, + }, + }, + Spec: licensev1.LicenseSpec{ + Type: licensev1.ClusterLicenseType, + }, + } + if err := r.Create(ctx, license); err != nil { + return fmt.Errorf("failed to create default license: %w", err) + } + r.Logger.Info( + "default license created", + "license", + fmt.Sprintf("%s/%s", ns, defaultLicenseName), + ) + default: + return err + } + expiration := r.CreateTimestamp.Add(defaultLicenseDuration) + devboxInstalled, err := r.isDevBoxInstalled(ctx) + if err != nil { + return fmt.Errorf("failed to check devbox installation: %w", err) + } + if !devboxInstalled { + // extend default license for devbox users + expiration = r.CreateTimestamp.Add(50 * 365 * 24 * time.Hour) + } + updateStatus := &license.Status + updateStatus.ActivationTime = r.CreateTimestamp + updateStatus.ExpirationTime = metav1.NewTime(expiration) + if time.Now().After(expiration) { + updateStatus.Phase = licensev1.LicenseStatusPhaseExpired + updateStatus.Reason = "default license expired" + } else { + updateStatus.Phase = licensev1.LicenseStatusPhaseActive + updateStatus.Reason = "default license active" + } + return r.updateStatus(ctx, client.ObjectKeyFromObject(license), updateStatus) +} + +func (r *LicenseReconciler) updateStatus( + ctx context.Context, + nn types.NamespacedName, + status *licensev1.LicenseStatus, +) error { + return retry.RetryOnConflict(retry.DefaultRetry, func() error { + original := &licensev1.License{} + if err := r.Get(ctx, nn, original); err != nil { + return err + } + original.Status = *status + return r.Client.Status().Update(ctx, original) + }) +} + +func (r *LicenseReconciler) isDevBoxInstalled(ctx context.Context) (bool, error) { + crd := &unstructured.Unstructured{} + crd.SetGroupVersionKind(schema.GroupVersionKind{ + Group: "apiextensions.k8s.io", + Version: "v1", + Kind: "CustomResourceDefinition", + }) + if err := r.Get(ctx, types.NamespacedName{Name: "devboxes.devbox.sealos.io"}, crd); err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} diff --git a/controllers/license/internal/controller/license_validator.go b/controllers/license/internal/controller/license_validator.go index c2bfdc64a..18fa9ab38 100644 --- a/controllers/license/internal/controller/license_validator.go +++ b/controllers/license/internal/controller/license_validator.go @@ -16,17 +16,16 @@ package controller import ( "context" + "fmt" "github.com/go-logr/logr" - - v1 "k8s.io/api/core/v1" - "k8s.io/apimachinery/pkg/api/resource" - "sigs.k8s.io/controller-runtime/pkg/client" - licensev1 "github.com/labring/sealos/controllers/license/api/v1" "github.com/labring/sealos/controllers/license/internal/util/claims" "github.com/labring/sealos/controllers/license/internal/util/cluster" licenseutil "github.com/labring/sealos/controllers/license/internal/util/license" + v1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/resource" + "sigs.k8s.io/controller-runtime/pkg/client" ) type LicenseValidator struct { @@ -35,10 +34,10 @@ type LicenseValidator struct { ClusterID string } -func (v *LicenseValidator) Validate(license *licensev1.License) (licensev1.ValidationCode, error) { +func (v *LicenseValidator) Validate(ctx context.Context, license *licensev1.License) error { nodeList := &v1.NodeList{} - if err := v.Client.List(context.Background(), nodeList); err != nil { - return licensev1.ValidationError, err + if err := v.List(ctx, nodeList); err != nil { + return fmt.Errorf("failed to list cluster nodes: %w", err) } nodeCount := len(nodeList.Items) totalCPU := resource.NewQuantity(0, resource.DecimalSI) @@ -58,6 +57,12 @@ func (v *LicenseValidator) Validate(license *licensev1.License) (licensev1.Valid TotalMemory: int(totalMemory.Value() / (1024 * 1024 * 1024)), }, } - v.Logger.Info("Validating license", "cluster info", clusterInfo, "license token", license.Spec.Token) + v.Logger.Info( + "Validating license", + "cluster info", + clusterInfo, + "license token", + license.Spec.Token, + ) return licenseutil.IsLicenseValid(license, clusterInfo, v.ClusterID) } diff --git a/controllers/license/internal/controller/suite_test.go b/controllers/license/internal/controller/suite_test.go index 4a837aa79..fef6fd92d 100644 --- a/controllers/license/internal/controller/suite_test.go +++ b/controllers/license/internal/controller/suite_test.go @@ -20,26 +20,25 @@ import ( "path/filepath" "testing" + licensev1 "github.com/labring/sealos/controllers/license/api/v1" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" - "k8s.io/client-go/kubernetes/scheme" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/envtest" logf "sigs.k8s.io/controller-runtime/pkg/log" "sigs.k8s.io/controller-runtime/pkg/log/zap" - - licensev1 "github.com/labring/sealos/controllers/license/api/v1" - //+kubebuilder:scaffold:imports ) // These tests use Ginkgo (BDD-style Go testing framework). Refer to // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. -var cfg *rest.Config -var k8sClient client.Client -var testEnv *envtest.Environment +var ( + cfg *rest.Config + k8sClient client.Client + testEnv *envtest.Environment +) func TestControllers(t *testing.T) { RegisterFailHandler(Fail) @@ -70,7 +69,6 @@ var _ = BeforeSuite(func() { k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) Expect(err).NotTo(HaveOccurred()) Expect(k8sClient).NotTo(BeNil()) - }) var _ = AfterSuite(func() { diff --git a/controllers/license/internal/util/claims/claims.go b/controllers/license/internal/util/claims/claims.go index c9390d7fe..5994ff2e4 100644 --- a/controllers/license/internal/util/claims/claims.go +++ b/controllers/license/internal/util/claims/claims.go @@ -16,9 +16,8 @@ package claims import ( "github.com/golang-jwt/jwt/v4" - "github.com/mitchellh/mapstructure" - v1 "github.com/labring/sealos/controllers/license/api/v1" + "github.com/mitchellh/mapstructure" ) // Claims is the data structure of license claims @@ -30,20 +29,12 @@ type Claims struct { jwt.RegisteredClaims } -type ClaimData map[string]interface{} - -func (c *ClaimData) SwitchToAccountData(data *AccountClaimData) error { - return mapstructure.Decode(c, data) -} +type ClaimData map[string]any func (c *ClaimData) SwitchToClusterData(data *ClusterClaimData) error { return mapstructure.Decode(c, data) } -type AccountClaimData struct { - Amount int64 `json:"amount"` -} - // ClusterClaimData is the data structure of cluster license // example: // { @@ -53,15 +44,16 @@ type AccountClaimData struct { // } type ClusterClaimData struct { - NodeCount int `json:"nodeCount"` - TotalCPU int `json:"totalCPU"` // in core - TotalMemory int `json:"totalMemory"` // in GB + NodeCount int `json:"nodeCount" mapstructure:"nodeCount"` + TotalCPU int `json:"totalCPU" mapstructure:"totalCPU"` // in core + TotalMemory int `json:"totalMemory" mapstructure:"totalMemory"` // in GB } // Compare compares the claims with the data // return true if the claims is equal or lager to the data func (c *ClusterClaimData) Compare(data *ClusterClaimData) bool { - if c.NodeCount > data.NodeCount || c.TotalCPU > data.TotalCPU || c.TotalMemory > data.TotalMemory { + if c.NodeCount > data.NodeCount || c.TotalCPU > data.TotalCPU || + c.TotalMemory > data.TotalMemory { return false } return true diff --git a/controllers/license/internal/util/claims/claims_test.go b/controllers/license/internal/util/claims/claims_test.go index 267362ee3..0467ecae8 100644 --- a/controllers/license/internal/util/claims/claims_test.go +++ b/controllers/license/internal/util/claims/claims_test.go @@ -15,41 +15,9 @@ package claims import ( - "fmt" "testing" ) -func TestClaimData_SwitchToAccountData(t *testing.T) { - type args struct { - data *AccountClaimData - } - tests := []struct { - name string - c ClaimData - args args - wantErr bool - }{ - { - name: "test", - c: ClaimData{ - "amount": 100, - }, - args: args{ - data: &AccountClaimData{}, - }, - wantErr: false, - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if err := tt.c.SwitchToAccountData(tt.args.data); (err != nil) != tt.wantErr { - t.Errorf("SwitchToAccountData() error = %v, wantErr %v", err, tt.wantErr) - } - fmt.Printf("%v", tt.args.data) - }) - } -} - func TestClaimData_SwitchToClusterData(t *testing.T) { type args struct { data *ClusterClaimData diff --git a/controllers/license/internal/util/cluster/cluster.go b/controllers/license/internal/util/cluster/cluster.go index 10dd175ad..fcb594619 100644 --- a/controllers/license/internal/util/cluster/cluster.go +++ b/controllers/license/internal/util/cluster/cluster.go @@ -31,5 +31,5 @@ func (i *Info) CompareWithClaimData(data *claims.ClaimData) bool { if err != nil { return false } - return i.ClusterClaimData.Compare(cdata) + return i.Compare(cdata) } diff --git a/controllers/license/internal/util/clusterid/cluster_id.go b/controllers/license/internal/util/clusterid/cluster_id.go index 5dbf48fba..ec401b458 100644 --- a/controllers/license/internal/util/clusterid/cluster_id.go +++ b/controllers/license/internal/util/clusterid/cluster_id.go @@ -16,16 +16,16 @@ package clusterid import ( "context" - "fmt" + "errors" corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/client-go/rest" "sigs.k8s.io/controller-runtime/pkg/client" ) -func GetClusterID(config *rest.Config) (string, error) { +func GetClusterID(ctx context.Context, config *rest.Config) (string, error) { ns := &corev1.Namespace{} - ctx := context.Background() c, err := client.New(config, client.Options{}) if err != nil { return "", err @@ -36,7 +36,24 @@ func GetClusterID(config *rest.Config) (string, error) { } res := string(ns.UID) if res == "" || len(res) < 8 { - return "", fmt.Errorf("failed to get cluster id") + return "", errors.New("failed to get cluster id") } return res[0:8], nil } + +func GetClusterCreateTime(ctx context.Context, config *rest.Config) (*metav1.Time, error) { + ns := &corev1.Namespace{} + c, err := client.New(config, client.Options{}) + if err != nil { + return nil, err + } + err = c.Get(ctx, client.ObjectKey{Name: "kube-system"}, ns) + if err != nil { + return nil, err + } + res := ns.GetCreationTimestamp() + if res.IsZero() { + return nil, errors.New("failed to get create time") + } + return &res, nil +} diff --git a/controllers/license/internal/util/database/database.go b/controllers/license/internal/util/database/database.go deleted file mode 100644 index 6cee5675c..000000000 --- a/controllers/license/internal/util/database/database.go +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright © 2023 sealos. -// -// 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 database - -import ( - "context" - - "github.com/labring/sealos/controllers/license/internal/util/meta" - "github.com/labring/sealos/controllers/pkg/utils/logger" - - "go.mongodb.org/mongo-driver/bson" - "go.mongodb.org/mongo-driver/mongo" - mongoOptions "go.mongodb.org/mongo-driver/mongo/options" -) - -type DataBase struct { - URI string - Client *mongo.Client - - licenseCollection *mongo.Collection - clusterIDCollection *mongo.Collection -} - -const ( - DefaultLicenseDataBase = "sealos-license" - DefaultLicenseCollection = "license" - DefaultClusterIDCollection = "cluster-id" -) - -func New(ctx context.Context, uri string) (*DataBase, error) { - client, err := mongo.Connect(ctx, mongoOptions.Client().ApplyURI(uri)) - if err != nil { - return &DataBase{}, err - } - if err := client.Ping(ctx, nil); err != nil { - return nil, err - } - return &DataBase{ - URI: uri, - Client: client, - licenseCollection: client.Database(DefaultLicenseDataBase).Collection(DefaultLicenseCollection), - clusterIDCollection: client.Database(DefaultLicenseDataBase).Collection(DefaultClusterIDCollection), - }, nil -} - -func (db *DataBase) StoreLicenseMeta(ctx context.Context, meta *meta.Meta) error { - _, err := db.licenseCollection.InsertOne(ctx, meta) - return err -} - -func (db *DataBase) GetLicenseMeta(ctx context.Context, token string) (*meta.Meta, error) { - filter := bson.M{"token": token} - lic := &meta.Meta{} - err := db.licenseCollection.FindOne(ctx, filter).Decode(lic) - return lic, err -} - -func (db *DataBase) GetClusterID(ctx context.Context) (string, error) { - var result struct { - ClusterID string `bson:"cluster-id"` - } - err := db.licenseCollection.FindOne(ctx, bson.M{}).Decode(&result) - if err != nil { - return "", err - } - return result.ClusterID, nil -} - -func (db *DataBase) StoreClusterID(ctx context.Context, clusterID string) error { - document := bson.M{"cluster-id": clusterID} - _, err := db.licenseCollection.InsertOne(ctx, document) - if err != nil { - return err - } - return nil -} - -func (db *DataBase) Disconnect(ctx context.Context) { - err := db.Client.Disconnect(ctx) - if err != nil { - logger.Error(err, "disconnect from database failed") - return - } -} diff --git a/controllers/license/internal/util/database/database_test.go b/controllers/license/internal/util/database/database_test.go deleted file mode 100644 index 5f8df1b46..000000000 --- a/controllers/license/internal/util/database/database_test.go +++ /dev/null @@ -1,17 +0,0 @@ -// Copyright © 2023 sealos. -// -// 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 database - -// TODO Add tests diff --git a/controllers/license/internal/util/errors/errors.go b/controllers/license/internal/util/errors/errors.go index a1d00b09f..0f1a8c05f 100644 --- a/controllers/license/internal/util/errors/errors.go +++ b/controllers/license/internal/util/errors/errors.go @@ -15,12 +15,7 @@ package errors import ( - "fmt" + "errors" ) -var ErrLicenseInvalid = fmt.Errorf("the license provided appears to be invalid") -var ErrLicenseTypeNotMatch = fmt.Errorf("the license type provided appears to be invalid") -var ErrClaimsConvent = fmt.Errorf("the claims data provided appears to be invalid") -var ErrClusterIDNotMatch = fmt.Errorf("the cluster id provided appears to be invalid") -var ErrClusterLicenseNotMatch = fmt.Errorf("the cluster license provided appears to be invalid") -var ErrLicenseExpired = fmt.Errorf("the license provided appears to be expired") +var ErrClaimsConvent = errors.New("the claims data provided appears to be invalid") diff --git a/controllers/license/internal/util/key/key.go b/controllers/license/internal/util/key/key.go index 90b7bd967..5b07e9b0c 100644 --- a/controllers/license/internal/util/key/key.go +++ b/controllers/license/internal/util/key/key.go @@ -14,5 +14,9 @@ package key -// EncryptionKey is the key used to decode license token, base64 encoded -var EncryptionKey = "ZHd6b2Nyc3NrcXdwZm5lb2twZmV5dGNxa2JnZ3Znem8=" +// encryptionKey is the key used to decode license token, base64 encoded +var encryptionKey = "ZHd6b2Nyc3NrcXdwZm5lb2twZmV5dGNxa2JnZ3Znem8=" + +func GetEncryptionKey() string { + return encryptionKey +} diff --git a/controllers/license/internal/util/license/validate.go b/controllers/license/internal/util/license/validate.go index 2854a5492..adbdbd64e 100644 --- a/controllers/license/internal/util/license/validate.go +++ b/controllers/license/internal/util/license/validate.go @@ -16,10 +16,10 @@ package license import ( "encoding/base64" + "fmt" "time" "github.com/golang-jwt/jwt/v4" - licensev1 "github.com/labring/sealos/controllers/license/api/v1" utilclaims "github.com/labring/sealos/controllers/license/internal/util/claims" "github.com/labring/sealos/controllers/license/internal/util/cluster" @@ -28,14 +28,32 @@ import ( "github.com/labring/sealos/controllers/pkg/crypto" ) +// ValidationError represents a license validation error with structured information +type ValidationError struct { + Code licensev1.ValidationCode + Message string +} + +func (e ValidationError) Error() string { + return e.Message +} + +// NewValidationError creates a new ValidationError with the given code and message +func NewValidationError(code licensev1.ValidationCode, message string) error { + return ValidationError{ + Code: code, + Message: message, + } +} + func ParseLicenseToken(license *licensev1.License) (*jwt.Token, error) { token, err := jwt.ParseWithClaims(license.Spec.Token, &utilclaims.Claims{}, - func(_ *jwt.Token) (interface{}, error) { - decodeKey, err := base64.StdEncoding.DecodeString(key.EncryptionKey) + func(_ *jwt.Token) (any, error) { + decodeKey, err := base64.StdEncoding.DecodeString(key.GetEncryptionKey()) if err != nil { return nil, err } - publicKey, err := crypto.ParseRSAPublicKeyFromPEM(string(decodeKey)) + publicKey, err := crypto.ParseRSAPublicKeyFromAnyPEM(string(decodeKey)) if err != nil { return nil, err } @@ -59,29 +77,67 @@ func GetClaims(license *licensev1.License) (*utilclaims.Claims, error) { return claims, nil } -func IsLicenseValid(license *licensev1.License, clusterInfo *cluster.Info, clusterID string) (licensev1.ValidationCode, error) { +func IsLicenseValid( + license *licensev1.License, + clusterInfo *cluster.Info, + clusterID string, +) error { token, err := ParseLicenseToken(license) if err != nil { - return licensev1.ValidationError, err + return NewValidationError( + licensev1.ValidationError, + fmt.Sprintf("failed to parse license token: %v", err), + ) } if !token.Valid { - return licensev1.ValidationExpired, nil + // Get the expiration time from claims to provide more detailed error message + claims, ok := token.Claims.(*utilclaims.Claims) + if ok && claims.ExpiresAt != nil { + return NewValidationError( + licensev1.ValidationExpired, + "license has expired on "+claims.ExpiresAt.Format(time.DateTime), + ) + } + return NewValidationError( + licensev1.ValidationExpired, + "license has expired and is no longer valid", + ) } + claims, err := GetClaims(license) if err != nil { - return licensev1.ValidationError, err + return NewValidationError( + licensev1.ValidationError, + fmt.Sprintf("failed to get license claims: %v", err), + ) } + // if clusterID is empty, it means this license is a super license. if claims.ClusterID != "" && claims.ClusterID != clusterID { - return licensev1.ValidationClusterIDMismatch, nil + return NewValidationError( + licensev1.ValidationClusterIDMismatch, + fmt.Sprintf( + "license cluster ID mismatch: license cluster ID is '%s' but current cluster ID is '%s'", + claims.ClusterID, + clusterID, + ), + ) } if claims.Type == licensev1.ClusterLicenseType { if !clusterInfo.CompareWithClaimData(&claims.Data) { - return licensev1.ValidationClusterInfoMismatch, nil + return NewValidationError( + licensev1.ValidationClusterInfoMismatch, + fmt.Sprintf( + "license cluster constraints not met: license requires %v but current cluster does not satisfy these constraints", + claims.Data, + ), + ) } } - return licensev1.ValidationSuccess, nil + + // License is valid + return nil } func GetLicenseExpireTime(license *licensev1.License) (time.Time, error) { diff --git a/controllers/license/internal/util/license/validate_test.go b/controllers/license/internal/util/license/validate_test.go index c00825786..a2195d701 100644 --- a/controllers/license/internal/util/license/validate_test.go +++ b/controllers/license/internal/util/license/validate_test.go @@ -15,15 +15,53 @@ package license import ( + "encoding/base64" "testing" + "time" "github.com/golang-jwt/jwt/v4" - licensev1 "github.com/labring/sealos/controllers/license/api/v1" utilclaims "github.com/labring/sealos/controllers/license/internal/util/claims" "github.com/labring/sealos/controllers/license/internal/util/cluster" + "github.com/labring/sealos/controllers/license/internal/util/key" ) +func newTestLicense(t *testing.T) (*licensev1.License, *utilclaims.Claims) { + t.Helper() + decodeKey, err := base64.StdEncoding.DecodeString(key.GetEncryptionKey()) + if err != nil { + t.Fatalf("failed to decode encryption key: %v", err) + } + privateKey, err := jwt.ParseRSAPrivateKeyFromPEM(decodeKey) + if err != nil { + t.Fatalf("failed to parse private key: %v", err) + } + claimData := utilclaims.ClaimData{ + "nodeCount": 3, + "totalCPU": 16, + "totalMemory": 32, + } + claims := &utilclaims.Claims{ + Type: licensev1.ClusterLicenseType, + ClusterID: "test-cluster", + Data: claimData, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(time.Hour)), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims) + signed, err := token.SignedString(privateKey) + if err != nil { + t.Fatalf("failed to sign test token: %v", err) + } + return &licensev1.License{ + Spec: licensev1.LicenseSpec{ + Token: signed, + Type: claims.Type, + }, + }, claims +} + func TestIsLicenseValid(t *testing.T) { type args struct { license *licensev1.License @@ -35,34 +73,36 @@ func TestIsLicenseValid(t *testing.T) { wantErr bool }{ { - name: "test", - args: args{ - license: &licensev1.License{ - Spec: licensev1.LicenseSpec{ - Token: "", - Type: licensev1.ClusterLicenseType, + name: "valid license", + args: func() args { + license, claims := newTestLicense(t) + clusterData := &utilclaims.ClusterClaimData{} + if err := claims.Data.SwitchToClusterData(clusterData); err != nil { + t.Fatalf("failed to convert cluster data: %v", err) + } + return args{ + license: license, + data: &cluster.Info{ + ClusterID: claims.ClusterID, + ClusterClaimData: *clusterData, }, - }, - data: &cluster.Info{ - ClusterID: "", - ClusterClaimData: utilclaims.ClusterClaimData{ - NodeCount: 3, - TotalCPU: 16, - TotalMemory: 32, - }, - }, - }, + } + }(), wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := IsLicenseValid(tt.args.license, tt.args.data, "") + err := IsLicenseValid(tt.args.license, tt.args.data, tt.args.data.ClusterID) if (err != nil) != tt.wantErr { t.Errorf("IsLicenseValid() error = %v, wantErr %v", err, tt.wantErr) return } - t.Log(got) + if err != nil { + // If there's an error, the test is expecting it, so we're done + return + } + // If no error, validation succeeded, which is what we want }) } } @@ -78,25 +118,27 @@ func TestParseLicenseToken(t *testing.T) { wantErr bool }{ { - name: "test", - args: args{ - license: &licensev1.License{ - Spec: licensev1.LicenseSpec{ - Token: "", - }, - }, - }, + name: "parse valid token", + args: func() args { + license, _ := newTestLicense(t) + return args{license: license} + }(), wantErr: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := ParseLicenseToken(tt.args.license) - t.Log(got.Claims) if (err != nil) != tt.wantErr { t.Errorf("ParseLicenseToken() error = %v, wantErr %v", err, tt.wantErr) return } + if got == nil || !got.Valid { + t.Fatalf("ParseLicenseToken() returned invalid token: %+v", got) + } + if _, ok := got.Claims.(*utilclaims.Claims); !ok { + t.Fatalf("ParseLicenseToken() claims type mismatch") + } }) } } @@ -105,6 +147,7 @@ func TestGetClaims(t *testing.T) { type args struct { license *licensev1.License } + license, expectedClaims := newTestLicense(t) tests := []struct { name string args args @@ -112,14 +155,9 @@ func TestGetClaims(t *testing.T) { wantErr bool }{ { - name: "test", - args: args{ - license: &licensev1.License{ - Spec: licensev1.LicenseSpec{ - Token: "", - }, - }, - }, + name: "get claims from valid token", + args: args{license: license}, + want: expectedClaims, }, } for _, tt := range tests { @@ -129,7 +167,9 @@ func TestGetClaims(t *testing.T) { t.Errorf("GetClaims() error = %v, wantErr %v", err, tt.wantErr) return } - t.Log(got) + if got.Type != tt.want.Type || got.ClusterID != tt.want.ClusterID { + t.Errorf("GetClaims() got = %+v, want = %+v", got, tt.want) + } }) } } diff --git a/controllers/license/internal/util/meta/meta.go b/controllers/license/internal/util/meta/meta.go deleted file mode 100644 index 13d812aec..000000000 --- a/controllers/license/internal/util/meta/meta.go +++ /dev/null @@ -1,41 +0,0 @@ -// Copyright © 2023 sealos. -// -// 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 meta - -import ( - licensev1 "github.com/labring/sealos/controllers/license/api/v1" - claimsutil "github.com/labring/sealos/controllers/license/internal/util/claims" - licenseutil "github.com/labring/sealos/controllers/license/internal/util/license" -) - -// Meta is the license metadata, which will be stored in database - -type Meta struct { - Token string `bson:"token"` - ActivationTime string `bson:"activationTime"` - Claims claimsutil.Claims `bson:"claims"` -} - -func New(license *licensev1.License) (*Meta, error) { - claims, err := licenseutil.GetClaims(license) - if err != nil { - return nil, err - } - return &Meta{ - Token: license.Spec.Token, - ActivationTime: license.Status.ActivationTime.Format("2006-01-02 15:04:05"), - Claims: *claims, - }, nil -} diff --git a/controllers/objectstorage/Makefile b/controllers/objectstorage/Makefile index ec50253fe..dc963eb3c 100644 --- a/controllers/objectstorage/Makefile +++ b/controllers/objectstorage/Makefile @@ -64,7 +64,6 @@ test: manifests generate fmt vet envtest ## Run tests. .PHONY: build build: ## Build manager binary. LD_FLAGS="-s -w"; \ - [ -n "$(CRYPTOKEY)" ] && LD_FLAGS+=" -X github.com/labring/sealos/controllers/pkg/crypto.encryptionKey=${CRYPTOKEY} -X github.com/labring/sealos/controllers/pkg/database.cryptoKey=${CRYPTOKEY}"; \ CGO_ENABLED=0 GOOS=linux go build -ldflags "$${LD_FLAGS}" -trimpath -o bin/manager main.go .PHONY: run diff --git a/controllers/pkg/crypto/crypto.go b/controllers/pkg/crypto/crypto.go index bcd5f8e60..13225fd5f 100644 --- a/controllers/pkg/crypto/crypto.go +++ b/controllers/pkg/crypto/crypto.go @@ -132,3 +132,33 @@ func ParseRSAPublicKeyFromPEM(keyPEM string) (*rsa.PublicKey, error) { } return rsaPub, nil } + +// ParseRSAPublicKeyFromAnyPEM tries to parse a public key; if the PEM contains a private key it derives the public part. +func ParseRSAPublicKeyFromAnyPEM(keyPEM string) (*rsa.PublicKey, error) { + block, _ := pem.Decode([]byte(keyPEM)) + if block == nil { + return nil, errors.New("failed to parse PEM block") + } + switch block.Type { + case "PUBLIC KEY": + return ParseRSAPublicKeyFromPEM(keyPEM) + case "RSA PRIVATE KEY": + priv, err := x509.ParsePKCS1PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + return &priv.PublicKey, nil + case "PRIVATE KEY": + privAny, err := x509.ParsePKCS8PrivateKey(block.Bytes) + if err != nil { + return nil, err + } + priv, ok := privAny.(*rsa.PrivateKey) + if !ok { + return nil, errors.New("private key is not RSA") + } + return &priv.PublicKey, nil + default: + return nil, fmt.Errorf("unsupported key type %q", block.Type) + } +} diff --git a/lifecycle/staging/src/github.com/labring/image-cri-shim/.github/workflows/close-pr.yml b/lifecycle/staging/src/github.com/labring/image-cri-shim/.github/workflows/close-pr.yml index c7a21df20..4a89cb635 100644 --- a/lifecycle/staging/src/github.com/labring/image-cri-shim/.github/workflows/close-pr.yml +++ b/lifecycle/staging/src/github.com/labring/image-cri-shim/.github/workflows/close-pr.yml @@ -1,3 +1,17 @@ +# Copyright © 2025 sealos. +# +# 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. + name: ❌ Close Pull Requests on: diff --git a/lifecycle/staging/src/github.com/labring/lvscare/.github/workflows/close-pr.yml b/lifecycle/staging/src/github.com/labring/lvscare/.github/workflows/close-pr.yml index c7a21df20..4a89cb635 100644 --- a/lifecycle/staging/src/github.com/labring/lvscare/.github/workflows/close-pr.yml +++ b/lifecycle/staging/src/github.com/labring/lvscare/.github/workflows/close-pr.yml @@ -1,3 +1,17 @@ +# Copyright © 2025 sealos. +# +# 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. + name: ❌ Close Pull Requests on: