diff --git a/.vscode/settings.json b/.vscode/settings.json index a162b86..4a87807 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "editor.aiStats.enabled": true, "go.lintTool": "golangci-lint", - "go.lintOnSave": "package" + "go.lintOnSave": "package", + "git.scanRepositories": [ + "common" + ] } \ No newline at end of file diff --git a/Makefile b/Makefile index df72ae1..d0ba914 100644 --- a/Makefile +++ b/Makefile @@ -103,6 +103,12 @@ image: vendor coverage: ##@ Calculate test coverage percentage from coverage.out @go tool cover -func=$(REPORTS_DIR)/coverage.out | grep total | awk '{print $$3}' +debug-setup: ##@ Set up local debug environment + ##@ Generates go.work (Go version taken from go.mod) and symlinks the common module for local debugging + @GO_VERSION=$$(grep -m1 '^go ' go.mod | awk '{print $$2}') && \ + printf 'go %s\n\nuse (\n\t.\n\t/opt/shared/common\n)\n' "$$GO_VERSION" > go.work + ln -sfn /opt/shared/common common + ##@ ##@ Misc commands ##@ diff --git a/README.md b/README.md index d0559bd..93ed83c 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,9 @@ The blueprint is a YAML document mounted at `/etc/k8shell/blueprint.yaml`. It is On startup, `k8shelld` runs all executable files matching the pattern `__init_*` found in `/usr/local/k8shell/system`, sorted alphabetically. Scripts are executed sequentially in the background so they do not block the gRPC server from accepting connections. Each script runs as the workspace user inside its own process group. -A flag file is written to `~/.k8shell/flags/` after a successful run, so scripts are skipped on subsequent container restarts unless the flag is removed. +A flag file is written to `~/.k8shell/flags/` after a successful run, so scripts are skipped on subsequent container restarts unless the flag is removed. A blueprint init script with `always: true` skips this run-once guard and executes on every start; its flag file is still maintained so the script can tell the two cases apart. + +Every init script is run with `K8SHELL_INIT_FIRST_RUN` in its environment — `true` when the script has not previously completed successfully in this workspace, `false` when it is being re-run (only `always` scripts are ever re-run). Init-script progress is tracked in memory and streamed to the PTY display when a new shell session is opened before the scripts finish. diff --git a/go.mod b/go.mod index 2594d92..2f4b464 100644 --- a/go.mod +++ b/go.mod @@ -8,7 +8,7 @@ require ( github.com/fatih/color v1.18.0 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/gorilla/mux v1.8.1 - github.com/k8shell-io/common v0.40.0 + github.com/k8shell-io/common v0.52.0 github.com/k8shell-io/k8shell-go v0.2.3 github.com/pkg/sftp v1.13.10 github.com/rs/zerolog v1.34.0 @@ -20,6 +20,8 @@ require ( ) require ( + github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/buger/jsonparser v1.1.2 // indirect github.com/coreos/go-oidc/v3 v3.16.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect @@ -29,13 +31,16 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.27.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/invopop/jsonschema v0.14.0 // indirect github.com/kr/fs v0.1.0 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/pb33f/ordered-map/v2 v2.3.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/spf13/pflag v1.0.6 // indirect + go.yaml.in/yaml/v4 v4.0.0-rc.2 // indirect golang.org/x/crypto v0.43.0 // indirect golang.org/x/net v0.45.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect diff --git a/go.sum b/go.sum index 6c8664a..a41f109 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,7 @@ +github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= +github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= +github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/coreos/go-oidc/v3 v3.16.0 h1:qRQUCFstKpXwmEjDQTIbyY/5jF00+asXzSkmkoa/mow= github.com/coreos/go-oidc/v3 v3.16.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= @@ -42,18 +46,14 @@ github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= -github.com/k8shell-io/common v0.36.0 h1:fkMH1XfYRLzDxqhIq5/luHusWWPGnCGJUXSTEIhEDzI= -github.com/k8shell-io/common v0.36.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.37.0 h1:whq66WosIJECKErUKZF1RQep7tdpOfI6GtP4hXREpsQ= -github.com/k8shell-io/common v0.37.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.39.0 h1:hfrKZYX2lBonornGrfK35rSY/+5o2sK+SakgUPKDL24= -github.com/k8shell-io/common v0.39.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/common v0.40.0 h1:MhQPVI5oe+JSdRJhWrtvCTJf0MaJDjM58KR7Nq3+lsM= -github.com/k8shell-io/common v0.40.0/go.mod h1:E8dsb9ta4v3ne61AJgtRyTTbTkMMmKeCMAcXD+/9+cY= -github.com/k8shell-io/k8shell-go v0.2.1 h1:6n88ijXkzP39//lIy4ai3XqtpSUXzoa/dVaWogHQYf4= -github.com/k8shell-io/k8shell-go v0.2.1/go.mod h1:j1JHgUIKIbaiRaitx6Pzw37ahqS4Hu9OcM4uvJ7BP4g= -github.com/k8shell-io/k8shell-go v0.2.2 h1:rwLOeIfyq1+l2Jyv0ak/lXZS7x6xbA5ye0yMsRkTonw= -github.com/k8shell-io/k8shell-go v0.2.2/go.mod h1:ZShnaWs7zxUlNwAkIn4lJodFqaB+PB8O+gn2EIscxq8= +github.com/invopop/jsonschema v0.14.0 h1:MHQqLhvpNUZfw+hM3AZDYK7jxO8FZoQeQM77g8iyZjg= +github.com/invopop/jsonschema v0.14.0/go.mod h1:ygm6C2EaVNMBDPpaPlnOA2pFAxBnxGjFlMZABxm9n2I= +github.com/k8shell-io/common v0.49.0 h1:z29iAirMd3GZdz+s/QHI8bSYEcNqmfN5Fq8dCKWMO0w= +github.com/k8shell-io/common v0.49.0/go.mod h1:TcMCP6YSmuVFHi7igxE0hK4kRewdNcfOQmhsx7mJAWY= +github.com/k8shell-io/common v0.50.0 h1:p0sLvpn54K/BrD0khLVt4+go1zKQbGM85+lcu4xuKno= +github.com/k8shell-io/common v0.50.0/go.mod h1:TcMCP6YSmuVFHi7igxE0hK4kRewdNcfOQmhsx7mJAWY= +github.com/k8shell-io/common v0.52.0 h1:DI/BKMJwv5TLV+Ubmbqwu3jxy9prwpTUwKAymIXBOFs= +github.com/k8shell-io/common v0.52.0/go.mod h1:TcMCP6YSmuVFHi7igxE0hK4kRewdNcfOQmhsx7mJAWY= github.com/k8shell-io/k8shell-go v0.2.3 h1:gL7dXDYN4EhWdQvvnY4B2On9Tpb1sZ7G5lO8RtI/nr4= github.com/k8shell-io/k8shell-go v0.2.3/go.mod h1:wWb5gq693qqb48/p5iYrosLG4uNeGOr5dQJiOClIbE8= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= @@ -73,6 +73,8 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/pb33f/ordered-map/v2 v2.3.1 h1:5319HDO0aw4DA4gzi+zv4FXU9UlSs3xGZ40wcP1nBjY= +github.com/pb33f/ordered-map/v2 v2.3.1/go.mod h1:qxFQgd0PkVUtOMCkTapqotNgzRhMPL7VvaHKbd1HnmQ= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/sftp v1.13.10 h1:+5FbKNTe5Z9aspU88DPIKJ9z2KZoaGCu6Sr6kKR/5mU= @@ -104,6 +106,8 @@ go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFh go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.yaml.in/yaml/v4 v4.0.0-rc.2 h1:/FrI8D64VSr4HtGIlUtlFMGsm7H7pWTbj6vOLVZcA6s= +go.yaml.in/yaml/v4 v4.0.0-rc.2/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM= diff --git a/internal/config/config.go b/internal/config/config.go index d714a48..d9f0690 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,6 +29,12 @@ const ( BlueprintPath = "/etc/k8shell/blueprint.yaml" ProfilePath = "/etc/k8shell/profile.yaml" InitScriptsDir = "/usr/local/k8shell/system" + + // EnvInitFirstRun is injected into every init script's environment. It is + // "true" when the script has not previously completed successfully in this + // workspace and "false" when it is being re-run (only InitScript.Always + // scripts are ever re-run). + EnvInitFirstRun = "K8SHELL_INIT_FIRST_RUN" ) // Type aliases for types defined in common. diff --git a/internal/grpc/acquire.go b/internal/grpc/acquire.go index d6a9889..ddfc1f5 100644 --- a/internal/grpc/acquire.go +++ b/internal/grpc/acquire.go @@ -105,6 +105,65 @@ func (s *ShellHandler) AcquireSession(ctx context.Context, req *k8shelldv1.Acqui }, nil } +// ListSessions implements SshServiceServer.ListSessions. +// It returns the set of live PTY sessions that AcquireSession would currently +// accept: no client attached and no unexpired lock held, along with the OS +// user each session runs as. +func (s *ShellHandler) ListSessions(_ context.Context, _ *k8shelldv1.ListSessionsRequest) (*k8shelldv1.ListSessionsResponse, error) { + if !s.grpcApi.allowSessionDetach { + return nil, status.Errorf(codes.PermissionDenied, "session attachment is not enabled on this server") + } + + now := time.Now() + locked := make(map[string]bool) + s.grpcApi.SessionLockStore.Range(func(_, v any) bool { + lk := v.(*sessionLock) + if now.Before(lk.expiresAt) { + locked[lk.sessionId] = true + } + return true + }) + + resp := &k8shelldv1.ListSessionsResponse{} + s.grpcApi.SessionStore.Range(func(_, value any) bool { + session, ok := value.(*SessionData) + if !ok || session.ptyDone == nil || !session.Deleted.IsZero() { + return true + } + select { + case <-session.ptyDone: + return true + default: + } + + session.mu.Lock() + attached := session.attachedSender != nil + detachedAt := session.DetachedAt + session.mu.Unlock() + + if attached || locked[session.Id] { + return true + } + + var detachedAtStr string + if !detachedAt.IsZero() { + detachedAtStr = detachedAt.Format(timeFormat) + } + + resp.Sessions = append(resp.Sessions, &k8shelldv1.AcquirableSession{ + SessionId: session.Id, + Owner: session.user.Username, + CmdShell: session.CmdShell, + Pid: int32(session.Pid), + Created: session.Created.Format(timeFormat), + DetachedAt: detachedAtStr, + }) + return true + }) + + return resp, nil +} + // cleanupExpiredLocks removes session locks that have passed their TTL. func (a *GRPCService) cleanupExpiredLocks() { now := time.Now() diff --git a/internal/grpc/detachable.go b/internal/grpc/detachable.go index d4c69f4..5d018d1 100644 --- a/internal/grpc/detachable.go +++ b/internal/grpc/detachable.go @@ -528,6 +528,17 @@ func (a *GRPCService) runRESTAttachLoop(session *SessionData, conn net.Conn, det // - OSC 10 ; ... ST/BEL — foreground colour report // - OSC 11 ; ... ST/BEL — background colour report // - DCS ... ST — device control string responses (e.g. XTGETTCAP) +// +// Also stripped: the shell's PROMPT_EOL_MARK ("%" or "#" wrapped in SGR +// styling, padded with spaces to the terminal width, then a bare CR — e.g. +// zsh's default "%B%S%#%s%b"). It is rendered by overwriting it with the +// next prompt line, which relies on the replaying terminal being exactly as +// wide as the terminal the shell originally sized its padding for. Replayed +// scrollback is frequently viewed in a differently-sized terminal (the +// session's PTY width at attach time lags the reattaching client's actual +// width — see cmd/kbox/attach.go), so the overwrite trick fails and the +// mark is left stranded above the prompt. Since it carries no information +// for a new client, it's dropped like the other noise above. func stripTerminalQueryResponses(data []byte) []byte { if len(data) == 0 { return data @@ -544,6 +555,9 @@ func stripTerminalQueryResponses(data []byte) []byte { switch data[i+1] { case '[': // CSI end, ok := scanCSIResponse(data, i) + if !ok { + end, ok = scanEOLMark(data, i) + } if ok { i = end } else { @@ -611,6 +625,69 @@ func scanCSIResponse(data []byte, i int) (end int, ok bool) { return i, false } +// scanEOLMark matches a shell's PROMPT_EOL_MARK sequence: one or more SGR +// (colour/style) escapes, the marker character ('%' or '#'), one or more +// closing SGR escapes, a run of padding spaces, and a bare CR (not followed +// by LF — that would just be a normal line ending). Returns the index past +// the CR on success, or (i, false) if the pattern doesn't fully match, +// leaving the caller to emit data[i] unchanged. +// +// i must point at the ESC of what is expected to be the first SGR escape. +func scanEOLMark(data []byte, i int) (end int, ok bool) { + j := i + opened := 0 + for { + next, sgrOK := scanSGR(data, j) + if !sgrOK { + break + } + j = next + opened++ + } + if opened == 0 { + return i, false + } + if j >= len(data) || (data[j] != '%' && data[j] != '#') { + return i, false + } + j++ // consume the marker character + + for { + next, sgrOK := scanSGR(data, j) + if !sgrOK { + break + } + j = next + } + + for j < len(data) && data[j] == ' ' { + j++ + } + + if j >= len(data) || data[j] != '\r' { + return i, false + } + j++ // consume the CR; a following LF (if any) is left untouched + + return j, true +} + +// scanSGR returns the index past an SGR escape (ESC [ params m) starting at +// j, or (j, false) if there isn't one there. +func scanSGR(data []byte, j int) (end int, ok bool) { + if j+1 >= len(data) || data[j] != 0x1b || data[j+1] != '[' { + return j, false + } + k := j + 2 + for k < len(data) && ((data[k] >= 0x30 && data[k] <= 0x3f) || data[k] == ';') { + k++ + } + if k >= len(data) || data[k] != 'm' { + return j, false + } + return k + 1, true +} + // scanOSCResponse returns the index past the end of an OSC sequence that // starts with 10; or 11; (colour query responses), terminated by BEL or ST. func scanOSCResponse(data []byte, i int) (end int, ok bool) { diff --git a/internal/grpc/detachable_test.go b/internal/grpc/detachable_test.go new file mode 100644 index 0000000..c4c0313 --- /dev/null +++ b/internal/grpc/detachable_test.go @@ -0,0 +1,87 @@ +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +package grpc + +import "testing" + +func TestStripTerminalQueryResponses_EOLMark(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "zsh default PROMPT_EOL_MARK (bold+standout %, padded, bare CR)", + in: "hello\x1b[1m\x1b[7m%\x1b[27m\x1b[m \r[~]$ ", + want: "hello[~]$ ", + }, + { + name: "single SGR wrap with # marker (root prompt)", + in: "out\x1b[7m#\x1b[27m \r$ ", + want: "out$ ", + }, + { + name: "no padding spaces still strips", + in: "x\x1b[7m%\x1b[27m\r$ ", + want: "x$ ", + }, + { + name: "CRLF is left untouched (normal line ending, not the EOL mark)", + in: "line one\r\nline two\r\n", + want: "line one\r\nline two\r\n", + }, + { + name: "bare % without SGR wrapping is not stripped (e.g. a real progress indicator)", + in: "50% \rdone", + want: "50% \rdone", + }, + { + name: "styled text without the marker char is left alone", + in: "\x1b[1mBOLD\x1b[0m text", + want: "\x1b[1mBOLD\x1b[0m text", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := string(stripTerminalQueryResponses([]byte(c.in))) + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} + +func TestStripTerminalQueryResponses_ExistingCSIFiltering(t *testing.T) { + cases := []struct { + name string + in string + want string + }{ + { + name: "CPR response stripped", + in: "before\x1b[24;80Rafter", + want: "beforeafter", + }, + { + name: "primary DA response stripped", + in: "before\x1b[?1;2cafter", + want: "beforeafter", + }, + { + name: "OSC 11 colour response stripped (BEL terminated)", + in: "before\x1b]11;rgb:0000/0000/0000\x07after", + want: "beforeafter", + }, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + got := string(stripTerminalQueryResponses([]byte(c.in))) + if got != c.want { + t.Errorf("got %q, want %q", got, c.want) + } + }) + } +} diff --git a/internal/grpc/ssh.go b/internal/grpc/ssh.go index ef69302..57b7875 100644 --- a/internal/grpc/ssh.go +++ b/internal/grpc/ssh.go @@ -49,6 +49,10 @@ func (s *SshServiceServer) AcquireSession(ctx context.Context, req *k8shelldv1.A return s.shell.AcquireSession(ctx, req) } +func (s *SshServiceServer) ListSessions(ctx context.Context, req *k8shelldv1.ListSessionsRequest) (*k8shelldv1.ListSessionsResponse, error) { + return s.shell.ListSessions(ctx, req) +} + func (s *SshServiceServer) Exec(stream grpc.BidiStreamingServer[k8shelldv1.ExecRequest, k8shelldv1.ExecResponse]) error { return s.exec.Exec(stream) } diff --git a/internal/grpc/system.go b/internal/grpc/system.go index a8391ec..38bba82 100644 --- a/internal/grpc/system.go +++ b/internal/grpc/system.go @@ -17,8 +17,17 @@ import ( "github.com/rs/zerolog" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" + "gopkg.in/yaml.v3" ) +// logStreamPollInterval is how often GetLogsStream polls the in-memory log +// store for new entries while following (mirrors the REST /logs handler). +const logStreamPollInterval = 100 * time.Millisecond + +// defaultLogPageLimit is the page size GetLogsPage falls back to when the +// caller doesn't specify one (mirrors the REST /logs handler's default). +const defaultLogPageLimit = 100 + // SystemServiceServer is the gRPC server for the system service type SystemServiceServer struct { grpcApi *GRPCService @@ -124,3 +133,139 @@ func (s *SystemServiceServer) SystemInfo(ctx context.Context, return k8shelld.SystemInfoToProto(&systemInfo), nil } + +// SystemInfoHistory returns historical system/mount/docker usage samples +// for charting. See the proto comment on SystemInfoHistoryRequest for the +// precedence of range vs from/to and how step coarsening works. +func (s *SystemServiceServer) SystemInfoHistory(_ context.Context, + req *k8shelldv1.SystemInfoHistoryRequest) (*k8shelldv1.SystemInfoHistoryResponse, error) { + + query := k8shelld.SystemInfoHistoryQuery{ + From: req.GetFrom(), + To: req.GetTo(), + Range: req.GetRange(), + Step: req.GetStep(), + } + + hist, err := s.grpcApi.sysInfo.GetSystemInfoHistory(query) + if err != nil { + return nil, status.Errorf(codes.InvalidArgument, "invalid system info history request: %v", err) + } + + return k8shelld.SystemInfoHistoryToProto(hist), nil +} + +// GetLogsStream streams k8shelld daemon logs (the same logs shown by +// `kbox logs`). With Follow=false it sends the currently buffered entries +// and closes the stream; with Follow=true it keeps streaming new entries as +// they are produced until the client cancels. +func (s *SystemServiceServer) GetLogsStream(req *k8shelldv1.SystemLogsStreamRequest, + stream k8shelldv1.SystemService_GetLogsStreamServer) error { + + component := req.GetComponent() + level := k8shelld.LogLevelFromProto(req.GetLevel()) + follow := req.GetFollow() + + send := func(entries []logger.LogEntry) error { + for _, entry := range entries { + if sendErr := stream.Send(logEntryToProto(entry)); sendErr != nil { + return status.Errorf(codes.Canceled, "client canceled") + } + } + return nil + } + + var backlog []logger.LogEntry + var sinceID int64 + if n := req.GetLastN(); n > 0 { + backlog, _ = logger.GetLogsBefore(0, int(n), component, level) + if len(backlog) > 0 { + sinceID = backlog[len(backlog)-1].ID + } + } else { + backlog, sinceID = logger.GetLogsSince(0, component, level) + } + if err := send(backlog); err != nil { + return err + } + + if !follow { + return nil + } + + ctx := stream.Context() + for { + select { + case <-ctx.Done(): + return status.Errorf(codes.Canceled, "client canceled") + default: + entries, newSinceID := logger.GetLogsSince(sinceID, component, level) + if err := send(entries); err != nil { + return err + } + sinceID = newSinceID + + time.Sleep(logStreamPollInterval) + } + } +} + +// GetLogsPage returns one page of k8shelld daemon logs strictly older than +// the requested BeforeId, for "load more" / infinite-scroll style backward +// pagination independent of GetLogsStream's live tail. +func (s *SystemServiceServer) GetLogsPage(ctx context.Context, + req *k8shelldv1.GetLogsPageRequest) (*k8shelldv1.GetLogsPageResponse, error) { + + if req.GetBeforeId() < 0 { + return nil, status.Errorf(codes.InvalidArgument, "before_id must not be negative") + } + if req.GetLimit() < 0 { + return nil, status.Errorf(codes.InvalidArgument, "limit must not be negative") + } + + limit := int(req.GetLimit()) + if limit == 0 { + limit = defaultLogPageLimit + } + + component := req.GetComponent() + level := k8shelld.LogLevelFromProto(req.GetLevel()) + + entries, hasMore := logger.GetLogsBefore(req.GetBeforeId(), limit, component, level) + + resp := &k8shelldv1.GetLogsPageResponse{ + Entries: make([]*k8shelldv1.SystemLogsStreamResponse, 0, len(entries)), + HasMore: hasMore, + } + for _, entry := range entries { + resp.Entries = append(resp.Entries, logEntryToProto(entry)) + } + return resp, nil +} + +// GetBlueprint returns the raw blueprint YAML content this workspace was +// created from. +func (s *SystemServiceServer) GetBlueprint(_ context.Context, + _ *k8shelldv1.GetBlueprintRequest) (*k8shelldv1.GetBlueprintResponse, error) { + + if s.grpcApi.blueprint == nil { + return nil, status.Errorf(codes.NotFound, "blueprint not available") + } + + raw, err := yaml.Marshal(s.grpcApi.blueprint) + if err != nil { + return nil, status.Errorf(codes.Internal, "failed to marshal blueprint: %v", err) + } + + return &k8shelldv1.GetBlueprintResponse{Blueprint: raw}, nil +} + +func logEntryToProto(entry logger.LogEntry) *k8shelldv1.SystemLogsStreamResponse { + return &k8shelldv1.SystemLogsStreamResponse{ + Id: entry.ID, + Time: entry.Timestamp, + Component: entry.Component, + Level: entry.Level, + Message: entry.Message, + } +} diff --git a/internal/logger/logger.go b/internal/logger/logger.go index 430df28..0d58ad0 100644 --- a/internal/logger/logger.go +++ b/internal/logger/logger.go @@ -7,6 +7,7 @@ import ( "encoding/json" "fmt" "io" + "sort" "sync" clogger "github.com/k8shell-io/common/pkg/logger" @@ -17,19 +18,26 @@ const LOGSTORE_CAPACITY = 10000 // Shared memory log store var logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, LOGSTORE_CAPACITY), + entries: make([]LogEntry, 0, LOGSTORE_CAPACITY), cap: LOGSTORE_CAPACITY, } // MemoryLogStore is an in-memory log store that implements io.Writer type MemoryLogStore struct { mu sync.Mutex - entries []logEntry + entries []LogEntry cap int + nextID int64 } -// logEntry represents a single log entry -type logEntry struct { +// LogEntry represents a single log entry. +// +// ID is a per-store, monotonically increasing sequence number assigned on +// write (starting at 1). Unlike a slice index it never shifts as older +// entries are evicted from the buffer, so it's safe to hold onto as a +// pagination cursor across calls. +type LogEntry struct { + ID int64 `json:"id"` Timestamp string `json:"time"` Component string `json:"component"` Level string `json:"level"` @@ -45,7 +53,7 @@ func NewLogger(component string) *zerolog.Logger { // Write implements the io.Writer interface for MemoryLogStore func (s *MemoryLogStore) Write(p []byte) (int, error) { - var entry logEntry + var entry LogEntry if err := json.Unmarshal(p, &entry); err != nil { return 0, fmt.Errorf("failed to unmarshal log entry: %w", err) @@ -54,6 +62,9 @@ func (s *MemoryLogStore) Write(p []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() + s.nextID++ + entry.ID = s.nextID + if len(s.entries) >= s.cap { s.entries = s.entries[len(s.entries)-s.cap:] } @@ -82,28 +93,67 @@ func InitLogLevel(level string) error { return nil } -// GetLogsSince returns new log entries from the given offset. -func GetLogsSince(offset int, component, level string) ([]logEntry, int) { +// GetLogsSince returns log entries with ID > sinceID (in ID order), along +// with the highest entry ID currently in the store — pass that back in as +// sinceID on the next call to continue tailing without gaps or repeats, +// regardless of how many entries have since been evicted from the buffer. +// sinceID <= 0 returns every entry currently buffered. +func GetLogsSince(sinceID int64, component, level string) ([]LogEntry, int64) { logStore.mu.Lock() defer logStore.mu.Unlock() - if offset >= len(logStore.entries) { - return nil, len(logStore.entries) + lastID := sinceID + if n := len(logStore.entries); n > 0 && logStore.entries[n-1].ID > lastID { + lastID = logStore.entries[n-1].ID } - if offset < 0 { - offset = len(logStore.entries) + offset - if offset < 0 { - offset = 0 + var logs []LogEntry + for _, entry := range logStore.entries { + if entry.ID <= sinceID { + continue + } + if (component == "" || entry.Component == component) && (level == "" || entry.Level == level) { + logs = append(logs, entry) } } + return logs, lastID +} - var logs []logEntry - for i := offset; i < len(logStore.entries); i++ { +// GetLogsBefore returns up to limit log entries with ID < beforeID, oldest +// matching entry first — the "before" half of cursor-based pagination: +// pass the ID of the oldest entry from the previous page back in as +// beforeID to load the page before it. beforeID <= 0 starts from the most +// recent entry (there is no valid entry ID 0, since IDs are assigned +// starting at 1, so it doubles as the "no cursor yet" sentinel for the +// first page). The returned bool reports whether older entries remain +// unscanned in the buffer, i.e. whether a further "load more" call could +// return anything. +func GetLogsBefore(beforeID int64, limit int, component, level string) ([]LogEntry, bool) { + logStore.mu.Lock() + defer logStore.mu.Unlock() + + end := len(logStore.entries) + if beforeID > 0 { + end = sort.Search(end, func(i int) bool { + return logStore.entries[i].ID >= beforeID + }) + } + + var logs []LogEntry + hasMore := false + for i := end - 1; i >= 0; i-- { + if len(logs) == limit { + hasMore = true + break + } entry := logStore.entries[i] if (component == "" || entry.Component == component) && (level == "" || entry.Level == level) { logs = append(logs, entry) } } - return logs, len(logStore.entries) + + for i, j := 0, len(logs)-1; i < j; i, j = i+1, j-1 { + logs[i], logs[j] = logs[j], logs[i] + } + return logs, hasMore } diff --git a/internal/logger/logger_test.go b/internal/logger/logger_test.go index 4d1cb9d..df16245 100644 --- a/internal/logger/logger_test.go +++ b/internal/logger/logger_test.go @@ -24,11 +24,11 @@ func TestMemoryLogStore_Initialization(t *testing.T) { func TestMemoryLogStore_Write(t *testing.T) { // Create a test store store := &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - logData := logEntry{ + logData := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test-component", Level: "info", @@ -63,7 +63,7 @@ func TestMemoryLogStore_Write(t *testing.T) { func TestMemoryLogStore_Write_InvalidJSON(t *testing.T) { store := &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } @@ -77,13 +77,13 @@ func TestMemoryLogStore_Write_InvalidJSON(t *testing.T) { func TestMemoryLogStore_CapacityLimit(t *testing.T) { capacity := 5 store := &MemoryLogStore{ - entries: make([]logEntry, 0, capacity), + entries: make([]LogEntry, 0, capacity), cap: capacity, } // Write more entries than capacity for i := 0; i < 10; i++ { - logData := logEntry{ + logData := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test", Level: "info", @@ -129,12 +129,12 @@ func TestGetLogsSince_Basic(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } // Add some test entries - entries := []logEntry{ + entries := []LogEntry{ {Timestamp: "2025-12-18T10:00:00Z", Component: "comp1", Level: "info", Message: "msg1"}, {Timestamp: "2025-12-18T10:01:00Z", Component: "comp2", Level: "debug", Message: "msg2"}, {Timestamp: "2025-12-18T10:02:00Z", Component: "comp1", Level: "error", Message: "msg3"}, @@ -166,13 +166,13 @@ func TestGetLogsSince_WithOffset(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } // Add test entries for i := 0; i < 5; i++ { - entry := logEntry{ + entry := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test", Level: "info", @@ -200,20 +200,20 @@ func TestGetLogsSince_WithOffset(t *testing.T) { } } -func TestGetLogsSince_NegativeOffset(t *testing.T) { +func TestGetLogsBefore_MostRecent(t *testing.T) { // Save and restore original logStore originalStore := logStore defer func() { logStore = originalStore }() // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } // Add test entries for i := 0; i < 5; i++ { - entry := logEntry{ + entry := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test", Level: "info", @@ -226,19 +226,107 @@ func TestGetLogsSince_NegativeOffset(t *testing.T) { } } - // -2 should get last 2 entries - logs, newOffset := GetLogsSince(-2, "", "") + // beforeID <= 0 should get the last 2 entries, oldest-first + logs, hasMore := GetLogsBefore(0, 2, "", "") if len(logs) != 2 { - t.Errorf("expected 2 logs with offset -2, got %d", len(logs)) + t.Fatalf("expected 2 logs, got %d", len(logs)) } - if newOffset != 5 { - t.Errorf("expected newOffset 5, got %d", newOffset) + if !hasMore { + t.Error("expected hasMore=true, since 3 older entries remain") } - if logs[0].Message != "D" { - t.Errorf("expected first log message 'D', got '%s'", logs[0].Message) + if logs[0].Message != "D" || logs[1].Message != "E" { + t.Errorf("expected [D, E], got [%s, %s]", logs[0].Message, logs[1].Message) + } +} + +func TestGetLogsBefore_Pagination(t *testing.T) { + // Save and restore original logStore + originalStore := logStore + defer func() { logStore = originalStore }() + + // Create test store + logStore = &MemoryLogStore{ + entries: make([]LogEntry, 0, 10), + cap: 10, + } + + // Add 5 entries: A..E, IDs 1..5 + for i := 0; i < 5; i++ { + entry := LogEntry{ + Timestamp: "2025-12-18T10:00:00Z", + Component: "test", + Level: "info", + Message: string(rune('A' + i)), + } + data, _ := json.Marshal(entry) + _, err := logStore.Write(data) + if err != nil { + t.Fatalf("failed to write log entry: %v", err) + } + } + + // Page 1: most recent 2 -> [D, E], more remain (A, B, C) + page1, hasMore1 := GetLogsBefore(0, 2, "", "") + if len(page1) != 2 || page1[0].Message != "D" || page1[1].Message != "E" || !hasMore1 { + t.Fatalf("unexpected page1: %+v hasMore=%v", page1, hasMore1) + } + + // Page 2: before the oldest entry of page1 -> [B, C], A remains + page2, hasMore2 := GetLogsBefore(page1[0].ID, 2, "", "") + if len(page2) != 2 || page2[0].Message != "B" || page2[1].Message != "C" || !hasMore2 { + t.Fatalf("unexpected page2: %+v hasMore=%v", page2, hasMore2) + } + + // Page 3: before the oldest entry of page2 -> [A], nothing left + page3, hasMore3 := GetLogsBefore(page2[0].ID, 2, "", "") + if len(page3) != 1 || page3[0].Message != "A" || hasMore3 { + t.Fatalf("unexpected page3: %+v hasMore=%v", page3, hasMore3) + } +} + +// TestGetLogsBefore_StableAcrossEviction verifies that a cursor obtained +// before older entries are evicted from the buffer still lands in the +// right place afterwards — the whole point of using a stable sequence id +// instead of a slice index, which would shift under eviction. +func TestGetLogsBefore_StableAcrossEviction(t *testing.T) { + // Save and restore original logStore + originalStore := logStore + defer func() { logStore = originalStore }() + + capacity := 5 + logStore = &MemoryLogStore{ + entries: make([]LogEntry, 0, capacity), + cap: capacity, + } + + // Write 10 entries (IDs 1..10); with capacity 5 the store retains the + // last 6 (see TestMemoryLogStore_CapacityLimit), i.e. messages E..J + // with IDs 5..10. + for i := 0; i < 10; i++ { + entry := LogEntry{ + Timestamp: "2025-12-18T10:00:00Z", + Component: "test", + Level: "info", + Message: string(rune('A' + i)), + } + data, _ := json.Marshal(entry) + if _, err := logStore.Write(data); err != nil { + t.Fatalf("Write failed at iteration %d: %v", i, err) + } + } + + // Cursor id 8 ("H") predates eviction of "A"-"D"; paging before it must + // still resolve correctly against what remains in the buffer: entries + // with ID < 8 that are still present are E(5), F(6), G(7). + logs, hasMore := GetLogsBefore(8, 10, "", "") + if len(logs) != 3 || logs[0].Message != "E" || logs[1].Message != "F" || logs[2].Message != "G" { + t.Fatalf("expected [E, F, G], got %+v", logs) + } + if hasMore { + t.Error("expected hasMore=false, buffer has no entries older than E") } } @@ -249,11 +337,11 @@ func TestGetLogsSince_ComponentFilter(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - entries := []logEntry{ + entries := []LogEntry{ {Timestamp: "2025-12-18T10:00:00Z", Component: "comp1", Level: "info", Message: "msg1"}, {Timestamp: "2025-12-18T10:01:00Z", Component: "comp2", Level: "info", Message: "msg2"}, {Timestamp: "2025-12-18T10:02:00Z", Component: "comp1", Level: "info", Message: "msg3"}, @@ -292,11 +380,11 @@ func TestGetLogsSince_LevelFilter(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - entries := []logEntry{ + entries := []LogEntry{ {Timestamp: "2025-12-18T10:00:00Z", Component: "comp1", Level: "info", Message: "msg1"}, {Timestamp: "2025-12-18T10:01:00Z", Component: "comp1", Level: "error", Message: "msg2"}, {Timestamp: "2025-12-18T10:02:00Z", Component: "comp1", Level: "info", Message: "msg3"}, @@ -333,11 +421,11 @@ func TestGetLogsSince_CombinedFilters(t *testing.T) { // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - entries := []logEntry{ + entries := []LogEntry{ {Timestamp: "2025-12-18T10:00:00Z", Component: "comp1", Level: "info", Message: "msg1"}, {Timestamp: "2025-12-18T10:01:00Z", Component: "comp2", Level: "error", Message: "msg2"}, {Timestamp: "2025-12-18T10:02:00Z", Component: "comp1", Level: "error", Message: "msg3"}, @@ -363,20 +451,20 @@ func TestGetLogsSince_CombinedFilters(t *testing.T) { } } -func TestGetLogsSince_OffsetBeyondLength(t *testing.T) { +func TestGetLogsSince_SinceIDBeyondLatest(t *testing.T) { // Save and restore original logStore originalStore := logStore defer func() { logStore = originalStore }() // Create test store logStore = &MemoryLogStore{ - entries: make([]logEntry, 0, 10), + entries: make([]LogEntry, 0, 10), cap: 10, } - // Add 3 entries + // Add 3 entries (IDs 1..3) for i := 0; i < 3; i++ { - entry := logEntry{ + entry := LogEntry{ Timestamp: "2025-12-18T10:00:00Z", Component: "test", Level: "info", @@ -389,14 +477,16 @@ func TestGetLogsSince_OffsetBeyondLength(t *testing.T) { } } - logs, newOffset := GetLogsSince(10, "", "") + // A sinceID beyond the latest entry id has nothing new to return, and + // the cursor doesn't move backward. + logs, lastID := GetLogsSince(10, "", "") if logs != nil { - t.Errorf("expected nil logs for offset beyond length, got %d logs", len(logs)) + t.Errorf("expected nil logs for sinceID beyond the latest entry, got %d logs", len(logs)) } - if newOffset != 3 { - t.Errorf("expected newOffset 3, got %d", newOffset) + if lastID != 10 { + t.Errorf("expected lastID to stay at 10, got %d", lastID) } } diff --git a/internal/server/restapi.go b/internal/server/restapi.go index eed1185..afe6d42 100644 --- a/internal/server/restapi.go +++ b/internal/server/restapi.go @@ -449,26 +449,59 @@ func (a *RESTService) GetSplash(w http.ResponseWriter, r *http.Request) { } } +// defaultLogPageSize is the page size used for cursor-based pagination +// (the "before" query parameter) when the caller doesn't specify a 'limit'. +const defaultLogPageSize = 100 + +// GetLogs serves k8shelld daemon logs (the same logs shown by `kbox logs`). +// +// Query parameters: +// - component, level: optional filters +// - follow=true: keep the connection open and stream new entries as they +// arrive, seeded with the last 'limit' entries (or everything currently +// buffered if 'limit' is unset) +// - before=: cursor-based pagination — returns up to 'limit' entries +// older than the given entry id, oldest-first, for "load more" style +// paging that stays correct even as the in-memory buffer evicts old +// entries underneath it. Omit (or 0) for the most recent page. The +// response carries an 'X-Log-Has-More' header (true/false) indicating +// whether an older page exists; the next 'before' cursor is simply the +// 'id' of the oldest entry in the response body. +// - limit (alias: lastN, kept for backward compatibility): page size for +// 'before' pagination, or entry count for the initial/follow backlog. +// Omitted with no 'before' returns everything currently buffered. func (a *RESTService) GetLogs(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/x-ndjson") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") - component := r.URL.Query().Get("component") - level := r.URL.Query().Get("level") - follow := r.URL.Query().Get("follow") == "true" - lastN := r.URL.Query().Get("lastN") + q := r.URL.Query() + component := q.Get("component") + level := q.Get("level") + follow := q.Get("follow") == "true" - var err error - var n int - if lastN != "" { - n, err = strconv.Atoi(lastN) + limitStr := q.Get("limit") + if limitStr == "" { + limitStr = q.Get("lastN") + } + var limit int + if limitStr != "" { + n, err := strconv.Atoi(limitStr) if err != nil || n < 0 { - http.Error(w, "Invalid 'lastN' parameter", http.StatusBadRequest) + http.Error(w, "Invalid 'limit' parameter", http.StatusBadRequest) return } - } else { - n = 0 + limit = n + } + + var beforeID int64 + if beforeStr := q.Get("before"); beforeStr != "" { + id, err := strconv.ParseInt(beforeStr, 10, 64) + if err != nil || id < 0 { + http.Error(w, "Invalid 'before' parameter", http.StatusBadRequest) + return + } + beforeID = id } flusher, ok := w.(http.Flusher) @@ -477,37 +510,62 @@ func (a *RESTService) GetLogs(w http.ResponseWriter, r *http.Request) { return } - offset := 0 - if n > 0 { - offset = -n + writeEntries := func(entries []logger.LogEntry) { + for _, entry := range entries { + b, err := json.Marshal(entry) + if err != nil { + a.logger.Error().Err(err).Msg("failed to encode log entry") + continue + } + _, _ = fmt.Fprintln(w, string(b)) + } } - for { - select { - case <-r.Context().Done(): - return - default: - entries, newOffset := logger.GetLogsSince(offset, component, level) - - for _, entry := range entries { - b, err := json.Marshal(entry) - if err != nil { - a.logger.Error().Err(err).Msg("failed to encode log entry") - continue - } - _, _ = fmt.Fprintln(w, string(b)) + if follow { + var backlog []logger.LogEntry + var sinceID int64 + if limit > 0 { + backlog, _ = logger.GetLogsBefore(0, limit, component, level) + if len(backlog) > 0 { + sinceID = backlog[len(backlog)-1].ID } + } else { + backlog, sinceID = logger.GetLogsSince(0, component, level) + } + writeEntries(backlog) + flusher.Flush() - flusher.Flush() - offset = newOffset - - if !follow { + for { + select { + case <-r.Context().Done(): return + default: + entries, newSinceID := logger.GetLogsSince(sinceID, component, level) + if len(entries) > 0 { + writeEntries(entries) + flusher.Flush() + } + sinceID = newSinceID + time.Sleep(100 * time.Millisecond) } + } + } - time.Sleep(100 * time.Millisecond) + var entries []logger.LogEntry + if beforeID > 0 || limit > 0 { + pageLimit := limit + if pageLimit == 0 { + pageLimit = defaultLogPageSize } + var hasMore bool + entries, hasMore = logger.GetLogsBefore(beforeID, pageLimit, component, level) + w.Header().Set("X-Log-Has-More", strconv.FormatBool(hasMore)) + } else { + entries, _ = logger.GetLogsSince(0, component, level) } + + writeEntries(entries) + flusher.Flush() } func (a *RESTService) ValidateK8shelldFile(w http.ResponseWriter, r *http.Request) { diff --git a/internal/server/server.go b/internal/server/server.go index 34a975f..877ecce 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -311,11 +311,23 @@ func (s *Server) runInitScripts( } s.initTracker.RegisterAll(scriptNames) + // The blueprint is the source of truth for per-script behavior. The ordinal + // encoded in each file name maps back to a blueprint.InitScripts position + // (see commonmodels.ParseInitScriptFileName, which returns a zero-based idx). + var initScripts []commonmodels.InitScript + if s.blueprint != nil { + initScripts = s.blueprint.InitScripts + } + s.logger.Info().Msgf("Running %d init scripts sequentially in background.", len(scripts)) go func() { for _, sp := range scripts { - s.logger.Info().Msgf("Running %s.", sp) - if err := s.runScriptHelper(scriptsDir, sp, flagDir, []string{}); err != nil { + always := false + if idx, _, ok := commonmodels.ParseInitScriptFileName(sp); ok && idx >= 0 && idx < len(initScripts) { + always = initScripts[idx].Always + } + s.logger.Info().Msgf("Running %s (always=%t).", sp, always) + if err := s.runScriptHelper(scriptsDir, sp, flagDir, always, []string{}); err != nil { s.logger.Error().Msgf("Failed to run init script %s: %v", sp, err) } } @@ -328,17 +340,25 @@ func (s *Server) runInitScripts( return nil } -// runScriptHelper executes a script with flag handling -func (s *Server) runScriptHelper(scriptsDir, scriptPath string, flagDir string, envVars []string) error { +// runScriptHelper executes a script with flag handling. When always is true the +// run-once guard is skipped so the script executes on every workspace start; the +// flag file is still maintained so the script can tell a first run from a rerun +// via the config.EnvInitFirstRun environment variable. +func (s *Server) runScriptHelper(scriptsDir, scriptPath string, flagDir string, always bool, envVars []string) error { scriptName := filepath.Base(scriptPath) flagFile := filepath.Join(flagDir, scriptName) - if _, err := os.Stat(flagFile); err == nil { + _, flagErr := os.Stat(flagFile) + hasRunBefore := flagErr == nil + + if hasRunBefore && !always { s.logger.Info().Msgf("Flag file exists for %s. Skipping execution.", scriptName) s.initTracker.SetCompleted(scriptName, false) return nil } + envVars = append(envVars, fmt.Sprintf("%s=%t", config.EnvInitFirstRun, !hasRunBefore)) + s.initTracker.SetRunning(scriptName) return s.runScript(scriptsDir, scriptName, flagFile, envVars) } diff --git a/internal/system/history.go b/internal/system/history.go new file mode 100644 index 0000000..d871827 --- /dev/null +++ b/internal/system/history.go @@ -0,0 +1,367 @@ +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +package system + +import ( + "fmt" + "sync" + "time" + + "github.com/k8shell-io/common/pkg/api/client/k8shelld" +) + +const ( + // HistoryRetention is how long historical system/mount/docker usage + // samples are kept in memory before aging out. + HistoryRetention = 24 * time.Hour + + // MaxHistoryPoints caps the number of points returned for a single + // series in one SystemInfoHistory response; a finer step than this + // would allow is coarsened to respect it. + MaxHistoryPoints = 720 + + // DefaultHistoryRange is the window used when a request supplies + // neither range nor a from/to pair. + DefaultHistoryRange = 1 * time.Hour +) + +// systemHistoryPoint is one recorded sample of system.go's CPU/memory +// fields. A nil field means that metric wasn't collected at this tick (the +// underlying refresh failed), so it shows as a gap rather than a stale or +// false value. +type systemHistoryPoint struct { + time time.Time + cpuUsageMillicores *float64 + cpuLimitMillicores *float64 + memoryUsageMiB *float64 + memLimitMiB *float64 +} + +// mountHistoryPoint is one recorded sample of a single mount's usage. +type mountHistoryPoint struct { + time time.Time + usedBytes *uint64 + totalBytes *uint64 +} + +// dockerHistoryPoint is one recorded sample of Docker/Podman storage usage. +type dockerHistoryPoint struct { + time time.Time + totalBytes *uint64 + declaredSize *uint64 +} + +// history holds ring-buffer style, age-pruned samples backing the +// SystemInfoHistory RPC. Samples are appended once per Collect() tick from +// the same data already cached on SystemInfo, and pruned to HistoryRetention +// on every append. +type history struct { + mu sync.Mutex + system []systemHistoryPoint + mounts map[string][]mountHistoryPoint + docker []dockerHistoryPoint +} + +func newHistory() *history { + return &history{mounts: make(map[string][]mountHistoryPoint)} +} + +// record appends one tick's samples. sys is nil when that tick's CPU/memory +// refresh failed. mounts/docker are whatever is currently cached (possibly +// empty/nil on a failed or not-applicable collection) — records are only +// added for what's actually present, leaving the rest as a gap. +func (h *history) record(now time.Time, sys *systemHistoryPoint, mounts []k8shelld.MountUsage, docker *k8shelld.DockerUsage) { + h.mu.Lock() + defer h.mu.Unlock() + + if sys != nil { + h.system = append(h.system, *sys) + } + + for _, m := range mounts { + used, total := m.UsedBytes, m.TotalBytes + h.mounts[m.MountPoint] = append(h.mounts[m.MountPoint], mountHistoryPoint{ + time: now, usedBytes: &used, totalBytes: &total, + }) + } + + if docker != nil { + total, declared := docker.TotalBytes, docker.DeclaredSize + h.docker = append(h.docker, dockerHistoryPoint{time: now, totalBytes: &total, declaredSize: &declared}) + } + + h.pruneLocked(now) +} + +// pruneLocked drops samples older than HistoryRetention. Samples are always +// appended in increasing time order, so the first non-expired sample marks +// where each series should be truncated from. Callers must hold h.mu. +func (h *history) pruneLocked(now time.Time) { + cutoff := now.Add(-HistoryRetention) + + h.system = dropBefore(h.system, cutoff, func(p systemHistoryPoint) time.Time { return p.time }) + + for mountPoint, pts := range h.mounts { + pruned := dropBefore(pts, cutoff, func(p mountHistoryPoint) time.Time { return p.time }) + if len(pruned) == 0 { + delete(h.mounts, mountPoint) + } else { + h.mounts[mountPoint] = pruned + } + } + + h.docker = dropBefore(h.docker, cutoff, func(p dockerHistoryPoint) time.Time { return p.time }) +} + +func dropBefore[T any](pts []T, cutoff time.Time, timeOf func(T) time.Time) []T { + i := 0 + for i < len(pts) && timeOf(pts[i]).Before(cutoff) { + i++ + } + return pts[i:] +} + +// resolveHistoryWindow resolves the [from, to) window for a query. When both +// from and to are set they take precedence over range; otherwise range (or +// DefaultHistoryRange when range is also empty) is applied back from now. +// The window is then clamped to [now-HistoryRetention, now]. +func resolveHistoryWindow(reqFrom, reqTo, reqRange string, now time.Time) (from, to time.Time, err error) { + if reqFrom != "" && reqTo != "" { + from, err = time.Parse(time.RFC3339, reqFrom) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid from %q: %w", reqFrom, err) + } + to, err = time.Parse(time.RFC3339, reqTo) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid to %q: %w", reqTo, err) + } + if !to.After(from) { + return time.Time{}, time.Time{}, fmt.Errorf("to must be after from") + } + } else { + rangeDur := DefaultHistoryRange + if reqRange != "" { + rangeDur, err = time.ParseDuration(reqRange) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("invalid range %q: %w", reqRange, err) + } + if rangeDur <= 0 { + return time.Time{}, time.Time{}, fmt.Errorf("range must be positive") + } + } + to = now + from = now.Add(-rangeDur) + } + + if to.After(now) { + to = now + } + if floor := now.Add(-HistoryRetention); from.Before(floor) { + from = floor + } + if !to.After(from) { + to = from.Add(time.Second) + } + + return from, to, nil +} + +// resolveHistoryStep resolves the sample interval for a query: it can't be +// finer than nativeInterval (the collection resolution), and is coarsened +// further if needed to keep the bucket count within MaxHistoryPoints. +func resolveHistoryStep(reqStep string, from, to time.Time, nativeInterval time.Duration) (time.Duration, error) { + step := nativeInterval + if reqStep != "" { + d, err := time.ParseDuration(reqStep) + if err != nil { + return 0, fmt.Errorf("invalid step %q: %w", reqStep, err) + } + if d <= 0 { + return 0, fmt.Errorf("step must be positive") + } + step = d + } + if step < nativeInterval { + step = nativeInterval + } + + if span := to.Sub(from); span > 0 { + if minStep := span / MaxHistoryPoints; minStep > step { + step = minStep + } + } + + return step, nil +} + +func numHistoryBuckets(from, to time.Time, step time.Duration) int { + span := to.Sub(from) + if span <= 0 || step <= 0 { + return 0 + } + n := int(span / step) + if span%step != 0 { + n++ + } + if n < 1 { + n = 1 + } + return n +} + +func bucketIndex(t, from time.Time, step time.Duration) (int, bool) { + if t.Before(from) { + return 0, false + } + return int(t.Sub(from) / step), true +} + +// bucketSystemPoints downsamples raw system samples into n evenly spaced +// buckets of width step starting at from, averaging present values per +// bucket and leaving a field nil where no sample carried it. +func bucketSystemPoints(points []systemHistoryPoint, from, to time.Time, step time.Duration) []k8shelld.SystemMetricsPoint { + n := numHistoryBuckets(from, to, step) + result := make([]k8shelld.SystemMetricsPoint, n) + + type acc struct { + cpuUsage, cpuLimit, mem, memLimit float64 + nCPUUsage, nCPULimit, nMem, nMemLimit int + } + sums := make([]acc, n) + + for _, p := range points { + if !p.time.Before(to) { + continue + } + idx, ok := bucketIndex(p.time, from, step) + if !ok || idx >= n { + continue + } + if p.cpuUsageMillicores != nil { + sums[idx].cpuUsage += *p.cpuUsageMillicores + sums[idx].nCPUUsage++ + } + if p.cpuLimitMillicores != nil { + sums[idx].cpuLimit += *p.cpuLimitMillicores + sums[idx].nCPULimit++ + } + if p.memoryUsageMiB != nil { + sums[idx].mem += *p.memoryUsageMiB + sums[idx].nMem++ + } + if p.memLimitMiB != nil { + sums[idx].memLimit += *p.memLimitMiB + sums[idx].nMemLimit++ + } + } + + for i := range result { + result[i].Time = from.Add(time.Duration(i) * step).Format(time.RFC3339) + if sums[i].nCPUUsage > 0 { + v := sums[i].cpuUsage / float64(sums[i].nCPUUsage) + result[i].CPUUsageMillicores = &v + } + if sums[i].nCPULimit > 0 { + v := sums[i].cpuLimit / float64(sums[i].nCPULimit) + result[i].CPULimitMillicores = &v + } + if sums[i].nMem > 0 { + v := sums[i].mem / float64(sums[i].nMem) + result[i].MemoryUsageMiB = &v + } + if sums[i].nMemLimit > 0 { + v := sums[i].memLimit / float64(sums[i].nMemLimit) + result[i].MemLimitMiB = &v + } + } + return result +} + +// bucketMountPoints downsamples one mount's raw samples the same way +// bucketSystemPoints does, averaging uint64 fields. +func bucketMountPoints(points []mountHistoryPoint, from, to time.Time, step time.Duration) []k8shelld.MountUsagePoint { + n := numHistoryBuckets(from, to, step) + result := make([]k8shelld.MountUsagePoint, n) + + type acc struct { + usedSum, totalSum uint64 + nUsed, nTotal int + } + sums := make([]acc, n) + + for _, p := range points { + if !p.time.Before(to) { + continue + } + idx, ok := bucketIndex(p.time, from, step) + if !ok || idx >= n { + continue + } + if p.usedBytes != nil { + sums[idx].usedSum += *p.usedBytes + sums[idx].nUsed++ + } + if p.totalBytes != nil { + sums[idx].totalSum += *p.totalBytes + sums[idx].nTotal++ + } + } + + for i := range result { + result[i].Time = from.Add(time.Duration(i) * step).Format(time.RFC3339) + if sums[i].nUsed > 0 { + v := sums[i].usedSum / uint64(sums[i].nUsed) + result[i].UsedBytes = &v + } + if sums[i].nTotal > 0 { + v := sums[i].totalSum / uint64(sums[i].nTotal) + result[i].TotalBytes = &v + } + } + return result +} + +// bucketDockerPoints downsamples raw Docker/Podman samples the same way +// bucketMountPoints does. +func bucketDockerPoints(points []dockerHistoryPoint, from, to time.Time, step time.Duration) []k8shelld.DockerUsagePoint { + n := numHistoryBuckets(from, to, step) + result := make([]k8shelld.DockerUsagePoint, n) + + type acc struct { + totalSum, declaredSum uint64 + nTotal, nDeclared int + } + sums := make([]acc, n) + + for _, p := range points { + if !p.time.Before(to) { + continue + } + idx, ok := bucketIndex(p.time, from, step) + if !ok || idx >= n { + continue + } + if p.totalBytes != nil { + sums[idx].totalSum += *p.totalBytes + sums[idx].nTotal++ + } + if p.declaredSize != nil { + sums[idx].declaredSum += *p.declaredSize + sums[idx].nDeclared++ + } + } + + for i := range result { + result[i].Time = from.Add(time.Duration(i) * step).Format(time.RFC3339) + if sums[i].nTotal > 0 { + v := sums[i].totalSum / uint64(sums[i].nTotal) + result[i].TotalBytes = &v + } + if sums[i].nDeclared > 0 { + v := sums[i].declaredSum / uint64(sums[i].nDeclared) + result[i].DeclaredSize = &v + } + } + return result +} diff --git a/internal/system/history_test.go b/internal/system/history_test.go new file mode 100644 index 0000000..5d76c6c --- /dev/null +++ b/internal/system/history_test.go @@ -0,0 +1,270 @@ +// Use of this source code is governed by a AGPLv3 +// license that can be found in the LICENSE file. + +package system + +import ( + "testing" + "time" + + "github.com/k8shell-io/common/pkg/api/client/k8shelld" + commonmodels "github.com/k8shell-io/common/pkg/models" +) + +func TestResolveHistoryWindow(t *testing.T) { + now := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) + + t.Run("defaults to DefaultHistoryRange when nothing is set", func(t *testing.T) { + from, to, err := resolveHistoryWindow("", "", "", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !to.Equal(now) { + t.Errorf("to = %v, want %v", to, now) + } + if want := now.Add(-DefaultHistoryRange); !from.Equal(want) { + t.Errorf("from = %v, want %v", from, want) + } + }) + + t.Run("range shorthand applies back from now", func(t *testing.T) { + from, to, err := resolveHistoryWindow("", "", "30m", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !to.Equal(now) { + t.Errorf("to = %v, want %v", to, now) + } + if want := now.Add(-30 * time.Minute); !from.Equal(want) { + t.Errorf("from = %v, want %v", from, want) + } + }) + + t.Run("from/to take precedence over range", func(t *testing.T) { + reqFrom := now.Add(-2 * time.Hour).Format(time.RFC3339) + reqTo := now.Add(-1 * time.Hour).Format(time.RFC3339) + from, to, err := resolveHistoryWindow(reqFrom, reqTo, "5m", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := now.Add(-2 * time.Hour); !from.Equal(want) { + t.Errorf("from = %v, want %v", from, want) + } + if want := now.Add(-1 * time.Hour); !to.Equal(want) { + t.Errorf("to = %v, want %v", to, want) + } + }) + + t.Run("to is clamped to now", func(t *testing.T) { + reqFrom := now.Add(-1 * time.Hour).Format(time.RFC3339) + reqTo := now.Add(1 * time.Hour).Format(time.RFC3339) + _, to, err := resolveHistoryWindow(reqFrom, reqTo, "", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !to.Equal(now) { + t.Errorf("to = %v, want clamped to now (%v)", to, now) + } + }) + + t.Run("from is clamped to the retention floor", func(t *testing.T) { + from, _, err := resolveHistoryWindow("", "", "1000h", now) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if want := now.Add(-HistoryRetention); !from.Equal(want) { + t.Errorf("from = %v, want retention floor %v", from, want) + } + }) + + t.Run("rejects to before from", func(t *testing.T) { + reqFrom := now.Format(time.RFC3339) + reqTo := now.Add(-1 * time.Hour).Format(time.RFC3339) + if _, _, err := resolveHistoryWindow(reqFrom, reqTo, "", now); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("rejects malformed range", func(t *testing.T) { + if _, _, err := resolveHistoryWindow("", "", "not-a-duration", now); err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestResolveHistoryStep(t *testing.T) { + now := time.Now() + native := 30 * time.Second + + t.Run("defaults to native interval", func(t *testing.T) { + step, err := resolveHistoryStep("", now.Add(-time.Hour), now, native) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if step != native { + t.Errorf("step = %v, want %v", step, native) + } + }) + + t.Run("clamps a finer request up to native interval", func(t *testing.T) { + step, err := resolveHistoryStep("1s", now.Add(-time.Hour), now, native) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if step != native { + t.Errorf("step = %v, want native %v", step, native) + } + }) + + t.Run("coarsens to respect MaxHistoryPoints", func(t *testing.T) { + from := now.Add(-HistoryRetention) + step, err := resolveHistoryStep("", from, now, native) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + n := numHistoryBuckets(from, now, step) + if n > MaxHistoryPoints { + t.Errorf("buckets = %d, want <= %d (step=%v)", n, MaxHistoryPoints, step) + } + }) + + t.Run("rejects malformed step", func(t *testing.T) { + if _, err := resolveHistoryStep("nope", now.Add(-time.Hour), now, native); err == nil { + t.Fatal("expected error, got nil") + } + }) + + t.Run("rejects non-positive step", func(t *testing.T) { + if _, err := resolveHistoryStep("0s", now.Add(-time.Hour), now, native); err == nil { + t.Fatal("expected error, got nil") + } + }) +} + +func TestHistoryRecordAndPrune(t *testing.T) { + h := newHistory() + base := time.Now().Add(-2 * HistoryRetention) + + // Old sample that should be pruned away as newer samples come in. + h.record(base, &systemHistoryPoint{time: base, cpuUsageMillicores: floatPtr(1)}, nil, nil) + + now := time.Now() + h.record(now, &systemHistoryPoint{time: now, cpuUsageMillicores: floatPtr(2)}, nil, nil) + + if len(h.system) != 1 { + t.Fatalf("len(h.system) = %d, want 1 (old sample should have aged out)", len(h.system)) + } + if *h.system[0].cpuUsageMillicores != 2 { + t.Errorf("remaining sample = %v, want 2", *h.system[0].cpuUsageMillicores) + } +} + +func TestHistoryRecordMountsAndDocker(t *testing.T) { + h := newHistory() + now := time.Now() + + mounts := []k8shelld.MountUsage{ + {MountPoint: "/data", UsedBytes: 100, TotalBytes: 1000}, + } + docker := &k8shelld.DockerUsage{TotalBytes: 500, DeclaredSize: 2000} + + h.record(now, nil, mounts, docker) + + if _, ok := h.mounts["/data"]; !ok { + t.Fatal("expected /data mount history to be recorded") + } + if len(h.docker) != 1 { + t.Fatalf("len(h.docker) = %d, want 1", len(h.docker)) + } + if len(h.system) != 0 { + t.Errorf("len(h.system) = %d, want 0 (nil sys point should not be recorded)", len(h.system)) + } +} + +func TestBucketSystemPointsAveragesAndGaps(t *testing.T) { + from := time.Date(2026, 8, 23, 12, 0, 0, 0, time.UTC) + step := time.Minute + to := from.Add(3 * step) + + points := []systemHistoryPoint{ + {time: from, cpuUsageMillicores: floatPtr(10)}, + {time: from.Add(30 * time.Second), cpuUsageMillicores: floatPtr(20)}, + // bucket 1 (from+1min..from+2min) has no samples: should be a gap. + {time: from.Add(2 * step), memoryUsageMiB: floatPtr(50)}, + } + + result := bucketSystemPoints(points, from, to, step) + if len(result) != 3 { + t.Fatalf("len(result) = %d, want 3", len(result)) + } + + if result[0].CPUUsageMillicores == nil { + t.Fatal("bucket 0 CPUUsageMillicores should be set") + } else if want := 15.0; *result[0].CPUUsageMillicores != want { + t.Errorf("bucket 0 CPUUsageMillicores = %v, want %v (average of 10 and 20)", *result[0].CPUUsageMillicores, want) + } + + if result[1].CPUUsageMillicores != nil { + t.Errorf("bucket 1 CPUUsageMillicores = %v, want nil (gap)", *result[1].CPUUsageMillicores) + } + + if result[2].MemoryUsageMiB == nil || *result[2].MemoryUsageMiB != 50 { + t.Errorf("bucket 2 MemoryUsageMiB = %v, want 50", result[2].MemoryUsageMiB) + } +} + +func TestGetSystemInfoHistoryEndToEnd(t *testing.T) { + blueprint := &commonmodels.Blueprint{} + blueprint.Podman.Enabled = true + + s := NewSystemInfo(nil, blueprint) + s.mu.Lock() + s.collectionInterval = 30 * time.Second + s.mu.Unlock() + + now := time.Now() + for i := 0; i < 3; i++ { + t := now.Add(time.Duration(i) * 30 * time.Second) + s.history.record(t, + &systemHistoryPoint{time: t, cpuUsageMillicores: floatPtr(float64(i))}, + []k8shelld.MountUsage{{MountPoint: "/data", UsedBytes: uint64(i), TotalBytes: 1000}}, + &k8shelld.DockerUsage{TotalBytes: uint64(i * 10)}, + ) + } + + hist, err := s.GetSystemInfoHistory(k8shelld.SystemInfoHistoryQuery{Range: "5m"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hist.Docker == nil { + t.Fatal("expected Docker to be non-nil since Podman is enabled") + } + if _, ok := hist.Mounts["/data"]; !ok { + t.Fatal("expected /data in Mounts") + } + if len(hist.System) == 0 { + t.Fatal("expected non-empty System series") + } +} + +func TestGetSystemInfoHistoryDockerUnavailable(t *testing.T) { + s := NewSystemInfo(nil, &commonmodels.Blueprint{}) + s.mu.Lock() + s.collectionInterval = 30 * time.Second + s.mu.Unlock() + + hist, err := s.GetSystemInfoHistory(k8shelld.SystemInfoHistoryQuery{Range: "5m"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if hist.Docker != nil { + t.Error("expected Docker to be nil when Podman is not enabled") + } +} + +func TestGetSystemInfoHistoryInvalidQuery(t *testing.T) { + s := NewSystemInfo(nil, &commonmodels.Blueprint{}) + if _, err := s.GetSystemInfoHistory(k8shelld.SystemInfoHistoryQuery{Range: "not-a-duration"}); err == nil { + t.Fatal("expected error for malformed range") + } +} diff --git a/internal/system/process.go b/internal/system/process.go index 4b9389e..49a325a 100644 --- a/internal/system/process.go +++ b/internal/system/process.go @@ -95,6 +95,7 @@ func NewProcessWatcher(terminateOrphans bool, reapZombies bool, checkInterval in pattern, err := regexp.Compile(exclude) if err != nil { p.logger.Warn().Msgf("Invalid exclude pattern '%s': %v", exclude, err) + continue } p.excludePatterns = append(p.excludePatterns, pattern) } diff --git a/internal/system/system.go b/internal/system/system.go index 5be3a6c..3c2e164 100644 --- a/internal/system/system.go +++ b/internal/system/system.go @@ -83,6 +83,8 @@ type SystemInfo struct { cachedMounts []k8shelld.MountUsage // Cached mount usage (refreshed in background) cachedDocker *k8shelld.DockerUsage // Cached podman/docker usage (refreshed in background) cachedPodmanDetails *PodmanDetails // Cached extra Podman details (refreshed in background) + history *history // Historical system/mount/docker usage samples for SystemInfoHistory + collectionInterval time.Duration // Native sample interval, set by Collect; guarded by mu } func NewSystemInfo(config *config.Config, blueprint *commonmodels.Blueprint) *SystemInfo { @@ -93,15 +95,21 @@ func NewSystemInfo(config *config.Config, blueprint *commonmodels.Blueprint) *Sy prevTime: time.Now(), mu: sync.Mutex{}, log: logger.NewLogger("sysifo"), + history: newHistory(), } } func (s *SystemInfo) Collect(ctx context.Context, refreshTimeSec int) error { + s.mu.Lock() + s.collectionInterval = time.Duration(refreshTimeSec) * time.Second + s.mu.Unlock() + // Do an initial refresh immediately so data is available before the first tick. - if err := s.refresh(); err != nil { - s.log.Warn().Msgf("Initial system info refresh failed: %v", err) + refreshErr := s.refresh() + if refreshErr != nil { + s.log.Warn().Msgf("Initial system info refresh failed: %v", refreshErr) } - s.refreshStorage(ctx) + s.refreshStorage(ctx, refreshErr == nil) ticker := time.NewTicker(time.Duration(refreshTimeSec) * time.Second) defer ticker.Stop() @@ -109,10 +117,11 @@ func (s *SystemInfo) Collect(ctx context.Context, refreshTimeSec int) error { for { select { case <-ticker.C: - if err := s.refresh(); err != nil { - s.log.Warn().Msgf("Failed to update system info: %v", err) + refreshErr := s.refresh() + if refreshErr != nil { + s.log.Warn().Msgf("Failed to update system info: %v", refreshErr) } - s.refreshStorage(ctx) + s.refreshStorage(ctx, refreshErr == nil) case <-ctx.Done(): s.log.Info().Msg("System info updater stopped.") return ctx.Err() @@ -274,8 +283,11 @@ func (s *SystemInfo) computeDockerSnapshot(ctx context.Context) (*k8shelld.Docke return du, pd, nil } -// refreshStorage updates cachedMounts and cachedDocker in parallel. -func (s *SystemInfo) refreshStorage(ctx context.Context) { +// refreshStorage updates cachedMounts and cachedDocker in parallel, then +// records a history sample. refreshOK indicates whether the CPU/memory +// refresh that preceded this call succeeded; when it didn't, the system +// portion of the history sample is left as a gap rather than a stale value. +func (s *SystemInfo) refreshStorage(ctx context.Context, refreshOK bool) { var ( mounts []k8shelld.MountUsage docker *k8shelld.DockerUsage @@ -293,11 +305,74 @@ func (s *SystemInfo) refreshStorage(ctx context.Context) { }() wg.Wait() + now := time.Now() + s.mu.Lock() s.cachedMounts = mounts s.cachedDocker = docker s.cachedPodmanDetails = podman + + var sysPoint *systemHistoryPoint + if refreshOK { + sysPoint = &systemHistoryPoint{ + time: now, + cpuUsageMillicores: floatPtr(s.CPUUsageMillicores), + cpuLimitMillicores: floatPtr(s.CPULimitMillicores), + memoryUsageMiB: floatPtr(s.MemoryUsageMiB), + memLimitMiB: floatPtr(s.MemLimitMiB), + } + } s.mu.Unlock() + + s.history.record(now, sysPoint, mounts, docker) +} + +func floatPtr(v float64) *float64 { return &v } + +// GetSystemInfoHistory resolves a SystemInfoHistory query against the +// recorded ring buffer and returns the downsampled series. The returned +// From/To/Step reflect what was actually used after clamping/coarsening. +func (s *SystemInfo) GetSystemInfoHistory(q k8shelld.SystemInfoHistoryQuery) (*k8shelld.SystemInfoHistory, error) { + now := time.Now() + + from, to, err := resolveHistoryWindow(q.From, q.To, q.Range, now) + if err != nil { + return nil, err + } + + s.mu.Lock() + nativeInterval := s.collectionInterval + s.mu.Unlock() + if nativeInterval <= 0 { + nativeInterval = time.Second + } + + step, err := resolveHistoryStep(q.Step, from, to, nativeInterval) + if err != nil { + return nil, err + } + + s.history.mu.Lock() + defer s.history.mu.Unlock() + + result := &k8shelld.SystemInfoHistory{ + From: from.Format(time.RFC3339), + To: to.Format(time.RFC3339), + Step: step.String(), + System: bucketSystemPoints(s.history.system, from, to, step), + Mounts: make(map[string][]k8shelld.MountUsagePoint, len(s.history.mounts)), + } + + for mountPoint, points := range s.history.mounts { + result.Mounts[mountPoint] = bucketMountPoints(points, from, to, step) + } + + if s.blueprint != nil && s.blueprint.Podman.Enabled { + docker := bucketDockerPoints(s.history.docker, from, to, step) + result.Docker = &docker + } + + return result, nil } // CPUSample represents a CPU usage sample