From 74d645ae1c7e1264745ef58fbb4b8c8610d2bdd0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Berkay=20Tekin=20=C3=96z?= Date: Fri, 6 Sep 2024 11:02:28 +0300 Subject: [PATCH] Adds SnapRefreshRPC and node token authentication (#636) Adds SnapRefreshRPC and node token authentication --------- Co-authored-by: Angelos Kolaitis --- src/k8s/cmd/k8s/k8s_x_capi.go | 22 ++++++ src/k8s/cmd/util/environ.go | 5 +- src/k8s/go.mod | 2 +- src/k8s/go.sum | 4 +- src/k8s/pkg/client/snapd/client.go | 30 +++++++++ src/k8s/pkg/client/snapd/refresh_status.go | 32 +++++++++ src/k8s/pkg/k8sd/api/endpoints.go | 11 +++ src/k8s/pkg/k8sd/api/node_access_handler.go | 33 +++++++++ src/k8s/pkg/k8sd/api/snap_refresh.go | 31 +++++++++ src/k8s/pkg/k8sd/api/snap_refresh_status.go | 25 +++++++ src/k8s/pkg/k8sd/types/refresh.go | 46 +++++++++++++ src/k8s/pkg/k8sd/types/refresh_status.go | 24 +++++++ src/k8s/pkg/snap/interface.go | 5 ++ src/k8s/pkg/snap/mock/mock.go | 19 +++++- src/k8s/pkg/snap/pebble.go | 45 +++++++++++++ src/k8s/pkg/snap/snap.go | 75 ++++++++++++++++++--- 16 files changed, 394 insertions(+), 15 deletions(-) create mode 100644 src/k8s/pkg/client/snapd/client.go create mode 100644 src/k8s/pkg/client/snapd/refresh_status.go create mode 100644 src/k8s/pkg/k8sd/api/node_access_handler.go create mode 100644 src/k8s/pkg/k8sd/api/snap_refresh.go create mode 100644 src/k8s/pkg/k8sd/api/snap_refresh_status.go create mode 100644 src/k8s/pkg/k8sd/types/refresh.go create mode 100644 src/k8s/pkg/k8sd/types/refresh_status.go diff --git a/src/k8s/cmd/k8s/k8s_x_capi.go b/src/k8s/cmd/k8s/k8s_x_capi.go index bdeb88377..2da816bcb 100644 --- a/src/k8s/cmd/k8s/k8s_x_capi.go +++ b/src/k8s/cmd/k8s/k8s_x_capi.go @@ -1,6 +1,8 @@ package k8s import ( + "os" + apiv1 "github.com/canonical/k8s-snap-api/api/v1" cmdutil "github.com/canonical/k8s/cmd/util" "github.com/spf13/cobra" @@ -34,6 +36,25 @@ func newXCAPICmd(env cmdutil.ExecutionEnvironment) *cobra.Command { } }, } + setNodeToken := &cobra.Command{ + Use: "set-node-token ", + Short: "Set the node token to authenticate with per-node k8sd endpoints", + Args: cmdutil.ExactArgs(env, 1), + Run: func(cmd *cobra.Command, args []string) { + token := args[0] + if token == "" { + cmd.PrintErrf("Error: The token must be provided.\n") + env.Exit(1) + return + } + + if err := os.WriteFile(env.Snap.NodeTokenFile(), []byte(token), 0600); err != nil { + cmd.PrintErrf("Error: Failed to write the node token to file.\n\nThe error was: %v\n", err) + env.Exit(1) + return + } + }, + } cmd := &cobra.Command{ Use: "x-capi", @@ -42,6 +63,7 @@ func newXCAPICmd(env cmdutil.ExecutionEnvironment) *cobra.Command { } cmd.AddCommand(setAuthTokenCmd) + cmd.AddCommand(setNodeToken) return cmd } diff --git a/src/k8s/cmd/util/environ.go b/src/k8s/cmd/util/environ.go index 9c19ab6ef..d843f71f0 100644 --- a/src/k8s/cmd/util/environ.go +++ b/src/k8s/cmd/util/environ.go @@ -33,8 +33,9 @@ func DefaultExecutionEnvironment() ExecutionEnvironment { switch os.Getenv("K8SD_RUNTIME_ENVIRONMENT") { case "", "snap": s = snap.NewSnap(snap.SnapOpts{ - SnapDir: os.Getenv("SNAP"), - SnapCommonDir: os.Getenv("SNAP_COMMON"), + SnapDir: os.Getenv("SNAP"), + SnapCommonDir: os.Getenv("SNAP_COMMON"), + SnapInstanceName: os.Getenv("SNAP_INSTANCE_NAME"), }) case "pebble": s = snap.NewPebble(snap.PebbleOpts{ diff --git a/src/k8s/go.mod b/src/k8s/go.mod index d315d614f..57164d98b 100644 --- a/src/k8s/go.mod +++ b/src/k8s/go.mod @@ -5,7 +5,7 @@ go 1.22.6 require ( dario.cat/mergo v1.0.0 github.com/canonical/go-dqlite v1.22.0 - github.com/canonical/k8s-snap-api v1.0.3 + github.com/canonical/k8s-snap-api v1.0.5 github.com/canonical/lxd v0.0.0-20240822122218-e7b2a7a83230 github.com/canonical/microcluster/v3 v3.0.0-20240827143335-f7a4d3984970 github.com/go-logr/logr v1.4.2 diff --git a/src/k8s/go.sum b/src/k8s/go.sum index bbd2e7cda..0a695e62d 100644 --- a/src/k8s/go.sum +++ b/src/k8s/go.sum @@ -99,8 +99,8 @@ github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0 h1:nvj0OLI3YqYXe github.com/bugsnag/panicwrap v0.0.0-20151223152923-e2c28503fcd0/go.mod h1:D/8v3kj0zr8ZAKg1AQ6crr+5VwKN5eIywRkfhyM/+dE= github.com/canonical/go-dqlite v1.22.0 h1:DuJmfcREl4gkQJyvZzjl2GHFZROhbPyfdjDRQXpkOyw= github.com/canonical/go-dqlite v1.22.0/go.mod h1:Uvy943N8R4CFUAs59A1NVaziWY9nJ686lScY7ywurfg= -github.com/canonical/k8s-snap-api v1.0.3 h1:unMuIdLgdjlYj3bhkTQoHzphNrJG54IV23mAi1EBB38= -github.com/canonical/k8s-snap-api v1.0.3/go.mod h1:LDPoIYCeYnfgOFrwVPJ/4edGU264w7BB7g0GsVi36AY= +github.com/canonical/k8s-snap-api v1.0.5 h1:49bgi6CGtFjCPweeTz55Sv/waKgCl6ftx4BqXt3RI9k= +github.com/canonical/k8s-snap-api v1.0.5/go.mod h1:LDPoIYCeYnfgOFrwVPJ/4edGU264w7BB7g0GsVi36AY= github.com/canonical/lxd v0.0.0-20240822122218-e7b2a7a83230 h1:YOqZ+/14OPZ+/TOXpRHIX3KLT0C+wZVpewKIwlGUmW0= github.com/canonical/lxd v0.0.0-20240822122218-e7b2a7a83230/go.mod h1:YVGI7HStOKsV+cMyXWnJ7RaMPaeWtrkxyIPvGWbgACc= github.com/canonical/microcluster/v3 v3.0.0-20240827143335-f7a4d3984970 h1:UrnpglbXELlxtufdk6DGDytu2JzyzuS3WTsOwPrkQLI= diff --git a/src/k8s/pkg/client/snapd/client.go b/src/k8s/pkg/client/snapd/client.go new file mode 100644 index 000000000..b06cac05c --- /dev/null +++ b/src/k8s/pkg/client/snapd/client.go @@ -0,0 +1,30 @@ +package snapd + +import ( + "context" + "fmt" + "net" + "net/http" +) + +var socketPath = "/run/snapd.socket" + +type Client struct { + client *http.Client +} + +func NewClient() (*Client, error) { + defaultTransport, ok := http.DefaultTransport.(*http.Transport) + if !ok { + return nil, fmt.Errorf("http.DefaultTransport is not a *http.Transport") + } + + unixTransport := defaultTransport.Clone() + defaultDialContext := unixTransport.DialContext + + unixTransport.DialContext = func(ctx context.Context, _, _ string) (net.Conn, error) { + return defaultDialContext(ctx, "unix", socketPath) + } + + return &Client{client: &http.Client{Transport: unixTransport}}, nil +} diff --git a/src/k8s/pkg/client/snapd/refresh_status.go b/src/k8s/pkg/client/snapd/refresh_status.go new file mode 100644 index 000000000..e2feb37a7 --- /dev/null +++ b/src/k8s/pkg/client/snapd/refresh_status.go @@ -0,0 +1,32 @@ +package snapd + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/canonical/k8s/pkg/k8sd/types" +) + +type snapdChangeResponse struct { + Result types.RefreshStatus `json:"result"` +} + +func (c *Client) GetRefreshStatus(changeID string) (*types.RefreshStatus, error) { + resp, err := c.client.Get(fmt.Sprintf("http://localhost/v2/changes/%s", changeID)) + if err != nil { + return nil, fmt.Errorf("failed to get snapd change status: %w", err) + } + + resBody, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("client: could not read response body: %s", err) + } + + var changeResponse snapdChangeResponse + if err := json.Unmarshal(resBody, &changeResponse); err != nil { + return nil, fmt.Errorf("client: could not unmarshal response body: %s", err) + } + + return &changeResponse.Result, nil +} diff --git a/src/k8s/pkg/k8sd/api/endpoints.go b/src/k8s/pkg/k8sd/api/endpoints.go index 53b080459..e7e7af5d0 100644 --- a/src/k8s/pkg/k8sd/api/endpoints.go +++ b/src/k8s/pkg/k8sd/api/endpoints.go @@ -140,5 +140,16 @@ func (e *Endpoints) Endpoints() []rest.Endpoint { Path: apiv1.ClusterAPIRemoveNodeRPC, Post: rest.EndpointAction{Handler: e.postClusterRemove, AccessHandler: ValidateCAPIAuthTokenAccessHandler("capi-auth-token"), AllowUntrusted: true}, }, + // Snap refreshes + { + Name: "Snap/Refresh", + Path: apiv1.SnapRefreshRPC, + Post: rest.EndpointAction{Handler: e.postSnapRefresh, AccessHandler: e.ValidateNodeTokenAccessHandler("node-token"), AllowUntrusted: true}, + }, + { + Name: "Snap/RefreshStatus", + Path: apiv1.SnapRefreshStatusRPC, + Post: rest.EndpointAction{Handler: e.postSnapRefreshStatus, AccessHandler: e.ValidateNodeTokenAccessHandler("node-token"), AllowUntrusted: true}, + }, } } diff --git a/src/k8s/pkg/k8sd/api/node_access_handler.go b/src/k8s/pkg/k8sd/api/node_access_handler.go new file mode 100644 index 000000000..afe392f58 --- /dev/null +++ b/src/k8s/pkg/k8sd/api/node_access_handler.go @@ -0,0 +1,33 @@ +package api + +import ( + "fmt" + "net/http" + "os" + "strings" + + "github.com/canonical/lxd/lxd/response" + "github.com/canonical/microcluster/v3/state" +) + +func (e *Endpoints) ValidateNodeTokenAccessHandler(tokenHeaderName string) func(s state.State, r *http.Request) (bool, response.Response) { + return func(s state.State, r *http.Request) (bool, response.Response) { + token := r.Header.Get(tokenHeaderName) + if token == "" { + return false, response.Unauthorized(fmt.Errorf("missing header %q", tokenHeaderName)) + } + + snap := e.provider.Snap() + + nodeToken, err := os.ReadFile(snap.NodeTokenFile()) + if err != nil { + return false, response.InternalError(fmt.Errorf("failed to read node access token: %w", err)) + } + + if strings.TrimSpace(string(nodeToken)) != token { + return false, response.Unauthorized(fmt.Errorf("invalid token")) + } + + return true, nil + } +} diff --git a/src/k8s/pkg/k8sd/api/snap_refresh.go b/src/k8s/pkg/k8sd/api/snap_refresh.go new file mode 100644 index 000000000..112eb2cf6 --- /dev/null +++ b/src/k8s/pkg/k8sd/api/snap_refresh.go @@ -0,0 +1,31 @@ +package api + +import ( + "fmt" + "net/http" + + apiv1 "github.com/canonical/k8s-snap-api/api/v1" + "github.com/canonical/k8s/pkg/k8sd/types" + "github.com/canonical/k8s/pkg/utils" + "github.com/canonical/lxd/lxd/response" + "github.com/canonical/microcluster/v3/state" +) + +func (e *Endpoints) postSnapRefresh(s state.State, r *http.Request) response.Response { + req := apiv1.SnapRefreshRequest{} + if err := utils.NewStrictJSONDecoder(r.Body).Decode(&req); err != nil { + return response.BadRequest(fmt.Errorf("failed to parse request: %w", err)) + } + + refreshOpts, err := types.RefreshOptsFromAPI(req) + if err != nil { + return response.BadRequest(fmt.Errorf("invalid refresh options: %w", err)) + } + + id, err := e.provider.Snap().Refresh(e.Context(), refreshOpts) + if err != nil { + return response.InternalError(fmt.Errorf("failed to refresh snap: %w", err)) + } + + return response.SyncResponse(true, apiv1.SnapRefreshResponse{ChangeID: id}) +} diff --git a/src/k8s/pkg/k8sd/api/snap_refresh_status.go b/src/k8s/pkg/k8sd/api/snap_refresh_status.go new file mode 100644 index 000000000..990b7953c --- /dev/null +++ b/src/k8s/pkg/k8sd/api/snap_refresh_status.go @@ -0,0 +1,25 @@ +package api + +import ( + "fmt" + "net/http" + + apiv1 "github.com/canonical/k8s-snap-api/api/v1" + "github.com/canonical/k8s/pkg/utils" + "github.com/canonical/lxd/lxd/response" + "github.com/canonical/microcluster/v3/state" +) + +func (e *Endpoints) postSnapRefreshStatus(s state.State, r *http.Request) response.Response { + req := apiv1.SnapRefreshStatusRequest{} + if err := utils.NewStrictJSONDecoder(r.Body).Decode(&req); err != nil { + return response.BadRequest(fmt.Errorf("failed to parse request: %w", err)) + } + + status, err := e.provider.Snap().RefreshStatus(e.Context(), req.ChangeID) + if err != nil { + return response.InternalError(fmt.Errorf("failed to get snap refresh status: %w", err)) + } + + return response.SyncResponse(true, status.ToAPI()) +} diff --git a/src/k8s/pkg/k8sd/types/refresh.go b/src/k8s/pkg/k8sd/types/refresh.go new file mode 100644 index 000000000..365c9cb30 --- /dev/null +++ b/src/k8s/pkg/k8sd/types/refresh.go @@ -0,0 +1,46 @@ +package types + +import ( + "fmt" + + apiv1 "github.com/canonical/k8s-snap-api/api/v1" +) + +// RefreshOpts controls the target version of the snap during a refresh. +type RefreshOpts struct { + // LocalPath refreshes the snap using a local snap archive, e.g. "/path/to/k8s.snap". + LocalPath string `json:"localPath"` + // Channel refreshes the snap to track a specific channel, e.g. "latest/edge". + Channel string `json:"channel"` + // Revision refreshes the snap to a specific revision, e.g. "722". + Revision string `json:"revision"` +} + +func RefreshOptsFromAPI(req apiv1.SnapRefreshRequest) (RefreshOpts, error) { + var optsMap = map[string]string{ + "localPath": req.LocalPath, + "channel": req.Channel, + "revision": req.Revision, + } + + // Make sure only one of the options is set. + alreadySet := false + for _, v := range optsMap { + if alreadySet && v != "" { + return RefreshOpts{}, fmt.Errorf("only one of localPath, channel or revision can be specified") + } + if v != "" { + alreadySet = true + } + } + + switch { + case req.LocalPath != "": + return RefreshOpts{LocalPath: req.LocalPath}, nil + case req.Channel != "": + return RefreshOpts{Channel: req.Channel}, nil + case req.Revision != "": + return RefreshOpts{Revision: req.Revision}, nil + } + return RefreshOpts{}, fmt.Errorf("empty snap refresh target") +} diff --git a/src/k8s/pkg/k8sd/types/refresh_status.go b/src/k8s/pkg/k8sd/types/refresh_status.go new file mode 100644 index 000000000..cf3c59fa9 --- /dev/null +++ b/src/k8s/pkg/k8sd/types/refresh_status.go @@ -0,0 +1,24 @@ +package types + +import ( + apiv1 "github.com/canonical/k8s-snap-api/api/v1" +) + +// RefreshStatus represents the status of a snap refresh operation. +// This is a partial struct derived from the Change struct used by the snapd API. +type RefreshStatus struct { + // Status is the current status of the operation. + Status string `json:"status"` + // Ready indicates whether the operation has completed. + Ready bool `json:"ready"` + // Err contains an error message if the operation failed. + Err string `json:"err,omitempty"` +} + +func (r RefreshStatus) ToAPI() apiv1.SnapRefreshStatusResponse { + return apiv1.SnapRefreshStatusResponse{ + Status: r.Status, + Completed: r.Ready, + ErrorMessage: r.Err, + } +} diff --git a/src/k8s/pkg/snap/interface.go b/src/k8s/pkg/snap/interface.go index 7613deea9..03094679e 100644 --- a/src/k8s/pkg/snap/interface.go +++ b/src/k8s/pkg/snap/interface.go @@ -26,6 +26,9 @@ type Snap interface { SnapctlGet(ctx context.Context, args ...string) ([]byte, error) // snapctl get $args... SnapctlSet(ctx context.Context, args ...string) error // snapctl set $args... + Refresh(ctx context.Context, to types.RefreshOpts) (string, error) // snap refresh --no-wait [k8s --channel $track | k8s --revision $revision | $path ] + RefreshStatus(ctx context.Context, changeID string) (*types.RefreshStatus, error) // snap tasks $changeID + CNIConfDir() string // /etc/cni/net.d CNIBinDir() string // /opt/cni/bin CNIPluginsBinary() string // /snap/k8s/current/bin/cni @@ -51,6 +54,8 @@ type Snap interface { LockFilesDir() string // /var/snap/k8s/common/lock + NodeTokenFile() string // /var/snap/k8s/common/node-token + KubernetesClient(namespace string) (*kubernetes.Client, error) // admin kubernetes client KubernetesNodeClient(namespace string) (*kubernetes.Client, error) // node kubernetes client diff --git a/src/k8s/pkg/snap/mock/mock.go b/src/k8s/pkg/snap/mock/mock.go index 093e8eca4..fc9261720 100644 --- a/src/k8s/pkg/snap/mock/mock.go +++ b/src/k8s/pkg/snap/mock/mock.go @@ -38,6 +38,7 @@ type Mock struct { ServiceArgumentsDir string ServiceExtraConfigDir string LockFilesDir string + NodeTokenFile string KubernetesClient *kubernetes.Client KubernetesNodeClient *kubernetes.Client HelmClient helm.Client @@ -55,6 +56,9 @@ type Snap struct { RestartServiceCalledWith []string RestartServiceErr error + RefreshCalledWith []types.RefreshOpts + RefreshErr error + SnapctlSetCalledWith [][]string SnapctlSetErr error SnapctlGetCalledWith [][]string @@ -90,7 +94,17 @@ func (s *Snap) RestartService(ctx context.Context, name string) error { } return s.RestartServiceErr } - +func (s *Snap) Refresh(ctx context.Context, opts types.RefreshOpts) (string, error) { + if len(s.RefreshCalledWith) == 0 { + s.RefreshCalledWith = []types.RefreshOpts{opts} + } else { + s.RefreshCalledWith = append(s.RefreshCalledWith, opts) + } + return "", s.RefreshErr +} +func (s *Snap) RefreshStatus(ctx context.Context, changeID string) (*types.RefreshStatus, error) { + return nil, nil +} func (s *Snap) Strict() bool { return s.Mock.Strict } @@ -163,6 +177,9 @@ func (s *Snap) ServiceExtraConfigDir() string { func (s *Snap) LockFilesDir() string { return s.Mock.LockFilesDir } +func (s *Snap) NodeTokenFile() string { + return s.Mock.NodeTokenFile +} func (s *Snap) KubernetesClient(namespace string) (*kubernetes.Client, error) { return s.Mock.KubernetesClient, nil } diff --git a/src/k8s/pkg/snap/pebble.go b/src/k8s/pkg/snap/pebble.go index 27bfa4fca..c4a67019e 100644 --- a/src/k8s/pkg/snap/pebble.go +++ b/src/k8s/pkg/snap/pebble.go @@ -2,9 +2,13 @@ package snap import ( "context" + "fmt" "os/exec" "path/filepath" + "time" + "github.com/canonical/k8s/pkg/k8sd/types" + "github.com/canonical/k8s/pkg/log" "github.com/canonical/k8s/pkg/utils" ) @@ -52,6 +56,47 @@ func (s *pebble) RestartService(ctx context.Context, name string) error { return s.runCommand(ctx, []string{filepath.Join(s.snapDir, "bin", "pebble"), "restart", name}) } +func (s *pebble) Refresh(ctx context.Context, to types.RefreshOpts) (string, error) { + switch { + case to.Revision != "": + return "", fmt.Errorf("pebble does not support refreshing to a revision, only a local path") + case to.Channel != "": + return "", fmt.Errorf("pebble does not support refreshing to a channel, only a local path") + case to.LocalPath != "": + go func() { + select { + case <-ctx.Done(): + return + case <-time.After(5 * time.Second): + log.FromContext(ctx).Info("Refreshing kubernetes snap") + } + // replace the "kubernetes" binary with the new source. + // "cp -f" will replace the binary in case it's currently in use. + if err := s.runCommand(ctx, []string{"cp", "-f", to.LocalPath, filepath.Join(s.snapDir, "bin", "kubernetes")}); err != nil { + log.FromContext(ctx).Error(err, "Warning: failed to update the kubernetes binary") + } + // restart services if already running. + for _, service := range []string{"kube-apiserver", "kubelet", "kube-controller-manager", "kube-proxy", "kube-scheduler"} { + if err := s.RestartService(ctx, service); err != nil { + log.FromContext(ctx).WithValues("service", service).Error(err, "Warning: failed to restart after updating kubernetes binary") + } + } + }() + return "0", nil + default: + return "", fmt.Errorf("empty refresh options") + } +} + +func (s *pebble) RefreshStatus(ctx context.Context, changeID string) (*types.RefreshStatus, error) { + // pebble does not support refresh status checks + return &types.RefreshStatus{ + Status: "Done", + Ready: true, + Err: "", + }, nil +} + func (s *pebble) Strict() bool { return false } diff --git a/src/k8s/pkg/snap/snap.go b/src/k8s/pkg/snap/snap.go index b1206b750..032f6c988 100644 --- a/src/k8s/pkg/snap/snap.go +++ b/src/k8s/pkg/snap/snap.go @@ -7,11 +7,13 @@ import ( "os" "os/exec" "path/filepath" + "strings" "github.com/canonical/k8s/pkg/client/dqlite" "github.com/canonical/k8s/pkg/client/helm" "github.com/canonical/k8s/pkg/client/k8sd" "github.com/canonical/k8s/pkg/client/kubernetes" + "github.com/canonical/k8s/pkg/client/snapd" "github.com/canonical/k8s/pkg/k8sd/types" "github.com/canonical/k8s/pkg/log" "github.com/canonical/k8s/pkg/utils" @@ -21,16 +23,18 @@ import ( ) type SnapOpts struct { - SnapDir string - SnapCommonDir string - RunCommand func(ctx context.Context, command []string, opts ...func(c *exec.Cmd)) error + SnapInstanceName string + SnapDir string + SnapCommonDir string + RunCommand func(ctx context.Context, command []string, opts ...func(c *exec.Cmd)) error } // snap implements the Snap interface. type snap struct { - snapDir string - snapCommonDir string - runCommand func(ctx context.Context, command []string, opts ...func(c *exec.Cmd)) error + snapDir string + snapCommonDir string + snapInstanceName string + runCommand func(ctx context.Context, command []string, opts ...func(c *exec.Cmd)) error } // NewSnap creates a new interface with the K8s snap. @@ -41,9 +45,10 @@ func NewSnap(opts SnapOpts) *snap { runCommand = opts.RunCommand } s := &snap{ - snapDir: opts.SnapDir, - snapCommonDir: opts.SnapCommonDir, - runCommand: runCommand, + snapDir: opts.SnapDir, + snapCommonDir: opts.SnapCommonDir, + snapInstanceName: opts.SnapInstanceName, + runCommand: runCommand, } return s @@ -67,6 +72,54 @@ func (s *snap) RestartService(ctx context.Context, name string) error { return s.runCommand(ctx, []string{"snapctl", "restart", serviceName(name)}) } +// Refresh refreshes the snap to a different track, revision or custom snap. +func (s *snap) Refresh(ctx context.Context, to types.RefreshOpts) (string, error) { + if s.Strict() { + return "", fmt.Errorf("refresh operation not available on strictly confined deployments") + } + + var out []byte + var err error + + switch { + case to.Channel != "": + out, err = exec.CommandContext(ctx, "snap", "refresh", s.snapInstanceName, "--amend", "--channel", to.Channel, "--no-wait").Output() + case to.Revision != "": + out, err = exec.CommandContext(ctx, "snap", "refresh", s.snapInstanceName, "--amend", "--revision", to.Revision, "--no-wait").Output() + case to.LocalPath != "": + out, err = exec.CommandContext(ctx, "snap", "install", to.LocalPath, "--classic", "--dangerous", "--name", s.snapInstanceName, "--no-wait").Output() + default: + return "", fmt.Errorf("empty refresh options") + } + + if err != nil { + return "", fmt.Errorf("failed to refresh snap: %w", err) + } + + changeID := strings.TrimSpace(string(out)) + + return changeID, nil +} + +// RefreshStatus returns the status of a refresh operation. +func (s *snap) RefreshStatus(ctx context.Context, changeID string) (*types.RefreshStatus, error) { + if s.Strict() { + return nil, fmt.Errorf("refresh status operation not available on strictly confined deployments") + } + + client, err := snapd.NewClient() + if err != nil { + return nil, fmt.Errorf("failed to create snapd client: %w", err) + } + + status, err := client.GetRefreshStatus(changeID) + if err != nil { + return nil, fmt.Errorf("failed to get snapd refresh status: %w", err) + } + + return status, nil +} + type snapcraftYml struct { Confinement string `yaml:"confinement"` } @@ -192,6 +245,10 @@ func (s *snap) LockFilesDir() string { return filepath.Join(s.snapCommonDir, "lock") } +func (s *snap) NodeTokenFile() string { + return filepath.Join(s.snapCommonDir, "node-token") +} + func (s *snap) ContainerdExtraConfigDir() string { return filepath.Join(s.snapCommonDir, "etc", "containerd", "conf.d") }