From 0dcdcbed4b12ed6d5d55d3347786496538507901 Mon Sep 17 00:00:00 2001 From: "Xinwei Xiong(cubxxw-openim)" <3293172751nss@gmail.com> Date: Mon, 14 Aug 2023 17:57:46 +0800 Subject: [PATCH] feat: add sctips help Signed-off-by: Xinwei Xiong(cubxxw-openim) <3293172751nss@gmail.com> --- .dockerignore | 4 +- build/images/openim-api/Dockerfile | 2 +- go.work | 1 + go.work.sum | 16 + scripts/build_push_k8s_images.sh | 46 --- scripts/check_all.sh | 4 +- scripts/init_config.sh | 4 +- scripts/install/common.sh | 4 + scripts/install/environment.sh | 3 +- scripts/install/start_rpc_service.sh | 1 - scripts/lib/golang.sh | 40 +++ scripts/lib/init.sh | 2 +- scripts/make-rules/common.mk | 4 +- scripts/make-rules/image.mk | 35 ++- scripts/path_info.sh | 19 -- scripts/start_all.sh | 65 +--- tools/imctl/README.md | 47 +++ tools/imctl/cmd/imctl/imctl.go | 15 + tools/imctl/go.mod | 19 ++ tools/imctl/go.sum | 26 ++ tools/imctl/internal/imctl/cmd/cmd.go | 119 ++++++++ tools/imctl/internal/imctl/cmd/profiling.go | 81 +++++ .../imctl/util/interrupt/interrupt.go | 89 ++++++ .../imctl/util/templates/command_groups.go | 43 +++ .../internal/imctl/util/templates/markdown.go | 135 +++++++++ .../imctl/util/templates/normalizers.go | 85 ++++++ .../imctl/util/templates/templater.go | 282 ++++++++++++++++++ .../imctl/util/templates/templates.go | 89 ++++++ .../imctl/internal/imctl/util/term/resize.go | 43 +++ tools/imctl/internal/imctl/util/term/term.go | 27 ++ .../internal/imctl/util/term/term_writer.go | 112 +++++++ .../imctl/util/term/term_writer_test.go | 104 +++++++ 32 files changed, 1420 insertions(+), 146 deletions(-) delete mode 100755 scripts/build_push_k8s_images.sh create mode 100644 tools/imctl/README.md create mode 100644 tools/imctl/cmd/imctl/imctl.go create mode 100644 tools/imctl/go.mod create mode 100644 tools/imctl/go.sum create mode 100644 tools/imctl/internal/imctl/cmd/cmd.go create mode 100644 tools/imctl/internal/imctl/cmd/profiling.go create mode 100644 tools/imctl/internal/imctl/util/interrupt/interrupt.go create mode 100644 tools/imctl/internal/imctl/util/templates/command_groups.go create mode 100644 tools/imctl/internal/imctl/util/templates/markdown.go create mode 100644 tools/imctl/internal/imctl/util/templates/normalizers.go create mode 100644 tools/imctl/internal/imctl/util/templates/templater.go create mode 100644 tools/imctl/internal/imctl/util/templates/templates.go create mode 100644 tools/imctl/internal/imctl/util/term/resize.go create mode 100644 tools/imctl/internal/imctl/util/term/term.go create mode 100644 tools/imctl/internal/imctl/util/term/term_writer.go create mode 100644 tools/imctl/internal/imctl/util/term/term_writer_test.go diff --git a/.dockerignore b/.dockerignore index c0fc0856c..8e6a7c121 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -# Ignore files and directories starting with a dot + diff --git a/build/images/openim-api/Dockerfile b/build/images/openim-api/Dockerfile index 2761852ce..0d738e5bb 100644 --- a/build/images/openim-api/Dockerfile +++ b/build/images/openim-api/Dockerfile @@ -35,7 +35,7 @@ RUN make clean RUN make build BINS=openim-api # FROM ghcr.io/openim-sigs/openim-bash-image:latest -FROM test:v1 +FROM ghcr.io/openim-sigs/openim-bash-image:latest WORKDIR /openim/openim-server diff --git a/go.work b/go.work index 1fb7044d2..ce1770c8e 100644 --- a/go.work +++ b/go.work @@ -4,6 +4,7 @@ use ( . ./test/typecheck ./tools/changelog + ./tools/imctl ./tools/infra ./tools/ncpu ./tools/yamlfmt diff --git a/go.work.sum b/go.work.sum index a2fb8415a..2e74d77c9 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,10 +1,26 @@ cloud.google.com/go/iam v1.1.1 h1:lW7fzj15aVIXYHREOqjRBV9PsH0Z6u8Y46a1YGvQP4Y= cloud.google.com/go/iam v1.1.1/go.mod h1:A5avdyVL2tCppe4unb0951eI9jreack+RJ0/d+KUZOU= +github.com/AlekSi/pointer v1.1.0 h1:SSDMPcXD9jSl8FPy9cRzoRaMJtm9g9ggGTxecRUbQoI= +github.com/client9/misspell v0.3.4 h1:ta993UF76GwbvJcIo3Y68y/M3WxlpEHPWIGDkJYwzJI= +github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= +github.com/fsnotify/fsnotify v1.5.1 h1:mZcQUHVQUQWoPXXtuf9yuEXKudkV2sx1E06UadKWpgI= +github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/magiconair/properties v1.8.5 h1:b6kJs+EmPFMYGkow9GiUyCyOvIwYetYJ3fSaWak/Gls= +github.com/marmotedu/component-base v1.6.2 h1:UtQkG0ZmAbVHVUdky5Sw68QLJno5ARSqslHu/xsVNl0= +github.com/marmotedu/errors v1.0.2 h1:qx9GtOljmAL+wLuemahe3WSWdXyEpJvLBlpXK8y2rdI= +github.com/marmotedu/marmotedu-sdk-go v1.6.2 h1:eQcHVdK89Xb107+XbeqIyEXzYFxmyjQFChBtijrQSl8= +github.com/pelletier/go-toml v1.9.4 h1:tjENF6MfZAg8e4ZmZTeWaWiT2vXtsoO6+iuOjFhECwM= github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U= +github.com/spf13/afero v1.6.0 h1:xoax2sJ2DT8S8xA2paPFjDCScCNeWsg75VG0DLRreiY= +github.com/spf13/cast v1.4.1 h1:s0hze+J0196ZfEMTs80N7UlFt0BDuQ7Q+JDnHiMWKdA= +github.com/spf13/jwalterweatherman v1.1.0 h1:ue6voC5bR5F8YxI5S67j9i582FU4Qvo2bmqnqMYADFk= +github.com/spf13/viper v1.9.0 h1:yR6EXjTp0y0cLN8OZg1CRZmOBdI88UcGkhgyJhu6nZk= +github.com/subosito/gotenv v1.2.0 h1:Slr1R9HxAlEKefgq5jn9U+DnETlIUa6HfgEzj0g5d7s= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/fsnotify.v1 v1.4.7 h1:xOHLXZwVvI9hhs+cLKq5+I5onOuwQLhQwiu63xxlHs4= honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc h1:/hemPrYIhOhy8zYrNj+069zDB68us2sMGsfkFJO0iZs= +moul.io/http2curl v1.0.0 h1:6XwpyZOYsgZJrU8exnG87ncVkU1FVCcTRpwzOkTDUi8= rsc.io/pdf v0.1.1 h1:k1MczvYDUvJBe93bYd7wrZLLUEcLZAuF824/I4e5Xr4= diff --git a/scripts/build_push_k8s_images.sh b/scripts/build_push_k8s_images.sh deleted file mode 100755 index a620c7847..000000000 --- a/scripts/build_push_k8s_images.sh +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env bash -# Copyright © 2023 OpenIM. All rights reserved. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -version=errcode -repository=${1} -if [[ -z ${repository} ]] -then - echo "repository is empty" - exit 0 -fi - -set +e -echo "repository: ${repository}" -source ./path_info.sh -echo "start to build docker images" -currentPwd=`pwd` -echo ${currentPwd} -i=0 -for path in ${service_source_root[*]} -do - cd ${path} - make build - image="${repository}/${image_names[${i}]}:$version" - echo ${image} - docker build -t $image . -f ./deploy.Dockerfile - echo "build ${image} success" - docker push ${image} - echo "push ${image} success" - echo "==============================" - i=$((i + 1)) - cd ${currentPwd} -done - -echo "build all images success" \ No newline at end of file diff --git a/scripts/check_all.sh b/scripts/check_all.sh index 80a0dd805..43fc78d18 100755 --- a/scripts/check_all.sh +++ b/scripts/check_all.sh @@ -49,8 +49,8 @@ for i in ${service_port_name[*]}; do for j in ${ports_array}; do port=$(ss -tunlp| grep openim | awk '{print $5}' | grep -w ${j} | awk -F '[:]' '{print $NF}') if [[ ${port} -ne ${j} ]]; then - echo -e ${BACKGROUND_GREEN}${i}${COLOR_SUFFIX}${RED_PREFIX}" service does not start normally,not initiated port is "${COLOR_SUFFIX}${BACKGROUND_GREEN}${j}${COLOR_SUFFIX} - echo -e ${RED_PREFIX}"please check $OPENIM_ROOT/logs/openIM.log "${COLOR_SUFFIX} + openim::log::info "service does not start normally,not initiated port is "${COLOR_SUFFIX}${BACKGROUND_GREEN}${j} + echo "please check ${log_file}" exit -1 else echo -e ${j}${GREEN_PREFIX}" port has been listening,belongs service is "${i}${COLOR_SUFFIX} diff --git a/scripts/init_config.sh b/scripts/init_config.sh index ec8a1ac5b..f5945d50c 100755 --- a/scripts/init_config.sh +++ b/scripts/init_config.sh @@ -29,8 +29,8 @@ readonly ENV_FILE=${ENV_FILE:-${OPENIM_ROOT}/scripts/install/environment.sh} # 定义关联数组,其中键是模板文件,值是对应的输出文件 (en: Defines an associative array where the keys are the template files and the values are the corresponding output files.) declare -A TEMPLATES=( - ["${OPENIM_ROOT}/scripts/template/config-tmpl/env.template"]="${OPENIM_OUTPUT_SUBPATH}/bin/.env" - ["${OPENIM_ROOT}/scripts/template/config-tmpl/openim_config.yaml"]="${OPENIM_OUTPUT_SUBPATH}/bin/openim_config.yaml" + ["${OPENIM_ROOT}/deployments/templates/env_template.yaml"]="${OPENIM_OUTPUT_SUBPATH}/bin/.env" + ["${OPENIM_ROOT}/deployments/templates/openim.yaml"]="${OPENIM_OUTPUT_SUBPATH}/bin/openim_config.yaml" ) for template in "${!TEMPLATES[@]}"; do diff --git a/scripts/install/common.sh b/scripts/install/common.sh index d996c0864..fd63f37d9 100755 --- a/scripts/install/common.sh +++ b/scripts/install/common.sh @@ -30,6 +30,10 @@ source "${OPENIM_ROOT}/scripts/lib/init.sh" # Make sure the environment is only called via common to avoid too much nesting source "${OPENIM_ROOT}/scripts/install/environment.sh" +service_port_name={ + +} + # Execute commands that require root permission without entering a password function openim::common::sudo { echo ${LINUX_PASSWORD} | sudo -S $1 diff --git a/scripts/install/environment.sh b/scripts/install/environment.sh index 412469c8f..bb7c3947b 100755 --- a/scripts/install/environment.sh +++ b/scripts/install/environment.sh @@ -164,7 +164,8 @@ def "WEBSOCKET_MAX_CONN_NUM" "100000" # Websocket最大连接数 def "WEBSOCKET_MAX_MSG_LEN" "4096" # Websocket最大消息长度 def "WEBSOCKET_TIMEOUT" "10" # Websocket超时 def "PUSH_ENABLE" "getui" # 推送是否启用 -def "GETUI_PUSH_URL" "https://restapi.getui.com/v2/$appId" # GeTui推送URL +# GeTui推送URL +readonly GETUI_PUSH_URL=${GETUI_PUSH_URL:-'https://restapi.getui.com/v2/$appId'} def "FCM_SERVICE_ACCOUNT" "x.json" # FCM服务账户 def "JPNS_APP_KEY" # JPNS应用密钥 def "JPNS_MASTER_SECRET" # JPNS主密钥 diff --git a/scripts/install/start_rpc_service.sh b/scripts/install/start_rpc_service.sh index e42510689..c02e9fe1c 100755 --- a/scripts/install/start_rpc_service.sh +++ b/scripts/install/start_rpc_service.sh @@ -15,7 +15,6 @@ # limitations under the License. #Include shell font styles and some basic information -SCRIPTS_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) OPENIM_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" #Include shell font styles and some basic information diff --git a/scripts/lib/golang.sh b/scripts/lib/golang.sh index ee340c83e..57557f936 100755 --- a/scripts/lib/golang.sh +++ b/scripts/lib/golang.sh @@ -14,6 +14,7 @@ # limitations under the License. # The golang package that we are building. +OPENIM_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd -P)" readonly KUBE_GO_PACKAGE=github.com/openimsdk/open-im-server readonly KUBE_GOPATH="${KUBE_GOPATH:-"${KUBE_OUTPUT}/go"}" export KUBE_GOPATH @@ -89,6 +90,45 @@ IFS=" " read -ra OPENIM_SERVER_TARGETS <<< "$(openim::golang::server_targets)" readonly OPENIM_SERVER_TARGETS readonly OPENIM_SERVER_BINARIES=("${OPENIM_SERVER_TARGETS[@]##*/}") +START_SCRIPTS_PATH="${OPENIM_ROOT}/scripts/install/" +openim::golang::start_script_list() { + local targets=( + start_rpc_service.sh + push_start.sh + msg_transfer_start.sh + msg_gateway_start.sh + start_cron.sh + ) + + for target in "${targets[@]}"; do + local full_path="${START_SCRIPTS_PATH}${target}" + echo "$full_path" + chmod +x "$full_path" + done +} + +IFS=" " read -ra OPENIM_SERVER_SCRIPT_START_LIST <<< "$(openim::golang::start_script_list)" +readonly OPENIM_SERVER_SCRIPT_START_LIST + +openim::golang::check_openim_binaries() { + local missing_binaries=() + for binary in "${OPENIM_SERVER_BINARIES[@]}"; do + if [[ ! -x "${OPENIM_OUTPUT_HOSTBIN}/${binary}" ]]; then + missing_binaries+=("${binary}") + fi + done + + if [[ ${#missing_binaries[@]} -ne 0 ]]; then + echo "The following binaries were not found in ${OPENIM_OUTPUT_HOSTBIN}:" + for missing in "${missing_binaries[@]}"; do + echo " - ${missing}" + done + return 1 + else + echo "All binaries have been installed in ${OPENIM_OUTPUT_HOSTBIN}。" + return 0 + fi +} openim::golang::tools_targets() { local targets=( diff --git a/scripts/lib/init.sh b/scripts/lib/init.sh index a9abb55ee..af73f1f3a 100755 --- a/scripts/lib/init.sh +++ b/scripts/lib/init.sh @@ -36,7 +36,7 @@ OPENIM_OUTPUT_SUBPATH="${OPENIM_OUTPUT_SUBPATH:-_output}" OPENIM_OUTPUT="${OPENIM_ROOT}/${OPENIM_OUTPUT_SUBPATH}" OPENIM_OUTPUT_BINPATH="${OPENIM_OUTPUT}/bin/platforms" -OPENIM_OUTPUT_BINTOOLPATH="${OPENIM_OUTPUT}/bin-tools" +OPENIM_OUTPUT_BINTOOLPATH="${OPENIM_OUTPUT}/bin/tools" OPENIM_OUTPUT_TOOLS="${OPENIM_OUTPUT}/tools" OPENIM_OUTPUT_TMP="${OPENIM_OUTPUT}/tmp" OPENIM_OUTPUT_LOGS="${OPENIM_OUTPUT}/logs" diff --git a/scripts/make-rules/common.mk b/scripts/make-rules/common.mk index 442de9f16..c9a595560 100644 --- a/scripts/make-rules/common.mk +++ b/scripts/make-rules/common.mk @@ -48,7 +48,7 @@ endif # BIN_TOOLS_DIR: Directory where executable files are stored. ifeq ($(origin BIN_TOOLS_DIR),undefined) -BIN_TOOLS_DIR := $(OUTPUT_DIR)/bin-tools +BIN_TOOLS_DIR := $(BIN_DIR)/tools $(shell mkdir -p $(BIN_TOOLS_DIR)) endif @@ -71,7 +71,7 @@ $(shell mkdir -p $(TMP_DIR)) endif ifeq ($(origin VERSION), undefined) -# VERSION := $(shell git describe --abbrev=0 --dirty --always --tags | sed 's/-/./g') #v2.3.3.dirty +# VERSION := $(shell git describe --tags --always --match='v*') # git describe --tags --always --match="v*" --dirty VERSION := $(shell git describe --tags --always --match="v*" --dirty | sed 's/-/./g') #v2.3.3.631.g00abdc9b.dirty # v2.3.3: git tag diff --git a/scripts/make-rules/image.mk b/scripts/make-rules/image.mk index 7766c221c..60ef83666 100644 --- a/scripts/make-rules/image.mk +++ b/scripts/make-rules/image.mk @@ -14,7 +14,6 @@ # ============================================================================== # Makefile helper functions for docker image -# TODO: For the time being only used for compilation, it can be arm or amd, please do not delete it, it can be extended with new functions # ============================================================================== # Path: scripts/make-rules/image.mk # docker registry: registry.example.com/namespace/image:tag as: registry.hub.docker.com/cubxxw/: @@ -24,8 +23,11 @@ DOCKER := docker DOCKER_SUPPORTED_API_VERSION ?= 1.32|1.40|1.41 -REGISTRY_PREFIX ?= ghcr.io/OpenIMSDK -IMAGES ?= lvscare +# read: https://github.com/OpenIMSDK/Open-IM-Server/blob/main/docs/conversions/images.md +REGISTRY_PREFIX ?= ghcr.io/openimsdk + +BASE_IMAGE ?= ghcr.io/openim-sigs/openim-bash-image + IMAGE_PLAT ?= $(subst $(SPACE),$(COMMA),$(subst _,/,$(PLATFORMS))) EXTRA_ARGS ?= --no-cache @@ -102,25 +104,26 @@ image.build.multiarch: image.verify $(foreach p,$(PLATFORMS),$(addprefix image.b ## image.build.%: Build docker image for a specific platform .PHONY: image.build.% -image.build.%: go.bin.% +image.build.%: go.build.% $(eval IMAGE := $(COMMAND)) $(eval IMAGE_PLAT := $(subst _,/,$(PLATFORM))) $(eval ARCH := $(word 2,$(subst _, ,$(PLATFORM)))) - @echo "===========> Building LOCAL docker image $(IMAGE) $(VERSION) for $(IMAGE_PLAT)" + @echo "===========> Building docker image $(IMAGE) $(VERSION) for $(IMAGE_PLAT)" @mkdir -p $(TMP_DIR)/$(IMAGE)/$(PLATFORM) - @cat $(ROOT_DIR)/docker/$(IMAGE)/Dockerfile\ - >$(TMP_DIR)/$(IMAGE)/Dockerfile - @cp $(BIN_DIR)/$(PLATFORM)/$(IMAGE) $(TMP_DIR)/$(IMAGE)/$(PLATFORM) - - $(eval BUILD_SUFFIX := --load --pull -t $(REGISTRY_PREFIX)/$(IMAGE):$(VERSION) $(TMP_DIR)/$(IMAGE)) - $(eval BUILD_SUFFIX_ARM := --load --pull -t $(REGISTRY_PREFIX)/$(IMAGE).$(ARCH):$(VERSION) $(TMP_DIR)/$(IMAGE)) - @if [ "$(ARCH)" == "amd64" ]; then \ - echo "===========> Creating LOCAL docker image tag $(REGISTRY_PREFIX)/$(IMAGE):$(VERSION) for $(ARCH)"; \ - $(DOCKER) buildx build --platform $(IMAGE_PLAT) $(BUILD_SUFFIX); \ + @awk '/FROM/ {c++; if (c==2) {print; next}} c>=2' $(ROOT_DIR)/build/images/$(IMAGE)/Dockerfile \ +| sed -e "s#BASE_IMAGE#$(BASE_IMAGE)#g" \ + -e 's/--from=builder //g' \ + -e 's#COPY /openim/openim-server/#COPY ./#g' > $(TMP_DIR)/$(IMAGE)/Dockerfile + @cp $(BIN_DIR)/platforms/$(IMAGE_PLAT)/$(IMAGE) $(TMP_DIR)/$(IMAGE) + $(eval BUILD_SUFFIX := $(_DOCKER_BUILD_EXTRA_ARGS) --pull -t $(REGISTRY_PREFIX)/$(IMAGE)-$(ARCH):$(VERSION) $(TMP_DIR)/$(IMAGE)) + @if [ $(shell $(GO) env GOARCH) != $(ARCH) ] ; then \ + $(MAKE) image.daemon.verify ;\ + $(DOCKER) build --platform $(IMAGE_PLAT) $(BUILD_SUFFIX) ; \ else \ - echo "===========> Creating LOCAL docker image tag $(REGISTRY_PREFIX)/$(IMAGE).$(ARCH):$(VERSION) for $(ARCH)"; \ - $(DOCKER) buildx build --platform $(IMAGE_PLAT) $(BUILD_SUFFIX_ARM); \ + $(DOCKER) build $(BUILD_SUFFIX) ; \ fi + @rm -rf $(TMP_DIR)/$(IMAGE) + # https://docs.docker.com/build/building/multi-platform/ # busybox image supports amd64, arm32v5, arm32v6, arm32v7, arm64v8, i386, ppc64le, and s390x diff --git a/scripts/path_info.sh b/scripts/path_info.sh index ddc728783..21b56ea70 100755 --- a/scripts/path_info.sh +++ b/scripts/path_info.sh @@ -40,19 +40,6 @@ declare -A supported_architectures=( ["darwin-x86_64"]="_output/bin/platforms/darwin/amd64" # Alias for darwin-amd64 ) -declare -A supported_architectures_tools=( - ["linux-amd64"]="_output/bin-tools/platforms/linux/amd64" - ["linux-arm64"]="_output/bin-tools/platforms/linux/arm64" - ["linux-mips64"]="_output/bin-tools/platforms/linux/mips64" - ["linux-mips64le"]="_output/bin-tools/platforms/linux/mips64le" - ["linux-ppc64le"]="_output/bin-tools/platforms/linux/ppc64le" - ["linux-s390x"]="_output/bin-tools/platforms/linux/s390x" - ["darwin-amd64"]="_output/bin-tools/platforms/darwin/amd64" - ["windows-amd64"]="_output/bin-tools/platforms/windows/amd64" - ["linux-x86_64"]="_output/bin-tools/platforms/linux/amd64" # Alias for linux-amd64 - ["darwin-x86_64"]="_output/bin-tools/platforms/darwin/amd64" # Alias for darwin-amd64 -) - # Check if the architecture and version are supported if [[ -z ${supported_architectures["$version-$architecture"]} ]]; then echo -e "${BLUE_PREFIX}================> Unsupported architecture: $architecture or version: $version${COLOR_SUFFIX}" @@ -63,18 +50,15 @@ echo -e "${BLUE_PREFIX}================> Architecture: $architecture${COLOR_SUFF # Set the BIN_DIR based on the architecture and version BIN_DIR=${supported_architectures["$version-$architecture"]} -BIN_DIR_TOOLS=${supported_architectures_tools["$version-$architecture"]} echo -e "${BLUE_PREFIX}================> BIN_DIR: $OPENIM_ROOT/$BIN_DIR${COLOR_SUFFIX}" # Don't put the space between "=" openim_msggateway="openim-msggateway" msg_gateway_binary_root="$OPENIM_ROOT/$BIN_DIR" -msg_gateway_source_root="$OPENIM_ROOT/cmd/openim-msggateway/" msg_name="openim-rpc-msg" msg_binary_root="$OPENIM_ROOT/$BIN_DIR" -msg_source_root="$OPENIM_ROOT/cmd/openim-rpc/openim-rpc-msg/" push_name="openim-push" push_binary_root="$OPENIM_ROOT/$BIN_DIR" @@ -82,16 +66,13 @@ push_source_root="$OPENIM_ROOT/cmd/openim-push/" openim_msgtransfer="openim-msgtransfer" msg_transfer_binary_root="$OPENIM_ROOT/$BIN_DIR" -msg_transfer_source_root="$OPENIM_ROOT/cmd/openim-msgtransfer/" msg_transfer_service_num=4 cron_task_name="openim-crontask" cron_task_binary_root="$OPENIM_ROOT/$BIN_DIR" -cron_task_source_root="$OPENIM_ROOT/cmd/openim-crontask/" cmd_utils_name="openim-cmdutils" cmd_utils_binary_root="$OPENIM_ROOT/$BIN_DIR" -cmd_utils_source_root="$OPENIM_ROOT/cmd/openim-cmdutils/" # Global configuration file default dir config_path="$OPENIM_ROOT/config/config.yaml" diff --git a/scripts/start_all.sh b/scripts/start_all.sh index d746c9fb8..134d2f958 100755 --- a/scripts/start_all.sh +++ b/scripts/start_all.sh @@ -16,69 +16,28 @@ #FIXME This script is the startup script for multiple servers. #FIXME The full names of the shell scripts that need to be started are placed in the `need_to_start_server_shell` array. -set -o errexit set -o nounset set -o pipefail OPENIM_ROOT=$(dirname "${BASH_SOURCE[0]}")/.. source "${OPENIM_ROOT}/scripts/lib/init.sh" -if [ ! -d "${OPENIM_ROOT}/_output/bin/platforms" ]; then - # exec build_all_service.sh - "${SCRIPTS_ROOT}/build_all_service.sh" -fi - -bin_dir="$OPENIM_ROOT/_output/bin" -logs_dir="$OPENIM_ROOT/logs" - -if [ ! -d "$bin_dir" ]; then - mkdir -p "$bin_dir" +set +o errexit +openim::golang::check_openim_binaries +if [[ $? -ne 0 ]]; then + openim::log::error "OpenIM binaries are not found. Please run 'make' to build binaries." + ${OPENIM_ROOT}/scripts/build_all_service.sh fi +set -o errexit -if [ ! -d "$logs_dir" ]; then - mkdir -p "$logs_dir" -fi - - -cd $SCRIPTS_ROOT - -# FIXME Put the shell script names here -need_to_start_server_shell=( - start_rpc_service.sh - push_start.sh - msg_transfer_start.sh - msg_gateway_start.sh - start_cron.sh -) - -component_check=start_component_check.sh -echo -e "" -chmod +x $component_check -echo -e "=========> ${BACKGROUND_GREEN}Executing ${component_check}...${COLOR_SUFFIX}" -echo -e "" -./$component_check -if [ $? -ne 0 ]; then - # Print error message and exit - echo -e "${BOLD_PREFIX}${RED_PREFIX}Error executing ${component_check}. Exiting...${COLOR_SUFFIX}" - exit -1 -fi - - -# Loop through the script names and execute them -for i in ${need_to_start_server_shell[*]}; do - chmod +x $i - - echo -e "" - # Print script execution message - echo -e "=========> ${BACKGROUND_GREEN}Executing ${i}...${COLOR_SUFFIX}" - echo -e "" - - ./$i +scripts_to_run=$(openim::golang::start_script_list) - # Check if the script executed successfully - if [ $? -ne 0 ]; then +for script in $scripts_to_run; do + openim::log::info "Executing: $script" + "$script" + if [ $? -ne 0 ]; then # Print error message and exit - echo "${BOLD_PREFIX}${RED_PREFIX}Error executing ${i}. Exiting...${COLOR_SUFFIX}" + openim::log::error "Error executing ${i}. Exiting..." exit -1 fi done diff --git a/tools/imctl/README.md b/tools/imctl/README.md new file mode 100644 index 000000000..d5f674b55 --- /dev/null +++ b/tools/imctl/README.md @@ -0,0 +1,47 @@ +# OpenIM CTL 模块 + + +## 为什么设计这个模块 + +OpenIM 后期功能扩展,不能总依赖一些单独的模块,而是整合到 Imctl 中。 + +测试同学做自动化测试或者是 e2e 测试,接口测试等等,每一次调用 API 很麻烦,用 imctl 的方式为 api 的调用提供了方便 + +和 scripts 深度交互,同样减少了 IM 本身的耦合度,提高了 IM 的可扩展性。 + + +## 功能设计 + ++ 用户管理:例如,添加、删除或禁用用户账户。 + ++ 系统监控:查看在线用户数量、消息传送速率等关键性能指标。 + ++ 调试:如查看日志、调整日志级别、查看系统状态等。 + ++ 配置管理:更新系统设置、管理插件或模块等。 + ++ 数据管理:备份和恢复数据、导入和导出数据等。 + ++ 系统维护:例如,执行更新、重启服务、进行维护模式等。 + + +## 设计思路 + +参考 kubectl, 方便 开发、运维、测试同学使用系统功能,并且实现自动化功能。 + +**自动化设计思路:** +1. 为后面的扩展子模块或者子命令提供自动化的功能,提供子命令 `imctl new` 自动创建新的子命令 +2. 以通过 imctl 对用户、密钥和策略进行CURD操作 +3. 设置 imctl 自动补全脚本 +4. 版本管理,问题:https://github.com/OpenIMSDK/Open-IM-Server/issues/574,做 IM 自动化的版本管理,查看 IM 系统版本 + +一些简单的 IM 解决方案可能不需要这样的工具,而复杂、高度定制的系统可能会从中受益。 + +所以暂时将这个模块放在 tools/imctl 中,后面迁移到 cmd/imctl 中 + + +## 目录结构设计 + +为了方便迁移,将 imctl 工程化设计,命令工具放入到 tools/imctl/cmd 中,其他的模块放入到 tools/imctl/pkg 中 + +``` \ No newline at end of file diff --git a/tools/imctl/cmd/imctl/imctl.go b/tools/imctl/cmd/imctl/imctl.go new file mode 100644 index 000000000..3afe57699 --- /dev/null +++ b/tools/imctl/cmd/imctl/imctl.go @@ -0,0 +1,15 @@ +// iamctl is the command line tool for iam platform. +package main + +import ( + "os" + + "github.com/OpenIMSDK/Open-IM-Server/tools/imctl/internal/imctl/cmd" +) + +func main() { + command := cmd.NewDefaultIMCtlCommand() + if err := command.Execute(); err != nil { + os.Exit(1) + } +} diff --git a/tools/imctl/go.mod b/tools/imctl/go.mod new file mode 100644 index 000000000..ed3685f56 --- /dev/null +++ b/tools/imctl/go.mod @@ -0,0 +1,19 @@ +module github.com/OpenIMSDK/Open-IM-Server/tools/imctl + +go 1.20 + +require ( + github.com/MakeNowJust/heredoc/v2 v2.0.1 + github.com/marmotedu/iam v1.7.0 + github.com/mitchellh/go-wordwrap v1.0.1 + github.com/moby/term v0.5.0 + github.com/russross/blackfriday v1.6.0 + github.com/spf13/cobra v1.7.0 + github.com/spf13/pflag v1.0.5 +) + +require ( + github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + golang.org/x/sys v0.1.0 // indirect +) diff --git a/tools/imctl/go.sum b/tools/imctl/go.sum new file mode 100644 index 000000000..05f38d414 --- /dev/null +++ b/tools/imctl/go.sum @@ -0,0 +1,26 @@ +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8= +github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= +github.com/MakeNowJust/heredoc/v2 v2.0.1 h1:rlCHh70XXXv7toz95ajQWOWQnN4WNLt0TdpZYIR/J6A= +github.com/MakeNowJust/heredoc/v2 v2.0.1/go.mod h1:6/2Abh5s+hc3g9nbWLe9ObDIOhaRrqsyY9MWy+4JdRM= +github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/marmotedu/iam v1.7.0 h1:9aWg5Enx+npHU9kxQ0eYsdXvbiGeUsuuzxaV49BQa0I= +github.com/marmotedu/iam v1.7.0/go.mod h1:kjQ1Tzr+M6/B49DSC3Zky+2Ai9vAr6PbhuHV8mWUS48= +github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= +github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= +github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0= +github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y= +github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww= +github.com/russross/blackfriday v1.6.0/go.mod h1:ti0ldHuxg49ri4ksnFxlkCfN+hvslNlmVHqNRXXJNAY= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I= +github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0= +github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= +github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.1.0 h1:kunALQeHf1/185U1i0GOB/fy1IPRDDpuoOOqRReG57U= +golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/tools/imctl/internal/imctl/cmd/cmd.go b/tools/imctl/internal/imctl/cmd/cmd.go new file mode 100644 index 000000000..cf4ec477e --- /dev/null +++ b/tools/imctl/internal/imctl/cmd/cmd.go @@ -0,0 +1,119 @@ +package cmd + +import ( + "flag" + "io" + "os" + + "github.com/marmotedu/iam/pkg/cli/genericclioptions" + "github.com/spf13/cobra" + "github.com/spf13/viper" + "k8s.io/kubectl/pkg/cmd/completion" + "k8s.io/kubectl/pkg/cmd/options" + "k8s.io/kubectl/pkg/cmd/set" + "k8s.io/kubectl/pkg/cmd/version" + + "github.com/OpenIMSDK/Open-IM-Server/tools/imctl/internal/util/templates" +) + +// NewDefaultIAMCtlCommand creates the `imctl` command with default arguments. +func NewDefaultIMCtlCommand() *cobra.Command { + return NewIMCtlCommand(os.Stdin, os.Stdout, os.Stderr) +} + +// NewIAMCtlCommand returns new initialized instance of 'imctl' root command. +func NewIAMCtlCommand(in io.Reader, out, err io.Writer) *cobra.Command { + // Parent command to which all subcommands are added. + cmds := &cobra.Command{ + Use: "imctl", + Short: "imctl controls the IM platform", + Long: templates.LongDesc(` + imctl controls the IM platform, is the client side tool for IM platform. + + Find more information at: + // TODO: add link to docs, from auto scripts and gendocs + https://github.com/OpenIMSDK/Open-IM-Server/tree/main/docs`), + Run: runHelp, + // Hook before and after Run initialize and write profiles to disk, + // respectively. + PersistentPreRunE: func(*cobra.Command, []string) error { + return initProfiling() + }, + PersistentPostRunE: func(*cobra.Command, []string) error { + return flushProfiling() + }, + } + + flags := cmds.PersistentFlags() + flags.SetNormalizeFunc(cliflag.WarnWordSepNormalizeFunc) // Warn for "_" flags + + // Normalize all flags that are coming from other packages or pre-configurations + // a.k.a. change all "_" to "-". e.g. glog package + flags.SetNormalizeFunc(cliflag.WordSepNormalizeFunc) + + addProfilingFlags(flags) + + iamConfigFlags := genericclioptions.NewConfigFlags(true).WithDeprecatedPasswordFlag().WithDeprecatedSecretFlag() + iamConfigFlags.AddFlags(flags) + matchVersionIAMConfigFlags := cmdutil.NewMatchVersionFlags(iamConfigFlags) + matchVersionIAMConfigFlags.AddFlags(cmds.PersistentFlags()) + + _ = viper.BindPFlags(cmds.PersistentFlags()) + cobra.OnInitialize(func() { + genericapiserver.LoadConfig(viper.GetString(genericclioptions.FlagIAMConfig), "iamctl") + }) + cmds.PersistentFlags().AddGoFlagSet(flag.CommandLine) + + f := cmdutil.NewFactory(matchVersionIAMConfigFlags) + + // From this point and forward we get warnings on flags that contain "_" separators + cmds.SetGlobalNormalizationFunc(cliflag.WarnWordSepNormalizeFunc) + + ioStreams := genericclioptions.IOStreams{In: in, Out: out, ErrOut: err} + + groups := templates.CommandGroups{ + { + Message: "Basic Commands:", + Commands: []*cobra.Command{ + info.NewCmdInfo(f, ioStreams), + color.NewCmdColor(f, ioStreams), + new.NewCmdNew(f, ioStreams), + jwt.NewCmdJWT(f, ioStreams), + }, + }, + { + Message: "Identity and Access Management Commands:", + Commands: []*cobra.Command{ + user.NewCmdUser(f, ioStreams), + secret.NewCmdSecret(f, ioStreams), + policy.NewCmdPolicy(f, ioStreams), + }, + }, + { + Message: "Troubleshooting and Debugging Commands:", + Commands: []*cobra.Command{ + validate.NewCmdValidate(f, ioStreams), + }, + }, + { + Message: "Settings Commands:", + Commands: []*cobra.Command{ + set.NewCmdSet(f, ioStreams), + completion.NewCmdCompletion(ioStreams.Out, ""), + }, + }, + } + groups.Add(cmds) + + filters := []string{"options"} + templates.ActsAsRootCommand(cmds, filters, groups...) + + cmds.AddCommand(version.NewCmdVersion(f, ioStreams)) + cmds.AddCommand(options.NewCmdOptions(ioStreams.Out)) + + return cmds +} + +func runHelp(cmd *cobra.Command, args []string) { + _ = cmd.Help() +} diff --git a/tools/imctl/internal/imctl/cmd/profiling.go b/tools/imctl/internal/imctl/cmd/profiling.go new file mode 100644 index 000000000..eb460b270 --- /dev/null +++ b/tools/imctl/internal/imctl/cmd/profiling.go @@ -0,0 +1,81 @@ +package cmd + +import ( + "errors" + "fmt" + "os" + "runtime" + "runtime/pprof" + + "github.com/spf13/pflag" +) + +// profiling configuration variables +var ( + profileName string = "none" // Name of the profile to capture. + profileOutput string = "profile.pprof" // File to write the profile data. +) + +// addProfilingFlags registers profiling related flags to the given FlagSet. +func addProfilingFlags(flags *pflag.FlagSet) { + flags.StringVar( + &profileName, + "profile", + "none", + "Type of profile to capture. Options: none, cpu, heap, goroutine, threadcreate, block, mutex", + ) + flags.StringVar(&profileOutput, "profile-output", "profile.pprof", "File to write the profile data") +} + +// initProfiling sets up profiling based on the user's choice. +// If 'cpu' is selected, it starts the CPU profile. For block and mutex profiles, +// sampling rates are set up. +func initProfiling() error { + switch profileName { + case "none": + return nil + case "cpu": + f, err := os.Create(profileOutput) + if err != nil { + return err + } + return pprof.StartCPUProfile(f) + case "block": + runtime.SetBlockProfileRate(1) // Sampling every block event + return nil + case "mutex": + runtime.SetMutexProfileFraction(1) // Sampling every mutex event + return nil + default: + if profile := pprof.Lookup(profileName); profile == nil { + return fmt.Errorf("unknown profile type: '%s'", profileName) + } + return nil + } +} + +// flushProfiling writes the profiling data to the specified file. +// For heap profiles, it runs the GC before capturing the data. +// It stops the CPU profile if it was started. +func flushProfiling() error { + switch profileName { + case "none": + return nil + case "cpu": + pprof.StopCPUProfile() + return nil + case "heap": + runtime.GC() // Run garbage collection before writing heap profile + fallthrough + default: + profile := pprof.Lookup(profileName) + if profile == nil { + return errors.New("invalid profile type") + } + f, err := os.Create(profileOutput) + if err != nil { + return err + } + return profile.WriteTo(f, 0) + } +} diff --git a/tools/imctl/internal/imctl/util/interrupt/interrupt.go b/tools/imctl/internal/imctl/util/interrupt/interrupt.go new file mode 100644 index 000000000..4f7a7bf1b --- /dev/null +++ b/tools/imctl/internal/imctl/util/interrupt/interrupt.go @@ -0,0 +1,89 @@ +// Package interrupt deal with signals. +package interrupt + +import ( + "os" + "os/signal" + "sync" + "syscall" +) + +// terminationSignals are signals that cause the program to exit in the +// supported platforms (linux, darwin, windows). +var terminationSignals = []os.Signal{syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT} + +// Handler guarantees execution of notifications after a critical section (the function passed +// to a Run method), even in the presence of process termination. It guarantees exactly once +// invocation of the provided notify functions. +type Handler struct { + notify []func() + final func(os.Signal) + once sync.Once +} + +// Chain creates a new handler that invokes all notify functions when the critical section exits +// and then invokes the optional handler's notifications. This allows critical sections to be +// nested without losing exactly once invocations. Notify functions can invoke any cleanup needed +// but should not exit (which is the responsibility of the parent handler). +func Chain(handler *Handler, notify ...func()) *Handler { + if handler == nil { + return New(nil, notify...) + } + return New(handler.Signal, append(notify, handler.Close)...) +} + +// New creates a new handler that guarantees all notify functions are run after the critical +// section exits (or is interrupted by the OS), then invokes the final handler. If no final +// handler is specified, the default final is `os.Exit(1)`. A handler can only be used for +// one critical section. +func New(final func(os.Signal), notify ...func()) *Handler { + return &Handler{ + final: final, + notify: notify, + } +} + +// Close executes all the notification handlers if they have not yet been executed. +func (h *Handler) Close() { + h.once.Do(func() { + for _, fn := range h.notify { + fn() + } + }) +} + +// Signal is called when an os.Signal is received, and guarantees that all notifications +// are executed, then the final handler is executed. This function should only be called once +// per Handler instance. +func (h *Handler) Signal(s os.Signal) { + h.once.Do(func() { + for _, fn := range h.notify { + fn() + } + if h.final == nil { + os.Exit(1) + } + h.final(s) + }) +} + +// Run ensures that any notifications are invoked after the provided fn exits (even if the +// process is interrupted by an OS termination signal). Notifications are only invoked once +// per Handler instance, so calling Run more than once will not behave as the user expects. +func (h *Handler) Run(fn func() error) error { + ch := make(chan os.Signal, 1) + signal.Notify(ch, terminationSignals...) + defer func() { + signal.Stop(ch) + close(ch) + }() + go func() { + sig, ok := <-ch + if !ok { + return + } + h.Signal(sig) + }() + defer h.Close() + return fn() +} diff --git a/tools/imctl/internal/imctl/util/templates/command_groups.go b/tools/imctl/internal/imctl/util/templates/command_groups.go new file mode 100644 index 000000000..7e86578a3 --- /dev/null +++ b/tools/imctl/internal/imctl/util/templates/command_groups.go @@ -0,0 +1,43 @@ +package templates + +import ( + "github.com/spf13/cobra" +) + +type CommandGroup struct { + Message string + Commands []*cobra.Command +} + +type CommandGroups []CommandGroup + +func (g CommandGroups) Add(c *cobra.Command) { + for _, group := range g { + c.AddCommand(group.Commands...) + } +} + +func (g CommandGroups) Has(c *cobra.Command) bool { + for _, group := range g { + for _, command := range group.Commands { + if command == c { + return true + } + } + } + return false +} + +func AddAdditionalCommands(g CommandGroups, message string, cmds []*cobra.Command) CommandGroups { + group := CommandGroup{Message: message} + for _, c := range cmds { + // Don't show commands that have no short description + if !g.Has(c) && len(c.Short) != 0 { + group.Commands = append(group.Commands, c) + } + } + if len(group.Commands) == 0 { + return g + } + return append(g, group) +} diff --git a/tools/imctl/internal/imctl/util/templates/markdown.go b/tools/imctl/internal/imctl/util/templates/markdown.go new file mode 100644 index 000000000..d32146fc1 --- /dev/null +++ b/tools/imctl/internal/imctl/util/templates/markdown.go @@ -0,0 +1,135 @@ +package templates + +import ( + "bytes" + "fmt" + "io" + "strings" + + "github.com/russross/blackfriday" +) + +const linebreak = "\n" + +// ASCIIRenderer implements blackfriday.Renderer. +var _ blackfriday.Renderer = &ASCIIRenderer{} + +// ASCIIRenderer is a blackfriday.Renderer intended for rendering markdown +// documents as plain text, well suited for human reading on terminals. +type ASCIIRenderer struct { + Indentation string + + listItemCount uint + listLevel uint +} + +// NormalText gets a text chunk *after* the markdown syntax was already +// processed and does a final cleanup on things we don't expect here, like +// removing linebreaks on things that are not a paragraph break (auto unwrap). +func (r *ASCIIRenderer) NormalText(out *bytes.Buffer, text []byte) { + raw := string(text) + lines := strings.Split(raw, linebreak) + for _, line := range lines { + trimmed := strings.Trim(line, " \n\t") + if len(trimmed) > 0 && trimmed[0] != '_' { + out.WriteString(" ") + } + out.WriteString(trimmed) + } +} + +// List renders the start and end of a list. +func (r *ASCIIRenderer) List(out *bytes.Buffer, text func() bool, flags int) { + r.listLevel++ + out.WriteString(linebreak) + text() + r.listLevel-- +} + +// ListItem renders list items and supports both ordered and unordered lists. +func (r *ASCIIRenderer) ListItem(out *bytes.Buffer, text []byte, flags int) { + if flags&blackfriday.LIST_ITEM_BEGINNING_OF_LIST != 0 { + r.listItemCount = 1 + } else { + r.listItemCount++ + } + indent := strings.Repeat(r.Indentation, int(r.listLevel)) + var bullet string + if flags&blackfriday.LIST_TYPE_ORDERED != 0 { + bullet += fmt.Sprintf("%d.", r.listItemCount) + } else { + bullet += "*" + } + out.WriteString(indent + bullet + " ") + r.fw(out, text) + out.WriteString(linebreak) +} + +// Paragraph renders the start and end of a paragraph. +func (r *ASCIIRenderer) Paragraph(out *bytes.Buffer, text func() bool) { + out.WriteString(linebreak) + text() + out.WriteString(linebreak) +} + +// BlockCode renders a chunk of text that represents source code. +func (r *ASCIIRenderer) BlockCode(out *bytes.Buffer, text []byte, lang string) { + out.WriteString(linebreak) + lines := []string{} + for _, line := range strings.Split(string(text), linebreak) { + indented := r.Indentation + line + lines = append(lines, indented) + } + out.WriteString(strings.Join(lines, linebreak)) +} + +func (r *ASCIIRenderer) GetFlags() int { return 0 } +func (r *ASCIIRenderer) HRule(out *bytes.Buffer) { + out.WriteString(linebreak + "----------" + linebreak) +} +func (r *ASCIIRenderer) LineBreak(out *bytes.Buffer) { out.WriteString(linebreak) } +func (r *ASCIIRenderer) TitleBlock(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) Header(out *bytes.Buffer, text func() bool, level int, id string) { text() } +func (r *ASCIIRenderer) BlockHtml(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) BlockQuote(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) TableRow(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) TableHeaderCell(out *bytes.Buffer, text []byte, align int) { r.fw(out, text) } +func (r *ASCIIRenderer) TableCell(out *bytes.Buffer, text []byte, align int) { r.fw(out, text) } +func (r *ASCIIRenderer) Footnotes(out *bytes.Buffer, text func() bool) { text() } +func (r *ASCIIRenderer) FootnoteItem(out *bytes.Buffer, name, text []byte, flags int) { + r.fw(out, text) +} +func (r *ASCIIRenderer) AutoLink(out *bytes.Buffer, link []byte, kind int) { r.fw(out, link) } +func (r *ASCIIRenderer) CodeSpan(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) DoubleEmphasis(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) Emphasis(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) RawHtmlTag(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) TripleEmphasis(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) StrikeThrough(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) FootnoteRef(out *bytes.Buffer, ref []byte, id int) { r.fw(out, ref) } +func (r *ASCIIRenderer) Entity(out *bytes.Buffer, entity []byte) { r.fw(out, entity) } +func (r *ASCIIRenderer) Smartypants(out *bytes.Buffer, text []byte) { r.fw(out, text) } +func (r *ASCIIRenderer) DocumentHeader(out *bytes.Buffer) {} +func (r *ASCIIRenderer) DocumentFooter(out *bytes.Buffer) {} +func (r *ASCIIRenderer) TocHeaderWithAnchor(text []byte, level int, anchor string) {} +func (r *ASCIIRenderer) TocHeader(text []byte, level int) {} +func (r *ASCIIRenderer) TocFinalize() {} + +func (r *ASCIIRenderer) Table(out *bytes.Buffer, header []byte, body []byte, columnData []int) { + r.fw(out, header, body) +} + +func (r *ASCIIRenderer) Link(out *bytes.Buffer, link []byte, title []byte, content []byte) { + out.WriteString(" ") + r.fw(out, link) +} + +func (r *ASCIIRenderer) Image(out *bytes.Buffer, link []byte, title []byte, alt []byte) { + r.fw(out, link) +} + +func (r *ASCIIRenderer) fw(out io.Writer, text ...[]byte) { + for _, t := range text { + out.Write(t) + } +} diff --git a/tools/imctl/internal/imctl/util/templates/normalizers.go b/tools/imctl/internal/imctl/util/templates/normalizers.go new file mode 100644 index 000000000..fbc52c8e8 --- /dev/null +++ b/tools/imctl/internal/imctl/util/templates/normalizers.go @@ -0,0 +1,85 @@ +package templates + +import ( + "strings" + + "github.com/MakeNowJust/heredoc/v2" + "github.com/russross/blackfriday" + "github.com/spf13/cobra" +) + +const Indentation = ` ` + +// LongDesc normalizes a command's long description to follow the conventions. +func LongDesc(s string) string { + if len(s) == 0 { + return s + } + return normalizer{s}.heredoc().markdown().trim().string +} + +// Examples normalizes a command's examples to follow the conventions. +func Examples(s string) string { + if len(s) == 0 { + return s + } + return normalizer{s}.trim().indent().string +} + +// Normalize perform all required normalizations on a given command. +func Normalize(cmd *cobra.Command) *cobra.Command { + if len(cmd.Long) > 0 { + cmd.Long = LongDesc(cmd.Long) + } + if len(cmd.Example) > 0 { + cmd.Example = Examples(cmd.Example) + } + return cmd +} + +// NormalizeAll perform all required normalizations in the entire command tree. +func NormalizeAll(cmd *cobra.Command) *cobra.Command { + if cmd.HasSubCommands() { + for _, subCmd := range cmd.Commands() { + NormalizeAll(subCmd) + } + } + Normalize(cmd) + return cmd +} + +type normalizer struct { + string +} + +func (s normalizer) markdown() normalizer { + bytes := []byte(s.string) + formatted := blackfriday.Markdown( + bytes, + &ASCIIRenderer{Indentation: Indentation}, + blackfriday.EXTENSION_NO_INTRA_EMPHASIS, + ) + s.string = string(formatted) + return s +} + +func (s normalizer) heredoc() normalizer { + s.string = heredoc.Doc(s.string) + return s +} + +func (s normalizer) trim() normalizer { + s.string = strings.TrimSpace(s.string) + return s +} + +func (s normalizer) indent() normalizer { + indentedLines := []string{} + for _, line := range strings.Split(s.string, "\n") { + trimmed := strings.TrimSpace(line) + indented := Indentation + trimmed + indentedLines = append(indentedLines, indented) + } + s.string = strings.Join(indentedLines, "\n") + return s +} diff --git a/tools/imctl/internal/imctl/util/templates/templater.go b/tools/imctl/internal/imctl/util/templates/templater.go new file mode 100644 index 000000000..e616ec602 --- /dev/null +++ b/tools/imctl/internal/imctl/util/templates/templater.go @@ -0,0 +1,282 @@ +package templates + +import ( + "bytes" + "fmt" + "strings" + "text/template" + "unicode" + + "github.com/spf13/cobra" + flag "github.com/spf13/pflag" + + "github.com/OpenIMSDK/Open-IM-Server/tools/imctl/internal/util/term" +) + +type FlagExposer interface { + ExposeFlags(cmd *cobra.Command, flags ...string) FlagExposer +} + +func ActsAsRootCommand(cmd *cobra.Command, filters []string, groups ...CommandGroup) FlagExposer { + if cmd == nil { + panic("nil root command") + } + templater := &templater{ + RootCmd: cmd, + UsageTemplate: MainUsageTemplate(), + HelpTemplate: MainHelpTemplate(), + CommandGroups: groups, + Filtered: filters, + } + cmd.SetFlagErrorFunc(templater.FlagErrorFunc()) + cmd.SilenceUsage = true + cmd.SetUsageFunc(templater.UsageFunc()) + cmd.SetHelpFunc(templater.HelpFunc()) + return templater +} + +func UseOptionsTemplates(cmd *cobra.Command) { + templater := &templater{ + UsageTemplate: OptionsUsageTemplate(), + HelpTemplate: OptionsHelpTemplate(), + } + cmd.SetUsageFunc(templater.UsageFunc()) + cmd.SetHelpFunc(templater.HelpFunc()) +} + +type templater struct { + UsageTemplate string + HelpTemplate string + RootCmd *cobra.Command + CommandGroups + Filtered []string +} + +func (t *templater) FlagErrorFunc(exposedFlags ...string) func(*cobra.Command, error) error { + return func(c *cobra.Command, err error) error { + c.SilenceUsage = true + switch c.CalledAs() { + case "options": + return fmt.Errorf("%s\nrun '%s' without flags", err, c.CommandPath()) + default: + return fmt.Errorf("%s\nsee '%s --help' for usage", err, c.CommandPath()) + } + } +} + +func (t *templater) ExposeFlags(cmd *cobra.Command, flags ...string) FlagExposer { + cmd.SetUsageFunc(t.UsageFunc(flags...)) + return t +} + +func (t *templater) HelpFunc() func(*cobra.Command, []string) { + return func(c *cobra.Command, s []string) { + tt := template.New("help") + tt.Funcs(t.templateFuncs()) + template.Must(tt.Parse(t.HelpTemplate)) + out := term.NewResponsiveWriter(c.OutOrStdout()) + err := tt.Execute(out, c) + if err != nil { + c.Println(err) + } + } +} + +func (t *templater) UsageFunc(exposedFlags ...string) func(*cobra.Command) error { + return func(c *cobra.Command) error { + tt := template.New("usage") + tt.Funcs(t.templateFuncs(exposedFlags...)) + template.Must(tt.Parse(t.UsageTemplate)) + out := term.NewResponsiveWriter(c.OutOrStderr()) + return tt.Execute(out, c) + } +} + +func (t *templater) templateFuncs(exposedFlags ...string) template.FuncMap { + return template.FuncMap{ + "trim": strings.TrimSpace, + "trimRight": func(s string) string { return strings.TrimRightFunc(s, unicode.IsSpace) }, + "trimLeft": func(s string) string { return strings.TrimLeftFunc(s, unicode.IsSpace) }, + "gt": cobra.Gt, + "eq": cobra.Eq, + "rpad": rpad, + "appendIfNotPresent": appendIfNotPresent, + "flagsNotIntersected": flagsNotIntersected, + "visibleFlags": visibleFlags, + "flagsUsages": flagsUsages, + "cmdGroups": t.cmdGroups, + "cmdGroupsString": t.cmdGroupsString, + "rootCmd": t.rootCmdName, + "isRootCmd": t.isRootCmd, + "optionsCmdFor": t.optionsCmdFor, + "usageLine": t.usageLine, + "exposed": func(c *cobra.Command) *flag.FlagSet { + exposed := flag.NewFlagSet("exposed", flag.ContinueOnError) + if len(exposedFlags) > 0 { + for _, name := range exposedFlags { + if flag := c.Flags().Lookup(name); flag != nil { + exposed.AddFlag(flag) + } + } + } + return exposed + }, + } +} + +func (t *templater) cmdGroups(c *cobra.Command, all []*cobra.Command) []CommandGroup { + if len(t.CommandGroups) > 0 && c == t.RootCmd { + all = filter(all, t.Filtered...) + return AddAdditionalCommands(t.CommandGroups, "Other Commands:", all) + } + all = filter(all, "options") + return []CommandGroup{ + { + Message: "Available Commands:", + Commands: all, + }, + } +} + +func (t *templater) cmdGroupsString(c *cobra.Command) string { + groups := []string{} + for _, cmdGroup := range t.cmdGroups(c, c.Commands()) { + cmds := []string{cmdGroup.Message} + for _, cmd := range cmdGroup.Commands { + if cmd.IsAvailableCommand() { + cmds = append(cmds, " "+rpad(cmd.Name(), cmd.NamePadding())+" "+cmd.Short) + } + } + groups = append(groups, strings.Join(cmds, "\n")) + } + return strings.Join(groups, "\n\n") +} + +func (t *templater) rootCmdName(c *cobra.Command) string { + return t.rootCmd(c).CommandPath() +} + +func (t *templater) isRootCmd(c *cobra.Command) bool { + return t.rootCmd(c) == c +} + +func (t *templater) parents(c *cobra.Command) []*cobra.Command { + parents := []*cobra.Command{c} + for current := c; !t.isRootCmd(current) && current.HasParent(); { + current = current.Parent() + parents = append(parents, current) + } + return parents +} + +func (t *templater) rootCmd(c *cobra.Command) *cobra.Command { + if c != nil && !c.HasParent() { + return c + } + if t.RootCmd == nil { + panic("nil root cmd") + } + return t.RootCmd +} + +func (t *templater) optionsCmdFor(c *cobra.Command) string { + if !c.Runnable() { + return "" + } + rootCmdStructure := t.parents(c) + for i := len(rootCmdStructure) - 1; i >= 0; i-- { + cmd := rootCmdStructure[i] + if _, _, err := cmd.Find([]string{"options"}); err == nil { + return cmd.CommandPath() + " options" + } + } + return "" +} + +func (t *templater) usageLine(c *cobra.Command) string { + usage := c.UseLine() + suffix := "[options]" + if c.HasFlags() && !strings.Contains(usage, suffix) { + usage += " " + suffix + } + return usage +} + +func flagsUsages(f *flag.FlagSet) string { + x := new(bytes.Buffer) + + f.VisitAll(func(flag *flag.Flag) { + if flag.Hidden { + return + } + format := "--%s=%s: %s\n" + + if flag.Value.Type() == "string" { + format = "--%s='%s': %s\n" + } + + if len(flag.Shorthand) > 0 { + format = " -%s, " + format + } else { + format = " %s " + format + } + + fmt.Fprintf(x, format, flag.Shorthand, flag.Name, flag.DefValue, flag.Usage) + }) + + return x.String() +} + +func rpad(s string, padding int) string { + template := fmt.Sprintf("%%-%ds", padding) + return fmt.Sprintf(template, s) +} + +func appendIfNotPresent(s, stringToAppend string) string { + if strings.Contains(s, stringToAppend) { + return s + } + return s + " " + stringToAppend +} + +func flagsNotIntersected(l *flag.FlagSet, r *flag.FlagSet) *flag.FlagSet { + f := flag.NewFlagSet("notIntersected", flag.ContinueOnError) + l.VisitAll(func(flag *flag.Flag) { + if r.Lookup(flag.Name) == nil { + f.AddFlag(flag) + } + }) + return f +} + +func visibleFlags(l *flag.FlagSet) *flag.FlagSet { + hidden := "help" + f := flag.NewFlagSet("visible", flag.ContinueOnError) + l.VisitAll(func(flag *flag.Flag) { + if flag.Name != hidden { + f.AddFlag(flag) + } + }) + return f +} + +func filter(cmds []*cobra.Command, names ...string) []*cobra.Command { + out := []*cobra.Command{} + for _, c := range cmds { + if c.Hidden { + continue + } + skip := false + for _, name := range names { + if name == c.Name() { + skip = true + break + } + } + if skip { + continue + } + out = append(out, c) + } + return out +} diff --git a/tools/imctl/internal/imctl/util/templates/templates.go b/tools/imctl/internal/imctl/util/templates/templates.go new file mode 100644 index 000000000..1ef16981a --- /dev/null +++ b/tools/imctl/internal/imctl/util/templates/templates.go @@ -0,0 +1,89 @@ +// Package templates provides template functions for working with templates. +package templates + +import ( + "strings" + "unicode" +) + +const ( + // SectionVars is the help template section that declares variables to be used in the template. + SectionVars = `{{$isRootCmd := isRootCmd .}}` + + `{{$rootCmd := rootCmd .}}` + + `{{$visibleFlags := visibleFlags (flagsNotIntersected .LocalFlags .PersistentFlags)}}` + + `{{$explicitlyExposedFlags := exposed .}}` + + `{{$optionsCmdFor := optionsCmdFor .}}` + + `{{$usageLine := usageLine .}}` + + // SectionAliases is the help template section that displays command aliases. + SectionAliases = `{{if gt .Aliases 0}}Aliases: +{{.NameAndAliases}} + +{{end}}` + + // SectionExamples is the help template section that displays command examples. + SectionExamples = `{{if .HasExample}}Examples: +{{trimRight .Example}} + +{{end}}` + + // SectionSubcommands is the help template section that displays the command's subcommands. + SectionSubcommands = `{{if .HasAvailableSubCommands}}{{cmdGroupsString .}} + +{{end}}` + + // SectionFlags is the help template section that displays the command's flags. + SectionFlags = `{{ if or $visibleFlags.HasFlags $explicitlyExposedFlags.HasFlags}}Options: +{{ if $visibleFlags.HasFlags}}{{trimRight (flagsUsages $visibleFlags)}}{{end}}{{ if $explicitlyExposedFlags.HasFlags}}{{ if $visibleFlags.HasFlags}} +{{end}}{{trimRight (flagsUsages $explicitlyExposedFlags)}}{{end}} + +{{end}}` + + // SectionUsage is the help template section that displays the command's usage. + SectionUsage = `{{if and .Runnable (ne .UseLine "") (ne .UseLine $rootCmd)}}Usage: + {{$usageLine}} + +{{end}}` + + // SectionTipsHelp is the help template section that displays the '--help' hint. + SectionTipsHelp = `{{if .HasSubCommands}}Use "{{$rootCmd}} --help" for more information about a given command. +{{end}}` + + // SectionTipsGlobalOptions is the help template section that displays the 'options' hint for displaying global + // flags. + SectionTipsGlobalOptions = `{{if $optionsCmdFor}}Use "{{$optionsCmdFor}}" for a list of global command-line options (applies to all commands). +{{end}}` +) + +// MainHelpTemplate if the template for 'help' used by most commands. +func MainHelpTemplate() string { + return `{{with or .Long .Short }}{{. | trim}}{{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}}` +} + +// MainUsageTemplate if the template for 'usage' used by most commands. +func MainUsageTemplate() string { + sections := []string{ + "\n\n", + SectionVars, + SectionAliases, + SectionExamples, + SectionSubcommands, + SectionFlags, + SectionUsage, + SectionTipsHelp, + SectionTipsGlobalOptions, + } + return strings.TrimRightFunc(strings.Join(sections, ""), unicode.IsSpace) +} + +// OptionsHelpTemplate if the template for 'help' used by the 'options' command. +func OptionsHelpTemplate() string { + return "" +} + +// OptionsUsageTemplate if the template for 'usage' used by the 'options' command. +func OptionsUsageTemplate() string { + return `{{ if .HasInheritedFlags}}The following options can be passed to any command: + +{{flagsUsages .InheritedFlags}}{{end}}` +} diff --git a/tools/imctl/internal/imctl/util/term/resize.go b/tools/imctl/internal/imctl/util/term/resize.go new file mode 100644 index 000000000..4c59ed3d7 --- /dev/null +++ b/tools/imctl/internal/imctl/util/term/resize.go @@ -0,0 +1,43 @@ +// Copyright 2020 Lingfei Kong . All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package term + +import ( + "github.com/moby/term" +) + +// TerminalSize represents the width and height of a terminal. +type TerminalSize struct { + Width uint16 + Height uint16 +} + +// TerminalSizeQueue is capable of returning terminal resize events as they occur. +type TerminalSizeQueue interface { + // Next returns the new terminal size after the terminal has been resized. It returns nil when + // monitoring has been stopped. + Next() *TerminalSize +} + +// GetSize returns the current size of the user's terminal. If it isn't a terminal, +// nil is returned. +func (t TTY) GetSize() *TerminalSize { + outFd, isTerminal := term.GetFdInfo(t.Out) + if !isTerminal { + return nil + } + return GetSize(outFd) +} + +// GetSize returns the current size of the terminal associated with fd. +func GetSize(fd uintptr) *TerminalSize { + winsize, err := term.GetWinsize(fd) + if err != nil { + // runtime.HandleError(fmt.Errorf("unable to get terminal size: %v", err)) + return nil + } + + return &TerminalSize{Width: winsize.Width, Height: winsize.Height} +} diff --git a/tools/imctl/internal/imctl/util/term/term.go b/tools/imctl/internal/imctl/util/term/term.go new file mode 100644 index 000000000..cb4a3caeb --- /dev/null +++ b/tools/imctl/internal/imctl/util/term/term.go @@ -0,0 +1,27 @@ +// Copyright 2020 Lingfei Kong . All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +// Package term provides structures and helper functions to work with +// terminal (state, sizes). +package term + +import ( + "io" +) + +// TTY helps invoke a function and preserve the state of the terminal, even if the process is +// terminated during execution. It also provides support for terminal resizing for remote command +// execution/attachment. +type TTY struct { + // In is a reader representing stdin. It is a required field. + In io.Reader + // Out is a writer representing stdout. It must be set to support terminal resizing. It is an + // optional field. + Out io.Writer + // Raw is true if the terminal should be set raw. + Raw bool + // TryDev indicates the TTY should try to open /dev/tty if the provided input + // is not a file descriptor. + TryDev bool +} diff --git a/tools/imctl/internal/imctl/util/term/term_writer.go b/tools/imctl/internal/imctl/util/term/term_writer.go new file mode 100644 index 000000000..dcc9d3607 --- /dev/null +++ b/tools/imctl/internal/imctl/util/term/term_writer.go @@ -0,0 +1,112 @@ +// Copyright 2020 Lingfei Kong . All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package term + +import ( + "io" + "os" + + wordwrap "github.com/mitchellh/go-wordwrap" + "github.com/moby/term" +) + +type wordWrapWriter struct { + limit uint + writer io.Writer +} + +// NewResponsiveWriter creates a Writer that detects the column width of the +// terminal we are in, and adjusts every line width to fit and use recommended +// terminal sizes for better readability. Does proper word wrapping automatically. +// if terminal width >= 120 columns use 120 columns +// if terminal width >= 100 columns use 100 columns +// if terminal width >= 80 columns use 80 columns +// In case we're not in a terminal or if it's smaller than 80 columns width, +// doesn't do any wrapping. +func NewResponsiveWriter(w io.Writer) io.Writer { + file, ok := w.(*os.File) + if !ok { + return w + } + fd := file.Fd() + if !term.IsTerminal(fd) { + return w + } + + terminalSize := GetSize(fd) + if terminalSize == nil { + return w + } + + var limit uint + switch { + case terminalSize.Width >= 120: + limit = 120 + case terminalSize.Width >= 100: + limit = 100 + case terminalSize.Width >= 80: + limit = 80 + } + + return NewWordWrapWriter(w, limit) +} + +// NewWordWrapWriter is a Writer that supports a limit of characters on every line +// and does auto word wrapping that respects that limit. +func NewWordWrapWriter(w io.Writer, limit uint) io.Writer { + return &wordWrapWriter{ + limit: limit, + writer: w, + } +} + +func (w wordWrapWriter) Write(p []byte) (nn int, err error) { + if w.limit == 0 { + return w.writer.Write(p) + } + original := string(p) + wrapped := wordwrap.WrapString(original, w.limit) + return w.writer.Write([]byte(wrapped)) +} + +// NewPunchCardWriter is a NewWordWrapWriter that limits the line width to 80 columns. +func NewPunchCardWriter(w io.Writer) io.Writer { + return NewWordWrapWriter(w, 80) +} + +type maxWidthWriter struct { + maxWidth uint + currentWidth uint + written uint + writer io.Writer +} + +// NewMaxWidthWriter is a Writer that supports a limit of characters on every +// line, but doesn't do any word wrapping automatically. +func NewMaxWidthWriter(w io.Writer, maxWidth uint) io.Writer { + return &maxWidthWriter{ + maxWidth: maxWidth, + writer: w, + } +} + +func (m maxWidthWriter) Write(p []byte) (nn int, err error) { + for _, b := range p { + if m.currentWidth == m.maxWidth { + m.writer.Write([]byte{'\n'}) + m.currentWidth = 0 + } + if b == '\n' { + m.currentWidth = 0 + } + _, err := m.writer.Write([]byte{b}) + if err != nil { + return int(m.written), err + } + m.written++ + m.currentWidth++ + } + return len(p), nil +} diff --git a/tools/imctl/internal/imctl/util/term/term_writer_test.go b/tools/imctl/internal/imctl/util/term/term_writer_test.go new file mode 100644 index 000000000..a4abd4259 --- /dev/null +++ b/tools/imctl/internal/imctl/util/term/term_writer_test.go @@ -0,0 +1,104 @@ +// Copyright 2020 Lingfei Kong . All rights reserved. +// Use of this source code is governed by a MIT style +// license that can be found in the LICENSE file. + +package term + +import ( + "bytes" + "strings" + "testing" +) + +const test = "Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam Iam" + +func TestWordWrapWriter(t *testing.T) { + testcases := map[string]struct { + input string + maxWidth uint + }{ + "max 10": {input: test, maxWidth: 10}, + "max 80": {input: test, maxWidth: 80}, + "max 120": {input: test, maxWidth: 120}, + "max 5000": {input: test, maxWidth: 5000}, + } + for k, tc := range testcases { + b := bytes.NewBufferString("") + w := NewWordWrapWriter(b, tc.maxWidth) + _, err := w.Write([]byte(tc.input)) + if err != nil { + t.Errorf("%s: Unexpected error: %v", k, err) + } + result := b.String() + if !strings.Contains(result, "Iam") { + t.Errorf("%s: Expected to contain \"Iam\"", k) + } + if len(result) < len(tc.input) { + t.Errorf( + "%s: Unexpectedly short string, got %d wanted at least %d chars: %q", + k, + len(result), + len(tc.input), + result, + ) + } + for _, line := range strings.Split(result, "\n") { + if len(line) > int(tc.maxWidth) { + t.Errorf("%s: Every line must be at most %d chars long, got %d: %q", k, tc.maxWidth, len(line), line) + } + } + for _, word := range strings.Split(result, " ") { + if !strings.Contains(word, "Iam") { + t.Errorf("%s: Unexpected broken word: %q", k, word) + } + } + } +} + +func TestMaxWidthWriter(t *testing.T) { + testcases := map[string]struct { + input string + maxWidth uint + }{ + "max 10": {input: test, maxWidth: 10}, + "max 80": {input: test, maxWidth: 80}, + "max 120": {input: test, maxWidth: 120}, + "max 5000": {input: test, maxWidth: 5000}, + } + for k, tc := range testcases { + b := bytes.NewBufferString("") + w := NewMaxWidthWriter(b, tc.maxWidth) + _, err := w.Write([]byte(tc.input)) + if err != nil { + t.Errorf("%s: Unexpected error: %v", k, err) + } + result := b.String() + if !strings.Contains(result, "Iam") { + t.Errorf("%s: Expected to contain \"Iam\"", k) + } + if len(result) < len(tc.input) { + t.Errorf( + "%s: Unexpectedly short string, got %d wanted at least %d chars: %q", + k, + len(result), + len(tc.input), + result, + ) + } + lines := strings.Split(result, "\n") + for i, line := range lines { + if len(line) > int(tc.maxWidth) { + t.Errorf("%s: Every line must be at most %d chars long, got %d: %q", k, tc.maxWidth, len(line), line) + } + if i < len(lines)-1 && len(line) != int(tc.maxWidth) { + t.Errorf( + "%s: Lines except the last one are expected to be exactly %d chars long, got %d: %q", + k, + tc.maxWidth, + len(line), + line, + ) + } + } + } +}