mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 00:55:53 +08:00
feat: add apps observability helpers
This commit is contained in:
166
shortcuts/apps/apps_observability_common.go
Normal file
166
shortcuts/apps/apps_observability_common.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/internal/validate"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAppsPageSize = 50
|
||||
maxAppsPageSize = 100
|
||||
)
|
||||
|
||||
func appScopedPath(appID, suffix string) string {
|
||||
base := apiBasePath + "/apps/" + validate.EncodePathSegment(strings.TrimSpace(appID))
|
||||
suffix = strings.TrimLeft(strings.TrimSpace(suffix), "/")
|
||||
if suffix == "" {
|
||||
return base
|
||||
}
|
||||
return base + "/" + suffix
|
||||
}
|
||||
|
||||
func validateObservabilityEnv(env string) error {
|
||||
switch strings.TrimSpace(env) {
|
||||
case "", "online":
|
||||
return nil
|
||||
default:
|
||||
return appsValidationParamError("--env", "observability commands only support --env online (got %q)", env)
|
||||
}
|
||||
}
|
||||
|
||||
func validateEnvVarEnv(env string) error {
|
||||
switch strings.TrimSpace(env) {
|
||||
case "dev", "online":
|
||||
return nil
|
||||
default:
|
||||
return appsValidationParamError("--env", "env var commands only support --env dev or --env online (got %q)", env)
|
||||
}
|
||||
}
|
||||
|
||||
func validateAppsPageSize(n int) error {
|
||||
if n < 1 || n > maxAppsPageSize {
|
||||
return appsValidationParamError("--page-size", "--page-size must be between 1 and %d", maxAppsPageSize)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func cleanRepeatedStrings(values []string) []string {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
out := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
out = append(out, value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseAppsTimeRange(sinceName, sinceRaw, untilName, untilRaw string) (time.Time, time.Time, bool, bool, error) {
|
||||
var since, until time.Time
|
||||
var hasSince, hasUntil bool
|
||||
now := time.Now()
|
||||
if strings.TrimSpace(sinceRaw) != "" {
|
||||
parsed, err := parseAppsTimeFlag(sinceName, sinceRaw, now)
|
||||
if err != nil {
|
||||
return time.Time{}, time.Time{}, false, false, err
|
||||
}
|
||||
since = parsed
|
||||
hasSince = true
|
||||
}
|
||||
if strings.TrimSpace(untilRaw) != "" {
|
||||
parsed, err := parseAppsTimeFlag(untilName, untilRaw, now)
|
||||
if err != nil {
|
||||
return since, time.Time{}, hasSince, false, err
|
||||
}
|
||||
until = parsed
|
||||
hasUntil = true
|
||||
}
|
||||
if hasSince && hasUntil && since.After(until) {
|
||||
return since, until, true, true, appsValidationParamError(untilName, "%s must be greater than or equal to %s", untilName, sinceName)
|
||||
}
|
||||
return since, until, hasSince, hasUntil, nil
|
||||
}
|
||||
|
||||
func parseAppsTimeFlag(param, raw string, now time.Time) (time.Time, error) {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return time.Time{}, appsValidationParamError(param, "%s is required", param)
|
||||
}
|
||||
if d, ok := parseAppsRelativeDuration(raw); ok {
|
||||
return now.Add(-d), nil
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, raw); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
for _, layout := range []string{
|
||||
"2006-01-02",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02T15:04:05.000",
|
||||
} {
|
||||
if t, err := time.ParseInLocation(layout, raw, time.Local); err == nil {
|
||||
return t, nil
|
||||
}
|
||||
}
|
||||
return time.Time{}, appsValidationParamError(param, "invalid %s %q: expected relative duration (30s, 5m, 2h, 3d, 1w), YYYY-MM-DD, local YYYY-MM-DDTHH:mm:ss(.SSS), or RFC3339", param, raw)
|
||||
}
|
||||
|
||||
func parseAppsRelativeDuration(s string) (time.Duration, bool) {
|
||||
s = strings.TrimSpace(s)
|
||||
if len(s) < 2 {
|
||||
return 0, false
|
||||
}
|
||||
unit := s[len(s)-1]
|
||||
number := s[:len(s)-1]
|
||||
for i := 0; i < len(number); i++ {
|
||||
if number[i] < '0' || number[i] > '9' {
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
n, err := strconv.ParseInt(number, 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return 0, false
|
||||
}
|
||||
var unitDuration time.Duration
|
||||
switch unit {
|
||||
case 's':
|
||||
unitDuration = time.Second
|
||||
case 'm':
|
||||
unitDuration = time.Minute
|
||||
case 'h':
|
||||
unitDuration = time.Hour
|
||||
case 'd':
|
||||
unitDuration = 24 * time.Hour
|
||||
case 'w':
|
||||
unitDuration = 7 * 24 * time.Hour
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
const maxDuration = time.Duration(1<<63 - 1)
|
||||
if n > int64(maxDuration)/int64(unitDuration) {
|
||||
return 0, false
|
||||
}
|
||||
return time.Duration(n) * unitDuration, true
|
||||
}
|
||||
|
||||
func nsString(t time.Time) string {
|
||||
return strconv.FormatInt(t.UnixNano(), 10)
|
||||
}
|
||||
|
||||
func secString(t time.Time) string {
|
||||
return strconv.FormatInt(t.Unix(), 10)
|
||||
}
|
||||
132
shortcuts/apps/apps_observability_common_test.go
Normal file
132
shortcuts/apps/apps_observability_common_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
// Copyright (c) 2026 Lark Technologies Pte. Ltd.
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
package apps
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
)
|
||||
|
||||
func requireAppsValidationParam(t *testing.T, err error, want string) *errs.Problem {
|
||||
t.Helper()
|
||||
p := requireAppsValidationProblem(t, err)
|
||||
var validationErr *errs.ValidationError
|
||||
if !errors.As(err, &validationErr) {
|
||||
t.Fatalf("expected validation error with param %q, got %T: %v", want, err, err)
|
||||
}
|
||||
if validationErr.Param != want {
|
||||
t.Fatalf("param = %q, want %s", validationErr.Param, want)
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func TestAppsObservabilityValidateEnvOnlyOnline(t *testing.T) {
|
||||
if err := validateObservabilityEnv(""); err != nil {
|
||||
t.Fatalf("empty env should default/pass as online: %v", err)
|
||||
}
|
||||
if err := validateObservabilityEnv("online"); err != nil {
|
||||
t.Fatalf("online should pass: %v", err)
|
||||
}
|
||||
err := validateObservabilityEnv("dev")
|
||||
p := requireAppsValidationParam(t, err, "--env")
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %#v, want invalid_argument param --env", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsObservabilityPageSizeRange(t *testing.T) {
|
||||
for _, n := range []int{1, 50, 100} {
|
||||
if err := validateAppsPageSize(n); err != nil {
|
||||
t.Fatalf("page size %d should pass: %v", n, err)
|
||||
}
|
||||
}
|
||||
for _, n := range []int{0, 101} {
|
||||
err := validateAppsPageSize(n)
|
||||
requireAppsValidationParam(t, err, "--page-size")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsObservabilityCommonHelpers(t *testing.T) {
|
||||
if got := appScopedPath("app/x", "observability/logs"); got != "/open-apis/spark/v1/apps/app%2Fx/observability/logs" {
|
||||
t.Fatalf("appScopedPath = %q", got)
|
||||
}
|
||||
for _, env := range []string{"dev", "online"} {
|
||||
if err := validateEnvVarEnv(env); err != nil {
|
||||
t.Fatalf("validateEnvVarEnv(%q) err=%v", env, err)
|
||||
}
|
||||
}
|
||||
requireAppsValidationParam(t, validateEnvVarEnv(""), "--env")
|
||||
requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--env")
|
||||
got := cleanRepeatedStrings([]string{" a ", "b", "a", "", "b", "c"})
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("cleanRepeatedStrings len=%d, want %d: %v", len(got), len(want), got)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("cleanRepeatedStrings[%d]=%q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
ts := time.Date(2026, 6, 23, 10, 11, 12, 123456789, time.UTC)
|
||||
if got := nsString(ts); got != "1782209472123456789" {
|
||||
t.Fatalf("nsString = %q", got)
|
||||
}
|
||||
if got := secString(ts); got != "1782209472" {
|
||||
t.Fatalf("secString = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAppsTimeAcceptsSupportedInputs(t *testing.T) {
|
||||
now := time.Date(2026, 6, 23, 12, 0, 0, 0, time.Local)
|
||||
cases := []struct {
|
||||
raw string
|
||||
want time.Time
|
||||
wantOffset *int
|
||||
}{
|
||||
{raw: "30s", want: now.Add(-30 * time.Second)},
|
||||
{raw: "5m", want: now.Add(-5 * time.Minute)},
|
||||
{raw: "2h", want: now.Add(-2 * time.Hour)},
|
||||
{raw: "3d", want: now.Add(-72 * time.Hour)},
|
||||
{raw: "1w", want: now.Add(-7 * 24 * time.Hour)},
|
||||
{raw: "2026-06-23", want: time.Date(2026, 6, 23, 0, 0, 0, 0, time.Local)},
|
||||
{raw: "2026-06-23T10:11:12", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.Local)},
|
||||
{raw: "2026-06-23T10:11:12.123", want: time.Date(2026, 6, 23, 10, 11, 12, 123000000, time.Local)},
|
||||
{raw: "2026-06-23T10:11:12Z", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.UTC), wantOffset: ptrInt(0)},
|
||||
{raw: "2026-06-23T10:11:12+08:00", want: time.Date(2026, 6, 23, 10, 11, 12, 0, time.FixedZone("", 8*60*60)), wantOffset: ptrInt(8 * 60 * 60)},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got, err := parseAppsTimeFlag("--since", tc.raw, now)
|
||||
if err != nil {
|
||||
t.Fatalf("parseAppsTimeFlag(%q) err=%v", tc.raw, err)
|
||||
}
|
||||
if !got.Equal(tc.want) {
|
||||
t.Fatalf("parseAppsTimeFlag(%q)=%s, want %s", tc.raw, got.Format(time.RFC3339Nano), tc.want.Format(time.RFC3339Nano))
|
||||
}
|
||||
if tc.wantOffset != nil {
|
||||
_, offset := got.Zone()
|
||||
if offset != *tc.wantOffset {
|
||||
t.Fatalf("parseAppsTimeFlag(%q) zone offset=%d, want %d", tc.raw, offset, *tc.wantOffset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAppsTimeRejectsUnsupportedInputs(t *testing.T) {
|
||||
for _, in := range []string{"2026/06/23", "yesterday", "2026-06-23 10:11:12", "999999999999999999w", "2147483647w"} {
|
||||
_, _, _, _, err := parseAppsTimeRange("--since", in, "--until", "")
|
||||
requireAppsValidationParam(t, err, "--since")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseAppsTimeRangeRejectsSinceAfterUntil(t *testing.T) {
|
||||
_, _, _, _, err := parseAppsTimeRange("--since", "2026-06-24", "--until", "2026-06-23")
|
||||
requireAppsValidationParam(t, err, "--until")
|
||||
}
|
||||
|
||||
func ptrInt(n int) *int {
|
||||
return &n
|
||||
}
|
||||
Reference in New Issue
Block a user