From a542a86dce0cf61b79cf319116a2c8ce717eb651 Mon Sep 17 00:00:00 2001 From: Jussi Nummelin Date: Mon, 21 Aug 2023 14:25:46 +0300 Subject: [PATCH] Standalone autopilot update logic This PR introduces a fully standalone logic in autopilot to follow different update channels. The (upcoming) update server will host channels in this way: latest: 1.28.0 stable/v1.27: 1.27.x (what ever is the latest in 1.27 series) stable/v1.26: 1.26.x (what ever is the latest in 1.26 series) unstable/v1.28: 1.28.0-beta.1 (what ever is the latest pre release in 1.28) For each channel the update-server "protocol" is a simple yaml data: ```yaml channel: latest version: v5.6.7 downloadURLs: - arch: amd64 os: linux k0s: http://localhost/dist/k0s k0sSha: deadbeef airgapBundle: https://url.to.airgap/k0s-images-amd64.tgz airgapSha: deadbeef - arch: arm64 os: linux k0s: http://localhost/dist/k0s - arch: arm os: linux k0s: http://localhost/dist/k0s ``` Essentially this offers an easy way for users to either always follow the latest version or stay up-to-date with some specific minor version. Each time k0s checks for possible updates it sends some general cluster details to the update server to allow the server also to make some decicions which update to push. Following data is sent in HTTP Headers: ``` K0S_StorageType: etcd/kine K0S_ClusterID: uuid of kube-system NS K0S_ControlPlaneNodesCount: 3 K0S_WorkerData: base64 encoded json of worker node arch, OS type, container runtime type ``` On top of the autopilot functionality there's a common component that will check if there's updates available and notifies cluster admin via Event. This checker does not perform any real updates, it's just a convenience way to inform admins there's new versions available. Neither does this component perform any checks if there is already a autopilot UpdateConfig in place. Signed-off-by: Jussi Nummelin --- cmd/controller/controller.go | 14 +- docs/autopilot.md | 36 +++- go.mod | 4 +- go.sum | 2 + inttest/Makefile | 4 +- inttest/Makefile.variables | 1 + inttest/ap-updater-periodic/updater_test.go | 182 +++++++++++++++++ inttest/ap-updater/updater_test.go | 1 + inttest/update-server/Dockerfile | 1 + inttest/update-server/html/latest/index.yaml | 12 ++ .../html/stable/v1.27/index.yaml | 13 ++ inttest/update-server/nginx.conf | 113 +++++++++++ pkg/apis/autopilot/v1beta2/updateconfig.go | 176 +++++++++++++++- .../autopilot/v1beta2/updateconfig_test.go | 172 ++++++++++++++++ .../v1beta2/zz_generated.deepcopy.go | 23 ++- pkg/autopilot/channels/channelclient.go | 92 +++++++++ pkg/autopilot/channels/channelclient_test.go | 64 ++++++ pkg/autopilot/channels/versions.go | 51 +++++ .../controller/updates/clusterinfo.go | 130 ++++++++++++ .../controller/updates/periodicupdater.go | 190 +++++++++++++++++ .../controller/updates/update_controller.go | 54 ++++- pkg/autopilot/controller/updates/updater.go | 48 ++++- pkg/component/controller/updateprober.go | 192 ++++++++++++++++++ pkg/context/context.go | 47 +++++ ...autopilot.k0sproject.io_updateconfigs.yaml | 34 +++- 25 files changed, 1618 insertions(+), 38 deletions(-) create mode 100644 inttest/ap-updater-periodic/updater_test.go create mode 100644 inttest/update-server/html/latest/index.yaml create mode 100644 inttest/update-server/html/stable/v1.27/index.yaml create mode 100644 inttest/update-server/nginx.conf create mode 100644 pkg/apis/autopilot/v1beta2/updateconfig_test.go create mode 100644 pkg/autopilot/channels/channelclient.go create mode 100644 pkg/autopilot/channels/channelclient_test.go create mode 100644 pkg/autopilot/channels/versions.go create mode 100644 pkg/autopilot/controller/updates/clusterinfo.go create mode 100644 pkg/autopilot/controller/updates/periodicupdater.go create mode 100644 pkg/component/controller/updateprober.go create mode 100644 pkg/context/context.go diff --git a/cmd/controller/controller.go b/cmd/controller/controller.go index 02efd2a9a864..ba8f6269f841 100644 --- a/cmd/controller/controller.go +++ b/cmd/controller/controller.go @@ -28,6 +28,7 @@ import ( "syscall" "time" + "github.com/avast/retry-go" workercmd "github.com/k0sproject/k0s/cmd/worker" "github.com/k0sproject/k0s/internal/pkg/dir" "github.com/k0sproject/k0s/internal/pkg/file" @@ -36,6 +37,7 @@ import ( "github.com/k0sproject/k0s/internal/pkg/sysinfo" "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" "github.com/k0sproject/k0s/pkg/applier" + apclient "github.com/k0sproject/k0s/pkg/autopilot/client" "github.com/k0sproject/k0s/pkg/build" "github.com/k0sproject/k0s/pkg/certificate" "github.com/k0sproject/k0s/pkg/component/controller" @@ -48,12 +50,11 @@ import ( "github.com/k0sproject/k0s/pkg/component/worker" "github.com/k0sproject/k0s/pkg/config" "github.com/k0sproject/k0s/pkg/constant" + k0sctx "github.com/k0sproject/k0s/pkg/context" "github.com/k0sproject/k0s/pkg/kubernetes" "github.com/k0sproject/k0s/pkg/performance" "github.com/k0sproject/k0s/pkg/telemetry" "github.com/k0sproject/k0s/pkg/token" - - "github.com/avast/retry-go" "github.com/sirupsen/logrus" "github.com/spf13/cobra" "golang.org/x/exp/slices" @@ -142,6 +143,9 @@ func (c *command) start(ctx context.Context) error { return fmt.Errorf("invalid node config: %w", errors.Join(errs...)) } + // Add the node config to the context so it can be used by components deep in the "stack" + ctx = context.WithValue(ctx, k0sctx.ContextNodeConfigKey, nodeConfig) + nodeComponents := manager.New(prober.DefaultProber) clusterComponents := manager.New(prober.DefaultProber) @@ -505,6 +509,12 @@ func (c *command) start(ctx context.Context) error { EnableWorker: c.EnableWorker, }) + apClientFactory, err := apclient.NewClientFactory(adminClientFactory.GetRESTConfig()) + if err != nil { + return err + } + clusterComponents.Add(ctx, controller.NewUpdateProber(apClientFactory, leaderElector)) + perfTimer.Checkpoint("starting-cluster-components-init") // init Cluster components if err := clusterComponents.Init(ctx); err != nil { diff --git a/docs/autopilot.md b/docs/autopilot.md index aa4427acc4dc..a13f1811483a 100644 --- a/docs/autopilot.md +++ b/docs/autopilot.md @@ -26,9 +26,16 @@ metadata: namespace: default spec: channel: edge_release - updateServer: https://docs.k0sproject.io/ + updateServer: https://updates.k0sproject.io/ upgradeStrategy: - cron: "0 12 * * TUE,WED" # Check for updates at 12:00 on Tuesday and Wednesday. + type: periodic + periodic: + # The folowing fields configures updates to happen only on Tue or Wed at 13:00-15:00 + days: [Tuesdsay,Wednesday] + startTime: "13:00" + length: 2h + planSpec: # This defines the plan to be created IF there are updates available + ... ``` ## Safeguards @@ -354,12 +361,24 @@ Similar to the **Plan Status**, the individual nodes can have their own statuses #### `spec.updateServer (optional)` -* Update server url. +* Update server url. Defaults to `https://updates.k0sproject.io` + +#### `spec.upgradeStrategy.type ` + +* Select which update strategy to use. -#### `spec.upgradeStrategy.cron (optional)` +#### `spec.upgradeStrategy.cron (optional)` **DEPRECATED** * Schedule to check for updates in crontab format. +#### `spec.upgradeStrategy.cron ` + +Fields: + +* `days`: On which weekdays to check for updates +* `startTime`: At which time of day to check updates +* `length`: The length of the update window + #### `spec.planSpec (optional)` * Describes the behavior of the autopilot generated `Plan` @@ -375,7 +394,12 @@ spec: channel: stable updateServer: https://updates.k0sproject.io/ upgradeStrategy: - cron: "0 12 * * TUE,WED" # Check for updates at 12:00 on Tuesday and Wednesday. + type: periodic + periodic: + # The folowing fields configures updates to happen only on Tue or Wed at 13:00-15:00 + days: [Tuesdsay,Wednesday] + startTime: "13:00" + length: 2h # Optional. Specifies a created Plan object planSpec: commands: @@ -410,7 +434,7 @@ spec: ### Q: How do I apply the `Plan` and `ControlNode` CRDs? -A: These CRD definitions are embedded in the **autopilot** binary and applied on startup. +A: These CRD definitions are embedded in the **k0s** binary and applied on startup. No additional action is needed. ### Q: How will `ControlNode` instances get removed? diff --git a/go.mod b/go.mod index 30519f57a2ec..f23aaa6565d2 100644 --- a/go.mod +++ b/go.mod @@ -26,6 +26,7 @@ require ( github.com/hashicorp/terraform-exec v0.19.0 github.com/imdario/mergo v0.3.16 github.com/k0sproject/dig v0.2.0 + github.com/k0sproject/version v0.3.1-0.20220411075111-0270bb85e7f8 github.com/kardianos/service v1.2.2 github.com/logrusorgru/aurora/v3 v3.0.0 github.com/mesosphere/toml-merge v0.2.0 @@ -83,6 +84,8 @@ require ( sigs.k8s.io/yaml v1.3.0 ) +require gopkg.in/yaml.v2 v2.4.0 // indirect + require ( github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect github.com/AdamKorcz/go-118-fuzz-build v0.0.0-20230306123547-8075edf89bb0 // indirect @@ -268,7 +271,6 @@ require ( google.golang.org/protobuf v1.31.0 // indirect gopkg.in/inf.v0 v0.9.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect - gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect k8s.io/apiserver v0.28.1 // indirect k8s.io/controller-manager v0.28.1 // indirect diff --git a/go.sum b/go.sum index ce6b208d446c..8cfa3fc95e1b 100644 --- a/go.sum +++ b/go.sum @@ -519,6 +519,8 @@ github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfV github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w= github.com/k0sproject/dig v0.2.0 h1:cNxEIl96g9kqSMfPSZLhpnZ0P8bWXKv08nxvsMHop5w= github.com/k0sproject/dig v0.2.0/go.mod h1:rBcqaQlJpcKdt2x/OE/lPvhGU50u/e95CSm5g/r4s78= +github.com/k0sproject/version v0.3.1-0.20220411075111-0270bb85e7f8 h1:jxqyyKDoio9LKTynl17J52QKvFj7UWfhQ/tSaobgcLs= +github.com/k0sproject/version v0.3.1-0.20220411075111-0270bb85e7f8/go.mod h1:oEjuz2ItQQtAnGyRgwEV9m5R6/9rjoFC6EiEEzbkFdI= github.com/kardianos/service v1.2.2 h1:ZvePhAHfvo0A7Mftk/tEzqEZ7Q4lgnR8sGz4xu1YX60= github.com/kardianos/service v1.2.2/go.mod h1:CIMRFEJVL+0DS1a3Nx06NaMn4Dz63Ng6O7dl0qH0zVM= github.com/karrick/godirwalk v1.16.1/go.mod h1:j4mkqPuvaLI8mp1DroR3P6ad7cyYd4c1qeJ3RV7ULlk= diff --git a/inttest/Makefile b/inttest/Makefile index 8928df03a93b..c4377c6e246c 100644 --- a/inttest/Makefile +++ b/inttest/Makefile @@ -49,7 +49,7 @@ check-footloose-alpine-buildx: $(dir $(K0S_PATH)) touch $@ -.update-server.stamp: .footloose-alpine.stamp update-server/Dockerfile update-server/html/stable/index.yaml +.update-server.stamp: .footloose-alpine.stamp update-server/Dockerfile $(wildcard update-server/html/**/*.html) docker build -t update-server --build-arg BASE=footloose-alpine -f update-server/Dockerfile update-server touch $@ @@ -101,6 +101,8 @@ check-dualstack-dynamicconfig: export K0S_ENABLE_DYNAMIC_CONFIG=true check-dualstack-dynamicconfig: TEST_PACKAGE=dualstack check-ap-updater: .update-server.stamp +check-ap-updater-periodic: .update-server.stamp +check-ap-updater-periodic: TIMEOUT=10m check-network-conformance-kuberouter: TIMEOUT=15m check-network-conformance-kuberouter: export K0S_NETWORK_CONFORMANCE_CNI=kuberouter diff --git a/inttest/Makefile.variables b/inttest/Makefile.variables index 7f5cfe812b24..718db5974e41 100644 --- a/inttest/Makefile.variables +++ b/inttest/Makefile.variables @@ -10,6 +10,7 @@ smoketests := \ check-ap-selector \ check-ap-single \ check-ap-updater \ + check-ap-updater-periodic \ check-backup \ check-basic \ check-byocri \ diff --git a/inttest/ap-updater-periodic/updater_test.go b/inttest/ap-updater-periodic/updater_test.go new file mode 100644 index 000000000000..e79c1b3a442e --- /dev/null +++ b/inttest/ap-updater-periodic/updater_test.go @@ -0,0 +1,182 @@ +// Copyright 2023 k0s 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 updater + +import ( + "fmt" + "testing" + "time" + + "github.com/k0sproject/k0s/inttest/common" + aptest "github.com/k0sproject/k0s/inttest/common/autopilot" + + apconst "github.com/k0sproject/k0s/pkg/autopilot/constant" + appc "github.com/k0sproject/k0s/pkg/autopilot/controller/plans/core" + "github.com/k0sproject/k0s/pkg/kubernetes/watch" + "github.com/stretchr/testify/suite" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +const ( + ManifestTestDirPerms = "775" +) + +type plansSingleControllerSuite struct { + common.FootlooseSuite +} + +var envTemplate = ` +export K0S_UPDATE_SERVER={{.Address}} +export K0S_UPDATE_PERIOD=1m +export K0S_UPDATE_CHECK_INTERVAL=1m +` + +// SetupTest prepares the controller and filesystem, getting it into a consistent +// state which we can run tests against. +func (s *plansSingleControllerSuite) SetupTest() { + ctx := s.Context() + s.Require().NoError(s.WaitForSSH(s.ControllerNode(0), 2*time.Minute, 1*time.Second)) + + // Dump some env vars for testing in /etc/conf.d/k0scontroller + vars := struct { + Address string + }{ + Address: fmt.Sprintf("http://%s", s.GetUpdateServerIPAddress()), + } + s.PutFileTemplate(s.ControllerNode(0), "/etc/conf.d/k0scontroller", envTemplate, vars) + + s.Require().NoError(s.InitController(0), "--disable-components=metrics-server") + s.Require().NoError(s.WaitJoinAPI(s.ControllerNode(0))) + + kc, err := s.KubeClient(s.ControllerNode(0)) + s.Require().NoError(err) + + client, err := s.ExtensionsClient(s.ControllerNode(0)) + s.Require().NoError(err) + + s.Require().NoError(aptest.WaitForCRDByName(ctx, client, "plans")) + s.Require().NoError(aptest.WaitForCRDByName(ctx, client, "controlnodes")) + s.Require().NoError(aptest.WaitForCRDByName(ctx, client, "updateconfigs")) + // Wait that we see an event for update before proceeding to actual update testing + err = watch.Events(kc.CoreV1().Events("")). + Until(s.Context(), func(e *corev1.Event) (done bool, err error) { + return e.Type == "Normal" && + e.Source.Component == "k0s" && + e.Reason == "NewVersionAvailable", nil + }) + s.Require().NoError(err) + // Get the first line of access logs to verify that the update headers are present + ssh, err := s.SSH(s.Context(), "updateserver0") + s.Require().NoError(err) + defer ssh.Disconnect() + logs, err := ssh.ExecWithOutput(s.Context(), "head -1 /var/log/nginx/access.log") + s.Require().NoError(err) + s.verifyUpdateHeaders(kc, logs) +} + +func (s *plansSingleControllerSuite) verifyUpdateHeaders(kc kubernetes.Interface, logLine string) { + // Verify that the update headers are present in the update server logs + s.Require().Contains(logLine, `K0S_StorageType="etcd"`) + s.Require().Contains(logLine, "K0S_ControlPlaneNodesCount=1") + s.Require().Contains(logLine, fmt.Sprintf(`K0S_ClusterID="%s"`, s.getClusterID(kc))) + s.Require().Contains(logLine, `K0S_CNIProvider="kuberouter"`) +} + +func (s *plansSingleControllerSuite) getClusterID(kc kubernetes.Interface) string { + ns, err := kc.CoreV1().Namespaces().Get(s.Context(), "kube-system", metav1.GetOptions{}) + s.Require().NoError(err) + return fmt.Sprintf("%s:%s", ns.Name, ns.UID) +} + +// TestApply applies a well-formed `plan` yaml, and asserts that all of the correct values +// across different objects are correct. +func (s *plansSingleControllerSuite) TestApply() { + updaterConfig := ` +apiVersion: autopilot.k0sproject.io/v1beta2 +kind: UpdateConfig +metadata: + name: autopilot +spec: + channel: latest + updateServer: {{.Address}} + upgradeStrategy: + type: periodic + periodic: + days: [Monday, Tuesday, Wednesday, Thursday, Friday, Saturday, Sunday] + startTime: 00:00 + length: 24h + planSpec: + commands: + - k0supdate: + forceupdate: true + targets: + controllers: + discovery: + selector: {} + workers: + discovery: + selector: {} +` + + vars := struct { + Address string + }{ + Address: fmt.Sprintf("http://%s", s.GetUpdateServerIPAddress()), + } + + manifestFile := "/tmp/updateconfig.yaml" + s.PutFileTemplate(s.ControllerNode(0), manifestFile, updaterConfig, vars) + + out, err := s.RunCommandController(0, fmt.Sprintf("/usr/local/bin/k0s kubectl apply -f %s", manifestFile)) + s.T().Logf("kubectl apply output: '%s'", out) + s.Require().NoError(err) + + client, err := s.AutopilotClient(s.ControllerNode(0)) + s.Require().NoError(err) + s.NotEmpty(client) + + // The plan has enough information to perform a successful update of k0s, so wait for it. + _, err = aptest.WaitForPlanState(s.Context(), client, apconst.AutopilotName, appc.PlanCompleted) + s.Require().NoError(err) + + kc, err := s.KubeClient(s.ControllerNode(0)) + s.Require().NoError(err) + + // Verify that the update headers are present in the update server logs, + // ignoring the "grab" user-agent as that's the actual k0s bin download which we don't care + ssh, err := s.SSH(s.Context(), "updateserver0") + s.Require().NoError(err) + defer ssh.Disconnect() + logs, err := ssh.ExecWithOutput(s.Context(), "grep -v grab /var/log/nginx/access.log | tail -1") + s.Require().NoError(err) + + s.verifyUpdateHeaders(kc, logs) + +} + +// TestPlansSingleControllerSuite sets up a suite using a single controller, running various +// autopilot upgrade scenarios against it. +func TestPlansSingleControllerSuite(t *testing.T) { + suite.Run(t, &plansSingleControllerSuite{ + common.FootlooseSuite{ + ControllerCount: 1, + WorkerCount: 0, + WithUpdateServer: true, + LaunchMode: common.LaunchModeOpenRC, + }, + }) +} diff --git a/inttest/ap-updater/updater_test.go b/inttest/ap-updater/updater_test.go index dcf14110c7e4..f944cc9a7a5a 100644 --- a/inttest/ap-updater/updater_test.go +++ b/inttest/ap-updater/updater_test.go @@ -65,6 +65,7 @@ spec: channel: stable updateServer: {{.Address}} upgradeStrategy: + type: cron cron: "* * * * * *" planSpec: commands: diff --git a/inttest/update-server/Dockerfile b/inttest/update-server/Dockerfile index 9af7e0f4bdd5..1432230a9be8 100644 --- a/inttest/update-server/Dockerfile +++ b/inttest/update-server/Dockerfile @@ -6,4 +6,5 @@ RUN apk add nginx RUN rc-update add nginx boot && mkdir -p /run/nginx/ ADD html /var/lib/nginx/html +ADD nginx.conf /etc/nginx/nginx.conf ADD default.conf /etc/nginx/http.d/default.conf diff --git a/inttest/update-server/html/latest/index.yaml b/inttest/update-server/html/latest/index.yaml new file mode 100644 index 000000000000..595fe06b441e --- /dev/null +++ b/inttest/update-server/html/latest/index.yaml @@ -0,0 +1,12 @@ +channel: latest +version: v5.6.7 +downloadURLs: + - arch: amd64 + os: linux + k0s: http://localhost/dist/k0s + - arch: arm64 + os: linux + k0s: http://localhost/dist/k0s + - arch: arm + os: linux + k0s: http://localhost/dist/k0s diff --git a/inttest/update-server/html/stable/v1.27/index.yaml b/inttest/update-server/html/stable/v1.27/index.yaml new file mode 100644 index 000000000000..b5afea97c02a --- /dev/null +++ b/inttest/update-server/html/stable/v1.27/index.yaml @@ -0,0 +1,13 @@ +channel: v1.27 +version: v0.0.0 +downloadURLs: + - arch: amd64 + os: linux + url: http://localhost/dist/k0s + - arch: arm64 + os: linux + url: http://localhost/dist/k0s + - arch: arm + os: linux + url: http://localhost/dist/k0s + \ No newline at end of file diff --git a/inttest/update-server/nginx.conf b/inttest/update-server/nginx.conf new file mode 100644 index 000000000000..279cb05581d3 --- /dev/null +++ b/inttest/update-server/nginx.conf @@ -0,0 +1,113 @@ +# /etc/nginx/nginx.conf + +user nginx; + +# Set number of worker processes automatically based on number of CPU cores. +worker_processes auto; + +# Enables the use of JIT for regular expressions to speed-up their processing. +pcre_jit on; + +# Configures default error logger. +error_log /var/log/nginx/error.log warn; + +# Includes files with directives to load dynamic modules. +include /etc/nginx/modules/*.conf; + +# Include files with config snippets into the root context. +include /etc/nginx/conf.d/*.conf; + +events { + # The maximum number of simultaneous connections that can be opened by + # a worker process. + worker_connections 1024; +} + +http { + # Includes mapping of file name extensions to MIME types of responses + # and defines the default type. + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # Name servers used to resolve names of upstream servers into addresses. + # It's also needed when using tcpsocket and udpsocket in Lua modules. + #resolver 1.1.1.1 1.0.0.1 2606:4700:4700::1111 2606:4700:4700::1001; + + # Don't tell nginx version to the clients. Default is 'on'. + server_tokens off; + + # Specifies the maximum accepted body size of a client request, as + # indicated by the request header Content-Length. If the stated content + # length is greater than this size, then the client receives the HTTP + # error code 413. Set to 0 to disable. Default is '1m'. + client_max_body_size 1m; + + # Sendfile copies data between one FD and other from within the kernel, + # which is more efficient than read() + write(). Default is off. + sendfile on; + + # Causes nginx to attempt to send its HTTP response head in one packet, + # instead of using partial frames. Default is 'off'. + tcp_nopush on; + + + # Enables the specified protocols. Default is TLSv1 TLSv1.1 TLSv1.2. + # TIP: If you're not obligated to support ancient clients, remove TLSv1.1. + ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; + + # Path of the file with Diffie-Hellman parameters for EDH ciphers. + # TIP: Generate with: `openssl dhparam -out /etc/ssl/nginx/dh2048.pem 2048` + #ssl_dhparam /etc/ssl/nginx/dh2048.pem; + + # Specifies that our cipher suits should be preferred over client ciphers. + # Default is 'off'. + ssl_prefer_server_ciphers on; + + # Enables a shared SSL cache with size that can hold around 8000 sessions. + # Default is 'none'. + ssl_session_cache shared:SSL:2m; + + # Specifies a time during which a client may reuse the session parameters. + # Default is '5m'. + ssl_session_timeout 1h; + + # Disable TLS session tickets (they are insecure). Default is 'on'. + ssl_session_tickets off; + + + # Enable gzipping of responses. + #gzip on; + + # Set the Vary HTTP header as defined in the RFC 2616. Default is 'off'. + gzip_vary on; + + + # Helper variable for proxying websockets. + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } + + underscores_in_headers on; + ignore_invalid_headers off; + + # Specifies the main log format. + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for" ' + # Log the k0s update request headers for inspection + 'K0S_StorageType="$http_k0s_storagetype" ' + 'K0S_ClusterID="$http_k0s_clusterid" ' + 'K0S_ControlPlaneNodesCount=$http_k0s_controlplanenodescount ' + 'K0S_WorkerData="$http_k0s_workerdata" ' + 'K0S_Version="$http_k0s_version" ' + 'K0S_CNIProvider="$http_k0s_cniprovider" ' + 'K0S_Arch="$http_k0s_arch" '; + + # Sets the path, format, and configuration for a buffered log write. + access_log /var/log/nginx/access.log main; + + + # Includes virtual hosts configs. + include /etc/nginx/http.d/*.conf; +} \ No newline at end of file diff --git a/pkg/apis/autopilot/v1beta2/updateconfig.go b/pkg/apis/autopilot/v1beta2/updateconfig.go index 4c24b7297314..b51afbbd74e3 100644 --- a/pkg/apis/autopilot/v1beta2/updateconfig.go +++ b/pkg/apis/autopilot/v1beta2/updateconfig.go @@ -15,6 +15,11 @@ package v1beta2 import ( + "fmt" + "strconv" + "time" + + uc "github.com/k0sproject/k0s/pkg/autopilot/channels" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -25,6 +30,13 @@ func init() { ) } +const UpdateConfigFinalizer = "updateconfig.autopilot.k0sproject.io" + +const ( + UpdateStrategyTypeCron = "cron" + UpdateStrategyTypePeriodic = "periodic" +) + // +kubebuilder:object:root=true // +kubebuilder:resource:scope=Cluster // +genclient @@ -38,10 +50,17 @@ type UpdateConfig struct { } type UpdateSpec struct { - Channel string `json:"channel,omitempty"` - UpdateServer string `json:"updateServer,omitempty"` - UpgradeStrategy UpgradeStrategy `json:"upgradeStrategy,omitempty"` - PlanSpec AutopilotPlanSpec `json:"planSpec,omitempty"` + // Channel defines the update channel to use for this update config + // +kubebuilder:default:=stable + Channel string `json:"channel,omitempty"` + // UpdateServer defines the update server to use for this update config + // +kubebuilder:default:="https://updates.k0sproject.io" + UpdateServer string `json:"updateServer,omitempty"` + // UpdateStrategy defines the update strategy to use for this update config + UpgradeStrategy UpgradeStrategy `json:"upgradeStrategy,omitempty"` + // PlanSpec defines the plan spec to use for this update config + // +kubebuilder:Validation:Required + PlanSpec AutopilotPlanSpec `json:"planSpec,omitempty"` } // AutopilotPlanSpec describes the behavior of the autopilot generated `Plan` @@ -78,7 +97,65 @@ type AutopilotPlanCommandAirgapUpdate struct { } type UpgradeStrategy struct { - Cron string `json:"cron"` + // Type defines the type of upgrade strategy + // +kubebuilder:validation:Enum=periodic;cron + Type string `json:"type,omitempty"` + // Cron defines the cron expression for the cron upgrade strategy + // +kubebuilder:validation:Optional + //+kubebuilder:deprecatedversion:warning="Cron is deprecated and will be removed in 1.29" + Cron string `json:"cron,omitempty"` + // Periodic defines the periodic upgrade strategy + Periodic PeriodicUpgradeStrategy `json:"periodic,omitempty"` +} + +type PeriodicUpgradeStrategy struct { + Days []string `json:"days,omitempty"` + StartTime string `json:"startTime,omitempty"` + Length string `json:"length,omitempty"` +} + +func (p *PeriodicUpgradeStrategy) IsWithinPeriod(t time.Time) bool { + days := p.Days + if len(p.Days) == 0 { + days = []string{"Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"} + } + + // Parse the start time and window length + st, err := time.Parse("15:04", p.StartTime) + if err != nil { + fmt.Println("Error parsing start time:", err) + return false + } + + startTime := startTimeForCurrentDay(st) + + windowDuration, err := time.ParseDuration(p.Length) + if err != nil { + fmt.Println("Error parsing window length:", err) + return false + } + + // Check if the current day is within the specified window days + currentDay := t.Weekday().String() + isWindowDay := false + for _, day := range days { + if day == currentDay { + isWindowDay = true + break + } + } + + // Check if the current time is within the specified window + return isWindowDay && + t.After(startTime) && + t.Before(startTime.Add(windowDuration)) + +} + +// Returns the "adjusted" time for the current day. I.e. if the starTime is 15:00, this function will return the current day at 15:00 +func startTimeForCurrentDay(startTime time.Time) time.Time { + now := time.Now() + return time.Date(now.Year(), now.Month(), now.Day(), startTime.Hour(), startTime.Minute(), 0, 0, time.Local) } // +kubebuilder:object:root=true @@ -89,3 +166,92 @@ type UpdateConfigList struct { Items []UpdateConfig `json:"items"` } + +func (uc *UpdateConfig) ToPlan(nextVersion uc.VersionInfo) Plan { + p := Plan{ + TypeMeta: metav1.TypeMeta{ + Kind: "Plan", + APIVersion: "autopilot.k0sproject.io/v1beta2", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: "autopilot", + }, + Spec: PlanSpec{}, + } + + platforms := make(PlanPlatformResourceURLMap) + airgapPlatforms := make(PlanPlatformResourceURLMap) + for _, downloadURL := range nextVersion.DownloadURLs { + osArch := fmt.Sprintf("%s-%s", downloadURL.OS, downloadURL.Arch) + k0sURL := PlanResourceURL{ + URL: downloadURL.K0S, + } + if downloadURL.K0SSha256 != "" { + k0sURL.Sha256 = downloadURL.K0SSha256 + } + platforms[osArch] = k0sURL + + airgapURL := PlanResourceURL{ + URL: downloadURL.AirgapBundle, + } + if downloadURL.AirgapSha256 != "" { + airgapURL.Sha256 = downloadURL.AirgapSha256 + } + airgapPlatforms[osArch] = airgapURL + } + + p.Spec.ID = strconv.FormatInt(time.Now().Unix(), 10) + p.Spec.Timestamp = strconv.FormatInt(time.Now().Unix(), 10) + + var updateCommandFound bool + for _, cmd := range uc.Spec.PlanSpec.Commands { + if cmd.K0sUpdate != nil || cmd.AirgapUpdate != nil { + updateCommandFound = true + break + } + } + + // If update command is not specified, we add a default one to update all controller and workers in the cluster + if !updateCommandFound { + p.Spec.Commands = append(p.Spec.Commands, PlanCommand{ + K0sUpdate: &PlanCommandK0sUpdate{ + Version: string(nextVersion.Version), + Platforms: platforms, + Targets: PlanCommandTargets{ + Controllers: PlanCommandTarget{ + Discovery: PlanCommandTargetDiscovery{ + Selector: &PlanCommandTargetDiscoverySelector{}, + }, + }, + Workers: PlanCommandTarget{ + Discovery: PlanCommandTargetDiscovery{ + Selector: &PlanCommandTargetDiscoverySelector{}, + }, + }, + }, + }, + }) + } else { + for _, cmd := range uc.Spec.PlanSpec.Commands { + planCmd := PlanCommand{} + if cmd.K0sUpdate != nil { + planCmd.K0sUpdate = &PlanCommandK0sUpdate{ + Version: string(nextVersion.Version), + ForceUpdate: cmd.K0sUpdate.ForceUpdate, + Platforms: platforms, + Targets: cmd.K0sUpdate.Targets, + } + } + if cmd.AirgapUpdate != nil { + planCmd.AirgapUpdate = &PlanCommandAirgapUpdate{ + Version: string(nextVersion.Version), + Platforms: airgapPlatforms, + Workers: cmd.AirgapUpdate.Workers, + } + } + p.Spec.Commands = append(p.Spec.Commands, planCmd) + } + } + + return p +} diff --git a/pkg/apis/autopilot/v1beta2/updateconfig_test.go b/pkg/apis/autopilot/v1beta2/updateconfig_test.go new file mode 100644 index 000000000000..0e9c395d9091 --- /dev/null +++ b/pkg/apis/autopilot/v1beta2/updateconfig_test.go @@ -0,0 +1,172 @@ +// Copyright 2023 k0s 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 v1beta2 + +import ( + "testing" + "time" + + "github.com/k0sproject/k0s/pkg/autopilot/channels" + "github.com/stretchr/testify/require" +) + +func TestPeriodicUpgradeStrategy_IsWithinPeriod(t *testing.T) { + type fields struct { + Days []string + StartTime string + Length string + } + tests := []struct { + name string + fields fields + time time.Time + want bool + }{ + { + name: "empty days", + fields: fields{ + Days: []string{}, + StartTime: time.Now().Format("15:04"), + Length: "1h", + }, + time: time.Now(), + want: true, + }, + { + name: "Current weekday", + fields: fields{ + Days: []string{time.Now().Weekday().String()}, + StartTime: time.Now().Format("15:04"), + Length: "1h", + }, + time: time.Now(), + want: true, + }, + { + name: "Current weekday - after window", + fields: fields{ + Days: []string{time.Now().Weekday().String()}, + StartTime: time.Now().Format("15:04"), + Length: "1h", + }, + time: time.Now().Add(time.Hour * 2), + want: false, + }, + { + name: "Current weekday - before window", + fields: fields{ + Days: []string{time.Now().Weekday().String()}, + StartTime: time.Now().Format("15:04"), + Length: "1h", + }, + time: time.Now().Add(time.Hour * -2), + want: false, + }, + { + name: "Wrong weekday - outside window", + fields: fields{ + Days: []string{time.Now().Weekday().String()}, + StartTime: time.Now().Format("15:04"), + Length: "1h", + }, + time: time.Now().Add(time.Hour * -24), + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := &PeriodicUpgradeStrategy{ + Days: tt.fields.Days, + StartTime: tt.fields.StartTime, + Length: tt.fields.Length, + } + if got := p.IsWithinPeriod(tt.time); got != tt.want { + t.Errorf("PeriodicUpgradeStrategy.IsWithinPeriod() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestToPlan_EmptyCommand(t *testing.T) { + uc := UpdateConfig{ + Spec: UpdateSpec{ + PlanSpec: AutopilotPlanSpec{}, + }, + } + + nextVersion := channels.VersionInfo{ + Version: "v1.2.3", + DownloadURLs: []channels.DownloadURL{ + { + Arch: "arm64", + OS: "linux", + K0S: "some_k0s_url", + }, + }, + } + plan := uc.ToPlan(nextVersion) + require := require.New(t) + var k0sCommand *PlanCommandK0sUpdate + for _, c := range plan.Spec.Commands { + if c.K0sUpdate != nil { + k0sCommand = c.K0sUpdate + } + } + require.Equal("some_k0s_url", k0sCommand.Platforms["linux-arm64"].URL) +} + +func TestToPlan_ExistingCommand(t *testing.T) { + uc := UpdateConfig{ + Spec: UpdateSpec{ + PlanSpec: AutopilotPlanSpec{ + Commands: []AutopilotPlanCommand{ + { + K0sUpdate: &AutopilotPlanCommandK0sUpdate{ + ForceUpdate: true, + Targets: PlanCommandTargets{ + Controllers: PlanCommandTarget{ + Discovery: PlanCommandTargetDiscovery{ + Selector: nil, + }, + }, + }, + }, + }, + }, + }, + }, + } + + nextVersion := channels.VersionInfo{ + Version: "v1.2.3", + DownloadURLs: []channels.DownloadURL{ + { + Arch: "arm64", + OS: "linux", + K0S: "some_k0s_url", + }, + }, + } + plan := uc.ToPlan(nextVersion) + require := require.New(t) + var k0sCommand *PlanCommandK0sUpdate + for _, c := range plan.Spec.Commands { + if c.K0sUpdate != nil { + k0sCommand = c.K0sUpdate + } + } + require.Equal("some_k0s_url", k0sCommand.Platforms["linux-arm64"].URL) + +} diff --git a/pkg/apis/autopilot/v1beta2/zz_generated.deepcopy.go b/pkg/apis/autopilot/v1beta2/zz_generated.deepcopy.go index 266976aa68bd..bfc6b6642f86 100644 --- a/pkg/apis/autopilot/v1beta2/zz_generated.deepcopy.go +++ b/pkg/apis/autopilot/v1beta2/zz_generated.deepcopy.go @@ -182,6 +182,26 @@ func (in *ControlNodeStatus) DeepCopy() *ControlNodeStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *PeriodicUpgradeStrategy) DeepCopyInto(out *PeriodicUpgradeStrategy) { + *out = *in + if in.Days != nil { + in, out := &in.Days, &out.Days + *out = make([]string, len(*in)) + copy(*out, *in) + } +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PeriodicUpgradeStrategy. +func (in *PeriodicUpgradeStrategy) DeepCopy() *PeriodicUpgradeStrategy { + if in == nil { + return nil + } + out := new(PeriodicUpgradeStrategy) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Plan) DeepCopyInto(out *Plan) { *out = *in @@ -654,7 +674,7 @@ func (in *UpdateConfigList) DeepCopyObject() runtime.Object { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpdateSpec) DeepCopyInto(out *UpdateSpec) { *out = *in - out.UpgradeStrategy = in.UpgradeStrategy + in.UpgradeStrategy.DeepCopyInto(&out.UpgradeStrategy) in.PlanSpec.DeepCopyInto(&out.PlanSpec) } @@ -671,6 +691,7 @@ func (in *UpdateSpec) DeepCopy() *UpdateSpec { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *UpgradeStrategy) DeepCopyInto(out *UpgradeStrategy) { *out = *in + in.Periodic.DeepCopyInto(&out.Periodic) } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new UpgradeStrategy. diff --git a/pkg/autopilot/channels/channelclient.go b/pkg/autopilot/channels/channelclient.go new file mode 100644 index 000000000000..6fa84a2e1a1a --- /dev/null +++ b/pkg/autopilot/channels/channelclient.go @@ -0,0 +1,92 @@ +// Copyright 2023 k0s 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 channels + +import ( + "context" + "fmt" + "io" + "net/http" + "strings" + "time" + + "sigs.k8s.io/yaml" +) + +type ChannelClient struct { + httpClient *http.Client + token string + channelURL string +} + +func NewChannelClient(server string, channel string, token string) (*ChannelClient, error) { + httpClient := &http.Client{ + Timeout: 10 * time.Second, + } + + // If server is a full URL, use that. If not assume it's a hostname and use the default path + if strings.HasPrefix(server, "http") { + server = strings.TrimSuffix(server, "/") + } else { + server = fmt.Sprintf("https://%s", server) + } + + channelURL := fmt.Sprintf("%s/%s/index.yaml", server, channel) + + return &ChannelClient{ + httpClient: httpClient, + token: token, + channelURL: channelURL, + }, nil +} + +func (c *ChannelClient) GetLatest(ctx context.Context, headers map[string]string) (VersionInfo, error) { + + var v VersionInfo + + req, err := http.NewRequestWithContext(ctx, "GET", c.channelURL, nil) + if err != nil { + return v, err + } + + for k, v := range headers { + req.Header.Add(k, v) + } + + if c.token != "" { + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.token)) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return v, err + } + + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return v, fmt.Errorf("error fetching channel: %s", resp.Status) + } + + data, err := io.ReadAll(resp.Body) + if err != nil { + return v, err + } + + if err := yaml.Unmarshal(data, &v); err != nil { + return v, err + } + + return v, nil +} diff --git a/pkg/autopilot/channels/channelclient_test.go b/pkg/autopilot/channels/channelclient_test.go new file mode 100644 index 000000000000..785270dcffe6 --- /dev/null +++ b/pkg/autopilot/channels/channelclient_test.go @@ -0,0 +1,64 @@ +// Copyright 2023 k0s 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 channels + +import ( + "testing" +) + +func TestNewChannelClientChannelURL(t *testing.T) { + type args struct { + server string + channel string + token string + } + tests := []struct { + name string + args args + want string + wantErr bool + }{ + { + name: "full URL", + args: args{ + server: "https://example.com", + channel: "foo", + token: "", + }, + want: "https://example.com/foo/index.yaml", + }, + { + name: "partial URL", + args: args{ + server: "example.com", + channel: "foo", + token: "", + }, + want: "https://example.com/foo/index.yaml", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := NewChannelClient(tt.args.server, tt.args.channel, tt.args.token) + if (err != nil) != tt.wantErr { + t.Errorf("NewChannelClient() error = %v, wantErr %v", err, tt.wantErr) + return + } + if got.channelURL != tt.want { + t.Errorf("NewChannelClient() = %v, want %v", got.channelURL, tt.want) + } + }) + } +} diff --git a/pkg/autopilot/channels/versions.go b/pkg/autopilot/channels/versions.go new file mode 100644 index 000000000000..ea92cd2e1189 --- /dev/null +++ b/pkg/autopilot/channels/versions.go @@ -0,0 +1,51 @@ +// Copyright 2023 k0s 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 channels + +import ( + "github.com/k0sproject/version" +) + +type DownloadURL struct { + Arch string `yaml:"arch"` + OS string `yaml:"os"` + K0S string `yaml:"k0s"` + K0SSha256 string `yaml:"k0sSha256"` + AirgapBundle string `yaml:"airgapBundle"` + AirgapSha256 string `yaml:"airgapSha256"` +} + +type Channel struct { + Channel string `yaml:"channel"` + EOLDate string `yaml:"eolDate"` + VersionInfo `yaml:",inline"` +} + +type VersionInfo struct { + Version string `yaml:"version"` + DownloadURLs []DownloadURL `yaml:"downloadURLs"` +} + +func (v *VersionInfo) IsNewerThan(other string) (bool, error) { + new, err := version.NewVersion(v.Version) + if err != nil { + return false, err + } + o, err := version.NewVersion(other) + if err != nil { + return false, err + } + return new.GreaterThan(o), nil +} diff --git a/pkg/autopilot/controller/updates/clusterinfo.go b/pkg/autopilot/controller/updates/clusterinfo.go new file mode 100644 index 000000000000..80961d2ed251 --- /dev/null +++ b/pkg/autopilot/controller/updates/clusterinfo.go @@ -0,0 +1,130 @@ +/* +Copyright 2023 k0s 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 updates + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "runtime" + + "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" + "github.com/k0sproject/k0s/pkg/build" + k0sctx "github.com/k0sproject/k0s/pkg/context" + kubeutil "github.com/k0sproject/k0s/pkg/kubernetes" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes" +) + +// ClusterInfo holds cluster related information that the update server can use to determine which updates to push to clusters +type ClusterInfo struct { + K0sVersion string + StorageType string + ClusterID string + ControlPlaneNodesCount int + WorkerData WorkerData + CNIProvider string + Arch string +} + +type WorkerData struct { + Archs map[string]int + OSes map[string]int + Runtimes map[string]int +} + +func (ci *ClusterInfo) AsMap() map[string]string { + // Marshal and encode the worker data as a string + wd, err := json.Marshal(ci.WorkerData) + if err != nil { + return map[string]string{} + } + workerData := base64.StdEncoding.EncodeToString(wd) + return map[string]string{ + "K0S_StorageType": ci.StorageType, + "K0S_ClusterID": ci.ClusterID, + "K0S_ControlPlaneNodesCount": fmt.Sprintf("%d", ci.ControlPlaneNodesCount), + "K0S_WorkerData": workerData, + "K0S_Version": ci.K0sVersion, + "K0S_CNIProvider": ci.CNIProvider, + "K0S_Arch": ci.Arch, + } + +} + +// CollectData collects the cluster information +func CollectData(ctx context.Context, kc kubernetes.Interface) (*ClusterInfo, error) { + ci := &ClusterInfo{} + ci.K0sVersion = build.Version + ci.Arch = runtime.GOARCH + + nodeConfig := k0sctx.FromContext[v1beta1.ClusterConfig](ctx, k0sctx.ContextNodeConfigKey) + if nodeConfig != nil { + ci.CNIProvider = nodeConfig.Spec.Network.Provider + ci.StorageType = nodeConfig.Spec.Storage.Type + } + + // Collect cluster ID + ns, err := kc.CoreV1().Namespaces().Get(ctx, + "kube-system", + metav1.GetOptions{}) + if err != nil { + return ci, fmt.Errorf("can't find kube-system namespace: %v", err) + } + + ci.ClusterID = fmt.Sprintf("kube-system:%s", ns.UID) + + // Collect worker node infos + wns, err := kc.CoreV1().Nodes().List(ctx, metav1.ListOptions{}) + if err != nil { + return ci, err + } + + ci.WorkerData = WorkerData{ + Archs: make(map[string]int), + OSes: make(map[string]int), + Runtimes: make(map[string]int), + } + for _, node := range wns.Items { + arch := node.Status.NodeInfo.Architecture + if _, ok := ci.WorkerData.Archs[arch]; !ok { + ci.WorkerData.Archs[arch] = 0 + } + ci.WorkerData.Archs[arch]++ + + os := node.Status.NodeInfo.OSImage + if _, ok := ci.WorkerData.OSes[os]; !ok { + ci.WorkerData.OSes[os] = 0 + } + ci.WorkerData.OSes[os]++ + + runtime := node.Status.NodeInfo.ContainerRuntimeVersion + if _, ok := ci.WorkerData.Runtimes[runtime]; !ok { + ci.WorkerData.Runtimes[runtime] = 0 + } + ci.WorkerData.Runtimes[runtime]++ + } + + // Collect control plane node count + ci.ControlPlaneNodesCount, err = kubeutil.GetControlPlaneNodeCount(ctx, kc) + if err != nil { + return ci, fmt.Errorf("can't collect control plane nodes count: %v", err) + } + + return ci, nil +} diff --git a/pkg/autopilot/controller/updates/periodicupdater.go b/pkg/autopilot/controller/updates/periodicupdater.go new file mode 100644 index 000000000000..2baf26bf5283 --- /dev/null +++ b/pkg/autopilot/controller/updates/periodicupdater.go @@ -0,0 +1,190 @@ +// Copyright 2023 k0s 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 updates + +import ( + "context" + "os" + "time" + + apv1beta2 "github.com/k0sproject/k0s/pkg/apis/autopilot/v1beta2" + uc "github.com/k0sproject/k0s/pkg/autopilot/channels" + apcli "github.com/k0sproject/k0s/pkg/autopilot/client" + apcore "github.com/k0sproject/k0s/pkg/autopilot/controller/plans/core" + "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/types" + crcli "sigs.k8s.io/controller-runtime/pkg/client" + + "github.com/k0sproject/version" +) + +type periodicUpdater struct { + ctx context.Context + log *logrus.Entry + updateConfig apv1beta2.UpdateConfig + k8sClient crcli.Client + apClientFactory apcli.FactoryInterface + + clusterID string + currentK0sVersion string + + ticker *time.Ticker +} + +func newPeriodicUpdater(ctx context.Context, updateConfig apv1beta2.UpdateConfig, k8sClient crcli.Client, apClientFactory apcli.FactoryInterface, clusterID, currentK0sVersion string) (*periodicUpdater, error) { + + return &periodicUpdater{ + ctx: ctx, + log: logrus.WithField("component", "periodic-updater"), + updateConfig: updateConfig, + k8sClient: k8sClient, + clusterID: clusterID, + currentK0sVersion: currentK0sVersion, + apClientFactory: apClientFactory, + }, nil +} + +func (u *periodicUpdater) Config() *apv1beta2.UpdateConfig { + return &u.updateConfig +} + +func (u *periodicUpdater) Run() error { + u.log.Debug("starting periodic updater") + checkDuration := time.Duration(10 * time.Minute) + // ENV var used only for testing purposes + if e := os.Getenv("K0S_UPDATE_PERIOD"); e != "" { + cd, err := time.ParseDuration(e) + if err != nil { + u.log.Errorf("failed to parse %s as duration for update checks: %s", e, err.Error()) + } else { + checkDuration = cd + } + } + u.log.Debugf("using %s for update check period", checkDuration.String()) + go func() { + // Check for update every checkDuration, return when context is cancelled + ticker := time.NewTicker(checkDuration) + u.ticker = ticker + defer ticker.Stop() + for { + select { + case <-u.ctx.Done(): + u.log.Infof("parent context done, stopping polling") + return + case <-ticker.C: + u.checkForUpdate() + } + } + }() + + return nil +} + +func (u *periodicUpdater) Stop() { + // u.cancel() + if u.ticker != nil { + u.ticker.Stop() + } +} + +func (u *periodicUpdater) checkForUpdate() { + u.log.Debug("checking for updates") + ctx, cancel := context.WithTimeout(u.ctx, 2*time.Minute) + defer cancel() + + // Check if there's a token configured + var token string + tokenSecret := &corev1.Secret{} + if err := u.k8sClient.Get(ctx, crcli.ObjectKey{Name: "update-server-token", Namespace: "kube-system"}, tokenSecret); err != nil { + u.log.Infof("unable to get update server token: %v", err) + } else { + token = string(tokenSecret.Data["token"]) + } + + // Fetch the latest version from the update server + channelClient, err := uc.NewChannelClient(u.updateConfig.Spec.UpdateServer, u.updateConfig.Spec.Channel, token) + if err != nil { + u.log.Errorf("failed to create channel client: %v", err) + return + } + + k8sClient, err := u.apClientFactory.GetClient() + if err != nil { + u.log.Errorf("failed to create k8s client: %v", err) + return + } + // Collect cluster info + ci, err := CollectData(ctx, k8sClient) + if err != nil { + u.log.Errorf("failed to collect cluster info: %s", err.Error()) + return + } + extraHeaders := ci.AsMap() + + latestVersion, err := channelClient.GetLatest(ctx, extraHeaders) + if err != nil { + u.log.Errorf("failed to get latest version: %v", err) + return + } + u.log.Debugf("got new version: %s", latestVersion.Version) + // Check if the latest version is newer than the current version + current, err := version.NewVersion(u.currentK0sVersion) + if err != nil { + u.log.Errorf("failed to parse current version: %v", err) + return + } + new, err := version.NewVersion(latestVersion.Version) + if err != nil { + u.log.Errorf("failed to parse latest version: %v", err) + return + } + + if !new.GreaterThan(current) { + u.log.Infof("no new version available") + return + } + + if !u.updateConfig.Spec.UpgradeStrategy.Periodic.IsWithinPeriod(time.Now()) { + u.log.Infof("new version available but not within update window") + return + } + + u.log.Infof("new version available: %+v", latestVersion) + // Check if there's existing plan in-progress + existingPlan := &apv1beta2.Plan{} + found := true + if err := u.k8sClient.Get(ctx, types.NamespacedName{Name: "autopilot"}, existingPlan, &crcli.GetOptions{}); err != nil { + if !errors.IsNotFound(err) { + u.log.WithError(err).Errorf("failed to get possible existing plans") + return + } + found = false + } + + if found && existingPlan.Status.State != apcore.PlanCompleted { + u.log.Infof("existing plan in state %s, won't create a new one", existingPlan.Status.State.String()) + return + } + + // Create the update plan + plan := u.updateConfig.ToPlan(latestVersion) + if err := u.k8sClient.Patch(ctx, &plan, crcli.Apply, patchOpts...); err != nil { + u.log.Errorf("failed to patch plan: %v", err) + return + } + u.log.Info("successfully updated plan") +} diff --git a/pkg/autopilot/controller/updates/update_controller.go b/pkg/autopilot/controller/updates/update_controller.go index 30e71826c4b9..a9ed8cd03b78 100644 --- a/pkg/autopilot/controller/updates/update_controller.go +++ b/pkg/autopilot/controller/updates/update_controller.go @@ -24,6 +24,7 @@ import ( corev1 "k8s.io/api/core/v1" cr "sigs.k8s.io/controller-runtime" crcli "sigs.k8s.io/controller-runtime/pkg/client" + "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" crman "sigs.k8s.io/controller-runtime/pkg/manager" ) @@ -34,7 +35,8 @@ type updateController struct { clusterID string - updater *updater + updaters map[string]updater + parentCtx context.Context } func RegisterControllers(ctx context.Context, logger *logrus.Entry, mgr crman.Manager, clientFactory apcli.FactoryInterface, leaderMode bool, clusterID string) error { @@ -46,6 +48,8 @@ func RegisterControllers(ctx context.Context, logger *logrus.Entry, mgr crman.Ma client: mgr.GetClient(), clientFactory: clientFactory, clusterID: clusterID, + updaters: make(map[string]updater), + parentCtx: ctx, }, ) } @@ -59,20 +63,54 @@ func (u *updateController) Reconcile(ctx context.Context, req cr.Request) (cr.Re var token string tokenSecret := &corev1.Secret{} if err := u.client.Get(ctx, crcli.ObjectKey{Name: "update-server-token", Namespace: "kube-system"}, tokenSecret); err != nil { - u.log.Errorf("unable to get plan='%s': %v", req.NamespacedName, err) + u.log.Infof("unable to get update server token='%s': %v", req.NamespacedName, err) } else { token = string(tokenSecret.Data["token"]) } - u.log.Infof("processing updater config '%s'", req.NamespacedName) + u.log.Debugf("processing updater config '%s'", req.NamespacedName) - if u.updater == nil { - updater, err := newUpdater(ctx, *updaterConfig, u.client, u.clusterID, token) - if err != nil { + // If the config is being deleted, stop the updater + if !updaterConfig.DeletionTimestamp.IsZero() { + u.log.Debugf("updater config '%s' is being deleted", req.NamespacedName) + if updater, ok := u.updaters[req.NamespacedName.String()]; ok { + u.log.Debugf("stopping existing updater for '%s'", req.NamespacedName) + updater.Stop() + delete(u.updaters, req.NamespacedName.String()) + } + // Remove finalizer + controllerutil.RemoveFinalizer(updaterConfig, apv1beta2.UpdateConfigFinalizer) + if err := u.client.Update(ctx, updaterConfig); err != nil { return cr.Result{}, err } - u.updater = updater - u.updater.Run() + return cr.Result{}, nil + } + u.log.Debugf("checking if there's an existing updater for '%s'", req.NamespacedName) + // Find the updater for this config if exists + if updater, ok := u.updaters[req.NamespacedName.String()]; ok { + // Check if there's been updates to the config, if so re-create the updater + if updater.Config() == nil || updater.Config().ObjectMeta.ResourceVersion != updaterConfig.ResourceVersion { + u.log.Debugf("updater config '%s' has been updated, re-creating updater", req.NamespacedName) + updater.Stop() + delete(u.updaters, req.NamespacedName.String()) + } + } + u.log.Debugf("creating new updater for '%s'", req.NamespacedName) + // Create new updater + updater, err := newUpdater(u.parentCtx, *updaterConfig, u.client, u.clientFactory, u.clusterID, token) + if err != nil { + u.log.Errorf("failed to create updater for '%s': %s", req.NamespacedName, err) + return cr.Result{}, err + } + u.updaters[req.NamespacedName.String()] = updater + if err := updater.Run(); err != nil { + return cr.Result{}, err + } + + // Add finalizer if not present + controllerutil.AddFinalizer(updaterConfig, apv1beta2.UpdateConfigFinalizer) + if err := u.client.Update(ctx, updaterConfig); err != nil { + return cr.Result{}, err } return cr.Result{}, nil diff --git a/pkg/autopilot/controller/updates/updater.go b/pkg/autopilot/controller/updates/updater.go index 3333f8844780..4b43980a1a12 100644 --- a/pkg/autopilot/controller/updates/updater.go +++ b/pkg/autopilot/controller/updates/updater.go @@ -16,6 +16,7 @@ package updates import ( "context" + "fmt" "strconv" "time" @@ -27,15 +28,26 @@ import ( crcli "sigs.k8s.io/controller-runtime/pkg/client" apv1beta2 "github.com/k0sproject/k0s/pkg/apis/autopilot/v1beta2" + apcli "github.com/k0sproject/k0s/pkg/autopilot/client" appc "github.com/k0sproject/k0s/pkg/autopilot/controller/plans/core" "github.com/k0sproject/k0s/pkg/autopilot/controller/signal/k0s" uc "github.com/k0sproject/k0s/pkg/autopilot/updater" + "github.com/k0sproject/k0s/pkg/build" "github.com/k0sproject/k0s/pkg/component/status" ) +type updater interface { + // Run starts the updater + Run() error + // Stop stops the updater + Stop() + + Config() *apv1beta2.UpdateConfig +} + const defaultCronSchedule = "@hourly" -type updater struct { +type cronUpdater struct { ctx context.Context cancel context.CancelFunc log *logrus.Entry @@ -53,12 +65,23 @@ var patchOpts = []crcli.PatchOption{ crcli.ForceOwnership, } -func newUpdater(parentCtx context.Context, updateConfig apv1beta2.UpdateConfig, k8sClient crcli.Client, clusterID string, updateServerToken string) (*updater, error) { +func newUpdater(parentCtx context.Context, updateConfig apv1beta2.UpdateConfig, k8sClient crcli.Client, apClientFactory apcli.FactoryInterface, clusterID string, updateServerToken string) (updater, error) { updateClient, err := uc.NewClient(updateConfig.Spec.UpdateServer, updateServerToken) if err != nil { return nil, err } + switch updateConfig.Spec.UpgradeStrategy.Type { + case apv1beta2.UpdateStrategyTypeCron: + return newCronUpdater(parentCtx, updateConfig, k8sClient, clusterID, updateClient) + case apv1beta2.UpdateStrategyTypePeriodic: + return newPeriodicUpdater(parentCtx, updateConfig, k8sClient, apClientFactory, clusterID, build.Version) + default: + return nil, fmt.Errorf("unknown update strategy type: %s", updateConfig.Spec.UpgradeStrategy.Type) + } +} + +func newCronUpdater(parentCtx context.Context, updateConfig apv1beta2.UpdateConfig, k8sClient crcli.Client, clusterID string, updateClient uc.Client) (updater, error) { schedule := updateConfig.Spec.UpgradeStrategy.Cron if schedule == "" { schedule = defaultCronSchedule @@ -70,10 +93,10 @@ func newUpdater(parentCtx context.Context, updateConfig apv1beta2.UpdateConfig, } ctx, cancel := context.WithCancel(parentCtx) - u := &updater{ + u := &cronUpdater{ ctx: ctx, cancel: cancel, - log: logrus.WithField("controller", "update-checker"), + log: logrus.WithField("controller", "update-checker-cron"), updateClient: updateClient, updateConfig: updateConfig, updateSchedule: schedule, @@ -85,14 +108,19 @@ func newUpdater(parentCtx context.Context, updateConfig apv1beta2.UpdateConfig, return u, nil } -func (u *updater) Run() { - u.log.Info("running update checker") +func (u *cronUpdater) Run() error { + u.log.Info("running cron update checker") u.cron = cron.New() _ = u.cron.AddFunc(u.updateSchedule, u.checkUpdates) u.cron.Start() + return nil +} + +func (u *cronUpdater) Config() *apv1beta2.UpdateConfig { + return &u.updateConfig } -func (u *updater) checkUpdates() { +func (u *cronUpdater) checkUpdates() { u.log.Info("checking updates...") var curPlan apv1beta2.Plan err := u.k8sClient.Get(u.ctx, crcli.ObjectKey{Name: "autopilot"}, &curPlan) @@ -123,14 +151,14 @@ func (u *updater) checkUpdates() { u.log.Info("successfully updated plan") } -func (u *updater) Stop() { +func (u *cronUpdater) Stop() { u.cron.Stop() u.cancel() } // needToUpdate checks the need to update. we'll create the update Plan if: // - there's no existing plan -func (u *updater) needToUpdate() bool { +func (u *cronUpdater) needToUpdate() bool { var plan apv1beta2.Plan err := u.k8sClient.Get(u.ctx, crcli.ObjectKey{Name: "autopilot"}, &plan) if err != nil && errors.IsNotFound(err) { @@ -144,7 +172,7 @@ func (u *updater) needToUpdate() bool { return false } -func (u *updater) toPlan(nextVersion *uc.Update) apv1beta2.Plan { +func (u *cronUpdater) toPlan(nextVersion *uc.Update) apv1beta2.Plan { p := apv1beta2.Plan{ TypeMeta: v1.TypeMeta{ Kind: "Plan", diff --git a/pkg/component/controller/updateprober.go b/pkg/component/controller/updateprober.go new file mode 100644 index 000000000000..152feaf97556 --- /dev/null +++ b/pkg/component/controller/updateprober.go @@ -0,0 +1,192 @@ +// Copyright 2023 k0s 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 controller + +import ( + "context" + "fmt" + "os" + "time" + + // "github.com/k0sproject/k0s/pkg/component/manager" + + "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" + "github.com/k0sproject/k0s/pkg/autopilot/channels" + apcli "github.com/k0sproject/k0s/pkg/autopilot/client" + "github.com/k0sproject/k0s/pkg/autopilot/controller/updates" + "github.com/k0sproject/k0s/pkg/build" + "github.com/k0sproject/k0s/pkg/component/controller/leaderelector" + "github.com/k0sproject/k0s/pkg/component/manager" + "github.com/sirupsen/logrus" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +// Dummy checks so we catch easily if we miss some interface implementation +var _ manager.Component = (*UpdateProber)(nil) + +type UpdateProber struct { + APClientFactory apcli.FactoryInterface + ClusterConfig *v1beta1.ClusterConfig + log logrus.FieldLogger + leaderElector leaderelector.Interface +} + +func NewUpdateProber(apClientFactory apcli.FactoryInterface, leaderElector leaderelector.Interface) *UpdateProber { + return &UpdateProber{ + APClientFactory: apClientFactory, + log: logrus.WithFields(logrus.Fields{"component": "updateprober"}), + leaderElector: leaderElector, + } +} + +func (u *UpdateProber) Init(ctx context.Context) error { + return nil +} + +func (u *UpdateProber) Start(ctx context.Context) error { + u.log.Debug("starting up") + // Check for updates in 30min intervals from default update server + // ENV var only to be used for testing purposes + updateCheckInterval := time.Duration(30 * time.Minute) + if os.Getenv("K0S_UPDATE_CHECK_INTERVAL") != "" { + d, err := time.ParseDuration(os.Getenv("K0S_UPDATE_CHECK_INTERVAL")) + if err != nil { + u.log.Warnf("failed to parse K0S_UPDATE_CHECK_INTERVAL, using default value of 30mins: %s", err.Error()) + } else { + updateCheckInterval = d + } + } + u.log.Debugf("using interval %s", updateCheckInterval.String()) + go func() { + ticker := time.NewTicker(updateCheckInterval) + defer ticker.Stop() + for { + select { + case <-ticker.C: + u.checkUpdates(ctx) + case <-ctx.Done(): + return + } + } + }() + return nil +} + +func (u *UpdateProber) Stop() error { + return nil +} + +func (u *UpdateProber) checkUpdates(ctx context.Context) { + if !u.leaderElector.IsLeader() { + u.log.Debug("not leader, skipping check") + } + u.log.Debug("checking updates") + // Check if there's an active UpdateConfig, if there is no need to do this generic polling + apClient, err := u.APClientFactory.GetAutopilotClient() + if err != nil { + u.log.Warnf("failed to create k8s client: %s", err.Error()) + } + ctx, cancel := context.WithTimeout(ctx, 2*time.Minute) + defer cancel() + + u.log.Debug("checking if there's existing UpdateConfig objects") + updateConfigs, err := apClient.AutopilotV1beta2().UpdateConfigs().List(ctx, metav1.ListOptions{}) + if err != nil { + u.log.Warnf("failed to list update configs: %s", err.Error()) + return + } + u.log.Debugf("found %d UpdateConfig objects", len(updateConfigs.Items)) + if len(updateConfigs.Items) > 0 { + u.log.Debugf("found %d update configs, skipping generic update check", len(updateConfigs.Items)) + return + } + + // Create new update channel client for default server and latest channel + // ENV var only to be used for testing purposes + updateServer := "https://updates.k0sproject.io" + if os.Getenv("K0S_UPDATE_SERVER") != "" { + updateServer = os.Getenv("K0S_UPDATE_SERVER") + } + u.log.Debugf("using update server: %s", updateServer) + uc, err := channels.NewChannelClient(updateServer, "latest", "") + if err != nil { + u.log.Errorf("failed to create update channel client: %s", err.Error()) + return + } + + kc, err := u.APClientFactory.GetClient() + if err != nil { + u.log.Errorf("failed to create k8s client: %s", err.Error()) + return + } + + // Collect cluster info + ci, err := updates.CollectData(ctx, kc) + if err != nil { + u.log.Errorf("failed to collect cluster info: %s", err.Error()) + return + } + extraHeaders := ci.AsMap() + u.log.Debugf("checking for updates from %s", updateServer) + + // Check for updates + v, err := uc.GetLatest(ctx, extraHeaders) + if err != nil { + u.log.Errorf("failed to get latest version: %s", err.Error()) + return + } + u.log.Debugf("got latest version: %s", v.Version) + ksns, err := kc.CoreV1().Namespaces().Get(ctx, "kube-system", metav1.GetOptions{}) + if err != nil { + u.log.WithError(err).Warn("failed to get kube-system namespace details") + } + // Check if current version is outdated + isNewer, err := v.IsNewerThan(build.Version) + if err != nil { + u.log.Errorf("failed to compare versions: %s", err.Error()) + return + } + if isNewer { + // Create event to notify admin + u.log.Infof("New version available: %s", v.Version) + name := fmt.Sprintf("k0s-update-probe-%s-%d", v.Version, time.Now().Unix()) + e := corev1.Event{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: "kube-system", + }, + InvolvedObject: corev1.ObjectReference{ + Kind: "Namespace", + Name: "kube-system", + Namespace: "kube-system", + APIVersion: ksns.APIVersion, + UID: ksns.UID, + }, + Reason: "NewVersionAvailable", + Message: "New version available: " + v.Version, + Type: "Normal", + Source: corev1.EventSource{ + Component: "k0s", + }, + } + if _, err := kc.CoreV1().Events("kube-system").Create(ctx, &e, metav1.CreateOptions{}); err != nil { + u.log.Errorf("failed to create event: %s", err.Error()) + return + } + } else { + u.log.Debugf("no newer version availablen") + } +} diff --git a/pkg/context/context.go b/pkg/context/context.go new file mode 100644 index 000000000000..2f11ec774636 --- /dev/null +++ b/pkg/context/context.go @@ -0,0 +1,47 @@ +/* +Copyright 2023 k0s 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 context + +import ( + "context" + + k0sapi "github.com/k0sproject/k0s/pkg/apis/k0s/v1beta1" +) + +type Key string + +const ( + ContextNodeConfigKey Key = "k0s_node_config" + ContextClusterConfigKey Key = "k0s_cluster_config" +) + +func FromContext[out any](ctx context.Context, key Key) *out { + v, ok := ctx.Value(key).(*out) + if !ok { + return nil + } + return v +} + +func GetNodeConfig(ctx context.Context) *k0sapi.ClusterConfig { + cfg, ok := ctx.Value(ContextNodeConfigKey).(*k0sapi.ClusterConfig) + if !ok { + return nil + } + + return cfg +} diff --git a/static/manifests/autopilot/CustomResourceDefinition/autopilot.k0sproject.io_updateconfigs.yaml b/static/manifests/autopilot/CustomResourceDefinition/autopilot.k0sproject.io_updateconfigs.yaml index eabf3ebe3f80..3090adcb8cc9 100644 --- a/static/manifests/autopilot/CustomResourceDefinition/autopilot.k0sproject.io_updateconfigs.yaml +++ b/static/manifests/autopilot/CustomResourceDefinition/autopilot.k0sproject.io_updateconfigs.yaml @@ -33,10 +33,13 @@ spec: spec: properties: channel: + default: stable + description: Channel defines the update channel to use for this update + config type: string planSpec: - description: AutopilotPlanSpec describes the behavior of the autopilot - generated `Plan` + description: PlanSpec defines the plan spec to use for this update + config properties: commands: description: Commands are a collection of all of the commands @@ -232,13 +235,36 @@ spec: - commands type: object updateServer: + default: https://updates.k0sproject.io + description: UpdateServer defines the update server to use for this + update config type: string upgradeStrategy: + description: UpdateStrategy defines the update strategy to use for + this update config properties: cron: + description: Cron defines the cron expression for the cron upgrade + strategy + type: string + periodic: + description: Periodic defines the periodic upgrade strategy + properties: + days: + items: + type: string + type: array + length: + type: string + startTime: + type: string + type: object + type: + description: Type defines the type of upgrade strategy + enum: + - periodic + - cron type: string - required: - - cron type: object type: object required: