Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Desktop.ini
!.vscode/settings.json
*.swp
*.swo

.claude
# Testing / coverage
coverage/
.nyc_output/
Expand Down
2 changes: 1 addition & 1 deletion apps/agent/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ agent:

server:
# DockSight server WebSocket endpoint for agent registration
url: ws://127.0.0.1:2002/agents
url: ws://127.0.0.1:3000/agents

docker:
# Leave empty to use the platform default.
Expand Down
73 changes: 72 additions & 1 deletion apps/cli/cmd/config/defaults.go
Original file line number Diff line number Diff line change
@@ -1,12 +1,83 @@
package config

// Default returns the standard installation layout.
import (
"os"
"path/filepath"
"runtime"
"strings"
)

// Default returns the standard installation layout for this host.
func Default() Config {

if runtime.GOOS == "windows" {
return WindowsDefault()
}

return LinuxDefault()
}

// LinuxDefault is the standard installation on a Linux host.
func LinuxDefault() Config {

return Config{
InstallationDir: "/opt/docksight",
DataDir: "/var/lib/docksight",
BinaryPath: "/usr/local/bin/docksight",
Port: 2002,
}
}

// WindowsDefault is the standard installation on a Windows host.
//
// The split follows the platform's own convention. Program Files holds the
// executable, which only an installer may write. ProgramData holds the
// installation — the compose file, the generated .env and the state record —
// because that is machine-wide state that must survive any particular user
// account being deleted.
//
// The stack's own data is not here. Postgres and Redis write to named Docker
// volumes managed by the Engine, so DataDir holds only what the CLI itself
// puts there.
//
// The elements are joined with an explicit separator rather than with
// filepath.Join. filepath.Join would give the same answer on the only host
// this is reached from — unlike the agent's Layout, nothing here is written
// into a file another machine reads, so there is no correctness problem to
// avoid. What it would cost is the ability to assert any of this from a
// Linux test runner, and these paths are exactly the kind of thing that is
// worth pinning in CI.
func WindowsDefault() Config {

programData := environmentOr("ProgramData", `C:\ProgramData`)

return Config{
InstallationDir: windowsJoin(programData, "DockSight", "platform"),
DataDir: windowsJoin(programData, "DockSight", "data"),
BinaryPath: windowsJoin(
environmentOr("ProgramFiles", `C:\Program Files`),
"DockSight",
"docksight.exe",
),
Port: 2002,
}
}

