Skip to content

Commit

Permalink
Adds SnapRefreshRPC and node token authentication (#636)
Browse files Browse the repository at this point in the history
Adds SnapRefreshRPC and node token authentication

---------

Co-authored-by: Angelos Kolaitis <[email protected]>
  • Loading branch information
berkayoz and neoaggelos authored Sep 6, 2024
1 parent fc2902f commit 74d645a
Show file tree
Hide file tree
Showing 16 changed files with 394 additions and 15 deletions.
22 changes: 22 additions & 0 deletions src/k8s/cmd/k8s/k8s_x_capi.go
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -34,6 +36,25 @@ func newXCAPICmd(env cmdutil.ExecutionEnvironment) *cobra.Command {
}
},
}
setNodeToken := &cobra.Command{
Use: "set-node-token <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",
Expand All @@ -42,6 +63,7 @@ func newXCAPICmd(env cmdutil.ExecutionEnvironment) *cobra.Command {
}

cmd.AddCommand(setAuthTokenCmd)
cmd.AddCommand(setNodeToken)

return cmd
}
5 changes: 3 additions & 2 deletions src/k8s/cmd/util/environ.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down
2 changes: 1 addition & 1 deletion src/k8s/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/k8s/go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
30 changes: 30 additions & 0 deletions src/k8s/pkg/client/snapd/client.go
Original file line number Diff line number Diff line change
@@ -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
}
32 changes: 32 additions & 0 deletions src/k8s/pkg/client/snapd/refresh_status.go
Original file line number Diff line number Diff line change
@@ -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
}
11 changes: 11 additions & 0 deletions src/k8s/pkg/k8sd/api/endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -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},
},
}
}
33 changes: 33 additions & 0 deletions src/k8s/pkg/k8sd/api/node_access_handler.go
Original file line number Diff line number Diff line change
@@ -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
}
}
31 changes: 31 additions & 0 deletions src/k8s/pkg/k8sd/api/snap_refresh.go
Original file line number Diff line number Diff line change
@@ -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})
}
25 changes: 25 additions & 0 deletions src/k8s/pkg/k8sd/api/snap_refresh_status.go
Original file line number Diff line number Diff line change
@@ -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())
}
46 changes: 46 additions & 0 deletions src/k8s/pkg/k8sd/types/refresh.go
Original file line number Diff line number Diff line change
@@ -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")
}
24 changes: 24 additions & 0 deletions src/k8s/pkg/k8sd/types/refresh_status.go
Original file line number Diff line number Diff line change
@@ -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,
}
}
5 changes: 5 additions & 0 deletions src/k8s/pkg/snap/interface.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
19 changes: 18 additions & 1 deletion src/k8s/pkg/snap/mock/mock.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ type Mock struct {
ServiceArgumentsDir string
ServiceExtraConfigDir string
LockFilesDir string
NodeTokenFile string
KubernetesClient *kubernetes.Client
KubernetesNodeClient *kubernetes.Client
HelmClient helm.Client
Expand All @@ -55,6 +56,9 @@ type Snap struct {
RestartServiceCalledWith []string
RestartServiceErr error

RefreshCalledWith []types.RefreshOpts
RefreshErr error

SnapctlSetCalledWith [][]string
SnapctlSetErr error
SnapctlGetCalledWith [][]string
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down
Loading

0 comments on commit 74d645a

Please sign in to comment.