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
33 changes: 33 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
name: Security

on:
push:
branches:
- main
pull_request:
schedule:
# Catch newly-disclosed vulnerabilities in dependencies even when the
# repo has no other changes. Runs weekly on Monday at 07:00 UTC.
- cron: '0 7 * * 1'

permissions:
contents: read

jobs:
vuln:
name: govulncheck
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6

- name: Setup Go
uses: actions/setup-go@v6
with:
go-version-file: go.mod

- name: Install govulncheck
run: go install golang.org/x/vuln/cmd/govulncheck@latest

- name: Run govulncheck
run: govulncheck ./...
9 changes: 9 additions & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,5 +24,14 @@ jobs:
go mod tidy
make test

- name: Upload coverage to Codecov
# make test writes cover.out via `go test -coverprofile`.
# Do not fail CI if Codecov is unreachable.
uses: codecov/codecov-action@v5
with:
files: ./cover.out
fail_ci_if_error: false
token: ${{ secrets.CODECOV_TOKEN }}

- name: Build Metal Agent (compile check)
run: GOOS=darwin GOARCH=arm64 go build -o /dev/null ./cmd/metal-agent
16 changes: 16 additions & 0 deletions .golangci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@ run:
linters:
default: none
enable:
- bodyclose
- copyloopvar
- dupl
- errcheck
- ginkgolinter
- goconst
- gocyclo
- gosec
- govet
- ineffassign
- lll
Expand All @@ -26,6 +28,20 @@ linters:
rules:
- name: comment-spacings
- name: import-shadowing
gosec:
# Rules disabled for being fundamentally noisy for an operator/CLI
# that intentionally runs user-provided binaries, fetches user-provided
# URLs, and reads/writes user-provided paths. Enforcing these would
# require tagging essentially every subprocess, HTTP, and file-open
# call-site with #nosec annotations and deliver no real security value.
# The remaining gosec rules (weak crypto, hardcoded creds, overflow
# conversions, etc.) remain enabled and do catch genuine bugs.
excludes:
- G107 # variable URL in HTTP request (design intent: model download)
- G204 # subprocess with variable args (design intent: exec runtimes)
- G301 # dir permissions ≤0750 (0755 is correct for shared caches)
- G304 # file inclusion via variable (design intent: read user files)
- G306 # file permissions ≤0600 (0644 is correct for cache/reports)
exclusions:
generated: lax
rules:
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/defilantech/llmkube

go 1.25.0
go 1.25.9

