diff --git a/api/openapi.yaml b/api/openapi.yaml index 1be42aa..901d727 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -16,6 +16,29 @@ paths: application/json: schema: $ref: '#components/schemas/ControllerInfo' + /cluster-settings: + get: + summary: "Retrieve cluster settings" + tags: + - cluster-settings + responses: + '200': + description: OK + content: + application/json: + schema: + $ref: '#components/schemas/ClusterSettings' + put: + summary: "Update cluster settings" + tags: + - cluster-settings + responses: + '200': + description: Cluster settings were successfully updated + content: + application/json: + schema: + $ref: '#components/schemas/ClusterSettings' /service-accounts: post: summary: "Create a Service Account" @@ -301,6 +324,17 @@ components: for this VM to be scheduled additionalProperties: type: integer + hostDirs: + type: array + items: + type: object + properties: + name: + type: string + path: + type: string + ro: + type: boolean Events: title: Events type: object @@ -343,3 +377,17 @@ components: commit: type: string description: Commit hash + ClusterSettings: + title: Cluster settings + type: object + properties: + hostDirPolicies: + type: array + description: If not empty, enables instantiating VMs with hostDir volumes that match the policies listed in this array. + items: + type: object + properties: + pathPrefix: + type: string + ro: + type: boolean diff --git a/internal/command/create/vm.go b/internal/command/create/vm.go index 76da537..a231a0a 100644 --- a/internal/command/create/vm.go +++ b/internal/command/create/vm.go @@ -21,6 +21,7 @@ var headless bool var resources map[string]string var restartPolicy string var startupScript string +var hostDirsRaw []string func newCreateVMCommand() *cobra.Command { command := &cobra.Command{ @@ -44,6 +45,9 @@ func newCreateVMCommand() *cobra.Command { command.PersistentFlags().StringVar(&startupScript, "startup-script", "", "startup script (e.g. --startup-script=\"sync\") or a path to a script file prefixed with \"@\" "+ "(e.g. \"--startup-script=@script.sh\")") + command.PersistentFlags().StringSliceVar(&hostDirsRaw, "host-dirs", []string{}, + "host directories to mount to the VM, can be specified multiple times and/or be comma-separated "+ + "(see \"tart run\"'s --dir argument for syntax)") return command } @@ -51,6 +55,18 @@ func newCreateVMCommand() *cobra.Command { func runCreateVM(cmd *cobra.Command, args []string) error { name := args[0] + // Convert arguments + var hostDirs []v1.HostDir + + for _, hostDirRaw := range hostDirsRaw { + hostDir, err := v1.NewHostDirFromString(hostDirRaw) + if err != nil { + return err + } + + hostDirs = append(hostDirs, hostDir) + } + vm := &v1.VM{ Meta: v1.Meta{ Name: name, @@ -61,6 +77,7 @@ func runCreateVM(cmd *cobra.Command, args []string) error { NetSoftnet: netSoftnet, NetBridged: netBridged, Headless: headless, + HostDirs: hostDirs, } // Convert resources diff --git a/internal/command/get/cluster_settings.go b/internal/command/get/cluster_settings.go new file mode 100644 index 0000000..9ae0acc --- /dev/null +++ b/internal/command/get/cluster_settings.go @@ -0,0 +1,50 @@ +package get + +import ( + "fmt" + "github.com/cirruslabs/orchard/pkg/client" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/gosuri/uitable" + "github.com/samber/lo" + "github.com/spf13/cobra" + "strings" +) + +func newGetClusterSettingsCommand() *cobra.Command { + command := &cobra.Command{ + Use: "cluster-settings", + Short: "Retrieve cluster settings", + RunE: runGetClusterSettings, + } + + return command +} + +func runGetClusterSettings(cmd *cobra.Command, args []string) error { + client, err := client.New() + if err != nil { + return err + } + + clusterSettings, err := client.ClusterSettings().Get(cmd.Context()) + if err != nil { + return err + } + + table := uitable.New() + + table.AddRow("Key", "Value") + + hostDirPoliciesAsStrings := lo.Map(clusterSettings.HostDirPolicies, func(policy v1.HostDirPolicy, _ int) string { + return policy.String() + }) + hostDirPoliciesDescription := strings.Join(hostDirPoliciesAsStrings, ",") + if hostDirPoliciesDescription == "" { + hostDirPoliciesDescription = "none" + } + table.AddRow("hostDir policies", hostDirPoliciesDescription) + + fmt.Println(table) + + return nil +} diff --git a/internal/command/get/get.go b/internal/command/get/get.go index e6e93a2..ad2dc2a 100644 --- a/internal/command/get/get.go +++ b/internal/command/get/get.go @@ -13,7 +13,11 @@ func NewCommand() *cobra.Command { Short: "Retrieve resources from the controller", } - command.AddCommand(newGetServiceAccountCommand(), newGetBootstrapTokenCommand()) + command.AddCommand( + newGetBootstrapTokenCommand(), + newGetClusterSettingsCommand(), + newGetServiceAccountCommand(), + ) return command } diff --git a/internal/command/root.go b/internal/command/root.go index d423a43..59ff820 100644 --- a/internal/command/root.go +++ b/internal/command/root.go @@ -12,6 +12,7 @@ import ( "github.com/cirruslabs/orchard/internal/command/pause" "github.com/cirruslabs/orchard/internal/command/portforward" "github.com/cirruslabs/orchard/internal/command/resume" + "github.com/cirruslabs/orchard/internal/command/set" "github.com/cirruslabs/orchard/internal/command/ssh" "github.com/cirruslabs/orchard/internal/command/vnc" "github.com/cirruslabs/orchard/internal/command/worker" @@ -29,15 +30,16 @@ func NewRootCmd() *cobra.Command { addGroupedCommands(command, "Working With Resources:", create.NewCommand(), + deletepkg.NewCommand(), get.NewCommand(), list.NewCommand(), logs.NewCommand(), pause.NewCommand(), portforward.NewCommand(), resume.NewCommand(), + set.NewCommand(), ssh.NewCommand(), vnc.NewCommand(), - deletepkg.NewCommand(), ) addGroupedCommands(command, "Administrative Tasks:", diff --git a/internal/command/set/cluster_settings.go b/internal/command/set/cluster_settings.go new file mode 100644 index 0000000..a521862 --- /dev/null +++ b/internal/command/set/cluster_settings.go @@ -0,0 +1,67 @@ +package set + +import ( + "errors" + "fmt" + "github.com/cirruslabs/orchard/pkg/client" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/spf13/cobra" +) + +var ErrClusterSettingsFailed = errors.New("failed to set cluster settings") + +var hostDirPoliciesRaw []string + +const hostDirPoliciesFlag = "host-dir-policies" + +func newSetClusterSettingsCommand() *cobra.Command { + command := &cobra.Command{ + Use: "cluster-settings", + Short: "Set cluster settings", + RunE: runSetClusterSettings, + } + + command.PersistentFlags().StringSliceVar(&hostDirPoliciesRaw, hostDirPoliciesFlag, []string{}, + fmt.Sprintf("comma-separated list of hostDir policies containing an allowed path prefix "+ + "and an optional \":ro\" modifier to only allow read-only mounts for that path prefix "+ + "(for example, --%s=/Users/ci/sources:ro,/tmp)", hostDirPoliciesFlag)) + + return command +} + +func runSetClusterSettings(cmd *cobra.Command, args []string) error { + // Convert arguments + var hostDirPolicies []v1.HostDirPolicy + + for _, hostDirPolicyRaw := range hostDirPoliciesRaw { + hostDirPolicy, err := v1.NewHostDirPolicyFromString(hostDirPolicyRaw) + if err != nil { + return err + } + + hostDirPolicies = append(hostDirPolicies, hostDirPolicy) + } + + // Check if we need to update anything in the cluster settings + if !cmd.Flag(hostDirPoliciesFlag).Changed { + return fmt.Errorf("%w: you need to specify at least one setting to update", + ErrClusterSettingsFailed) + } + + // Update cluster settings + client, err := client.New() + if err != nil { + return err + } + + clusterSettings, err := client.ClusterSettings().Get(cmd.Context()) + if err != nil { + return err + } + + if cmd.Flag(hostDirPoliciesFlag).Changed { + clusterSettings.HostDirPolicies = hostDirPolicies + } + + return client.ClusterSettings().Set(cmd.Context(), clusterSettings) +} diff --git a/internal/command/set/set.go b/internal/command/set/set.go new file mode 100644 index 0000000..7dde1b4 --- /dev/null +++ b/internal/command/set/set.go @@ -0,0 +1,16 @@ +package set + +import ( + "github.com/spf13/cobra" +) + +func NewCommand() *cobra.Command { + command := &cobra.Command{ + Use: "set", + Short: "Set resource properties on the controller", + } + + command.AddCommand(newSetClusterSettingsCommand()) + + return command +} diff --git a/internal/controller/api.go b/internal/controller/api.go index 03beeca..0aca62f 100644 --- a/internal/controller/api.go +++ b/internal/controller/api.go @@ -59,6 +59,14 @@ func (controller *Controller) initAPI() *gin.Engine { controller.controllerInfo(c).Respond(c) }) + // Cluster settings + v1.GET("/cluster-settings", func(c *gin.Context) { + controller.getClusterSettings(c).Respond(c) + }) + v1.PUT("/cluster-settings", func(c *gin.Context) { + controller.updateClusterSettings(c).Respond(c) + }) + // Service accounts v1.POST("/service-accounts", func(c *gin.Context) { controller.createServiceAccount(c).Respond(c) diff --git a/internal/controller/api_cluster_settings.go b/internal/controller/api_cluster_settings.go new file mode 100644 index 0000000..ec3fd30 --- /dev/null +++ b/internal/controller/api_cluster_settings.go @@ -0,0 +1,53 @@ +package controller + +import ( + storepkg "github.com/cirruslabs/orchard/internal/controller/store" + "github.com/cirruslabs/orchard/internal/responder" + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/gin-gonic/gin" + "net/http" +) + +func (controller *Controller) getClusterSettings(ctx *gin.Context) responder.Responder { + if responder := controller.authorize(ctx, v1.ServiceAccountRoleAdminRead); responder != nil { + return responder + } + + return controller.storeView(func(txn storepkg.Transaction) responder.Responder { + clusterSettings, err := txn.GetClusterSettings() + if err != nil { + controller.logger.Errorf("failed to retrieve cluster settings: %v", err) + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(http.StatusOK, clusterSettings) + }) +} + +func (controller *Controller) updateClusterSettings(ctx *gin.Context) responder.Responder { + if responder := controller.authorize(ctx, v1.ServiceAccountRoleAdminWrite); responder != nil { + return responder + } + + var clusterSettings v1.ClusterSettings + + if err := ctx.ShouldBindJSON(&clusterSettings); err != nil { + return responder.JSON(http.StatusBadRequest, NewErrorResponse("invalid JSON was provided")) + } + + // Validate + for _, allowedHostDir := range clusterSettings.HostDirPolicies { + if allowedHostDir.PathPrefix == "" { + return responder.JSON(http.StatusBadRequest, + NewErrorResponse("pathPrefix field cannot be empty")) + } + } + + return controller.storeUpdate(func(txn storepkg.Transaction) responder.Responder { + if err := txn.SetClusterSettings(clusterSettings); err != nil { + return responder.Code(http.StatusInternalServerError) + } + + return responder.JSON(http.StatusOK, &clusterSettings) + }) +} diff --git a/internal/controller/api_vms.go b/internal/controller/api_vms.go index b586998..2cf6cd6 100644 --- a/internal/controller/api_vms.go +++ b/internal/controller/api_vms.go @@ -7,6 +7,7 @@ import ( "github.com/cirruslabs/orchard/pkg/resource/v1" "github.com/gin-gonic/gin" "github.com/google/uuid" + "github.com/samber/lo" "net/http" "time" ) @@ -59,6 +60,11 @@ func (controller *Controller) createVM(ctx *gin.Context) responder.Responder { vm.RestartPolicy = v1.RestartPolicyNever } + // Validate hostDirs + if responder := controller.validateHostDirs(vm.HostDirs); responder != nil { + return responder + } + response := controller.storeUpdate(func(txn storepkg.Transaction) responder.Responder { // Does the VM resource with this name already exists? _, err := txn.GetVM(vm.Name) @@ -220,3 +226,43 @@ func (controller *Controller) listVMEvents(ctx *gin.Context) responder.Responder return responder.JSON(http.StatusOK, events) }) } + +func (controller *Controller) validateHostDirs(hostDirs []v1.HostDir) responder.Responder { + if len(hostDirs) == 0 { + return nil + } + + // Retrieve cluster settings + var clusterSettings *v1.ClusterSettings + var err error + + err = controller.store.View(func(txn storepkg.Transaction) error { + clusterSettings, err = txn.GetClusterSettings() + + return err + }) + if err != nil { + return responder.Code(http.StatusInternalServerError) + } + + for _, hostDir := range hostDirs { + if hostDir.Name == "" { + return responder.JSON(http.StatusBadRequest, + NewErrorResponse("hostDir volume's \"name\" field cannot be empty")) + } + + if hostDir.Path == "" { + return responder.JSON(http.StatusBadRequest, + NewErrorResponse("hostDir volume's \"path\" field cannot be empty")) + } + + if !lo.SomeBy(clusterSettings.HostDirPolicies, func(hostDirPolicy v1.HostDirPolicy) bool { + return hostDirPolicy.Validate(hostDir.Path, hostDir.ReadOnly) + }) { + return responder.JSON(http.StatusBadRequest, NewErrorResponse("host directory %q is disallowed "+ + "by policy, check your cluster settings", hostDir.String())) + } + } + + return nil +} diff --git a/internal/controller/controller.go b/internal/controller/controller.go index 235d171..f7f4cd6 100644 --- a/internal/controller/controller.go +++ b/internal/controller/controller.go @@ -113,6 +113,19 @@ func New(opts ...Option) (*Controller, error) { ReadHeaderTimeout: 60 * time.Second, } + // Ensure cluster settings object is present + if err := controller.store.Update(func(txn storepkg.Transaction) error { + _, err := txn.GetClusterSettings() + if errors.Is(err, storepkg.ErrNotFound) { + return txn.SetClusterSettings(v1.ClusterSettings{}) + } + + return err + }); err != nil { + return nil, fmt.Errorf("%w: failed to ensure cluster settings object is present: %v", + ErrInitFailed, err) + } + return controller, nil } diff --git a/internal/controller/store/badger/badger_cluster_settings.go b/internal/controller/store/badger/badger_cluster_settings.go new file mode 100644 index 0000000..9828725 --- /dev/null +++ b/internal/controller/store/badger/badger_cluster_settings.go @@ -0,0 +1,46 @@ +package badger + +import ( + "encoding/json" + "github.com/cirruslabs/orchard/pkg/resource/v1" +) + +var ClusterSettingsKey = []byte("/cluster-settings") + +func (txn *Transaction) GetClusterSettings() (result *v1.ClusterSettings, err error) { + defer func() { + err = mapErr(err) + }() + + item, err := txn.badgerTxn.Get(ClusterSettingsKey) + if err != nil { + return nil, err + } + + valueBytes, err := item.ValueCopy(nil) + if err != nil { + return nil, err + } + + var clusterSettings v1.ClusterSettings + + err = json.Unmarshal(valueBytes, &clusterSettings) + if err != nil { + return nil, err + } + + return &clusterSettings, nil +} + +func (txn *Transaction) SetClusterSettings(clusterSettings v1.ClusterSettings) (err error) { + defer func() { + err = mapErr(err) + }() + + valueBytes, err := json.Marshal(clusterSettings) + if err != nil { + return err + } + + return txn.badgerTxn.Set(ClusterSettingsKey, valueBytes) +} diff --git a/internal/controller/store/store.go b/internal/controller/store/store.go index 2c7a385..ac04d33 100644 --- a/internal/controller/store/store.go +++ b/internal/controller/store/store.go @@ -26,4 +26,7 @@ type Transaction interface { AppendEvents(event []v1.Event, scope ...string) (err error) ListEvents(scope ...string) (result []v1.Event, err error) DeleteEvents(scope ...string) (err error) + + GetClusterSettings() (*v1.ClusterSettings, error) + SetClusterSettings(clusterSettings v1.ClusterSettings) error } diff --git a/internal/tests/integration_test.go b/internal/tests/integration_test.go index 6d8b955..982c377 100644 --- a/internal/tests/integration_test.go +++ b/internal/tests/integration_test.go @@ -3,6 +3,7 @@ package tests_test import ( "context" "errors" + "fmt" "github.com/cirruslabs/orchard/internal/command/dev" "github.com/cirruslabs/orchard/internal/controller" "github.com/cirruslabs/orchard/internal/worker" @@ -18,6 +19,8 @@ import ( "golang.org/x/exp/slices" "net" "net/http" + "os" + "path/filepath" "strconv" "strings" "testing" @@ -422,6 +425,112 @@ func TestVMGarbageCollection(t *testing.T) { }), "failed to wait for the VM %s to be garbage-collected", vmName) } +func TestHostDirs(t *testing.T) { + devClient := StartIntegrationTestEnvironment(t) + + dirToMount := t.TempDir() + + vmName := "test-host-dirs-" + uuid.NewString() + + err := devClient.ClusterSettings().Set(context.Background(), &v1.ClusterSettings{ + HostDirPolicies: []v1.HostDirPolicy{{PathPrefix: dirToMount}}, + }) + require.NoError(t, err) + + scriptContent, err := os.ReadFile(filepath.Join("testdata", "host-dirs.sh")) + require.NoError(t, err) + + err = devClient.VMs().Create(context.Background(), &v1.VM{ + Meta: v1.Meta{ + Name: vmName, + }, + Image: "ghcr.io/cirruslabs/macos-ventura-base:latest", + CPU: 4, + Memory: 8 * 1024, + Headless: true, + Status: v1.VMStatusPending, + StartupScript: &v1.VMScript{ + ScriptContent: string(scriptContent), + }, + HostDirs: []v1.HostDir{ + {Name: "readwrite", Path: dirToMount}, + {Name: "readonly", Path: dirToMount, ReadOnly: true}, + }, + }) + require.NoError(t, err) + + var vm *v1.VM + + require.True(t, Wait(2*time.Minute, func() bool { + vm, err = devClient.VMs().Get(context.Background(), vmName) + require.NoError(t, err) + + t.Logf("Waiting for the VM to start. Current status: %s", vm.Status) + + return vm.Status == v1.VMStatusRunning || vm.Status == v1.VMStatusFailed + }), "failed to start a VM") + + require.Empty(t, vm.StatusMessage) + require.Equal(t, v1.VMStatusRunning, vm.Status) + + var logLines []string + + require.True(t, Wait(2*time.Minute, func() bool { + logLines, err = devClient.VMs().Logs(context.Background(), vmName) + require.NoError(t, err) + + return len(logLines) > 0 + }), "failed to wait for logs to become available") + + fmt.Println(logLines) + + require.EqualValues(t, []string{ + "Read-write mount exists", + "Read-only mount exists", + "Failed to create a file in read-only mount", + "Successfully created a file in read-write mount", + }, logLines) + require.FileExists(t, filepath.Join(dirToMount, "test-rw.txt")) + require.NoFileExists(t, filepath.Join(dirToMount, "test-ro.txt")) +} + +func TestHostDirsInvalidPolicy(t *testing.T) { + devClient := StartIntegrationTestEnvironment(t) + + dirToMount := t.TempDir() + + vmName := "test-host-dirs-" + uuid.NewString() + + // Create a VM without creating any directory policies + // and make sure we get an error + vmSpec := &v1.VM{ + Meta: v1.Meta{ + Name: vmName, + }, + Image: "ghcr.io/cirruslabs/macos-ventura-base:latest", + CPU: 4, + Memory: 8 * 1024, + Headless: true, + Status: v1.VMStatusPending, + HostDirs: []v1.HostDir{ + {Name: "test" + uuid.NewString(), Path: dirToMount}, + }, + } + + err := devClient.VMs().Create(context.Background(), vmSpec) + require.Error(t, err) + + // Create a policy for our directory, but do not allow for writing + err = devClient.ClusterSettings().Set(context.Background(), &v1.ClusterSettings{ + HostDirPolicies: []v1.HostDirPolicy{{PathPrefix: dirToMount, ReadOnly: true}}, + }) + require.NoError(t, err) + + // Make sure we get error with the same spec + err = devClient.VMs().Create(context.Background(), vmSpec) + require.Error(t, err) +} + func hasVM(t *testing.T, name string, logger *zap.Logger) bool { return hasVMByPredicate(t, func(vmInfo tart.VMInfo) bool { return vmInfo.Name == name diff --git a/internal/tests/testdata/host-dirs.sh b/internal/tests/testdata/host-dirs.sh new file mode 100644 index 0000000..c6cc166 --- /dev/null +++ b/internal/tests/testdata/host-dirs.sh @@ -0,0 +1,22 @@ +#!/bin/sh + +if test -d "/Volumes/My Shared Files/readwrite" +then + echo "Read-write mount exists" +else + echo "Read-write mount not found" + + exit 1 +fi + +if test -d "/Volumes/My Shared Files/readonly" +then + echo "Read-only mount exists" +else + echo "Read-only mount not found" + + exit 1 +fi + +touch "/Volumes/My Shared Files/readonly/test-ro.txt" 2>/dev/null || echo "Failed to create a file in read-only mount" +touch "/Volumes/My Shared Files/readwrite/test-rw.txt" && echo "Successfully created a file in read-write mount" diff --git a/internal/worker/vmmanager/vm.go b/internal/worker/vmmanager/vm.go index f8d7ccc..94dd99d 100644 --- a/internal/worker/vmmanager/vm.go +++ b/internal/worker/vmmanager/vm.go @@ -147,6 +147,10 @@ func (vm *VM) run(ctx context.Context) error { runArgs = append(runArgs, "--no-graphics") } + for _, hostDir := range vm.Resource.HostDirs { + runArgs = append(runArgs, fmt.Sprintf("--dir=%s", hostDir.String())) + } + runArgs = append(runArgs, vm.id()) _, _, err := tart.Tart(ctx, vm.logger, runArgs...) if err != nil { diff --git a/pkg/client/client.go b/pkg/client/client.go index f338940..62bb89c 100644 --- a/pkg/client/client.go +++ b/pkg/client/client.go @@ -335,3 +335,9 @@ func (client *Client) Controller() *ControllerService { client: client, } } + +func (client *Client) ClusterSettings() *ClusterSettingsService { + return &ClusterSettingsService{ + client: client, + } +} diff --git a/pkg/client/cluster_settings.go b/pkg/client/cluster_settings.go new file mode 100644 index 0000000..be25ec9 --- /dev/null +++ b/pkg/client/cluster_settings.go @@ -0,0 +1,26 @@ +package client + +import ( + "context" + "github.com/cirruslabs/orchard/pkg/resource/v1" + "net/http" +) + +type ClusterSettingsService struct { + client *Client +} + +func (service *ClusterSettingsService) Get(ctx context.Context) (*v1.ClusterSettings, error) { + var clusterSettings v1.ClusterSettings + + err := service.client.request(ctx, http.MethodGet, "cluster-settings", nil, &clusterSettings, nil) + if err != nil { + return nil, err + } + + return &clusterSettings, nil +} + +func (service *ClusterSettingsService) Set(ctx context.Context, clusterSettings *v1.ClusterSettings) error { + return service.client.request(ctx, http.MethodPut, "cluster-settings", clusterSettings, nil, nil) +} diff --git a/pkg/resource/v1/cluster_settings.go b/pkg/resource/v1/cluster_settings.go new file mode 100644 index 0000000..3fd8570 --- /dev/null +++ b/pkg/resource/v1/cluster_settings.go @@ -0,0 +1,5 @@ +package v1 + +type ClusterSettings struct { + HostDirPolicies []HostDirPolicy `json:"hostDirPolicies"` +} diff --git a/pkg/resource/v1/host_dir.go b/pkg/resource/v1/host_dir.go new file mode 100644 index 0000000..8ffdea9 --- /dev/null +++ b/pkg/resource/v1/host_dir.go @@ -0,0 +1,58 @@ +package v1 + +import ( + "errors" + "fmt" + "strings" +) + +var ErrInvalidHostDir = errors.New("invalid hostDir specification") + +type HostDir struct { + Name string `json:"name"` + Path string `json:"path"` + ReadOnly bool `json:"ro"` +} + +func NewHostDirFromString(s string) (HostDir, error) { + parts := strings.Split(s, ":") + + if len(parts) > 3 { + return HostDir{}, fmt.Errorf("%w: hostDir specification can only contain 3 parts at max", + ErrInvalidHostDir) + } + + if parts[0] == "" { + return HostDir{}, fmt.Errorf("%w: name cannot be empty", ErrInvalidHostDir) + } + if parts[1] == "" { + return HostDir{}, fmt.Errorf("%w: path cannot be empty", ErrInvalidHostDir) + } + + var readOnly bool + + if len(parts) == 3 { + if parts[2] == "ro" { + readOnly = true + } else { + return HostDir{}, fmt.Errorf("%w: hostDir's third part can only be \"ro\", got %q", + ErrInvalidHostDir, parts[2]) + } + } + + return HostDir{ + Name: parts[0], + Path: parts[1], + ReadOnly: readOnly, + }, nil +} + +func (hostDir HostDir) String() string { + var roPart string + + if hostDir.ReadOnly { + roPart = ":ro" + } + + return fmt.Sprintf("%s:%s%s", hostDir.Name, hostDir.Path, roPart) +} diff --git a/pkg/resource/v1/host_dir_policy.go b/pkg/resource/v1/host_dir_policy.go new file mode 100644 index 0000000..502d210 --- /dev/null +++ b/pkg/resource/v1/host_dir_policy.go @@ -0,0 +1,68 @@ +package v1 + +import ( + "errors" + "fmt" + "strings" +) + +var ErrInvalidHostDirPolicy = errors.New("invalid hostDir policy") + +type HostDirPolicy struct { + PathPrefix string `json:"pathPrefix"` + ReadOnly bool `json:"ro"` +} + +func NewHostDirPolicyFromString(s string) (HostDirPolicy, error) { + parts := strings.Split(s, ":") + + if len(parts) > 2 { + return HostDirPolicy{}, fmt.Errorf("%w: hostDir policy should contain 2 parts at max, found %d", + ErrInvalidHostDirPolicy, len(parts)) + } + + if parts[0] == "" { + return HostDirPolicy{}, fmt.Errorf("%w: path prefix cannot be empty", ErrInvalidHostDirPolicy) + } + + var readOnly bool + + if len(parts) == 2 { + if parts[1] == "ro" { + readOnly = true + } else { + return HostDirPolicy{}, fmt.Errorf("%w: hostDir policy's second part can only be \"ro\", found %q", + ErrInvalidHostDirPolicy, parts[1]) + } + } + + return HostDirPolicy{ + PathPrefix: parts[0], + ReadOnly: readOnly, + }, nil +} + +func (policy HostDirPolicy) Validate(path string, readOnly bool) bool { + if strings.Contains(path, "..") { + return false + } + + if policy.ReadOnly && !readOnly { + return false + } + + return strings.HasPrefix( + strings.TrimSuffix(path, "/"), + strings.TrimSuffix(policy.PathPrefix, "/"), + ) +} + +func (policy HostDirPolicy) String() string { + var roPart string + + if policy.ReadOnly { + roPart = ":ro" + } + + return fmt.Sprintf("%s%s", policy.PathPrefix, roPart) +} diff --git a/pkg/resource/v1/host_dir_policy_test.go b/pkg/resource/v1/host_dir_policy_test.go new file mode 100644 index 0000000..49b7cc0 --- /dev/null +++ b/pkg/resource/v1/host_dir_policy_test.go @@ -0,0 +1,63 @@ +package v1_test + +import ( + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/stretchr/testify/require" + "testing" +) + +func TestNewHostDirPolicyFromString(t *testing.T) { + policy, err := v1.NewHostDirPolicyFromString("/Users/ci/src:ro") + require.NoError(t, err) + require.EqualValues(t, v1.HostDirPolicy{ + PathPrefix: "/Users/ci/src", + ReadOnly: true, + }, policy) + + _, err = v1.NewHostDirPolicyFromString("/Users/ci/src:ro:something") + require.Error(t, err) + + _, err = v1.NewHostDirPolicyFromString("/Users/ci/src:rw") + require.Error(t, err) +} + +func TestHostDirPolicyValidate(t *testing.T) { + policy := &v1.HostDirPolicy{PathPrefix: "/Users/ci/src"} + + // Valid uses + require.True(t, policy.Validate("/Users/ci/src", true)) + require.True(t, policy.Validate("/Users/ci/src/", true)) + require.True(t, policy.Validate("/Users/ci/src/website", true)) + + // Invalid uses + require.False(t, policy.Validate("/Users/ci/", true)) + require.False(t, policy.Validate("/Users", true)) + require.False(t, policy.Validate("/tmp", true)) + require.False(t, policy.Validate("/", true)) + + // No path traversal, even within the path prefix + require.False(t, policy.Validate("/Users/ci/src/website/../../../../../../etc/passwd", true)) + require.False(t, policy.Validate("/Users/ci/src/website/..", true)) + require.False(t, policy.Validate("/Users/ci/src/..", true)) + require.False(t, policy.Validate("/Users/ci/..", true)) + require.False(t, policy.Validate("/Users/..", true)) + require.False(t, policy.Validate("/..", true)) +} + +func TestHostDirPolicyValidateReadOnly(t *testing.T) { + policy := &v1.HostDirPolicy{PathPrefix: "/Users/ci/src", ReadOnly: true} + + const desiredPath = "/Users/ci/src/website" + + // Only read-only is allowed + require.True(t, policy.Validate(desiredPath, true)) + require.False(t, policy.Validate(desiredPath, false)) +} + +func TestHostDirPolicyString(t *testing.T) { + policyRw := &v1.HostDirPolicy{PathPrefix: "/Users/ci/src"} + require.EqualValues(t, "/Users/ci/src", policyRw.String()) + + policyRo := &v1.HostDirPolicy{PathPrefix: "/Users/ci/src", ReadOnly: true} + require.EqualValues(t, "/Users/ci/src:ro", policyRo.String()) +} diff --git a/pkg/resource/v1/host_dir_test.go b/pkg/resource/v1/host_dir_test.go new file mode 100644 index 0000000..53a6e70 --- /dev/null +++ b/pkg/resource/v1/host_dir_test.go @@ -0,0 +1,25 @@ +package v1_test + +import ( + v1 "github.com/cirruslabs/orchard/pkg/resource/v1" + "github.com/stretchr/testify/require" + "testing" +) + +func TestNewHostDirFromString(t *testing.T) { + hostDir, err := v1.NewHostDirFromString("large-project:/Users/ci/src/www:ro") + require.NoError(t, err) + require.EqualValues(t, v1.HostDir{ + Name: "large-project", + Path: "/Users/ci/src/www", + ReadOnly: true, + }, hostDir) +} + +func TestHostDirString(t *testing.T) { + require.EqualValues(t, "large-project:/Users/ci/src/www:ro", v1.HostDir{ + Name: "large-project", + Path: "/Users/ci/src/www", + ReadOnly: true, + }.String()) +} diff --git a/pkg/resource/v1/v1.go b/pkg/resource/v1/v1.go index f9ac759..115b918 100644 --- a/pkg/resource/v1/v1.go +++ b/pkg/resource/v1/v1.go @@ -49,6 +49,9 @@ type VM struct { // Resources required by this VM. Resources Resources `json:"resources"` + // HostDir is a list of host directories to be mounted to the VM. + HostDirs []HostDir `json:"hostDirs"` + Meta }