diff --git a/.golangci.yaml b/.golangci.yaml index ad21c62..2c390e0 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -6,7 +6,6 @@ linters: - bidichk - bodyclose - containedctx - - contextcheck - decorder - dogsled - dupl diff --git a/Makefile b/Makefile index 4a90bad..4af8a13 100644 --- a/Makefile +++ b/Makefile @@ -2,6 +2,8 @@ include hack/base.mk TARGET_BIN ?= build/bin/qubesome +PROTO = pkg/inception/proto + GO_TAGS = -tags 'netgo,osusergo,static_build' LDFLAGS = -ldflags '-extldflags -static -s -w -X \ github.com/qubesome/cli/cmd/cli.version=$(VERSION)' @@ -18,7 +20,13 @@ build: ## build qubesome to the path set by TARGET_BIN. test: ## run golang tests. go test -race -parallel 10 ./... -verify: verify-lint verify-dirty ## Run verification checks. +verify: generate verify-lint verify-dirty ## Run verification checks. verify-lint: $(GOLANGCI) $(GOLANGCI) run + +generate: $(PROTOC) + rm $(PROTO)/*.pb.go || true + PATH=$(TOOLS_BIN) $(PROTOC) --go_out=. --go_opt=paths=source_relative \ + --go-grpc_out=. --go-grpc_opt=paths=source_relative \ + $(PROTO)/host.proto diff --git a/README.md b/README.md index 32496ab..6199884 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ qubesome clip to-host i3 - `qubesome start`: Start a qubesome environment for a given profile. - `qubesome run`: Run qubesome workloads. +- `qubesome host-run`: Run commands on the host but display them in a qubesome profile. - `qubesome clip`: Manage the images within your workloads. - `qubesome images`: Manage the images within your workloads. - `qubesome xdg`: Handle xdg-open based via qubesome. diff --git a/cmd/cli/clipboard.go b/cmd/cli/clipboard.go index 40c88c1..a602f1c 100644 --- a/cmd/cli/clipboard.go +++ b/cmd/cli/clipboard.go @@ -30,10 +30,17 @@ func clipboardCommand() *cli.Command { { Name: "from-host", Usage: "copies the clipboard contents from the host to a profile", + Description: `Examples: + +qubesome clip from-host - Copy clipboard contents from host to the active profile +qubesome clip from-host -type image/png - Copy image from host clipboard to the active profile +qubesome clip from-host -profile - Copy clipboard contents from host to a specific profile +`, Arguments: []cli.Argument{ &cli.StringArg{ Name: "target_profile", - Min: 1, + UsageText: "Required when multiple profiles are active", + Min: 0, Max: 1, Destination: &targetProfile, }, @@ -42,11 +49,9 @@ func clipboardCommand() *cli.Command { clipType, }, Action: func(ctx context.Context, c *cli.Command) error { - cfg := profileConfigOrDefault(targetProfile) - - target, ok := cfg.Profiles[targetProfile] - if !ok { - return fmt.Errorf("no active profile %q found", targetProfile) + target, err := profileOrActive(targetProfile) + if err != nil { + return err } opts := []command.Option[clipboard.Options]{ @@ -55,7 +60,6 @@ func clipboardCommand() *cli.Command { } if typ := c.String("type"); typ != "" { - fmt.Println(typ) opts = append(opts, clipboard.WithContentType(typ)) } @@ -87,12 +91,12 @@ func clipboardCommand() *cli.Command { Action: func(ctx context.Context, c *cli.Command) error { cfg := profileConfigOrDefault(targetProfile) - source, ok := cfg.Profiles[sourceProfile] + source, ok := cfg.Profile(sourceProfile) if !ok { return fmt.Errorf("no active profile %q found", sourceProfile) } - target, ok := cfg.Profiles[targetProfile] + target, ok := cfg.Profile(targetProfile) if !ok { return fmt.Errorf("no active profile %q found", targetProfile) } @@ -103,7 +107,6 @@ func clipboardCommand() *cli.Command { } if typ := c.String("type"); typ != "" { - fmt.Println(typ) opts = append(opts, clipboard.WithContentType(typ)) } @@ -115,10 +118,17 @@ func clipboardCommand() *cli.Command { { Name: "to-host", Usage: "copies the clipboard contents from a profile to the host", + Description: `Examples: + +qubesome clip to-host - Copy clipboard contents from the active profile to the host +qubesome clip to-host -type image/png - Copy image from the active profile clipboard to the host +qubesome clip to-host -profile - Copy clipboard contents from a specific profile to the host + `, Arguments: []cli.Argument{ &cli.StringArg{ Name: "source_profile", - Min: 1, + UsageText: "Required when multiple profiles are active", + Min: 0, Max: 1, Destination: &sourceProfile, }, @@ -127,11 +137,9 @@ func clipboardCommand() *cli.Command { clipType, }, Action: func(ctx context.Context, c *cli.Command) error { - cfg := profileConfigOrDefault(sourceProfile) - - target, ok := cfg.Profiles[sourceProfile] - if !ok { - return fmt.Errorf("no active profile %q found", sourceProfile) + target, err := profileOrActive(sourceProfile) + if err != nil { + return err } opts := []command.Option[clipboard.Options]{ diff --git a/cmd/cli/host_run.go b/cmd/cli/host_run.go new file mode 100644 index 0000000..8b8b1c0 --- /dev/null +++ b/cmd/cli/host_run.go @@ -0,0 +1,51 @@ +package cli + +import ( + "context" + "fmt" + "os/exec" + + "github.com/urfave/cli/v3" +) + +func hostRunCommand() *cli.Command { + cmd := &cli.Command{ + Name: "host-run", + Aliases: []string{"hr"}, + Usage: "Runs a command at the host, but shows it in a given qubesome profile", + Description: `Examples: + +qubesome host-run firefox - Run firefox on the host and display it on the active profile +qubesome host-run -profile firefox - Run firefox on the host and display it on a specific profile +`, + Arguments: []cli.Argument{ + &cli.StringArg{ + Name: "command", + Min: 1, + Max: 1, + Destination: &commandName, + }, + }, + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "profile", + Usage: "Required when multiple profiles are active", + Destination: &targetProfile, + }, + }, + Action: func(ctx context.Context, cmd *cli.Command) error { + prof, err := profileOrActive(targetProfile) + if err != nil { + return err + } + + c := exec.Command(commandName) + c.Env = append(c.Env, fmt.Sprintf("DISPLAY=:%d", prof.Display)) + out, err := c.CombinedOutput() + fmt.Println(out) + + return err + }, + } + return cmd +} diff --git a/cmd/cli/images.go b/cmd/cli/images.go index a20e938..6af08d2 100644 --- a/cmd/cli/images.go +++ b/cmd/cli/images.go @@ -2,6 +2,8 @@ package cli import ( "context" + "errors" + "fmt" "github.com/qubesome/cli/internal/images" "github.com/urfave/cli/v3" @@ -27,6 +29,15 @@ func imagesCommand() *cli.Command { }, Action: func(ctx context.Context, cmd *cli.Command) error { cfg := profileConfigOrDefault(targetProfile) + if cfg == nil { + return errors.New("could not find qubesome config") + } + + if targetProfile != "" { + if _, ok := cfg.Profile(targetProfile); !ok { + return fmt.Errorf("could not find profile %q", targetProfile) + } + } return images.Run( images.WithConfig(cfg), diff --git a/cmd/cli/root.go b/cmd/cli/root.go index 488e4bb..283bba4 100644 --- a/cmd/cli/root.go +++ b/cmd/cli/root.go @@ -2,8 +2,12 @@ package cli import ( "context" + "errors" + "fmt" + "log/slog" "os" "path/filepath" + "strings" "github.com/qubesome/cli/internal/files" "github.com/qubesome/cli/internal/log" @@ -19,6 +23,7 @@ var ( path string local string runner string + commandName string debug bool ) @@ -33,9 +38,17 @@ func RootCommand() *cli.Command { depsCommand(), versionCommand(), completionCommand(), + hostRunCommand(), }, } + cmd.Before = func(ctx context.Context, c *cli.Command) (context.Context, error) { + if strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") { + fmt.Println("\033[33mWARN: Running qubesome in Wayland is experimental. Some features may not work as expected.\033[0m") + } + return ctx, nil + } + cmd.Flags = append(cmd.Flags, &cli.BoolFlag{ Name: "debug", Value: false, @@ -70,18 +83,76 @@ func config(path string) *types.Config { } func profileConfigOrDefault(profile string) *types.Config { - path := files.ProfileConfig(profile) - target, err := os.Readlink(path) + if profile != "" { + // Try to load the profile specific config. + path := files.ProfileConfig(profile) + target, err := os.Readlink(path) - var c *types.Config - if err == nil { - c = config(target) + if err == nil { + c := config(target) + slog.Debug("using profile config", "path", path, "config", c) + if c != nil { + return c + } + } } - if c != nil { - return c + cfgs := activeConfigs() + if len(cfgs) == 1 { + c := config(cfgs[0]) + slog.Debug("using active profile config", "path", cfgs[0], "config", c) + if c != nil && len(c.Profiles) > 0 { + return c + } } + // Try to load user-level qubesome config. path = files.QubesomeConfig() - return config(path) + c := config(path) + slog.Debug("using user-level config", "path", path, "config", c) + if c != nil && len(c.Profiles) > 0 { + return c + } + + return nil +} + +func profileOrActive(profile string) (*types.Profile, error) { + if profile != "" { + cfg := profileConfigOrDefault(profile) + prof, ok := cfg.Profile(profile) + if !ok { + return nil, fmt.Errorf("profile %q not active", profile) + } + return prof, nil + } + + cfgs := activeConfigs() + if len(cfgs) > 1 { + return nil, errors.New("multiple profiles active: pick one with -profile") + } + if len(cfgs) == 0 { + return nil, errors.New("no active profile found: start one with qubesome start") + } + + f := cfgs[0] + name := strings.TrimSuffix(filepath.Base(f), filepath.Ext(f)) + return profileOrActive(name) +} + +func activeConfigs() []string { + var active []string + + root := files.RunUserQubesome() + entries, err := os.ReadDir(root) + if err == nil { + for _, entry := range entries { + fn := entry.Name() + if filepath.Ext(fn) == ".config" { + active = append(active, filepath.Join(root, fn)) + } + } + } + + return active } diff --git a/cmd/cli/run.go b/cmd/cli/run.go index 1961ef8..17ae559 100644 --- a/cmd/cli/run.go +++ b/cmd/cli/run.go @@ -11,6 +11,12 @@ func runCommand() *cli.Command { cmd := &cli.Command{ Name: "run", Aliases: []string{"r"}, + Usage: "execute workloads", + Description: `Examples: + +qubesome run chrome - Run the chrome workload on the active profile +qubesome run -profile chrome - Run the chrome workload on a specific profile +`, Arguments: []cli.Argument{ &cli.StringArg{ Name: "workload", @@ -29,13 +35,17 @@ func runCommand() *cli.Command { Destination: &runner, }, }, - Usage: "execute workloads", Action: func(ctx context.Context, cmd *cli.Command) error { - cfg := profileConfigOrDefault(targetProfile) + prof, err := profileOrActive(targetProfile) + if err != nil { + return err + } + + cfg := profileConfigOrDefault(prof.Name) return qubesome.Run( qubesome.WithWorkload(workload), - qubesome.WithProfile(targetProfile), + qubesome.WithProfile(prof.Name), qubesome.WithConfig(cfg), qubesome.WithRunner(runner), qubesome.WithExtraArgs(cmd.Args().Slice()), diff --git a/cmd/cli/start.go b/cmd/cli/start.go index c81b7b8..472fbe9 100644 --- a/cmd/cli/start.go +++ b/cmd/cli/start.go @@ -11,6 +11,12 @@ func startCommand() *cli.Command { cmd := &cli.Command{ Name: "start", Aliases: []string{"s"}, + Usage: "start qubesome profiles", + Description: `Examples: + +qubesome start -git https://github.com/qubesome/sample-dotfiles awesome +qubesome start -git https://github.com/qubesome/sample-dotfiles i3 +`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "git", @@ -40,7 +46,6 @@ func startCommand() *cli.Command { Destination: &targetProfile, }, }, - Usage: "start qubesome profiles", Action: func(ctx context.Context, cmd *cli.Command) error { cfg := profileConfigOrDefault(targetProfile) diff --git a/cmd/cli/xdg.go b/cmd/cli/xdg.go index 006c79b..f630f35 100644 --- a/cmd/cli/xdg.go +++ b/cmd/cli/xdg.go @@ -12,6 +12,11 @@ func xdgCommand() *cli.Command { Name: "xdg-open", Aliases: []string{"xdg"}, Usage: "opens a file or URL in the user's configured workload", + Description: `Examples: + +qubesome xdg-open https://github.com/qubesome - Opens the URL on the workload defined on the active qubesome config +qubesome xdg-open -profile https://github.com/qubesome - Opens the URL on the workload defined on the given profile's qubesome config +`, Flags: []cli.Flag{ &cli.StringFlag{ Name: "profile", @@ -23,7 +28,12 @@ func xdgCommand() *cli.Command { }, }, Action: func(ctx context.Context, cmd *cli.Command) error { - cfg := profileConfigOrDefault(targetProfile) + prof, err := profileOrActive(targetProfile) + if err != nil { + return err + } + + cfg := profileConfigOrDefault(prof.Name) return qubesome.XdgRun( qubesome.WithConfig(cfg), diff --git a/go.mod b/go.mod index 83c436f..5539041 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,8 @@ require ( github.com/stretchr/testify v1.9.0 github.com/urfave/cli/v3 v3.0.0-alpha9.3 golang.org/x/sys v0.27.0 + google.golang.org/grpc v1.68.0 + google.golang.org/protobuf v1.34.2 gopkg.in/yaml.v3 v3.0.1 ) @@ -32,5 +34,7 @@ require ( github.com/xanzy/ssh-agent v0.3.3 // indirect golang.org/x/crypto v0.29.0 // indirect golang.org/x/net v0.31.0 // indirect + golang.org/x/text v0.20.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect ) diff --git a/go.sum b/go.sum index 901a8ae..3709fca 100644 --- a/go.sum +++ b/go.sum @@ -32,6 +32,8 @@ github.com/go-git/go-git/v5 v5.12.1-0.20241115094014-70dd9f8347eb h1:TEo1aHmTS/Q github.com/go-git/go-git/v5 v5.12.1-0.20241115094014-70dd9f8347eb/go.mod h1:KECzDiPamjQz6lBAKQI+cIhdDfUcb64jyErZarVFKIE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -96,6 +98,12 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0= +google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/hack/base.mk b/hack/base.mk index 0150fac..2632860 100644 --- a/hack/base.mk +++ b/hack/base.mk @@ -1,4 +1,5 @@ GOLANGCI_VERSION ?= v1.62.0 +PROTOC_VERSION ?= 28.3 TOOLS_BIN := $(shell mkdir -p build/tools && realpath build/tools) ifneq ($(shell git status --porcelain --untracked-files=no),) @@ -12,6 +13,17 @@ $(GOLANGCI): curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/$(GOLANGCI_VERSION)/install.sh | sh -s -- -b $(TOOLS_BIN) $(GOLANGCI_VERSION) mv $(TOOLS_BIN)/golangci-lint $(TOOLS_BIN)/golangci-lint-$(GOLANGCI_VERSION) + +PROTOC = $(TOOLS_BIN)/protoc +$(PROTOC): + curl -fsSL https://github.com/protocolbuffers/protobuf/releases/download/v$(PROTOC_VERSION)/protoc-$(PROTOC_VERSION)-linux-x86_64.zip \ + -o $(TOOLS_BIN)/protoc.zip + unzip -j $(TOOLS_BIN)/protoc.zip -d $(TOOLS_BIN) "bin/protoc" + rm $(TOOLS_BIN)/protoc.zip + + $(call go-install-tool,protoc-gen-go,google.golang.org/protobuf/cmd/protoc-gen-go@latest) + $(call go-install-tool,protoc-gen-go-grpc,google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest) + # go-install-tool will 'go install' any package $2 and install it as $1. define go-install-tool @[ -f $(1) ] || { \ diff --git a/internal/inception/client.go b/internal/inception/client.go new file mode 100644 index 0000000..47a78d0 --- /dev/null +++ b/internal/inception/client.go @@ -0,0 +1,68 @@ +package inception + +import ( + "context" + "fmt" + "log/slog" + "strings" + "time" + + pb "github.com/qubesome/cli/pkg/inception/proto" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +func NewClient(socket string) *Client { + return &Client{ + socket: "unix://" + socket, + } +} + +type Client struct { + socket string +} + +func (c *Client) XdgOpen(ctx context.Context, url string) error { + conn, err := grpc.NewClient(c.socket, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("failed to connect to qubesome host: %w", err) + } + defer conn.Close() + + cl := pb.NewQubesomeHostClient(conn) + + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + slog.Debug("[client] calling XdgOpen", "url", url) + _, err = cl.XdgOpen(ctx, &pb.XdgOpenRequest{Url: url}) + if err != nil { + return err + } + + return nil +} + +func (c *Client) Run(ctx context.Context, workload string, args []string) error { + conn, err := grpc.NewClient(c.socket, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return fmt.Errorf("failed to connect to qubesome host: %w", err) + } + defer conn.Close() + + cl := pb.NewQubesomeHostClient(conn) + + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + + slog.Debug("[client] calling RunWorkload", "workload", workload, "args", args) + _, err = cl.RunWorkload(ctx, &pb.RunWorkloadRequest{ + Workload: workload, + Args: strings.Join(args, " "), + }) + if err != nil { + return err + } + + return nil +} diff --git a/internal/inception/inception.go b/internal/inception/inception.go index 51cfe8e..156f81f 100644 --- a/internal/inception/inception.go +++ b/internal/inception/inception.go @@ -1,27 +1,9 @@ package inception import ( - "fmt" - "log/slog" - "net" "os" - "strings" "github.com/qubesome/cli/internal/files" - "github.com/qubesome/cli/internal/types" - "github.com/qubesome/cli/internal/util/dbus" -) - -var ( - commands = map[string]func(*types.Config, *types.Profile, []string) error{} -) - -func Add(cmd string, f func(*types.Config, *types.Profile, []string) error) { - commands[cmd] = f -} - -const ( - bufferSize = 1024 ) func Inside() bool { @@ -29,71 +11,3 @@ func Inside() bool { _, err := os.Stat(path) return (err == nil) } - -func RunOnHost(cmd string, args []string) error { - slog.Debug("check whether running inside container") - if !Inside() { - return fmt.Errorf("cannot run against host: socket not found") - } - - path := files.InProfileSocketPath() - fmt.Println("dialing host qubesome", "socket", path) - c, err := net.Dial("unix", path) - if err != nil { - return err - } - - a := append([]string{cmd}, args...) - command := strings.Join(a, " ") - - fmt.Println("host qubesome run", "command", command) - _, err = c.Write([]byte(command)) - if err != nil { - return err - } - err = c.Close() - if err != nil { - return fmt.Errorf("failed to close socket: %w", err) - } - - return nil -} - -func HandleConnection(cfg *types.Config, p *types.Profile, conn net.Conn) { - defer conn.Close() - // Create a buffer for incoming data. - buf := make([]byte, bufferSize) - - // Read data from the connection. - n, err := conn.Read(buf) - if err != nil { - slog.Error("cannot read from socket", "error", err) - dbus.NotifyOrLog("inception error", fmt.Sprintf("cannot read from socket: %v", err)) - return - } - - fields := strings.Fields(string(buf[:n])) - slog.Debug("remote command", "fields", fields) - - if len(fields) < 1 { - return - } - - cmd, ok := commands[fields[0]] - if !ok { - slog.Debug("command not supported", "fields", fields) - dbus.NotifyOrLog("inception error", fmt.Sprintf("command not supported: %v", fields)) - return - } - - var args []string - if len(fields) > 1 { - args = fields[1:] - } - - err = cmd(cfg, p, args) - if err != nil { - slog.Debug("inception error: failed to run command", "fields", fields, "error", err) - dbus.NotifyOrLog("inception error", fmt.Sprintf("fail to run command: %v: %v", fields, err)) - } -} diff --git a/internal/profiles/profiles.go b/internal/profiles/profiles.go index 76df000..3cd749b 100644 --- a/internal/profiles/profiles.go +++ b/internal/profiles/profiles.go @@ -20,14 +20,13 @@ import ( "github.com/qubesome/cli/internal/env" "github.com/qubesome/cli/internal/files" "github.com/qubesome/cli/internal/images" - "github.com/qubesome/cli/internal/inception" "github.com/qubesome/cli/internal/runners/util/container" - "github.com/qubesome/cli/internal/socket" "github.com/qubesome/cli/internal/types" "github.com/qubesome/cli/internal/util/dbus" "github.com/qubesome/cli/internal/util/gpu" "github.com/qubesome/cli/internal/util/resolution" "github.com/qubesome/cli/internal/util/xauth" + "github.com/qubesome/cli/pkg/inception" "golang.org/x/sys/execabs" ) @@ -49,7 +48,7 @@ func Run(opts ...command.Option[Options]) error { if o.Config == nil { return fmt.Errorf("cannot start profile: nil config") } - profile, ok := o.Config.Profiles[o.Profile] + profile, ok := o.Config.Profile(o.Profile) if !ok { return fmt.Errorf("cannot start profile: profile %q not found", o.Profile) } @@ -153,7 +152,7 @@ func StartFromGit(runner, name, gitURL, path, local string) error { _ = os.Remove(ln) }() - p, ok := cfg.Profiles[name] + p, ok := cfg.Profile(name) if !ok { return fmt.Errorf("cannot file profile %q in config %q", name, cfgPath) } @@ -232,22 +231,26 @@ func Start(runner string, profile *types.Profile, cfg *types.Config) (err error) wg := &sync.WaitGroup{} wg.Add(1) + sockPath, err := files.SocketPath(profile.Name) + if err != nil { + return err + } + go func() { - err1 := socket.Listen(profile, cfg, inception.HandleConnection) + defer wg.Done() + + server := inception.NewServer(profile, cfg) + err1 := server.Listen(sockPath) if err1 != nil { slog.Debug("error listening to socket", "error", err1) if err == nil { err = err1 } } - - wg.Done() }() defer func() { - if fn, err := files.SocketPath(profile.Name); err != nil { - _ = os.Remove(fn) - } + _ = os.Remove(sockPath) }() err = createMagicCookie(profile) @@ -445,7 +448,6 @@ func createNewDisplay(bin string, profile *types.Profile, display string) error dockerArgs = append(dockerArgs, "--userns=keep-id") } if strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") { - fmt.Println("WARN: running qubesome in Wayland (experimental)") xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR") if xdgRuntimeDir == "" { uid := os.Getuid() diff --git a/internal/qubesome/run.go b/internal/qubesome/run.go index b6f8469..69660cf 100644 --- a/internal/qubesome/run.go +++ b/internal/qubesome/run.go @@ -1,6 +1,7 @@ package qubesome import ( + "context" "fmt" "log/slog" "os" @@ -24,29 +25,6 @@ import ( "gopkg.in/yaml.v3" ) -func init() { //nolint - inception.Add("run", runCmd) - inception.Add("xdg-open", xdgCmd) -} - -func runCmd(cfg *types.Config, p *types.Profile, args []string) error { - opts := []command.Option[Options]{ - WithConfig(cfg), - WithProfile(p.Name), - WithWorkload(args[0]), - } - - if len(args) > 0 { - opts = append(opts, WithExtraArgs(args[1:])) - } - - return Run(opts...) -} - -func xdgCmd(cfg *types.Config, p *types.Profile, args []string) error { - return XdgRun(WithConfig(cfg), WithProfile(p.Name), WithExtraArgs(args)) -} - func XdgRun(opts ...command.Option[Options]) error { o := &Options{} for _, opt := range opts { @@ -58,7 +36,8 @@ func XdgRun(opts ...command.Option[Options]) error { } if inception.Inside() { - return inception.RunOnHost("xdg-open", o.ExtraArgs) + client := inception.NewClient(files.InProfileSocketPath()) + return client.XdgOpen(context.TODO(), o.ExtraArgs[0]) } q := New() @@ -77,9 +56,8 @@ func Run(opts ...command.Option[Options]) error { } if inception.Inside() { - args := []string{o.Workload} - args = append(args, o.ExtraArgs...) - return inception.RunOnHost("run", args) + client := inception.NewClient(files.InProfileSocketPath()) + return client.Run(context.TODO(), o.Workload, o.ExtraArgs) } if err := o.Validate(); err != nil { @@ -108,7 +86,7 @@ func runner(in WorkloadInfo, runnerOverride string) error { return err } - profile, exists := in.Config.Profiles[in.Profile] + profile, exists := in.Config.Profile(in.Profile) if !exists { return fmt.Errorf("profile %q does not exist", in.Profile) } diff --git a/internal/runners/docker/run.go b/internal/runners/docker/run.go index 6cfde9c..29e4e04 100644 --- a/internal/runners/docker/run.go +++ b/internal/runners/docker/run.go @@ -100,7 +100,6 @@ func Run(ew types.EffectiveWorkload) error { display := ew.Profile.Display if strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") { //nolint - fmt.Println("WARN: running qubesome in Wayland (experimental)") display = 0 xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR") diff --git a/internal/runners/podman/run.go b/internal/runners/podman/run.go index 4b5eaf1..6d827fc 100644 --- a/internal/runners/podman/run.go +++ b/internal/runners/podman/run.go @@ -103,7 +103,6 @@ func Run(ew types.EffectiveWorkload) error { display := ew.Profile.Display if strings.EqualFold(os.Getenv("XDG_SESSION_TYPE"), "wayland") { //nolint - fmt.Println("WARN: running qubesome in Wayland (experimental)") display = 0 xdgRuntimeDir := os.Getenv("XDG_RUNTIME_DIR") diff --git a/internal/types/config.go b/internal/types/config.go index cf303dd..5a908a7 100644 --- a/internal/types/config.go +++ b/internal/types/config.go @@ -2,6 +2,7 @@ package types import ( "fmt" + "log/slog" "os" "path/filepath" "regexp" @@ -25,7 +26,7 @@ var ( type Config struct { Logging Logging `yaml:"logging"` - Profiles map[string]*Profile `yaml:"profiles"` + Profiles map[string]Profile `yaml:"profiles"` // MimeHandler configures mime types and the specific workloads to handle them. MimeHandlers map[string]MimeHandler `yaml:"mimeHandlers"` @@ -38,33 +39,51 @@ type Config struct { RootDir string } +func (c *Config) Profile(name string) (*Profile, bool) { + if c == nil || len(c.Profiles) == 0 { + return nil, false + } + p, ok := c.Profiles[name] + return &p, ok +} + // WorkloadFiles returns a list of workload file paths. func (c *Config) WorkloadFiles() ([]string, error) { var matches []string root := c.RootDir - if root == "" { - root = files.QubesomeConfig() - } - pattern := fmt.Sprintf("^%s/.*/workloads/.*.yaml$", root) - - err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - if info.IsDir() { - return nil + slog.Debug("workload files lookup", "root", root) + + for _, profile := range c.Profiles { + if c.RootDir == files.RunUserQubesome() { + ln := filepath.Join(files.RunUserQubesome(), profile.Name+".config") + target, err := os.Readlink(ln) + if err != nil { + slog.Debug("fail to Readlink", "err", err) + continue + } + root = filepath.Dir(target) } - matched, err := regexp.MatchString(pattern, path) + wd := filepath.Join(root, profile.Name, "workloads") + we, err := os.ReadDir(wd) if err != nil { - return err + slog.Debug("fail to ReadDir", "err", err, "wd", wd) + continue } - if matched { - matches = append(matches, path) + + for _, w := range we { + if w.IsDir() { + continue + } + + path := filepath.Join(wd, w.Name()) + if filepath.Ext(w.Name()) == ".yaml" { + matches = append(matches, path) + } } - return nil - }) - return matches, err + } + + return matches, nil } type Logging struct { @@ -87,7 +106,7 @@ type Profile struct { // Note that this Path descends from the dir where the qubesome // config is being consumed. When sourcing from git, it descends // from the git repository directory. - Path string + Path string `yaml:"path"` Runner string // TODO: Better name runner // HostAccess defines all the access request which are allowed for @@ -107,7 +126,7 @@ type Profile struct { // Image is the container image name used for running the profile. // It should contain Xephyr and any additional window managers required. - Image string + Image string `yaml:"image"` Timezone string `yaml:"timezone"` @@ -177,14 +196,16 @@ func (p Profile) Validate() error { func LoadConfig(path string) (*Config, error) { cfg := &Config{} - data, err := os.ReadFile(path) + f, err := os.Open(path) if err != nil { return nil, err } - err = yaml.Unmarshal(data, cfg) + decoder := yaml.NewDecoder(f) + decoder.KnownFields(true) // Enforces that all YAML fields match struct fields exactly. + err = decoder.Decode(&cfg) if err != nil { - return nil, fmt.Errorf("cannot unmarshal qubesome config %q: %w", path, err) + fmt.Println("Strict YAML decoding error:", err) } cfg.RootDir = filepath.Dir(path) @@ -192,9 +213,9 @@ func LoadConfig(path string) (*Config, error) { // To avoid names being defined twice on the profiles, the name // is only defined when referring to a profile which results // on the .name field of Profiles not being populated. - for k := range cfg.Profiles { - p := cfg.Profiles[k] - p.Name = k + for k, v := range cfg.Profiles { + v.Name = k + cfg.Profiles[k] = v } return cfg, nil diff --git a/pkg/inception/proto/host.pb.go b/pkg/inception/proto/host.pb.go new file mode 100644 index 0000000..b640b3d --- /dev/null +++ b/pkg/inception/proto/host.pb.go @@ -0,0 +1,277 @@ +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.35.2 +// protoc v5.28.3 +// source: pkg/inception/proto/host.proto + +package proto + +import ( + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +type XdgOpenRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` +} + +func (x *XdgOpenRequest) Reset() { + *x = XdgOpenRequest{} + mi := &file_pkg_inception_proto_host_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *XdgOpenRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*XdgOpenRequest) ProtoMessage() {} + +func (x *XdgOpenRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_inception_proto_host_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use XdgOpenRequest.ProtoReflect.Descriptor instead. +func (*XdgOpenRequest) Descriptor() ([]byte, []int) { + return file_pkg_inception_proto_host_proto_rawDescGZIP(), []int{0} +} + +func (x *XdgOpenRequest) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +type XdgOpenReply struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *XdgOpenReply) Reset() { + *x = XdgOpenReply{} + mi := &file_pkg_inception_proto_host_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *XdgOpenReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*XdgOpenReply) ProtoMessage() {} + +func (x *XdgOpenReply) ProtoReflect() protoreflect.Message { + mi := &file_pkg_inception_proto_host_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use XdgOpenReply.ProtoReflect.Descriptor instead. +func (*XdgOpenReply) Descriptor() ([]byte, []int) { + return file_pkg_inception_proto_host_proto_rawDescGZIP(), []int{1} +} + +type RunWorkloadRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + Workload string `protobuf:"bytes,1,opt,name=workload,proto3" json:"workload,omitempty"` + Args string `protobuf:"bytes,2,opt,name=args,proto3" json:"args,omitempty"` +} + +func (x *RunWorkloadRequest) Reset() { + *x = RunWorkloadRequest{} + mi := &file_pkg_inception_proto_host_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RunWorkloadRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunWorkloadRequest) ProtoMessage() {} + +func (x *RunWorkloadRequest) ProtoReflect() protoreflect.Message { + mi := &file_pkg_inception_proto_host_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RunWorkloadRequest.ProtoReflect.Descriptor instead. +func (*RunWorkloadRequest) Descriptor() ([]byte, []int) { + return file_pkg_inception_proto_host_proto_rawDescGZIP(), []int{2} +} + +func (x *RunWorkloadRequest) GetWorkload() string { + if x != nil { + return x.Workload + } + return "" +} + +func (x *RunWorkloadRequest) GetArgs() string { + if x != nil { + return x.Args + } + return "" +} + +type RunWorkloadReply struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *RunWorkloadReply) Reset() { + *x = RunWorkloadReply{} + mi := &file_pkg_inception_proto_host_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RunWorkloadReply) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RunWorkloadReply) ProtoMessage() {} + +func (x *RunWorkloadReply) ProtoReflect() protoreflect.Message { + mi := &file_pkg_inception_proto_host_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RunWorkloadReply.ProtoReflect.Descriptor instead. +func (*RunWorkloadReply) Descriptor() ([]byte, []int) { + return file_pkg_inception_proto_host_proto_rawDescGZIP(), []int{3} +} + +var File_pkg_inception_proto_host_proto protoreflect.FileDescriptor + +var file_pkg_inception_proto_host_proto_rawDesc = []byte{ + 0x0a, 0x1e, 0x70, 0x6b, 0x67, 0x2f, 0x69, 0x6e, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x2f, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x68, 0x6f, 0x73, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, + 0x12, 0x08, 0x71, 0x75, 0x62, 0x65, 0x73, 0x6f, 0x6d, 0x65, 0x22, 0x22, 0x0a, 0x0e, 0x58, 0x64, + 0x67, 0x4f, 0x70, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x10, 0x0a, 0x03, + 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x22, 0x0e, + 0x0a, 0x0c, 0x58, 0x64, 0x67, 0x4f, 0x70, 0x65, 0x6e, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x44, + 0x0a, 0x12, 0x52, 0x75, 0x6e, 0x57, 0x6f, 0x72, 0x6b, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x1a, 0x0a, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x6c, 0x6f, 0x61, 0x64, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x77, 0x6f, 0x72, 0x6b, 0x6c, 0x6f, 0x61, 0x64, + 0x12, 0x12, 0x0a, 0x04, 0x61, 0x72, 0x67, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, + 0x61, 0x72, 0x67, 0x73, 0x22, 0x12, 0x0a, 0x10, 0x52, 0x75, 0x6e, 0x57, 0x6f, 0x72, 0x6b, 0x6c, + 0x6f, 0x61, 0x64, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x32, 0x98, 0x01, 0x0a, 0x0c, 0x51, 0x75, 0x62, + 0x65, 0x73, 0x6f, 0x6d, 0x65, 0x48, 0x6f, 0x73, 0x74, 0x12, 0x3d, 0x0a, 0x07, 0x58, 0x64, 0x67, + 0x4f, 0x70, 0x65, 0x6e, 0x12, 0x18, 0x2e, 0x71, 0x75, 0x62, 0x65, 0x73, 0x6f, 0x6d, 0x65, 0x2e, + 0x58, 0x64, 0x67, 0x4f, 0x70, 0x65, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x16, + 0x2e, 0x71, 0x75, 0x62, 0x65, 0x73, 0x6f, 0x6d, 0x65, 0x2e, 0x58, 0x64, 0x67, 0x4f, 0x70, 0x65, + 0x6e, 0x52, 0x65, 0x70, 0x6c, 0x79, 0x22, 0x00, 0x12, 0x49, 0x0a, 0x0b, 0x52, 0x75, 0x6e, 0x57, + 0x6f, 0x72, 0x6b, 0x6c, 0x6f, 0x61, 0x64, 0x12, 0x1c, 0x2e, 0x71, 0x75, 0x62, 0x65, 0x73, 0x6f, + 0x6d, 0x65, 0x2e, 0x52, 0x75, 0x6e, 0x57, 0x6f, 0x72, 0x6b, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x71, 0x75, 0x62, 0x65, 0x73, 0x6f, 0x6d, 0x65, + 0x2e, 0x52, 0x75, 0x6e, 0x57, 0x6f, 0x72, 0x6b, 0x6c, 0x6f, 0x61, 0x64, 0x52, 0x65, 0x70, 0x6c, + 0x79, 0x22, 0x00, 0x42, 0x2d, 0x5a, 0x2b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, + 0x6d, 0x2f, 0x71, 0x75, 0x62, 0x65, 0x73, 0x6f, 0x6d, 0x65, 0x2f, 0x63, 0x6c, 0x69, 0x2f, 0x70, + 0x6b, 0x67, 0x2f, 0x69, 0x6e, 0x63, 0x65, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x2f, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_pkg_inception_proto_host_proto_rawDescOnce sync.Once + file_pkg_inception_proto_host_proto_rawDescData = file_pkg_inception_proto_host_proto_rawDesc +) + +func file_pkg_inception_proto_host_proto_rawDescGZIP() []byte { + file_pkg_inception_proto_host_proto_rawDescOnce.Do(func() { + file_pkg_inception_proto_host_proto_rawDescData = protoimpl.X.CompressGZIP(file_pkg_inception_proto_host_proto_rawDescData) + }) + return file_pkg_inception_proto_host_proto_rawDescData +} + +var file_pkg_inception_proto_host_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_pkg_inception_proto_host_proto_goTypes = []any{ + (*XdgOpenRequest)(nil), // 0: qubesome.XdgOpenRequest + (*XdgOpenReply)(nil), // 1: qubesome.XdgOpenReply + (*RunWorkloadRequest)(nil), // 2: qubesome.RunWorkloadRequest + (*RunWorkloadReply)(nil), // 3: qubesome.RunWorkloadReply +} +var file_pkg_inception_proto_host_proto_depIdxs = []int32{ + 0, // 0: qubesome.QubesomeHost.XdgOpen:input_type -> qubesome.XdgOpenRequest + 2, // 1: qubesome.QubesomeHost.RunWorkload:input_type -> qubesome.RunWorkloadRequest + 1, // 2: qubesome.QubesomeHost.XdgOpen:output_type -> qubesome.XdgOpenReply + 3, // 3: qubesome.QubesomeHost.RunWorkload:output_type -> qubesome.RunWorkloadReply + 2, // [2:4] is the sub-list for method output_type + 0, // [0:2] is the sub-list for method input_type + 0, // [0:0] is the sub-list for extension type_name + 0, // [0:0] is the sub-list for extension extendee + 0, // [0:0] is the sub-list for field type_name +} + +func init() { file_pkg_inception_proto_host_proto_init() } +func file_pkg_inception_proto_host_proto_init() { + if File_pkg_inception_proto_host_proto != nil { + return + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_pkg_inception_proto_host_proto_rawDesc, + NumEnums: 0, + NumMessages: 4, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_pkg_inception_proto_host_proto_goTypes, + DependencyIndexes: file_pkg_inception_proto_host_proto_depIdxs, + MessageInfos: file_pkg_inception_proto_host_proto_msgTypes, + }.Build() + File_pkg_inception_proto_host_proto = out.File + file_pkg_inception_proto_host_proto_rawDesc = nil + file_pkg_inception_proto_host_proto_goTypes = nil + file_pkg_inception_proto_host_proto_depIdxs = nil +} diff --git a/pkg/inception/proto/host.proto b/pkg/inception/proto/host.proto new file mode 100644 index 0000000..efbfa86 --- /dev/null +++ b/pkg/inception/proto/host.proto @@ -0,0 +1,25 @@ +syntax = "proto3"; + +option go_package = "github.com/qubesome/cli/pkg/inception/proto"; + +package qubesome; + +service QubesomeHost { + rpc XdgOpen (XdgOpenRequest) returns (XdgOpenReply) {} + rpc RunWorkload (RunWorkloadRequest) returns (RunWorkloadReply) {} +} + +message XdgOpenRequest { + string url = 1; +} + +message XdgOpenReply { +} + +message RunWorkloadRequest { + string workload = 1; + string args = 2; +} + +message RunWorkloadReply { +} diff --git a/pkg/inception/proto/host_grpc.pb.go b/pkg/inception/proto/host_grpc.pb.go new file mode 100644 index 0000000..109a23e --- /dev/null +++ b/pkg/inception/proto/host_grpc.pb.go @@ -0,0 +1,159 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.5.1 +// - protoc v5.28.3 +// source: pkg/inception/proto/host.proto + +package proto + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + QubesomeHost_XdgOpen_FullMethodName = "/qubesome.QubesomeHost/XdgOpen" + QubesomeHost_RunWorkload_FullMethodName = "/qubesome.QubesomeHost/RunWorkload" +) + +// QubesomeHostClient is the client API for QubesomeHost service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type QubesomeHostClient interface { + XdgOpen(ctx context.Context, in *XdgOpenRequest, opts ...grpc.CallOption) (*XdgOpenReply, error) + RunWorkload(ctx context.Context, in *RunWorkloadRequest, opts ...grpc.CallOption) (*RunWorkloadReply, error) +} + +type qubesomeHostClient struct { + cc grpc.ClientConnInterface +} + +func NewQubesomeHostClient(cc grpc.ClientConnInterface) QubesomeHostClient { + return &qubesomeHostClient{cc} +} + +func (c *qubesomeHostClient) XdgOpen(ctx context.Context, in *XdgOpenRequest, opts ...grpc.CallOption) (*XdgOpenReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(XdgOpenReply) + err := c.cc.Invoke(ctx, QubesomeHost_XdgOpen_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *qubesomeHostClient) RunWorkload(ctx context.Context, in *RunWorkloadRequest, opts ...grpc.CallOption) (*RunWorkloadReply, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RunWorkloadReply) + err := c.cc.Invoke(ctx, QubesomeHost_RunWorkload_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// QubesomeHostServer is the server API for QubesomeHost service. +// All implementations must embed UnimplementedQubesomeHostServer +// for forward compatibility. +type QubesomeHostServer interface { + XdgOpen(context.Context, *XdgOpenRequest) (*XdgOpenReply, error) + RunWorkload(context.Context, *RunWorkloadRequest) (*RunWorkloadReply, error) + mustEmbedUnimplementedQubesomeHostServer() +} + +// UnimplementedQubesomeHostServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedQubesomeHostServer struct{} + +func (UnimplementedQubesomeHostServer) XdgOpen(context.Context, *XdgOpenRequest) (*XdgOpenReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method XdgOpen not implemented") +} +func (UnimplementedQubesomeHostServer) RunWorkload(context.Context, *RunWorkloadRequest) (*RunWorkloadReply, error) { + return nil, status.Errorf(codes.Unimplemented, "method RunWorkload not implemented") +} +func (UnimplementedQubesomeHostServer) mustEmbedUnimplementedQubesomeHostServer() {} +func (UnimplementedQubesomeHostServer) testEmbeddedByValue() {} + +// UnsafeQubesomeHostServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to QubesomeHostServer will +// result in compilation errors. +type UnsafeQubesomeHostServer interface { + mustEmbedUnimplementedQubesomeHostServer() +} + +func RegisterQubesomeHostServer(s grpc.ServiceRegistrar, srv QubesomeHostServer) { + // If the following call pancis, it indicates UnimplementedQubesomeHostServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&QubesomeHost_ServiceDesc, srv) +} + +func _QubesomeHost_XdgOpen_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(XdgOpenRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(QubesomeHostServer).XdgOpen(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: QubesomeHost_XdgOpen_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(QubesomeHostServer).XdgOpen(ctx, req.(*XdgOpenRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _QubesomeHost_RunWorkload_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RunWorkloadRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(QubesomeHostServer).RunWorkload(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: QubesomeHost_RunWorkload_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(QubesomeHostServer).RunWorkload(ctx, req.(*RunWorkloadRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// QubesomeHost_ServiceDesc is the grpc.ServiceDesc for QubesomeHost service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var QubesomeHost_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "qubesome.QubesomeHost", + HandlerType: (*QubesomeHostServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "XdgOpen", + Handler: _QubesomeHost_XdgOpen_Handler, + }, + { + MethodName: "RunWorkload", + Handler: _QubesomeHost_RunWorkload_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "pkg/inception/proto/host.proto", +} diff --git a/pkg/inception/server.go b/pkg/inception/server.go new file mode 100644 index 0000000..3969d2c --- /dev/null +++ b/pkg/inception/server.go @@ -0,0 +1,90 @@ +package inception + +import ( + "context" + "fmt" + "log/slog" + "net" + "strings" + + "github.com/qubesome/cli/internal/command" + "github.com/qubesome/cli/internal/qubesome" + "github.com/qubesome/cli/internal/types" + pb "github.com/qubesome/cli/pkg/inception/proto" + "google.golang.org/grpc" +) + +// NewServer returns a new inception server. +func NewServer(p *types.Profile, cfg *types.Config) *Server { + return &Server{ + server: &grpcServer{ + profile: p, + config: cfg, + }, + } +} + +// Server represents an inception server. It is bound to a given profile, +// so all calls it receives will be constraints within that scope. +// +// Each profile can only have a single inception server. +type Server struct { + server *grpcServer +} + +func (s *Server) Listen(socket string) error { + lis, err := net.Listen("unix", socket) + if err != nil { + return fmt.Errorf("failed to listen: %w", err) + } + + gs := grpc.NewServer() + pb.RegisterQubesomeHostServer(gs, s.server) + + slog.Debug("[server] listening", "addr", lis.Addr()) + if err := gs.Serve(lis); err != nil { + return fmt.Errorf("failed to serve: %w", err) + } + + return nil +} + +type grpcServer struct { + pb.UnimplementedQubesomeHostServer + profile *types.Profile + config *types.Config +} + +func (s *grpcServer) XdgOpen(ctx context.Context, in *pb.XdgOpenRequest) (*pb.XdgOpenReply, error) { + url := in.GetUrl() + profile := s.profile.Name + slog.Debug("[server] xdg-open received", "url", url, "profile", profile) + + err := qubesome.XdgRun( + qubesome.WithConfig(s.config), + qubesome.WithProfile(s.profile.Name), + qubesome.WithExtraArgs([]string{url}), + ) + + return &pb.XdgOpenReply{}, err +} + +func (s *grpcServer) RunWorkload(ctx context.Context, in *pb.RunWorkloadRequest) (*pb.RunWorkloadReply, error) { + worload := in.GetWorkload() + args := in.GetArgs() + profile := s.profile.Name + slog.Debug("[server] run-workload received", "workload", worload, "profile", profile, "args", args) + + opts := []command.Option[qubesome.Options]{ + qubesome.WithConfig(s.config), + qubesome.WithProfile(profile), + qubesome.WithWorkload(worload), + } + + if len(args) > 0 { + opts = append(opts, qubesome.WithExtraArgs(strings.Split(args, " "))) + } + + err := qubesome.Run(opts...) + return &pb.RunWorkloadReply{}, err +}