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/2] 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/2] 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