mirror of
https://github.com/cirruslabs/orchard.git
synced 2026-10-17 20:25:50 +02:00
Support for sharing files with the host system (#103)
* Support for sharing files with the host system * Integration tests * Added back TestVMGarbageCollection comment
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Vendored
+22
@@ -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"
|
||||
@@ -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 {
|
||||
|
||||
@@ -335,3 +335,9 @@ func (client *Client) Controller() *ControllerService {
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (client *Client) ClusterSettings() *ClusterSettingsService {
|
||||
return &ClusterSettingsService{
|
||||
client: client,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
package v1
|
||||
|
||||
type ClusterSettings struct {
|
||||
HostDirPolicies []HostDirPolicy `json:"hostDirPolicies"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user