func windowsJoin(elements ...string) string {
return strings.Join(elements, `\`)
}

// InstallDirectory is the directory the CLI binary is installed into. It is
// what has to be on PATH for `docksight` to resolve in a new shell.
func (c Config) InstallDirectory() string {
return filepath.Dir(c.BinaryPath)
}

func environmentOr(name string, fallback string) string {

if value := strings.TrimRight(os.Getenv(name), `\`); value != "" {
return value
}

return fallback
}
110 changes: 110 additions & 0 deletions apps/cli/cmd/config/defaults_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package config

import (
"path/filepath"
"runtime"
"strings"
"testing"
)

func TestLinuxDefault(t *testing.T) {

cfg := LinuxDefault()

expected := map[string]string{
"install": "/opt/docksight",
"data": "/var/lib/docksight",
"binary": "/usr/local/bin/docksight",
"compose": "/opt/docksight/" + ComposeFileName,
"env": "/opt/docksight/" + EnvFileName,
"state": "/opt/docksight/" + StateFileName,
"bindir": "/usr/local/bin",
"envexamp": "/opt/docksight/" + EnvExampleFileName,
}

got := map[string]string{
"install": cfg.InstallationDir,
"data": cfg.DataDir,
"binary": cfg.BinaryPath,
"compose": filepath.ToSlash(cfg.ComposePath()),
"env": filepath.ToSlash(cfg.EnvPath()),
"state": filepath.ToSlash(cfg.StatePath()),
"bindir": filepath.ToSlash(cfg.InstallDirectory()),
"envexamp": filepath.ToSlash(cfg.EnvExamplePath()),
}

for name, want := range expected {

if got[name] != want {
t.Errorf("%s is %q, want %q", name, got[name], want)
}
}

if cfg.Port != 2002 {
t.Errorf("port is %d", cfg.Port)
}
}

func TestWindowsDefault(t *testing.T) {

t.Setenv("ProgramData", `C:\ProgramData`)
t.Setenv("ProgramFiles", `C:\Program Files`)

cfg := WindowsDefault()

// The binary and the installation are deliberately not in the same
// place: one is a program, the other is machine-wide state.
if cfg.InstallationDir != `C:\ProgramData\DockSight\platform` {
t.Errorf("installation dir is %q", cfg.InstallationDir)
}

if cfg.DataDir != `C:\ProgramData\DockSight\data` {
t.Errorf("data dir is %q", cfg.DataDir)
}

if cfg.BinaryPath != `C:\Program Files\DockSight\docksight.exe` {
t.Errorf("binary path is %q", cfg.BinaryPath)
}

if cfg.Port != 2002 {
t.Errorf("port is %d", cfg.Port)
}

// The directory that has to reach the machine PATH. Derived with
// filepath, so it is only meaningful on the host the layout describes —
// the literal fields above are what this test pins everywhere.
if runtime.GOOS == "windows" && cfg.InstallDirectory() != `C:\Program Files\DockSight` {
t.Errorf("install directory is %q", cfg.InstallDirectory())
}
}

// A machine that keeps ProgramData somewhere else must be honoured, and a
// trailing separator in the variable must not double up.
func TestWindowsDefaultHonoursEnvironment(t *testing.T) {

t.Setenv("ProgramData", `D:\State\`)
t.Setenv("ProgramFiles", `D:\Apps`)

cfg := WindowsDefault()

if !strings.HasPrefix(cfg.InstallationDir, `D:\State\DockSight`) {
t.Errorf("installation dir is %q", cfg.InstallationDir)
}

if strings.Contains(cfg.InstallationDir, `\\`) {
t.Errorf("doubled separator in %q", cfg.InstallationDir)
}

if !strings.HasPrefix(cfg.BinaryPath, `D:\Apps\DockSight`) {
t.Errorf("binary path is %q", cfg.BinaryPath)
}
}

func TestDefaultMatchesHost(t *testing.T) {

windows := strings.Contains(Default().BinaryPath, ".exe")

if windows != (runtime.GOOS == "windows") {
t.Fatalf("Default() returned a %s layout on %s", map[bool]string{true: "windows", false: "unix"}[windows], runtime.GOOS)
}
}
86 changes: 76 additions & 10 deletions apps/cli/cmd/install.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
package cmd

import (
"context"
"errors"
"fmt"
"runtime"

"github.com/Open-Source-Kigali/docksight/apps/cli/cmd/config"
"github.com/Open-Source-Kigali/docksight/apps/cli/cmd/internal/installer"
Expand All @@ -26,7 +29,7 @@ var installCMD = &cobra.Command{
consoleReporter{},
system.PlatformRequirements(),
); err != nil {
return err
return withElevationHint(err)
}

cfg := config.Default()
Expand All @@ -37,20 +40,83 @@ var installCMD = &cobra.Command{
return err
}

host := system.PrimaryIPv4()
ui.Success(
fmt.Sprintf("DockSight is running on http://%s:%d", host, cfg.Port),
)
if host != "localhost" {
ui.Info(
fmt.Sprintf("Local-only access: http://localhost:%d", cfg.Port),
)
}
reportReachability(cmd.Context(), cfg)

return nil
},
}

// withElevationHint adds the fix to a validation failure that is about
// privileges, and leaves every other failure alone. A Docker daemon that is
// down is not started by running as Administrator, and a hint that does not
// apply is worse than none.
//
// The layout matches how the agent installer renders a PhaseError hint, so
// both commands read the same way when they fail for the same reason.
func withElevationHint(err error) error {

var notElevated *system.NotElevatedError

if errors.As(err, &notElevated) {
return fmt.Errorf("%w\n %s", err, system.ElevationHint())
}

return err
}

// reportReachability tells the operator where the platform can be reached
// from, and what would stop it being reachable later.
//
// The distinction matters more than it looks. Agents run on other machines
// and dial in; an operator who only ever checks the dashboard from the host
// itself can have a working localhost URL and a platform no agent can reach,
// and nothing in a successful install would have said so.
func reportReachability(ctx context.Context, cfg config.Config) {

host := system.PrimaryIPv4()

ui.Success(fmt.Sprintf("DockSight is running on http://%s:%d", host, cfg.Port))

if host != "localhost" {
ui.Info(fmt.Sprintf("Local-only access: http://localhost:%d", cfg.Port))
ui.Info(fmt.Sprintf("Point agents at: http://%s:%d", host, cfg.Port))
} else {
ui.Warning(
"No network address was found for this host, so only this machine " +
"can reach the platform. Agents on other machines will not connect.",
)
}

warnAboutDockerDesktop(ctx)
}

// warnAboutDockerDesktop states the one thing this installation cannot do.
//
// Docker Desktop's engine runs inside a VM started by its desktop
// application, in a user session. A Windows host that reboots to a sign-in
// screen therefore has no engine, and no platform, until somebody signs in —
// the containers are all restart: unless-stopped, but there is nothing
// running to restart them. That is the accepted cost of not managing a WSL
// distribution, and an operator has to hear it at install time rather than
// discover it during the outage it causes.
func warnAboutDockerDesktop(ctx context.Context) {

if runtime.GOOS != "windows" || !system.DockerDesktop(ctx) {
return
}

ui.Warning(
"This host runs Docker Desktop, whose engine starts with the Docker " +
"Desktop application in a user session. After a reboot the platform " +
"stays down until someone signs in.",
)

ui.Info(
"Enable Docker Desktop > Settings > General > \"Start Docker Desktop when you sign in\", " +
"and sign in after every reboot. For unattended restarts, run the platform on a Linux host.",
)
}

func init() {
rootCmd.AddCommand(installCMD)
}
65 changes: 65 additions & 0 deletions apps/cli/cmd/internal/envpath/envpath.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Package envpath puts the CLI's install directory on the system search path.
//
// It exists for Windows. On Linux the CLI installs into /usr/local/bin, which
// every shell already searches, so there is nothing to do and the non-Windows
// implementation says so by doing nothing. On Windows there is no such
// directory: an executable dropped in Program Files is not on PATH, and a
// user who installs and then types "docksight" in a new terminal is told the
// command does not exist.
package envpath

import "strings"

// SamePathElement reports whether two PATH entries name the same directory.
//
// Comparison is case-insensitive and ignores a trailing separator, because
// Windows paths are case-insensitive and "C:\Program Files\DockSight" and
// "C:\Program Files\DockSight\" are the same directory written twice. Getting
// this wrong appends a duplicate on every install, and PATH has a length
// limit that duplicates eventually reach.
func SamePathElement(left string, right string) bool {

return normalizeElement(left) == normalizeElement(right)
}

func normalizeElement(element string) string {

trimmed := strings.TrimSpace(element)
trimmed = strings.TrimRight(trimmed, `\/`)

return strings.ToLower(trimmed)
}

// Contains reports whether a PATH value already lists directory.
func Contains(path string, directory string) bool {

for _, element := range strings.Split(path, ";") {

if element == "" {
continue
}

if SamePathElement(element, directory) {
return true
}
}

return false
}

// Append adds directory to a PATH value, returning the new value and whether
// it changed.
func Append(path string, directory string) (string, bool) {

if Contains(path, directory) {
return path, false
}

trimmed := strings.TrimRight(path, "; ")

if trimmed == "" {
return directory, true
}

return trimmed + ";" + directory, true
}
Loading
Loading