require (
github.com/onsi/ginkgo/v2 v2.28.1
Expand Down
75 changes: 67 additions & 8 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@
# Usage: curl -sSL https://raw.githubusercontent.com/defilantech/LLMKube/main/install.sh | bash
#
# Options (via environment variables):
# LLMKUBE_VERSION - Install specific version (default: latest)
# LLMKUBE_INSTALL_DIR - Installation directory (default: /usr/local/bin)
# LLMKUBE_NO_SUDO - Set to 1 to skip sudo (for user-local installs)
# LLMKUBE_VERSION - Install specific version (default: latest)
# LLMKUBE_INSTALL_DIR - Installation directory (default: /usr/local/bin)
# LLMKUBE_NO_SUDO - Set to 1 to skip sudo (for user-local installs)
# LLMKUBE_SKIP_CHECKSUM - Set to 1 to skip sha256 verification (NOT recommended)

set -e

REPO="defilantech/LLMKube"
BINARY_NAME="llmkube"
BINARY_NAME="llmkube" # binary installed into $INSTALL_DIR
ARCHIVE_PREFIX="LLMKube" # tarball filename prefix emitted by goreleaser
INSTALL_DIR="${LLMKUBE_INSTALL_DIR:-/usr/local/bin}"

# Colors for output
Expand Down Expand Up @@ -66,6 +68,58 @@ get_latest_version() {
fi
}

# Compute sha256 of a file, using whichever tool is available on this host.
compute_sha256() {
local file="$1"
if command -v sha256sum &> /dev/null; then
sha256sum "$file" | awk '{print $1}'
elif command -v shasum &> /dev/null; then
shasum -a 256 "$file" | awk '{print $1}'
else
error "Neither sha256sum nor shasum is available; cannot verify integrity. Install one, or set LLMKUBE_SKIP_CHECKSUM=1 to bypass (NOT recommended)."
fi
}

# Fetch the release checksums.txt and verify the downloaded archive matches.
# Aborts on any failure unless LLMKUBE_SKIP_CHECKSUM=1 is set.
verify_checksum() {
local archive="$1"
local filename="$2"
local checksums_url="$3"
local tmp_dir="$4"

if [[ "${LLMKUBE_SKIP_CHECKSUM:-0}" == "1" ]]; then
warn "LLMKUBE_SKIP_CHECKSUM=1 — skipping integrity verification. Not recommended for production."
return 0
fi

info "Fetching checksums.txt..."
if command -v curl &> /dev/null; then
curl -fsSL "$checksums_url" -o "$tmp_dir/checksums.txt" \
|| error "Failed to fetch checksums.txt from $checksums_url. Set LLMKUBE_SKIP_CHECKSUM=1 to bypass (NOT recommended)."
else
wget -q "$checksums_url" -O "$tmp_dir/checksums.txt" \
|| error "Failed to fetch checksums.txt from $checksums_url. Set LLMKUBE_SKIP_CHECKSUM=1 to bypass (NOT recommended)."
fi

local expected
expected=$(awk -v fname="$filename" '$2 == fname { print $1 }' "$tmp_dir/checksums.txt")
if [[ -z "$expected" ]]; then
error "No checksum entry for $filename in checksums.txt. Set LLMKUBE_SKIP_CHECKSUM=1 to bypass (NOT recommended)."
fi

local actual
actual=$(compute_sha256 "$archive")
if [[ "$actual" != "$expected" ]]; then
error "Checksum mismatch for $filename
expected: $expected
actual: $actual
Refusing to install a binary that does not match its published checksum."
fi

info "Checksum verified (sha256: ${actual:0:12}…)"
}

# Download and install
download_and_install() {
local version="$1"
Expand All @@ -75,23 +129,28 @@ download_and_install() {
# Remove 'v' prefix for filename
local version_num="${version#v}"

# Construct download URL
local filename="${BINARY_NAME}_${version_num}_${os}_${arch}.tar.gz"
# Construct download URL (archive name follows goreleaser's "{{ .ProjectName }}_..."
# template, which resolves to "LLMKube_..." for this project.)
local filename="${ARCHIVE_PREFIX}_${version_num}_${os}_${arch}.tar.gz"
local url="https://github.com/${REPO}/releases/download/${version}/${filename}"
local checksums_url="https://github.com/${REPO}/releases/download/${version}/checksums.txt"

info "Downloading llmkube ${version} for ${os}/${arch}..."

# Create temp directory
local tmp_dir=$(mktemp -d)
trap "rm -rf $tmp_dir" EXIT

# Download
# Download (use -f so HTTP 4xx/5xx fail fast instead of silently saving an error page)
if command -v curl &> /dev/null; then
curl -sSL "$url" -o "$tmp_dir/llmkube.tar.gz" || error "Failed to download from $url"
curl -fsSL "$url" -o "$tmp_dir/llmkube.tar.gz" || error "Failed to download from $url"
else
wget -q "$url" -O "$tmp_dir/llmkube.tar.gz" || error "Failed to download from $url"
fi

# Verify checksum before unpacking any untrusted bytes
verify_checksum "$tmp_dir/llmkube.tar.gz" "$filename" "$checksums_url" "$tmp_dir"

# Extract
info "Extracting..."
tar -xzf "$tmp_dir/llmkube.tar.gz" -C "$tmp_dir"
Expand Down
3 changes: 2 additions & 1 deletion internal/controller/inferenceservice_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -853,7 +853,7 @@ func (r *InferenceServiceReconciler) calculateQueuePosition(ctx context.Context,

for pos, entry := range waitingServices {
if entry.name == isvc.Name && entry.namespace == isvc.Namespace {
return int32(pos + 1), nil
return int32(pos + 1), nil //nolint:gosec // G115: queue index bounded by waitingServices size
}
}

Expand Down Expand Up @@ -928,6 +928,7 @@ func calculateTensorSplit(gpuCount int32, sharding *inferencev1alpha1.GPUShardin
return ""
}

//nolint:gosec // G115: LayerSplit slice length is bounded by user-configured GPU count (≤8 per CRD)
if sharding != nil && len(sharding.LayerSplit) > 0 && int32(len(sharding.LayerSplit)) == gpuCount {
layerCounts := make([]int, len(sharding.LayerSplit))
valid := true
Expand Down
2 changes: 1 addition & 1 deletion pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -589,7 +589,7 @@ func (a *MetalAgent) estimateModelMemory(model *inferencev1alpha1.Model, context
filename := filepath.Base(model.Spec.Source)
localPath := filepath.Join(a.config.ModelStorePath, model.Name, filename)
if info, err := os.Stat(localPath); err == nil {
fileSizeBytes = uint64(info.Size())
fileSizeBytes = uint64(info.Size()) //nolint:gosec // G115: os.FileInfo.Size is non-negative by contract
} else if model.Status.Size != "" {
// Fall back to parsing the human-readable size from model status
parsed, err := parseSize(model.Status.Size)
Expand Down
4 changes: 2 additions & 2 deletions pkg/agent/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func EstimateModelMemory(fileSizeBytes uint64, layerCount, embeddingSize uint64,
if layerEmbed/layerCount != embeddingSize {
return fallbackEstimate(fileSizeBytes)
}
ctx := uint64(contextSize)
ctx := uint64(contextSize) //nolint:gosec // G115: contextSize is CRD-validated ≥128 upstream
product := layerEmbed * ctx
if ctx != 0 && product/ctx != layerEmbed {
return fallbackEstimate(fileSizeBytes)
Expand Down Expand Up @@ -245,7 +245,7 @@ func ResolveMemoryBudget(hardware *inferencev1alpha1.HardwareSpec, agentFraction
}
return ResolvedBudget{
Mode: BudgetModeAbsolute,
Bytes: uint64(qty.Value()),
Bytes: uint64(qty.Value()), //nolint:gosec // G115: guarded positive by the qty.Value() <= 0 check above
Source: "crd-budget",
}, nil
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/agent/registry.go
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ func (r *ServiceRegistry) RegisterEndpoint(
Ports: []corev1.EndpointPort{
{
Name: "http",
Port: int32(port),
Port: int32(port), //nolint:gosec // G115: TCP port numbers 0-65535 fit in int32
Protocol: corev1.ProtocolTCP,
},
},
Expand Down
1 change: 1 addition & 0 deletions pkg/cli/benchmark_catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -406,6 +406,7 @@ func deployModel(
if opts.contextSize > 0 {
inferenceService.Spec.ContextSize = &opts.contextSize
} else if catalogModel.ContextSize > 0 {
//nolint:gosec // G115: catalog ContextSize is always a sensible positive int
contextSize := int32(catalogModel.ContextSize)
inferenceService.Spec.ContextSize = &contextSize
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/benchmark_suite.go
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,7 @@ func runSuiteContextSweep(
fmt.Printf("📊 Testing context size: %d\n", contextSize)

testOpts := *opts
testOpts.contextSize = int32(contextSize)
testOpts.contextSize = int32(contextSize) //nolint:gosec // G115: sweep ContextSizes are user-provided positive ints
testOpts.name = modelID

result := SweepResult{
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/benchmark_sweep.go
Original file line number Diff line number Diff line change
Expand Up @@ -246,7 +246,7 @@ func runContextSweepIteration(
}

testOpts := *opts
testOpts.contextSize = int32(contextSize)
testOpts.contextSize = int32(contextSize) //nolint:gosec // G115: sweep contextSize is user-provided positive int
testOpts.name = modelID

fmt.Printf("🚀 Deploying with context size %d...\n", contextSize)
Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -626,7 +626,7 @@ func applyCatalogDefaults(opts *deployOptions, catalogModel *Model) {
opts.gpuMemory = catalogModel.Resources.GPUMemory
}
if opts.contextSize == 0 && catalogModel.ContextSize > 0 {
opts.contextSize = int32(catalogModel.ContextSize)
opts.contextSize = int32(catalogModel.ContextSize) //nolint:gosec // G115: catalog ContextSize positive
}
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/cli/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,7 +334,7 @@ func TestApplyCatalogDefaults(t *testing.T) {
if opts.gpuMemory != catalogModel.Resources.GPUMemory {
t.Errorf("gpuMemory = %q, want %q", opts.gpuMemory, catalogModel.Resources.GPUMemory)
}
if opts.contextSize != int32(catalogModel.ContextSize) {
if opts.contextSize != int32(catalogModel.ContextSize) { //nolint:gosec // G115: test fixture is small positive int
t.Errorf("contextSize = %d, want %d", opts.contextSize, catalogModel.ContextSize)
}
})
Expand Down
Loading