mirror of
https://github.com/larksuite/cli.git
synced 2026-07-07 09:11:44 +08:00
fix: upgrade observability and env
This commit is contained in:
@@ -5,6 +5,7 @@ package apps
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
@@ -12,23 +13,26 @@ import (
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const defaultAppsEnvVarEnv = "dev"
|
||||
const (
|
||||
defaultAppsEnvVarEnv = "dev"
|
||||
defaultAppsEnvVarScene = 2
|
||||
)
|
||||
|
||||
// AppsEnvVarList lists app environment variables without values by default.
|
||||
var AppsEnvVarList = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+envvar-list",
|
||||
Command: "+env-list",
|
||||
Description: "List app environment variables",
|
||||
Risk: "read",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +envvar-list --app-id <app_id>",
|
||||
"Example: lark-cli apps +env-list --app-id <app_id>",
|
||||
},
|
||||
Scopes: []string{"spark:app:read"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
|
||||
{Name: "include-values", Type: "bool", Desc: "include environment variable values"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -57,7 +61,10 @@ var AppsEnvVarList = common.Shortcut{
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
rctx.OutFormat(normalizeEnvVarListOutput(data, includeValues), nil, nil)
|
||||
out := normalizeEnvVarListOutput(data, includeValues)
|
||||
rctx.OutFormat(out, nil, func(w io.Writer) {
|
||||
appsPrintSchemaTable(w, out.Items, envVarListSchema(includeValues))
|
||||
})
|
||||
return nil
|
||||
},
|
||||
}
|
||||
@@ -65,18 +72,18 @@ var AppsEnvVarList = common.Shortcut{
|
||||
// AppsEnvVarSet sets one app environment variable. Values are never printed.
|
||||
var AppsEnvVarSet = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+envvar-set",
|
||||
Command: "+env-set",
|
||||
Description: "Set an app environment variable",
|
||||
Risk: "write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +envvar-set --app-id <app_id> --key FOO --value bar",
|
||||
"Example: lark-cli apps +env-set --app-id <app_id> --key FOO --value bar",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
|
||||
{Name: "key", Desc: "environment variable key", Required: true},
|
||||
{Name: "value", Desc: "environment variable value", Required: true, Input: []string{common.File, common.Stdin}},
|
||||
{Name: "yes", Type: "bool", Desc: "confirm setting variables in online"},
|
||||
@@ -113,8 +120,8 @@ var AppsEnvVarSet = common.Shortcut{
|
||||
if env == "online" && !rctx.Bool("yes") {
|
||||
return errs.NewConfirmationRequiredError(
|
||||
errs.RiskWrite,
|
||||
"apps +envvar-set --env online",
|
||||
"apps +envvar-set --env online requires confirmation",
|
||||
"apps +env-set --environment online",
|
||||
"apps +env-set --environment online requires confirmation",
|
||||
).WithHint("add --yes to confirm")
|
||||
}
|
||||
appID, err := requireAppID(rctx.Str("app-id"))
|
||||
@@ -131,7 +138,7 @@ var AppsEnvVarSet = common.Shortcut{
|
||||
"value": rctx.Str("value"),
|
||||
})
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
return withAppsHint(err, envVarMutationHint(err))
|
||||
}
|
||||
action := envVarStringAny(data, "action")
|
||||
if action == "" {
|
||||
@@ -149,18 +156,18 @@ var AppsEnvVarSet = common.Shortcut{
|
||||
// AppsEnvVarDelete deletes one or more app environment variables.
|
||||
var AppsEnvVarDelete = common.Shortcut{
|
||||
Service: appsService,
|
||||
Command: "+envvar-delete",
|
||||
Command: "+env-delete",
|
||||
Description: "Delete app environment variables",
|
||||
Risk: "high-risk-write",
|
||||
Tips: []string{
|
||||
"Example: lark-cli apps +envvar-delete --app-id <app_id> --key FOO --yes",
|
||||
"Example: lark-cli apps +env-delete --app-id <app_id> --key FOO --yes",
|
||||
},
|
||||
Scopes: []string{"spark:app:write"},
|
||||
AuthTypes: []string{"user"},
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID", Required: true},
|
||||
{Name: "env", Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsEnvVarEnv, Enum: []string{"dev", "online"}, Desc: "target environment"},
|
||||
{Name: "key", Type: "string_array", Desc: "environment variable key; repeatable", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -193,7 +200,7 @@ var AppsEnvVarDelete = common.Shortcut{
|
||||
env := envVarEnv(rctx)
|
||||
data, err := rctx.CallAPITyped("POST", envVarDeletePath(appID), nil, buildEnvVarDeleteBody(env, keys))
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
return withAppsHint(err, envVarMutationHint(err))
|
||||
}
|
||||
deletedKeys := envVarStringSliceAny(data, "deleted_keys", "deletedKeys")
|
||||
if len(deletedKeys) == 0 {
|
||||
@@ -208,7 +215,7 @@ var AppsEnvVarDelete = common.Shortcut{
|
||||
}
|
||||
|
||||
func envVarEnv(rctx *common.RuntimeContext) string {
|
||||
env := strings.TrimSpace(rctx.Str("env"))
|
||||
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
|
||||
if env == "" {
|
||||
return defaultAppsEnvVarEnv
|
||||
}
|
||||
@@ -229,7 +236,8 @@ func envVarDeletePath(appID string) string {
|
||||
|
||||
func buildEnvVarListBody(rctx *common.RuntimeContext) map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": envVarEnv(rctx),
|
||||
"env": envVarEnv(rctx),
|
||||
"scene": defaultAppsEnvVarScene,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -240,6 +248,21 @@ func buildEnvVarDeleteBody(env string, keys []string) map[string]interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func envVarMutationHint(err error) string {
|
||||
if isEnvVarNotModifiableError(err) {
|
||||
return "this environment variable is platform-managed and cannot be modified; remove protected keys from --key and retry only with user-defined variables"
|
||||
}
|
||||
return appIDListHint
|
||||
}
|
||||
|
||||
func isEnvVarNotModifiableError(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return strings.Contains(strings.ToLower(p.Message), "not modifiable")
|
||||
}
|
||||
|
||||
func requireEnvVarKey(raw string) (string, error) {
|
||||
key := strings.TrimSpace(raw)
|
||||
if key == "" {
|
||||
@@ -339,6 +362,17 @@ func filterEnvVarItem(item map[string]interface{}, includeValues bool) map[strin
|
||||
return out
|
||||
}
|
||||
|
||||
func envVarListSchema(includeValues bool) appsOutputSchema {
|
||||
columns := []appsOutputColumn{
|
||||
{Key: "key"},
|
||||
{Key: "env"},
|
||||
}
|
||||
if includeValues {
|
||||
columns = append(columns, appsOutputColumn{Key: "value"})
|
||||
}
|
||||
return appsOutputSchema{Columns: columns, Strict: true}
|
||||
}
|
||||
|
||||
func envVarStringAny(data map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key].(string); ok {
|
||||
@@ -62,9 +62,9 @@ var AppsEnvPull = common.Shortcut{
|
||||
projectPath, envFile, _ := resolveEnvPullTarget(strings.TrimSpace(rctx.Str("project-path")))
|
||||
appID := strings.TrimSpace(rctx.Str("app-id"))
|
||||
return common.NewDryRunAPI().
|
||||
POST(fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))).
|
||||
POST(envPullVarsPath(appID)).
|
||||
Desc("Pull app startup env vars into the local .env.local file").
|
||||
Body(map[string]interface{}{"env": "dev"}).
|
||||
Body(envPullVarsBody()).
|
||||
Set("project_path", projectPath).
|
||||
Set("env_file", envFile)
|
||||
},
|
||||
@@ -81,8 +81,7 @@ var AppsEnvPull = common.Shortcut{
|
||||
return err
|
||||
}
|
||||
|
||||
path := fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
data, err := rctx.CallAPITyped("POST", path, nil, map[string]interface{}{"env": "dev"})
|
||||
data, err := rctx.CallAPITyped("POST", envPullVarsPath(appID), nil, envPullVarsBody())
|
||||
if err != nil {
|
||||
return withAppsHint(err, "verify --app-id is correct and you have access to the app; list your apps with `lark-cli apps +list`")
|
||||
}
|
||||
@@ -117,6 +116,16 @@ var AppsEnvPull = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func envPullVarsPath(appID string) string {
|
||||
return fmt.Sprintf("%s/apps/%s/env_vars", apiBasePath, validate.EncodePathSegment(appID))
|
||||
}
|
||||
|
||||
func envPullVarsBody() map[string]interface{} {
|
||||
return map[string]interface{}{
|
||||
"env": "dev",
|
||||
}
|
||||
}
|
||||
|
||||
func resolveEnvPullTarget(projectPath string) (string, string, error) {
|
||||
if strings.TrimSpace(projectPath) == "" {
|
||||
cwd, err := os.Getwd() //nolint:forbidigo // shortcuts cannot import internal/vfs; cwd lookup is local-only and bounded.
|
||||
|
||||
@@ -32,6 +32,11 @@ func assertValidationError(t *testing.T, err error, wantSubstr string) {
|
||||
}
|
||||
}
|
||||
|
||||
func assertEnvPullBody(t *testing.T, req *http.Request) {
|
||||
t.Helper()
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"})
|
||||
}
|
||||
|
||||
func TestResolveEnvPullTarget_DefaultProjectPathUsesCWD(t *testing.T) {
|
||||
cwd := t.TempDir()
|
||||
oldwd, err := os.Getwd()
|
||||
@@ -288,7 +293,7 @@ func TestAppsEnvPull_PrettyOutput_WithDatabaseLine(t *testing.T) {
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"})
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
@@ -557,6 +562,36 @@ func TestAppsEnvPull_ExecuteUsesNestedDataEnvVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_NonObjectJSONDoesNotCarryAppIDHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
RawBody: []byte("[]"),
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvPull,
|
||||
[]string{"+env-pull", "--app-id", "app_x", "--project-path", t.TempDir(), "--as", "user"},
|
||||
factory, stdout,
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatalf("expected non-object JSON failure, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Category != errs.CategoryInternal || p.Subtype != errs.SubtypeInvalidResponse {
|
||||
t.Fatalf("classification = %s/%s, want internal/invalid_response", p.Category, p.Subtype)
|
||||
}
|
||||
if strings.Contains(p.Hint, "apps +list") || strings.Contains(p.Hint, "--app-id") {
|
||||
t.Fatalf("hint should not point to app-id/list recovery for malformed upstream JSON: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvPull_ExecuteUsesArrayEnvVars(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
projectDir := t.TempDir()
|
||||
|
||||
@@ -29,6 +29,10 @@ func assertEnvVarBody(t *testing.T, req *http.Request, want map[string]interface
|
||||
}
|
||||
}
|
||||
|
||||
func expectedEnvVarSceneJSON() float64 {
|
||||
return float64(defaultAppsEnvVarScene)
|
||||
}
|
||||
|
||||
func decodeEnvVarEnvelopeData(t *testing.T, stdout string) map[string]interface{} {
|
||||
t.Helper()
|
||||
var envelope struct {
|
||||
@@ -65,7 +69,7 @@ func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) {
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"})
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev", "scene": expectedEnvVarSceneJSON()})
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
@@ -78,7 +82,7 @@ func TestAppsEnvVarList_DefaultsToDevAndHidesValues(t *testing.T) {
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+envvar-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
[]string{"+env-list", "--app-id", "app_x", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
@@ -106,7 +110,7 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) {
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "online"})
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "online", "scene": expectedEnvVarSceneJSON()})
|
||||
},
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
@@ -119,7 +123,7 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) {
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+envvar-list", "--app-id", "app_x", "--env", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil {
|
||||
[]string{"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
|
||||
@@ -129,10 +133,73 @@ func TestAppsEnvVarList_IncludeValuesAllowsValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_DoesNotAcceptEnvironmentShorthand(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+env-list", "--app-id", "app_x", "-e", "online", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown shorthand flag: 'e'") {
|
||||
t.Fatalf("expected unknown -e shorthand, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_DryRunIncludesScene(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsEnvVarList, []string{
|
||||
"+env-list", "--app-id", "app_x", "--include-values", "--dry-run", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
var dryRun struct {
|
||||
API []struct {
|
||||
Body map[string]interface{} `json:"body"`
|
||||
} `json:"api"`
|
||||
}
|
||||
if err := json.Unmarshal(stdout.Bytes(), &dryRun); err != nil {
|
||||
t.Fatalf("decode dry-run: %v\n%s", err, stdout.String())
|
||||
}
|
||||
if got := dryRun.API[0].Body["scene"]; got != expectedEnvVarSceneJSON() {
|
||||
t.Fatalf("body.scene = %#v, want %v; stdout:\n%s", got, expectedEnvVarSceneJSON(), stdout.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_PrettyDisplaysTable(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/env_vars",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"envVars": []interface{}{
|
||||
map[string]interface{}{"key": "API_HOST", "value": "https://example.com", "env": "online"},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarList, []string{
|
||||
"+env-list", "--app-id", "app_x", "--environment", "online", "--include-values", "--format", "pretty", "--as", "user",
|
||||
}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
got := stdout.String()
|
||||
if !strings.HasPrefix(got, "key") {
|
||||
t.Fatalf("pretty output should start with key column, got:\n%s", got)
|
||||
}
|
||||
for _, want := range []string{"API_HOST", "online", "https://example.com"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("pretty output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `"ok"`) || strings.Contains(got, `"data"`) {
|
||||
t.Fatalf("pretty output should not fall back to JSON envelope:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarSet,
|
||||
[]string{"+envvar-set", "--app-id", "app_x", "--env", "online",
|
||||
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_TOKEN", "--value", "super-secret", "--as", "user"}, factory, stdout)
|
||||
|
||||
p := requireAppsProblem(t, err, errs.CategoryConfirmation)
|
||||
@@ -147,7 +214,7 @@ func TestAppsEnvVarSet_OnlineRequiresYesOutsideDryRun(t *testing.T) {
|
||||
func TestAppsEnvVarSet_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsEnvVarSet,
|
||||
[]string{"+envvar-set", "--app-id", "app_x", "--env", "online",
|
||||
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_TOKEN", "--value", "super-secret", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -184,7 +251,7 @@ func TestAppsEnvVarSet_ExecutesWithYesAndDoesNotEchoValue(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarSet,
|
||||
[]string{"+envvar-set", "--app-id", "app_x", "--env", "online",
|
||||
[]string{"+env-set", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_TOKEN", "--value", "super-secret", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -223,7 +290,7 @@ func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) {
|
||||
reg.Register(stub)
|
||||
|
||||
if err := runAppsShortcut(t, AppsEnvVarDelete,
|
||||
[]string{"+envvar-delete", "--app-id", "app_x", "--env", "online",
|
||||
[]string{"+env-delete", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_ONE", "--key", "SECRET_TWO", "--yes", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
@@ -247,10 +314,41 @@ func TestAppsEnvVarDelete_BuildsDeleteBodyWithKeys(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarDelete_NotModifiableHint(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/delete_env_vars",
|
||||
Body: map[string]interface{}{
|
||||
"code": 400000072,
|
||||
"msg": "Invalid Request: env var (INTEGRATION_TOKEN) is not modifiable",
|
||||
},
|
||||
})
|
||||
|
||||
err := runAppsShortcut(t, AppsEnvVarDelete,
|
||||
[]string{"+env-delete", "--app-id", "app_x", "--key", "INTEGRATION_TOKEN", "--yes", "--as", "user"}, factory, stdout)
|
||||
if err == nil {
|
||||
t.Fatalf("expected not modifiable error, got nil; stdout=%s", stdout.String())
|
||||
}
|
||||
p, ok := errs.ProblemOf(err)
|
||||
if !ok {
|
||||
t.Fatalf("expected typed problem, got %T: %v", err, err)
|
||||
}
|
||||
if p.Code != 400000072 {
|
||||
t.Fatalf("code = %d, want 400000072", p.Code)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "platform-managed") || !strings.Contains(p.Hint, "user-defined") {
|
||||
t.Fatalf("hint = %q, want platform-managed/user-defined guidance", p.Hint)
|
||||
}
|
||||
if strings.Contains(p.Hint, "apps +list") {
|
||||
t.Fatalf("hint should not point at app listing for protected env vars: %q", p.Hint)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
if err := runAppsShortcut(t, AppsEnvVarDelete,
|
||||
[]string{"+envvar-delete", "--app-id", "app_x", "--env", "online",
|
||||
[]string{"+env-delete", "--app-id", "app_x", "--environment", "online",
|
||||
"--key", "SECRET_ONE", "--key", "SECRET_TWO", "--dry-run", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("dry-run err=%v", err)
|
||||
}
|
||||
@@ -281,14 +379,23 @@ func TestAppsEnvVarDelete_OnlineDryRunDoesNotRequireYes(t *testing.T) {
|
||||
func TestAppsEnvVarList_InvalidEnvTypedValidation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+envvar-list", "--app-id", "app_x", "--env", "prod", "--as", "user"}, factory, stdout)
|
||||
requireEnvVarValidationProblem(t, err, "--env")
|
||||
[]string{"+env-list", "--app-id", "app_x", "--environment", "prod", "--as", "user"}, factory, stdout)
|
||||
requireEnvVarValidationProblem(t, err, "--environment")
|
||||
}
|
||||
|
||||
func TestAppsEnvVarList_OldEnvFlagIsNotAlias(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarList,
|
||||
[]string{"+env-list", "--app-id", "app_x", "--env", "online", "--as", "user"}, factory, stdout)
|
||||
if err == nil || !strings.Contains(err.Error(), "unknown flag: --env") {
|
||||
t.Fatalf("expected old --env to be rejected, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarSet,
|
||||
[]string{"+envvar-set", "--app-id", "app_x", "--key", "bad-key",
|
||||
[]string{"+env-set", "--app-id", "app_x", "--key", "bad-key",
|
||||
"--value", "super-secret", "--as", "user"}, factory, stdout)
|
||||
requireEnvVarValidationProblem(t, err, "--key")
|
||||
}
|
||||
@@ -296,7 +403,7 @@ func TestAppsEnvVarSet_InvalidKeyTypedValidation(t *testing.T) {
|
||||
func TestAppsEnvVarDelete_InvalidKeyTypedValidation(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsEnvVarDelete,
|
||||
[]string{"+envvar-delete", "--app-id", "app_x", "--key", "bad-key",
|
||||
[]string{"+env-delete", "--app-id", "app_x", "--key", "bad-key",
|
||||
"--yes", "--as", "user"}, factory, stdout)
|
||||
requireEnvVarValidationProblem(t, err, "--key")
|
||||
}
|
||||
@@ -14,6 +14,9 @@ func TestAppsShortcutsHaveExamples(t *testing.T) {
|
||||
email := regexp.MustCompile(`[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}`)
|
||||
phone := regexp.MustCompile(`\b1[3-9]\d{9}\b`)
|
||||
for _, s := range Shortcuts() {
|
||||
if s.Hidden {
|
||||
continue
|
||||
}
|
||||
hasExample := false
|
||||
for _, tip := range s.Tips {
|
||||
if strings.HasPrefix(tip, "Example: lark-cli apps +") {
|
||||
@@ -51,15 +54,15 @@ func TestHighFreqCommandsHaveMultipleExamples(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsEnvVarTipsCoverConfirmations(t *testing.T) {
|
||||
envvarSet := requireShortcutForExamples(t, "+envvar-set")
|
||||
if !tipsContainAll(envvarSet.Tips, "--env online", "--yes") {
|
||||
t.Fatalf("+envvar-set tips must include an online write example with --env online --yes: %#v", envvarSet.Tips)
|
||||
func TestAppsEnvTipsCoverConfirmations(t *testing.T) {
|
||||
envSet := requireShortcutForExamples(t, "+env-set")
|
||||
if !tipsContainAll(envSet.Tips, "--environment online", "--yes") {
|
||||
t.Fatalf("+env-set tips must include an online write example with --environment online --yes: %#v", envSet.Tips)
|
||||
}
|
||||
|
||||
envvarDelete := requireShortcutForExamples(t, "+envvar-delete")
|
||||
if !tipsContainAll(envvarDelete.Tips, "--yes") {
|
||||
t.Fatalf("+envvar-delete tips must include --yes: %#v", envvarDelete.Tips)
|
||||
envDelete := requireShortcutForExamples(t, "+env-delete")
|
||||
if !tipsContainAll(envDelete.Tips, "--yes") {
|
||||
t.Fatalf("+env-delete tips must include --yes: %#v", envDelete.Tips)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +76,7 @@ func TestAppsObservabilityTipsMentionOnlineOnly(t *testing.T) {
|
||||
"+analytics-query",
|
||||
} {
|
||||
shortcut := requireShortcutForExamples(t, cmd)
|
||||
if !tipsContainAll(shortcut.Tips, "online-only", "--env online") {
|
||||
if !tipsContainAll(shortcut.Tips, "online-only", "--environment online") {
|
||||
t.Fatalf("%s tips should mention online-only env: %#v", cmd, shortcut.Tips)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ func TestAppsEnvPull_4xxFailureCarriesListHint(t *testing.T) {
|
||||
Status: http.StatusForbidden,
|
||||
Body: map[string]interface{}{"msg": "permission denied"},
|
||||
OnMatch: func(req *http.Request) {
|
||||
assertEnvVarBody(t, req, map[string]interface{}{"env": "dev"})
|
||||
assertEnvPullBody(t, req)
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ import (
|
||||
const (
|
||||
defaultAppsPageSize = 50
|
||||
maxAppsPageSize = 100
|
||||
appsEnvironmentFlag = "environment"
|
||||
|
||||
// The CLI exposes the user-facing online environment, while the
|
||||
// observability backend stores online app runtime telemetry under runtime.
|
||||
@@ -34,8 +35,8 @@ func validateObservabilityEnv(env string) error {
|
||||
case "", "online":
|
||||
return nil
|
||||
default:
|
||||
return appsValidationParamError("--env", "observability commands only support online (got %q)", env).
|
||||
WithHint("only online is supported; omit --env to use the default online environment")
|
||||
return appsValidationParamError("--environment", "observability commands only support online (got %q)", env).
|
||||
WithHint("only online is supported; omit --environment to use the default online environment")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,7 +45,7 @@ func validateEnvVarEnv(env string) error {
|
||||
case "dev", "online":
|
||||
return nil
|
||||
default:
|
||||
return appsValidationParamError("--env", "env var commands only support --env dev or --env online (got %q)", env)
|
||||
return appsValidationParamError("--environment", "env var commands only support --environment dev or --environment online (got %q)", env)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -33,9 +33,9 @@ func TestAppsObservabilityValidateEnvOnlyOnline(t *testing.T) {
|
||||
t.Fatalf("online should pass: %v", err)
|
||||
}
|
||||
err := validateObservabilityEnv("dev")
|
||||
p := requireAppsValidationParam(t, err, "--env")
|
||||
p := requireAppsValidationParam(t, err, "--environment")
|
||||
if p.Subtype != errs.SubtypeInvalidArgument {
|
||||
t.Fatalf("problem = %#v, want invalid_argument param --env", p)
|
||||
t.Fatalf("problem = %#v, want invalid_argument param --environment", p)
|
||||
}
|
||||
if !strings.Contains(p.Hint, "only online is supported") {
|
||||
t.Fatalf("hint = %q, want only-online guidance", p.Hint)
|
||||
@@ -63,8 +63,8 @@ func TestAppsObservabilityCommonHelpers(t *testing.T) {
|
||||
t.Fatalf("validateEnvVarEnv(%q) err=%v", env, err)
|
||||
}
|
||||
}
|
||||
requireAppsValidationParam(t, validateEnvVarEnv(""), "--env")
|
||||
requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--env")
|
||||
requireAppsValidationParam(t, validateEnvVarEnv(""), "--environment")
|
||||
requireAppsValidationParam(t, validateEnvVarEnv("boe"), "--environment")
|
||||
got := cleanRepeatedStrings([]string{" a ", "b", "a", "", "b", "c"})
|
||||
want := []string{"a", "b", "c"}
|
||||
if len(got) != len(want) {
|
||||
|
||||
@@ -8,18 +8,29 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/larksuite/cli/errs"
|
||||
"github.com/larksuite/cli/shortcuts/common"
|
||||
larkcore "github.com/larksuite/oapi-sdk-go/v3/core"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultAppsLogEnv = "online"
|
||||
logSearchEndpoint = "search_logs"
|
||||
resolveStackEndpoint = "resolve_stack_trace"
|
||||
sourceStackStatusOK = "resolved"
|
||||
sourceStackStatusError = "unresolved"
|
||||
defaultAppsLogEnv = "online"
|
||||
logSearchEndpoint = "search_logs"
|
||||
resolveStackEndpoint = "resolve_stack_trace"
|
||||
sourceStackStatusOK = "resolved"
|
||||
sourceStackStatusError = "unresolved"
|
||||
sourceStackMaxScanDepth = 8
|
||||
sourceStackMaxFrames = 2000
|
||||
defaultSourceMapPrefix = "client/assets/"
|
||||
)
|
||||
|
||||
var (
|
||||
jsStackFrameParenRe = regexp.MustCompile(`^\s*(?:at\s+(.+?)\s+)?\((.+):(\d+):(\d+)\)\s*$`)
|
||||
jsStackFrameBareRe = regexp.MustCompile(`^\s*(?:at\s+)?(.+):(\d+):(\d+)\s*$`)
|
||||
)
|
||||
|
||||
// AppsLogList searches online app logs with observability filters.
|
||||
@@ -37,7 +48,7 @@ var AppsLogList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true},
|
||||
{Name: "env", Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"},
|
||||
{Name: "until", Desc: "end time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339"},
|
||||
{Name: "level", Type: "string_array", Desc: "log level filter; repeatable, one of DEBUG, INFO, WARN, ERROR (case-insensitive)"},
|
||||
@@ -100,7 +111,7 @@ var AppsLogGet = common.Shortcut{
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID whose online logs should be searched", Required: true},
|
||||
{Name: "log-id", Desc: "log ID to fetch", Required: true},
|
||||
{Name: "env", Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsLogEnv, Desc: "observability environment; only online is supported"},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
if _, err := requireAppID(rctx.Str("app-id")); err != nil {
|
||||
@@ -109,7 +120,7 @@ var AppsLogGet = common.Shortcut{
|
||||
if strings.TrimSpace(rctx.Str("log-id")) == "" {
|
||||
return appsValidationParamError("--log-id", "--log-id is required")
|
||||
}
|
||||
return validateObservabilityEnv(rctx.Str("env"))
|
||||
return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag))
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
@@ -119,14 +130,14 @@ var AppsLogGet = common.Shortcut{
|
||||
},
|
||||
Execute: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
appID, _ := requireAppID(rctx.Str("app-id"))
|
||||
data, err := rctx.CallAPITyped("POST", logSearchPath(appID), nil, buildLogGetSearchBody(rctx))
|
||||
data, err := callLogGetSearch(rctx, appID, buildLogGetSearchBody(rctx))
|
||||
if err != nil {
|
||||
return withAppsHint(err, appIDListHint)
|
||||
}
|
||||
out := normalizeLogSearchResponse(data)
|
||||
if len(out.Items) == 0 {
|
||||
return appsFailedPreconditionParamError("--log-id", "log not found").
|
||||
WithHint("verify --log-id and --env online")
|
||||
WithHint("verify --log-id and --environment online")
|
||||
}
|
||||
log := out.Items[0]
|
||||
enrichLogSourceStack(rctx, appID, log)
|
||||
@@ -137,6 +148,25 @@ var AppsLogGet = common.Shortcut{
|
||||
},
|
||||
}
|
||||
|
||||
func callLogGetSearch(rctx *common.RuntimeContext, appID string, body map[string]interface{}) (map[string]interface{}, error) {
|
||||
resp, err := rctx.DoAPI(&larkcore.ApiReq{
|
||||
HttpMethod: "POST",
|
||||
ApiPath: logSearchPath(appID),
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, err := rctx.ClassifyAPIResponse(resp)
|
||||
if err == nil && data != nil {
|
||||
return data, nil
|
||||
}
|
||||
if flex, ok := flexibleLogSearchData(resp.RawBody); ok && (err == nil || isNonObjectInvalidResponse(err)) {
|
||||
return flex, nil
|
||||
}
|
||||
return data, err
|
||||
}
|
||||
|
||||
type logSearchOutput struct {
|
||||
Items []map[string]interface{} `json:"items"`
|
||||
PageToken string `json:"page_token,omitempty"`
|
||||
@@ -152,7 +182,7 @@ func resolveStackPath(appID string) string {
|
||||
}
|
||||
|
||||
func buildLogSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
env := strings.TrimSpace(rctx.Str("env"))
|
||||
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
|
||||
if env == "" {
|
||||
env = defaultAppsLogEnv
|
||||
}
|
||||
@@ -330,6 +360,40 @@ func firstMapSlice(data map[string]interface{}, keys ...string) []map[string]int
|
||||
return nil
|
||||
}
|
||||
|
||||
func flexibleLogSearchData(raw []byte) (map[string]interface{}, bool) {
|
||||
var result interface{}
|
||||
if err := json.Unmarshal(raw, &result); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
switch value := result.(type) {
|
||||
case []interface{}:
|
||||
return map[string]interface{}{"items": value}, true
|
||||
case map[string]interface{}:
|
||||
data, ok := value["data"]
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
items, ok := data.([]interface{})
|
||||
if !ok {
|
||||
return nil, false
|
||||
}
|
||||
out := map[string]interface{}{"items": items}
|
||||
for _, key := range []string{"page_token", "next_page_token", "pageToken", "nextPageToken", "has_more", "hasMore"} {
|
||||
if v, present := value[key]; present {
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
return out, true
|
||||
default:
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func isNonObjectInvalidResponse(err error) bool {
|
||||
p, ok := errs.ProblemOf(err)
|
||||
return ok && p.Category == errs.CategoryInternal && p.Subtype == errs.SubtypeInvalidResponse
|
||||
}
|
||||
|
||||
func firstLogString(data map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if s, ok := data[key].(string); ok && strings.TrimSpace(s) != "" {
|
||||
@@ -429,8 +493,7 @@ func enrichLogSourceStack(rctx *common.RuntimeContext, appID string, log map[str
|
||||
data, err := rctx.CallAPITyped("POST", resolveStackPath(appID), nil, body)
|
||||
if err != nil {
|
||||
if _, typed := errs.ProblemOf(err); typed {
|
||||
log["source_stack_status"] = sourceStackStatusError
|
||||
log["source_stack_reason"] = "resolve_stack_trace failed"
|
||||
markSourceStackResolveError(log, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -442,6 +505,20 @@ func enrichLogSourceStack(rctx *common.RuntimeContext, appID string, log map[str
|
||||
log["source_stack"] = stack
|
||||
}
|
||||
|
||||
func markSourceStackResolveError(log map[string]interface{}, err error) {
|
||||
log["source_stack_status"] = sourceStackStatusError
|
||||
log["source_stack_reason"] = "resolve_stack_trace failed"
|
||||
if problem, ok := errs.ProblemOf(err); ok {
|
||||
if problem.Code != 0 {
|
||||
log["source_stack_error_code"] = problem.Code
|
||||
log["source_stack_reason"] = fmt.Sprintf("resolve_stack_trace failed: code %d", problem.Code)
|
||||
}
|
||||
if problem.LogID != "" {
|
||||
log["source_stack_log_id"] = problem.LogID
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func shouldResolveSourceStack(log map[string]interface{}) bool {
|
||||
level := strings.ToUpper(firstItemString(log, "level", "severity_text", "severityText"))
|
||||
if level != "ERROR" {
|
||||
@@ -479,24 +556,78 @@ func isSourceMapSignal(value string) bool {
|
||||
}
|
||||
|
||||
func extractSourceStackResolveBody(log map[string]interface{}) (map[string]interface{}, bool) {
|
||||
sources := []map[string]interface{}{log}
|
||||
if attrs, ok := log["attributes"].(map[string]interface{}); ok {
|
||||
sources = append([]map[string]interface{}{attrs}, sources...)
|
||||
}
|
||||
if bodyMap, ok := log["body"].(map[string]interface{}); ok {
|
||||
sources = append([]map[string]interface{}{bodyMap}, sources...)
|
||||
}
|
||||
commitID := firstStringInMaps(sources, "commit_id", "commitID", "commitId")
|
||||
sources := collectSourceStackMaps(log)
|
||||
commitID := firstStringInMaps(sources, "commit_id", "commitID", "commitId", "release_commit_id", "releaseCommitID", "releaseCommitId")
|
||||
prefix := firstStringInMaps(sources, "source_map_file_prefix", "sourceMapFilePrefix", "source_map_prefix", "sourceMapPrefix")
|
||||
frames := firstFramesInMaps(sources, "frames", "stack_frames", "stackFrames", "source_stack_frames", "sourceStackFrames")
|
||||
if prefix == "" && firstStringInMaps(sources, "release_commit_id", "releaseCommitID", "releaseCommitId") != "" {
|
||||
prefix = defaultSourceMapPrefix
|
||||
}
|
||||
frames := firstFramesInMaps(
|
||||
sources,
|
||||
"frames",
|
||||
"stack_frames",
|
||||
"stackFrames",
|
||||
"source_stack_frames",
|
||||
"sourceStackFrames",
|
||||
"stack",
|
||||
"stack_trace",
|
||||
"stackTrace",
|
||||
"error_stack",
|
||||
"errorStack",
|
||||
"exception_stack",
|
||||
"exceptionStack",
|
||||
"message",
|
||||
"body",
|
||||
)
|
||||
if commitID == "" || prefix == "" || len(frames) == 0 {
|
||||
return nil, false
|
||||
}
|
||||
return map[string]interface{}{
|
||||
body := map[string]interface{}{
|
||||
"commit_id": commitID,
|
||||
"source_map_file_prefix": prefix,
|
||||
"frames": frames,
|
||||
}, true
|
||||
}
|
||||
if tenantID := firstStringInMaps(sources, "tenant_id", "tenantID", "tenantId"); tenantID != "" {
|
||||
body["tenant_id"] = tenantID
|
||||
}
|
||||
return body, true
|
||||
}
|
||||
|
||||
func collectSourceStackMaps(value interface{}) []map[string]interface{} {
|
||||
out := make([]map[string]interface{}, 0, 8)
|
||||
collectSourceStackMapsInto(value, 0, &out)
|
||||
return out
|
||||
}
|
||||
|
||||
func collectSourceStackMapsInto(value interface{}, depth int, out *[]map[string]interface{}) {
|
||||
if depth > sourceStackMaxScanDepth || value == nil {
|
||||
return
|
||||
}
|
||||
switch v := value.(type) {
|
||||
case map[string]interface{}:
|
||||
*out = append(*out, v)
|
||||
for _, nested := range v {
|
||||
collectSourceStackMapsInto(nested, depth+1, out)
|
||||
}
|
||||
case []interface{}:
|
||||
if attrs := observabilityKVList(v); len(attrs) > 0 {
|
||||
*out = append(*out, attrs)
|
||||
for _, nested := range attrs {
|
||||
collectSourceStackMapsInto(nested, depth+1, out)
|
||||
}
|
||||
}
|
||||
for _, nested := range v {
|
||||
collectSourceStackMapsInto(nested, depth+1, out)
|
||||
}
|
||||
case []map[string]interface{}:
|
||||
for _, nested := range v {
|
||||
collectSourceStackMapsInto(nested, depth+1, out)
|
||||
}
|
||||
case string:
|
||||
if parsed := parseJSONObjectString(v); parsed != nil {
|
||||
collectSourceStackMapsInto(parsed, depth+1, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func firstStringInMaps(sources []map[string]interface{}, keys ...string) string {
|
||||
@@ -509,8 +640,8 @@ func firstStringInMaps(sources []map[string]interface{}, keys ...string) string
|
||||
}
|
||||
|
||||
func firstFramesInMaps(sources []map[string]interface{}, keys ...string) []interface{} {
|
||||
for _, source := range sources {
|
||||
for _, key := range keys {
|
||||
for _, key := range keys {
|
||||
for _, source := range sources {
|
||||
frames := normalizeFrames(source[key])
|
||||
if len(frames) > 0 {
|
||||
return frames
|
||||
@@ -525,16 +656,22 @@ func normalizeFrames(raw interface{}) []interface{} {
|
||||
case []interface{}:
|
||||
out := make([]interface{}, 0, len(frames))
|
||||
for _, frame := range frames {
|
||||
if isNonEmptyFrame(frame) {
|
||||
out = append(out, frame)
|
||||
if normalized, ok := normalizeFrame(frame); ok {
|
||||
out = append(out, normalized)
|
||||
if len(out) >= sourceStackMaxFrames {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []map[string]interface{}:
|
||||
out := make([]interface{}, 0, len(frames))
|
||||
for _, frame := range frames {
|
||||
if len(frame) > 0 {
|
||||
out = append(out, frame)
|
||||
if normalized, ok := normalizeFrame(frame); ok {
|
||||
out = append(out, normalized)
|
||||
if len(out) >= sourceStackMaxFrames {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -545,19 +682,106 @@ func normalizeFrames(raw interface{}) []interface{} {
|
||||
}
|
||||
}
|
||||
|
||||
func isNonEmptyFrame(frame interface{}) bool {
|
||||
func normalizeFrame(frame interface{}) (map[string]interface{}, bool) {
|
||||
switch f := frame.(type) {
|
||||
case map[string]interface{}:
|
||||
return len(f) > 0
|
||||
return normalizeFrameMap(f)
|
||||
case map[string]string:
|
||||
return len(f) > 0
|
||||
m := make(map[string]interface{}, len(f))
|
||||
for key, value := range f {
|
||||
m[key] = value
|
||||
}
|
||||
return normalizeFrameMap(m)
|
||||
case string:
|
||||
return strings.TrimSpace(f) != ""
|
||||
parsed := parseJSStackFrameLine(f)
|
||||
if _, ok := parsed["file_name"]; !ok {
|
||||
return nil, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return frame != nil
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFrameMap(frame map[string]interface{}) (map[string]interface{}, bool) {
|
||||
fileName := normalizeSourceFrameFileName(firstLogString(frame, "file_name", "fileName", "filename", "file", "url"))
|
||||
line, lineOK := firstFrameInt(frame, "line", "line_number", "lineNumber")
|
||||
column, columnOK := firstFrameInt(frame, "column", "col", "column_number", "columnNumber")
|
||||
if fileName == "" || !lineOK || !columnOK {
|
||||
return nil, false
|
||||
}
|
||||
out := map[string]interface{}{
|
||||
"file_name": fileName,
|
||||
"line": line,
|
||||
"column": column,
|
||||
}
|
||||
if fn := firstLogString(frame, "function", "function_name", "functionName", "method", "methodName"); fn != "" {
|
||||
out["function"] = fn
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
|
||||
func normalizeSourceFrameFileName(fileName string) string {
|
||||
fileName = strings.TrimSpace(fileName)
|
||||
if fileName == "" {
|
||||
return ""
|
||||
}
|
||||
parts := strings.FieldsFunc(fileName, func(r rune) bool {
|
||||
return r == '/' || r == '?' || r == '#'
|
||||
})
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
if part := strings.TrimSpace(parts[i]); part != "" {
|
||||
return part
|
||||
}
|
||||
}
|
||||
return fileName
|
||||
}
|
||||
|
||||
func firstFrameInt(frame map[string]interface{}, keys ...string) (int, bool) {
|
||||
for _, key := range keys {
|
||||
if value, ok := frame[key]; ok {
|
||||
if n, valid := frameInt(value); valid {
|
||||
return n, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func frameInt(value interface{}) (int, bool) {
|
||||
switch v := value.(type) {
|
||||
case int:
|
||||
return positiveFrameInt(v)
|
||||
case int64:
|
||||
if v > int64(^uint(0)>>1) {
|
||||
return 0, false
|
||||
}
|
||||
return positiveFrameInt(int(v))
|
||||
case float64:
|
||||
if v != float64(int(v)) {
|
||||
return 0, false
|
||||
}
|
||||
return positiveFrameInt(int(v))
|
||||
case json.Number:
|
||||
n, err := strconv.Atoi(v.String())
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return positiveFrameInt(n)
|
||||
case string:
|
||||
return parsePositiveInt(v)
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
func positiveFrameInt(n int) (int, bool) {
|
||||
if n < 1 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func parseFrameString(raw string) []interface{} {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
@@ -571,13 +795,78 @@ func parseFrameString(raw string) []interface{} {
|
||||
out := make([]interface{}, 0, len(lines))
|
||||
for _, line := range lines {
|
||||
line = strings.TrimSpace(line)
|
||||
if line != "" {
|
||||
out = append(out, map[string]interface{}{"raw": line})
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
if frame, ok := normalizeFrame(parseJSStackFrameLine(line)); ok {
|
||||
out = append(out, frame)
|
||||
if len(out) >= sourceStackMaxFrames {
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func parseJSStackFrameLine(line string) map[string]interface{} {
|
||||
if frame := parseJSStackFrameMatch(line, jsStackFrameParenRe.FindStringSubmatch(line)); frame != nil {
|
||||
return frame
|
||||
}
|
||||
if frame := parseJSStackFrameMatch(line, jsStackFrameBareRe.FindStringSubmatch(line)); frame != nil {
|
||||
return frame
|
||||
}
|
||||
return map[string]interface{}{"raw": line}
|
||||
}
|
||||
|
||||
func parseJSStackFrameMatch(raw string, match []string) map[string]interface{} {
|
||||
if match == nil {
|
||||
return nil
|
||||
}
|
||||
switch len(match) {
|
||||
case 4:
|
||||
line, lineOK := parsePositiveInt(match[2])
|
||||
column, columnOK := parsePositiveInt(match[3])
|
||||
if lineOK && columnOK {
|
||||
return map[string]interface{}{"file_name": normalizeSourceFrameFileName(match[1]), "line": line, "column": column}
|
||||
}
|
||||
case 5:
|
||||
line, lineOK := parsePositiveInt(match[3])
|
||||
column, columnOK := parsePositiveInt(match[4])
|
||||
if lineOK && columnOK {
|
||||
out := map[string]interface{}{
|
||||
"file_name": normalizeSourceFrameFileName(match[2]),
|
||||
"line": line,
|
||||
"column": column,
|
||||
}
|
||||
if fn := strings.TrimSpace(match[1]); fn != "" {
|
||||
out["function"] = fn
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
return map[string]interface{}{"raw": raw}
|
||||
}
|
||||
|
||||
func parseJSONObjectString(raw string) map[string]interface{} {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || !strings.HasPrefix(raw, "{") {
|
||||
return nil
|
||||
}
|
||||
var parsed map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||||
return nil
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func parsePositiveInt(raw string) (int, bool) {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil || n < 1 {
|
||||
return 0, false
|
||||
}
|
||||
return n, true
|
||||
}
|
||||
|
||||
func firstLogValue(data map[string]interface{}, keys ...string) interface{} {
|
||||
for _, key := range keys {
|
||||
if value, ok := data[key]; ok {
|
||||
|
||||
@@ -74,8 +74,8 @@ func TestAppsLogList_DoesNotAcceptLogIDFlag(t *testing.T) {
|
||||
|
||||
func TestAppsLogList_RejectsDevEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, factory, stdout)
|
||||
requireAppsValidationParam(t, err, "--env")
|
||||
err := runAppsShortcut(t, AppsLogList, []string{"+log-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout)
|
||||
requireAppsValidationParam(t, err, "--environment")
|
||||
}
|
||||
|
||||
func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) {
|
||||
@@ -108,6 +108,49 @@ func TestAppsLogGet_SearchesByLogIDLimitOne(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsLogGet_AcceptsDataArraySearchResponse(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
search := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
|
||||
RawBody: []byte(`{
|
||||
"code": 0,
|
||||
"data": [
|
||||
{
|
||||
"log_id": "LOG7655249917057764881",
|
||||
"level": "ERROR",
|
||||
"attributes": {
|
||||
"commit_id": "commit_array",
|
||||
"source_map_file_prefix": "sourcemaps/array",
|
||||
"frames": [{"file":"main.js","line":10,"column":20}]
|
||||
}
|
||||
}
|
||||
]
|
||||
}`),
|
||||
}
|
||||
resolve := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"source_stack": []interface{}{
|
||||
map[string]interface{}{"file": "src/App.tsx", "line": 7, "column": 9},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(search)
|
||||
reg.Register(resolve)
|
||||
|
||||
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") {
|
||||
t.Fatalf("stdout missing resolved source stack from data array response: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsLogList_NormalizesResponseVariantsAndCanonicalLevel(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
reg.Register(&httpmock.Stub{
|
||||
@@ -297,6 +340,212 @@ func TestAppsLogGet_ResolvesSourceStackWhenFieldsPresent(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsLogGet_ResolvesSourceStackFromNestedKVAttributes(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
search := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"log_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"log_id": "LOG7655249917057764881",
|
||||
"severityText": "ERROR",
|
||||
"attributes": []interface{}{
|
||||
map[string]interface{}{"key": "commit_id", "value": "commit_nested"},
|
||||
map[string]interface{}{"key": "source_map_file_prefix", "value": "sourcemaps/nested"},
|
||||
map[string]interface{}{
|
||||
"key": "exception",
|
||||
"value": map[string]interface{}{
|
||||
"stackTrace": strings.Join([]string{
|
||||
"TypeError: failed to render",
|
||||
" at render (https://cdn.example.com/assets/main.js:12:34)",
|
||||
" at https://cdn.example.com/assets/chunk.js:56:78",
|
||||
}, "\n"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resolve := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"source_stack": []interface{}{
|
||||
map[string]interface{}{"file": "src/App.tsx", "line": 12, "column": 34},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(search)
|
||||
reg.Register(resolve)
|
||||
|
||||
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent["commit_id"] != "commit_nested" || sent["source_map_file_prefix"] != "sourcemaps/nested" {
|
||||
t.Fatalf("resolve body missing nested source map fields: %#v", sent)
|
||||
}
|
||||
frames, ok := sent["frames"].([]interface{})
|
||||
if !ok || len(frames) != 2 {
|
||||
t.Fatalf("resolve frames = %#v, want parsed stack frames", sent["frames"])
|
||||
}
|
||||
frame, ok := frames[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("parsed frame = %#v, want object", frames[0])
|
||||
}
|
||||
if frame["function"] != "render" || frame["file_name"] != "main.js" || frame["line"] != float64(12) || frame["column"] != float64(34) {
|
||||
t.Fatalf("parsed frame = %#v", frame)
|
||||
}
|
||||
bare, ok := frames[1].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("bare frame = %#v, want object", frames[1])
|
||||
}
|
||||
if bare["file_name"] != "chunk.js" || bare["line"] != float64(56) || bare["column"] != float64(78) {
|
||||
t.Fatalf("bare frame = %#v", bare)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") {
|
||||
t.Fatalf("stdout missing resolved source stack: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsLogGet_ResolvesSourceStackFromReleaseCommitJSONStack(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
search := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"log_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"log_id": "LOG7655249917057764881",
|
||||
"severityText": "ERROR",
|
||||
"attributes": map[string]interface{}{
|
||||
"tenant_id": "110564",
|
||||
"release_commit_id": "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f",
|
||||
"stack": `[{"fileName":"main.js","line":3348,"column":540585},` +
|
||||
`{"fileName":"main.js","line":3107,"column":51935},` +
|
||||
`{"fileName":"main.js","line":62,"column":12516}]`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resolve := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"source_stack": []interface{}{
|
||||
map[string]interface{}{"file": "src/App.tsx", "line": 42, "column": 7},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(search)
|
||||
reg.Register(resolve)
|
||||
|
||||
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG7655249917057764881", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent["commit_id"] != "4b393e4e0ca9ca1a855ba4585bc6750a7db2266f" || sent["source_map_file_prefix"] != defaultSourceMapPrefix || sent["tenant_id"] != "110564" {
|
||||
t.Fatalf("resolve body missing release source map fields: %#v", sent)
|
||||
}
|
||||
frames, ok := sent["frames"].([]interface{})
|
||||
if !ok || len(frames) != 3 {
|
||||
t.Fatalf("resolve frames = %#v, want all valid generated frames", sent["frames"])
|
||||
}
|
||||
first, ok := frames[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("first frame = %#v, want object", frames[0])
|
||||
}
|
||||
if first["file_name"] != "main.js" || first["line"] != float64(3348) || first["column"] != float64(540585) {
|
||||
t.Fatalf("first frame = %#v", first)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/App.tsx") {
|
||||
t.Fatalf("stdout missing resolved source stack: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsLogGet_ResolvesSourceStackFromJSONBodyStack(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
search := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/search_logs",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"log_items": []interface{}{
|
||||
map[string]interface{}{
|
||||
"log_id": "LOG_BODY_STACK",
|
||||
"severityText": "ERROR",
|
||||
"attributes": map[string]interface{}{
|
||||
"release_commit_id": "commit_body",
|
||||
},
|
||||
"body": `{"error":{"stack":"AxiosError: failed\n at request (https://cdn.example.com/client/assets/body.js:9:88)"}}`,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resolve := &httpmock.Stub{
|
||||
Method: "POST",
|
||||
URL: "/open-apis/spark/v1/apps/app_x/resolve_stack_trace",
|
||||
Body: map[string]interface{}{
|
||||
"code": 0,
|
||||
"data": map[string]interface{}{
|
||||
"source_stack": []interface{}{
|
||||
map[string]interface{}{"file": "src/request.ts", "line": 9, "column": 88},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
reg.Register(search)
|
||||
reg.Register(resolve)
|
||||
|
||||
if err := runAppsShortcut(t, AppsLogGet, []string{"+log-get", "--app-id", "app_x", "--log-id", "LOG_BODY_STACK", "--as", "user"}, factory, stdout); err != nil {
|
||||
t.Fatalf("execute err=%v", err)
|
||||
}
|
||||
var sent map[string]interface{}
|
||||
if err := json.Unmarshal(resolve.CapturedBody, &sent); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sent["commit_id"] != "commit_body" || sent["source_map_file_prefix"] != defaultSourceMapPrefix {
|
||||
t.Fatalf("resolve body missing body stack source map fields: %#v", sent)
|
||||
}
|
||||
frames, ok := sent["frames"].([]interface{})
|
||||
if !ok || len(frames) != 1 {
|
||||
t.Fatalf("resolve frames = %#v, want parsed JSON body stack frame", sent["frames"])
|
||||
}
|
||||
frame, ok := frames[0].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("frame = %#v, want object", frames[0])
|
||||
}
|
||||
if frame["function"] != "request" || frame["file_name"] != "body.js" || frame["line"] != float64(9) || frame["column"] != float64(88) {
|
||||
t.Fatalf("frame = %#v", frame)
|
||||
}
|
||||
if got := stdout.String(); !strings.Contains(got, `"source_stack_status": "resolved"`) || !strings.Contains(got, "src/request.ts") {
|
||||
t.Fatalf("stdout missing resolved source stack: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsLogGet_SourceStackMissingFieldsDoesNotFail(t *testing.T) {
|
||||
factory, stdout, reg := newAppsExecuteFactory(t)
|
||||
search := &httpmock.Stub{
|
||||
@@ -404,6 +653,9 @@ func TestAppsLogGet_SourceStackResolveFailureIsRedacted(t *testing.T) {
|
||||
if !strings.Contains(got, `"source_stack_status": "unresolved"`) {
|
||||
t.Fatalf("stdout missing unresolved status: %s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"source_stack_error_code": 999`) {
|
||||
t.Fatalf("stdout missing resolve error code: %s", got)
|
||||
}
|
||||
for _, banned := range []string{"secret", "token", "raw request payload"} {
|
||||
if strings.Contains(strings.ToLower(got), banned) {
|
||||
t.Fatalf("stdout leaked %q: %s", banned, got)
|
||||
|
||||
@@ -40,7 +40,7 @@ var AppsMetricQuery = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID whose online metrics should be queried", Required: true},
|
||||
{Name: "env", Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsMetricEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: "metric", Desc: "metric family to query", Required: true, Enum: []string{"requests", "latency", "cpu", "memory"}},
|
||||
{Name: "series", Desc: "metric series within the family, such as total/error or p50/p99"},
|
||||
{Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"},
|
||||
@@ -102,7 +102,7 @@ var AppsAnalyticsQuery = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID whose online analytics should be queried", Required: true},
|
||||
{Name: "env", Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsAnalyticsEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: "analytics", Desc: "analytics family to query", Required: true, Enum: []string{"users", "page-view"}},
|
||||
{Name: "series", Desc: "analytics series within the family, such as active-users or desktop-view"},
|
||||
{Name: "since", Desc: "start time, relative duration (30s, 5m, 0.5h, 2h, 3d, 1w), local date/time, or RFC3339; defaults to 30 days before --until"},
|
||||
@@ -163,7 +163,7 @@ func analyticsQueryPath(appID string) string {
|
||||
}
|
||||
|
||||
func buildMetricQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, bool, error) {
|
||||
env := strings.TrimSpace(rctx.Str("env"))
|
||||
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
|
||||
if env == "" {
|
||||
env = defaultAppsMetricEnv
|
||||
}
|
||||
@@ -221,7 +221,7 @@ func buildMetricQueryFilter(rctx *common.RuntimeContext) map[string]interface{}
|
||||
}
|
||||
|
||||
func buildAnalyticsQueryBody(rctx *common.RuntimeContext) (map[string]interface{}, []string, []string, error) {
|
||||
env := strings.TrimSpace(rctx.Str("env"))
|
||||
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
|
||||
if env == "" {
|
||||
env = defaultAppsAnalyticsEnv
|
||||
}
|
||||
|
||||
@@ -108,9 +108,9 @@ func TestAppsMetricQuery_AutoDownSampleByRange(t *testing.T) {
|
||||
func TestAppsMetricQuery_RejectsDevEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsMetricQuery, []string{
|
||||
"+metric-query", "--app-id", "app_x", "--metric", "requests", "--env", "dev", "--as", "user",
|
||||
"+metric-query", "--app-id", "app_x", "--metric", "requests", "--environment", "dev", "--as", "user",
|
||||
}, factory, stdout)
|
||||
requireAppsValidationParam(t, err, "--env")
|
||||
requireAppsValidationParam(t, err, "--environment")
|
||||
}
|
||||
|
||||
func TestAppsMetricQuery_FillsMissingRequestValuesWithZero(t *testing.T) {
|
||||
|
||||
@@ -34,7 +34,7 @@ var AppsTraceList = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID whose online traces should be searched", Required: true},
|
||||
{Name: "env", Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: "trace-id", Type: "string_array", Desc: "trace ID filter; repeatable"},
|
||||
{Name: "root-span", Desc: "root span keyword filter applied by the trace search backend"},
|
||||
{Name: "user-id", Desc: "end user ID filter"},
|
||||
@@ -90,7 +90,7 @@ var AppsTraceGet = common.Shortcut{
|
||||
HasFormat: true,
|
||||
Flags: []common.Flag{
|
||||
{Name: "app-id", Desc: "app ID whose online trace should be fetched", Required: true},
|
||||
{Name: "env", Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: appsEnvironmentFlag, Default: defaultAppsTraceEnv, Desc: "observability environment; only online is supported"},
|
||||
{Name: "trace-id", Desc: "trace ID to fetch", Required: true},
|
||||
},
|
||||
Validate: func(ctx context.Context, rctx *common.RuntimeContext) error {
|
||||
@@ -100,7 +100,7 @@ var AppsTraceGet = common.Shortcut{
|
||||
if strings.TrimSpace(rctx.Str("trace-id")) == "" {
|
||||
return appsValidationParamError("--trace-id", "--trace-id is required")
|
||||
}
|
||||
return validateObservabilityEnv(rctx.Str("env"))
|
||||
return validateObservabilityEnv(rctx.Str(appsEnvironmentFlag))
|
||||
},
|
||||
DryRun: func(ctx context.Context, rctx *common.RuntimeContext) *common.DryRunAPI {
|
||||
return common.NewDryRunAPI().
|
||||
@@ -137,7 +137,7 @@ func traceGetPath(appID string) string {
|
||||
}
|
||||
|
||||
func buildTraceSearchBody(rctx *common.RuntimeContext) (map[string]interface{}, error) {
|
||||
env := strings.TrimSpace(rctx.Str("env"))
|
||||
env := strings.TrimSpace(rctx.Str(appsEnvironmentFlag))
|
||||
if env == "" {
|
||||
env = defaultAppsTraceEnv
|
||||
}
|
||||
|
||||
@@ -60,8 +60,8 @@ func TestAppsTraceList_DryRunBuildsSearchTracesBody(t *testing.T) {
|
||||
|
||||
func TestAppsTraceList_RejectsDevEnv(t *testing.T) {
|
||||
factory, stdout, _ := newAppsExecuteFactory(t)
|
||||
err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--env", "dev", "--as", "user"}, factory, stdout)
|
||||
requireAppsValidationParam(t, err, "--env")
|
||||
err := runAppsShortcut(t, AppsTraceList, []string{"+trace-list", "--app-id", "app_x", "--environment", "dev", "--as", "user"}, factory, stdout)
|
||||
requireAppsValidationParam(t, err, "--environment")
|
||||
}
|
||||
|
||||
func TestAppsTraceGet_DryRunBuildsGetTraceBody(t *testing.T) {
|
||||
|
||||
@@ -7,6 +7,9 @@ import "github.com/larksuite/cli/shortcuts/common"
|
||||
|
||||
// Shortcuts returns all apps domain shortcuts.
|
||||
func Shortcuts() []common.Shortcut {
|
||||
envSet := withExtraTips(AppsEnvVarSet, "Example: lark-cli apps +env-set --app-id <app_id> --environment online --key FOO --value <value> --yes")
|
||||
envDelete := withExtraTips(AppsEnvVarDelete, "Tip: +env-delete is high-risk-write; only pass --yes after explicit confirmation.")
|
||||
|
||||
return []common.Shortcut{
|
||||
AppsCreate,
|
||||
AppsUpdate,
|
||||
@@ -19,15 +22,15 @@ func Shortcuts() []common.Shortcut {
|
||||
AppsReleaseList,
|
||||
AppsReleaseGet,
|
||||
AppsEnvPull,
|
||||
withExtraTips(AppsLogList, "Tip: logs are online-only; keep --env omitted or set --env online."),
|
||||
withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --env omitted or set --env online."),
|
||||
withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --env omitted or set --env online."),
|
||||
withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --env omitted or set --env online."),
|
||||
withExtraTips(AppsMetricQuery, "Tip: metrics are online-only; keep --env omitted or set --env online."),
|
||||
withExtraTips(AppsAnalyticsQuery, "Tip: analytics are online-only; keep --env omitted or set --env online."),
|
||||
withExtraTips(AppsLogList, "Tip: logs are online-only; keep --environment omitted or set --environment online."),
|
||||
withExtraTips(AppsLogGet, "Tip: logs are online-only; keep --environment omitted or set --environment online."),
|
||||
withExtraTips(AppsTraceList, "Tip: traces are online-only; keep --environment omitted or set --environment online."),
|
||||
withExtraTips(AppsTraceGet, "Tip: traces are online-only; keep --environment omitted or set --environment online."),
|
||||
withExtraTips(AppsMetricQuery, "Tip: metrics are online-only; keep --environment omitted or set --environment online."),
|
||||
withExtraTips(AppsAnalyticsQuery, "Tip: analytics are online-only; keep --environment omitted or set --environment online."),
|
||||
AppsEnvVarList,
|
||||
withExtraTips(AppsEnvVarSet, "Example: lark-cli apps +envvar-set --app-id <app_id> --env online --key FOO --value <value> --yes"),
|
||||
withExtraTips(AppsEnvVarDelete, "Tip: +envvar-delete is high-risk-write; only pass --yes after explicit confirmation."),
|
||||
envSet,
|
||||
envDelete,
|
||||
AppsDBTableList,
|
||||
AppsDBTableGet,
|
||||
AppsDBExecute,
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
// 钉死域内 shortcut 数量。少一条(漏挂)或多一条(误加)都会被这个测试拦截。
|
||||
// 6 基础 + 1 init + 3 publish + 1 env-pull + 6 observability
|
||||
// + 3 envvar + 4 db(table-list/table-schema/sql/dev-init)
|
||||
// + 3 env + 4 db(table-list/table-schema/sql/dev-init)
|
||||
// + 3 git-credential + 5 session(create/list/get/stop/chat)+ 1 session-messages-list = 33。
|
||||
func TestAppsShortcuts_Returns33(t *testing.T) {
|
||||
got := Shortcuts()
|
||||
@@ -20,10 +20,32 @@ func TestAppsShortcuts_Returns33(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsShortcuts_DoesNotIncludeEnvVarGet(t *testing.T) {
|
||||
func TestAppsShortcuts_DoesNotIncludeEnvGet(t *testing.T) {
|
||||
for _, sc := range Shortcuts() {
|
||||
if sc.Command == "+envvar-get" {
|
||||
t.Fatalf("Shortcuts() must not register +envvar-get")
|
||||
switch sc.Command {
|
||||
case "+env-get", "+envvar-get", "+envvar-list", "+envvar-set", "+envvar-delete":
|
||||
t.Fatalf("Shortcuts() must not register %s", sc.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppsShortcuts_EnvCommandsUseCanonicalNames(t *testing.T) {
|
||||
want := map[string]bool{
|
||||
"+env-list": false,
|
||||
"+env-set": false,
|
||||
"+env-delete": false,
|
||||
}
|
||||
for _, sc := range Shortcuts() {
|
||||
if _, ok := want[sc.Command]; ok {
|
||||
want[sc.Command] = true
|
||||
if sc.Hidden {
|
||||
t.Errorf("%s must be visible", sc.Command)
|
||||
}
|
||||
}
|
||||
}
|
||||
for cmd, found := range want {
|
||||
if !found {
|
||||
t.Errorf("Shortcuts() missing canonical %s", cmd)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: lark-apps
|
||||
version: 1.0.0
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
description: "妙搭(Spark/Miaoda)应用开发与托管:应用创建、HTML静态站点发布、本地全栈开发、云端生成迭代、日志/Trace/监控指标/PV/UV 查询、环境变量管理。当用户要开发/新建一个系统·工具·平台·应用,或要本地开发 / 云端开发 / 修改 / 部署 / 发布 / 上线 / 拿可分享链接,或用 HTML 做页面·网站给人看,或提到妙搭/Spark/Miaoda、应用数据库、可见范围、线上日志、接口请求量、错误量、延迟、访问量、环境变量时使用。不负责普通云盘文件上传(lark-drive)、飞书文档编辑(lark-doc)、原生幻灯片创建(lark-slides)。"
|
||||
metadata:
|
||||
requires:
|
||||
bins: ["lark-cli"]
|
||||
@@ -24,7 +24,7 @@ metadata:
|
||||
| 发布本地 `index.html` 或静态目录为可访问 URL | `+html-publish` | [`lark-apps-html-publish.md`](references/lark-apps-html-publish.md) |
|
||||
| 开发已有应用 / 初始化本地仓库(开发方式已定为本地后;先解析 app_id,勿 `+create` 新建) | `+init`(或手动 `+git-credential-init` + 原生 git) | [`lark-apps-local-dev.md`](references/lark-apps-local-dev.md), [`lark-apps-init.md`](references/lark-apps-init.md), [`lark-apps-git-credential.md`](references/lark-apps-git-credential.md) |
|
||||
| 本地开发时 `.env.local` 损坏/丢失,重新拉取启动期环境变量 | `+env-pull` | [`lark-apps-env-pull.md`](references/lark-apps-env-pull.md) |
|
||||
| 管理应用环境变量(查看/设置/删除) | `+envvar-list`, `+envvar-set`, `+envvar-delete` | [`lark-apps-envvar.md`](references/lark-apps-envvar.md) |
|
||||
| 管理应用环境变量(查看/设置/删除) | `+env-list`, `+env-set`, `+env-delete` | [`lark-apps-env.md`](references/lark-apps-env.md) |
|
||||
| 查线上日志、Trace、请求数、错误率、延迟、CPU、memory、PV/UV/访问量 | `+log-list`, `+log-get`, `+trace-list`, `+trace-get`, `+metric-query`, `+analytics-query` | [`lark-apps-observability.md`](references/lark-apps-observability.md) |
|
||||
| 看表、看 schema、跑 SQL、初始化 dev/online 多环境 DB | `+db-table-list`, `+db-table-get`, `+db-execute`, `+db-env-create` | 对应 `lark-apps-db-*.md` |
|
||||
| **部署/上线全栈应用**("部署""上线""推上去并部署""发布到云端");查发布状态/历史 | `+release-create`(部署上线动作), `+release-get`(轮询发布结果,finished 给 online_url / failed 给 error_logs), `+release-list` | [`lark-apps-release-create.md`](references/lark-apps-release-create.md), [`lark-apps-release-get.md`](references/lark-apps-release-get.md), [`lark-apps-release-list.md`](references/lark-apps-release-list.md) |
|
||||
@@ -32,6 +32,14 @@ metadata:
|
||||
| 云端 Agent 生成/迭代应用(开发方式已定为云端后) | `+session-create` -> `+chat` -> `+session-get` | [`lark-apps-cloud-dev.md`](references/lark-apps-cloud-dev.md) |
|
||||
| 查看某次会话某一轮(turn)的回复消息(含仍在生成中的本轮)/ 导出上一轮模型回复("这一轮回复了什么""上一轮的回复""导出某轮消息") | 先 `+session-get`(取 `latest_turn.turn_id`)-> `+session-messages-list --turn-id <id>`(仅 user 身份;分页用 `--page-token`) | [`lark-apps-session-messages-list.md`](references/lark-apps-session-messages-list.md) |
|
||||
|
||||
## 高频路径
|
||||
|
||||
- **性能/监控/观测指标**:用户问“接口请求量、错误量、错误率、接口慢、延迟、CPU、内存、最近一小时/七天趋势”时,不要去当前工作区搜索监控文件,也不要询问“监控数据在哪”。先按「app_id 获取」解析应用:`lark-cli apps +list --keyword "<应用名>" --as user`;拿到 `app_id` 后读 [`lark-apps-observability.md`](references/lark-apps-observability.md),用 `+metric-query`。
|
||||
- **请求量 + 错误量 + 延迟**:请求量/错误量用 `lark-cli apps +metric-query --app-id <app_id> --metric requests --since <range> --as user`(不传 `--series` 会同时返回 total/error);延迟用 `--metric latency`(不传 `--series` 会返回 p50/p99)。如果用户给了具体接口,再加 `--api <path-or-name>`;不要臆造 group-by 参数。
|
||||
- **PV/UV/访问量/活跃用户**:先解析 `app_id`,再用 `+analytics-query`,不要误用 `+metric-query`。
|
||||
- **设置环境变量**:如果用户只给应用名,仍先 `+list --keyword` 解析 app_id;设置 online 环境且用户已经明确说“确认/直接执行”时,调用 `+env-set --environment online ... --yes`,不要再次要求确认。回复和日志摘要里只提 key / env / app,不回显真实 value;需要传复杂值时优先用 `@file` 或 stdin。
|
||||
- **删除环境变量**:`+env-delete` 是破坏性操作。除非用户在同一轮已经明确确认删除这个 app/env/key,否则先向用户确认应用、环境、key 和删除后果;确认后再加 `--yes`。不要因为认证失败/重登完成就自动继续删除,必须保留确认门槛。
|
||||
|
||||
## 选择开发路径(进意图路由前先判这步)
|
||||
|
||||
新建必先定 **app_type** 和**开发方式**两件正交的事;修改已有先按「app_id 获取」指认到 app,指认不到就问用户,不擅自 `+create`。开发方式(本地 vs 云端)只看用户对"谁来写代码"的偏好,与应用复杂度、要不要数据库无关。
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
## 何时别用(核心反模式)
|
||||
|
||||
**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并把用户刚改完的 `.env.local` 临时改动覆盖掉。
|
||||
**通常不需要手动跑**——脚手架的 `npm run dev` 在起本地开发时会自动后台拉取(非阻塞)。手动再跑会重复做同样的事,并用服务端返回值覆盖 `.env.local` 里的同名 key;本地无关行和注释会保留。
|
||||
|
||||
只在这些兜底场景用:
|
||||
|
||||
|
||||
48
skills/lark-apps/references/lark-apps-env.md
Normal file
48
skills/lark-apps/references/lark-apps-env.md
Normal file
@@ -0,0 +1,48 @@
|
||||
# apps env
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。
|
||||
|
||||
管理妙搭应用环境变量。查看用 `+env-list`,设置用 `+env-set`,删除用 `+env-delete`。没有单变量 get 命令;要确认某个 key 是否存在,使用 list 后用 `--jq` 过滤。
|
||||
|
||||
环境 flag 使用 `--environment`;不要使用旧的 `--env`,也不要使用短选项。
|
||||
|
||||
## 查看
|
||||
|
||||
`+env-list` 默认查 dev,且默认不返回 value。只有显式传 `--include-values` 后,响应中才可能出现变量值;不要在公开日志里展示带值输出。
|
||||
|
||||
接口契约:list 使用 `POST env_vars`,body 固定包含 `env` 和 CLI 场景 `scene=2`;set 使用 `POST create_or_update_env_var`;delete 使用 `POST delete_env_vars`。`--include-values` 只控制 CLI 输出是否展示 value,不作为服务端查询参数发送。
|
||||
|
||||
```bash
|
||||
lark-cli apps +env-list --app-id <app_id>
|
||||
lark-cli apps +env-list --app-id <app_id> --environment online
|
||||
lark-cli apps +env-list --app-id <app_id> --include-values --jq '.data.items[] | select(.key == "FOO")'
|
||||
```
|
||||
|
||||
## 设置
|
||||
|
||||
dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;如果用户在同一轮已经明确说“确认/直接执行”,视为已确认,直接带 `--yes`,不要再次追问。`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 `<value>`,也支持 `@file` 或 stdin 输入。
|
||||
|
||||
回复中只说明 app/env/key 和执行结果;不要回显真实 value。需要举例时使用 `<value>`、`@file` 或 stdin。
|
||||
|
||||
```bash
|
||||
lark-cli apps +env-set --app-id <app_id> --key FOO --value <value>
|
||||
lark-cli apps +env-set --app-id <app_id> --key FOO --value @./secret.txt
|
||||
lark-cli apps +env-set --app-id <app_id> --environment online --key FOO --value <value> --dry-run
|
||||
lark-cli apps +env-set --app-id <app_id> --environment online --key FOO --value <value> --yes
|
||||
```
|
||||
|
||||
## 删除
|
||||
|
||||
`+env-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认 app/env/key 和删除后果,再传 `--yes`。不要自动补 `--yes`。如果只是认证失败后让用户重登,重登完成不等于删除确认;继续删除前仍需确认。
|
||||
|
||||
```bash
|
||||
lark-cli apps +env-delete --app-id <app_id> --key FOO --dry-run
|
||||
lark-cli apps +env-delete --app-id <app_id> --key FOO --yes
|
||||
lark-cli apps +env-delete --app-id <app_id> --environment online --key FOO --yes
|
||||
```
|
||||
|
||||
## 反模式
|
||||
|
||||
- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。
|
||||
- 不要为了看一个变量臆造名为 env-get 的 apps shortcut;用 `+env-list --include-values` 加 `--jq`。
|
||||
- 不要把真实 secret 写进示例或对话输出;需要示例时使用 `<value>`、`@file` 或 stdin。
|
||||
@@ -1,44 +0,0 @@
|
||||
# apps envvar
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。
|
||||
|
||||
管理妙搭应用环境变量。查看用 `+envvar-list`,设置用 `+envvar-set`,删除用 `+envvar-delete`。没有单变量 get 命令;要确认某个 key 是否存在,使用 list 后用 `--jq` 过滤。
|
||||
|
||||
## 查看
|
||||
|
||||
`+envvar-list` 默认查 dev,且默认不返回 value。只有显式传 `--include-values` 后,响应中才可能出现变量值;不要在公开日志里展示带值输出。
|
||||
|
||||
接口契约:list 使用 `POST env_vars`;set 使用 `POST create_or_update_env_var`;delete 使用 `POST delete_env_vars`。`--include-values` 只控制 CLI 输出是否展示 value,不作为服务端查询参数发送。
|
||||
|
||||
```bash
|
||||
lark-cli apps +envvar-list --app-id <app_id>
|
||||
lark-cli apps +envvar-list --app-id <app_id> --env online
|
||||
lark-cli apps +envvar-list --app-id <app_id> --include-values --jq '.data.items[] | select(.key == "FOO")'
|
||||
```
|
||||
|
||||
## 设置
|
||||
|
||||
dev 环境设置不需要 `--yes`。设置 online 环境需要人类确认并显式传 `--yes`;`--dry-run` 可用于预览请求且不需要 `--yes`。变量值支持直接传 `<value>`,也支持 `@file` 或 stdin 输入。
|
||||
|
||||
```bash
|
||||
lark-cli apps +envvar-set --app-id <app_id> --key FOO --value <value>
|
||||
lark-cli apps +envvar-set --app-id <app_id> --key FOO --value @./secret.txt
|
||||
lark-cli apps +envvar-set --app-id <app_id> --env online --key FOO --value <value> --dry-run
|
||||
lark-cli apps +envvar-set --app-id <app_id> --env online --key FOO --value <value> --yes
|
||||
```
|
||||
|
||||
## 删除
|
||||
|
||||
`+envvar-delete` 是 high-risk-write。尊重 exit 10 confirmation protocol:先让用户确认要删除哪些 key,再传 `--yes`。不要自动补 `--yes`。
|
||||
|
||||
```bash
|
||||
lark-cli apps +envvar-delete --app-id <app_id> --key FOO --dry-run
|
||||
lark-cli apps +envvar-delete --app-id <app_id> --key FOO --yes
|
||||
lark-cli apps +envvar-delete --app-id <app_id> --env online --key FOO --yes
|
||||
```
|
||||
|
||||
## 反模式
|
||||
|
||||
- 不要把 `+env-pull` 当成环境变量管理命令;它只是刷新本地 `.env.local` 的兜底工具。
|
||||
- 不要为了看一个变量臆造名为 envvar-get 的 apps shortcut;用 `+envvar-list --include-values` 加 `--jq`。
|
||||
- 不要把真实 secret 写进示例或对话输出;需要示例时使用 `<value>`、`@file` 或 stdin。
|
||||
@@ -2,20 +2,24 @@
|
||||
|
||||
> **前置条件:** 先阅读 [`../lark-shared/SKILL.md`](../../lark-shared/SKILL.md)(认证 / 全局参数 / 安全)。
|
||||
|
||||
查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--env online`;省略 `--env` 时默认就是 online,传 dev 或其他环境是不支持的。
|
||||
查询妙搭应用的线上运行观测和产品访问分析。所有 observability 命令只支持 `--environment online`;省略 `--environment` 时默认就是 online,传 dev 或其他环境是不支持的。不要使用旧的 `--env`,也不要使用短选项。
|
||||
|
||||
日志和 trace 的用户侧环境仍然是 online;但 OpenAPI 请求体里的后端 `app_env` 固定发送 `runtime`,因为线上应用的运行时日志和 trace 存储在 runtime 观测环境下。dry-run 输出会展示这个后端参数。
|
||||
|
||||
metric / analytics 的 `--environment` 只是 CLI 侧 online-only 校验:`+metric-query` 和 `+analytics-query` 不会向 OpenAPI body 发送 `env` 或 `app_env`。dry-run 里看不到环境字段是预期行为,不要补造参数。
|
||||
|
||||
时间过滤支持相对时间(如 `30s`、`5m`、`0.5h`、`2h`、`3d`、`1w`)、本地日期 / 时间和 RFC3339。
|
||||
|
||||
## 命令选择
|
||||
|
||||
- 日志检索:用 `+log-list` 搜索日志,用 `+log-get` 按 log ID 取单条日志。
|
||||
- `+log-list` 不再支持 `--log-id`;已有 log ID 时直接用 `+log-get --log-id <log_id>`。
|
||||
- 前端 ERROR 日志详情:`+log-get` 可能补充 `source_stack`;没有独立的 source-stack 命令。
|
||||
- Trace 检索:用 `+trace-list` 搜索 trace,用 `+trace-get` 按 trace ID 取详情。
|
||||
- 运行时指标:请求数、错误、延迟、CPU、memory 用 `+metric-query`。
|
||||
- 产品分析:PV、UV、访问量这类业务访问分析用 `+analytics-query`,不要放到 runtime metric 里混查。
|
||||
- `+analytics-query` 按最新 OpenAPI 发送 `metric_types`、纳秒时间戳和 `need_pack_lack_point=false`;`group_by` 暂不支持。
|
||||
- 用户询问“最近一小时接口请求量、错误量、延迟、接口慢/报错多”时,这是平台运行时监控,不是本地项目文件。先用 `apps +list --keyword` 找 `app_id`,再查 `+metric-query`。
|
||||
|
||||
## 示例
|
||||
|
||||
@@ -25,6 +29,8 @@ lark-cli apps +log-get --app-id <app_id> --log-id <log_id>
|
||||
lark-cli apps +trace-list --app-id <app_id> --trace-id <trace_id>
|
||||
lark-cli apps +trace-get --app-id <app_id> --trace-id <trace_id>
|
||||
lark-cli apps +metric-query --app-id <app_id> --metric requests --series total --since 1d
|
||||
lark-cli apps +metric-query --app-id <app_id> --metric requests --since 1h
|
||||
lark-cli apps +metric-query --app-id <app_id> --metric latency --since 1h
|
||||
lark-cli apps +metric-query --app-id <app_id> --metric latency --series p99 --since 1d
|
||||
lark-cli apps +metric-query --app-id <app_id> --metric cpu --since 1h
|
||||
lark-cli apps +metric-query --app-id <app_id> --metric memory --since 1h
|
||||
@@ -35,5 +41,8 @@ lark-cli apps +analytics-query --app-id <app_id> --analytics page-view --granula
|
||||
## 使用边界
|
||||
|
||||
- 如果用户问“接口慢、报错多、CPU/内存高”,优先走 `+metric-query`。
|
||||
- `+metric-query --metric requests` 不传 `--series` 会同时返回请求总量 total 和错误量 error;`--metric latency` 不传 `--series` 会同时返回 p50 和 p99。只想看单条曲线时再传 `--series total|error|p50|p99`。
|
||||
- 按接口收窄范围时使用 `--api <path-or-name>`;当前没有 `group-by` 参数,不要臆造。
|
||||
- `+metric-query` 未显式传 `--down-sample` 时会按时间范围自动选择粒度:短范围用 `1m`,中等范围用 `1h`,长范围用 `1d`;显式传入时尊重用户指定。
|
||||
- 如果用户问“页面访问量、PV、UV、活跃用户”,优先走 `+analytics-query`。
|
||||
- 如果用户已有 `trace_id` 或 `log_id`,直接用对应 get 命令;不知道 ID 时先 list。
|
||||
|
||||
@@ -35,6 +35,9 @@ func TestAppsEnvPullDryRun(t *testing.T) {
|
||||
|
||||
assert.Equal(t, "POST", gjson.Get(result.Stdout, "api.0.method").String())
|
||||
assert.Equal(t, "/open-apis/spark/v1/apps/app_x/env_vars", gjson.Get(result.Stdout, "api.0.url").String())
|
||||
assert.Equal(t, "dev", gjson.Get(result.Stdout, "api.0.body.env").String())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.body.include_values").Exists())
|
||||
assert.False(t, gjson.Get(result.Stdout, "api.0.params").Exists())
|
||||
assert.True(t, gjson.Get(result.Stdout, "project_path").Exists())
|
||||
assert.Contains(t, gjson.Get(result.Stdout, "env_file").String(), ".env.local")
|
||||
assert.False(t, gjson.Get(result.Stdout, "env_keys").Exists())
|
||||
|
||||
Reference in New Issue
Block a user