Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions .github/workflows/container-timezones.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
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 \
-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
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
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
2 changes: 1 addition & 1 deletion docker/Dockerfile.goreleaser
Original file line number Diff line number Diff line change
@@ -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
30 changes: 30 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 18 additions & 0 deletions docker/test-timezones.sh
Original file line number Diff line number Diff line change
@@ -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
113 changes: 113 additions & 0 deletions docker/testdata/timezones.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading