From 71c9a5eaf2b3098c6f0f6d599adf0f4fcf17cefa Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Wed, 16 Sep 2026 15:47:01 +0000 Subject: [PATCH 1/5] Preserve named timezones in the BCI CLI image --- .github/workflows/container-timezones.yml | 114 ++++++++++++++++++++++ docker/README.md | 30 ++++++ docker/test-timezones.sh | 18 ++++ docker/testdata/timezones.go | 113 +++++++++++++++++++++ main.go | 1 + 5 files changed, 276 insertions(+) create mode 100644 .github/workflows/container-timezones.yml create mode 100644 docker/README.md create mode 100644 docker/test-timezones.sh create mode 100644 docker/testdata/timezones.go diff --git a/.github/workflows/container-timezones.yml b/.github/workflows/container-timezones.yml new file mode 100644 index 00000000..d2bd7246 --- /dev/null +++ b/.github/workflows/container-timezones.yml @@ -0,0 +1,114 @@ +name: Container timezone regression +on: + pull_request: + branches: [main] + paths: + - 'main.go' + - 'go.mod' + - 'go.sum' + - 'docker/**' + - '.goreleaser.yml' + - '.github/workflows/container-timezones.yml' + workflow_dispatch: + +permissions: {} + +jobs: + timezone: + name: Timezones, build and scans (${{ matrix.arch }}) + permissions: + contents: read + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-24.04 + trivy_arch: 64bit + - arch: arm64 + runner: ubuntu-24.04-arm + trivy_arch: ARM64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 20 + env: + ARCH: ${{ matrix.arch }} + TRIVY_ARCH: ${{ matrix.trivy_arch }} + IMAGE: cli-timezone-candidate + steps: + - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 + with: + persist-credentials: false + ref: ${{ github.event.pull_request.head.sha || github.sha }} + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 + with: + go-version-file: go.mod + cache: false + - name: Build release-style binary and container + shell: bash + run: | + set -euo pipefail + mkdir -p "$RUNNER_TEMP/build" evidence + commit=$(git rev-parse HEAD) + date=$(date -u +%Y-%m-%dT%H:%M:%SZ) + CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" go build \ + -ldflags "-s -w -X github.com/stackvista/stackstate-cli/static_info.Version=timezone-candidate -X github.com/stackvista/stackstate-cli/static_info.Commit=$commit -X github.com/stackvista/stackstate-cli/static_info.BuildDate=$date -X static_info.builtBy=goreleaser" \ + -o "$RUNNER_TEMP/build/sts" . + git rev-parse HEAD > evidence/source.txt + go version -m "$RUNNER_TEMP/build/sts" > evidence/buildinfo.txt + sha256sum "$RUNNER_TEMP/build/sts" > evidence/binary-sha256.txt + docker build --platform "linux/$ARCH" --provenance=false \ + -t "$IMAGE" -f docker/Dockerfile.goreleaser "$RUNNER_TEMP/build" + docker image inspect "$IMAGE" > evidence/image.json + - name: Check actual CLI timezone output in the container + shell: bash + run: | + set -euo pipefail + bash docker/test-timezones.sh "$IMAGE" "$ARCH" | tee evidence/timezones.txt + docker run --rm "$IMAGE" version -o json > evidence/version.json + docker run --rm "$IMAGE" --help > evidence/help.txt + - name: Install checksum-verified scanners + shell: bash + run: | + set -euo pipefail + tools="$RUNNER_TEMP/scanners" + mkdir -p "$tools" + cd "$tools" + trivy_archive="trivy_0.74.0_Linux-${TRIVY_ARCH}.tar.gz" + grype_archive="grype_0.118.0_linux_${ARCH}.tar.gz" + curl -fsSLO "https://github.com/aquasecurity/trivy/releases/download/v0.74.0/$trivy_archive" + curl -fsSLO https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_checksums.txt + grep " $trivy_archive\$" trivy_0.74.0_checksums.txt | sha256sum -c - + tar -xzf "$trivy_archive" trivy + curl -fsSLO "https://github.com/anchore/grype/releases/download/v0.118.0/$grype_archive" + curl -fsSLO https://github.com/anchore/grype/releases/download/v0.118.0/grype_0.118.0_checksums.txt + grep " $grype_archive\$" grype_0.118.0_checksums.txt | sha256sum -c - + tar -xzf "$grype_archive" grype + echo "$tools" >> "$GITHUB_PATH" + - name: Scan vulnerabilities including UNKNOWN and scan secrets separately + shell: bash + run: | + set -euo pipefail + trivy image --image-src docker --scanners vuln --list-all-pkgs \ + --format json --output evidence/trivy-vuln.json "$IMAGE" + grype "docker:$IMAGE" -o json > evidence/grype.json + trivy image --image-src docker --scanners secret --exit-code 1 \ + --format json --output "$RUNNER_TEMP/secrets.json" "$IMAGE" + cp "$RUNNER_TEMP/secrets.json" evidence/trivy-secret.json + trivy version --format json > evidence/trivy-version.json + grype version > evidence/grype-version.txt + grype db status -o json > evidence/grype-db.json + - name: Retain candidate evidence + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: container-timezones-${{ matrix.arch }} + path: evidence/ + retention-days: 30 + - name: Require supported OS, package inventory and clean vulnerability reports + shell: bash + run: | + set -euo pipefail + jq -e '.Metadata.OS.Family == "sles" and .Metadata.OS.EOSL != true and + ([.Results[]? | select(.Class == "os-pkgs") | .Packages[]?] | length > 0) and + ([.Results[]?.Vulnerabilities[]?] | length == 0)' evidence/trivy-vuln.json + jq -e '([.artifacts[]? | select(.type == "rpm")] | length > 0) and + (.matches | length == 0)' evidence/grype.json diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..6f77b9a7 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,30 @@ +# Container timezone checks + +The SUSE Observability CLI embeds Go's timezone database so named `TZ` values +continue to work in the BCI micro image without system zoneinfo. System-provided +timezone data still takes precedence. Updating the Go toolchain updates the +embedded database; an OS package scan alone does not establish its freshness. + +Build the Linux CLI with the toolchain selected by `go.mod`, then use the same +Dockerfile as GoReleaser: + +```bash +mkdir -p /tmp/cli-build +CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/cli-build/sts . +docker build --platform linux/amd64 -f docker/Dockerfile.goreleaser \ + -t cli-timezones /tmp/cli-build +bash docker/test-timezones.sh cli-timezones amd64 +``` + +Use `arm64` on an arm64 runner (or with local emulation). The fixture runs an HTTP +server on container loopback, with external networking disabled, and invokes the +image's actual CLI. It checks both agent timestamp columns, license and service +token expiry dates, and raw JSON epoch milliseconds. Literal UTC, New York winter +and summer, and Kathmandu expectations detect UTC fallback, DST and fractional +offset regressions. The helper is copied into a disposable container, never into +the built/scanned image. + +`container-timezones.yml` runs this check on both native architectures and retains +source/binary/image identities, runtime output, package inventories and raw +Trivy/Grype reports. Vulnerability scans include UNKNOWN and apply no exceptions +or VEX filtering; secrets are scanned separately. It builds candidates only. diff --git a/docker/test-timezones.sh b/docker/test-timezones.sh new file mode 100644 index 00000000..15f36403 --- /dev/null +++ b/docker/test-timezones.sh @@ -0,0 +1,18 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Run against a prebuilt image; the helper never enters the scanned image layers. +image=${1:?usage: docker/test-timezones.sh IMAGE ARCH} +arch=${2:?usage: docker/test-timezones.sh IMAGE ARCH} +work=$(mktemp -d) +container= +cleanup() { + if [[ -n "$container" ]]; then docker rm -f "$container" >/dev/null; fi + rm -rf "$work" +} +trap cleanup EXIT +CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -o "$work/fixture" ./docker/testdata/timezones.go +container=$(docker create --network=none --platform "linux/$arch" --entrypoint /fixture "$image") +docker cp "$work/fixture" "$container:/fixture" +docker start -a "$container" +test "$(docker inspect --format '{{.State.ExitCode}}' "$container")" = 0 diff --git a/docker/testdata/timezones.go b/docker/testdata/timezones.go new file mode 100644 index 00000000..f2094d45 --- /dev/null +++ b/docker/testdata/timezones.go @@ -0,0 +1,113 @@ +// This fixture runs inside the candidate image and invokes the real CLI. +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "strings" + "time" +) + +func main() { + if err := check(); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +func check() error { + // Expectations are literals, independent of the fixture's timezone database. + cases := []struct { + zone string + epoch int64 + timestamp, day string + }{ + {"UTC", 0, "1970-01-01 00:00:00 UTC", "1970-01-01"}, + {"America/New_York", 0, "1969-12-31 19:00:00 EST", "1969-12-31"}, + {"America/New_York", 1593561600000, "2020-06-30 20:00:00 EDT", "2020-06-30"}, + {"Asia/Kathmandu", 1593561600000, "2020-07-01 05:45:00 +0545", "2020-07-01"}, + } + for _, tc := range cases { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch strings.TrimPrefix(r.URL.Path, "/api") { + case "/server/info": + fmt.Fprint(w, `{"version":{"major":6,"minor":0,"patch":0,"diff":"","commit":"","isDev":false},"deploymentMode":"SelfHosted","applicationDomains":[]}`) + case "/agents": + fmt.Fprintf(w, `{"agents":[{"agentId":"tz-probe","lease":"Active","registeredEpochMs":%d,"leaseUntilEpochMs":%d,"nodeBudgetCount":1}]}`, tc.epoch, tc.epoch) + case "/subscription": + fmt.Fprintf(w, `{"_type":"LicensedSubscription","subscription":{"tenant":"fixture","plan":"test","expiryTimestampMs":%d}}`, tc.epoch) + case "/security/tokens": + fmt.Fprintf(w, `[{"id":1,"name":"fixture","expiration":%d,"roles":[]}]`, tc.epoch) + default: + http.NotFound(w, r) + } + })) + for _, command := range []struct { + args []string + column int + want string + }{ + {[]string{"agent", "list"}, 2, tc.timestamp}, + {[]string{"agent", "list"}, 3, tc.timestamp}, + {[]string{"license", "show"}, 2, tc.day}, + {[]string{"service-token", "list"}, 2, tc.day}, + } { + out, err := run(tc.zone, server.URL, command.args...) + if err != nil { + server.Close() + return err + } + // Reassemble a wrapped table column at the CLI's default width. + var column strings.Builder + for _, line := range strings.Split(out, "\n") { + cells := strings.Split(line, "|") + if len(cells) > command.column { + column.WriteString(strings.Join(strings.Fields(cells[command.column]), "")) + } + } + if !strings.Contains(column.String(), strings.ReplaceAll(command.want, " ", "")) { + server.Close() + return fmt.Errorf("TZ=%s %v: expected %q in column %d\n%s", tc.zone, command.args, command.want, command.column, out) + } + fmt.Printf("PASS TZ=%s %v column=%d: %s\n", tc.zone, command.args, command.column, command.want) + } + out, err := run(tc.zone, server.URL, "agent", "list", "-o", "json") + server.Close() + if err != nil { + return err + } + var result struct { + Agents []struct { + Registered int64 `json:"registeredEpochMs"` + LeaseUntil int64 `json:"leaseUntilEpochMs"` + } `json:"agents"` + } + if err := json.Unmarshal([]byte(out), &result); err != nil { + return fmt.Errorf("agent JSON: %w\n%s", err, out) + } + if len(result.Agents) != 1 || result.Agents[0].Registered != tc.epoch || result.Agents[0].LeaseUntil != tc.epoch { + return fmt.Errorf("TZ=%s: JSON timestamps changed: %s", tc.zone, out) + } + fmt.Printf("PASS TZ=%s JSON epoch milliseconds: %d\n", tc.zone, tc.epoch) + } + return nil +} + +func run(zone, url string, args ...string) (string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + args = append(args, "--url", url, "--api-token", "fixture") + cmd := exec.CommandContext(ctx, "/usr/bin/sts", args...) + cmd.Env = append(os.Environ(), "TZ="+zone, "NO_COLOR=1", "TERM=dumb", "XDG_CONFIG_HOME=/tmp/cli-timezone-test") + out, err := cmd.CombinedOutput() + if err != nil { + return "", fmt.Errorf("%v: %w\n%s", args[:2], err, out) + } + return string(out), nil +} diff --git a/main.go b/main.go index f3f37806..75abecbe 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "os" "runtime" "strings" + _ "time/tzdata" // Preserve named timezones when the runtime has no zoneinfo. "github.com/rs/zerolog" "github.com/rs/zerolog/log" From b387844594ceeac3011cdda635a7daea8a288af5 Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Wed, 16 Sep 2026 16:13:13 +0000 Subject: [PATCH 2/5] Fix Grype inventory validation and refresh patched BCI micro base --- .github/workflows/container-timezones.yml | 10 +++++++--- docker/Dockerfile.goreleaser | 2 +- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/container-timezones.yml b/.github/workflows/container-timezones.yml index d2bd7246..0bf9fff9 100644 --- a/.github/workflows/container-timezones.yml +++ b/.github/workflows/container-timezones.yml @@ -90,7 +90,8 @@ jobs: set -euo pipefail trivy image --image-src docker --scanners vuln --list-all-pkgs \ --format json --output evidence/trivy-vuln.json "$IMAGE" - grype "docker:$IMAGE" -o json > evidence/grype.json + grype "docker:$IMAGE" -o json=evidence/grype.json \ + -o cyclonedx-json=evidence/grype-inventory.json trivy image --image-src docker --scanners secret --exit-code 1 \ --format json --output "$RUNNER_TEMP/secrets.json" "$IMAGE" cp "$RUNNER_TEMP/secrets.json" evidence/trivy-secret.json @@ -110,5 +111,8 @@ jobs: jq -e '.Metadata.OS.Family == "sles" and .Metadata.OS.EOSL != true and ([.Results[]? | select(.Class == "os-pkgs") | .Packages[]?] | length > 0) and ([.Results[]?.Vulnerabilities[]?] | length == 0)' evidence/trivy-vuln.json - jq -e '([.artifacts[]? | select(.type == "rpm")] | length > 0) and - (.matches | length == 0)' evidence/grype.json + echo 'Require Grype RPM inventory from its CycloneDX report' + jq -e '[.components[]? | select((.purl // "") | startswith("pkg:rpm/"))] + | length > 0' evidence/grype-inventory.json + echo 'Require zero Grype vulnerability matches' + jq -e '.matches | type == "array" and length == 0' evidence/grype.json diff --git a/docker/Dockerfile.goreleaser b/docker/Dockerfile.goreleaser index 2ab24459..46637492 100644 --- a/docker/Dockerfile.goreleaser +++ b/docker/Dockerfile.goreleaser @@ -1,5 +1,5 @@ # Pinned to the multi-arch index digest, not a per-arch one, so the amd64 and # arm64v8 GoReleaser builds can both resolve it via --platform. -FROM registry.suse.com/bci/bci-micro:15.7@sha256:44f5c047210188eb290414c52f883b763efa1e234c2a15c325f03e83f0984fa6 +FROM registry.suse.com/bci/bci-micro:15.7@sha256:d56510e6d35ef2ffe7534f880bcd5ec599322693e4909f5c99f1bcbb155cf2fa ENTRYPOINT ["/usr/bin/sts"] COPY sts /usr/bin/sts From 37637ae44e2b1509689073d815275fdcea74ccd3 Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 18 Sep 2026 07:48:16 +0000 Subject: [PATCH 3/5] Fix Vancouver timezone data for DLA-4569-1 --- .github/workflows/ci.yml | 2 +- .golangci.yml | 5 +++++ docker/README.md | 8 +++++++- docker/testdata/timezones.go | 5 +++++ go.mod | 2 +- internal/util/string_util.go | 2 +- 6 files changed, 20 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1bb69cd7..6fc4a5a4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: # Require: The version of golangci-lint to use. # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. - version: v2.9.0 + version: v2.13.2 check-license: name: License scan diff --git a/.golangci.yml b/.golangci.yml index 8b9d40e8..e35e82a7 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -37,6 +37,11 @@ linters: # - revive TODO: enable when fixed # - stylecheck TODO: enable when fixed settings: + goconst: + # Preserve v2.9.0 coverage; composite literal checks were added afterward. + exclude-types: + - Call + - CompositeLit depguard: rules: main: diff --git a/docker/README.md b/docker/README.md index 6f77b9a7..c77be2d3 100644 --- a/docker/README.md +++ b/docker/README.md @@ -4,6 +4,10 @@ The SUSE Observability CLI embeds Go's timezone database so named `TZ` values continue to work in the BCI micro image without system zoneinfo. System-provided timezone data still takes precedence. Updating the Go toolchain updates the embedded database; an OS package scan alone does not establish its freshness. +The minimum Go version in `go.mod` is 1.27.1, which embeds IANA tzdata 2026c. +This includes the 2026b correction required by DLA-4569-1: Vancouver remains at +UTC-07 after November 1, 2026 (abbreviated MST in this database). Go 1.25.13 +still applied the old winter fallback and displayed the previous date near midnight. Build the Linux CLI with the toolchain selected by `go.mod`, then use the same Dockerfile as GoReleaser: @@ -21,7 +25,9 @@ server on container loopback, with external networking disabled, and invokes the image's actual CLI. It checks both agent timestamp columns, license and service token expiry dates, and raw JSON epoch milliseconds. Literal UTC, New York winter and summer, and Kathmandu expectations detect UTC fallback, DST and fractional -offset regressions. The helper is copied into a disposable container, never into +offset regressions. Vancouver checks cover the reported November 2026 affected +date, January 2027, and historical winter time, including unchanged JSON epochs. +The helper is copied into a disposable container, never into the built/scanned image. `container-timezones.yml` runs this check on both native architectures and retains diff --git a/docker/testdata/timezones.go b/docker/testdata/timezones.go index f2094d45..7aa68956 100644 --- a/docker/testdata/timezones.go +++ b/docker/testdata/timezones.go @@ -31,6 +31,11 @@ func check() error { {"America/New_York", 0, "1969-12-31 19:00:00 EST", "1969-12-31"}, {"America/New_York", 1593561600000, "2020-06-30 20:00:00 EDT", "2020-06-30"}, {"Asia/Kathmandu", 1593561600000, "2020-07-01 05:45:00 +0545", "2020-07-01"}, + // DLA-4569-1: Vancouver must not fall back to UTC-08 in November 2026. + {"America/Vancouver", 1793604600000, "2026-11-02 00:30:00 MST", "2026-11-02"}, + {"America/Vancouver", 1798788600000, "2027-01-01 00:30:00 MST", "2027-01-01"}, + // Historical winter timestamps must still use the former UTC-08 rule. + {"America/Vancouver", 0, "1969-12-31 16:00:00 PST", "1969-12-31"}, } for _, tc := range cases { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/go.mod b/go.mod index ee1917a2..d77bd506 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stackvista/stackstate-cli -go 1.25.13 +go 1.27.1 replace github.com/spf13/pflag => github.com/stackvista/pflag v1.22.0 diff --git a/internal/util/string_util.go b/internal/util/string_util.go index cf672e69..a4023794 100644 --- a/internal/util/string_util.go +++ b/internal/util/string_util.go @@ -69,7 +69,7 @@ func ToString(x interface{}) string { k := r.Kind() // this is the only way to check whether `x == nill`, because nil is specific to the type in Golang :( - isPointer := k == reflect.Chan || k == reflect.Func || k == reflect.Map || k == reflect.Ptr || + isPointer := k == reflect.Chan || k == reflect.Func || k == reflect.Map || k == reflect.Pointer || k == reflect.UnsafePointer || k == reflect.Slice || k == reflect.Interface if k == reflect.Invalid || (isPointer && r.IsNil()) { return "-" From d15b40d4add021e0867b97624c450f9728e0d7ee Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 18 Sep 2026 09:33:12 +0000 Subject: [PATCH 4/5] Use BCI Nano for CLI timezone data and TLS trust --- .github/workflows/ci.yml | 2 +- .github/workflows/container-timezones.yml | 8 ++-- .golangci.yml | 5 --- docker/Dockerfile.goreleaser | 2 +- docker/README.md | 49 ++++++++++++----------- docker/test-tls.sh | 27 +++++++++++++ go.mod | 2 +- internal/util/string_util.go | 2 +- main.go | 1 - 9 files changed, 61 insertions(+), 37 deletions(-) create mode 100644 docker/test-tls.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6fc4a5a4..1bb69cd7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -39,7 +39,7 @@ jobs: # Require: The version of golangci-lint to use. # When `install-mode` is `binary` (default) the value can be v1.2 or v1.2.3 or `latest` to use the latest version. # When `install-mode` is `goinstall` the value can be v1.2.3, `latest`, or the hash of a commit. - version: v2.13.2 + version: v2.9.0 check-license: name: License scan diff --git a/.github/workflows/container-timezones.yml b/.github/workflows/container-timezones.yml index 0bf9fff9..235e1523 100644 --- a/.github/workflows/container-timezones.yml +++ b/.github/workflows/container-timezones.yml @@ -66,6 +66,11 @@ jobs: bash docker/test-timezones.sh "$IMAGE" "$ARCH" | tee evidence/timezones.txt docker run --rm "$IMAGE" version -o json > evidence/version.json docker run --rm "$IMAGE" --help > evidence/help.txt + - name: Check actual CLI TLS trust and missing-CA negative control + shell: bash + run: | + set -euo pipefail + bash docker/test-tls.sh "$IMAGE" | tee evidence/tls.txt - name: Install checksum-verified scanners shell: bash run: | @@ -95,9 +100,6 @@ jobs: trivy image --image-src docker --scanners secret --exit-code 1 \ --format json --output "$RUNNER_TEMP/secrets.json" "$IMAGE" cp "$RUNNER_TEMP/secrets.json" evidence/trivy-secret.json - trivy version --format json > evidence/trivy-version.json - grype version > evidence/grype-version.txt - grype db status -o json > evidence/grype-db.json - name: Retain candidate evidence uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: diff --git a/.golangci.yml b/.golangci.yml index e35e82a7..8b9d40e8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -37,11 +37,6 @@ linters: # - revive TODO: enable when fixed # - stylecheck TODO: enable when fixed settings: - goconst: - # Preserve v2.9.0 coverage; composite literal checks were added afterward. - exclude-types: - - Call - - CompositeLit depguard: rules: main: diff --git a/docker/Dockerfile.goreleaser b/docker/Dockerfile.goreleaser index 46637492..02e44cec 100644 --- a/docker/Dockerfile.goreleaser +++ b/docker/Dockerfile.goreleaser @@ -1,5 +1,5 @@ # Pinned to the multi-arch index digest, not a per-arch one, so the amd64 and # arm64v8 GoReleaser builds can both resolve it via --platform. -FROM registry.suse.com/bci/bci-micro:15.7@sha256:d56510e6d35ef2ffe7534f880bcd5ec599322693e4909f5c99f1bcbb155cf2fa +FROM registry.suse.com/bci/bci-nano:15.7@sha256:794d075f0ffa66f6fc1f7073b4957c4c797f29f04384fe547b34f865c7afeebb ENTRYPOINT ["/usr/bin/sts"] COPY sts /usr/bin/sts diff --git a/docker/README.md b/docker/README.md index c77be2d3..c08310d0 100644 --- a/docker/README.md +++ b/docker/README.md @@ -1,16 +1,12 @@ -# Container timezone checks +# Container checks -The SUSE Observability CLI embeds Go's timezone database so named `TZ` values -continue to work in the BCI micro image without system zoneinfo. System-provided -timezone data still takes precedence. Updating the Go toolchain updates the -embedded database; an OS package scan alone does not establish its freshness. -The minimum Go version in `go.mod` is 1.27.1, which embeds IANA tzdata 2026c. -This includes the 2026b correction required by DLA-4569-1: Vancouver remains at -UTC-07 after November 1, 2026 (abbreviated MST in this database). Go 1.25.13 -still applied the old winter fallback and displayed the previous date near midnight. +The SUSE Observability CLI uses digest-pinned BCI Nano, which supplies timezone +data and CA certificates for the static release binary. The image's tzdata 2026c +includes the DLA-4569-1 correction: Vancouver stays at UTC-07 in winter 2026 +(abbreviated MST). There is no embedded Go timezone database or special toolchain +requirement for timezone data; refresh the base digest to update its packages. -Build the Linux CLI with the toolchain selected by `go.mod`, then use the same -Dockerfile as GoReleaser: +Build with the toolchain selected by `go.mod` and the release Dockerfile: ```bash mkdir -p /tmp/cli-build @@ -18,19 +14,24 @@ CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/cli-build/sts . docker build --platform linux/amd64 -f docker/Dockerfile.goreleaser \ -t cli-timezones /tmp/cli-build bash docker/test-timezones.sh cli-timezones amd64 +bash docker/test-tls.sh cli-timezones ``` -Use `arm64` on an arm64 runner (or with local emulation). The fixture runs an HTTP -server on container loopback, with external networking disabled, and invokes the -image's actual CLI. It checks both agent timestamp columns, license and service -token expiry dates, and raw JSON epoch milliseconds. Literal UTC, New York winter -and summer, and Kathmandu expectations detect UTC fallback, DST and fractional -offset regressions. Vancouver checks cover the reported November 2026 affected -date, January 2027, and historical winter time, including unchanged JSON epochs. -The helper is copied into a disposable container, never into -the built/scanned image. +Use `arm64` on an arm64 runner (or with local emulation). The timezone fixture +invokes the actual CLI against a loopback API with external networking disabled. +Its 35 assertions cover both agent timestamp columns, license/service-token +expiry dates and unchanged JSON epochs: UTC, New York winter/summer, Kathmandu, +and Vancouver historical winter, November 2026 and January 2027. Expectations +are literals independent of the fixture's timezone database. The helper is copied +into a disposable container, never into the built/scanned image. -`container-timezones.yml` runs this check on both native architectures and retains -source/binary/image identities, runtime output, package inventories and raw -Trivy/Grype reports. Vulnerability scans include UNKNOWN and apply no exceptions -or VEX filtering; secrets are scanned separately. It builds candidates only. +The TLS check uses the actual CLI against a public HTTPS endpoint and checks that +it reaches an HTTP response. A negative control disables the system CA paths and +must fail certificate verification. This check requires internet access. + +The existing `container-timezones.yml` runs on both native architectures and +retains binary/image identities, runtime output, inventories and raw scan reports. +Vulnerability scans include UNKNOWN with no exceptions or VEX filtering; secrets +are scanned separately. Empty inventories fail validation. This workflow builds +candidates only; normal CI supplies unit tests, lint, license and release-config +checks. diff --git a/docker/test-tls.sh b/docker/test-tls.sh new file mode 100644 index 00000000..fe9f1689 --- /dev/null +++ b/docker/test-tls.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +set -euo pipefail + +image=${1:?usage: docker/test-tls.sh IMAGE} +work=$(mktemp -d) +trap 'rm -rf "$work"' EXIT + +# The registry is not a CLI API: its HTTP 404 proves the actual CLI completed TLS. +# Use a dummy token, never credentials. A timeout/network error cannot pass. +args=(agent list --url https://registry.suse.com --api-token fixture) +if timeout 30 docker run --rm -e NO_COLOR=1 -e TERM=dumb "$image" "${args[@]}" > "$work/trusted" 2>&1; then + echo "Expected an API error from the non-API endpoint" >&2 + exit 1 +fi +cat "$work/trusted" +grep -Fq '(404 Not Found)' "$work/trusted" +echo 'PASS actual CLI reaches HTTP through TLS using image CA certificates' + +if timeout 30 docker run --rm -e NO_COLOR=1 -e TERM=dumb \ + -e SSL_CERT_FILE=/dev/null -e SSL_CERT_DIR=/no-certificates \ + "$image" "${args[@]}" > "$work/untrusted" 2>&1; then + echo "Expected certificate verification to fail without CA certificates" >&2 + exit 1 +fi +cat "$work/untrusted" +grep -Fq 'x509: certificate signed by unknown authority' "$work/untrusted" +echo 'PASS actual CLI rejects TLS when system CA certificates are disabled' diff --git a/go.mod b/go.mod index d77bd506..ee1917a2 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/stackvista/stackstate-cli -go 1.27.1 +go 1.25.13 replace github.com/spf13/pflag => github.com/stackvista/pflag v1.22.0 diff --git a/internal/util/string_util.go b/internal/util/string_util.go index a4023794..cf672e69 100644 --- a/internal/util/string_util.go +++ b/internal/util/string_util.go @@ -69,7 +69,7 @@ func ToString(x interface{}) string { k := r.Kind() // this is the only way to check whether `x == nill`, because nil is specific to the type in Golang :( - isPointer := k == reflect.Chan || k == reflect.Func || k == reflect.Map || k == reflect.Pointer || + isPointer := k == reflect.Chan || k == reflect.Func || k == reflect.Map || k == reflect.Ptr || k == reflect.UnsafePointer || k == reflect.Slice || k == reflect.Interface if k == reflect.Invalid || (isPointer && r.IsNil()) { return "-" diff --git a/main.go b/main.go index 75abecbe..f3f37806 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,6 @@ import ( "os" "runtime" "strings" - _ "time/tzdata" // Preserve named timezones when the runtime has no zoneinfo. "github.com/rs/zerolog" "github.com/rs/zerolog/log" From 28f541deb8dcc646b84dabae449d064169fe0f5b Mon Sep 17 00:00:00 2001 From: "SUSE Observability AI (POC)" Date: Fri, 18 Sep 2026 11:27:06 +0000 Subject: [PATCH 5/5] Keep only the BCI Nano base change in PR164 --- .github/workflows/container-timezones.yml | 120 ---------------------- docker/README.md | 37 ------- docker/test-timezones.sh | 18 ---- docker/test-tls.sh | 27 ----- docker/testdata/timezones.go | 118 --------------------- 5 files changed, 320 deletions(-) delete mode 100644 .github/workflows/container-timezones.yml delete mode 100644 docker/README.md delete mode 100644 docker/test-timezones.sh delete mode 100644 docker/test-tls.sh delete mode 100644 docker/testdata/timezones.go diff --git a/.github/workflows/container-timezones.yml b/.github/workflows/container-timezones.yml deleted file mode 100644 index 235e1523..00000000 --- a/.github/workflows/container-timezones.yml +++ /dev/null @@ -1,120 +0,0 @@ -name: Container timezone regression -on: - pull_request: - branches: [main] - paths: - - 'main.go' - - 'go.mod' - - 'go.sum' - - 'docker/**' - - '.goreleaser.yml' - - '.github/workflows/container-timezones.yml' - workflow_dispatch: - -permissions: {} - -jobs: - timezone: - name: Timezones, build and scans (${{ matrix.arch }}) - permissions: - contents: read - strategy: - fail-fast: false - matrix: - include: - - arch: amd64 - runner: ubuntu-24.04 - trivy_arch: 64bit - - arch: arm64 - runner: ubuntu-24.04-arm - trivy_arch: ARM64 - runs-on: ${{ matrix.runner }} - timeout-minutes: 20 - env: - ARCH: ${{ matrix.arch }} - TRIVY_ARCH: ${{ matrix.trivy_arch }} - IMAGE: cli-timezone-candidate - steps: - - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09 # v5 - with: - persist-credentials: false - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 - with: - go-version-file: go.mod - cache: false - - name: Build release-style binary and container - shell: bash - run: | - set -euo pipefail - mkdir -p "$RUNNER_TEMP/build" evidence - commit=$(git rev-parse HEAD) - date=$(date -u +%Y-%m-%dT%H:%M:%SZ) - CGO_ENABLED=0 GOOS=linux GOARCH="$ARCH" go build \ - -ldflags "-s -w -X github.com/stackvista/stackstate-cli/static_info.Version=timezone-candidate -X github.com/stackvista/stackstate-cli/static_info.Commit=$commit -X github.com/stackvista/stackstate-cli/static_info.BuildDate=$date -X static_info.builtBy=goreleaser" \ - -o "$RUNNER_TEMP/build/sts" . - git rev-parse HEAD > evidence/source.txt - go version -m "$RUNNER_TEMP/build/sts" > evidence/buildinfo.txt - sha256sum "$RUNNER_TEMP/build/sts" > evidence/binary-sha256.txt - docker build --platform "linux/$ARCH" --provenance=false \ - -t "$IMAGE" -f docker/Dockerfile.goreleaser "$RUNNER_TEMP/build" - docker image inspect "$IMAGE" > evidence/image.json - - name: Check actual CLI timezone output in the container - shell: bash - run: | - set -euo pipefail - bash docker/test-timezones.sh "$IMAGE" "$ARCH" | tee evidence/timezones.txt - docker run --rm "$IMAGE" version -o json > evidence/version.json - docker run --rm "$IMAGE" --help > evidence/help.txt - - name: Check actual CLI TLS trust and missing-CA negative control - shell: bash - run: | - set -euo pipefail - bash docker/test-tls.sh "$IMAGE" | tee evidence/tls.txt - - name: Install checksum-verified scanners - shell: bash - run: | - set -euo pipefail - tools="$RUNNER_TEMP/scanners" - mkdir -p "$tools" - cd "$tools" - trivy_archive="trivy_0.74.0_Linux-${TRIVY_ARCH}.tar.gz" - grype_archive="grype_0.118.0_linux_${ARCH}.tar.gz" - curl -fsSLO "https://github.com/aquasecurity/trivy/releases/download/v0.74.0/$trivy_archive" - curl -fsSLO https://github.com/aquasecurity/trivy/releases/download/v0.74.0/trivy_0.74.0_checksums.txt - grep " $trivy_archive\$" trivy_0.74.0_checksums.txt | sha256sum -c - - tar -xzf "$trivy_archive" trivy - curl -fsSLO "https://github.com/anchore/grype/releases/download/v0.118.0/$grype_archive" - curl -fsSLO https://github.com/anchore/grype/releases/download/v0.118.0/grype_0.118.0_checksums.txt - grep " $grype_archive\$" grype_0.118.0_checksums.txt | sha256sum -c - - tar -xzf "$grype_archive" grype - echo "$tools" >> "$GITHUB_PATH" - - name: Scan vulnerabilities including UNKNOWN and scan secrets separately - shell: bash - run: | - set -euo pipefail - trivy image --image-src docker --scanners vuln --list-all-pkgs \ - --format json --output evidence/trivy-vuln.json "$IMAGE" - grype "docker:$IMAGE" -o json=evidence/grype.json \ - -o cyclonedx-json=evidence/grype-inventory.json - trivy image --image-src docker --scanners secret --exit-code 1 \ - --format json --output "$RUNNER_TEMP/secrets.json" "$IMAGE" - cp "$RUNNER_TEMP/secrets.json" evidence/trivy-secret.json - - name: Retain candidate evidence - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: container-timezones-${{ matrix.arch }} - path: evidence/ - retention-days: 30 - - name: Require supported OS, package inventory and clean vulnerability reports - shell: bash - run: | - set -euo pipefail - jq -e '.Metadata.OS.Family == "sles" and .Metadata.OS.EOSL != true and - ([.Results[]? | select(.Class == "os-pkgs") | .Packages[]?] | length > 0) and - ([.Results[]?.Vulnerabilities[]?] | length == 0)' evidence/trivy-vuln.json - echo 'Require Grype RPM inventory from its CycloneDX report' - jq -e '[.components[]? | select((.purl // "") | startswith("pkg:rpm/"))] - | length > 0' evidence/grype-inventory.json - echo 'Require zero Grype vulnerability matches' - jq -e '.matches | type == "array" and length == 0' evidence/grype.json diff --git a/docker/README.md b/docker/README.md deleted file mode 100644 index c08310d0..00000000 --- a/docker/README.md +++ /dev/null @@ -1,37 +0,0 @@ -# Container checks - -The SUSE Observability CLI uses digest-pinned BCI Nano, which supplies timezone -data and CA certificates for the static release binary. The image's tzdata 2026c -includes the DLA-4569-1 correction: Vancouver stays at UTC-07 in winter 2026 -(abbreviated MST). There is no embedded Go timezone database or special toolchain -requirement for timezone data; refresh the base digest to update its packages. - -Build with the toolchain selected by `go.mod` and the release Dockerfile: - -```bash -mkdir -p /tmp/cli-build -CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /tmp/cli-build/sts . -docker build --platform linux/amd64 -f docker/Dockerfile.goreleaser \ - -t cli-timezones /tmp/cli-build -bash docker/test-timezones.sh cli-timezones amd64 -bash docker/test-tls.sh cli-timezones -``` - -Use `arm64` on an arm64 runner (or with local emulation). The timezone fixture -invokes the actual CLI against a loopback API with external networking disabled. -Its 35 assertions cover both agent timestamp columns, license/service-token -expiry dates and unchanged JSON epochs: UTC, New York winter/summer, Kathmandu, -and Vancouver historical winter, November 2026 and January 2027. Expectations -are literals independent of the fixture's timezone database. The helper is copied -into a disposable container, never into the built/scanned image. - -The TLS check uses the actual CLI against a public HTTPS endpoint and checks that -it reaches an HTTP response. A negative control disables the system CA paths and -must fail certificate verification. This check requires internet access. - -The existing `container-timezones.yml` runs on both native architectures and -retains binary/image identities, runtime output, inventories and raw scan reports. -Vulnerability scans include UNKNOWN with no exceptions or VEX filtering; secrets -are scanned separately. Empty inventories fail validation. This workflow builds -candidates only; normal CI supplies unit tests, lint, license and release-config -checks. diff --git a/docker/test-timezones.sh b/docker/test-timezones.sh deleted file mode 100644 index 15f36403..00000000 --- a/docker/test-timezones.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Run against a prebuilt image; the helper never enters the scanned image layers. -image=${1:?usage: docker/test-timezones.sh IMAGE ARCH} -arch=${2:?usage: docker/test-timezones.sh IMAGE ARCH} -work=$(mktemp -d) -container= -cleanup() { - if [[ -n "$container" ]]; then docker rm -f "$container" >/dev/null; fi - rm -rf "$work" -} -trap cleanup EXIT -CGO_ENABLED=0 GOOS=linux GOARCH="$arch" go build -o "$work/fixture" ./docker/testdata/timezones.go -container=$(docker create --network=none --platform "linux/$arch" --entrypoint /fixture "$image") -docker cp "$work/fixture" "$container:/fixture" -docker start -a "$container" -test "$(docker inspect --format '{{.State.ExitCode}}' "$container")" = 0 diff --git a/docker/test-tls.sh b/docker/test-tls.sh deleted file mode 100644 index fe9f1689..00000000 --- a/docker/test-tls.sh +++ /dev/null @@ -1,27 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -image=${1:?usage: docker/test-tls.sh IMAGE} -work=$(mktemp -d) -trap 'rm -rf "$work"' EXIT - -# The registry is not a CLI API: its HTTP 404 proves the actual CLI completed TLS. -# Use a dummy token, never credentials. A timeout/network error cannot pass. -args=(agent list --url https://registry.suse.com --api-token fixture) -if timeout 30 docker run --rm -e NO_COLOR=1 -e TERM=dumb "$image" "${args[@]}" > "$work/trusted" 2>&1; then - echo "Expected an API error from the non-API endpoint" >&2 - exit 1 -fi -cat "$work/trusted" -grep -Fq '(404 Not Found)' "$work/trusted" -echo 'PASS actual CLI reaches HTTP through TLS using image CA certificates' - -if timeout 30 docker run --rm -e NO_COLOR=1 -e TERM=dumb \ - -e SSL_CERT_FILE=/dev/null -e SSL_CERT_DIR=/no-certificates \ - "$image" "${args[@]}" > "$work/untrusted" 2>&1; then - echo "Expected certificate verification to fail without CA certificates" >&2 - exit 1 -fi -cat "$work/untrusted" -grep -Fq 'x509: certificate signed by unknown authority' "$work/untrusted" -echo 'PASS actual CLI rejects TLS when system CA certificates are disabled' diff --git a/docker/testdata/timezones.go b/docker/testdata/timezones.go deleted file mode 100644 index 7aa68956..00000000 --- a/docker/testdata/timezones.go +++ /dev/null @@ -1,118 +0,0 @@ -// This fixture runs inside the candidate image and invokes the real CLI. -package main - -import ( - "context" - "encoding/json" - "fmt" - "net/http" - "net/http/httptest" - "os" - "os/exec" - "strings" - "time" -) - -func main() { - if err := check(); err != nil { - fmt.Fprintln(os.Stderr, err) - os.Exit(1) - } -} - -func check() error { - // Expectations are literals, independent of the fixture's timezone database. - cases := []struct { - zone string - epoch int64 - timestamp, day string - }{ - {"UTC", 0, "1970-01-01 00:00:00 UTC", "1970-01-01"}, - {"America/New_York", 0, "1969-12-31 19:00:00 EST", "1969-12-31"}, - {"America/New_York", 1593561600000, "2020-06-30 20:00:00 EDT", "2020-06-30"}, - {"Asia/Kathmandu", 1593561600000, "2020-07-01 05:45:00 +0545", "2020-07-01"}, - // DLA-4569-1: Vancouver must not fall back to UTC-08 in November 2026. - {"America/Vancouver", 1793604600000, "2026-11-02 00:30:00 MST", "2026-11-02"}, - {"America/Vancouver", 1798788600000, "2027-01-01 00:30:00 MST", "2027-01-01"}, - // Historical winter timestamps must still use the former UTC-08 rule. - {"America/Vancouver", 0, "1969-12-31 16:00:00 PST", "1969-12-31"}, - } - for _, tc := range cases { - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - switch strings.TrimPrefix(r.URL.Path, "/api") { - case "/server/info": - fmt.Fprint(w, `{"version":{"major":6,"minor":0,"patch":0,"diff":"","commit":"","isDev":false},"deploymentMode":"SelfHosted","applicationDomains":[]}`) - case "/agents": - fmt.Fprintf(w, `{"agents":[{"agentId":"tz-probe","lease":"Active","registeredEpochMs":%d,"leaseUntilEpochMs":%d,"nodeBudgetCount":1}]}`, tc.epoch, tc.epoch) - case "/subscription": - fmt.Fprintf(w, `{"_type":"LicensedSubscription","subscription":{"tenant":"fixture","plan":"test","expiryTimestampMs":%d}}`, tc.epoch) - case "/security/tokens": - fmt.Fprintf(w, `[{"id":1,"name":"fixture","expiration":%d,"roles":[]}]`, tc.epoch) - default: - http.NotFound(w, r) - } - })) - for _, command := range []struct { - args []string - column int - want string - }{ - {[]string{"agent", "list"}, 2, tc.timestamp}, - {[]string{"agent", "list"}, 3, tc.timestamp}, - {[]string{"license", "show"}, 2, tc.day}, - {[]string{"service-token", "list"}, 2, tc.day}, - } { - out, err := run(tc.zone, server.URL, command.args...) - if err != nil { - server.Close() - return err - } - // Reassemble a wrapped table column at the CLI's default width. - var column strings.Builder - for _, line := range strings.Split(out, "\n") { - cells := strings.Split(line, "|") - if len(cells) > command.column { - column.WriteString(strings.Join(strings.Fields(cells[command.column]), "")) - } - } - if !strings.Contains(column.String(), strings.ReplaceAll(command.want, " ", "")) { - server.Close() - return fmt.Errorf("TZ=%s %v: expected %q in column %d\n%s", tc.zone, command.args, command.want, command.column, out) - } - fmt.Printf("PASS TZ=%s %v column=%d: %s\n", tc.zone, command.args, command.column, command.want) - } - out, err := run(tc.zone, server.URL, "agent", "list", "-o", "json") - server.Close() - if err != nil { - return err - } - var result struct { - Agents []struct { - Registered int64 `json:"registeredEpochMs"` - LeaseUntil int64 `json:"leaseUntilEpochMs"` - } `json:"agents"` - } - if err := json.Unmarshal([]byte(out), &result); err != nil { - return fmt.Errorf("agent JSON: %w\n%s", err, out) - } - if len(result.Agents) != 1 || result.Agents[0].Registered != tc.epoch || result.Agents[0].LeaseUntil != tc.epoch { - return fmt.Errorf("TZ=%s: JSON timestamps changed: %s", tc.zone, out) - } - fmt.Printf("PASS TZ=%s JSON epoch milliseconds: %d\n", tc.zone, tc.epoch) - } - return nil -} - -func run(zone, url string, args ...string) (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) - defer cancel() - args = append(args, "--url", url, "--api-token", "fixture") - cmd := exec.CommandContext(ctx, "/usr/bin/sts", args...) - cmd.Env = append(os.Environ(), "TZ="+zone, "NO_COLOR=1", "TERM=dumb", "XDG_CONFIG_HOME=/tmp/cli-timezone-test") - out, err := cmd.CombinedOutput() - if err != nil { - return "", fmt.Errorf("%v: %w\n%s", args[:2], err, out) - } - return string(out), nil -}