diff --git a/.gitignore b/.gitignore index 0cb00dca..fcda0ba4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /rhc +/rhc-server /rhc.1.gz /USAGE.md rhc.spec diff --git a/Makefile b/Makefile index e9565e46..5a94548d 100644 --- a/Makefile +++ b/Makefile @@ -7,6 +7,7 @@ VERSION := $(shell rpmspec rhc.spec --query --queryformat '%{version}') .PHONY: build build: go build -ldflags "-X main.Version=$(VERSION)" -o rhc ./cmd/rhc + go build -ldflags "-X main.Version=${VERSION}" -o rhc-server ./cmd/rhc-server .PHONY: archive archive: @@ -21,4 +22,5 @@ srpm: archive .PHONY: clean clean: rm -f rhc + rm -f rhc-server rm -f rhc-*.tar* diff --git a/cmd/rhc-server/main.go b/cmd/rhc-server/main.go new file mode 100644 index 00000000..5fa08907 --- /dev/null +++ b/cmd/rhc-server/main.go @@ -0,0 +1,261 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log/slog" + "net" + "os" + "os/signal" + "path/filepath" + "syscall" + + "github.com/coreos/go-systemd/v22/activation" + govarlink "github.com/emersion/go-varlink" + + "github.com/redhatinsights/rhc/varlink/internalapi" +) + +const ( + socketPath = "/run/rhc/com.redhat.rhc" + pidFilePath = "/run/rhc/rhc-server.pid" + socketDirPerms = 0755 + socketPerms = 0660 + pidFilePerms = 0644 + + // Channel buffer sizes for graceful shutdown + signalChanBuffer = 1 + errorChanBuffer = 1 +) + +func main() { + // Acquire PID lock to ensure only one instance runs + cleanup, err := acquirePIDLock() + if err != nil { + slog.Error("Failed to acquire PID lock", "error", err) + os.Exit(1) + } + defer cleanup() + + if err := run(); err != nil { + slog.Error("rhc-server error", "error", err) + os.Exit(1) + } +} + +func run() error { + // Create backend + backend := NewBackend() + + // Create registry and register the internal API + registry := govarlink.NewRegistry(&govarlink.RegistryOptions{ + Vendor: "Red Hat", + Product: "rhc", + Version: Version, + URL: "https://github.com/redhatinsights/rhc", + }) + + // Register internal API + handler := internalapi.Handler{Backend: backend} + handler.Register(registry) + + // Create server + varlinkServer := &govarlink.Server{ + Handler: registry, + } + + // Try to get listener from systemd socket activation first + listener, err := getListener() + if err != nil { + return fmt.Errorf("failed to get listener: %w", err) + } + defer func() { + if err := listener.Close(); err != nil { + slog.Error("Error closing listener", "error", err) + } + }() + + slog.Info("rhc-server starting", "version", Version) + slog.Info("Listening on socket", "address", listener.Addr()) + + // Setup graceful shutdown + _, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Setup signal handler for graceful shutdown on SIGINT/SIGTERM + sigChan := make(chan os.Signal, signalChanBuffer) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + // Run the server in a goroutine so we can handle signals concurrently + errChan := make(chan error, errorChanBuffer) + go func() { + errChan <- varlinkServer.Serve(listener) + }() + + // Block until either: + // - The server encounters an error (errChan) + // - We receive a shutdown signal (sigChan) + select { + case err := <-errChan: + if err != nil { + return fmt.Errorf("server error: %w", err) + } + case sig := <-sigChan: + slog.Info("Received signal, shutting down gracefully", "signal", sig) + cancel() + } + + slog.Info("rhc-server stopped") + return nil +} + +// acquirePIDLock creates and locks a PID file to ensure only one instance runs. +// Returns a cleanup function that should be deferred to release the lock. +func acquirePIDLock() (func(), error) { + // Ensure the directory exists + dir := filepath.Dir(pidFilePath) + if err := os.MkdirAll(dir, socketDirPerms); err != nil { + return nil, fmt.Errorf("failed to create PID file directory: %w", err) + } + + // Open or create the PID file + pidFile, err := os.OpenFile(pidFilePath, os.O_CREATE|os.O_RDWR, pidFilePerms) + if err != nil { + return nil, fmt.Errorf("failed to open PID file: %w", err) + } + + // Try to acquire an exclusive lock (non-blocking) + if err := syscall.Flock(int(pidFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil { + _ = pidFile.Close() + if errors.Is(err, syscall.EWOULDBLOCK) { + return nil, fmt.Errorf("another instance of rhc-server is already running") + } + return nil, fmt.Errorf("failed to lock PID file: %w", err) + } + + // release defines a local helper to unlock and close the PID file + // during error handling or normal shutdown. + release := func() { + _ = syscall.Flock(int(pidFile.Fd()), syscall.LOCK_UN) + _ = pidFile.Close() + } + + // Reset file content to ensure a clean state before writing the new PID + if err := pidFile.Truncate(0); err != nil { + release() + return nil, fmt.Errorf("failed to truncate PID file: %w", err) + } + + // Ensure the file cursor is at the beginning + if _, err := pidFile.Seek(0, 0); err != nil { + release() + return nil, fmt.Errorf("failed to seek PID file: %w", err) + } + + // Save current process ID to the lock file + pid := os.Getpid() + if _, err := fmt.Fprintf(pidFile, "%d\n", pid); err != nil { + release() + return nil, fmt.Errorf("failed to write PID to file: %w", err) + } + + // Commit pidFile content to stable storage + if err := pidFile.Sync(); err != nil { + release() + return nil, fmt.Errorf("failed to sync PID file: %w", err) + } + + slog.Info("PID lock acquired", "pid", pid, "pidFile", pidFilePath) + + // Return cleanup function + cleanup := func() { + release() + if err := os.Remove(pidFilePath); err != nil { + slog.Warn("Failed to remove PID file", "path", pidFilePath, "error", err) + } + slog.Info("PID lock released") + } + + return cleanup, nil +} + +// trySystemdActivation attempts to get a listener from systemd socket activation. +func trySystemdActivation() (net.Listener, error) { + listeners, err := activation.Listeners() + if err != nil { + return nil, fmt.Errorf("failed to get systemd listeners: %w", err) + } + + if len(listeners) == 0 { + slog.Debug("Unable to find systemd listeners") + return nil, nil // No systemd socket available + } + + // Find the first unix socket listener + for _, listener := range listeners { + if listener.Addr().Network() == "unix" { + slog.Info("Using systemd socket activation", "address", listener.Addr()) + return listener, nil + } + } + + return nil, fmt.Errorf("no unix socket found in systemd listeners") +} + +// ensureSocketDirectory creates the directory for the socket if it doesn't exist. +func ensureSocketDirectory(path string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, socketDirPerms); err != nil { + return fmt.Errorf("failed to create socket directory: %w", err) + } + return nil +} + +// createUnixSocket creates a unix socket at the specified path. +func createUnixSocket(path string) (net.Listener, error) { + slog.Info("Creating unix socket", "path", path) + + // Ensure the directory exists + if err := ensureSocketDirectory(path); err != nil { + return nil, err + } + + // Remove existing socket file if it exists. + // This is safe because we hold the PID lock, ensuring no other + // rhc-server instance is running. The socket exists only because + // a previous instance was terminated abnormally. + if err := os.RemoveAll(path); err != nil { + return nil, fmt.Errorf("failed to remove existing socket: %w", err) + } + + // Create unix socket + listener, err := net.Listen("unix", path) + if err != nil { + return nil, fmt.Errorf("failed to create unix socket: %w", err) + } + + // Set socket permissions + if err := os.Chmod(path, socketPerms); err != nil { + _ = listener.Close() + return nil, fmt.Errorf("failed to set socket permissions: %w", err) + } + + return listener, nil +} + +// getListener tries to get a listener from systemd socket activation, +// falls back to creating a unix socket. +func getListener() (net.Listener, error) { + // Try systemd socket activation first + listener, err := trySystemdActivation() + if err != nil { + return nil, err + } + if listener != nil { + return listener, nil + } + + // Fall back to creating our own unix socket + return createUnixSocket(socketPath) +} diff --git a/cmd/rhc-server/rhc-server.go b/cmd/rhc-server/rhc-server.go new file mode 100644 index 00000000..303a6092 --- /dev/null +++ b/cmd/rhc-server/rhc-server.go @@ -0,0 +1,27 @@ +package main + +import ( + "fmt" + + "github.com/redhatinsights/rhc/varlink/internalapi" +) + +var ( + // Version is set at build time. + Version = "dev" +) + +// Backend implements the internal API backend. +type Backend struct{} + +// NewBackend creates a new backend instance. +func NewBackend() *Backend { + return &Backend{} +} + +// Test implements the Test method of the internal API. +// Simply echoes back the input with a prefix. +func (b *Backend) Test(in *internalapi.TestIn) (*internalapi.TestOut, error) { + output := fmt.Sprintf("Echo from rhc-server: %s", in.Input) + return &internalapi.TestOut{Output: output}, nil +} diff --git a/cmd/rhc-server/rhc-server_test.go b/cmd/rhc-server/rhc-server_test.go new file mode 100644 index 00000000..fc38216e --- /dev/null +++ b/cmd/rhc-server/rhc-server_test.go @@ -0,0 +1,96 @@ +package main + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + + "github.com/redhatinsights/rhc/varlink/internalapi" +) + +func TestNewBackend(t *testing.T) { + backend := NewBackend() + + if backend == nil { + t.Fatal("NewBackend() returned nil") + } +} + +func TestBackend_Test(t *testing.T) { + tests := []struct { + description string + input *internalapi.TestIn + want *internalapi.TestOut + }{ + { + description: "simple message", + input: &internalapi.TestIn{ + Input: "hello", + }, + want: &internalapi.TestOut{ + Output: "Echo from rhc-server: hello", + }, + }, + { + description: "empty string", + input: &internalapi.TestIn{ + Input: "", + }, + want: &internalapi.TestOut{ + Output: "Echo from rhc-server: ", + }, + }, + { + description: "message with special characters", + input: &internalapi.TestIn{ + Input: "hello!@#$%^&*()", + }, + want: &internalapi.TestOut{ + Output: "Echo from rhc-server: hello!@#$%^&*()", + }, + }, + { + description: "message with newlines", + input: &internalapi.TestIn{ + Input: "line1\nline2\nline3", + }, + want: &internalapi.TestOut{ + Output: "Echo from rhc-server: line1\nline2\nline3", + }, + }, + { + description: "message with unicode", + input: &internalapi.TestIn{ + Input: "Hello δΈ–η•Œ 🌍", + }, + want: &internalapi.TestOut{ + Output: "Echo from rhc-server: Hello δΈ–η•Œ 🌍", + }, + }, + { + description: "very long message", + input: &internalapi.TestIn{ + Input: string(make([]byte, 10000)), + }, + want: &internalapi.TestOut{ + Output: "Echo from rhc-server: " + string(make([]byte, 10000)), + }, + }, + } + + backend := NewBackend() + + for _, test := range tests { + t.Run(test.description, func(t *testing.T) { + got, err := backend.Test(test.input) + + if err != nil { + t.Errorf("Backend.Test(%v) unexpected error: %v", test.input, err) + } + + if !cmp.Equal(got, test.want) { + t.Errorf("Backend.Test(%v) = %v; want %v", test.input, got, test.want) + } + }) + } +} diff --git a/data/systemd/rhc-server.service b/data/systemd/rhc-server.service new file mode 100644 index 00000000..fc2d3db6 --- /dev/null +++ b/data/systemd/rhc-server.service @@ -0,0 +1,18 @@ +[Unit] +Description=rhc server +Documentation=https://github.com/RedHatInsights/rhc +Requires=rhc-server.socket +After=rhc-server.socket + +[Service] +Type=simple +ExecStart=/usr/libexec/rhc/rhc-server +StandardOutput=journal +StandardError=journal +ReadWritePaths=/run/rhc +RuntimeDirectory=rhc +RuntimeDirectoryMode=0755 +RuntimeDirectoryPreserve=yes + +[Install] +WantedBy=multi-user.target diff --git a/data/systemd/rhc-server.socket b/data/systemd/rhc-server.socket new file mode 100644 index 00000000..9dc9375b --- /dev/null +++ b/data/systemd/rhc-server.socket @@ -0,0 +1,10 @@ +[Unit] +Description=rhc server socket +Documentation=https://github.com/RedHatInsights/rhc + +[Socket] +ListenStream=/run/rhc/com.redhat.rhc +SocketMode=0660 + +[Install] +WantedBy=sockets.target diff --git a/go.mod b/go.mod index 8284b6d6..1c0d0eb7 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/BurntSushi/toml v1.6.0 github.com/briandowns/spinner v1.23.2 github.com/coreos/go-systemd/v22 v22.7.0 + github.com/emersion/go-varlink v0.1.0 github.com/godbus/dbus/v5 v5.2.2 github.com/google/go-cmp v0.7.0 github.com/google/uuid v1.6.0 @@ -16,6 +17,7 @@ require ( require ( github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect + github.com/dave/jennifer v1.7.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/go.sum b/go.sum index fa1353c3..67a062e1 100644 --- a/go.sum +++ b/go.sum @@ -6,6 +6,10 @@ github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w= github.com/cpuguy83/go-md2man/v2 v2.0.7 h1:zbFlGlXEAKlwXpmvle3d8Oe3YnkKIK4xSRTd3sHPnBo= github.com/cpuguy83/go-md2man/v2 v2.0.7/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dave/jennifer v1.7.1 h1:B4jJJDHelWcDhlRQxWeo0Npa/pYKBLrirAQoTN45txo= +github.com/dave/jennifer v1.7.1/go.mod h1:nXbxhEmQfOZhWml3D1cDK5M1FLnMSozpbFN/m3RmGZc= +github.com/emersion/go-varlink v0.1.0 h1:FPDn7g7CclN6/D8Dlf+EfBV136Zlf3qHaezNnpcuYQ8= +github.com/emersion/go-varlink v0.1.0/go.mod h1:0/V0Ta8VUzKRLXUtyZS49soMI93Taqlm63wX5nx6YEo= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= diff --git a/rhc.spec b/rhc.spec index 10304725..7efc23d3 100644 --- a/rhc.spec +++ b/rhc.spec @@ -45,6 +45,7 @@ to Red Hat Subscription Management and Red Hat Lightspeed. %build export GO_LDFLAGS="-X main.Version=%{version} -X main.ServiceName=yggdrasil" %gobuild -o %{gobuilddir}/bin/rhc %{goipath}/cmd/rhc +%gobuild -o %{gobuilddir}/bin/rhc-server %{goipath}/cmd/rhc-server # Generate man page %{gobuilddir}/bin/rhc --generate-man-page > rhc.1 @@ -54,7 +55,9 @@ export GO_LDFLAGS="-X main.Version=%{version} -X main.ServiceName=yggdrasil" %go_vendor_license_install -c %{S:2} # Binaries install -m 0755 -vd %{buildroot}%{_bindir} -install -m 0755 -vp _build/bin/* %{buildroot}%{_bindir}/ +install -m 0755 -vp _build/bin/rhc %{buildroot}%{_bindir}/ +install -m 0755 -vd %{buildroot}%{_libexecdir}/%{name} +install -m 0755 -vp _build/bin/rhc-server %{buildroot}%{_libexecdir}/%{name}/ # Bash completion install -m 0755 -vd %{buildroot}%{bash_completions_dir}/ install -m 0644 -vp data/completion/rhc.bash %{buildroot}%{bash_completions_dir}/%{name} @@ -69,6 +72,8 @@ install -m 0644 -vp rhc.1 %{buildroot}%{_mandir}/man1/rhc.1 # Systemd files install -m 0755 -vd %{buildroot}%{_unitdir} install -m 0644 -vp data/systemd/rhc-canonical-facts.* %{buildroot}%{_unitdir}/ +install -m 0644 -vp data/systemd/rhc-server.service %{buildroot}%{_unitdir}/ +install -m 0644 -vp data/systemd/rhc-server.socket %{buildroot}%{_unitdir}/ # Configuration install -m 0755 -vd %{buildroot}%{_sysconfdir}/%{name}/ # Yggdrasil @@ -85,6 +90,7 @@ install -m 0644 -vp %{buildroot}%{_unitdir}/yggdrasil.service.d/rhcd.conf %{buil %post %systemd_post rhc-canonical-facts.timer +%systemd_post rhc-server.socket %if 0%{?with_rhcd_compat} # On package update, ensure yggdrasil (formerly rhcd) has its own configuration file if [ $1 -eq 2 ] && [ ! -f /etc/yggdrasil/config.toml ]; then @@ -97,19 +103,24 @@ fi %preun %systemd_preun rhc-canonical-facts.timer +%systemd_preun rhc-server.socket rhc-server.service %postun %systemd_postun_with_restart rhc-canonical-facts.timer +%systemd_postun_with_restart rhc-server.service %files -f %{go_vendor_license_filelist} # Binaries %{_bindir}/rhc +%{_libexecdir}/%{name}/rhc-server # Bash completion %{bash_completions_dir}/%{name} # Man page %{_mandir}/man1/* # Systemd %{_unitdir}/rhc-canonical-facts.* +%{_unitdir}/rhc-server.service +%{_unitdir}/rhc-server.socket # Configuration %{_sysconfdir}/%{name}/ # Logrotate diff --git a/varlink/internalapi/com.redhat.rhc.internal.go b/varlink/internalapi/com.redhat.rhc.internal.go new file mode 100644 index 00000000..5a39a990 --- /dev/null +++ b/varlink/internalapi/com.redhat.rhc.internal.go @@ -0,0 +1,105 @@ +// Code generated by go-varlink/varlinkgen. DO NOT EDIT. + +package internalapi + +import ( + "encoding/json" + govarlink "github.com/emersion/go-varlink" +) + +type InvalidParameterError struct { + Parameter string `json:"parameter"` +} + +func (err *InvalidParameterError) Error() string { + return "varlink call failed: com.redhat.rhc.internal.InvalidParameter" +} + +type TestIn struct { + Input string `json:"input"` +} +type TestOut struct { + Output string `json:"output"` +} + +type Client struct { + *govarlink.Client +} + +func unmarshalError(err error) error { + verr, ok := err.(*govarlink.ClientError) + if !ok { + return err + } + var v error + switch verr.Name { + case "com.redhat.rhc.internal.InvalidParameter": + v = new(InvalidParameterError) + default: + return err + } + if err := json.Unmarshal(verr.Parameters, v); err != nil { + return err + } + return v +} +func (c Client) Test(in *TestIn) (*TestOut, error) { + if in == nil { + in = new(TestIn) + } + out := new(TestOut) + err := c.Client.Do("com.redhat.rhc.internal.Test", in, out) + return out, unmarshalError(err) +} + +type Backend interface { + Test(*TestIn) (*TestOut, error) +} + +type Handler struct { + Backend Backend +} + +func marshalError(err error) error { + var name string + switch err.(type) { + case *InvalidParameterError: + name = "com.redhat.rhc.internal.InvalidParameter" + default: + return err + } + return &govarlink.ServerError{ + Name: name, + Parameters: err, + } +} +func (h Handler) HandleVarlink(call *govarlink.ServerCall, req *govarlink.ServerRequest) error { + var ( + out interface{} + err error + ) + switch req.Method { + case "com.redhat.rhc.internal.Test": + in := new(TestIn) + if err := json.Unmarshal(req.Parameters, in); err != nil { + return err + } + out, err = h.Backend.Test(in) + default: + err = &govarlink.ServerError{ + Name: "org.varlink.service.MethodNotFound", + Parameters: map[string]string{"method": req.Method}, + } + } + if err != nil { + return marshalError(err) + } + return call.CloseWithReply(out) +} + +func (h Handler) Register(reg *govarlink.Registry) { + reg.Add(&govarlink.RegistryInterface{ + Definition: "interface com.redhat.rhc.internal\n# Internal API for rhc-server\n# This API is temporary and unstable\n\n# Generic error for invalid parameters\nerror InvalidParameter (parameter: string)\n\n# Test method - returns the input string as-is\nmethod Test(input: string) -> (output: string)\n", + Name: "com.redhat.rhc.internal", + }, h) +} diff --git a/varlink/internalapi/com.redhat.rhc.internal.varlink b/varlink/internalapi/com.redhat.rhc.internal.varlink new file mode 100644 index 00000000..0ab0050c --- /dev/null +++ b/varlink/internalapi/com.redhat.rhc.internal.varlink @@ -0,0 +1,9 @@ +interface com.redhat.rhc.internal +# Internal API for rhc-server +# This API is temporary and unstable + +# Generic error for invalid parameters +error InvalidParameter (parameter: string) + +# Test method - returns the input string as-is +method Test(input: string) -> (output: string) diff --git a/varlink/internalapi/generate.go b/varlink/internalapi/generate.go new file mode 100644 index 00000000..e697b18b --- /dev/null +++ b/varlink/internalapi/generate.go @@ -0,0 +1,3 @@ +package internalapi + +//go:generate go run github.com/emersion/go-varlink/cmd/varlinkgen -i com.redhat.rhc.internal.varlink