Skip to content
Draft
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
52 changes: 48 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,18 +6,40 @@ A good use case is to let it run inside of a docker container. It will give addi

The current runtime backends are Docker for local development and Kubernetes for in-cluster or kubeconfig-backed cluster operation.

## Installation
## Installation and Local Build

We publish [releases on Github](https://github.com/highcard-dev/druid-cli/releases).
We publish [releases on GitHub](https://github.com/highcard-dev/druid-cli/releases).

You can easlily install druid-cli on Linux by running:
You can install the latest Linux release with:

```bash
curl -L -o druid "https://github.com/highcard-dev/druid-cli/releases/latest/download/druid" && sudo install -c -m 0755 druid /usr/local/bin
```

Also consider our installation documentation: [https://docs.druid.gg/cli/introduction](https://docs.druid.gg/cli/introduction)

### Windows development build

For local Windows development, use the sibling monorepo tool installer first:

```powershell
cd ..\monorepo
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\install-windows-tools.ps1
```

Then build both CLI binaries from this repository:

```powershell
cd ..\druid-cli
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\build-windows.ps1
.\bin\druid.exe version
```

The Windows build script generates OpenAPI clients, then writes:

- `bin/druid.exe`
- `bin/druid-coldstarter.exe`

## Scroll OCI manifest

The Druid CLI uses a **so called Scroll** to describe container-backed commands.
Expand All @@ -39,6 +61,12 @@ Build all binaries with:
make build
```

On Windows without GNU Make, use:

```powershell
.\scripts\build-windows.ps1
```

Common local flow:

```bash
Expand All @@ -55,7 +83,7 @@ For examples, omit `[name]` so each scroll derives its own id from `scroll.yaml`

### Dependency based command runner

The way commands are handled is described in the `scroll.yaml` and is similar to how Github Actions work, with support for long-running container commands.
The way commands are handled is described in the `scroll.yaml` and is similar to how GitHub Actions work, with support for long-running container commands.
Commands can also depend on each other.

### Web Server
Expand All @@ -69,6 +97,22 @@ Runtime selection is daemon-only: start the daemon with `druid daemon --runtime

Kubernetes runtime support is available with `druid daemon --runtime kubernetes` for in-cluster daemons or out-of-cluster daemons using kubeconfig. It stores daemon scroll state in ConfigMaps, materializes OCI artifacts through `druid worker pull` Jobs, and uses kubelet pod stats for procedure-level traffic checks. See `docs/kubernetes_runtime.md` for kubeconfig, RBAC, and PVC setup.

For the local K3D cluster created by the monorepo, build and import the runtime image, then start the daemon:

```powershell
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\k3d-build-pull-image.ps1
powershell -NoProfile -ExecutionPolicy Bypass -File .\scripts\start-kubernetes-daemon.ps1
```

The daemon listens on:

- management API: `http://127.0.0.1:8081` from the host and `http://host.docker.internal:8081` from K3D worker pods
- public API: `http://127.0.0.1:8082`
- worker callback API: `http://host.docker.internal:8083` from K3D worker pods

Those defaults match the monorepo's generated local operator and SPA environment files.
The Windows daemon script automatically uses `..\monorepo\.tools\kubeconfig-druid-gs.yaml` when no kubeconfig is set.

## Documentation

Read more at https://docs.druid.gg/cli
2 changes: 1 addition & 1 deletion apps/druid-coldstarter/core/services/coldstarter.go
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ func portServiceFromEnv(root string) (*envPortService, error) {
}
if handler != "generic" {
path := filepath.Join(root, filepath.Clean(handler))
if rel, err := filepath.Rel(root, path); err != nil || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, "../") {
if rel, err := filepath.Rel(root, path); err != nil || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(filepath.ToSlash(rel), "../") {
return nil, fmt.Errorf("%s must be generic or a path below DRUID_ROOT", key)
}
}
Expand Down
6 changes: 4 additions & 2 deletions apps/druid/adapters/cli/client/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,12 +337,14 @@ func (s devServer) writeFile(c *fiber.Ctx, raw string) error {

func devFilePath(root string, raw string) (string, error) {
cleaned := filepath.Clean(strings.TrimPrefix(raw, "/"))
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, "../") {
cleanedSlash := filepath.ToSlash(cleaned)
if cleanedSlash == "." || cleanedSlash == ".." || strings.HasPrefix(cleanedSlash, "../") {
return "", fmt.Errorf("invalid path %q", raw)
}
full := filepath.Join(root, filepath.FromSlash(cleaned))
rel, err := filepath.Rel(root, full)
if err != nil || rel == ".." || strings.HasPrefix(rel, "../") {
relSlash := filepath.ToSlash(rel)
if err != nil || relSlash == ".." || strings.HasPrefix(relSlash, "../") {
return "", fmt.Errorf("invalid path %q", raw)
}
return full, nil
Expand Down
49 changes: 44 additions & 5 deletions internal/core/domain/scroll.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"io"
"os"
"path"
"path/filepath"
"regexp"
"strconv"
Expand Down Expand Up @@ -311,7 +312,7 @@ func (sc *Scroll) Validate(strict bool) error {
if mount.Path == "" {
return fmt.Errorf("mount path is required")
}
if !filepath.IsAbs(mount.Path) {
if !mountPathIsAbsolute(mount.Path) {
return fmt.Errorf("mount path %s must be absolute", mount.Path)
}
if mountPaths[mount.Path] {
Expand All @@ -321,11 +322,12 @@ func (sc *Scroll) Validate(strict bool) error {
if mount.SubPath == "" {
continue
}
if filepath.IsAbs(mount.SubPath) {
normalizedSubPath := normalizeMountSubPath(mount.SubPath)
if mountSubPathIsAbsolute(mount.SubPath, normalizedSubPath) {
return fmt.Errorf("mount sub_path %s must be relative", mount.SubPath)
}
clean := filepath.Clean(mount.SubPath)
if clean == ".." || strings.HasPrefix(clean, "../") {
clean := path.Clean(normalizedSubPath)
if clean == ".." || strings.HasPrefix(clean, "../") || mountSubPathHasParentSegment(normalizedSubPath) {
return fmt.Errorf("mount sub_path %s escapes runtime root", mount.SubPath)
}
}
Expand Down Expand Up @@ -363,7 +365,7 @@ func (sc *Scroll) Validate(strict bool) error {
}
}
//scan for files in sc.scrollDir
if sc.scrollDir == "" {
if sc.scrollDir == "" || isVirtualScrollDir(sc.scrollDir) {
return nil
}
entries, err := os.ReadDir(sc.scrollDir)
Expand Down Expand Up @@ -393,6 +395,43 @@ func (sc *Scroll) Validate(strict bool) error {
return nil
}

func normalizeMountSubPath(subPath string) string {
return strings.ReplaceAll(subPath, "\\", "/")
}

func mountPathIsAbsolute(mountPath string) bool {
normalized := strings.ReplaceAll(mountPath, "\\", "/")
return path.IsAbs(normalized) && !isWindowsDrivePath(normalized)
}

func mountSubPathIsAbsolute(subPath string, normalized string) bool {
return filepath.IsAbs(subPath) ||
path.IsAbs(normalized) ||
isWindowsDrivePath(normalized)
}

func mountSubPathHasParentSegment(subPath string) bool {
for _, segment := range strings.Split(subPath, "/") {
if segment == ".." {
return true
}
}
return false
}

func isWindowsDrivePath(value string) bool {
return len(value) >= 2 && value[1] == ':'
}

func isVirtualScrollDir(scrollDir string) bool {
for _, prefix := range []string{"runtime://", "k8s://", "docker-volume://", "docker-bind://"} {
if strings.HasPrefix(scrollDir, prefix) {
return true
}
}
return false
}

func (sc *Scroll) CanColdStart() bool {
return len(sc.Ports) != 0
}
Expand Down
45 changes: 45 additions & 0 deletions internal/core/domain/scroll_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,51 @@ func TestScrollValidateRejectsUnknownServeCommand(t *testing.T) {
}
}

func TestScrollValidateAcceptsPosixContainerMountPathOnWindows(t *testing.T) {
scroll := testScroll(t, &Procedure{
Image: "alpine:3.20",
Command: []string{"true"},
Mounts: []Mount{{
Path: "/data",
SubPath: "public",
}},
})

if err := scroll.Validate(false); err != nil {
t.Fatalf("Validate() error = %v", err)
}
}

func TestScrollValidateRejectsEscapingMountSubPath(t *testing.T) {
tests := []string{
"../private",
`..\private`,
`data\..\private`,
"C:/private",
}

for _, subPath := range tests {
t.Run(subPath, func(t *testing.T) {
scroll := testScroll(t, &Procedure{
Image: "alpine:3.20",
Command: []string{"true"},
Mounts: []Mount{{
Path: "/data",
SubPath: subPath,
}},
})

err := scroll.Validate(false)
if err == nil {
t.Fatal("Validate() error = nil, want error")
}
if !strings.Contains(err.Error(), "mount sub_path") {
t.Fatalf("Validate() error = %q, want mount sub_path error", err.Error())
}
})
}
}

func testScroll(t *testing.T, procedure *Procedure) *Scroll {
t.Helper()
version, err := semver.NewVersion("0.1.0")
Expand Down
2 changes: 1 addition & 1 deletion internal/core/services/coldstarter.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ func (c *ColdStarter) Serve(ctx context.Context) {
handler = lua.NewGenericReturnHandler()
} else {
path := filepath.Join(c.dir, filepath.Clean(port.ColdstarterHandler))
if rel, err := filepath.Rel(c.dir, path); err != nil || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(rel, "../") {
if rel, err := filepath.Rel(c.dir, path); err != nil || rel == ".." || filepath.IsAbs(rel) || strings.HasPrefix(filepath.ToSlash(rel), "../") {
logger.Log().Error("Invalid coldstarter handler path", zap.String("handler", port.ColdstarterHandler))
continue
}
Expand Down
5 changes: 5 additions & 0 deletions internal/core/services/registry/oci_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -211,6 +212,10 @@ func TestResolveAnnotationInfoReadsManifestAnnotations(t *testing.T) {
}

func TestPushPullExecutableDataChunkPreservesMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Windows filesystems do not preserve POSIX executable bits")
}

tmpDir := t.TempDir()
t.Chdir(tmpDir)

Expand Down
11 changes: 7 additions & 4 deletions internal/runtime/docker/storage_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,10 +40,12 @@ func TestRuntimeRootRefUsesBindRoot(t *testing.T) {
}

func TestParseRootRefSupportsVolumeBindAndLocalBindPath(t *testing.T) {
bindRoot := filepath.Join(t.TempDir(), "scroll")
bindRef := "docker-bind://" + bindRoot
cases := map[string]RootRef{
"docker-volume://druid-scroll-data": {Kind: StorageVolume, Source: "druid-scroll-data"},
"docker-bind:///tmp/druid/scroll": {Kind: StorageBind, Source: "/tmp/druid/scroll"},
"/tmp/druid/local": {Kind: StorageBind, Source: "/tmp/druid/local"},
bindRef: {Kind: StorageBind, Source: filepath.Clean(bindRoot)},
bindRoot: {Kind: StorageBind, Source: filepath.Clean(bindRoot)},
}
for input, want := range cases {
got, err := ParseRootRef(input)
Expand Down Expand Up @@ -74,13 +76,14 @@ func TestDockerMountUsesVolumeSubpath(t *testing.T) {
}

func TestDockerMountUsesBindSubpath(t *testing.T) {
got, err := DockerMount("docker-bind:///tmp/druid/scroll", "/site", false, "data/site")
bindRoot := filepath.Join(t.TempDir(), "scroll")
got, err := DockerMount("docker-bind://"+bindRoot, "/site", false, "data/site")
if err != nil {
t.Fatal(err)
}
want := mount.Mount{
Type: mount.TypeBind,
Source: "/tmp/druid/scroll/data/site",
Source: filepath.Join(bindRoot, "data", "site"),
Target: "/site",
BindOptions: &mount.BindOptions{CreateMountpoint: true},
}
Expand Down
4 changes: 4 additions & 0 deletions internal/utils/fs_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"

Expand Down Expand Up @@ -168,6 +169,9 @@ func TestAutoChunkDataDirValidatesSymlinksAfterExpansion(t *testing.T) {
contentDir := filepath.Join(dataDir, "serverfiles", "ShooterGame", "Content")
mkdirAll(t, filepath.Join(contentDir, "Maps", "TheIsland"))
if err := os.Symlink(filepath.Join("Maps", "TheIsland"), filepath.Join(contentDir, "CurrentMap")); err != nil {
if runtime.GOOS == "windows" {
t.Skipf("Windows denied symlink creation: %v", err)
}
t.Fatalf("failed to create symlink: %v", err)
}

Expand Down
65 changes: 65 additions & 0 deletions scripts/build-windows.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
[CmdletBinding()]
param(
[string]$Version = 'dev',
[string]$GoBin = ''
)

$ErrorActionPreference = 'Stop'

$repoRoot = Split-Path -Parent $PSScriptRoot
$monorepoToolEnv = Join-Path (Split-Path -Parent $repoRoot) 'monorepo\.tools\env.ps1'
if (Test-Path $monorepoToolEnv) {
. $monorepoToolEnv
}

function Assert-Command {
param([string]$Name)
if (!(Get-Command $Name -ErrorAction SilentlyContinue)) {
throw "$Name is required. Install Go first, or load the monorepo local tools with: . ..\monorepo\.tools\env.ps1"
}
}

function Invoke-Logged {
param(
[string]$Command,
[string[]]$Arguments
)

Write-Host "> $Command $($Arguments -join ' ')"
& $Command @Arguments
if ($LASTEXITCODE -ne 0) {
throw "$Command exited with $LASTEXITCODE"
}
}

Assert-Command go

if ([string]::IsNullOrWhiteSpace($GoBin)) {
$goPath = (& go env GOPATH).Trim()
$GoBin = Join-Path $goPath 'bin'
}

New-Item -ItemType Directory -Force -Path $GoBin, 'bin' | Out-Null
$env:Path = "$GoBin;$env:Path"

if (!(Get-Command oapi-codegen -ErrorAction SilentlyContinue)) {
Write-Host 'Installing oapi-codegen v2.5.1...'
$env:GOBIN = $GoBin
Invoke-Logged go @('install', 'github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen@v2.5.1')
}

Write-Host 'Generating API clients...'
Invoke-Logged oapi-codegen @('-config', 'api/oapi-codegen.yaml', 'api/openapi.yaml')
Invoke-Logged oapi-codegen @('-config', 'api/dev-oapi-codegen.yaml', 'api/dev.openapi.yaml')
Invoke-Logged oapi-codegen @('-config', 'api/callback-oapi-codegen.yaml', 'api/callback.openapi.yaml')

$env:CGO_ENABLED = '0'
$ldflags = "-X github.com/highcard-dev/daemon/internal.Version=$Version"

Write-Host 'Building bin/druid.exe...'
Invoke-Logged go @('build', '-buildvcs=false', '-ldflags', $ldflags, '-o', './bin/druid.exe', './apps/druid')

Write-Host 'Building bin/druid-coldstarter.exe...'
Invoke-Logged go @('build', '-buildvcs=false', '-ldflags', $ldflags, '-o', './bin/druid-coldstarter.exe', './apps/druid-coldstarter')

Write-Host 'Druid CLI Windows binaries built in ./bin'
Loading
Loading