├── .bandit.yml ├── .circleci └── config.yml ├── .dockerignore ├── .gitignore ├── .gitmodules ├── .pre-commit-config.yaml ├── .readthedocs.yaml ├── Dockerfile ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── docker-image ├── .pip │ └── pip.conf ├── .zshrc ├── apt │ └── sources.list ├── git_askpass.sh ├── requirements.in └── requirements.txt ├── docs ├── _static │ ├── grafana-container-monitor.json │ └── grafana.jpg ├── admin.rst ├── app.rst ├── best-practices.rst ├── conf.py ├── design.rst ├── dev.rst ├── errors.rst ├── index.rst ├── quick-start.rst └── requirements.txt ├── lain_cli ├── __init__.py ├── aliyun.py ├── chart_template │ ├── Chart.yaml.j2 │ ├── index.yaml │ ├── templates │ │ ├── _helpers.tpl │ │ ├── cronjob.yaml │ │ ├── deployment.yaml │ │ ├── externalIngress.yaml │ │ ├── ingress.yaml │ │ ├── job.yaml │ │ ├── networkPolicy.yaml │ │ ├── pvc.yaml │ │ ├── service.yaml │ │ ├── statefulSet.yaml │ │ └── test.yaml │ └── values.yaml.j2 ├── cluster_values │ └── values-test.yaml ├── harbor.py ├── kibana.py ├── lain.py ├── lint.py ├── prometheus.py ├── prompt.py ├── registry.py ├── scm.py ├── templates │ ├── .dockerignore.j2 │ ├── Dockerfile.j2 │ ├── canary-toast.txt.j2 │ ├── deploy-toast.txt.j2 │ ├── deploy-webhook-message.txt.j2 │ ├── docker-compose.yaml.j2 │ ├── job.yaml.j2 │ ├── k8s-secret-diff.txt.j2 │ └── values-canary.yaml.j2 ├── tencent.py ├── utils.py └── webhook.py ├── pylintrc ├── pyproject.toml ├── pytest.ini ├── setup.cfg ├── setup.py └── tests ├── __init__.py ├── conftest.py ├── editor.py ├── test_commands.py ├── test_utils.py ├── test_values.py └── test_workflow.py /.bandit.yml: -------------------------------------------------------------------------------- 1 | skips: 2 | - B108 # download file into /tmp 3 | - B303 # md5 4 | - B604 # run shell command 5 | - B701 # jinja2 is used to render dockerfile 6 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | version: '2.1' 2 | orbs: 3 | codecov: codecov/codecov@3.0.0 4 | 5 | jobs: 6 | 7 | semgrep-scan: 8 | parameters: 9 | repo_path: 10 | type: string 11 | default: timfeirg/lain-cli 12 | default_branch: 13 | type: string 14 | default: master 15 | environment: 16 | SEMGREP_RULES: >- 17 | p/security-audit 18 | p/secrets 19 | p/ci 20 | p/python 21 | SEMGREP_BASELINE_REF: << parameters.default_branch >> 22 | docker: 23 | - image: returntocorp/semgrep-agent:v1 24 | steps: 25 | - checkout 26 | - run: 27 | name: "scan" 28 | command: semgrep-agent 29 | 30 | e2e: 31 | machine: 32 | image: ubuntu-2204:current 33 | docker_layer_caching: true 34 | environment: 35 | K8S_VERSION: v1.20.0 36 | KUBECONFIG: /home/circleci/.kube/config 37 | MINIKUBE_VERSION: v1.24.0 38 | MINIKUBE_WANTUPDATENOTIFICATION: false 39 | MINIKUBE_WANTREPORTERRORPROMPT: false 40 | MINIKUBE_HOME: /home/circleci 41 | CHANGE_MINIKUBE_NONE_USER: true 42 | steps: 43 | - checkout 44 | 45 | - run: 46 | name: "provision" 47 | command: | 48 | sudo apt-get update -y 49 | sudo apt-get install -y conntrack git python3.9 50 | sudo ln -s -f /usr/bin/python3.9 /usr/bin/python3 51 | curl -LO https://bootstrap.pypa.io/get-pip.py 52 | python3 get-pip.py 53 | rm get-pip.py 54 | git submodule update -f --init 55 | pip3 install -U -r docker-image/requirements.txt 56 | pip3 install -e .[tests] 57 | curl -Lo minikube https://github.com/kubernetes/minikube/releases/download/${MINIKUBE_VERSION}/minikube-linux-amd64 58 | chmod +x minikube 59 | sudo mv minikube /usr/local/bin/ 60 | sudo -E minikube start --vm-driver=none --kubernetes-version=${K8S_VERSION} 61 | sudo ln -s $(which minikube) /usr/local/bin/kubectl 62 | minikube addons enable ingress 63 | kubectl delete -A ValidatingWebhookConfiguration ingress-nginx-admission 64 | curl https://raw.githubusercontent.com/helm/helm/main/scripts/get-helm-3 | bash 65 | sudo tee -a /etc/hosts \<<< "$(minikube ip) dummy.info" 66 | sudo tee -a /etc/hosts \<<< "$(minikube ip) dummy-dev.info" 67 | docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_PASSWORD 68 | 69 | - run: 70 | name: "e2e tests" 71 | command: | 72 | mv ~/.kube/config ~/.kube/kubeconfig-test 73 | lain use test 74 | py.test tests --cov=lain_cli 75 | coverage xml 76 | 77 | - run: 78 | name: "upload" 79 | command: | 80 | python3 setup.py sdist 81 | twine upload -u $TWINE_USERNAME -p $TWINE_PASSWORD dist/* || true 82 | filters: 83 | branches: 84 | only: 85 | - master 86 | 87 | - codecov/upload 88 | 89 | workflows: 90 | version: 2.1 91 | tests: 92 | jobs: 93 | - semgrep-scan 94 | - e2e 95 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | dist 3 | *.egg-info 4 | tests 5 | LICENSE 6 | Makefile 7 | tests 8 | 9 | .DS_Store 10 | *.pyc 11 | **/build 12 | **/dist 13 | **/*.egg-info 14 | **/.ropeproject/ 15 | .pytest_cache 16 | .envrc 17 | .vscode 18 | passwords.txt 19 | Dockerfile 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | **/build 4 | **/dist 5 | **/*.egg-info 6 | **/.ropeproject/ 7 | .pytest_cache 8 | .envrc 9 | .vscode 10 | passwords.txt 11 | kubeconfig-* 12 | docker-image/config.json 13 | .cache 14 | public 15 | *.bak 16 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "tests/dummy"] 2 | path = tests/dummy 3 | url = https://github.com/timfeirg/dummy 4 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | 3 | - repo: meta 4 | hooks: 5 | - id: check-hooks-apply 6 | - id: check-useless-excludes 7 | 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v4.1.0 10 | hooks: 11 | - id: check-yaml 12 | exclude: lain_cli/chart_template 13 | - id: end-of-file-fixer 14 | - id: trailing-whitespace 15 | 16 | - repo: https://github.com/psf/black 17 | rev: 22.3.0 18 | hooks: 19 | - id: black 20 | args: ['--skip-string-normalization'] 21 | 22 | - repo: https://github.com/jendrikseipp/vulture 23 | rev: v2.3 24 | hooks: 25 | - id: vulture 26 | types: [python] 27 | args: 28 | - lain_cli 29 | 30 | - repo: https://gitlab.com/pycqa/flake8 31 | rev: 4.0.1 32 | hooks: 33 | - id: flake8 34 | additional_dependencies: 35 | - flake8-pytest-style 36 | - flake8-bugbear 37 | - flake8-logging-format 38 | 39 | - repo: https://github.com/PyCQA/bandit 40 | rev: 1.7.2 41 | hooks: 42 | - id: bandit 43 | args: ["-ll", "-c", ".bandit.yml"] 44 | 45 | - repo: https://github.com/jorisroovers/gitlint 46 | rev: v0.17.0 47 | hooks: 48 | - id: gitlint 49 | 50 | - repo: https://github.com/PyCQA/pylint 51 | rev: v2.12.2 52 | hooks: 53 | - id: pylint 54 | name: pylint 55 | types: [python] 56 | entry: python -m pylint.__main__ 57 | language: system 58 | args: 59 | [ 60 | "-rn", 61 | "-sn", 62 | "--rcfile=pylintrc", 63 | ] 64 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | version: 2 2 | sphinx: 3 | configuration: docs/conf.py 4 | python: 5 | version: 3.8 6 | install: 7 | - requirements: docs/requirements.txt 8 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ubuntu:focal 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 LAIN_IGNORE_LINT="true" PS1="lain# " 4 | 5 | ARG HELM_VERSION=3.8.0 6 | ARG PYTHON_VERSION_SHORT=3.9 7 | ARG TRIVY_VERSION=0.23.0 8 | 9 | WORKDIR /srv/lain 10 | 11 | ADD docker-image/apt/sources.list /etc/apt/sources.list 12 | RUN apt-get update && \ 13 | apt-get install -y --no-install-recommends tzdata locales gnupg2 curl jq ca-certificates python${PYTHON_VERSION_SHORT} python3-pip && \ 14 | ln -s -f /usr/bin/python${PYTHON_VERSION_SHORT} /usr/bin/python3 && \ 15 | ln -s -f /usr/bin/python${PYTHON_VERSION_SHORT} /usr/bin/python && \ 16 | ln -s -f /usr/bin/pip3 /usr/bin/pip && \ 17 | ln -s -f /usr/share/zoneinfo/Asia/Shanghai /etc/localtime && \ 18 | sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ 19 | dpkg-reconfigure --frontend=noninteractive locales && \ 20 | update-locale LANG=en_US.UTF-8 && \ 21 | curl -L https://github.com/getsentry/sentry-cli/releases/download/2.3.0/sentry-cli-Linux-x86_64 --output /usr/local/bin/sentry-cli && \ 22 | chmod +x /usr/local/bin/sentry-cli && \ 23 | curl -LO https://github.com/aquasecurity/trivy/releases/download/v$TRIVY_VERSION/trivy_${TRIVY_VERSION}_Linux-64bit.deb && \ 24 | dpkg -i trivy_${TRIVY_VERSION}_Linux-64bit.deb && \ 25 | curl -LO https://mirrors.huaweicloud.com/helm/v${HELM_VERSION}/helm-v${HELM_VERSION}-linux-amd64.tar.gz && \ 26 | tar -xvzf helm-v${HELM_VERSION}-linux-amd64.tar.gz && \ 27 | mv linux-amd64/helm /usr/local/bin/helm && \ 28 | chmod +x /usr/local/bin/helm && \ 29 | rm -rf linux-amd64 *.tar.gz *.deb && \ 30 | curl -fsSL http://mirrors.aliyun.com/docker-ce/linux/ubuntu/gpg | apt-key add - && \ 31 | curl https://mirrors.aliyun.com/kubernetes/apt/doc/apt-key.gpg | apt-key add - && \ 32 | echo "deb https://mirrors.aliyun.com/kubernetes/apt/ kubernetes-xenial main" >> /etc/apt/sources.list.d/kubernetes.list && \ 33 | echo "deb [arch=amd64] http://mirrors.aliyun.com/docker-ce/linux/ubuntu focal stable" >> /etc/apt/sources.list && \ 34 | apt-get update && apt-get install -y \ 35 | kubectl=1.20.11-00 python${PYTHON_VERSION_SHORT}-dev docker-ce-cli docker-compose mysql-client mytop libmysqlclient-dev redis-tools iputils-ping dnsutils \ 36 | zip zsh fasd silversearcher-ag telnet rsync vim lsof tree openssh-client apache2-utils git git-lfs && \ 37 | chsh -s /usr/bin/zsh root && \ 38 | apt-get clean 39 | ADD docker-image/.pip /root/.pip 40 | COPY docker-image/git_askpass.sh /usr/local/bin/git_askpass.sh 41 | ENV GIT_ASKPASS=/usr/local/bin/git_askpass.sh 42 | COPY docker-image/.zshrc /root/.zshrc 43 | COPY docker-image/requirements.txt /tmp/requirements.txt 44 | COPY setup.py ./setup.py 45 | COPY lain_cli ./lain_cli 46 | RUN pip3 install -U -r /tmp/requirements.txt && \ 47 | git init && \ 48 | rm -rf /tmp/* .git 49 | 50 | CMD ["bash"] 51 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 LAIN Cloud 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft lain_cli/chart_template 2 | graft lain_cli/templates 3 | graft lain_cli/cluster_values 4 | 5 | global-exclude __pycache__ 6 | global-exclude *.py[co] 7 | 8 | exclude tests 9 | exclude opensource 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: clean 2 | clean: 3 | - find . -iname "*__pycache__" | xargs rm -rf 4 | - find . -iname "*.pyc" | xargs rm -rf 5 | - rm -rf dist build *.egg-info .coverage htmlcov unittest.xml .*cache _build _templates public 6 | 7 | .PHONY: pip-compile 8 | pip-compile: 9 | pip-compile -U -vvvv --output-file=docker-image/requirements.txt docker-image/requirements.in 10 | 11 | .PHONY: sphinx 12 | sphinx: 13 | sphinx-build -b html docs public 14 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## lain 2 | 3 | [![readthedocs](https://readthedocs.org/projects/pip/badge/?version=latest&style=plastic)](https://lain-cli.readthedocs.io/en/latest/) [![CircleCI](https://circleci.com/gh/timfeirg/lain-cli.svg?style=svg)](https://circleci.com/gh/timfeirg/lain-cli) [![codecov](https://codecov.io/gh/timfeirg/lain-cli/branch/master/graph/badge.svg?token=A6153W38P4)](https://codecov.io/gh/timfeirg/lain-cli) 4 | 5 | lain is a DevOps solution, but really, it just helps you with kubectl / helm / docker. 6 | 7 | [![asciicast](https://asciinema.org/a/iLCiMoE4SDTyjcspXYfXGSkeO.svg)](https://asciinema.org/a/iLCiMoE4SDTyjcspXYfXGSkeO) 8 | 9 | ## Installation / Adoption 10 | 11 | The recommended way to use lain is to [maintain an internal fork for your team](https://lain-cli.readthedocs.io/en/latest/dev.html#lain), this may be too much, you can still try out lain with the following steps: 12 | 13 | * Install from PyPI: `pip install -U lain` 14 | * Write cluster values, according to docs [here](https://lain-cli.readthedocs.io/en/latest/dev.html#cluster-values), and examples [here](https://github.com/timfeirg/lain-cli/tree/master/lain_cli/cluster_values), so that lain knows how to talk to your Kubernetes cluster 15 | * Set `CLUSTER_VALUES_DIR` to the directory that contains all your cluster values 16 | * Start using lain 17 | 18 | ## Links 19 | 20 | * Documentation (Chinese): [lain-cli.readthedocs.io](https://lain-cli.readthedocs.io/en/latest/) 21 | -------------------------------------------------------------------------------- /docker-image/.pip/pip.conf: -------------------------------------------------------------------------------- 1 | [global] 2 | no-cache-dir = 0 3 | -------------------------------------------------------------------------------- /docker-image/.zshrc: -------------------------------------------------------------------------------- 1 | ZSH_THEME="ys" 2 | export LC_ALL=en_US.UTF-8 3 | export LANG=en_US.UTF-8 4 | export ZSH=$HOME/.oh-my-zsh 5 | export EDITOR=vim 6 | plugins=( 7 | git 8 | gitfast 9 | git-extras 10 | fasd 11 | redis-cli 12 | docker 13 | pip 14 | kubectl 15 | ) 16 | source $ZSH/oh-my-zsh.sh 17 | 18 | export ZLE_RPROMPT_INDENT=0 19 | 20 | # smartcase behavior in tab completions, see https://www.reddit.com/r/zsh/comments/4aq8ja/is_it_possible_to_enable_smartcase_tab_completion/ 21 | zstyle ':completion:*' matcher-list 'm:{[:lower:]}={[:upper:]}' 22 | 23 | COMPLETION_WAITING_DOTS="true" 24 | 25 | # User configuration 26 | DEBIAN_PREVENT_KEYBOARD_CHANGES=yes 27 | 28 | export PATH="$GOPATH/bin:$HOME/.rvm/bin:/usr/local/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:/opt/X11/bin" 29 | alias v='f -e vim' 30 | # ansible related 31 | alias ave="ansible-vault edit" 32 | 33 | # edit all files that match this ag search 34 | function agvi() { 35 | ag $@ -l | xargs -o vi 36 | } 37 | 38 | # vi mode 39 | bindkey -v 40 | bindkey '^h' backward-delete-char 41 | bindkey '^w' backward-kill-word 42 | bindkey '^[[Z' reverse-menu-complete 43 | bindkey '^E' end-of-line 44 | bindkey '^A' beginning-of-line 45 | bindkey '^R' history-incremental-search-backward 46 | export KEYTIMEOUT=1 47 | 48 | # history config 49 | HISTSIZE=100000 50 | SAVEHIST=10000000 51 | setopt menu_complete 52 | setopt BANG_HIST 53 | setopt HIST_IGNORE_ALL_DUPS 54 | setopt HIST_FIND_NO_DUPS 55 | setopt HIST_SAVE_NO_DUPS 56 | setopt HIST_REDUCE_BLANKS 57 | 58 | autoload -U compinit 59 | compinit 60 | zstyle ':completion:*:descriptions' format '%U%B%d%b%u' 61 | zstyle ':completion:*:warnings' format '%BSorry, no matches for: %d%b' 62 | setopt correctall 63 | 64 | autoload -U promptinit 65 | promptinit 66 | alias gst='git branch --all && grv && git status --show-stash && git rev-list --format=%B --max-count=1 HEAD' 67 | alias gfa='git fetch --all --tags --prune && git delete-merged-branches' 68 | alias gcne='gc! --no-edit' 69 | alias gcane='gca! --no-edit' 70 | alias gcanep='gca! --no-edit && gp -f $1 $2' 71 | alias gcls='gcl --depth 1 ' 72 | alias gcnep='gc! --no-edit && gp -f $1 $2' 73 | alias grhd='git reset HEAD ' 74 | alias gcl='hub clone' 75 | alias gcaanep='ga -A && gca! --no-edit && gp -f $1 $2' 76 | alias glt='git log --decorate=full --simplify-by-decoration' 77 | alias vi=$EDITOR 78 | alias ssh='TERM=xterm ssh' 79 | 80 | unsetopt correct_all 81 | unsetopt correct 82 | DISABLE_CORRECTION="true" 83 | 84 | echo " 85 | ================================================================================ 86 | welcome to lain container, below are some tips. 87 | 88 | * to manage mysql, copy the right mysql command from 1password, then run: 89 | mysql -h[HOST] -uroot -p[PASSWORD] 90 | ================================================================================ 91 | " 92 | -------------------------------------------------------------------------------- /docker-image/apt/sources.list: -------------------------------------------------------------------------------- 1 | deb http://mirrors.aliyun.com/ubuntu/ focal main restricted universe multiverse 2 | deb http://mirrors.aliyun.com/ubuntu/ focal-security main restricted universe multiverse 3 | deb http://mirrors.aliyun.com/ubuntu/ focal-updates main restricted universe multiverse 4 | deb http://mirrors.aliyun.com/ubuntu/ focal-proposed main restricted universe multiverse 5 | deb http://mirrors.aliyun.com/ubuntu/ focal-backports main restricted universe multiverse 6 | deb-src http://mirrors.aliyun.com/ubuntu/ focal main restricted universe multiverse 7 | deb-src http://mirrors.aliyun.com/ubuntu/ focal-security main restricted universe multiverse 8 | deb-src http://mirrors.aliyun.com/ubuntu/ focal-updates main restricted universe multiverse 9 | deb-src http://mirrors.aliyun.com/ubuntu/ focal-proposed main restricted universe multiverse 10 | deb-src http://mirrors.aliyun.com/ubuntu/ focal-backports main restricted universe multiverse 11 | -------------------------------------------------------------------------------- /docker-image/git_askpass.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | 4 | case "$1" in 5 | Username*) exec echo "$GIT_USER" ;; 6 | Password*) exec echo "$GIT_PASSWORD" ;; 7 | esac 8 | -------------------------------------------------------------------------------- /docker-image/requirements.in: -------------------------------------------------------------------------------- 1 | -e file:.[all] 2 | pip>=22.0.3 3 | docker 4 | devpi-client 5 | flake8 6 | pytest 7 | pytest-cov 8 | ipython 9 | pre-commit 10 | vulture 11 | pip-tools 12 | isort 13 | locust 14 | sentry-sdk 15 | codespell 16 | twine 17 | pylint 18 | semgrep 19 | alembic 20 | sphinx 21 | furo 22 | -------------------------------------------------------------------------------- /docker-image/requirements.txt: -------------------------------------------------------------------------------- 1 | # 2 | # This file is autogenerated by pip-compile with python 3.9 3 | # To update, run: 4 | # 5 | # pip-compile --output-file=docker-image/requirements.txt docker-image/requirements.in 6 | # 7 | -e file:. 8 | # via -r docker-image/requirements.in 9 | alabaster==0.7.12 10 | # via sphinx 11 | alembic==1.8.0 12 | # via -r docker-image/requirements.in 13 | aliyun-python-sdk-core==2.13.36 14 | # via 15 | # aliyun-python-sdk-cr 16 | # lain 17 | aliyun-python-sdk-cr==4.1.2 18 | # via lain 19 | appnope==0.1.3 20 | # via ipython 21 | astroid==2.11.6 22 | # via pylint 23 | asttokens==2.0.5 24 | # via stack-data 25 | attrs==21.4.0 26 | # via 27 | # glom 28 | # jsonschema 29 | # pytest 30 | # semgrep 31 | babel==2.10.1 32 | # via sphinx 33 | backcall==0.2.0 34 | # via ipython 35 | beautifulsoup4==4.11.1 36 | # via furo 37 | bleach==5.0.0 38 | # via readme-renderer 39 | boltons==21.0.0 40 | # via 41 | # face 42 | # glom 43 | # semgrep 44 | bracex==2.3.post1 45 | # via wcmatch 46 | brotli==1.0.9 47 | # via geventhttpclient 48 | build==0.8.0 49 | # via check-manifest 50 | cachetools==5.2.0 51 | # via lain 52 | certifi==2022.5.18.1 53 | # via 54 | # geventhttpclient 55 | # requests 56 | # sentry-sdk 57 | cffi==1.15.0 58 | # via cryptography 59 | cfgv==3.3.1 60 | # via pre-commit 61 | charset-normalizer==2.0.12 62 | # via requests 63 | check-manifest==0.48 64 | # via devpi-client 65 | click==8.1.3 66 | # via 67 | # click-option-group 68 | # flask 69 | # lain 70 | # pip-tools 71 | # semgrep 72 | click-option-group==0.5.3 73 | # via semgrep 74 | codespell==2.1.0 75 | # via -r docker-image/requirements.in 76 | colorama==0.4.4 77 | # via semgrep 78 | commonmark==0.9.1 79 | # via rich 80 | configargparse==1.5.3 81 | # via locust 82 | coverage[toml]==6.4.1 83 | # via pytest-cov 84 | cryptography==37.0.2 85 | # via aliyun-python-sdk-core 86 | decorator==5.1.1 87 | # via ipython 88 | defusedxml==0.7.1 89 | # via semgrep 90 | devpi-client==5.2.3 91 | # via -r docker-image/requirements.in 92 | devpi-common==3.6.0 93 | # via devpi-client 94 | dill==0.3.5.1 95 | # via pylint 96 | distlib==0.3.4 97 | # via virtualenv 98 | docker==5.0.3 99 | # via -r docker-image/requirements.in 100 | docutils==0.18.1 101 | # via 102 | # readme-renderer 103 | # sphinx 104 | executing==0.8.3 105 | # via stack-data 106 | face==20.1.1 107 | # via glom 108 | filelock==3.7.1 109 | # via 110 | # tox 111 | # virtualenv 112 | flake8==4.0.1 113 | # via -r docker-image/requirements.in 114 | flask==2.1.2 115 | # via 116 | # flask-basicauth 117 | # flask-cors 118 | # locust 119 | flask-basicauth==0.2.0 120 | # via locust 121 | flask-cors==3.0.10 122 | # via locust 123 | furo==2022.6.4.1 124 | # via -r docker-image/requirements.in 125 | gevent==21.12.0 126 | # via 127 | # geventhttpclient 128 | # locust 129 | geventhttpclient==1.5.3 130 | # via locust 131 | glom==22.1.0 132 | # via semgrep 133 | greenlet==1.1.2 134 | # via 135 | # gevent 136 | # sqlalchemy 137 | humanfriendly==10.0 138 | # via lain 139 | identify==2.5.1 140 | # via pre-commit 141 | idna==3.3 142 | # via requests 143 | imagesize==1.3.0 144 | # via sphinx 145 | importlib-metadata==4.11.4 146 | # via 147 | # flask 148 | # keyring 149 | # sphinx 150 | # twine 151 | iniconfig==1.1.1 152 | # via pytest 153 | ipython==8.4.0 154 | # via -r docker-image/requirements.in 155 | isort==5.10.1 156 | # via 157 | # -r docker-image/requirements.in 158 | # pylint 159 | itsdangerous==2.1.2 160 | # via flask 161 | jedi==0.18.1 162 | # via ipython 163 | jinja2==3.1.2 164 | # via 165 | # flask 166 | # lain 167 | # sphinx 168 | jmespath==0.10.0 169 | # via aliyun-python-sdk-core 170 | jsonschema==3.2.0 171 | # via semgrep 172 | keyring==23.6.0 173 | # via twine 174 | lazy==1.4 175 | # via devpi-common 176 | lazy-object-proxy==1.7.1 177 | # via astroid 178 | locust==2.9.0 179 | # via -r docker-image/requirements.in 180 | mako==1.2.0 181 | # via alembic 182 | markupsafe==2.1.1 183 | # via 184 | # jinja2 185 | # mako 186 | marshmallow==3.16.0 187 | # via lain 188 | matplotlib-inline==0.1.3 189 | # via ipython 190 | mccabe==0.6.1 191 | # via 192 | # flake8 193 | # pylint 194 | msgpack==1.0.4 195 | # via locust 196 | nodeenv==1.6.0 197 | # via pre-commit 198 | packaging==21.3 199 | # via 200 | # build 201 | # lain 202 | # marshmallow 203 | # pytest 204 | # semgrep 205 | # sphinx 206 | # tox 207 | parso==0.8.3 208 | # via jedi 209 | peewee==3.14.10 210 | # via semgrep 211 | pep517==0.12.0 212 | # via 213 | # build 214 | # pip-tools 215 | pexpect==4.8.0 216 | # via ipython 217 | pickleshare==0.7.5 218 | # via ipython 219 | pip-tools==6.6.2 220 | # via -r docker-image/requirements.in 221 | pkginfo==1.8.3 222 | # via 223 | # devpi-client 224 | # twine 225 | platformdirs==2.5.2 226 | # via 227 | # pylint 228 | # virtualenv 229 | pluggy==1.0.0 230 | # via 231 | # devpi-client 232 | # pytest 233 | # tox 234 | pre-commit==2.19.0 235 | # via -r docker-image/requirements.in 236 | prompt-toolkit==3.0.29 237 | # via 238 | # ipython 239 | # lain 240 | psutil==5.9.1 241 | # via 242 | # lain 243 | # locust 244 | ptyprocess==0.7.0 245 | # via pexpect 246 | pure-eval==0.2.2 247 | # via stack-data 248 | py==1.11.0 249 | # via 250 | # devpi-client 251 | # devpi-common 252 | # pytest 253 | # tox 254 | pycodestyle==2.8.0 255 | # via flake8 256 | pycparser==2.21 257 | # via cffi 258 | pyflakes==2.4.0 259 | # via flake8 260 | pygments==2.12.0 261 | # via 262 | # furo 263 | # ipython 264 | # readme-renderer 265 | # rich 266 | # sphinx 267 | pylint==2.14.1 268 | # via -r docker-image/requirements.in 269 | pyparsing==3.0.9 270 | # via packaging 271 | pyrsistent==0.18.1 272 | # via jsonschema 273 | pytest==7.1.2 274 | # via 275 | # -r docker-image/requirements.in 276 | # lain 277 | # pytest-cov 278 | # pytest-env 279 | # pytest-mock 280 | # pytest-ordering 281 | pytest-cov==3.0.0 282 | # via 283 | # -r docker-image/requirements.in 284 | # lain 285 | pytest-env==0.6.2 286 | # via lain 287 | pytest-mock==3.7.0 288 | # via lain 289 | pytest-ordering==0.6 290 | # via lain 291 | python-gitlab==3.5.0 292 | # via lain 293 | pytz==2022.1 294 | # via babel 295 | pyyaml==6.0 296 | # via pre-commit 297 | pyzmq==22.3.0 298 | # via locust 299 | readme-renderer==35.0 300 | # via twine 301 | requests==2.28.0 302 | # via 303 | # devpi-common 304 | # docker 305 | # lain 306 | # locust 307 | # python-gitlab 308 | # requests-toolbelt 309 | # semgrep 310 | # sphinx 311 | # tencentcloud-sdk-python 312 | # twine 313 | requests-toolbelt==0.9.1 314 | # via 315 | # python-gitlab 316 | # twine 317 | rfc3986==2.0.0 318 | # via twine 319 | rich==12.4.4 320 | # via twine 321 | roundrobin==0.0.2 322 | # via locust 323 | ruamel-yaml==0.17.21 324 | # via 325 | # lain 326 | # semgrep 327 | ruamel-yaml-clib==0.2.6 328 | # via ruamel-yaml 329 | semgrep==0.97.0 330 | # via -r docker-image/requirements.in 331 | sentry-sdk==1.5.12 332 | # via 333 | # -r docker-image/requirements.in 334 | # lain 335 | six==1.16.0 336 | # via 337 | # asttokens 338 | # bleach 339 | # flask-cors 340 | # geventhttpclient 341 | # jsonschema 342 | # tox 343 | # virtualenv 344 | snowballstemmer==2.2.0 345 | # via sphinx 346 | soupsieve==2.3.2.post1 347 | # via beautifulsoup4 348 | sphinx==5.0.1 349 | # via 350 | # -r docker-image/requirements.in 351 | # furo 352 | # sphinx-basic-ng 353 | sphinx-basic-ng==0.0.1a11 354 | # via furo 355 | sphinxcontrib-applehelp==1.0.2 356 | # via sphinx 357 | sphinxcontrib-devhelp==1.0.2 358 | # via sphinx 359 | sphinxcontrib-htmlhelp==2.0.0 360 | # via sphinx 361 | sphinxcontrib-jsmath==1.0.1 362 | # via sphinx 363 | sphinxcontrib-qthelp==1.0.3 364 | # via sphinx 365 | sphinxcontrib-serializinghtml==1.1.5 366 | # via sphinx 367 | sqlalchemy==1.4.37 368 | # via alembic 369 | stack-data==0.2.0 370 | # via ipython 371 | tenacity==8.0.1 372 | # via lain 373 | tencentcloud-sdk-python==3.0.654 374 | # via lain 375 | toml==0.10.2 376 | # via 377 | # pre-commit 378 | # tox 379 | # vulture 380 | tomli==2.0.1 381 | # via 382 | # build 383 | # check-manifest 384 | # coverage 385 | # pep517 386 | # pylint 387 | # pytest 388 | tomlkit==0.11.0 389 | # via pylint 390 | tox==3.25.0 391 | # via devpi-client 392 | tqdm==4.64.0 393 | # via semgrep 394 | traitlets==5.2.2.post1 395 | # via 396 | # ipython 397 | # matplotlib-inline 398 | twine==4.0.1 399 | # via -r docker-image/requirements.in 400 | typing-extensions==4.2.0 401 | # via 402 | # astroid 403 | # locust 404 | # pylint 405 | # semgrep 406 | urllib3==1.26.9 407 | # via 408 | # requests 409 | # semgrep 410 | # sentry-sdk 411 | # twine 412 | virtualenv==20.14.1 413 | # via 414 | # pre-commit 415 | # tox 416 | vulture==2.4 417 | # via -r docker-image/requirements.in 418 | wcmatch==8.4 419 | # via semgrep 420 | wcwidth==0.2.5 421 | # via prompt-toolkit 422 | webencodings==0.5.1 423 | # via bleach 424 | websocket-client==1.3.2 425 | # via docker 426 | werkzeug==2.1.2 427 | # via 428 | # flask 429 | # locust 430 | wheel==0.37.1 431 | # via pip-tools 432 | wrapt==1.14.1 433 | # via astroid 434 | zipp==3.8.0 435 | # via importlib-metadata 436 | zope-event==4.5.0 437 | # via gevent 438 | zope-interface==5.4.0 439 | # via gevent 440 | 441 | # The following packages are considered to be unsafe in a requirements file: 442 | # pip 443 | # setuptools 444 | -------------------------------------------------------------------------------- /docs/_static/grafana.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timfeirg/lain-cli/7728185ca05838d185e05d2cdf5ad5bc324b5378/docs/_static/grafana.jpg -------------------------------------------------------------------------------- /docs/admin.rst: -------------------------------------------------------------------------------- 1 | 集群管理 2 | ======== 3 | 4 | lain 对 Kubernetes 做了如此多的封装, 以至于很多 SA 工作都可以方便地用 lain 来完成. 这里罗列的管理员命令也许场景都比较特殊, 即便封装成了可以重复使用的功能, 也未必对你有用. 因此本章的作用基本是参考, 更多是向你展现 lain 的定制可能性, 以及分享一些 SA 的工作思路. 5 | 6 | 查看集群状态 7 | ------------ 8 | 9 | :code:`lain admin status` 的功能类似 :code:`lain status`, 可以打印出整个集群的异常容器, 节点, 以及异常的 Ingress URL. 10 | 11 | 推荐你将这个命令整合入 SA 的标准操作流程里, 比方说, 如果集群要进行某些运维操作, 例如升级/重启节点, 操作前先打开 :code:`lain admin status`, 确认一切无恙. 操作结束以后, 也用这个命令作为"绿灯", 看到大盘没有异常情况, 才宣告操作结束. 12 | 13 | 重启容器 14 | -------- 15 | 16 | SA 最喜欢的事情就是重启了, lain 为管理员提供这样一些有关重启容器的命令: 17 | 18 | * :code:`lain admin delete-bad-pod` 会删除所有异常状态的 pod / job. 19 | * :code:`lain restart --graceful -l [selector]` 等效于 :code:`kubectl delete pod -l [selector]`, 但每删除一个容器都会等待"绿灯", 让重启过程尽可能平滑. 20 | 21 | 在所有容器中执行命令 22 | -------------------- 23 | 24 | :code:`lain admin x` 是 :code:`lain x` 的一个拓展, 可以在所有容器里执行命令: 25 | 26 | .. code-block:: bash 27 | 28 | # 在整个集群排查 python 依赖 29 | $ lain admin x -- bash -c 'pip3 freeze | grep -i requests' 30 | command succeeds for celery-beat-77466f79bf-t62wq 31 | requests==2.25.1 32 | requests-toolbelt==0.9.1 33 | command succeeds for celery-worker-756d5846cd-qvm8p 34 | requests==2.25.1 35 | requests-toolbelt==0.9.1 36 | # ... 37 | 38 | 清理镜像 39 | -------- 40 | 41 | 如果你还在用 `Docker Registry `_ 作为自建的镜像仓库, 那你或许需要一个镜像清理的功能, 可以参考 :code:`lain admin cleanup-registry`, 里边实现了最基本的清理老旧镜像的功能. 42 | 43 | 不过有条件的话, 最好还是选用云服务商的镜像仓库吧, 或者 Harbor 什么的, 功能更齐全一些, 省的老是为周边功能操心. 44 | 45 | 梳理集群异常 46 | ------------ 47 | 48 | :code:`lain admin list-waste` 会遍历所有 deploy, 一个个地查询 Prometheus, 把实际占用资源与声明资源作对比, 这样就能查出到底是谁在浪费集群资源(占着茅坑不拉屎). 这个命令在集群资源吃紧的时候推荐用起来, 加节点虽然很简单直接, 但我们不希望集群有明显的资源浪费, 一定要尽可能压榨机器资源. 49 | 50 | :code:`lain admin delete-bad-ing` 会找出所有的问题 Ingress (比如 Default Backend, 或者 503, 都认为是有问题), 把游离的无效 Ingress 直接删除. 而如果 Ingress 并非"游离态", 而是属于某一个 Helm Release, 那么将会打印出该情况, 附上快捷的 :code:`helm delete` 命令, 协助你与业务沟通和梳理. 51 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'lain' 21 | copyright = '2021, timfeirg' 22 | author = 'timfeirg' 23 | 24 | 25 | # -- General configuration --------------------------------------------------- 26 | 27 | # Add any Sphinx extension module names here, as strings. They can be 28 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 29 | # ones. 30 | extensions = [] 31 | 32 | # Add any paths that contain templates here, relative to this directory. 33 | templates_path = ['_templates'] 34 | 35 | # The language for content autogenerated by Sphinx. Refer to documentation 36 | # for a list of supported languages. 37 | # 38 | # This is also used if you do content translation via gettext catalogs. 39 | # Usually you set "language" from the command line for these cases. 40 | language = 'zh_CN' 41 | 42 | # List of patterns, relative to source directory, that match files and 43 | # directories to ignore when looking for source files. 44 | # This pattern also affects html_static_path and html_extra_path. 45 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 46 | 47 | 48 | # -- Options for HTML output ------------------------------------------------- 49 | 50 | # The theme to use for HTML and HTML Help pages. See the documentation for 51 | # a list of builtin themes. 52 | # 53 | html_theme = 'furo' 54 | 55 | # Add any paths that contain custom static files (such as style sheets) here, 56 | # relative to this directory. They are copied after the builtin static files, 57 | # so a file named "default.css" will overwrite the builtin "default.css". 58 | # html_static_path = ['_static'] 59 | -------------------------------------------------------------------------------- /docs/design.rst: -------------------------------------------------------------------------------- 1 | 设计要点 2 | ======== 3 | 4 | lain 的诸多设计可能显得古怪而不合理, 在这里进行集中解释, 阐述设计思路. 5 | 6 | 易用性 7 | ------ 8 | 9 | lain 的定位是帮助你使用 docker / helm / kubectl 来达成 DevOps. 这么听上去, 用户需要有一定的云平台相关基础知识才行, 好像学习成本比较高? 其实并不是的, lain 对易用性不设上限, 对使用门槛不设下限, 力求做到对 Kubernetes / Helm / Container 完全不了解的人, 也能轻松参考文档, 将自己的应用部署上线. 10 | 11 | 为了达到这个目标, lain 的开发/反馈循环应该做到: 12 | 13 | * 没有无知的用户, 只有愚蠢的工具. 任何背景和知识水平的技术人员, 在使用 lain 途中遇到的问题, 都值得仔细钻研和针对优化. 14 | * 如无必要, 不写文档. 作为趁手的工具, 文档应该是越少越好, 能用技术解决的问题, 绝对不要用文档解决. 15 | * 帮助用户学习和成长. 要知道, lain 的定位是帮助用户使用 Kubernetes, 而不是屏蔽实现细节, 帮用户包办一切. 因此在提升易用性的同时, 要始终维持对 Kubernetes 的透明性. 这样一来, 无论是新手还是资深用户, lain 都会成为你的好帮手. 16 | 17 | 作为用户, lain 期望你做到: 18 | 19 | * 遇到报错的时候, 积极阅读 lain 打印的所有内容, 尤其关注 warning / error. 面对不少常见错误, lain 还会打印出文档链接, 也请详读. 如果最后仍未能解决错误, 请按照 :ref:`lain-debug` 来进行错误排查或报障. 20 | * 积极了解 Kubernetes / Helm 的科普概念, 这点在 :ref:`reading-list` 稍作罗列, 也欢迎你有闲情的时候多加搜索和学习. 21 | 22 | .. _lain-use-design: 23 | 24 | lain-use 为什么要修改我的 kubeconfig? 25 | ------------------------------------- 26 | 27 | 简单阅读代码就能发现, lain 最核心的功能都是通过调用 kubectl / helm 这些外部 CLI 实现的, 而这些工具的默认配置文件都是 :code:`~/.kube/config`, 也正因如此, 每次 :code:`lain use [CLUSTER]` 就是在将 :code:`~/.kube/config` 软链为 :code:`~/.kube/kubeconfig-[CLUSTER]`. 28 | 29 | 你可能会追问, kubectl / helm 明明都支持 :code:`--kubeconfig` 参数, 凭什么还要用做软链这种高侵入性的方式来做配置变更? 这还是因为, lain 希望与 kubectl 等工具协同工作. lain 虽然对大多数 DevOps 的功能做了封装, 但仍不免会持续出现各种各样的临时需求和特殊操作, 需要使用者直接用 kubectl / helm 来解决问题. lain 假定其用户对 kubectl / helm 有着基本了解, 不忌惮直接操作这些底层外部工具. 30 | 31 | .. _lain-config-design: 32 | 33 | 想使用 lain, 为什么还得自己打包发版? 34 | ------------------------------------ 35 | 36 | 的确, 开源软件世界的大多数 CLI 工具都遵循着"下载安装-撰写配置-运行"的使用模式, 没见过哪个软件会要求使用者先 fork, 超载配置, 然后重新发版, 才最终能开始使用. 37 | 38 | 因为配置流程的问题, lain 的确是一个难以上手的项目, 但要注意到, 也正因为 lain 的平台属性, 你绝不希望把撰写配置这一步交给用户来完成: 用户是不可靠的, 集群配置一定要中心化管理, 否则你会面临数不尽的配置相关的技术支持工作. 39 | 40 | 在 lain4 之前, 我们经历过的平台都是在网页上完成操作的(比如 `lain2 `_, 或者 `Project Eru `_), 大部分配置也都在服务端进行管理, 不存在开发者需要自己书写集群配置的问题. lain 同样希望开发者的心智负担尽量小, 但又不希望引入一个 server-side 组件来管理配置(维护难度骤增!), 只好把集群配置都写在代码库里, 随着 Python Package 一起发布. 41 | 42 | 当然了, 这并不是说把配置写死在代码里是一个良好实践, 还有许许多多别的办法能解决配置分发的问题, 只是目前而言, 这是对我们团队 ROI 合适的方式. lain 的内部性决定了他可能永远不会成为一个真正意义的"开源质量"的项目, 而仅仅是一个源码公开的项目. 43 | 44 | lain 与 Helm 是什么关系? 45 | ------------------------ 46 | 47 | lain 本身就是 Helm / Kubectl / Docker 的"粘合剂", 这点在本文档各处会不断提到. 一个 lain app 的代码仓库下一定会有一个 :code:`chart` 目录, 也就是说 lain app 本身就是一个合法的 Helm Chart, 脱离 lain 也完全能使用(如果你愿意手敲很长的 helm 命令). 若稍加留意, 你可以看到 :code:`lain deploy` 是如何调用 helm 的: 48 | 49 | .. code-block:: bash 50 | 51 | $ lain deploy --set imageTag=xxx 52 | lain lint 53 | helm lint ./chart --set cluster=test,registry=registry.example.com/dev,user=me,k8s_namespace=default,domain=example.com 54 | While being deployed, you can check the status of you app: 55 | lain status 56 | lain logs 57 | helm upgrade --set imageTag=xxx,cluster=test,registry=registry.example.com/dev,user=me,k8s_namespace=default,domain=example.com --install dummy ./chart 58 | 59 | lain 会将所有的三方调用命令都打印出来, 这既是为了透明, 也方便你在有特殊需要的时候, 能直接用 Helm / Kubectl 做事情. 如果你想要脱离 lain, 那么直接复制这些命令, 以你需要的方式来自己调用, 也是完全没有问题的: lain 没有什么魔法, 一切都是通过调用别的工具来完成的. 60 | 61 | 在项目部署上线以后, 你的 lain app 就被部署成为一个 `Helm Release `_, 但 lain app 和 Helm Release 可未必是一一对应的关系: 62 | 63 | * 一个 lain app 可以部署在多个集群, 在不同的集群里可以用 :code:`values-[CLUSTER].yaml` 来做配置超载. 详见 :ref:`multi-cluster`. 64 | * lain 实现了"金丝雀部署"功能 (`Canary `_), 当你用 :code:`lain deploy --canary` 进行金丝雀部署的时候, lain 帮你做的事情就是, 将你的 app 部署成另一个 Helm Release (以 dummy 为例, 在金丝雀上线后, 新部署的 Release 便叫做 dummy-canary). 然后 lain 会按照你书写的配置来调配 Ingress Canary 设置, 达到灰度上线的效果. 关于 Canary Deploy, 详见 :code:`lain deploy --help`, :code:`lain set-canary-group --help`. 65 | * 如果有隔离, 或者单独管理的需要, lain 甚至可以把一个 app, 在同一个集群上部署多份, 也就是多个不同的 Helm Release, 详见 :ref:`multiple-helm-releases`. 66 | 67 | .. _lain-resource-design: 68 | 69 | lain 如何管理资源? 70 | ------------------ 71 | 72 | lain 本身并不管理资源, `Kubernetes 已经出色地完成了这项工作 `_. lain 做的事情更贴近易用性改善: 如果你不熟悉 Kubernetes 声明资源占用的方式, :code:`lain lint` 可以帮助你书写 resources: 73 | 74 | .. code-block:: bash 75 | 76 | $ lain lint 77 | web memory limits: current 256Mi, suggestion 450Mi 78 | celery-worker memory requests: current 400Mi, suggestion: 727Mi 79 | celery-worker memory limits: current 1Gi, suggestion 1817Mi 80 | celery-beat memory limits: current 256Mi, suggestion 309Mi 81 | celery-flower-web memory limits: current 256Mi, suggestion 637Mi 82 | sync-wx-group cpu requests: current 1000m, suggestion 5m 83 | 84 | 如上所示, lain 会根据 Prometheus 查询到的 CPU / Memory 数据, 计算其 P95, 然后以此作为 requests 的建议值, 而 limits 则以一个固定系数进行放大. 这个策略当然无法放之四海皆准, 所以对应的 PromQL 查询语句是可以在集群配置中定制的(详见 cluster-values 的 :code:`pql_template` 字段), 你可以按照你们团队应用的特性进行修改. 85 | 86 | 但是 `requests / limits `_ 到底是个啥? 这个简单的概念在 Kubernetes 文档上似乎并没有直观的解释, 导致我们业务研发同事们其实一直都不太理解如何恰当地声明资源占用. 简单来说也许可以这样比喻: 你去同学家里吃饭, 叔叔阿姨提前问你平日饭量如何, 并且特意嘱咐, 如果聚餐当天玩太疯, 食量比较大, 也要告知你的最大食量, 叔叔阿姨好备菜. 这个例子当中, :code:`resources.requests` 便是你平日的食量, :code:`resources.limits` 则是你的最大食量, 一旦超过了, 就绝对无法供给, 翻译到应用空间发生的事情, 就是 OOM Kill. 87 | 88 | 声明资源占用就是这么一回事, 如果你对叔叔阿姨虚报了平日食量, 报太大, 就浪费了吃不完, 报太小, 就不够吃. 所以平日食量(也就是 :code:`resources.requests`)一定要准确报备, 如果你担心当天异常饥饿, 那也没关系, 提前说好自己偶尔喜欢多吃点(放大 :code:`requests.limits`), 那么同学家里就会准备好富余的食材, 让你不至于饿肚子. 89 | 90 | 可想而知, 大多数应用的 limits 肯定是大于 requests 的, 这就叫资源超售. 超售对压榨机器资源是非常好的, 但前提是要准确声明 requests, 并且集群需要有足够的资源冗余, 让应用在资源占用突然飙升的时候, 不至于拖垮机器. 91 | 92 | 根据实践, 我们总结了以下原则和注意事项: 93 | 94 | * 即便你的 CPU 静息用量很低, 也建议别把 CPU limits 锁死在最低用量, 容易发生 CPU Throttle. 比如一个 Python Web Server 的静息 CPU 用量是 5m, 那么最好写成 5m / 1000m, 确保需要的时候, 总能用到一整个核. 至少对于 Python 应用而言, 一定要遵循这个原则, 你在监控上看到 CPU 只有 5m, 但事实上可能在微观时间里, 瞬时 CPU 用量要大于这个数. 95 | * Memory 一般不作超售, 应用摸到了内存上界, 系统就直接给 OOM Kill 了, 造成灾难. CPU 则不然, 只是运算慢点. 96 | * 关于 OOM Killed, `Kubernetes 视角并不总是准确的 `_, 我们建议在集群里同时对系统日志的 OOM 事件做好监控(比如 `grok_exporter `_), 这样才能对 OOM 报警做到滴水不漏. 97 | * 对于 CronJob, 如无必要, 最好不要做资源超售. CronJob 的运行往往是瞬间完成的, 因此对于资源监控的采样也是瞬时的, 因此对于 CronJob 应用的资源监控无法像长期运行的容器一样准确, 如果在资源声明的时候进行超售, 反而增加了 Job 失败的风险. 考虑到 CronJob 对于集群资源的占用也是瞬时的, 所以在运维的时候, 就不必那么在意节省资源. 98 | 99 | .. note:: 100 | 101 | :code:`lain lint` 再怎么聪明, 给出的建议都是基于 Prometheus 的历史监控数据. 随着业务长大, 以及应用开发迭代, 资源占用往往会变得越来越高, 如果发生这种情况, 你需要手动调整资源声明. 102 | 103 | 这些调整如果和 :code:`lain lint` 发生冲突, 你可以临时地用 :code:`lain --ignore-lint deploy` 来跳过检查, 但也请注意, 切勿滥用这个选项, 因为 :code:`lain lint` 还包括许许多多其他的正确性检查, 如果你习惯性使用 :code:`--ignore-lint` 来上线, 总有一天会出问题. 104 | 105 | .. _lain-auto-pilot: 106 | 107 | Auto Pilot 108 | ---------- 109 | 110 | :code:`lain --auto-pilot` 是一个特殊的运行模式, 他的作用是 **如果存在, 自动执行最正确的步骤**, 因此能帮你自动完成不少机械操作, 目前实现了以下功能: 111 | 112 | * :code:`lain --auto-pilot deploy`, 如果应用尚未构建, 则先 :code:`lain build`. 如果部署遭遇某些特殊的 helm 状态导致卡死(比如 `pending-upgrade `_), 则自动回滚到上一个"正常"的版本, 然后继续部署操作. 113 | * :code:`lain --auto-pilot [secret|env] edit` 将会在编辑结束以后, 自动优雅重启应用. 当然啦, 这里的优雅重启指的是 :code:`lain restart --graceful`, 如果应用本身是单实例, 用这个命令也无法达到真正优雅的效果. 114 | * :code:`lain --auto-pilot restart` 会采用"优雅重启"的策略, 每删除一个 Pod, 就会等待全体容器 up and running, 再继续重启操作. 115 | 116 | .. _lain-security-design: 117 | 118 | 安全性 119 | ------ 120 | 121 | 操作安全性 122 | ^^^^^^^^^^ 123 | 124 | 因为没有权限 / 登录系统, lain 在操作方面并不是一个"安全"的应用, 某些功能甚至依赖 admin 权限的 kubeconfig, 比如 :code:`lain admin`, 或者如果在 values 里声明了 :code:`nodes`, lain 还会帮你执行 :code:`kubectl label node`. 125 | 126 | 安全和便利的天平, lain 毫无疑问的将砝码全部放在便利性这一边. 从这个角度来讲, lain 目前仅适合小团队使用. 127 | 128 | 好在 Kubernetes ServiceAccount 是一个功能完整的权限系统, 理论上也可以为每一个开发者单独配置账号, 收敛权限. 所以, 如果你的团队需要对每个开发者的权限做控制, 那么可以考虑实现 :code:`lain login` 或者类似的命令, 加入认证流程, 通过认证则下发对应权限的 kubeconfig. 129 | 130 | 应用安全性 131 | ^^^^^^^^^^ 132 | 133 | 目前 lain 在应用安全性方面做了以下事情: 134 | 135 | * :code:`lain build` 所采用的的 Dockerfile 里, 写死了 :code:`USER 1001`. 136 | * 你可以在 :code:`values.yaml` 中声明 :code:`networkPolicy`, 对应用网络进行访问限制. 137 | 138 | 稳定性, 兼容性 139 | -------------- 140 | 141 | lain 的关键流程都有 e2e 测试来保证, 所谓关键流程, 包含但不限于上线, 回滚, 服务暴露, 配置校验, 要知道这些功能如果出错了, 极易引发事故. 而其他周边功能, 比如 :code:`lain status`, :code:`lain logs`, 便不那么需要专门撰写测试了, 每天都在眼皮底下用, 运作是否正常, 谁都看得明白. 因此目前测试覆盖率也只有 56%. 142 | 143 | lain 目前运行在 Kubernetes 1.18 / 1.19 上, 但在更低版本的集群里, 按理说也能顺利运行. Helm chart 里很容易处理好 Kubernetes 兼容性, 只需要判断 Kubernetes 版本即可(你可以在代码库里搜索 :code:`ttlSecondsAfterFinished`, 这边是一个很好的例子). 由于 lain 的主要功能都在调用 kubectl / helm, 因此 lain 本身的兼容性显得没那么重要, 你更应该关心 helm / kubectl 与集群的兼容性. 144 | 145 | 文档为何使用半角符号? 146 | --------------------- 147 | 148 | 为了在文档写作过程中尽量少切换输入法, 这样句点符号同时也是合法的编程记号. 不光是文档如此, lain 的代码注释也遵循此原则. 149 | -------------------------------------------------------------------------------- /docs/dev.rst: -------------------------------------------------------------------------------- 1 | .. _dev: 2 | 3 | 开发文档 4 | ======== 5 | 6 | 为你的团队启用 lain 7 | ------------------- 8 | 9 | 使用 lain 是轻松高效的, 但为你的团队启用 lain, 却不是一件轻松的事情. 这是由 lain 本身的设计决定的: lain 没有 server side component (因为功能都基于 helm), 而且不需要用户维护集群配置(可以写死在 :ref:`集群配置 ` 里, 随包发布). 这是 lain 的重要特点与卖点, 针对用户的易用性都不是免费的, 都要靠 SA 的辛勤劳作才能挣得. 10 | 11 | 目前而言, 在你的团队启用 lain, 需要满足以下条件: 12 | 13 | * Kubernetes 集群, Apiserver 服务向内网暴露, kubeconfig 发布给所有团队成员 14 | * Docker Registry, 云原生时代, 这应该是每一家互联网公司必不可少的基础设施, lain 目前支持一系列 Registry: `Harbor `_, `阿里云 `_, `腾讯云 `_, 以及原生的 `Docker Registry `_. 15 | * [可选] 你熟悉 Python, 有能力维护 lain 的内部分支. lain 是一个内部性很强的软件, 有很多定制开发的可能性. 16 | * [可选] 打包发版, 这就需要有内部 PyPI, 比如 `GitLab Package Registry `_, lain 的代码里实现了检查新版, 自动提示升级. 如果你们是一个快节奏的开发团队, lain 的使用必定会遇到各种需要维护的情况, 因此应该尽量有一个内网 Package Index. 17 | * [可选] Prometheus, Grafana, Kibana, 这些将会给 lain 提供强大的周边服务, 具体有什么用? 那就任君想象了, 云平台和监控/日志系统整合以后, 能做的事情那可太多了. 18 | * [可选] 你的团队使用 GitLab 和 GitLab CI, 以我们内部现状, 大部分 DevOps 都基于 GitLab CI + lain, 如果你也恰好如此, 那便有很多工作可以分享. 19 | * [可选] 你的团队对 Kubernetes + Helm 有着基本的了解, 明白 Kubernetes 的基本架构, 以及 Pod / Deploy / Service / Ingress / Ingress Controller 等基本概念. 20 | 21 | 假设你满足以上条件, 并且对路上的麻烦事有足够心理准备, 可以按照以下步骤, 让 lain 能为你的团队所用. 22 | 23 | .. _fork-github-repo: 24 | 25 | Fork GitHub Repository 26 | ^^^^^^^^^^^^^^^^^^^^^^ 27 | 28 | lain 的最新进展在 `GitHub 仓库 `_, 你需要对这个仓库做内部 Fork, 这样才能开始做定制化, 以及内部发版. 29 | 30 | .. code-block:: bash 31 | 32 | git clone https://github.com/timfeirg/lain-cli 33 | cd lain-cli 34 | # 将 remote origin 更名为 upstream, 我这里用的是 https://github.com/tj/git-extras 提供的功能 35 | git-rename-remote origin upstream 36 | git remote add origin https://gitlab.mycompany.com/dev/lain-cli 37 | 38 | .. _cluster-values: 39 | 40 | 书写集群配置 41 | ^^^^^^^^^^^^ 42 | 43 | 将 lain 据为己有的第一步就是, 将自己团队使用的集群加入 lain 的 cluster config, 就在这里书写: :code:`lain_cli/cluster_values/values-[CLUSTER].yaml`, 示范如下: 44 | 45 | .. literalinclude:: ../lain_cli/cluster_values/values-test.yaml 46 | :language: yaml 47 | 48 | 我们推荐把集群配置一起打包进 Python Package, 随包发布. 但如果你愿意, 也可以超载 :code:`CLUSTER_VALUES_DIR` 来定制集群配置的目录, 这样就能直接引用本地的任意集群配置了. 49 | 50 | 集群配置写好了, 本地也测通各项功能正常使用, 那就想办法发布给你的团队们用了. 51 | 52 | 打包发版 53 | ^^^^^^^^ 54 | 55 | 这是一个可选(但推荐)的步骤, 打包到内部 PyPI 上, 意味着你可以把 :ref:`集群配置 ` 和代码一起打包, 随包发布, 这样一来, 大家就无需在自己本地维护集群配置了. 56 | 57 | 打包有很多种方式, 既可以上传私有 PyPI 仓库, 也可以把代码库打包, 直接上传到任意能 HTTP 下载的地方, 简单分享下我们曾经用过的打包方案: 58 | 59 | .. code-block:: yaml 60 | 61 | # 以下均为 GitLab CI Job 62 | upload_gitlab_pypi: 63 | stage: deliver 64 | rules: 65 | - if: '$CI_COMMIT_BRANCH == "master" && $CI_PIPELINE_SOURCE != "schedule"' 66 | allow_failure: true 67 | script: 68 | - python setup.py sdist bdist_wheel 69 | - pip install twine -i https://mirrors.cloud.tencent.com/pypi/simple/ 70 | - TWINE_PASSWORD=${CI_JOB_TOKEN} TWINE_USERNAME=gitlab-ci-token python -m twine upload --repository-url https://gitlab.example.com/api/v4/projects/${CI_PROJECT_ID}/packages/pypi dist/* 71 | 72 | upload_devpi: 73 | stage: deliver 74 | rules: 75 | - if: '$CI_COMMIT_BRANCH == "master" && $CI_PIPELINE_SOURCE != "schedule"' 76 | variables: 77 | PACKAGE_NAME: lain_cli 78 | script: 79 | - export VERSION=$(cat lain_cli/__init__.py | ag -o "(?<=').+(?=')") 80 | - devpi login root --password=$PYPI_ROOT_PASSWORD 81 | - devpi remove $PACKAGE_NAME==$VERSION || true 82 | - devpi upload 83 | 84 | deliver_job: 85 | stage: deliver 86 | except: 87 | - schedules 88 | script: 89 | - ./setup.py sdist 90 | # 用你自己的方式发布 dist/lain_cli-*.tar.gz 91 | 92 | 打包发布好了, 大家都顺利安装好了, 但要真的操作集群, 还得持有 kubeconfig 才行, 那我们接下来开始安排发布 kubeconfig. 93 | 94 | 暴露 Apiserver, 发布 kubeconfig 95 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 96 | 97 | 有 kubeconfig 才能和 Kubernetes 集群打交道, 你可以用以下步骤获得合适的 kubeconfig: 98 | 99 | * lain 调用的 kubectl, helm, 都是直接和 Kubernetes Apiserver 打交道的, 因此你需要让 Apiserver 对内网可访问. 100 | * [可选] 配置好 Kubernetes ServiceAccount, 加入私有 Registry 的 imagePullSecrets. 101 | 102 | 如果你在用阿里云, 可能需要注意关闭 `aliyun-acr-credential-helper `_, 否则这玩意会持续覆盖你的 ServiceAccount Secrets. 禁用的命令类似 :code:`kubectl scale --replicas=0 deployment -n kube-system aliyun-acr-credential-helper`. 103 | * lain 需要 admin 权限的 kubeconfig, 并且要提前设置好 namespace: :code:`kubectl config set-context --current --namespace=[namespace]`. 如果没什么特别要求, 并且这个集群仅使用 lain 来管理, 那么建议直接用 default namespace 就好. 104 | * 接下来就是想方设法发布给你的团队, 比如用 1password. 大家下载以后, 放置于各自电脑的 :code:`~/.kube/kubeconfig-[CLUSTER]` 目录, 目前 lain 都是在小公司用, 没那么在意权限问题. 关于安全性问题请阅读 :ref:`lain-security-design`. 105 | 106 | kubeconfig 也就位了, 那事情就算完成了, 接下来就是教育你的团队, 开始普及 lain, 可以参考 :ref:`quick-start` 的内容. 107 | 108 | 从上游获取更新 109 | ^^^^^^^^^^^^^^ 110 | 111 | lain 的开发非常考虑普适性, 通用性, 你一定希望能获取到 lain 的最新功能. 如果你是按照 :ref:`fork-github-repo` 来做的内部 fork, 那你只需要做 rebase, 就能获取到新代码: 112 | 113 | .. code-block:: bash 114 | 115 | git pull --rebase upstream master 116 | 117 | 如果你的定制部分不涉及代码变更, 那么 rebase 是不太容易产生冲突的. 但若你对代码做了修改, 那想必你也熟悉代码仓库, 知道如何进行适配. 118 | 119 | 做好 rebase 以后, 你肯定担心会引入 bug, 或者破坏原有的功能. 这时候如果你能自己运行下 lain 的测试, 甚至根据自己团队的情况, 进行定制化测试, 那将会大大提高维护的简易度和自信心. lain 有着还算全面的端到端测试, 欢迎参考. 120 | 121 | 搭建配套基础设施 122 | ---------------- 123 | 124 | lain 的诸多丰富功能都是由各种周边基础设施提供的, 比如 Prometheus, Grafana, Kibana, 这些组件都需要你一一部署, 并且按照 lain 的要求来进行配置. lain 的要求也并不复杂, 在本节进行介绍. 125 | 126 | Prometheus 127 | ^^^^^^^^^^ 128 | 129 | 在 Kubernetes 下一般都直接用社区的 `Helm chart `_ 来搭建吧, 默认的配置就已经包含了 Kubernetes 下的服务发现, 因此安装 Prometheus 没什么特别的注意事项. 130 | 131 | 不过为了提供完整功能, 除了必要的 node-exporter, cadvisor, kube-state-metrics 之外, 最好把 `grok-exporter `_ 也一并搭建, 这是为了在宿主机层面做 OOM 监控, 具体可参考 `拿什么拯救 OOM `_. 132 | 133 | Grafana 134 | ^^^^^^^ 135 | 136 | lain 鼓励开发者自行使用 Grafana, 查看自己的应用的监控. 在看板里给出了应用容器的基础指标, 这也是我们研发团队主要关心的数据: 137 | 138 | .. image:: _static/grafana.jpg 139 | 140 | 图中的 dashboard 可以在 :download:`这里 <_static/grafana-container-monitor.json>` 下载, 导入以后, 将对应的 Grafana URL 填写到集群配置里(参考 :ref:`cluster-values`), 然后就能在 :code:`lain status -s` 里看到打印出来的 Grafana URL 了, 点击这个 URL, 直接就会进入该应用的 Grafana 监控页面. 141 | 142 | Kibana 143 | ^^^^^^ 144 | 145 | 我们用 EFK 来搭建 Kubernetes 的容器日志收集系统, 也就是 Elasticsearch + Fluentd + Kibana. 搭建方面没有特殊要求, 遵循社区最佳实践即可. 146 | 147 | lain 和 EFK 的关系主要在于 Kibana, 你需要配置好 `Log monitoring `_, 然后将 URL 填写到集群配置里(依然是参考 :ref:`cluster-values`), 然后就能用 :code:`lain logs --kibana` 打开 Kibana 链接, 直接阅读该应用的日志流了. 148 | 149 | 本地开发与测试 150 | -------------- 151 | 152 | 参考 `lain 的 CircleCI `_ 不难发现, lain 的 E2E 测试就是用 minikube 拉起来一个本地集群, 然后在上边用 `dummy `_ 作为测试应用, 来对各种关键流程做正确性验证. 本地运行这个测试也很简单, 介绍下大致步骤: 153 | 154 | .. code-block:: bash 155 | 156 | # 运行测试之前, 如果执行过 lain use, 那就需要先删掉 kubeconfig, 否则 minikube 会篡改 link 对应的源文件 157 | rm ~/.kube/config 158 | # 安装好 minikube 以后, 拉起一个集群 159 | minikube start 160 | # 安装好 minikube 以后, 默认的 kubeconfig 文件内容就被修改成本地的 minikube 集群了, 因此我们把他改成 lain 默认的 kubeconfig-test 161 | mv ~/.kube/config ~/.kube/kubeconfig-test 162 | # 运行 lain use, 让 lain 来管理本地 minikube 集群 163 | lain use test 164 | # 接下来就可以在本地运行各种 E2E 测试啦 165 | pytest --pdb tests/test_workflow.py::test_workflow 166 | -------------------------------------------------------------------------------- /docs/errors.rst: -------------------------------------------------------------------------------- 1 | .. _lain-debug: 2 | 3 | 错误排查 4 | ======== 5 | 6 | lain 在设计上希望尽量把错误以可排查的方式暴露给用户: 能出错的地方就那么多, 只要说清楚哪里有问题, 为什么, 开发者自己应该都有能力自己解决. 因此, 如果在使用 lain 的过程中报错了, 请遵循以下排查步骤: 7 | 8 | * 升级到最新版, lain 永远要用最新版, 也正因如此, 代码里甚至做了检查, 如果不是最新的两个版本, 就报错不让用 9 | * 如无必要, 切勿使用 :code:`--ignore-lint` 这个参数, 有时候他会掩盖各种正确性问题, 让你排查起来摸不着头脑 10 | * 更新模板试试, lain 的 helm chart 时不时会更新: :code:`lain init --template-only` 11 | * 仍复现问题, 请详读报错信息, 耐心仔细的阅读错误输出, 没准你就明白应该如何修复了 12 | * 报错内容实在看不懂! 那就只好找 SA 吧, 注意提供以下信息: 13 | 14 | * 完整的报错内容, 若是截图, 也请截全 15 | * 你面对的项目, 最好能将项目 url, 所处的分支 / commit 一并告知 16 | * 你面对的集群 17 | * :code:`lain version` 的输出 18 | * 若有需要, 把你当前进展 push 到代码仓库, 确保 SA 能方便地拿到现场, 便于复现问题 19 | 20 | 同时, 在这里对一些不那么容易自行排查修复的问题进行汇总. 21 | 22 | 各类文件权限错误 (permission denied) 23 | ------------------------------------ 24 | 25 | 常和 linux 打交道的同学一定明白, 文件权限错误, 要么是需要对路径做 chown, 要么换用权限合适的用户来运行程序. 如果你的 lain app 遇到了此类问题, 也一样是遵循该步骤进行排查: 26 | 27 | * 若是挂载进容器的文件遇到此问题, 你可能需要对文件进行 chown, 使之匹配运行程序的用户. 但要如何确认, 我的容器在以哪个用户的身份运行呢? 你可以这样: 28 | 29 | * 若是容器在 Running 状态, 可以直接运行 :code:`lain x -- whoami` 来打印出用户名 30 | * 若容器报错崩溃, 你也可以编辑 :code:`values.yaml`, 修改 :code:`command` 为 :code:`['sleep', '3600']` 之类的, 创造一个方便调试的环境, 然后执行上边提到的 :code:`whoami` 命令 31 | 32 | * 为了安全性不大推荐, 但你也可以直接用 root 来运行你的应用: :code:`lain build` 产生的镜像, 默认是 :code:`1001` 这个小权限用户, 因此如果你需要的话, 可以换用 :code:`root` 用户来运行, 具体就是修改 :code:`podSecurityContext`, 请在 :ref:`helm-values` 自行搜索学习吧. 33 | 34 | .. _docker-error: 35 | 36 | Docker Error 37 | ------------ 38 | 39 | 视镜像不同, :code:`lain build` 可能会出现各式各样的错误, 在这一节里介绍一些典型问题. 40 | 41 | Unable to fetch some archives, maybe run apt-get update ... 42 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 43 | 44 | 一般来说是源更新了, 但镜像里的地址还是老的, 因此建议在用系统包管理器安装任何东西前, 都先做一下 update. 比如: 45 | 46 | .. code-block:: yaml 47 | 48 | build: 49 | prepare: 50 | script: 51 | - apt-get update # or yum makecache 52 | - apt-get install ... 53 | 54 | 不过这样做能解决问题的前提是, 你的构建所在地和源没有网络访问问题(翻墙). 因此如果你的团队在国内, 建议按照 :ref:`docker-images` 的实践, 将所有的源都采纳国内镜像. 55 | 56 | docker build error: no space left on device 57 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 58 | 59 | docker 分配的磁盘空间是有限的, 空间不够时, docker 就会报错无法使用. 你要么为自己的 docker 分配更大的磁盘空间, 要么用 :code:`docker system prune` 进行一番清理, 也许能修复此问题. 60 | 61 | docker build error: too many levels of symbolic links 62 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 63 | 64 | 如果在其他环境 (CI, 别人的电脑) 无法复现此问题, 那多半是你本地 docker 的数据有些异常, 请抹掉整个 docker 的数据, (可选)升级 docker, 然后重试. 65 | 66 | docker pull / push error 67 | ^^^^^^^^^^^^^^^^^^^^^^^^ 68 | 69 | 按照以下顺序进行排查: 70 | 71 | * 你的电脑能正常上网吗? 打开 baidu / weibo 试试 72 | * 是拉不下来, 还是仅仅是慢? 如果你是从官方镜像源 (hub.docker.com) 拉取镜像, 国内势必是非常慢的, 你可以考虑给自己本地 docker 加上 registry-mirrors 配置: 73 | 74 | .. code-block:: json 75 | 76 | { 77 | "features": { 78 | "buildkit": true 79 | }, 80 | "experimental": true, 81 | "registry-mirrors": ["https://2c6tmbev.mirror.aliyuncs.com"] 82 | } 83 | 84 | * 排除掉自己本地的各类 vpn 软件以及相关设置, 别忘了, docker 自己的配置也要检查清楚, 不要留有 proxy 设置. 85 | * 如果 docker pull 已经出现进度条了, 说明和 registry 的沟通没有问题, 剩下的就是等了. 如果实在卡死了, 删掉镜像重来一番. 86 | * docker pull 的报错是否显示未认证? 那么你做了 docker login 吗? 不妨在 keychain 里搜索 docker, 把所有的 key 删除, 然后再次 docker login, 然后重试 87 | * docker 不允许用两个用户登录同一个 registry, 比如腾讯云的 registry, 登录了 A 账号, 就没法拉取 B 的镜像了, 如果硬要的话, 只能在 keychain 里删掉密钥, 再次 docker login 回原来的 registry, 才能正常拉取 88 | * 你的 docker 升级到最新版了吗? 以写作期间为例, docker for mac 的最新版是 Docker 3.3.0, Docker Engine v20.10.5, 你的 Docker 也要对齐, 起码不能低于这个版本 89 | * 排查到现在还是无法拉取镜像的话, 把 curl, ping, dig 的结果发给 SA, 和他一起排查解决吧 90 | 91 | 跨硬件架构 (multi-arch) 92 | ^^^^^^^^^^^^^^^^^^^^^^^ 93 | 94 | lain 并无特殊的跨架构构建机制, 并不支持构建多平台代码. 简单讲, 你选用了什么架构的 base 镜像, docker 就会为你构建什么架构的产物. 95 | 96 | 所以比方说, 如果你在用 M1 MacBook (也就是 arm64), 要构建针对 amd64 的 node 应用, 你需要声明 :code:`base: "amd64/node:latest"`, 而不是 :code:`base: "node:latest"`. 因为在 M1 MacBook 下, :code:`docker pull node:latest` 会下载 arm64 的镜像, 这样最后构建出来的东西扔到 amd64 的服务器上, 就没办法运行了. 97 | 98 | 总之, 选用 base 镜像的时候注意点就行了, 如果 base 镜像本身是支持多架构的, 那么你书写 :code:`base` 的时候, 要在 image tag 里显式声明架构. 如果你不确定自己面对的镜像是个什么架构的话, 也可以这样查看: 99 | 100 | .. code-block:: bash 101 | 102 | docker inspect node:latest | grep -i arch 103 | 104 | 其他 docker build 灵异错误 105 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 106 | 107 | 在你排查到山穷水尽的时候, 记得额外确认下 docker 的配置: 108 | 109 | * docker 至少分配 5GB 内存, 否则构建的时候 OOM 了, 有时候甚至不会报错, 把你蒙在鼓里. 110 | * 在 docker engine 配置里把好东西都写上: 111 | 112 | .. code-block:: json 113 | 114 | { 115 | "experimental": true, 116 | "features": { 117 | "buildkit": true 118 | }, 119 | "builder": { 120 | "gc": { 121 | "enabled": true 122 | } 123 | } 124 | } 125 | 126 | 如果你面对的集群支持, 强烈推荐你使用 :code:`--remote-docker`, 这样就能直接连接 CI 机器的 docker daemon 进行各种 docker 操作了, 不仅能加速 pull / push, 还能有效规避各种本地 docker 的配置问题. 详见 :code:`lain --help`. 127 | 128 | 上线有问题! 不好用! 129 | ------------------- 130 | 131 | 实际报障时, 你可千万不要用标题里的这种模糊字眼, 一定要详述故障现象. 本章节选用这个标题, 仅仅是为了收录各种上线操作中的常见错误. 132 | 133 | 关于上线错误, 你需要知道的第一点是: **如果操作正确, lain 是不会(在关键问题上)犯错的**. 上线是 lain 唯一需要做好的事情, 也有相当充分的测试覆盖, 上线中的问题往往是操作错误所致, 请耐心阅读本章节. 134 | 135 | 上线以后, 应用没有任何变化 136 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 137 | 138 | 你操作 :code:`lain deploy`, 但你部署的正是当前线上版本, 镜像 tag 没变. 倘若容器配置也未变, 那么 Kubernetes 并不会帮你重新上线: 在 Kubernetes 看来, 你分明什么都没改嘛, 因此认为当前状态就是用户所期望的状态, 自然啥也不用做. 139 | 140 | 这时候你应该怎么做呢? 得分情况处理: 141 | 142 | * 很多新手操作 :code:`lain deploy`, 其实内心只是想重启下容器. 这其实是做错了, 应该用 :code:`lain restart` 来做重启. 甚至, 你还可以用 :code:`lain restart --graceful` 来进行平滑重启. 不过到底有多平滑, 就看你的健康检查和 HA 设置是否恰当了, 详见 :code:`lain restart --help` 吧. 143 | 144 | * 虽然镜像版本未变, 但你重新构建过该镜像. 镜像 tag 没变, 但内容却被覆盖了. 所幸 lain 默认配置了 :code:`imagePullPolicy: Always`, 只需要重启容器, 便会触发重新拉取镜像. 因此在这种情况下, :code:`lain restart` 也能解决你的问题. 145 | 146 | 不过如果你手动调整过配置, 设置了 :code:`imagePullPolicy: IfNotPresent`, 那么即便重建容器, 也未必会重新拉取镜像. 不过既然你都玩到这份上了, 怎么解决应该心里有数吧, 这里不详述. 147 | 148 | 上线发生失败, 如何自救? 149 | ----------------------- 150 | 151 | * 打开 lain status, 先检查 Kubernetes 空间有没有报错, 比如镜像拉不下来啊, 健康检查失败啊, lain status 是一个综合性的应用状态看板, 包括应用日志也在里边. 152 | * 如果是 Kubernetes 空间的报错 (你看不懂的日志应该都是 Kubernetes 的事件), 那么就第一时间找 SA 吧. 153 | 154 | 有很多 Evicted Pod, 好吓人啊 155 | ---------------------------- 156 | 157 | 如果看见 Evicted 状态容器, 不必惊慌, 这只是 Kubernetes 对 Pod 进行重新分配以后的残影, 并不意味着系统异常. 158 | 159 | 就像是你有三个抽屉, 用来放各种衣物袜子内裤, 每天随机从一个抽屉里拿东西穿. 久而久之, 抽屉的占用率不太均衡, 于是你重新收拾一下, 让他们各自都留有一些空位, 方便放新鲜洗净的衣服. 160 | 161 | Eviction 容器其实就是 Kubernetes 在"收拾自己的抽屉", 而 Evicted Pod, 就是驱逐容器留下的"残影", 并不影响应用正常服务. 可想而知, 偶发的容器驱逐, 绝不代表集群资源不足了, 如果你真的怀疑集群资源吃紧, 你应该去看 :code:`kubectl describe nodes`, 根据用量和超售情况来判断. 162 | 163 | 我的应用无法访问, 如何排查? 164 | --------------------------- 165 | 166 | 如果你的应用无法访问, 比如 502, 证书错误, 或者干脆直接超时, 请遵循以下路径进行排查: 167 | 168 | * 同一个集群下的其他服务, 能正常访问吗? 如果大家都挂了, 那多半就是流量入口本身挂了, 找 SA 解决 169 | * 用 :code:`lain [status|logs]` 对应用状态进行一次全面确认, 看看有无异常 170 | * 特别注意, :code:`lain status` 会同时显示 http / https 的请求状态, 如果二者请求状态不一致, 请参考以下排查要点进行甄别: 171 | 172 | * https 正常访问, http 请求失败: 有些应用在 web server 内做了强制 https 转发 (force-ssl-redirect), 劝你别这么做, 万一配置错误还会导致 http 状态下请求异常 (因为被 rewrite 到了错误的 url). 总而言之, 应用空间只处理 http 就好, 把 TLS 截断交给 ingress controller 去做 173 | * http 正常访问, https 请求失败: 如果你的应用是首次上线新的域名, cert-manager 需要一些时间去申请签发证书, 如果超过五分钟还提示证书错误, 那就找 SA 去处理证书错误问题 174 | * 检查一下 :code:`values.yaml` 里声明的 :code:`containerPort`, 是不是写错了? 真的是进程实际监听的端口吗? 有些人声明了 :code:`containerPort: 9000`, 结果 web server 实际在监听 :code:`8000`, 这就怪不得会发生 Connection refused 了 175 | * 如果你不确定应用到底在监听哪个端口, 可以用 :code:`lain x` 钻进容器里, 在容器内测试请求, 能正常响应吗? 如果在容器里都无法访问, 那就是 web server 本身有问题了, 请你继续在应用空间进行排查 176 | * 如果你认为 web server 的配置和启动都正常, 不妨先检查下资源声明: 如果 CPU / Memory limits 太小, 进程拿不到足够的资源, 可能会响应非常慢, 造成超时 177 | 178 | 不过说到底, 请求失败/超时的排查是个大话题, 各种技术框架下排查的操作都有所不同. Kubernetes 下的排查尤为复杂, 有兴趣可以详读 `A visual guide on troubleshooting Kubernetes deployments `_. 179 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. rst-class:: hide-header 2 | 3 | lain 4 | ==== 5 | 6 | 往高了说, lain 是一个 DevOps 方案. 但其实, lain 只是帮你封装了 helm, docker 和 kubectl, 让开发者更便捷地管理自己的 Kubernetes 应用. 大致效果如下: 7 | 8 | .. raw:: html 9 | 10 | 11 | 12 | 正如视频演示, lain 提供标准化的 Helm 模板, 开发者只须书写少量关键配置, 就能迅速部署上线. DevOps 里涉及的常见需求, 在这里都做了易用性封装. 例如查看容器状态 / 日志, 滚动上线, 甚至金丝雀部署, lain 都能帮你迅速完成. 13 | 14 | 学习如何使用 lain, 请参看 :ref:`quick-start`. 而如果你希望在你的团队中使用 lain, 则需要根据你所面对的基础设施的情况, 对 lain 做配置和版本发布, 才能开始上手. 具体请看 :ref:`dev`. 15 | 16 | Documentation 17 | ------------- 18 | 19 | .. toctree:: 20 | :maxdepth: 2 21 | 22 | quick-start 23 | app 24 | best-practices 25 | errors 26 | design 27 | admin 28 | dev 29 | -------------------------------------------------------------------------------- /docs/quick-start.rst: -------------------------------------------------------------------------------- 1 | .. _quick-start: 2 | 3 | 快速上手 4 | ======== 5 | 6 | lain 需要调用 kubectl, docker, helm, stern (可选). 这些工具都需要你自行安装. 如果你不清楚要安装什么版本, 那就统一安装最新版吧! lain 最喜欢新版了. 当然啦, kubectl 还是要和 server version 匹配才行, 如果你的团队面对多个版本的 Kubernetes 集群, 推荐你用 `asdf `_ 来管理多版本 kubectl. lain 也与 asdf 进行了整合, 会自动调用切换版本的流程. 7 | 8 | 提前准备 9 | -------- 10 | 11 | * 安装了 docker 以后, 你还需要进行 :code:`docker login`, 登录对应集群的 registry. 12 | 13 | Windows 14 | ^^^^^^^ 15 | 16 | 如果你是初接触 Windows, 请看这篇 `介绍在 PowerShell 下安装 lain 的博客文章 `_. 17 | 18 | * lain 支持在 PowerShell 下使用, 但建议尽量不要, 首选 WSL 里使用 19 | * 如果你有难言之隐, 必须要在 PowerShell 下安装和使用 lain, 这里是一些安装流程的备忘: 20 | 21 | * 记得将 lain 的 cli 执行文件所在的目录加入 :code:`$PATH`, 如果你没有使用 virtualenv, 那么这个路径一般是 :code:`c:\users\$UESR\appdata\roaming\python\python310\Scripts` 22 | * 添加 env: :code:`PYTHONUTF8=1`, 否则 lain 可能会因为你系统的默认编码不匹配而报错 23 | 24 | * lain 依赖的各种第三方程序, 都需要在 PowerShell 里安装好, 以 choco 为例, 可以这样安装: 25 | 26 | .. code-block:: powershell 27 | 28 | choco install git 29 | choco install kubernetes-helm 30 | # client / server 版本需匹配 31 | choco install kubernetes-cli --version=1.20.4 32 | 33 | Mac OS 34 | ^^^^^^ 35 | 36 | * Docker for Mac 的 Docker Daemon 是放在虚拟机里边的, 因此安装 docker 以后, 请确认你为其分配了足够的内存. 多大才算足够呢? 这就取决于你要构建什么项目了, 经验上以 4-5G 为宜, 但若是出现了灵异的构建错误, 也请记得 :ref:`往资源分配方向进行排查 `. 37 | 38 | 安装 lain 39 | --------- 40 | 41 | 因为隔离要求, 安装 lain 不是一件特别简单的事情, 在这里简述下步骤: 42 | 43 | .. code-block:: bash 44 | 45 | # 首先需要安装 virtualenvwrapper: https://virtualenvwrapper.readthedocs.io/en/latest/ 46 | # 这样就能为 lain 创建自己专用的 virtualenv 了 47 | mkvirtualenv lain --python=[abs path for python>=3.9] 48 | workon lain 49 | # lain 推荐的用法是自行维护 internal fork, 你需要从内部 PyPI 上下载 50 | # 但你也可以直接安装开源版本, 来先行评估一番, 不过开源版本需要自行书写集群配置, 请参考 github 项目 README 51 | pip install -U lain 52 | 53 | # 安装完毕以后, 我们还需要把 lain 软链到外边, 让你不需要激活 venv 也能顺利使用 54 | ln -s -f /Users/$USER/.virtualenvs/lain-cli/bin/lain /usr/local/bin/lain 55 | # 你也可以用你自己喜欢的方式将 lain 暴露出来, 比如修改 PATH 56 | # 但无论如何, 千万不要用 alias 来调用 lain, 目前 lain 会 subprocess 地调用自身, alias 会破坏这个过程 57 | 58 | 如果你是管理员(负责 lain 的内部分支维护工作), 或者仅仅是希望尝鲜, 你可能更希望直接用代码仓库进行安装, 上游 lain 并非每一次迭代都会更新版本号, 如果要时刻拿到最新的代码, 只好直接 clone git repo: 59 | 60 | .. code-block:: bash 61 | 62 | # 启用 lain 的团队, 都会维护一个内部分支 63 | # 所以下边的地址需要改成你团队内部的 scm url 64 | git clone https://github.com/timfeirg/lain-cli 65 | cd lain-cli 66 | pip install -e .[all] 67 | # 注意, 用 git repo 进行安装, 一样需要做 virtualenv 与软链, 参考更上方的安装步骤. 68 | 69 | 安装完毕以后, 就可以开始使用了, 你可以参考下面的步骤, 来把一个应用上线到 lain 集群. 70 | 71 | 用 lain 上线一个 APP 72 | -------------------- 73 | 74 | .. code-block:: bash 75 | 76 | # 如果面前是一个还未用 lain 上线的项目, 需要先执行 lain init, 为项目渲染出一份默认的 helm chart 77 | lain init --commit 78 | # 如果项目下已经有 chart 目录, 说明该项目已经是一个 lain app 了, 这时候考虑更新一下 helm chart 79 | lain init --template-only --commit 80 | # 如果你不希望立刻做 git add / commit, 你也可以去掉 --commit 参数, 自己控制. 但千万别忘了, chart 一定要进入代码仓库才行 81 | 82 | # 接下来需要对 values 进行完整的 review, 做必要的修改, 具体参考本文档"应用管理 - 撰写 Helm Values"一节 83 | vi chart/values.yaml 84 | 85 | # 如果应用需要添加一些密码环境变量配置, 可以增加 env, lain env 就是 Kubernetes Secret 的封装 86 | lain env edit 87 | # 如果环境变量的内容不算秘密, 仅仅是配置, 那最好直接写在 values.yaml 里, 还方便管理一些 88 | 89 | # 除了 env, 应用可能还希望添加一些包含密码的配置文件, 这时候就需要用 lain secret 90 | # 既可以直接 lain secret add, 也可以 lain secret edit 打开编辑器, 然后现场书写 91 | lain secret add deploy/secrets.json 92 | lain secret edit 93 | lain secret show 94 | 95 | # 改好了 values.yaml 以及代码以后, 进行构建和上线: 96 | lain use test 97 | lain deploy --build 98 | 99 | # 如果容器报错, 可以用 lain status 观察容器状态 100 | lain status 101 | # lain status 是一个综合信息面板, 空间有限, 里边的日志可能显示不全, 你也可以用 lain logs 进一步阅读完整日志 102 | lain logs 103 | 104 | [可选] 为 lain 设置自动补全 105 | --------------------------- 106 | 107 | 直接利用 click 的功能就能做出自动补全, 下方仅对 zsh 做示范, 其他 shell 请参考 `click 文档 `_. 108 | 109 | .. code-block:: bash 110 | 111 | _LAIN_COMPLETE=zsh_source lain > ~/.lain-complete.zsh 112 | # 把下方这行写在 ~/.zshrc 113 | source ~/.lain-complete.zsh 114 | 115 | [可选] 在命令行 prompt 显示当前集群 116 | ----------------------------------- 117 | 118 | 如果你常在命令行使用 lain, 并且面对多个集群, 肯定会害怕操作错集群(极易产生事故!), 因此为了清楚意识到自己正在操作哪个集群, 肯定希望把当前 cluster name 打印在屏幕上. 119 | 120 | 如果你用的是 `p10k `_, 那么恭喜你, 可以直接抄这几行配置: 121 | 122 | .. code-block:: bash 123 | 124 | typeset -g POWERLEVEL9K_KUBECONTEXT_SHOW_ON_COMMAND='kubectl|helm|kubens|kubectx|oc|istioctl|kogito|lain|stern' 125 | function prompt_kubecontext() { 126 | local cluster 127 | if [ -L ~/.kube/config ]; then 128 | cluster=$(readlink ~/.kube/config| xargs basename | cut -d- -f2) 129 | else 130 | cluster="NOTSET" 131 | fi 132 | p10k segment -f ${POWERLEVEL9K_KUBECONTEXT_DEFAULT_FOREGROUND} -i '⎈' -t "${cluster} " 133 | } 134 | 135 | 如果你用的是其他 shell / theme, 那就辛苦参考上边的函数进行配置吧. 136 | 137 | lain 如何工作? 138 | -------------- 139 | 140 | lain 的定位是"胶水", 以最大提升效率和易用性的方式来粘合 Kubernetes / Helm / Docker / Prometheus, 以及各种其他 DevOps 基础设施, 例如 GitLab CI, Kibana, 甚至是你正在用的 PaaS. 以最关键的几个功能为例, 解释下 lain 是如何运作的: 141 | 142 | * :code:`lain use [cluster]` 其实仅仅是给 :code:`~/.kube/config` 做个软链, 指向对应集群的 :code:`kubeconfig`. 可详读设计要点 :ref:`lain-use-design`. 143 | * :code:`lain build` 算是对 :code:`docker build` 的易用性封装, 你只须在 :code:`values.yaml` 里书写 build 相关的配置块, lain 便会帮你进行 Dockerfile 的渲染和镜像构建. 具体请阅读 :ref:`lain-build`. 144 | * lain 支持若干不同方式对应用进行配置管理, 既可以直接书写在 :code:`values.yaml`, 也可以使用 :code:`lain [env|secret]`, 将应用配置存入 Kubernetes Secret. 可详读 :ref:`lain-env`, :ref:`lain-secret`. 145 | * :code:`lain deploy` 最终会以 subprocess 的方式调用 :code:`helm upgrade --install ...`, 如果你并未安装 helm 或者版本不符合要求, lain 会贴心打断并提示. 146 | * 容器管理等直接和 Kubernetes 资源打交道的功能, 则由 kubectl 来实现, 比如 :code:`lain logs; lain status`. 147 | 148 | lain 尽量做好"粘合剂"的工作, 但也鼓励你直接用底层工具去解决 lain 没有覆盖或无法实现的功能, 比方说, 一个 lain APP 本身就是一个合法的 Helm APP, 你完全可以脱离 lain 而直接用 helm 执行相关操作. lain 在做好整合的前提下, 力求达到解耦, "不断后路"的效果, 决不妨碍用户直接用 kubectl / helm / docker 来自行解决问题. 149 | 150 | .. _reading-list: 151 | 152 | 我不熟悉 Kubernetes / Helm / Docker, 怎么办? 153 | -------------------------------------------- 154 | 155 | 要知道, lain 做的事情真的只是易用性封装, 如果你从没接触过云原生, 那么 lain 做的事情肯定会非常神秘难懂, 摆弄自己弄不懂的工具肯定容易出问题, 因此建议你对 Kubernetes / Helm / Docker 要有最基本的了解: 156 | 157 | * `什么是 Docker? 原理,作用,限制和优势简介 `_ 158 | * `Kubernetes 基本概念 `_ 159 | * `Helm 介绍 `_ 160 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | furo 2 | -------------------------------------------------------------------------------- /lain_cli/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '4.11.4' 2 | package_name = 'lain' 3 | -------------------------------------------------------------------------------- /lain_cli/aliyun.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from aliyunsdkcore.acs_exception.exceptions import ServerException 4 | from aliyunsdkcore.client import AcsClient 5 | from aliyunsdkcore.request import CommonRequest 6 | from aliyunsdkcr.request.v20160607 import GetRepoTagsRequest 7 | 8 | from lain_cli.utils import ( 9 | PaaSUtils, 10 | echo, 11 | error, 12 | jalo, 13 | tell_cluster_config, 14 | goodjob, 15 | warn, 16 | ) 17 | 18 | 19 | class AliyunPaaS(PaaSUtils): 20 | 21 | TYPE = 'aliyun' 22 | 23 | def __init__( 24 | self, 25 | access_key_id=None, 26 | access_key_secret=None, 27 | registry=None, 28 | region_id=None, 29 | **kwargs, 30 | ): 31 | if not all([access_key_id, access_key_secret, registry]): 32 | cc = tell_cluster_config() 33 | access_key_id = cc.get('access_key_id') 34 | access_key_secret = cc.get('access_key_secret') 35 | if not registry and cc.get('registry_type') == self.TYPE: 36 | registry = cc['registry'] 37 | 38 | if not all([access_key_id, access_key_secret]): 39 | raise ValueError( 40 | 'access_key_id / access_key_secret not provided in cluster config' 41 | ) 42 | 43 | if registry: 44 | _, region_id, _, _, repo_namespace = re.split(r'[\./]', registry) 45 | self.registry = f'registry.{region_id}.aliyuncs.com/{repo_namespace}' 46 | self.repo_namespace = repo_namespace 47 | else: 48 | region_id = 'cn-hangzhou' 49 | 50 | self.acs_client = AcsClient(access_key_id, access_key_secret, region_id) 51 | self.endpoint = f'cr.{region_id}.aliyuncs.com' 52 | 53 | def list_tags(self, repo_name, **kwargs): 54 | request = GetRepoTagsRequest.GetRepoTagsRequest() 55 | request.set_RepoNamespace(self.repo_namespace) 56 | request.set_RepoName(repo_name) 57 | request.set_endpoint(self.endpoint) 58 | request.set_PageSize(100) 59 | try: 60 | response = self.acs_client.do_action_with_exception(request) 61 | except ServerException as e: 62 | if e.http_status == 404: 63 | return None 64 | if e.http_status == 400: 65 | warn(f'error during aliyun api query: {e}') 66 | return None 67 | raise 68 | tags_data = jalo(response)['data']['tags'] 69 | tags = self.sort_and_filter((d['tag'] for d in tags_data), n=kwargs.get('n')) 70 | return tags 71 | 72 | def upload_tls_certificate(self, crt, key): 73 | """https://help.aliyun.com/document_detail/126557.htm""" 74 | name = self.tell_certificate_upload_name(crt) 75 | request = CommonRequest() 76 | request.set_accept_format('json') 77 | request.set_domain('cas.aliyuncs.com') 78 | request.set_method('POST') 79 | request.set_protocol_type('https') 80 | request.set_version('2018-07-13') 81 | request.set_action_name('CreateUserCertificate') 82 | request.add_query_param('Name', name) 83 | request.add_query_param('Cert', crt) 84 | request.add_query_param('Key', key) 85 | response = self.acs_client.do_action(request) 86 | res = jalo(response) 87 | code = res.get('Code') 88 | if code: 89 | if 'name already exists' in res.get('Message'): 90 | echo(f'certificate already uploaded: {res}') 91 | else: 92 | error(f'error during upload: {res}', exit=1) 93 | else: 94 | goodjob(f'certificate uploaded: {res}') 95 | -------------------------------------------------------------------------------- /lain_cli/chart_template/Chart.yaml.j2: -------------------------------------------------------------------------------- 1 | # helm 的世界里, chart 是需要上传, 分享, 以及版本管理的 2 | # 但是我们公司内部仅仅把 helm 当做一个模板生成工具, 和一个 Kubernetes 资源管理的 cli 来使用 3 | # 因此原本用来做 chart 管理的 Chart.yaml, 在这里仅仅是一份多余的文件 4 | # 放着就行哈 5 | apiVersion: v2 6 | name: {{ chart_name }} 7 | description: "I got this, baby" 8 | type: application 9 | # This is the chart version. This version number should be incremented each time you make changes 10 | # to the chart and its templates, including the app version. 11 | version: {{ chart_version }} 12 | # We won't be using this field 13 | # The actual imageTag is defined in values.yaml 14 | appVersion: no-use 15 | -------------------------------------------------------------------------------- /lain_cli/chart_template/index.yaml: -------------------------------------------------------------------------------- 1 | # 别删我, 别管我 2 | apiVersion: v1 3 | entries: {} 4 | generated: "2019-10-28T16:54:47.506981+08:00" 5 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{/* vim: set filetype=mustache: */}} 2 | 3 | {{- define "chart.image" -}} 4 | {{ $reg := default .Values.registry .Values.internalRegistry }} 5 | {{- printf "%s/%s:%s" $reg .Values.appname .Values.imageTag}} 6 | {{- end -}} 7 | 8 | {{- define "chart.registry" -}} 9 | {{ default .Values.registry .Values.internalRegistry }} 10 | {{- end -}} 11 | 12 | {{/* 13 | Create chart name and version as used by the chart label. 14 | */}} 15 | 16 | {{/* 17 | Common labels 18 | */}} 19 | {{- define "chart.labels" -}} 20 | helm.sh/chart: {{ .Release.Name }} 21 | {{ include "chart.selectorLabels" . }} 22 | app.kubernetes.io/managed-by: {{ .Release.Service }} 23 | {{- with .Values.labels }} 24 | {{ toYaml . }} 25 | {{- end }} 26 | {{- end -}} 27 | 28 | {{/* 29 | Selector labels 30 | */}} 31 | {{- define "chart.selectorLabels" -}} 32 | app.kubernetes.io/name: {{ .Values.appname }} 33 | {{- end -}} 34 | 35 | {{- define "deployment.apiVersion" -}} 36 | {{- if semverCompare "<1.14-0" .Capabilities.KubeVersion.GitVersion -}} 37 | {{- print "extensions/v1beta1" -}} 38 | {{- else if semverCompare ">=1.14-0" .Capabilities.KubeVersion.GitVersion -}} 39 | {{- print "apps/v1" -}} 40 | {{- end -}} 41 | {{- end -}} 42 | 43 | {{- define "statefulSet.apiVersion" -}} 44 | {{- if semverCompare "<1.14-0" .Capabilities.KubeVersion.GitVersion -}} 45 | {{- print "apps/v1beta2" -}} 46 | {{- else -}} 47 | {{- print "apps/v1" -}} 48 | {{- end -}} 49 | {{- end -}} 50 | 51 | {{- define "cronjob.apiVersion" -}} 52 | {{- if semverCompare "< 1.8-0" .Capabilities.KubeVersion.GitVersion -}} 53 | {{- print "batch/v2alpha1" }} 54 | {{- else if semverCompare ">=1.8-0" .Capabilities.KubeVersion.GitVersion -}} 55 | {{- print "batch/v1beta1" }} 56 | {{- end -}} 57 | {{- end -}} 58 | 59 | {{- define "ingress.apiVersion" -}} 60 | {{- if semverCompare "<1.14-0" .Capabilities.KubeVersion.GitVersion -}} 61 | {{- print "extensions/v1beta1" -}} 62 | {{- else if semverCompare ">=1.19-0" .Capabilities.KubeVersion.GitVersion -}} 63 | {{- print "networking.k8s.io/v1" -}} 64 | {{- else -}} 65 | {{- print "networking.k8s.io/v1beta1" -}} 66 | {{- end -}} 67 | {{- end -}} 68 | 69 | {{- define "hostAliases" -}} 70 | hostAliases: 71 | {{- with $.Values.clusterHostAliases }} 72 | {{ toYaml $.Values.clusterHostAliases }} 73 | {{- end }} 74 | {{- with $.Values.hostAliases }} 75 | {{ toYaml $.Values.hostAliases }} 76 | {{- end }} 77 | {{- end -}} 78 | 79 | {{- define "clusterEnv" -}} 80 | - name: LAIN_CLUSTER 81 | value: {{ default "UNKNOWN" $.Values.cluster }} 82 | - name: K8S_NAMESPACE 83 | value: {{ default "default" $.Values.namespace }} 84 | - name: IMAGE_TAG 85 | value: {{ default "UNKNOWN" $.Values.imageTag }} 86 | {{- end -}} 87 | 88 | {{- define "appEnv" -}} 89 | {{- if hasKey $ "env" }} 90 | {{- range $index, $element := $.env }} 91 | - name: {{ $index | quote }} 92 | value: {{ $element | quote }} 93 | {{- end -}} 94 | {{- end -}} 95 | {{- end -}} 96 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/cronjob.yaml: -------------------------------------------------------------------------------- 1 | {{- range $cronjobName, $cronjob := .Values.cronjobs }} 2 | --- 3 | apiVersion: {{ template "cronjob.apiVersion" $ }} 4 | kind: CronJob 5 | metadata: 6 | name: {{ $.Release.Name }}-{{ $cronjobName }} 7 | labels: 8 | {{- include "chart.labels" $ | nindent 4 }} 9 | spec: 10 | schedule: {{ $cronjob.schedule | quote }} 11 | suspend: {{ default false $cronjob.suspend }} 12 | concurrencyPolicy: {{ default "Replace" $cronjob.concurrencyPolicy }} 13 | successfulJobsHistoryLimit: {{ default 1 $cronjob.successfulJobsHistoryLimit }} 14 | failedJobsHistoryLimit: {{ default 1 $cronjob.failedJobsHistoryLimit }} 15 | startingDeadlineSeconds: 300 16 | jobTemplate: 17 | metadata: 18 | labels: 19 | app.kubernetes.io/instance: {{ $.Release.Name }}-{{ $cronjobName }} 20 | {{- include "chart.labels" $ | nindent 8 }} 21 | spec: 22 | backoffLimit: {{ default 0 $cronjob.backoffLimit }} 23 | activeDeadlineSeconds: {{ default 3600 $cronjob.activeDeadlineSeconds }} 24 | {{- if semverCompare ">=1.14-0" $.Capabilities.KubeVersion.GitVersion }} 25 | ttlSecondsAfterFinished: {{ default 86400 $cronjob.ttlSecondsAfterFinished }} 26 | {{- end }} 27 | template: 28 | metadata: 29 | labels: 30 | app.kubernetes.io/instance: {{ $.Release.Name }}-{{ $cronjobName }} 31 | {{- include "chart.labels" $ | nindent 12 }} 32 | spec: 33 | restartPolicy: Never 34 | serviceAccountName: {{ coalesce $cronjob.serviceAccountName $.Values.serviceAccountName "default" }} 35 | {{- with $cronjob.podSecurityContext }} 36 | securityContext: 37 | {{- toYaml $cronjob.podSecurityContext | nindent 12 }} 38 | {{- end }} 39 | {{- include "hostAliases" $ | nindent 10 }} 40 | terminationGracePeriodSeconds: {{ default 100 $cronjob.terminationGracePeriodSeconds }} 41 | {{- if hasKey $cronjob "initContainers" }} 42 | initContainers: 43 | {{- range $initJobName, $initJob := $cronjob.initContainers }} 44 | - name: {{ $initJob.name }} 45 | command: 46 | {{- toYaml $initJob.command | nindent 12 }} 47 | {{- if hasKey $initJob "workingDir" }} 48 | workingDir: {{ $initJob.workingDir }} 49 | {{- end }} 50 | envFrom: 51 | - secretRef: 52 | name: {{ $.Values.appname }}-env 53 | {{- with $.Values.extraEnvFrom }} 54 | {{- toYaml . | nindent 16 }} 55 | {{- end }} 56 | env: 57 | {{- include "clusterEnv" $ | nindent 16 }} 58 | {{- include "appEnv" (merge (deepCopy $initJob) $.Values) | nindent 16 }} 59 | {{- if hasKey $initJob "image" }} 60 | image: {{ $initJob.image }} 61 | {{- else if hasKey $initJob "imageTag" }} 62 | image: {{ include "chart.registry" $ }}/{{ printf "%s:%s" $.Values.appname $initJob.imageTag }} 63 | {{- else }} 64 | image: {{ include "chart.image" $ }} 65 | {{- end }} 66 | imagePullPolicy: {{ default "IfNotPresent" $initJob.imagePullPolicy }} 67 | volumeMounts: 68 | {{- range $volumeMount := $.Values.volumeMounts }} 69 | - name: {{ default "secret" $volumeMount.name }} 70 | {{- range $k, $v := $volumeMount}} 71 | {{- if ne $k "name"}} 72 | {{ $k }}: {{ $v }} 73 | {{- end }} 74 | {{- end }} 75 | {{- end }} 76 | {{- range $volumeMount := $initJob.volumeMounts }} 77 | - name: {{ default "secret" $volumeMount.name }} 78 | {{- range $k, $v := $volumeMount}} 79 | {{- if ne $k "name"}} 80 | {{ $k }}: {{ $v }} 81 | {{- end }} 82 | {{- end }} 83 | {{- end }} 84 | resources: 85 | {{- if hasKey $initJob "resources" }} 86 | {{- toYaml $initJob.resources | nindent 12 }} 87 | {{- else }} 88 | limits: 89 | cpu: 2000m 90 | memory: 2Gi 91 | requests: 92 | cpu: 500m 93 | memory: 1Gi 94 | {{- end }} 95 | {{- end }} 96 | {{- end }} 97 | containers: 98 | - name: {{ $cronjobName }} 99 | {{- with $cronjob.command }} 100 | command: 101 | {{- toYaml $cronjob.command | nindent 16 }} 102 | {{- end }} 103 | {{- if hasKey $cronjob "workingDir" }} 104 | workingDir: {{ $cronjob.workingDir }} 105 | {{- end }} 106 | envFrom: 107 | - secretRef: 108 | name: {{ $.Values.appname }}-env 109 | {{- with $.Values.extraEnvFrom }} 110 | {{- toYaml . | nindent 16 }} 111 | {{- end }} 112 | env: 113 | {{- include "clusterEnv" $ | nindent 16 }} 114 | {{- include "appEnv" (merge (deepCopy $cronjob) $.Values) | nindent 16 }} 115 | {{- if hasKey $cronjob "image" }} 116 | image: {{ $cronjob.image }} 117 | {{- else if hasKey $cronjob "imageTag" }} 118 | image: {{ include "chart.registry" $ }}/{{ printf "%s:%s" $.Values.appname $cronjob.imageTag }} 119 | {{- else }} 120 | image: {{ include "chart.image" $ }} 121 | {{- end }} 122 | imagePullPolicy: {{ default "IfNotPresent" $cronjob.imagePullPolicy }} 123 | resources: 124 | {{- toYaml $cronjob.resources | nindent 16 }} 125 | volumeMounts: 126 | {{- range $volumeMount := $.Values.volumeMounts }} 127 | - name: {{ default "secret" $volumeMount.name }} 128 | {{- range $k, $v := $volumeMount}} 129 | {{- if ne $k "name"}} 130 | {{ $k }}: {{ $v }} 131 | {{- end }} 132 | {{- end }} 133 | {{- end }} 134 | {{- range $volumeMount := $cronjob.volumeMounts }} 135 | - name: {{ default "secret" $volumeMount.name }} 136 | {{- range $k, $v := $volumeMount}} 137 | {{- if ne $k "name"}} 138 | {{ $k }}: {{ $v }} 139 | {{- end }} 140 | {{- end }} 141 | {{- end }} 142 | volumes: 143 | {{- with $.Values.volumes }} 144 | {{- toYaml . | nindent 12 }} 145 | {{- end }} 146 | - name: secret 147 | secret: 148 | secretName: {{ $.Values.appname }}-secret 149 | {{- range $pvcName, $pvc := $.Values.persistentVolumeClaims }} 150 | - name: {{ $pvcName }} 151 | persistentVolumeClaim: 152 | claimName: {{ $.Release.Name }}-{{ $pvcName }} 153 | {{- end }} 154 | {{- end }} 155 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/deployment.yaml: -------------------------------------------------------------------------------- 1 | {{- range $deployName, $deployment := .Values.deployments }} 2 | --- 3 | apiVersion: {{ template "deployment.apiVersion" $ }} 4 | kind: Deployment 5 | metadata: 6 | name: {{ $.Release.Name }}-{{ $deployName }} 7 | labels: 8 | {{- include "chart.labels" $ | nindent 4 }} 9 | spec: 10 | replicas: {{ $deployment.replicaCount }} 11 | minReadySeconds: {{ default 0 $deployment.minReadySeconds }} 12 | {{- with $deployment.strategy }} 13 | strategy: 14 | {{- toYaml $deployment.strategy | nindent 4 }} 15 | {{- end}} 16 | selector: 17 | matchLabels: 18 | {{- include "chart.selectorLabels" $ | nindent 6 }} 19 | app.kubernetes.io/instance: {{ $.Release.Name }}-{{ $deployName }} 20 | template: 21 | metadata: 22 | {{- if $deployment.podAnnotations }} 23 | annotations: 24 | {{- range $key, $value := $deployment.podAnnotations }} 25 | {{ $key }}: {{ $value | quote }} 26 | {{- end }} 27 | {{- end }} 28 | labels: 29 | app.kubernetes.io/instance: {{ $.Release.Name }}-{{ $deployName }} 30 | {{- include "chart.labels" $ | nindent 8 }} 31 | spec: 32 | {{- if or $deployment.nodes $deployment.affinity }} 33 | affinity: 34 | {{- if hasKey $deployment "affinity" }} 35 | {{ toYaml $deployment.affinity | indent 8 }} 36 | {{- end }} 37 | {{- if $deployment.nodes }} 38 | nodeAffinity: 39 | requiredDuringSchedulingIgnoredDuringExecution: 40 | nodeSelectorTerms: 41 | - matchExpressions: 42 | - key: {{ $.Release.Name }}-{{ $deployName }} 43 | operator: In 44 | values: 45 | - 'true' 46 | {{- end }} 47 | {{- end }} 48 | {{- if hasKey $deployment "hostNetwork" }} 49 | hostNetwork: {{ $deployment.hostNetwork }} 50 | {{- end }} 51 | serviceAccountName: {{ coalesce $deployment.serviceAccountName $.Values.serviceAccountName "default" }} 52 | {{- with $deployment.podSecurityContext }} 53 | securityContext: 54 | {{- toYaml $deployment.podSecurityContext | nindent 8 }} 55 | {{- end }} 56 | {{- include "hostAliases" $ | nindent 6 }} 57 | terminationGracePeriodSeconds: {{ default 100 $deployment.terminationGracePeriodSeconds }} 58 | {{- if hasKey $deployment "initContainers" }} 59 | initContainers: 60 | {{- range $initJobName, $initJob := $deployment.initContainers }} 61 | - name: {{ $initJob.name }} 62 | command: 63 | {{- toYaml $initJob.command | nindent 12 }} 64 | {{- if hasKey $initJob "workingDir" }} 65 | workingDir: {{ $initJob.workingDir }} 66 | {{- end }} 67 | {{- if hasKey $initJob "image" }} 68 | image: {{ $initJob.image }} 69 | {{- else if hasKey $initJob "imageTag" }} 70 | image: {{ include "chart.registry" $ }}/{{ printf "%s:%s" $.Values.appname $initJob.imageTag }} 71 | {{- else }} 72 | image: {{ include "chart.image" $ }} 73 | {{- end }} 74 | imagePullPolicy: {{ default "Always" $initJob.imagePullPolicy }} 75 | envFrom: 76 | - secretRef: 77 | name: {{ $.Values.appname }}-env 78 | {{- with $.Values.extraEnvFrom }} 79 | {{- toYaml . | nindent 12 }} 80 | {{- end }} 81 | env: 82 | {{- include "clusterEnv" $ | nindent 12 }} 83 | {{- include "appEnv" (merge (deepCopy $initJob) $.Values) | nindent 12 }} 84 | volumeMounts: 85 | {{- range $volumeMount := $.Values.volumeMounts }} 86 | - name: {{ default "secret" $volumeMount.name }} 87 | {{- range $k, $v := $volumeMount}} 88 | {{- if ne $k "name"}} 89 | {{ $k }}: {{ $v }} 90 | {{- end }} 91 | {{- end }} 92 | {{- end }} 93 | {{- range $volumeMount := $initJob.volumeMounts }} 94 | - name: {{ default "secret" $volumeMount.name }} 95 | {{- range $k, $v := $volumeMount}} 96 | {{- if ne $k "name"}} 97 | {{ $k }}: {{ $v }} 98 | {{- end }} 99 | {{- end }} 100 | {{- end }} 101 | resources: 102 | {{- if hasKey $initJob "resources" }} 103 | {{- toYaml $initJob.resources | nindent 12 }} 104 | {{- else }} 105 | limits: 106 | cpu: 2000m 107 | memory: 2Gi 108 | requests: 109 | cpu: 500m 110 | memory: 1Gi 111 | {{- end }} 112 | {{- end }} 113 | {{- end }} 114 | containers: 115 | - name: {{ $deployName }} 116 | {{- with $deployment.command }} 117 | command: 118 | {{- toYaml $deployment.command | nindent 12 }} 119 | {{- end }} 120 | {{- if hasKey $deployment "workingDir" }} 121 | workingDir: {{ $deployment.workingDir }} 122 | {{- end }} 123 | {{- if hasKey $deployment "image" }} 124 | image: {{ $deployment.image }} 125 | {{- else if hasKey $deployment "imageTag" }} 126 | image: {{ include "chart.registry" $ }}/{{ printf "%s:%s" $.Values.appname $deployment.imageTag }} 127 | {{- else }} 128 | image: {{ include "chart.image" $ }} 129 | {{- end }} 130 | imagePullPolicy: {{ default "Always" $deployment.imagePullPolicy }} 131 | envFrom: 132 | - secretRef: 133 | name: {{ $.Values.appname }}-env 134 | {{- with $.Values.extraEnvFrom }} 135 | {{- toYaml . | nindent 12 }} 136 | {{- end }} 137 | env: 138 | {{- include "clusterEnv" $ | nindent 12 }} 139 | {{- include "appEnv" (merge (deepCopy $deployment) $.Values) | nindent 12 }} 140 | {{- with $deployment.containerPort }} 141 | ports: 142 | - containerPort: {{ $deployment.containerPort }} 143 | protocol: TCP 144 | {{- end }} 145 | {{- with $deployment.lifecycle }} 146 | lifecycle: 147 | {{- toYaml . | nindent 12 }} 148 | {{- end }} 149 | {{- with $deployment.readinessProbe }} 150 | readinessProbe: 151 | {{- toYaml . | nindent 12 }} 152 | {{- end }} 153 | {{- with $deployment.livenessProbe }} 154 | livenessProbe: 155 | {{- toYaml . | nindent 12 }} 156 | {{- end }} 157 | {{- with $deployment.startupProbe }} 158 | startupProbe: 159 | {{- toYaml . | nindent 12 }} 160 | {{- end }} 161 | resources: 162 | {{- toYaml $deployment.resources | nindent 12 }} 163 | volumeMounts: 164 | {{- range $volumeMount := $.Values.volumeMounts }} 165 | - name: {{ default "secret" $volumeMount.name }} 166 | {{- range $k, $v := $volumeMount}} 167 | {{- if ne $k "name"}} 168 | {{ $k }}: {{ $v }} 169 | {{- end }} 170 | {{- end }} 171 | {{- end }} 172 | {{- range $volumeMount := $deployment.volumeMounts}} 173 | - name: {{ default "secret" $volumeMount.name }} 174 | {{- range $k, $v := $volumeMount}} 175 | {{- if ne $k "name"}} 176 | {{ $k }}: {{ $v }} 177 | {{- end }} 178 | {{- end }} 179 | {{- end }} 180 | volumes: 181 | {{- with $.Values.volumes }} 182 | {{- toYaml . | nindent 8 }} 183 | {{- end }} 184 | - name: secret 185 | secret: 186 | secretName: {{ $.Values.appname }}-secret 187 | {{- range $pvcName, $pvc := $.Values.persistentVolumeClaims }} 188 | - name: {{ $pvcName }} 189 | persistentVolumeClaim: 190 | claimName: {{ $.Release.Name }}-{{ $pvcName }} 191 | {{- end }} 192 | 193 | {{- if $deployment.hpa }} 194 | --- 195 | # ref: https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#horizontalpodautoscaler-v1-autoscaling 196 | apiVersion: autoscaling/v2beta2 197 | kind: HorizontalPodAutoscaler 198 | metadata: 199 | name: {{ $.Values.appname }}-{{ $deployName }} 200 | spec: 201 | scaleTargetRef: 202 | apiVersion: apps/v1 203 | kind: Deployment 204 | name: {{ $.Values.appname }}-{{ $deployName }} 205 | minReplicas: {{ $deployment.replicaCount }} 206 | maxReplicas: {{ $deployment.hpa.maxReplicas }} 207 | {{- if $deployment.hpa.metrics }} 208 | metrics: 209 | {{- with $deployment.hpa.metrics }} 210 | {{- toYaml . | nindent 4 }} 211 | {{- end }} 212 | {{- end }} 213 | {{- if semverCompare ">=1.18-0" $.Capabilities.KubeVersion.GitVersion }} 214 | behavior: 215 | {{- if $deployment.hpa.behavior }} 216 | {{- with $deployment.hpa.behavior }} 217 | {{- toYaml . | nindent 4 }} 218 | {{- end }} 219 | {{- else }} 220 | scaleUp: 221 | stabilizationWindowSeconds: 120 222 | {{- end }} 223 | {{- end }} 224 | {{- end }} 225 | 226 | {{- end }} 227 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/externalIngress.yaml: -------------------------------------------------------------------------------- 1 | {{- range $ingress := .Values.externalIngresses }} 2 | --- 3 | apiVersion: {{ template "ingress.apiVersion" $ }} 4 | kind: Ingress 5 | metadata: 6 | name: {{ $ingress.host | replace "." "-" }}-{{ $.Release.Name }}-{{ $ingress.deployName }} 7 | annotations: 8 | {{- if hasKey $.Values "clusterIssuer" }} 9 | cert-manager.io/cluster-issuer: {{ $.Values.clusterIssuer }} 10 | {{- end }} 11 | {{- if and (hasKey $.Values "externalIngressClass") (not $.Values.supportsIngressClassName | default false ) }} 12 | kubernetes.io/ingress.class: {{ default $.Values.externalIngressClass $ingress.ingressClass }} 13 | {{- end }} 14 | {{- with $.Values.externalIngressAnnotations }} 15 | {{- range $k, $v := $.Values.externalIngressAnnotations }} 16 | {{ $k }}: {{ $v | quote }} 17 | {{- end }} 18 | {{- end }} 19 | {{- with $ingress.annotations }} 20 | {{- range $k, $v := $ingress.annotations }} 21 | {{ $k }}: {{ $v | quote }} 22 | {{- end }} 23 | {{- end }} 24 | labels: 25 | {{- include "chart.labels" $ | nindent 4 }} 26 | spec: 27 | {{- if and (hasKey $.Values "externalIngressClass") ($.Values.supportsIngressClassName) }} 28 | ingressClassName: {{ default $.Values.externalIngressClass $ingress.ingressClass }} 29 | {{- end }} 30 | {{- if hasKey $.Values "externalIngressTLSSecretName" }} 31 | tls: 32 | - secretName: {{ $.Values.externalIngressTLSSecretName }} 33 | {{- end }} 34 | {{- if hasKey $.Values "clusterIssuer" }} 35 | tls: 36 | - hosts: 37 | {{- if regexMatch "^[^\\.]+\\.[^\\.]+$" $ingress.host }} 38 | - {{ $ingress.host }} 39 | - '*.{{ $ingress.host }}' 40 | secretName: {{ $ingress.host | replace "." "-" }} 41 | {{- else }} 42 | - '*.{{ regexReplaceAll "[^\\.]+\\.(.+)" $ingress.host "${1}" }}' 43 | - '{{ regexReplaceAll "[^\\.]+\\.(.+)" $ingress.host "${1}" }}' 44 | secretName: {{ regexReplaceAll "[^\\.]+\\.(.+)" $ingress.host "${1}" | replace "." "-" }} 45 | {{- end }} 46 | {{- end }} 47 | rules: 48 | - host: {{ $ingress.host }} 49 | http: 50 | paths: 51 | {{- range $ingress.paths }} 52 | - path: {{ . }} 53 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 54 | pathType: Prefix 55 | {{- end }} 56 | backend: 57 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 58 | service: 59 | name: {{ $.Release.Name }}-{{ $ingress.deployName }} 60 | port: 61 | number: {{ default 80 (index $.Values.deployments $ingress.deployName "nodePort") }} 62 | {{- else }} 63 | serviceName: {{ $.Release.Name }}-{{ $ingress.deployName }} 64 | servicePort: {{ default 80 (index $.Values.deployments $ingress.deployName "nodePort") }} 65 | {{- end }} 66 | {{- end }} 67 | 68 | {{- end }} 69 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/ingress.yaml: -------------------------------------------------------------------------------- 1 | {{- range $ingress := .Values.ingresses }} 2 | --- 3 | apiVersion: {{ template "ingress.apiVersion" $ }} 4 | kind: Ingress 5 | metadata: 6 | {{- if contains "." $ingress.host }} 7 | name: {{ $ingress.host | replace "." "-" }}-{{ $.Release.Name }}-{{ $ingress.deployName }} 8 | {{- else if $.Values.domain_suffix }} 9 | name: {{ $ingress.host | replace "." "-" }}{{ $.Values.domain_suffix | replace "." "-" }}-{{ $.Release.Name }}-{{ $ingress.deployName }} 10 | {{- else }} 11 | name: {{ $ingress.host | replace "." "-" }}-{{ $.Values.domain | replace "." "-" }}-{{ $.Release.Name }}-{{ $ingress.deployName }} 12 | {{- end }} 13 | annotations: 14 | {{- if hasKey $.Values "clusterIssuer" }} 15 | cert-manager.io/cluster-issuer: {{ $.Values.clusterIssuer }} 16 | {{- end }} 17 | {{- if and (hasKey $.Values "ingressClass") (not $.Values.supportsIngressClassName | default false ) }} 18 | kubernetes.io/ingress.class: {{ default $.Values.ingressClass $ingress.ingressClass }} 19 | {{- end }} 20 | {{- with $.Values.ingressAnnotations }} 21 | {{- range $k, $v := $.Values.ingressAnnotations }} 22 | {{ $k }}: {{ $v | quote }} 23 | {{- end }} 24 | {{- end }} 25 | {{- with $ingress.annotations }} 26 | {{- range $k, $v := $ingress.annotations }} 27 | {{ $k }}: {{ $v | quote }} 28 | {{- end }} 29 | {{- end }} 30 | labels: 31 | {{- include "chart.labels" $ | nindent 4 }} 32 | spec: 33 | {{- if and (hasKey $.Values "ingressClass") ($.Values.supportsIngressClassName) }} 34 | ingressClassName: {{ default $.Values.ingressClass $ingress.ingressClass }} 35 | {{- end }} 36 | {{- if hasKey $.Values "ingressTLSSecretName" }} 37 | tls: 38 | - secretName: {{ $.Values.ingressTLSSecretName }} 39 | {{- end }} 40 | {{- if hasKey $.Values "clusterIssuer" }} 41 | tls: 42 | - hosts: 43 | {{- if contains "." $ingress.host }} 44 | - '*.{{ regexReplaceAll "[^\\.]+\\.(.+)" $ingress.host "${1}" }}' 45 | - '{{ regexReplaceAll "[^\\.]+\\.(.+)" $ingress.host "${1}" }}' 46 | secretName: {{ regexReplaceAll "[^\\.]+\\.(.+)" $ingress.host "${1}" | replace "." "-" }} 47 | {{- else if $.Values.domain }} 48 | - "*.{{ $.Values.domain }}" 49 | - "{{ $.Values.domain }}" 50 | secretName: {{ $.Values.domain | replace "." "-" }} 51 | {{- else }} 52 | {{- fail "cannot infer tls config when domain is empty, use ingressTLSSecretName" }} 53 | {{- end }} 54 | {{- end }} 55 | rules: 56 | {{- if contains "." $ingress.host }} 57 | - host: {{ $ingress.host }} 58 | {{- else if $.Values.domain_suffix }} 59 | - host: {{ $ingress.host }}{{ $.Values.domain_suffix }} 60 | {{- else if $.Values.domain }} 61 | - host: {{ $ingress.host }}.{{ $.Values.domain }} 62 | {{- else }} 63 | {{- fail "host is not a FQDN, then domain or domain_suffix must be defined" }} 64 | {{- end }} 65 | http: 66 | paths: 67 | {{- range $ingress.paths }} 68 | - path: {{ . }} 69 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 70 | pathType: Prefix 71 | {{- end }} 72 | backend: 73 | {{- if semverCompare ">=1.19-0" $.Capabilities.KubeVersion.GitVersion }} 74 | service: 75 | name: {{ $.Release.Name }}-{{ $ingress.deployName }} 76 | port: 77 | number: {{ default 80 (index $.Values.deployments $ingress.deployName "nodePort") }} 78 | {{- else }} 79 | serviceName: {{ $.Release.Name }}-{{ $ingress.deployName }} 80 | servicePort: {{ default 80 (index $.Values.deployments $ingress.deployName "nodePort") }} 81 | {{- end }} 82 | {{- end }} 83 | 84 | {{- end }} 85 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/job.yaml: -------------------------------------------------------------------------------- 1 | {{- range $jobName, $job := .Values.jobs }} 2 | --- 3 | apiVersion: batch/v1 4 | kind: Job 5 | metadata: 6 | name: {{ $.Release.Name }}-{{ $jobName }} 7 | {{- if hasKey $job "annotations" }} 8 | annotations: 9 | {{- with $job.annotations }} 10 | {{- toYaml . | nindent 6 }} 11 | {{- end }} 12 | {{- end }} 13 | labels: 14 | {{- include "chart.labels" $ | nindent 4 }} 15 | spec: 16 | backoffLimit: {{ default 0 $job.backoffLimit }} 17 | activeDeadlineSeconds: {{ default 3600 $job.activeDeadlineSeconds }} 18 | {{- if semverCompare ">=1.14-0" $.Capabilities.KubeVersion.GitVersion }} 19 | ttlSecondsAfterFinished: {{ default 86400 $job.ttlSecondsAfterFinished }} 20 | {{- end }} 21 | template: 22 | metadata: 23 | labels: 24 | app.kubernetes.io/instance: {{ $.Release.Name }}-{{ $jobName }} 25 | {{- include "chart.labels" $ | nindent 8 }} 26 | spec: 27 | {{- include "hostAliases" $ | nindent 6 }} 28 | serviceAccountName: {{ coalesce $job.serviceAccountName $.Values.serviceAccountName "default" }} 29 | {{- with $job.podSecurityContext }} 30 | securityContext: 31 | {{- toYaml $job.podSecurityContext | nindent 8 }} 32 | {{- end }} 33 | {{- if hasKey $job "initContainers" }} 34 | initContainers: 35 | {{- range $initJobName, $initJob := $job.initContainers }} 36 | - name: {{ $initJob.name }} 37 | command: 38 | {{- toYaml $initJob.command | nindent 12 }} 39 | {{- if hasKey $initJob "workingDir" }} 40 | workingDir: {{ $initJob.workingDir }} 41 | {{- end }} 42 | {{- if hasKey $initJob "image" }} 43 | image: {{ $initJob.image }} 44 | {{- else if hasKey $initJob "imageTag" }} 45 | image: {{ include "chart.registry" $ }}/{{ printf "%s:%s" $.Values.appname $initJob.imageTag }} 46 | {{- else }} 47 | image: {{ include "chart.image" $ }} 48 | {{- end }} 49 | imagePullPolicy: {{ default "Always" $initJob.imagePullPolicy }} 50 | envFrom: 51 | - secretRef: 52 | name: {{ $.Values.appname }}-env 53 | {{- with $.Values.extraEnvFrom }} 54 | {{- toYaml . | nindent 12 }} 55 | {{- end }} 56 | env: 57 | {{- include "clusterEnv" $ | nindent 12 }} 58 | {{- include "appEnv" (merge (deepCopy $initJob) $.Values) | nindent 12 }} 59 | volumeMounts: 60 | {{- range $volumeMount := $.Values.volumeMounts }} 61 | - name: {{ default "secret" $volumeMount.name }} 62 | {{- range $k, $v := $volumeMount}} 63 | {{- if ne $k "name"}} 64 | {{ $k }}: {{ $v }} 65 | {{- end }} 66 | {{- end }} 67 | {{- end }} 68 | {{- range $volumeMount := $initJob.volumeMounts }} 69 | - name: {{ default "secret" $volumeMount.name }} 70 | {{- range $k, $v := $volumeMount}} 71 | {{- if ne $k "name"}} 72 | {{ $k }}: {{ $v }} 73 | {{- end }} 74 | {{- end }} 75 | {{- end }} 76 | resources: 77 | {{- if hasKey $initJob "resources" }} 78 | {{- toYaml $initJob.resources | nindent 12 }} 79 | {{- else }} 80 | limits: 81 | cpu: 2000m 82 | memory: 2Gi 83 | requests: 84 | cpu: 500m 85 | memory: 1Gi 86 | {{- end }} 87 | {{- end }} 88 | {{- end }} 89 | containers: 90 | - name: {{ $jobName }} 91 | command: 92 | {{- toYaml $job.command | nindent 12 }} 93 | {{- if hasKey $job "workingDir" }} 94 | workingDir: {{ $job.workingDir }} 95 | {{- end }} 96 | {{- if hasKey $job "image" }} 97 | image: {{ $job.image }} 98 | {{- else if hasKey $job "imageTag" }} 99 | image: {{ include "chart.registry" $ }}/{{ printf "%s:%s" $.Values.appname $job.imageTag }} 100 | {{- else }} 101 | image: {{ include "chart.image" $ }} 102 | {{- end }} 103 | imagePullPolicy: {{ default "Always" $job.imagePullPolicy }} 104 | envFrom: 105 | - secretRef: 106 | name: {{ $.Values.appname }}-env 107 | {{- with $.Values.extraEnvFrom }} 108 | {{- toYaml . | nindent 12 }} 109 | {{- end }} 110 | env: 111 | {{- include "clusterEnv" $ | nindent 12 }} 112 | {{- include "appEnv" (merge (deepCopy $job) $.Values) | nindent 12 }} 113 | volumeMounts: 114 | {{- range $volumeMount := $.Values.volumeMounts }} 115 | - name: {{ default "secret" $volumeMount.name }} 116 | {{- range $k, $v := $volumeMount}} 117 | {{- if ne $k "name"}} 118 | {{ $k }}: {{ $v }} 119 | {{- end }} 120 | {{- end }} 121 | {{- end }} 122 | {{- range $volumeMount := $job.volumeMounts }} 123 | - name: {{ default "secret" $volumeMount.name }} 124 | {{- range $k, $v := $volumeMount}} 125 | {{- if ne $k "name"}} 126 | {{ $k }}: {{ $v }} 127 | {{- end }} 128 | {{- end }} 129 | {{- end }} 130 | resources: 131 | {{- if hasKey $job "resources" }} 132 | {{- toYaml $job.resources | nindent 12 }} 133 | {{- else }} 134 | limits: 135 | cpu: 2000m 136 | memory: 2Gi 137 | requests: 138 | cpu: 500m 139 | memory: 1Gi 140 | {{- end }} 141 | volumes: 142 | {{- with $.Values.volumes }} 143 | {{- toYaml . | nindent 8 }} 144 | {{- end }} 145 | - name: secret 146 | secret: 147 | secretName: {{ $.Values.appname }}-secret 148 | {{- range $pvcName, $pvc := $.Values.persistentVolumeClaims }} 149 | - name: {{ $pvcName }} 150 | persistentVolumeClaim: 151 | claimName: {{ $.Release.Name }}-{{ $pvcName }} 152 | {{- end }} 153 | restartPolicy: Never 154 | {{- end }} 155 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/networkPolicy.yaml: -------------------------------------------------------------------------------- 1 | {{- with .Values.networkPolicy -}} 2 | apiVersion: networking.k8s.io/v1 3 | kind: NetworkPolicy 4 | metadata: 5 | name: {{ $.Release.Name }} 6 | spec: 7 | {{- toYaml .spec | nindent 2 }} 8 | {{- end }} 9 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/pvc.yaml: -------------------------------------------------------------------------------- 1 | {{- range $pvcName, $pvc := .Values.persistentVolumeClaims }} 2 | --- 3 | apiVersion: v1 4 | kind: PersistentVolumeClaim 5 | metadata: 6 | name: {{ $.Release.Name }}-{{ $pvcName }} 7 | spec: 8 | {{- toYaml $pvc | nindent 2 }} 9 | 10 | {{- end }} 11 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/service.yaml: -------------------------------------------------------------------------------- 1 | {{- range $deployName, $deployment := .Values.deployments }} 2 | {{- if or $deployment.containerPort $deployment.nodePort }} 3 | --- 4 | apiVersion: v1 5 | kind: Service 6 | metadata: 7 | name: {{ $.Release.Name }}-{{ $deployName }} 8 | annotations: 9 | {{- with $.Values.serviceAnnotations }} 10 | {{- range $k, $v := $.Values.serviceAnnotations }} 11 | {{ $k }}: {{ $v | quote }} 12 | {{- end }} 13 | {{- end }} 14 | labels: 15 | {{- include "chart.labels" $ | nindent 4 }} 16 | spec: 17 | {{- if $deployment.nodePort }} 18 | type: NodePort 19 | {{- else }} 20 | type: ClusterIP 21 | {{- end }} 22 | ports: 23 | # 你知道, service 解析出来就是一个 vip, 至于用哪个端口进行监听, 这就要靠你来声明 port 字段了 24 | # 但是大家都是写写 web server, 所以模板里就直接写死了80, 不会有人有意见的 25 | - port: {{ default 80 $deployment.nodePort }} 26 | # targetPort, 顾名思义, 就是说流量将会转发到 pod 的哪个端口 27 | targetPort: {{ default $deployment.nodePort $deployment.containerPort }} 28 | protocol: {{ default "TCP" $deployment.protocol }} 29 | {{- if $deployment.nodePort }} 30 | nodePort: {{ $deployment.nodePort }} 31 | {{- end }} 32 | # selector 定义了这个 service 应该转发到哪些 pod 上 33 | # 比如在 deployment spec 里, 就渲染了一样的 selector 配置, 因此我这个 service 才能找到对应的 pod 34 | selector: 35 | app.kubernetes.io/instance: {{ $.Release.Name }}-{{ $deployName }} 36 | {{- include "chart.selectorLabels" $ | nindent 4 }} 37 | {{- end }} 38 | {{- end }} 39 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/statefulSet.yaml: -------------------------------------------------------------------------------- 1 | {{- range $stsName, $sts := .Values.statefulSets }} 2 | --- 3 | apiVersion: {{ template "statefulSet.apiVersion" $ }} 4 | kind: StatefulSet 5 | metadata: 6 | name: {{ $.Release.Name }}-{{ $stsName }} 7 | labels: 8 | {{- include "chart.labels" $ | nindent 4 }} 9 | spec: 10 | replicas: {{ $sts.replicaCount }} 11 | {{- with $sts.updateStrategy }} 12 | updateStrategy: 13 | {{- toYaml $sts.updateStrategy | nindent 4 }} 14 | {{- end}} 15 | selector: 16 | matchLabels: 17 | {{- include "chart.selectorLabels" $ | nindent 6 }} 18 | app.kubernetes.io/instance: {{ $.Release.Name }}-{{ $stsName }} 19 | serviceName: {{ $.Release.Name }}-{{ $stsName }} 20 | template: 21 | metadata: 22 | {{- if $sts.podAnnotations }} 23 | annotations: 24 | {{- range $key, $value := $sts.podAnnotations }} 25 | {{ $key }}: {{ $value | quote }} 26 | {{- end }} 27 | {{- end }} 28 | labels: 29 | app.kubernetes.io/instance: {{ $.Release.Name }}-{{ $stsName }} 30 | {{- include "chart.labels" $ | nindent 8 }} 31 | spec: 32 | {{- if or $sts.nodes $sts.affinity }} 33 | affinity: 34 | {{- if hasKey $sts "affinity" }} 35 | {{ toYaml $sts.affinity | indent 8 }} 36 | {{- end }} 37 | {{- if $sts.nodes }} 38 | nodeAffinity: 39 | requiredDuringSchedulingIgnoredDuringExecution: 40 | nodeSelectorTerms: 41 | - matchExpressions: 42 | - key: {{ $.Release.Name }}-{{ $stsName }} 43 | operator: In 44 | values: 45 | - 'true' 46 | {{- end }} 47 | {{- end }} 48 | {{- if hasKey $sts "hostNetwork" }} 49 | hostNetwork: {{ $sts.hostNetwork }} 50 | {{- end }} 51 | serviceAccountName: {{ coalesce $sts.serviceAccountName $.Values.serviceAccountName "default" }} 52 | {{- with $sts.podSecurityContext }} 53 | securityContext: 54 | {{- toYaml $sts.podSecurityContext | nindent 8 }} 55 | {{- end }} 56 | {{- include "hostAliases" $ | nindent 6 }} 57 | terminationGracePeriodSeconds: {{ default 100 $sts.terminationGracePeriodSeconds }} 58 | {{- if hasKey $sts "initContainers" }} 59 | initContainers: 60 | {{- range $initJobName, $initJob := $sts.initContainers }} 61 | - name: {{ $initJob.name }} 62 | command: 63 | {{- toYaml $initJob.command | nindent 12 }} 64 | {{- if hasKey $initJob "image" }} 65 | {{- if hasKey $initJob "workingDir" }} 66 | workingDir: {{ $initJob.workingDir }} 67 | {{- end }} 68 | image: {{ $initJob.image }} 69 | {{- else if hasKey $initJob "imageTag" }} 70 | image: {{ include "chart.registry" $ }}/{{ printf "%s:%s" $.Values.appname $sts.imageTag }} 71 | {{- else }} 72 | image: {{ include "chart.image" $ }} 73 | {{- end }} 74 | imagePullPolicy: {{ default "Always" $initJob.imagePullPolicy }} 75 | envFrom: 76 | - secretRef: 77 | name: {{ $.Values.appname }}-env 78 | {{- with $.Values.extraEnvFrom }} 79 | {{- toYaml . | nindent 12 }} 80 | {{- end }} 81 | env: 82 | {{- include "clusterEnv" $ | nindent 12 }} 83 | {{- include "appEnv" (merge (deepCopy $initJob) $.Values) | nindent 12 }} 84 | volumeMounts: 85 | {{- range $volumeMount := $.Values.volumeMounts }} 86 | - name: {{ default "secret" $volumeMount.name }} 87 | {{- range $k, $v := $volumeMount}} 88 | {{- if ne $k "name"}} 89 | {{ $k }}: {{ $v }} 90 | {{- end }} 91 | {{- end }} 92 | {{- end }} 93 | {{- range $volumeMount := $initJob.volumeMounts }} 94 | - name: {{ default "secret" $volumeMount.name }} 95 | {{- range $k, $v := $volumeMount}} 96 | {{- if ne $k "name"}} 97 | {{ $k }}: {{ $v }} 98 | {{- end }} 99 | {{- end }} 100 | {{- end }} 101 | resources: 102 | {{- if hasKey $initJob "resources" }} 103 | {{- toYaml $initJob.resources | nindent 12 }} 104 | {{- else }} 105 | limits: 106 | cpu: 2000m 107 | memory: 2Gi 108 | requests: 109 | cpu: 500m 110 | memory: 1Gi 111 | {{- end }} 112 | {{- end }} 113 | {{- end }} 114 | containers: 115 | - name: {{ $stsName }} 116 | {{- with $sts.command }} 117 | command: 118 | {{- toYaml $sts.command | nindent 12 }} 119 | {{- end }} 120 | {{- if hasKey $sts "workingDir" }} 121 | workingDir: {{ $sts.workingDir }} 122 | {{- end }} 123 | {{- if hasKey $sts "image" }} 124 | image: {{ $sts.image }} 125 | {{- else if hasKey $sts "imageTag" }} 126 | image: {{ include "chart.registry" $ }}/{{ printf "%s:%s" $.Values.appname $sts.imageTag }} 127 | {{- else }} 128 | image: {{ include "chart.image" $ }} 129 | {{- end }} 130 | imagePullPolicy: {{ default "Always" $sts.imagePullPolicy }} 131 | envFrom: 132 | - secretRef: 133 | name: {{ $.Values.appname }}-env 134 | {{- with $.Values.extraEnvFrom }} 135 | {{- toYaml . | nindent 12 }} 136 | {{- end }} 137 | env: 138 | {{- include "clusterEnv" $ | nindent 12 }} 139 | {{- include "appEnv" (merge (deepCopy $sts) $.Values) | nindent 12 }} 140 | {{- with $sts.containerPort }} 141 | ports: 142 | - containerPort: {{ $sts.containerPort }} 143 | protocol: TCP 144 | {{- end }} 145 | {{- with $sts.readinessProbe }} 146 | readinessProbe: 147 | {{- toYaml . | nindent 12 }} 148 | {{- end }} 149 | {{- with $sts.livenessProbe }} 150 | livenessProbe: 151 | {{- toYaml . | nindent 12 }} 152 | {{- end }} 153 | {{- with $sts.startupProbe }} 154 | startupProbe: 155 | {{- toYaml . | nindent 12 }} 156 | {{- end }} 157 | resources: 158 | {{- toYaml $sts.resources | nindent 12 }} 159 | volumeMounts: 160 | {{- range $volumeMount := $.Values.volumeMounts }} 161 | - name: {{ default "secret" $volumeMount.name }} 162 | {{- range $k, $v := $volumeMount}} 163 | {{- if ne $k "name"}} 164 | {{ $k }}: {{ $v }} 165 | {{- end }} 166 | {{- end }} 167 | {{- end }} 168 | {{- range $volumeMount := $sts.volumeMounts }} 169 | - name: {{ default "secret" $volumeMount.name }} 170 | {{- range $k, $v := $volumeMount}} 171 | {{- if ne $k "name"}} 172 | {{ $k }}: {{ $v }} 173 | {{- end }} 174 | {{- end }} 175 | {{- end }} 176 | volumes: 177 | {{- with $.Values.volumes }} 178 | {{- toYaml . | nindent 8 }} 179 | {{- end }} 180 | - name: secret 181 | secret: 182 | secretName: {{ $.Values.appname }}-secret 183 | {{- range $pvcName, $pvc := $.Values.persistentVolumeClaims }} 184 | - name: {{ $pvcName }} 185 | persistentVolumeClaim: 186 | claimName: {{ $.Release.Name }}-{{ $pvcName }} 187 | {{- end }} 188 | 189 | {{- end }} 190 | -------------------------------------------------------------------------------- /lain_cli/chart_template/templates/test.yaml: -------------------------------------------------------------------------------- 1 | {{- range $testName, $test_job := .Values.tests }} 2 | --- 3 | apiVersion: v1 4 | kind: Pod 5 | metadata: 6 | name: {{ $.Release.Name }}-{{ $testName }} 7 | labels: 8 | {{- include "chart.labels" $ | nindent 4 }} 9 | annotations: 10 | "helm.sh/hook": test 11 | "helm.sh/hook-delete-policy": before-hook-creation 12 | spec: 13 | {{- include "hostAliases" $ | nindent 2 }} 14 | containers: 15 | - name: {{ $testName }} 16 | command: 17 | {{- toYaml $test_job.command | nindent 8 }} 18 | {{- if hasKey $test_job "workingDir" }} 19 | workingDir: {{ $test_job.workingDir }} 20 | {{- end }} 21 | {{- if hasKey $test_job "image" }} 22 | image: {{ $test_job.image }} 23 | {{- else if hasKey $test_job "imageTag" }} 24 | image: {{ include "chart.registry" $ }}/{{ printf "%s:%s" $.Values.appname $test_job.imageTag }} 25 | {{- else }} 26 | image: {{ include "chart.image" $ }} 27 | {{- end }} 28 | imagePullPolicy: {{ default "Always" $test_job.imagePullPolicy }} 29 | envFrom: 30 | - secretRef: 31 | name: {{ $.Values.appname }}-env 32 | {{- with $.Values.extraEnvFrom }} 33 | {{- toYaml . | nindent 8 }} 34 | {{- end }} 35 | env: 36 | {{- include "clusterEnv" $ | nindent 8 }} 37 | {{- include "appEnv" (merge (deepCopy $test_job) $.Values) | nindent 8 }} 38 | volumeMounts: 39 | {{- range $volumeMount := $.Values.volumeMounts }} 40 | - name: {{ default "secret" $volumeMount.name }} 41 | {{- range $k, $v := $volumeMount}} 42 | {{- if ne $k "name"}} 43 | {{ $k }}: {{ $v }} 44 | {{- end }} 45 | {{- end }} 46 | {{- end }} 47 | {{- range $volumeMount := $test_job.volumeMounts }} 48 | - name: {{ default "secret" $volumeMount.name }} 49 | {{- range $k, $v := $volumeMount}} 50 | {{- if ne $k "name"}} 51 | {{ $k }}: {{ $v }} 52 | {{- end }} 53 | {{- end }} 54 | {{- end }} 55 | resources: 56 | {{- if hasKey $test_job "resources" }} 57 | {{- toYaml $test_job.resources | nindent 8 }} 58 | {{- else }} 59 | limits: 60 | cpu: 2000m 61 | memory: 2Gi 62 | requests: 63 | cpu: 500m 64 | memory: 1Gi 65 | {{- end }} 66 | volumes: 67 | {{- with $.Values.volumes }} 68 | {{- toYaml . | nindent 4 }} 69 | {{- end }} 70 | - name: secret 71 | secret: 72 | secretName: {{ $.Values.appname }}-secret 73 | {{- range $pvcName, $pvc := $.Values.persistentVolumeClaims }} 74 | - name: {{ $pvcName }} 75 | persistentVolumeClaim: 76 | claimName: {{ $.Release.Name }}-{{ $pvcName }} 77 | {{- end }} 78 | restartPolicy: Never 79 | {{- end }} 80 | -------------------------------------------------------------------------------- /lain_cli/chart_template/values.yaml.j2: -------------------------------------------------------------------------------- 1 | # 应用名称, 如果不是有意要 hack, 绝对不要修改, 每一个 Kubernetes 资源的名称都通过这个 appname 计算而来 2 | appname: {{ appname }} 3 | # releaseName 就是 helm release name, 如果你想要把 app 部署两份, 则在其他 values 文件里超载该字段 4 | # releaseName: {{ appname }} 5 | 6 | # # 某些应用的容器数众多, 查 7d 数据的话, 会直接 timeout, 这时考虑缩短一些 7 | # prometheus_query_range: 7d 8 | 9 | # # 上线以后发送通知到指定的 webhook 10 | # webhook: 11 | # # 目前仅支持 feishu, 需要先添加 webhook 机器人才行: 12 | # # https://www.feishu.cn/hc/zh-CN/articles/360024984973 13 | # url: https://open.feishu.cn/open-apis/bot/v2/hook/c057c484-9fd9-4ed9-83db-63e4b271de76 14 | # # 可选, 不写则默认所有集群上线都发送通知 15 | # clusters: 16 | # - yashi 17 | 18 | # # 通用的环境变量写在这里 19 | # env: 20 | # AUTH_TYPE: "basic" 21 | # BASIC_AUTH_USER: "admin" 22 | # # 包含敏感信息的内容则由 lain env 命令来管理, 详见 lain env --help 23 | 24 | # 在 volumes 下声明出 volume, 每一个 volume 都是一个可供挂载的文件或者目录 25 | # 如果你的项目并不需要额外的自定义挂载, 那么 volumes 可以留空 26 | # 不过在这里留空, 并不表示你的应用没有任何 volumes, 因为 lain 默认会赠送你 lain secret 的 volume, 写死在 helm chart 内了 27 | 28 | # 比方说, 如果你要挂载 JuiceFS, 那就需要先声明出 volumes, 然后让 SA 帮你把目录提前做好 mkdir + chown 29 | 30 | # volumes: 31 | # - name: jfs-backup-dir 32 | # hostPath: 33 | # path: "/jfs/backup/{{ appname }}/" 34 | # type: Directory # 如果要挂载文件, 则写成 File 35 | 36 | # 顾名思义, volumeMounts 就是将 volume 挂载到容器内 37 | volumeMounts: 38 | # 如果 name 留空, 默认就是 {{ appname }}-secret 这个 volume, 你可以用 lain secret 来上传需要挂载进去的文件 39 | - subPath: topsecret.txt # lain secret 里可以存放多份文件, subPath 就是其中的文件名 40 | mountPath: /lain/app/deploy/topsecret.txt # mountPath 则用来控制, 该文件/目录要挂载到容器内的什么路径 41 | # # 如果你在 volumes 里声明了定制 volume, 那就需要写清楚 name 了 42 | # - name: jfs-backup-dir # name 就是 volumes 里声明的 volume name, 如果留空, 就是 lain secret 43 | # mountPath: /jfs/backup/{{ appname }}/ 44 | # # 用 persistentVolumeClaims 定义的 claim 在这里可以直接 mount 45 | # - name: juicefs-pvc 46 | # mountPath: /jfs/ 47 | 48 | # # 如果你的应用需要持久存储,并且 SA 告诉你需要用 PersistentVolumeClaim 来申请存储资源,那么需要在这里写上 49 | # # persistentVolumeClaims 来声明使用,参数如何配置合适请咨询 SA。 50 | # # 在这里定义的 claim 都会自动创建一个同名的 volume ,不需要在 volumes 里再写了。 51 | # #(别忘了用 volumeMounts 挂载上,pod 里才能访问到) 52 | 53 | # persistentVolumeClaims: 54 | # juicefs-pvc: 55 | # accessModes: 56 | # - ReadWriteMany 57 | # resources: 58 | # requests: 59 | # storage: 1Gi 60 | # storageClassName: juicefs-sc 61 | 62 | # deployments 描述了你的应用有哪些进程 (lain 的世界里叫做 proc), 以及这些进程如何启动, 需要占用多少资源 63 | deployments: 64 | web: 65 | env: 66 | FOO: BAR 67 | # 如果你真的需要, 当然也可以在 proc 级别定义 volumeMounts, 但一般而言为了方便管理, 请尽量都放在 global level 68 | # volumeMounts: 69 | # - mountPath: /lain/app/deploy/topsecret.txt 70 | # subPath: topsecret.txt 71 | # 开发阶段建议设置为单实例, 等顺利上线了, 做生产化梳理的时候, 再视需求进行扩容 72 | replicaCount: 1 73 | # hpa 用来自动扩缩容, 也就是 HorizontalPodAutoscaler, 详见: 74 | # https://unofficial-kubernetes.readthedocs.io/en/latest/tasks/run-application/horizontal-pod-autoscale-walkthrough/ 75 | # hpa: 76 | # # 默认 minReplicas 就是 replicaCount 77 | # maxReplicas: 10 78 | # # 默认的扩容规则是 80% 的 cpu 用量 79 | # # 以下属于高级定制, 不需要的话就省略 80 | # metrics: [] # https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#metricspec-v2beta2-autoscaling 81 | # behavior: {} # https://kubernetes.io/docs/reference/generated/kubernetes-api/v1.19/#horizontalpodautoscalerbehavior-v2beta2-autoscaling 82 | # 以 hard-code 方式指定 image, 而不是用 lain build 构建出镜像 83 | # image: kibana:7.5.0 84 | # 以 hard-code 方式指定 imageTag, 相当于还是在用该应用的镜像, 只是固定了版本 85 | # imageTag: specific-version 86 | # 为了支持同一个版本反复修改构建上线, 每次部署都会重新拉取镜像 87 | # imagePullPolicy: Always 88 | # lain 默认用 1001 这个低权限用户来运行你的应用, 如需切换成其他身份, 可以在 podSecurityContext 下声明 runAsUser 89 | # 比如用 root: 90 | # podSecurityContext: {'runAsUser': 0} 91 | podSecurityContext: {} 92 | # 有需要的话, 建议在 cluster values 里进行统一超载, 一般应用空间都统一用一个 sa 吧, 便于管理 93 | # serviceAccountName: default 94 | # 优雅退出时间默认 100 秒 95 | # terminationGracePeriodSeconds: 100 96 | # resources 用于声明资源的预期用量, 以及最大用量 97 | # 如果你不熟悉你的应用的资源使用表现, 可以先拍脑袋 requests 和 limits 写成一样 98 | # 运行一段时间以后, lain lint 会依靠监控数据, 计算给出修改建议 99 | resources: 100 | limits: 101 | # 1000m 相当于 1 核 102 | # ref: https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-cpu 103 | cpu: 1000m 104 | # memory 千万不要写小 m 啊, m 是一个小的要死的单位, 写上去一定会突破容器的最低内存导致无法启动, 要写 M, Mi, G, Gi 这种才好 105 | # ref: https://kubernetes.io/docs/concepts/configuration/manage-compute-resources-container/#meaning-of-memory 106 | memory: 80Mi 107 | requests: 108 | cpu: 10m 109 | memory: 80Mi 110 | # 仅支持 exec 写法, 如果你用一个 shell 脚本作为执行入口, 可以搜索 bash, 这份模板下方会有示范 111 | command: ["/lain/app/run.py"] 112 | # 默认的工作目录是 /lain/app, 允许超载 113 | # workingDir: /lain/app 114 | # web 容器肯定要暴露端口, 对外提供服务 115 | # 这里为了书写方便, 和照顾大多数应用的习惯, 默认应用最多只需要暴露一个 TCP 端口 116 | containerPort: 5000 117 | # 如果该容器暴露了 prometheus metrics 接口的话, 则需要用 podAnnotations 来声明 118 | # 当然啦, 前提是集群里已经支持了 prometheus 119 | # podAnnotations: 120 | # prometheus.io/scrape: 'true' 121 | # prometheus.io/port: '9540' 122 | # 如果你的应用不走统一流量入口, 而是需要从上层 LB 别的端口走流量转发, 那么你需要: 123 | # * 声明 nodePort, 注意, 需要在 30000-32767 以内, Kubernetes 默认只让用大端口 124 | # * (可选地)声明 containerPort, 留空则与 nodePort 相同 125 | # * 需要联系 sa, 为这个端口特地设置一下流量转发 126 | # nodePort: 32001 127 | # protocol: TCP 128 | # 一些特殊应用可能需要使用 host network, 你就不要乱改了 129 | # hostNetwork: false 130 | # 对于需要暴露端口提供服务的容器, 一定要声明健康检查, 不会写的话请参考文档 131 | # https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-liveness-http-request 132 | # readinessProbe: 133 | # httpGet: 134 | # path: /my-healthcheck-api 135 | # port: 5000 136 | # initialDelaySeconds: 25 137 | # periodSeconds: 2 138 | # failureThreshold: 1 139 | # https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-a-liveness-command 140 | # livenessProbe: 141 | # exec: 142 | # command: 143 | # - cat 144 | # - /tmp/healthy 145 | # initialDelaySeconds: 5 146 | # periodSeconds: 5 147 | # https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/#define-startup-probes 148 | # startupProbe: 149 | # httpGet: 150 | # path: /healthz 151 | # port: liveness-port 152 | # failureThreshold: 30 153 | # periodSeconds: 10 154 | # 部署策略, 一般人当然用不到, 但若你的应用需要部署上百容器, 滚动升级的时候可能就需要微调, 否则容易产生上线拥堵, 压垮节点 155 | # https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#strategy 156 | # minReadySeconds: 10 157 | # strategy: 158 | # type: RollingUpdate 159 | # rollingUpdate: 160 | # maxSurge: 25% 161 | # maxUnavailable: 25% 162 | # 配置节点亲和性: 如果声明了 nodes, 则该进程的容器仅会在指定的节点上运行 163 | # 这个字段由于是集群相关, 所以最好拆分到 values-[CLUSTER].yaml 里, 而不是直接写在 values.yaml 164 | # 具体节点名叫什么, 你需要用 kubectl get nodes 查看, 或者咨询 sa 165 | # nodes: 166 | # - node-1 167 | # - node-2 168 | # 除了节点亲和性之外, 如果还有其他的亲和性需求, 可以在这里声明自行书写 169 | # 参考: https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/ 170 | # affinity: {} 171 | 172 | # # statefulSets 的大部分配置同 deployments, 此处仅列出有区别的部分 173 | # statefulSets: 174 | # worker: 175 | # # sts 的部署策略写法与 deploy 略有不同 176 | # # https://kubernetes.io/docs/concepts/workloads/controllers/statefulset/#update-strategies 177 | # updateStrategy: 178 | # rollingUpdate: 179 | # partition: 0 180 | # type: RollingUpdate 181 | 182 | # # jobs 会随着 lain deploy 一起创建执行 183 | # # 各种诸如 env, resources 之类的字段都支持, 如果需要的话也可以单独超载 184 | # jobs: 185 | # migration: 186 | # ttlSecondsAfterFinished: 86400 # https://kubernetes.io/docs/concepts/workloads/controllers/job/#clean-up-finished-jobs-automatically 187 | # activeDeadlineSeconds: 3600 # 超时时间, https://kubernetes.io/docs/concepts/workloads/controllers/job/#job-termination-and-cleanup 188 | # backoffLimit: 0 # https://kubernetes.io/docs/concepts/workloads/controllers/job/#pod-backoff-failure-policy 189 | # annotations: 190 | # # 一般来说, job 都会搭配 hooks 一起使用, 比方说 pre-upgrade 能保证 helm 在 upgrade 之前运行该 job, 不成功不继续进行部署 191 | # # 更多请见 https://helm.sh/docs/topics/charts_hooks/#the-available-hooks 192 | # "helm.sh/hook": post-install,pre-upgrade 193 | # "helm.sh/hook-delete-policy": before-hook-creation 194 | # command: 195 | # - 'bash' 196 | # - '-c' 197 | # - | 198 | # set -e 199 | # # 下方以数据库 migration 为例, 变更表结构之前, 先做数据库备份 200 | # # 如果需要用 jfs, 则需要在上方的 volumes / volumeMounts 里声明, 才能使用 201 | # mysqldump --default-character-set=utf8mb4 --single-transaction --set-gtid-purged=OFF -h$MYSQL_HOST -p$MYSQL_PASSWORD -u$MYSQL_USER $MYSQL_DB | gzip -c > /jfs/backup/{{ appname }}/$MYSQL_DB-backup.sql.gz 202 | # alembic upgrade heads 203 | 204 | # 上线多了, 人喜欢 deploy 完了以后看都不看一眼就溜走, 导致线上挂了无法立刻获知 205 | # 如果定义了 tests, 那么在 lain deploy 过后, 会自动执行 helm test, 失败的话立刻就能看见 206 | # 如果啥 tests 都没写, lain deploy 过后会直接进入 lain status, 你也可以肉眼看到 url 和容器状态绿灯以后, 再结束上线任务 207 | # tests: 208 | # simple-test: 209 | # image: docker.io/timfeirg/lain:latest 210 | # command: 211 | # - bash 212 | # - -ecx 213 | # - | 214 | # curl -v {{ appname }}-web 215 | 216 | # cronjob 则是 Kubernetes 管理 job 的机制, 如果你的应用需要做定时任务, 则照着这里的示范声明出来 217 | # cronjobs: 218 | # daily: 219 | # suspend: false # 暂停该 cronjob 220 | # ttlSecondsAfterFinished: 86400 # https://kubernetes.io/docs/concepts/workloads/controllers/job/#clean-up-finished-jobs-automatically 221 | # activeDeadlineSeconds: 3600 # https://kubernetes.io/docs/concepts/workloads/controllers/job/#job-termination-and-cleanup 222 | # successfulJobsHistoryLimit: 1 # 保留多少个成功执行的容器 223 | # failedJobsHistoryLimit: 1 # 运行失败的话, 保留多少个出错容器 224 | # # 书写 schedule 的时候注意时区, 不同集群采用的时区可能不一样 225 | # # 如果你不确定自己面对的集群是什么时区, 可以登录到机器上, 用 date +"%Z %z" 打印一下 226 | # schedule: "0 17 * * *" 227 | # # 默认的定时任务调度策略是 Replace, 这意味着如果上一个任务还没执行完, 下一次 job 就开始了的话, 228 | # # 则用新的 job 来替代当前运行的 job. 229 | # # 声明 cronjob 的时候, 一定要注意合理配置资源分配和调度策略, 避免拖垮集群资源 230 | # # ref: https://kubernetes.io/docs/tasks/job/automated-tasks-with-cron-jobs/#concurrency-policy 231 | # concurrencyPolicy: Replace 232 | # # 重试次数, 默认不做任何重试, 如果你的应用能保证不因为资源问题失败, 可以加上宽容的重试 233 | # backoffLimit: 0 234 | # # 与其他资源类型不同, cronjobs 默认不重复拉取镜像, 这也是为了减少开销 235 | # imagePullPolicy: IfNotPresent 236 | # resources: 237 | # limits: 238 | # cpu: 1000m 239 | # memory: 1Gi 240 | # requests: 241 | # cpu: 1000m 242 | # memory: 1Gi 243 | # command: ["python3", "manage.py", "process_daily_stats_dag"] 244 | 245 | # ingress 是 Kubernetes 的世界里负责描述域名转发规则的东西 246 | # 一个 ingress rule 描述了一个域名要转发到哪个 Kubernetes service 下边 247 | # 但是在 values.yaml 中, 已经贴心的帮你把生成 service 的细节写到 templates/service.yaml 这个模板里了 248 | # 如果你想更进一步了解 service 是什么, 可以参看模板里的注释, 以及相应的 Kubernetes 文档: 249 | # https://kubernetes.io/docs/concepts/services-networking/service/#motivation 250 | 251 | # ingresses 用来声明内网域名 252 | ingresses: 253 | # host 这个字段, 既可以写 subdomain (一般 appname), 在模板里会帮你展开成对应的集群内网域名 254 | # 也可以写完整的域名, 总之, 如果 host 里边发现了句点, 则作为完整域名处理 255 | - host: {{ appname }} 256 | # # 可以这样为该 ingress 定制 annotations 257 | # annotations: 258 | # 你想把这个域名的流量打到哪个 proc 上, 就在这里写哪个 proc 的名称 259 | deployName: web 260 | paths: 261 | - / 262 | 263 | # externalIngresses 用来声明公网域名, 但是这个字段建议你写到 {{ chart_name }}/values-[CLUSTER].yaml 里, 毕竟这属于集群特定的配置 264 | # externalIngresses: 265 | # # 这里需要写成完整的域名, 因为每个集群的公网域名都不一样, 模板不好帮你做补全 266 | # - host: [DOMAIN] 267 | # # 可以这样为该 ingress 定制 annotations 268 | # annotations: 269 | # deployName: web 270 | # paths: 271 | # - / 272 | 273 | # 添加自定义 labels 274 | labels: {} 275 | 276 | # 一般没有人需要写这里的, 但本着模板精神, 还是放一个入口 277 | # serviceAnnotations: {} 278 | 279 | # ingressAnnotations / externalIngressAnnotations 里可以声明各种额外的 nginx 配置, 比方说强制 https 跳转 280 | # 详见 https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#annotations 281 | # ingressAnnotations: 282 | # nginx.ingress.kubernetes.io/force-ssl-redirect: "true" 283 | # externalIngressAnnotations: 284 | # nginx.ingress.kubernetes.io/force-ssl-redirect: "true" 285 | # nginx.ingress.kubernetes.io/server-snippet: | 286 | # location /api/internal { 287 | # return 404; 288 | # } 289 | 290 | # # 如果你需要用到金丝雀, 则需要自己定义好金丝雀组 291 | # # 详见 https://kubernetes.github.io/ingress-nginx/user-guide/nginx-configuration/annotations/#canary 292 | # canaryGroups: 293 | # internal: 294 | # # 内部分组, 当请求传入 canary: always 的时候, 便会把流量打到金丝雀版本 295 | # nginx.ingress.kubernetes.io/canary-by-header-value: canary 296 | # small: 297 | # # 第二组赋予金丝雀版本 10% 的流量 298 | # nginx.ingress.kubernetes.io/canary-weight: '10' 299 | # big: 300 | # nginx.ingress.kubernetes.io/canary-weight: '30' 301 | 302 | # 如果你的应用不需要外网访问, 则 ingresses 这一块留空即可, 删了也没问题啦 303 | # 别的应用如果需要在集群内访问 {{ appname }}, 可以直接通过 {{ appname }}-{{ deployName }} 来访问 304 | # 只要你在 deployment 里声明了 containerPort, chart 模板就会帮你创建出免费的 service, 作为集群的内部访问域名 305 | 306 | # 注入 /etc/hosts, 需要就写 307 | hostAliases: 308 | - ip: "127.0.0.1" 309 | hostnames: 310 | - "localhost" 311 | 312 | # 变态设计, 一个应用可以给自己指定额外的 envFrom, 以引用别的应用的环境变量, 一般人用不到的 313 | # extraEnvFrom: 314 | # - secretRef: 315 | # name: another-env 316 | 317 | build: 318 | base: python:latest 319 | # # build / prepare 下都可以声明 env, 会转化为 Dockerfile 里的 ENV clause, 这样一来, 镜像本身就会携带这些 ENV 320 | # # 可以在 value 中直接引用系统 env, lain 会进行解析, 传入 docker build 命令 321 | # env: 322 | # PATH: "/lain/app/node_modules/.bin:${PATH}" 323 | prepare: 324 | # prepare 完成以后, 会删除 working directory 下所有的文件, 如果你有舍不得的东西, 记得在 keep 下声明, 才能保留下来 325 | # 比如前端项目, 一般会保留 node_modules 326 | keep: 327 | - treasure.txt 328 | script: 329 | # 凡是用包管理器安装依赖, 建议在 prepare.script 和 build.script 重复书写 330 | # 避免依赖文件更新了, 但没来得及重新 lain prepare, 导致依赖文件缺失 331 | - pip3 install -r requirements.txt 332 | - echo "treasure" > treasure.txt 333 | script: 334 | - pip3 install -r requirements.txt 335 | 336 | # # 如果你的构建和运行环境希望分离, 可以用 release 步骤来转移构建产物 337 | # # 一般是前端项目需要用到该功能, 因为构建镜像庞大, 构建产物(也就是静态文件)却很小 338 | # release: 339 | # # env: 340 | # # PATH: "/lain/app/node_modules/.bin:${PATH}" 341 | # dest_base: python:latest 342 | # copy: 343 | # - src: /etc/nginx 344 | # - src: /path 345 | # dest: /another 346 | 347 | # # 如果你是一个敏感应用, 不希望被别的容器访问, 或者你希望限制你的应用做外部访问, 那么可以用 network policy 进行限制 348 | # # ref: https://kubernetes.io/docs/concepts/services-networking/network-policies/ 349 | # networkPolicy: 350 | # # network policy 作为一项高级功能, 直接暴露完整的 network policy spec, 未做任何封装 351 | # spec: 352 | # ingress: 353 | # # 默认的规则, 允许 ingress controller 访问, 这样你的应用才能将服务暴露到集群外 354 | # - from: 355 | # - namespaceSelector: {} 356 | # podSelector: 357 | # matchLabels: 358 | # # SA 注意, 这里的 labels 可能需要根据你集群的情况进行定制 359 | # app.kubernetes.io/name: ingress-nginx 360 | # podSelector: 361 | # matchLabels: 362 | # app.kubernetes.io/name: {{ appname }} 363 | # policyTypes: 364 | # - Ingress 365 | -------------------------------------------------------------------------------- /lain_cli/cluster_values/values-test.yaml: -------------------------------------------------------------------------------- 1 | # lain_cli/cluster_values/values-test.yaml 2 | 3 | # namespace: default # k8s namespace 4 | # serviceAccountName: default 5 | 6 | # # lain 每次运行前都会检查是否最新版, 在这里配置对应的 pypi 地址 7 | # # 这里用的是 gitlab pypi registry, 你可以换成你自己喜欢的, 例如 devpi-server 8 | # pypi_index: https://gitlab.example.com/api/v4/projects/[PORJECT_ID]/packages/pypi/simple 9 | # pypi_extra_index: https://mirrors.cloud.tencent.com/pypi/simple/ 10 | 11 | # 镜像仓库 12 | registry: docker.io/timfeirg 13 | 14 | # 有一些 PaaS 提供内网镜像加速, 集群内外用的镜像 tag 不一样 15 | # internalRegistry: registry.in.example.com 16 | 17 | # # lain 整合了一系列 gitlab 相关功能, 在这里配置 gitlab 地址 18 | # gitlab: http://gitlab.example.com 19 | 20 | # 内网域名写在这里, 至于公网地址, 就不写在这里了, lain 要求公网域名完整声明在 values 里 (更加显式) 21 | domain: info 22 | 23 | secrets_env: 24 | # 调用 registry 接口的认证信息, 用途就是从 registry api 获取可供使用的镜像列表 25 | dockerhub_username: DOCKERHUB_USERNAME 26 | dockerhub_password: DOCKERHUB_PASSWORD 27 | 28 | extra_docs: | 29 | 在这里书写额外的欢迎信息 30 | 31 | # 比如 TKE 需要将 kube-apiserver 的地址写到 hosts 里, lain 会根据以下配置, 提醒用户添加相应记录到 /etc/hosts 32 | # 同时, 同样的配置还会进入 Kubernetes manifests, 因此容器里也能解析这些域名 33 | clusterHostAliases: 34 | - ip: "127.0.0.1" 35 | hostnames: 36 | - "local" 37 | 38 | # # 内外网的服务使用不同的 ingressClass 39 | # # 这样一来, 外部人士就没办法通过内网域名, 直接经由公网流量入口, 访问内部服务了 40 | # # 这些配置当然也可以写在应用级别的 chart/values-[CLUSTER].yaml 下 41 | # # 但这样一来, 每一个应用都需要重复一遍, 因此抽出放在 cluster_values 里, 加强复用 42 | # ingressClass: lain-internal 43 | # externalIngressClass: lain-external 44 | # # 这是 cert-manager 配置, 同样出于复用的考虑, 放在 cluster_values 下, 开发者可没精力维护这种基础设施配置 45 | # clusterIssuer: cert-manager-webhook-dnspod-cluster-issuer 46 | 47 | # # 填写 grafana url, 让 lain 在恰当的时候给出监控页面链接 48 | # grafana_url: https://grafana.example.com/d/xxxxx/container-monitoring 49 | # # 填写 kibana host, 让 lain 在恰当的时候给出应用日志链接 50 | # kibana: kibana.behye.cn 51 | -------------------------------------------------------------------------------- /lain_cli/harbor.py: -------------------------------------------------------------------------------- 1 | from lain_cli.utils import ( 2 | PaaSUtils, 3 | RequestClientMixin, 4 | flatten_list, 5 | tell_cluster_config, 6 | ) 7 | 8 | 9 | class HarborRegistry(RequestClientMixin, PaaSUtils): 10 | def __init__(self, registry=None, harbor_token=None, **kwargs): 11 | if not all([registry, harbor_token]): 12 | cc = tell_cluster_config() 13 | registry = cc['registry'] 14 | if 'harbor_token' not in cc: 15 | raise ValueError('harbor_token not provided in cluster config') 16 | harbor_token = cc['harbor_token'] 17 | 18 | self.registry = registry 19 | try: 20 | host, project = registry.split('/') 21 | except ValueError as e: 22 | raise ValueError(f'bad registry: {registry}') from e 23 | self.endpoint = f'http://{host}/api/v2.0' 24 | self.headers = { 25 | # get from your harbor console 26 | 'authorization': f'Basic {harbor_token}', 27 | 'accept': 'application/json', 28 | } 29 | self.project = project 30 | 31 | def request(self, *args, **kwargs): 32 | res = super().request(*args, **kwargs) 33 | responson = res.json() 34 | if not isinstance(responson, dict): 35 | return res 36 | errors = responson.get('errors') 37 | if errors: 38 | raise ValueError(f'harbor error: {errors}') 39 | return res 40 | 41 | def list_repos(self): 42 | res = self.get( 43 | f'/projects/{self.project}/repositories', params={'page_size': 100} 44 | ) 45 | responson = res.json() 46 | repos = [dic['name'].split('/')[-1] for dic in responson] 47 | return repos 48 | 49 | def list_tags(self, repo_name, **kwargs): 50 | repo_name = repo_name.split('/')[-1] 51 | res = self.get( 52 | f'/projects/{self.project}/repositories/{repo_name}/artifacts', 53 | params={'page_size': 100}, 54 | ) 55 | responson = res.json() 56 | tag_dics = flatten_list([dic['tags'] for dic in responson if dic['tags']]) 57 | tags = self.sort_and_filter( 58 | (tag['name'] for tag in tag_dics), n=kwargs.get('n') 59 | ) 60 | return tags 61 | -------------------------------------------------------------------------------- /lain_cli/kibana.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime, timedelta 2 | from time import sleep 3 | 4 | from humanfriendly import parse_timespan 5 | 6 | from lain_cli.utils import RequestClientMixin, debug, error, tell_cluster_config 7 | 8 | 9 | class Kibana(RequestClientMixin): 10 | 11 | timezone = 'Asia/Shanghai' 12 | timeout = 40 13 | 14 | def __init__(self): 15 | cc = tell_cluster_config() 16 | kibana_host = cc.get('kibana') 17 | if not kibana_host: 18 | error('kibana not configured for this cluster', exit=1) 19 | 20 | self.endpoint = f'http://{kibana_host}' 21 | self.timeout_ms = self.timeout * 1000 22 | self.headers = { 23 | 'kbn-xsrf': 'true', 24 | } 25 | 26 | def request(self, *args, **kwargs): 27 | res = super().request(*args, **kwargs) 28 | res.raise_for_status() 29 | return res 30 | 31 | @staticmethod 32 | def isoformat(dt): 33 | return f'{dt.isoformat()}Z' 34 | 35 | def count_records_for_host( 36 | self, host=None, ingress_class='lain-internal', period='7d' 37 | ): 38 | path = '/internal/search/es' 39 | start = datetime.utcnow() 40 | delta = timedelta(seconds=parse_timespan(period)) 41 | end = start - delta 42 | if ingress_class == 'lain-internal': 43 | index_pattern = 'nginx-internal-*' 44 | elif ingress_class == 'lain-external': 45 | index_pattern = 'nginx-external-*' 46 | else: 47 | raise ValueError(f'weird ingress_class: {ingress_class}') 48 | 49 | # query copied from browser 50 | query = { 51 | 'params': { 52 | 'body': { 53 | '_source': {'excludes': []}, 54 | 'aggs': { 55 | '2': { 56 | 'date_histogram': { 57 | 'field': 'ts', 58 | 'fixed_interval': '3h', 59 | 'min_doc_count': 1, 60 | 'time_zone': self.timezone, 61 | } 62 | } 63 | }, 64 | 'docvalue_fields': [ 65 | {'field': '@timestamp', 'format': 'date_time'}, 66 | {'field': 'ts', 'format': 'date_time'}, 67 | ], 68 | 'highlight': { 69 | 'fields': {'*': {}}, 70 | 'fragment_size': 2147483647, 71 | 'post_tags': ['@/kibana-highlighted-field@'], 72 | 'pre_tags': ['@kibana-highlighted-field@'], 73 | }, 74 | 'query': { 75 | 'bool': { 76 | 'filter': [ 77 | { 78 | 'range': { 79 | 'ts': { 80 | 'format': 'strict_date_optional_time', 81 | 'lte': self.isoformat(start), 82 | 'gte': self.isoformat(end), 83 | } 84 | } 85 | } 86 | ], 87 | 'must': [ 88 | { 89 | 'query_string': { 90 | 'analyze_wildcard': True, 91 | 'query': f'vhost:"{host}"', 92 | 'time_zone': self.timezone, 93 | } 94 | } 95 | ], 96 | 'must_not': [], 97 | 'should': [], 98 | } 99 | }, 100 | 'script_fields': {}, 101 | 'size': 500, 102 | 'sort': [{'ts': {'order': 'desc', 'unmapped_type': 'boolean'}}], 103 | 'stored_fields': ['*'], 104 | 'version': True, 105 | }, 106 | 'ignoreThrottled': True, 107 | 'ignore_throttled': True, 108 | 'ignore_unavailable': True, 109 | 'index': index_pattern, 110 | # https://github.com/elastic/kibana/blob/master/src/plugins/data/public/search/es_search/get_es_preference.ts#L25 111 | 'preference': None, 112 | 'rest_total_hits_as_int': True, 113 | 'timeout': f'{self.timeout_ms}ms', 114 | }, 115 | 'serverStrategy': 'es', 116 | } 117 | res = self.post(path, json=query) 118 | responson = res.json() 119 | tries = 9 120 | request_id = responson.get('id') # 没给 id 的话, 说明查询已经结束, 不用轮询结果了 121 | if not request_id: 122 | while responson['loaded'] != responson['total'] and tries: 123 | debug(f'polling kibana search results: {request_id}') 124 | sleep(3) 125 | res = self.post(path, json={'id': request_id}) 126 | responson = res.json() 127 | tries -= 1 128 | 129 | try: 130 | count = responson['rawResponse']['hits']['total'] 131 | except KeyError: 132 | return 0 133 | return count 134 | -------------------------------------------------------------------------------- /lain_cli/lint.py: -------------------------------------------------------------------------------- 1 | from lain_cli.utils import format_kubernetes_memory, parse_size 2 | 3 | # lain lint config 4 | MEMORY_FORGIVING_COEFFICIENT = 1.13 5 | # 如果你用的内存不多, 放过你 6 | MEMORY_FORGIVING_POOR_MEMORY = parse_size('256Mi', binary=True) 7 | 8 | 9 | def suggest_cpu_limits(limits): 10 | if limits < 1000: 11 | return '1000m' 12 | return False 13 | 14 | 15 | def suggest_cpu_requests(requests, top): 16 | if requests < top - 300 or requests > top + 300: 17 | suggest_str = f'{top}m' 18 | return suggest_str 19 | return False 20 | 21 | 22 | def suggest_memory_requests(requests, top): 23 | # 对于内存需求太穷的应用, 就不麻烦人家了 24 | if top < MEMORY_FORGIVING_POOR_MEMORY and requests < MEMORY_FORGIVING_POOR_MEMORY: 25 | return False 26 | if ( 27 | top * MEMORY_FORGIVING_COEFFICIENT < requests 28 | or top / MEMORY_FORGIVING_COEFFICIENT > requests 29 | ): 30 | memory_requests_suggest_str = format_kubernetes_memory(top) 31 | return memory_requests_suggest_str 32 | 33 | return False 34 | 35 | 36 | def suggest_memory_limits(limits, top, proc=None): 37 | proc = proc or {} 38 | if proc.get('replicaCount', 0) > 5: 39 | top_to_limits = 1.3 40 | margin = parse_size('50Mi', binary=True) 41 | else: 42 | top_to_limits = 2.5 43 | margin = parse_size('1Gi', binary=True) 44 | 45 | limits_suggest = top * top_to_limits 46 | if abs(limits_suggest - limits) < margin: 47 | return False 48 | limits_suggest_str = format_kubernetes_memory(limits_suggest) 49 | if ( 50 | limits_suggest * MEMORY_FORGIVING_COEFFICIENT < limits 51 | or limits_suggest / MEMORY_FORGIVING_COEFFICIENT > limits 52 | ): 53 | return limits_suggest_str 54 | return False 55 | -------------------------------------------------------------------------------- /lain_cli/prometheus.py: -------------------------------------------------------------------------------- 1 | import json 2 | from datetime import datetime, timedelta, timezone 3 | from math import ceil 4 | from statistics import StatisticsError, quantiles 5 | 6 | import click 7 | from humanfriendly import parse_timespan 8 | from requests.exceptions import ReadTimeout 9 | 10 | from lain_cli.utils import ( 11 | RequestClientMixin, 12 | context, 13 | ensure_str, 14 | error, 15 | tell_cluster_config, 16 | warn, 17 | ) 18 | 19 | 20 | class Prometheus(RequestClientMixin): 21 | timeout = 20 22 | 23 | def __init__(self, endpoint=None): 24 | if not endpoint: 25 | cc = tell_cluster_config() 26 | endpoint = cc.get('prometheus') 27 | if not endpoint: 28 | raise click.Abort(f'prometheus not provided in cluster config: {cc}') 29 | 30 | ctx = context(silent=True) 31 | self.query_range = ( 32 | ctx.obj.get('values', {}).get('prometheus_query_range', '7d') 33 | if ctx 34 | else '7d' 35 | ) 36 | self.query_step = int(int(parse_timespan(self.query_range)) / 1440) 37 | self.endpoint = endpoint 38 | 39 | @staticmethod 40 | def format_time(dt): 41 | if isinstance(dt, str): 42 | return dt 43 | return dt.isoformat() 44 | 45 | def query_cpu(self, appname, proc_name, **kwargs): 46 | cc = tell_cluster_config() 47 | query_template = cc.get('pql_template', {}).get('cpu') 48 | if not query_template: 49 | raise ValueError('pql_template.cpu not configured in cluster config') 50 | q = query_template.format( 51 | appname=appname, proc_name=proc_name, range=self.query_range 52 | ) 53 | kwargs.setdefault('step', self.query_step) 54 | kwargs['end'] = datetime.now(timezone.utc) 55 | res = self.query(q, **kwargs) 56 | return res 57 | 58 | def cpu_p95(self, appname, proc_name, **kwargs): 59 | accurate = True 60 | cpu_result = self.query_cpu(appname, proc_name) 61 | # [{'metric': {}, 'value': [1595486084.053, '4.990567343235413']}] 62 | if cpu_result: 63 | cpu_top_list = [ceil(float(p[-1])) for p in cpu_result[0]['values']] 64 | cnt = len(cpu_top_list) 65 | if cpu_top_list.count(0) / cnt > 0.7: 66 | accurate = False 67 | 68 | try: 69 | cpu_top = int(quantiles(cpu_top_list, n=10)[-1]) 70 | except StatisticsError: 71 | cpu_top = 5 72 | else: 73 | cpu_top = 5 74 | 75 | return max([cpu_top, 5]), accurate 76 | 77 | def memory_quantile(self, appname, proc_name, **kwargs): 78 | cc = tell_cluster_config() 79 | query_template = cc.get('pql_template', {}).get('memory_quantile') 80 | if not query_template: 81 | raise ValueError( 82 | 'pql_template.memory_quantile not configured in cluster config' 83 | ) 84 | q = query_template.format( 85 | appname=appname, proc_name=proc_name, range=self.query_range 86 | ) 87 | kwargs.setdefault('step', self.query_step) 88 | res = self.query(q, **kwargs) 89 | if not res: 90 | return 91 | # [{'metric': {}, 'value': [1583388354.31, '744079360']}] 92 | memory_quantile = int(float(res[0]['value'][-1])) 93 | return memory_quantile 94 | 95 | def query(self, query, start=None, end=None, step=None, timeout=20): 96 | # https://prometheus.io/docs/prometheus/latest/querying/api/#range-queries 97 | data = { 98 | 'query': query, 99 | 'timeout': timeout, 100 | } 101 | if start or end: 102 | if not start: 103 | start = end - timedelta(days=1) 104 | 105 | if not end: 106 | end = datetime.now(timezone.utc).isoformat() 107 | 108 | if not step: 109 | step = 60 110 | 111 | path = '/api/v1/query_range' 112 | data.update( 113 | { 114 | 'start': self.format_time(start), 115 | 'end': self.format_time(end), 116 | 'step': step, 117 | } 118 | ) 119 | else: 120 | path = '/api/v1/query' 121 | 122 | try: 123 | res = self.post(path, data=data) 124 | except ReadTimeout: 125 | warn('prometheus query timeout, consider using grafana instead') 126 | return [] 127 | try: 128 | responson = res.json() 129 | except json.decoder.JSONDecodeError as e: 130 | raise ValueError(f'cannot decode: {ensure_str(res.text)}') from e 131 | if responson.get('status') == 'error': 132 | err_msg = responson['error'] 133 | if 'query timed out' in err_msg: 134 | warn('prometheus query timeout, consider using grafana instead') 135 | return [] 136 | raise ValueError(err_msg) 137 | return responson['data']['result'] 138 | 139 | 140 | class Alertmanager(RequestClientMixin): 141 | """https://github.com/prometheus/alertmanager/blob/main/api/v2/openapi.yaml""" 142 | 143 | timeout = 20 144 | 145 | def __init__(self, endpoint=None): 146 | if not endpoint: 147 | cc = tell_cluster_config() 148 | endpoint = cc.get('alertmanager') 149 | if not endpoint: 150 | raise click.Abort(f'alertmanager not provided in cluster config: {cc}') 151 | 152 | self.endpoint = endpoint.rstrip('/') 153 | 154 | def post_alerts(self, labels=None): 155 | label_dic = dict(labels or ('label', 'value')) 156 | payload = [ 157 | { 158 | 'labels': label_dic, 159 | 'annotations': label_dic, 160 | 'generatorURL': f'{self.endpoint}/', 161 | }, 162 | ] 163 | res = self.post('/api/v2/alerts', json=payload) 164 | if res.status_code >= 400: 165 | error(res.text) 166 | -------------------------------------------------------------------------------- /lain_cli/prompt.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections import defaultdict 3 | from concurrent.futures import ThreadPoolExecutor, as_completed 4 | from functools import partial 5 | from operator import itemgetter 6 | from subprocess import list2cmdline 7 | 8 | import requests 9 | from cachetools import TTLCache 10 | from humanfriendly import parse_size 11 | from prompt_toolkit.application import Application 12 | from prompt_toolkit.application.current import get_app 13 | from prompt_toolkit.key_binding import KeyBindings 14 | from prompt_toolkit.layout.containers import HSplit, Window 15 | from prompt_toolkit.layout.controls import FormattedTextControl 16 | from prompt_toolkit.layout.layout import Layout 17 | 18 | from lain_cli.utils import ( 19 | DEFAULT_BACKEND_RESPONSE, 20 | context, 21 | ensure_str, 22 | get_pods, 23 | kubectl, 24 | parse_kubernetes_cpu, 25 | parse_multi_timespan, 26 | parse_podline, 27 | parse_ready, 28 | rc, 29 | tell_pod_deploy_name, 30 | tell_pods_count, 31 | tell_screen_height, 32 | template_env, 33 | ) 34 | 35 | DEFAULT_POD_TEXT = 'no weird pods found' 36 | CONTENT_VENDERER = { 37 | 'podinfo_text': '', 38 | 'ingress_text': '', 39 | 'pod_text': '', 40 | 'top_text': '', 41 | 'pods': [], 42 | 'node_text': '', 43 | } 44 | POD_WITH_EMPTY_LOG = TTLCache(ttl=20, maxsize=1024) 45 | POD_WITH_GOOD_EVENTS = TTLCache(ttl=20, maxsize=1024) 46 | 47 | 48 | def set_content(k, v): 49 | CONTENT_VENDERER[k] = v 50 | 51 | 52 | async def refresh_podinfo_text(): 53 | """podinfo can be either pod events or logs, will pick the information 54 | that's most likely to answer for the pod's bad state. 55 | 56 | e.g. 57 | * if events are good, show logs 58 | * if logs are empty, show events 59 | """ 60 | pods = CONTENT_VENDERER['pods'] 61 | if not pods: 62 | CONTENT_VENDERER['podinfo_text'] = DEFAULT_POD_TEXT 63 | return 64 | cmd = [] 65 | for podline in pods[1:]: 66 | pod_name, ready_str, status, restarts, age, *_ = parse_podline(podline) 67 | if status == 'Completed': 68 | continue 69 | age = parse_multi_timespan(age) 70 | event_cmd = [ 71 | 'get', 72 | 'events', 73 | f'--field-selector=involvedObject.name={pod_name}', 74 | ] 75 | log_cmd = ['logs', '--tail=50', f'{pod_name}'] 76 | if status in {'Pending', 'ContainerCreating'} and age > 30: 77 | cmd = event_cmd 78 | break 79 | if ( 80 | status == 'CrashLoopBackOff' 81 | or not parse_ready(ready_str) 82 | or int(restarts) > 0 83 | ): 84 | if pod_name in POD_WITH_GOOD_EVENTS: 85 | cmd = log_cmd 86 | elif pod_name in POD_WITH_EMPTY_LOG: 87 | cmd = event_cmd 88 | else: 89 | cmd = log_cmd 90 | break 91 | 92 | if cmd: 93 | res = kubectl(*cmd, capture_output=True, check=False) 94 | info = (ensure_str(res.stdout) or ensure_str(res.stderr)).strip() 95 | CONTENT_VENDERER['podinfo_text'] = info 96 | if cmd[1] == 'logs': 97 | if not info: 98 | POD_WITH_EMPTY_LOG[pod_name] = 1 99 | 100 | elif cmd[1] == 'events': 101 | latest_event = info.splitlines()[-1] 102 | # 101s Normal Started pod/xxx Started container xxx 103 | _, level, state, *_ = latest_event.split() 104 | if level == 'Normal' and state == 'Started': 105 | POD_WITH_GOOD_EVENTS[pod_name] = 1 106 | 107 | return 108 | 109 | CONTENT_VENDERER['podinfo_text'] = DEFAULT_POD_TEXT 110 | 111 | 112 | def build_app_status_command(): 113 | ctx = context() 114 | appname = ctx.obj['appname'] 115 | pod_cmd = [ 116 | 'get', 117 | 'pod', 118 | '-owide', 119 | # add this sort so that abnormal pods appear on top 120 | '--sort-by={.status.phase}', 121 | '-lapp.kubernetes.io/name={appname}', 122 | ] 123 | ctx.obj['watch_pod_command'] = pod_cmd 124 | if tell_pods_count() > 13: 125 | ctx.obj['too_many_pods'] = True 126 | ctx.obj[ 127 | 'watch_pod_title' 128 | ] = f'(digested, only showing weird pods) k {list2cmdline(pod_cmd)}' 129 | else: 130 | ctx.obj['too_many_pods'] = False 131 | ctx.obj['watch_pod_title'] = f'k {list2cmdline(pod_cmd)}' 132 | 133 | top_cmd = ['top', 'po', '-l', f'app.kubernetes.io/name={appname}'] 134 | ctx.obj['watch_top_command'] = top_cmd 135 | if ctx.obj['too_many_pods']: 136 | ctx.obj['watch_top_title'] = f'(digested) k {list2cmdline(top_cmd)}' 137 | else: 138 | ctx.obj['watch_top_title'] = f'k {list2cmdline(top_cmd)}' 139 | 140 | 141 | def pod_text(too_many_pods=None): 142 | ctx = context() 143 | appname = ctx.obj['appname'] 144 | if too_many_pods is None: 145 | too_many_pods = ctx.obj['too_many_pods'] 146 | 147 | res, pods = get_pods( 148 | appname=appname, headers=True, show_only_bad_pods=too_many_pods 149 | ) 150 | if rc(res): 151 | return ensure_str(res.stderr) 152 | CONTENT_VENDERER['pods'] = pods 153 | report = '\n'.join(pods) 154 | return report 155 | 156 | 157 | async def refresh_pod_text(): 158 | set_content('pod_text', pod_text()) 159 | 160 | 161 | async def refresh_top_text(): 162 | set_content('top_text', top_text()) 163 | 164 | 165 | def kubectl_top_digest(stdout): 166 | lines = stdout.splitlines() 167 | procs_group = defaultdict(list) 168 | for l in lines[1:]: 169 | pod_name, cpu, memory = l.split() 170 | if memory.startswith('0'): 171 | continue 172 | deploy_name = tell_pod_deploy_name(pod_name) 173 | procs_group[deploy_name].append( 174 | {'memory': parse_size(memory), 'cpu': parse_kubernetes_cpu(cpu), 'line': l} 175 | ) 176 | 177 | pods_digest = set() 178 | for pods in procs_group.values(): 179 | by_cpu = sorted(pods, key=itemgetter('cpu')) 180 | pods_digest |= {by_cpu[0]['line'], by_cpu[-1]['line']} 181 | by_mem = sorted(pods, key=itemgetter('memory')) 182 | pods_digest |= {by_mem[0]['line'], by_mem[-1]['line']} 183 | 184 | report = '\n'.join(lines[0:0] + sorted(list(pods_digest))) 185 | return report 186 | 187 | 188 | def top_text(too_many_pods=None): 189 | """display kubectl top results""" 190 | ctx = context() 191 | cmd = ctx.obj['watch_top_command'] 192 | res = kubectl(*cmd, timeout=9, capture_output=True, check=False) 193 | stdout = ensure_str(res.stdout) 194 | if too_many_pods is None: 195 | too_many_pods = ctx.obj['too_many_pods'] 196 | 197 | if stdout and too_many_pods: 198 | report = kubectl_top_digest(stdout) 199 | else: 200 | report = stdout or ensure_str(res.stderr) 201 | 202 | return report 203 | 204 | 205 | def test_url(url): 206 | try: 207 | res = requests.get(url, timeout=2) 208 | except Exception as e: 209 | return e 210 | return res 211 | 212 | 213 | ingress_text_str = '''{% for res in results %} 214 | {% if res.status is defined %} 215 | {{ res.url }} {{ res.status }} {{ res.text | brief }} 216 | {% endif %} 217 | {% endfor %} 218 | ''' 219 | ingress_text_template = template_env.from_string(ingress_text_str) 220 | 221 | 222 | async def refresh_ingress_text(): 223 | set_content('ingress_text', ingress_text()) 224 | 225 | 226 | def ingress_text(): 227 | ctx = context() 228 | urls = ctx.obj.get('urls') 229 | if not urls: 230 | return '' 231 | rl = [] 232 | results = [] 233 | 234 | def tidy_report(re): 235 | if not re.request: 236 | return '' 237 | report = {'url': re.request.url} 238 | if isinstance(re, requests.Response): 239 | report.update( 240 | { 241 | 'status': re.status_code, 242 | 'text': re.text, 243 | } 244 | ) 245 | elif isinstance(re, requests.exceptions.RequestException): 246 | report.update( 247 | { 248 | 'status': re.__class__.__name__, 249 | 'text': str(re), 250 | } 251 | ) 252 | else: 253 | raise ValueError(f'cannot process this request result: {re}') 254 | return report 255 | 256 | # why use ThreadPoolExecutor? 257 | # because we can't use loop.run_until_complete in the main thread 258 | # and why is that? 259 | # because prompt_toolkit application itself runs in a asyncio eventloop 260 | # you can't tell the current eventloop to run something for you if the 261 | # invoker itself lives in that eventloop 262 | # ref: https://bugs.python.org/issue22239 263 | with ThreadPoolExecutor(max_workers=len(urls)) as executor: 264 | for url in urls: 265 | rl.append(executor.submit(test_url, url)) 266 | 267 | for future in as_completed(rl): 268 | results.append(tidy_report(future.result())) 269 | 270 | render_ctx = {'results': sorted(results, key=itemgetter('url'))} 271 | res = ingress_text_template.render(**render_ctx) 272 | return res 273 | 274 | 275 | Win = partial(Window, wrap_lines=True) 276 | Title = partial(FormattedTextControl, style='fg:GreenYellow') 277 | 278 | 279 | async def refresh_content(): 280 | while True: 281 | await asyncio.wait( 282 | [ 283 | refresh_pod_text(), 284 | refresh_ingress_text(), 285 | refresh_podinfo_text(), 286 | refresh_top_text(), 287 | ] 288 | ) 289 | get_app().invalidate() 290 | await asyncio.sleep(0.1) 291 | 292 | 293 | def build_app_status(): 294 | ctx = context() 295 | build_app_status_command() 296 | # building pods container 297 | pod_text_control = FormattedTextControl(text=lambda: CONTENT_VENDERER['pod_text']) 298 | pod_win = Win(content=pod_text_control) 299 | pod_title = ctx.obj['watch_pod_title'] 300 | pod_container = HSplit( 301 | [ 302 | Win( 303 | height=1, 304 | content=Title(pod_title), 305 | ), 306 | pod_win, 307 | ] 308 | ) 309 | # building top container 310 | top_text_control = FormattedTextControl(text=lambda: CONTENT_VENDERER['top_text']) 311 | top_win = Win(content=top_text_control) 312 | top_title = ctx.obj['watch_top_title'] 313 | top_container = HSplit( 314 | [ 315 | Win( 316 | height=1, 317 | content=Title(top_title), 318 | ), 319 | top_win, 320 | ] 321 | ) 322 | # building podinfo container 323 | podinfo_text_control = FormattedTextControl( 324 | text=lambda: CONTENT_VENDERER['podinfo_text'] 325 | ) 326 | podinfo_window = Win(content=podinfo_text_control) 327 | podinfo_container = HSplit( 328 | [ 329 | Win( 330 | height=1, 331 | content=Title('events or logs for pod in weird states'), 332 | ), 333 | podinfo_window, 334 | ] 335 | ) 336 | parts = [pod_container, top_container, podinfo_container] 337 | # building ingress container 338 | urls = ctx.obj.get('urls') 339 | if urls: 340 | ingress_text_control = FormattedTextControl( 341 | text=lambda: CONTENT_VENDERER['ingress_text'] 342 | ) 343 | ingress_window = Win(content=ingress_text_control, height=len(urls) + 3) 344 | ingress_container = HSplit( 345 | [ 346 | Win(height=1, content=Title('url requests')), 347 | ingress_window, 348 | ] 349 | ) 350 | parts.append(ingress_container) 351 | 352 | # building root container 353 | root_container = HSplit(parts) 354 | kb = KeyBindings() 355 | 356 | @kb.add('c-c', eager=True) 357 | @kb.add('c-q', eager=True) 358 | def _(event): 359 | event.app.exit() 360 | 361 | app = Application( 362 | key_bindings=kb, 363 | layout=Layout(root_container), 364 | full_screen=True, 365 | ) 366 | app.create_background_task(refresh_content()) 367 | return app 368 | 369 | 370 | def display_app_status(): 371 | prompt_app = build_app_status() 372 | prompt_app.run() 373 | 374 | 375 | def build_cluster_status_command(): 376 | ctx = context() 377 | pod_cmd = ctx.obj['watch_bad_pod_command'] = [ 378 | 'get', 379 | 'po', 380 | '--all-namespaces', 381 | '-owide', 382 | ] 383 | ctx.obj['watch_bad_pod_title'] = f'k {list2cmdline(pod_cmd)}' 384 | 385 | 386 | async def refresh_bad_pod_text(): 387 | res, pods = get_pods(headers=True, show_only_bad_pods=True) 388 | set_content('pod_text', '\n'.join(pods) or ensure_str(res.stderr)) 389 | 390 | 391 | def bad_node_text(): 392 | ctx = context() 393 | cmd = ctx.obj['watch_node_command'] = ['get', 'node', '--no-headers'] 394 | res = kubectl(*cmd, timeout=2, capture_output=True, check=False) 395 | if rc(res): 396 | return ensure_str(res.stderr) 397 | all_nodes = ensure_str(res.stdout) 398 | bad_nodes = [line for line in all_nodes.splitlines() if ' Ready ' not in line] 399 | return '\n'.join(bad_nodes) 400 | 401 | 402 | async def refresh_bad_node_text(): 403 | set_content('node_text', bad_node_text()) 404 | 405 | 406 | async def refresh_global_ingress_text(): 407 | set_content('ingress_text', global_ingress_text()) 408 | 409 | 410 | def global_ingress_text(): 411 | ctx = context() 412 | global_urls = ctx.obj['global_urls'] 413 | if not global_urls: 414 | return '' 415 | rl = [] 416 | results = [] 417 | 418 | def tidy_report(re): 419 | if not re.request: 420 | return '' 421 | report = {'url': re.request.url} 422 | if isinstance(re, requests.Response): 423 | code = re.status_code 424 | if code >= 502 or ( 425 | code == 404 and re.text.strip() == DEFAULT_BACKEND_RESPONSE 426 | ): 427 | report.update( 428 | { 429 | 'status': re.status_code, 430 | 'text': re.text, 431 | } 432 | ) 433 | elif isinstance(re, requests.exceptions.RequestException): 434 | report.update( 435 | { 436 | 'status': re.__class__.__name__, 437 | 'text': str(re), 438 | } 439 | ) 440 | else: 441 | raise ValueError(f'cannot process this request result: {re}') 442 | return report 443 | 444 | simple = ctx.obj.get('simple') 445 | with ThreadPoolExecutor(max_workers=len(global_urls)) as executor: 446 | for url in global_urls: 447 | rl.append(executor.submit(test_url, url)) 448 | 449 | for future in as_completed(rl): 450 | single_report = tidy_report(future.result()) 451 | if not single_report or not single_report.get('status'): 452 | continue 453 | if simple or len(results) < tell_screen_height(0.4): 454 | results.append(single_report) 455 | 456 | render_ctx = {'results': sorted(results, key=itemgetter('url'))} 457 | res = ingress_text_template.render(**render_ctx) 458 | return res 459 | 460 | 461 | async def refresh_admin_content(): 462 | while True: 463 | await asyncio.wait( 464 | [ 465 | refresh_bad_pod_text(), 466 | refresh_bad_node_text(), 467 | refresh_global_ingress_text(), 468 | ] 469 | ) 470 | get_app().invalidate() 471 | await asyncio.sleep(0.1) 472 | 473 | 474 | def build_cluster_status(): 475 | ctx = context() 476 | build_cluster_status_command() 477 | # building pods container 478 | bad_pod_text_control = FormattedTextControl( 479 | text=lambda: CONTENT_VENDERER['pod_text'] 480 | ) 481 | bad_pod_win = Win(content=bad_pod_text_control) 482 | bad_pod_title = ctx.obj['watch_bad_pod_title'] 483 | bad_pod_container = HSplit( 484 | [ 485 | Win( 486 | height=1, 487 | content=Title(bad_pod_title), 488 | ), 489 | bad_pod_win, 490 | ] 491 | ) 492 | # building nodes container 493 | bad_node_text_control = FormattedTextControl( 494 | text=lambda: CONTENT_VENDERER['node_text'] 495 | ) 496 | bad_node_window = Win(content=bad_node_text_control) 497 | bad_node_container = HSplit( 498 | [ 499 | Win( 500 | height=1, 501 | content=Title('bad nodes'), 502 | ), 503 | bad_node_window, 504 | ] 505 | ) 506 | parts = [bad_pod_container, bad_node_container] 507 | global_urls = ctx.obj.get('global_urls') 508 | if global_urls: 509 | ingress_text_control = FormattedTextControl( 510 | text=lambda: CONTENT_VENDERER['ingress_text'] 511 | ) 512 | ingress_window = Win( 513 | content=ingress_text_control, height=lambda: tell_screen_height(0.4) 514 | ) 515 | ingress_container = HSplit( 516 | [ 517 | Win(height=1, content=Title('bad url requests')), 518 | ingress_window, 519 | ] 520 | ) 521 | parts.append(ingress_container) 522 | 523 | # building root container 524 | root_container = HSplit(parts) 525 | kb = KeyBindings() 526 | 527 | @kb.add('c-c', eager=True) 528 | @kb.add('c-q', eager=True) 529 | def _(event): 530 | event.app.exit() 531 | 532 | app = Application( 533 | key_bindings=kb, 534 | layout=Layout(root_container), 535 | full_screen=True, 536 | ) 537 | app.create_background_task(refresh_admin_content()) 538 | return app 539 | 540 | 541 | def display_cluster_status(): 542 | prompt_app = build_cluster_status() 543 | prompt_app.run() 544 | -------------------------------------------------------------------------------- /lain_cli/registry.py: -------------------------------------------------------------------------------- 1 | from json.decoder import JSONDecodeError 2 | 3 | import requests 4 | from tenacity import retry, stop_after_attempt, wait_fixed 5 | 6 | from lain_cli.utils import PaaSUtils, RequestClientMixin, tell_cluster_config 7 | 8 | 9 | class Registry(RequestClientMixin, PaaSUtils): 10 | headers = {'Accept': 'application/vnd.docker.distribution.manifest.v2+json'} 11 | 12 | def __init__(self, registry=None, **kwargs): 13 | if not registry: 14 | cc = tell_cluster_config() 15 | registry = cc['registry'] 16 | 17 | self.registry = registry 18 | if '/' in registry: 19 | host, self.namespace = registry.split('/') 20 | else: 21 | host = registry 22 | self.namespace = '' 23 | 24 | if host == 'docker.io': 25 | api_host = 'index.docker.io' 26 | self.endpoint = f'http://{api_host}' 27 | else: 28 | self.endpoint = f'http://{registry}' 29 | 30 | self.dockerhub_password = kwargs.get('dockerhub_password') 31 | self.dockerhub_username = kwargs.get('dockerhub_username') 32 | 33 | def prepare_token(self, scope): 34 | if not all([self.dockerhub_password, self.dockerhub_username]): 35 | return 36 | res = requests.post( 37 | 'https://auth.docker.io/token', 38 | data={ 39 | 'grant_type': 'password', 40 | 'service': 'registry.docker.io', 41 | 'scope': scope, 42 | 'client_id': 'dockerengine', 43 | 'username': self.dockerhub_username, 44 | 'password': self.dockerhub_password, 45 | }, 46 | ) 47 | access_token = res.json()['access_token'] 48 | self.headers['Authorization'] = f'Bearer {access_token}' 49 | 50 | def request(self, *args, **kwargs): 51 | res = super().request(*args, **kwargs) 52 | try: 53 | responson = res.json() 54 | except JSONDecodeError as e: 55 | raise ValueError(f'bad registry response: {res.text}') from e 56 | if not isinstance(responson, dict): 57 | return res 58 | errors = responson.get('errors') 59 | if errors: 60 | raise ValueError(f'registry error: headers {res.headers}, errors {errors}') 61 | return res 62 | 63 | def list_repos(self): 64 | path = '/v2/_catalog' 65 | responson = self.get(path, params={'n': 9999}, timeout=90).json() 66 | return responson.get('repositories', []) 67 | 68 | @retry(reraise=True, wait=wait_fixed(2), stop=stop_after_attempt(6)) 69 | def delete_image(self, repo, tag=None): 70 | path = f'/v2/{repo}/manifests/{tag}' 71 | headers = self.head(path).headers 72 | docker_content_digest = headers.get('Docker-Content-Digest') 73 | if not docker_content_digest: 74 | return 75 | path = f'/v2/{repo}/manifests/{docker_content_digest}' 76 | return self.delete(path, timeout=20) # 不知道为啥删除操作就是很慢, 只好在这里单独放宽 77 | 78 | def list_tags(self, repo_name, n=None, timeout=90): 79 | repo = f'{self.namespace}/{repo_name}' if self.namespace else repo_name 80 | path = f'/v2/{repo}/tags/list' 81 | self.prepare_token(scope=f'repository:{repo}:pull,push') 82 | responson = self.get(path, params={'n': 99999}, timeout=timeout).json() 83 | if 'tags' not in responson: 84 | return [] 85 | tags = self.sort_and_filter(responson.get('tags') or [], n=n) 86 | return tags 87 | -------------------------------------------------------------------------------- /lain_cli/scm.py: -------------------------------------------------------------------------------- 1 | from random import choices 2 | 3 | import gitlab 4 | 5 | from lain_cli.utils import error, must_get_env, tell_cluster_config, warn 6 | 7 | 8 | def tell_scm(): 9 | cc = tell_cluster_config() 10 | endpoint = cc.get('gitlab') 11 | if not endpoint: 12 | error('gitlab not configured in cluster config', exit=1) 13 | 14 | token = must_get_env( 15 | 'GITLAB_API_TOKEN', 16 | f'get your own token at {endpoint}/-/profile/personal_access_tokens', 17 | ) 18 | return GitLabSCM(endpoint, token) 19 | 20 | 21 | class GitLabSCM: 22 | def __init__(self, endpoint, token): 23 | self.endpoint = endpoint.rstrip('/') 24 | self.gl = gitlab.Gitlab(self.endpoint, private_token=token) 25 | 26 | def is_approved(self, project_path, mr_id): 27 | pj = self.gl.projects.get(project_path) 28 | mr = pj.mergerequests.get(mr_id) 29 | approvals = mr.approvals.get() 30 | return approvals.approved 31 | 32 | @staticmethod 33 | def is_active(u): 34 | if not u: 35 | return False 36 | if u.get('state') != 'active': 37 | return False 38 | return True 39 | 40 | def assign_mr(self, project_path, mr_id): 41 | pj = self.gl.projects.get(project_path) 42 | mr = pj.mergerequests.get(mr_id) 43 | reviewers = mr.reviewers 44 | assignee = mr.assignee 45 | if reviewers or assignee: 46 | if self.is_active(reviewers[0]) and self.is_active(assignee): 47 | warn(f'already assigned to {assignee}, reviewer {reviewers}') 48 | return 49 | contributors = pj.repository_contributors() 50 | contributors_names = set() 51 | for c in contributors: 52 | contributors_names.add(c['name']) 53 | contributors_names.add(c['email']) 54 | 55 | author = mr.author 56 | author_names = {author['name'], author['username']} 57 | candidates = [] 58 | 59 | def add_attr(s, model, attr): 60 | if hasattr(model, attr): 61 | s.add(getattr(model, attr)) 62 | 63 | for user in pj.users.list(all=True): 64 | if user.state != 'active': 65 | continue 66 | user_names = set() 67 | user_names.add(user.name) 68 | user_names.add(user.username) 69 | add_attr(user_names, user, 'email') 70 | add_attr(user_names, user, 'commit_email') 71 | add_attr(user_names, user, 'public_email') 72 | if user_names.intersection(author_names): 73 | # author will not be his own reviewers 74 | continue 75 | if user_names.intersection(contributors_names): 76 | candidates.append(user) 77 | 78 | chosen = choices(candidates, k=2) 79 | # https://forge.extranet.logilab.fr/open-source/assignbot/-/blob/branch/default/assignbot/__main__.py#L173 80 | return self.gl.http_put( 81 | f'/projects/{pj.id}/merge_requests/{mr_id}', 82 | query_data={'reviewer_ids': chosen[0].id, 'assignee_ids': chosen[1].id}, 83 | ) 84 | -------------------------------------------------------------------------------- /lain_cli/templates/.dockerignore.j2: -------------------------------------------------------------------------------- 1 | **/.git 2 | # converted from .gitignore: 3 | {% for line in git_ignores %} 4 | {{ line }} 5 | {% endfor %} 6 | -------------------------------------------------------------------------------- /lain_cli/templates/Dockerfile.j2: -------------------------------------------------------------------------------- 1 | {% if values.build.prepare and build_stage == 'prepare' %} 2 | FROM {{ values.build.base }} AS prepare 3 | WORKDIR {{ values.build.workdir }} 4 | ADD --chown=1001:1001 . {{ values.build.workdir }} 5 | 6 | {% if values.build_args %} 7 | {% for a in values.build_args %} 8 | ARG {{ a }} 9 | {% endfor %} 10 | {% endif %} 11 | 12 | {% if values.build.prepare.env %} 13 | ENV {% for k, v in values.build.prepare.env.items() %}{{ k }}={{ v | to_json }} {% endfor %} 14 | {% endif %} 15 | 16 | {% for l in values.build.prepare.script %} 17 | RUN {{ l }} 18 | {% endfor %} 19 | 20 | RUN find . -type f {% if values.build.prepare.keep %}{% for k in values.build.prepare.keep %}{% if not loop.first %} -and {% endif %} -not -path '{{ k }}' -and -not -path '{{ k }}/*' {% endfor %}{% endif %} -delete 21 | {% endif %} 22 | 23 | {% if values.build.prepare %} 24 | FROM {{ cluster_config.registry }}/{{ appname }}:prepare AS build 25 | {% else %} 26 | FROM {{ values.build.base }} AS build 27 | {% endif %} 28 | WORKDIR {{ values.build.workdir }} 29 | 30 | {% if values.build_args %} 31 | {% for a in values.build_args %} 32 | ARG {{ a }} 33 | {% endfor %} 34 | {% endif %} 35 | 36 | {% if values.build.env %} 37 | ENV {% for k, v in values.build.env.items() %}{{ k }}={{ v | to_json }} {% endfor %} 38 | {% endif %} 39 | 40 | {% if lain_meta %} 41 | ENV LAIN_META={{ lain_meta }} 42 | {% endif %} 43 | 44 | ADD --chown=1001:1001 . {{ values.build.workdir }} 45 | {% if values.build.script %} 46 | RUN ({{ ') && ('.join(values.build.script) }}) 47 | {% endif %} 48 | 49 | {% if values.release %} 50 | FROM {{ values.release.dest_base }} AS release 51 | WORKDIR {{ values.release.workdir }} 52 | {% if lain_meta %} 53 | ENV LAIN_META={{ lain_meta }} 54 | {% endif %} 55 | 56 | {% for copy in values.release['copy'] %} 57 | COPY --chown=1001:1001 --from=build {{ copy.src }} {{ copy.dest }} 58 | {% endfor %} 59 | 60 | {% if values.release.env %} 61 | ENV {% for k, v in values.release.env.items() %}{{ k }}={{ v | to_json }} {% endfor %} 62 | {% endif %} 63 | 64 | {% if values.release.script %} 65 | RUN ({{ ') && ('.join(values.release.script) }}) 66 | {% endif %} 67 | {% endif %} 68 | 69 | USER 1001 70 | -------------------------------------------------------------------------------- /lain_cli/templates/canary-toast.txt.j2: -------------------------------------------------------------------------------- 1 | canary version has been deployed. 2 | {% if values.canaryGroups %} 3 | use the following commands to feed traffic 4 | {% for canary_group in values.canaryGroups %} 5 | lain set-canary-group {{ canary_group }} 6 | {%- endfor %} 7 | {% else %} 8 | values.canaryGroups is undefined, you should declare them in values.yaml 9 | {% endif %} 10 | to abort canary deploy: 11 | lain set-canary-group --abort 12 | to accept canary version: 13 | lain set-canary-group --final 14 | -------------------------------------------------------------------------------- /lain_cli/templates/deploy-toast.txt.j2: -------------------------------------------------------------------------------- 1 | your pods have all been created, you can see them using: 2 | lain status 3 | {%- if urls %} 4 | 5 | to access your app through internal domain: 6 | {% for url in urls %} 7 | {{ url }} 8 | {%- endfor %} 9 | {% endif %} 10 | 11 | to tail logs: 12 | {% for deploy_name in values.deployments %} 13 | lain logs {{ deploy_name }} 14 | kubectl logs -f --tail 10 -l app.kubernetes.io/instance={{ values.releaseName | default(appname) }}-{{ deploy_name }} 15 | {% if loop.index >= 1 %} 16 | ... 17 | {%- break %} 18 | {%- endif %} 19 | {%- endfor %} 20 | 21 | {%- if 'cronjobs' in values and values.cronjobs %} 22 | to test your cronjob: 23 | {%- for job_name in values.cronjobs.keys() %} 24 | lain create-job {{ job_name }} 25 | {% if loop.index >= 2 %} 26 | ... 27 | {% break %} 28 | {% endif %} 29 | {%- endfor %} 30 | {%- endif %} 31 | 32 | {%- if grafana_url %} 33 | 34 | use grafana for monitoring: 35 | {{ grafana_url }} 36 | {%- endif %} 37 | {%- if kibana %} 38 | 39 | kibana, for log output and analysing: 40 | {{ kibana_url }} 41 | {%- endif %} 42 | -------------------------------------------------------------------------------- /lain_cli/templates/deploy-webhook-message.txt.j2: -------------------------------------------------------------------------------- 1 | {% if rollback_revision %} 2 | app rollback to revision: {{ rollback_revision }} 3 | {% endif %} 4 | {% if stderr %} 5 | status: failed! 6 | {% endif %} 7 | cluster: {{ cluster }} 8 | release_name: {{ release_name | default(appname) }} 9 | executor: {{ executor }} 10 | version: {{ image_tag }} 11 | commit message: {{ commit_msg }} 12 | {% if stderr %} 13 | stderr: {{ stderr }} 14 | {% endif %} 15 | {% if cherry %} 16 | cherry: 17 | {{ cherry }} 18 | {% endif %} 19 | -------------------------------------------------------------------------------- /lain_cli/templates/docker-compose.yaml.j2: -------------------------------------------------------------------------------- 1 | # ref: https://github.com/compose-spec/compose-spec/blob/master/spec.md 2 | version: '3' 3 | services: 4 | {% for proc_name, proc in values.deployments.items() %} 5 | 6 | {{ proc_name }}: 7 | {% if proc.image %} 8 | image: {{ proc.image }} 9 | {% elif proc.imageTag %} 10 | image: {{ cluster_config['registry'] }}/{{ appname }}:{{ proc.imageTag }} 11 | {% else %} 12 | # lain push will overwrite the latest tag every time 13 | image: {{ cluster_config['registry'] }}/{{ appname }}:latest 14 | pull_policy: always 15 | {% endif %} 16 | command: 17 | {{ proc.command | to_yaml | indent(6) }} 18 | volumes: 19 | - .:{{ proc.working_dir | default('/lain/app') }} 20 | {% if values.env or proc.env %} 21 | environment: 22 | {% if values.env %} 23 | {{ values.env | to_yaml | indent(6) }} 24 | {%- endif %} 25 | {% if proc.env %} 26 | {{ proc.env | to_yaml | indent(6) }} 27 | {%- endif %} 28 | {%- endif %} 29 | working_dir: {{ proc.working_dir | default('/lain/app') }} 30 | # depends_on: 31 | # - redis 32 | # - mysql 33 | {% endfor %} 34 | 35 | # redis: 36 | # image: "redis:3.2.7" 37 | # command: --databases 64 38 | 39 | # mysql: 40 | # image: "mysql:8" 41 | # command: --character-set-server=utf8mb4 --collation-server=utf8mb4_general_ci 42 | # environment: 43 | # MYSQL_ROOT_PASSWORD: root 44 | # MYSQL_DATABASE: {{ appname }} 45 | -------------------------------------------------------------------------------- /lain_cli/templates/job.yaml.j2: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: batch/v1 3 | kind: Job 4 | metadata: 5 | name: {{ job_name }} 6 | labels: 7 | helm.sh/chart: {% if values is defined %}{{ values.releaseName | default(appname) }}{% else %}{{ appname }}{% endif %} 8 | 9 | app.kubernetes.io/name: {{ appname }} 10 | app.kubernetes.io/managed-by: Helm 11 | spec: 12 | backoffLimit: 0 13 | activeDeadlineSeconds: {{ timeout | default(86400) }} 14 | ttlSecondsAfterFinished: 86400 15 | template: 16 | metadata: 17 | labels: 18 | app.kubernetes.io/instance: {{ job_name }} 19 | app.kubernetes.io/name: {{ appname }} 20 | spec: 21 | {% if cluster_config.serviceAccountName %} 22 | serviceAccountName: {{ cluster_config.serviceAccountName }} 23 | {% endif %} 24 | {% if user is not none %} 25 | securityContext: 26 | runAsUser: {{ user }} 27 | {% endif %} 28 | containers: 29 | - name: {{ job_name }} 30 | image: {{ image }} 31 | {% if appname != 'lain' %} 32 | envFrom: 33 | - secretRef: 34 | name: {{ appname }}-env 35 | {% endif %} 36 | env: 37 | {{ env | default([]) | to_yaml | indent(12) }} 38 | volumeMounts: 39 | {{ volumeMounts | default([]) | to_yaml | indent(12) }} 40 | resources: 41 | limits: 42 | cpu: 4000m 43 | memory: {{ memory }} 44 | requests: 45 | cpu: 1 46 | memory: 1Gi 47 | command: 48 | {{ command | to_yaml | indent(12) }} 49 | volumes: 50 | {{ volumes | default([]) | to_yaml | indent(8) }} 51 | hostAliases: 52 | {% if hostAliases is defined %} 53 | {{ hostAliases | to_yaml | indent(8) }} 54 | {% endif %} 55 | restartPolicy: Never 56 | -------------------------------------------------------------------------------- /lain_cli/templates/k8s-secret-diff.txt.j2: -------------------------------------------------------------------------------- 1 | cluster: {{ cluster }} 2 | executor: {{ executor }} 3 | changed secret: {{ secret_name }} 4 | {% if added %} 5 | keys added: {{ added }} 6 | {% endif %} 7 | {% if removed %} 8 | keys removed: {{ removed }} 9 | {% endif %} 10 | {% if changed %} 11 | keys changed: {{ changed }} 12 | {% endif %} 13 | -------------------------------------------------------------------------------- /lain_cli/templates/values-canary.yaml.j2: -------------------------------------------------------------------------------- 1 | releaseName: {{ values.releaseName }} 2 | ingressAnnotations: 3 | nginx.ingress.kubernetes.io/canary: "true" 4 | externalIngressAnnotations: 5 | nginx.ingress.kubernetes.io/canary: "true" 6 | -------------------------------------------------------------------------------- /lain_cli/tencent.py: -------------------------------------------------------------------------------- 1 | from tenacity import retry, retry_if_exception_type, stop_after_attempt, wait_fixed 2 | from tencentcloud.common import credential 3 | from tencentcloud.common.exception.tencent_cloud_sdk_exception import ( 4 | TencentCloudSDKException, 5 | ) 6 | from tencentcloud.cvm.v20170312 import cvm_client 7 | from tencentcloud.cvm.v20170312 import models as cvm_models 8 | from tencentcloud.tcr.v20190924 import models as tcr_models 9 | from tencentcloud.tcr.v20190924.tcr_client import TcrClient 10 | 11 | from lain_cli.utils import ( 12 | PaaSUtils, 13 | debug, 14 | error, 15 | jalo, 16 | tell_cluster_config, 17 | warn, 18 | ) 19 | 20 | 21 | class TencentPaaS(PaaSUtils): 22 | 23 | """https://cloud.tencent.com/document/product/1141/41605""" 24 | 25 | VM_STATES = {'on', 'off'} 26 | 27 | def __init__( 28 | self, registry=None, access_key_id=None, access_key_secret=None, **kwargs 29 | ): 30 | if not all([registry, access_key_id, access_key_secret]): 31 | cc = tell_cluster_config() 32 | registry = cc['registry'] 33 | access_key_id = cc.get('access_key_id') 34 | access_key_secret = cc.get('access_key_secret') 35 | 36 | self.registry = registry 37 | self.repo_namespace = registry.split('/')[-1] 38 | if not all([access_key_id, access_key_secret]): 39 | raise ValueError( 40 | 'access_key_id, access_key_secret not provided in cluster config' 41 | ) 42 | 43 | self.cred = credential.Credential(access_key_id, access_key_secret) 44 | self.cvm_client = cvm_client.CvmClient(self.cred, "ap-beijing") 45 | self.tcr_client = TcrClient(self.cred, "ap-beijing") 46 | 47 | def list_repos(self): 48 | req = tcr_models.DescribeImagePersonalRequest() 49 | req.Limit = 100 50 | try: 51 | responson = jalo( 52 | self.tcr_client.DescribeRepositoryOwnerPersonal(req).to_json_string() 53 | ) 54 | except TencentCloudSDKException as e: 55 | if e.code == 'AuthFailure.SignatureExpire': 56 | raise 57 | return None 58 | repo_info = responson['Data']['RepoInfo'] 59 | repos = [dic['RepoName'] for dic in repo_info] 60 | return repos 61 | 62 | def list_tags(self, repo_name, **kwargs): 63 | req = tcr_models.DescribeImagePersonalRequest() 64 | req.RepoName = f'{self.repo_namespace}/{repo_name}' 65 | req.Limit = 100 66 | try: 67 | responson = jalo( 68 | self.tcr_client.DescribeImagePersonal(req).to_json_string() 69 | ) 70 | except TencentCloudSDKException as e: 71 | if e.code == 'AuthFailure.SignatureExpire': 72 | raise 73 | return None 74 | tags = self.sort_and_filter( 75 | (dic['TagName'] for dic in responson['Data']['TagInfo']), n=kwargs.get('n') 76 | ) 77 | return tags 78 | 79 | @retry( 80 | reraise=True, 81 | wait=wait_fixed(2), 82 | stop=stop_after_attempt(60), 83 | retry=retry_if_exception_type(TencentCloudSDKException), 84 | ) 85 | def turn_(self, InstanceIds=None, cluster=None, state: str = 'on'): 86 | if not InstanceIds: 87 | InstanceIds = tell_cluster_config(cluster).get('instance_ids') 88 | 89 | if not InstanceIds: 90 | warn('instance_ids not defined in cluster info, cannot proceed', exit=1) 91 | 92 | for id_ in InstanceIds: 93 | ids = [id_] 94 | if state.lower() == 'off': 95 | req = cvm_models.StopInstancesRequest() 96 | req.InstanceIds = ids 97 | req.ForceStop = True 98 | req.StoppedMode = 'STOP_CHARGING' 99 | try: 100 | self.cvm_client.StopInstances(req) 101 | except TencentCloudSDKException as e: 102 | if e.code == 'UnauthorizedOperation': 103 | error(f'weird error: {e.code}', exit=True) 104 | if e.code != 'InvalidInstanceState.Stopped': 105 | debug(f'retry due to {e.code}') 106 | raise 107 | elif state.lower() == 'on': 108 | req = cvm_models.StartInstancesRequest() 109 | req.InstanceIds = ids 110 | try: 111 | self.cvm_client.StartInstances(req) 112 | except TencentCloudSDKException as e: 113 | if e.code not in { 114 | 'InvalidInstanceState.Running', 115 | 'UnsupportedOperation.InstanceStateRunning', 116 | }: 117 | debug(f'retry due to {e.code}') 118 | raise 119 | else: 120 | error(f'weird state {state}, choose from {self.VM_STATES}', exit=True) 121 | 122 | 123 | TencentRegistry = TencentPaaS 124 | -------------------------------------------------------------------------------- /lain_cli/webhook.py: -------------------------------------------------------------------------------- 1 | from inspect import cleandoc 2 | from urllib.parse import urlparse 3 | 4 | from tenacity import retry, stop_after_attempt, wait_fixed 5 | 6 | from lain_cli.utils import ( 7 | RequestClientMixin, 8 | context, 9 | diff_dict, 10 | ensure_str, 11 | git, 12 | rc, 13 | tell_cherry, 14 | tell_executor, 15 | template_env, 16 | ) 17 | 18 | 19 | def tell_webhook_client(hook_url=None): 20 | ctx = context() 21 | obj = ctx.obj 22 | config = obj.get('values', {}).get('webhook', {}) 23 | hook_url = hook_url or config.get('url') 24 | if not hook_url: 25 | return 26 | clusters_to_notify = config.pop('clusters', None) or set() 27 | cluster = obj['cluster'] 28 | if clusters_to_notify and cluster not in clusters_to_notify: 29 | return 30 | pr = urlparse(hook_url) 31 | if pr.netloc == 'open.feishu.cn': 32 | return FeishuWebhook(hook_url, **config) 33 | if pr.netloc == 'hooks.slack.com': 34 | return SlackIncomingWebhook(hook_url, **config) 35 | raise NotImplementedError(f'webhook not implemented for {hook_url}') 36 | 37 | 38 | class Webhook(RequestClientMixin): 39 | 40 | endpoint = None 41 | deploy_message_template = template_env.get_template('deploy-webhook-message.txt.j2') 42 | k8s_secret_diff_template = template_env.get_template('k8s-secret-diff.txt.j2') 43 | 44 | def __init__(self, endpoint=None, **kwargs): 45 | self.endpoint = endpoint 46 | 47 | def send_msg(self, msg): 48 | raise NotImplementedError 49 | 50 | def diff_k8s_secret(self, old, new): 51 | secret_name = old['metadata']['name'] 52 | diff = diff_dict(old['data'], new['data']) 53 | if not sum(len(l) for l in diff.values()): 54 | # do not send notification on empty diff 55 | return 56 | ctx = context() 57 | report = self.k8s_secret_diff_template.render( 58 | secret_name=secret_name, 59 | executor=tell_executor(), 60 | cluster=ctx.obj['cluster'], 61 | **diff, 62 | ) 63 | return self.send_msg(report) 64 | 65 | def send_deploy_message( 66 | self, stderr=None, rollback_revision=None, previous_revision=None 67 | ): 68 | ctx = context() 69 | obj = ctx.obj 70 | git_revision = obj.get('git_revision') 71 | if git_revision: 72 | res = git( 73 | 'log', 74 | '-n', 75 | '1', 76 | '--pretty=format:%s', 77 | git_revision, 78 | check=False, 79 | capture_output=True, 80 | ) 81 | if rc(res): 82 | commit_msg = ensure_str(res.stderr) 83 | else: 84 | commit_msg = ensure_str(res.stdout) 85 | else: 86 | commit_msg = 'N/A' 87 | 88 | if previous_revision: 89 | cherry = tell_cherry(git_revision=previous_revision, capture_output=True) 90 | else: 91 | cherry = '' 92 | 93 | executor = tell_executor() 94 | text = self.deploy_message_template.render( 95 | executor=executor, 96 | commit_msg=commit_msg, 97 | stderr=stderr, 98 | cherry=cherry, 99 | rollback_revision=rollback_revision, 100 | **ctx.obj, 101 | ) 102 | return self.send_msg(text) 103 | 104 | 105 | class FeishuWebhook(Webhook): 106 | @retry(reraise=True, wait=wait_fixed(2), stop=stop_after_attempt(6)) 107 | def send_msg(self, msg): 108 | payload = { 109 | 'msg_type': 'text', 110 | 'content': { 111 | 'text': cleandoc(msg), 112 | }, 113 | } 114 | return self.post(json=payload) 115 | 116 | 117 | class SlackIncomingWebhook(Webhook): 118 | def __init__(self, endpoint=None, **kwargs): 119 | super().__init__(endpoint=endpoint, **kwargs) 120 | channel = kwargs.get('channel') 121 | if not channel: 122 | raise ValueError( 123 | 'must define webhook.channel when using SlackIncomingWebhook' 124 | ) 125 | self.channel = channel 126 | 127 | @retry(reraise=True, wait=wait_fixed(2), stop=stop_after_attempt(6)) 128 | def send_msg(self, msg): 129 | payload = { 130 | 'channel': self.channel, 131 | 'text': msg, 132 | } 133 | return self.post(json=payload) 134 | -------------------------------------------------------------------------------- /pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | init-hook='import sys; sys.path.append("lain_cli")' 3 | 4 | [MESSAGES CONTROL] 5 | disable= 6 | too-many-statements, 7 | too-many-locals, 8 | too-many-lines, 9 | too-many-arguments, 10 | too-many-public-methods, 11 | too-many-branches, 12 | too-many-instance-attributes, 13 | missing-module-docstring, 14 | missing-function-docstring, 15 | missing-class-docstring, 16 | consider-using-with, 17 | unsubscriptable-object, 18 | function-redefined, 19 | unexpected-keyword-arg, 20 | no-value-for-parameter, 21 | arguments-differ, 22 | subprocess-run-check, 23 | duplicate-code, 24 | line-too-long, 25 | redefined-outer-name, 26 | expression-not-assigned, 27 | import-outside-toplevel, 28 | inconsistent-return-statements, 29 | invalid-name, 30 | signature-differs, 31 | cyclic-import, 32 | broad-except, 33 | unused-argument, 34 | unused-variable, 35 | redefined-builtin, 36 | no-self-use, 37 | too-few-public-methods, 38 | abstract-class-instantiated, 39 | unspecified-encoding, 40 | abstract-method, 41 | bad-mcs-classmethod-argument, 42 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.vulture] 2 | ignore_decorators = ["@*.command"] 3 | exclude = [".ropeproject/"] 4 | ignore_names = ["_"] 5 | min_confidence = 80 6 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --capture=no --ignore=tests/dummy --ignore=tests/editor.py --doctest-modules lain_cli/utils.py -v --maxfail=1 3 | env = 4 | EDITOR={PWD}/tests/editor.py 5 | LAIN_IGNORE_LINT=false 6 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [flake8] 2 | ignore = E501,E741,F811,PT004,PT021,PT023,W503 3 | 4 | [pycodestyle] 5 | ignore = E501,E741,W503 6 | max-line-length = 160 7 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from pathlib import Path 3 | 4 | from setuptools import find_packages, setup 5 | 6 | from lain_cli import __version__, package_name 7 | 8 | requirements = [ 9 | 'pip>=22.2', 10 | 'ruamel.yaml>=0.17.10', 11 | 'requests', 12 | 'humanfriendly>=4.16.1', 13 | 'click>=8.0', 14 | 'jinja2>=3.0', 15 | 'prompt-toolkit>=3.0.0,<3.0.37', 16 | 'packaging>=19.2', 17 | 'marshmallow>=3.13.0', 18 | 'tenacity>=6.0.0', 19 | 'python-gitlab>=2.4.0', 20 | 'sentry-sdk>=1.0.0', 21 | 'psutil>=5.8.0', 22 | 'cachetools>=5.2.0', 23 | 'cryptography>=37.0.2', 24 | ] 25 | tencent_requirements = ['tencentcloud-sdk-python>=3.0.130'] 26 | aliyun_requirements = [ 27 | 'aliyun-python-sdk-cr>=3.0.1', 28 | 'aliyun-python-sdk-core>=2.13.15', 29 | 'aliyun-python-sdk-cloudapi>=4.9.2', 30 | ] 31 | requirements.extend(tencent_requirements) 32 | requirements.extend(aliyun_requirements) 33 | tests_requirements = [ 34 | 'pytest>=5.2.4', 35 | 'pytest-cov>=2.10.1', 36 | 'pytest-mock>=3.1.0', 37 | 'pytest-ordering>=0.6', 38 | 'pytest-env>=0.6.2', 39 | ] 40 | all_requirements = tests_requirements 41 | this_directory = Path(__file__).parent 42 | long_description = (this_directory / 'README.md').read_text() 43 | setup( 44 | name=package_name, 45 | long_description=long_description, 46 | long_description_content_type='text/markdown', 47 | url='https://github.com/timfeirg/lain-cli', 48 | python_requires='>=3.9', 49 | version=__version__, 50 | packages=find_packages(), 51 | include_package_data=True, 52 | entry_points={'console_scripts': ['lain=lain_cli.lain:main']}, 53 | install_requires=requirements, 54 | zip_safe=False, 55 | extras_require={ 56 | 'all': all_requirements, 57 | 'tests': tests_requirements, 58 | }, 59 | ) 60 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/timfeirg/lain-cli/7728185ca05838d185e05d2cdf5ad5bc324b5378/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | from os import chdir, environ, getcwd 4 | from os.path import abspath, dirname, join 5 | from random import choice 6 | from string import ascii_letters 7 | from typing import Any, Tuple 8 | 9 | import click 10 | import pytest 11 | from click.testing import CliRunner 12 | 13 | from lain_cli.lain import lain 14 | from lain_cli.utils import ( 15 | yadu, 16 | CHART_DIR_NAME, 17 | CLUSTERS, 18 | DOCKERFILE_NAME, 19 | DOCKERIGNORE_NAME, 20 | GITIGNORE_NAME, 21 | change_dir, 22 | ensure_absent, 23 | ensure_helm_initiated, 24 | error, 25 | helm, 26 | kubectl, 27 | lain_meta, 28 | make_canary_name, 29 | rc, 30 | tell_cluster_config, 31 | tell_registry_client, 32 | yalo, 33 | ) 34 | 35 | TESTS_BASE_DIR = dirname(abspath(__file__)) 36 | DUMMY_APPNAME = 'dummy' 37 | DUMMY_OVERRIDE_RELEASE_NAME = 'ymmud' 38 | DUMMY_CANARY_NAME = make_canary_name(DUMMY_APPNAME) 39 | DUMMY_REPO = f'tests/{DUMMY_APPNAME}' 40 | DUMMY_VALUES_PATH = join(CHART_DIR_NAME, 'values.yaml') 41 | with change_dir(DUMMY_REPO): 42 | DUMMY_IMAGE_TAG = lain_meta() 43 | 44 | TEST_CLUSTER = 'test' 45 | 46 | 47 | def run(*args, returncode=0, obj=None, mix_stderr=True, **kwargs): 48 | """run cli command in a click context""" 49 | runner = CliRunner(mix_stderr=mix_stderr) 50 | env = environ.copy() 51 | obj = obj or {} 52 | res = runner.invoke(*args, obj=obj, env=env, **kwargs) 53 | if returncode is not None: 54 | real_code = rc(res) 55 | if real_code != returncode: 56 | print(res.output) 57 | traceback.print_exception(*res.exc_info) 58 | 59 | assert real_code == returncode 60 | 61 | return res 62 | 63 | 64 | run(lain, args=['use', TEST_CLUSTER]) 65 | 66 | with click.Context(click.Command('lain'), obj={}): 67 | TEST_CLUSTER_CONFIG = tell_cluster_config(TEST_CLUSTER) 68 | 69 | DUMMY_URL = f'http://{DUMMY_APPNAME}.{TEST_CLUSTER_CONFIG["domain"]}' 70 | DUMMY_URL_HTTPS = f'https://{DUMMY_APPNAME}.{TEST_CLUSTER_CONFIG["domain"]}' 71 | # this url will point to proc.web-dev in example_lain_yaml 72 | DUMMY_DEV_URL = f'http://{DUMMY_APPNAME}-dev.{TEST_CLUSTER_CONFIG["domain"]}' 73 | RANDOM_STRING = ''.join([choice(ascii_letters) for n in range(9)]) 74 | BUILD_TREASURE_NAME = 'treasure.txt' 75 | DUMMY_JOBS_CLAUSE = { 76 | 'init': { 77 | 'initContainers': [ 78 | { 79 | 'name': f'{DUMMY_APPNAME}-init-container', 80 | 'command': ['echo', RANDOM_STRING], 81 | } 82 | ], 83 | 'imagePullPolicy': 'Always', 84 | 'command': ['bash', '-c', 'echo nothing >> README.md'], 85 | }, 86 | } 87 | DUMMY_TESTS_CLAUSE = { 88 | 'simple-test': { 89 | 'image': f'{TEST_CLUSTER_CONFIG["registry"]}/lain:latest', 90 | 'command': [ 91 | 'bash', 92 | '-ec', 93 | ''' 94 | lain -v wait dummy 95 | ''', 96 | ], 97 | }, 98 | } 99 | 100 | 101 | def render_k8s_specs(): 102 | res = run(lain, args=['-s', 'template'], mix_stderr=False) 103 | return list(yalo(res.stdout, many=True)) 104 | 105 | 106 | def load_dummy_values(): 107 | with open(DUMMY_VALUES_PATH) as f: 108 | values = yalo(f) 109 | 110 | return values 111 | 112 | 113 | def tell_ing_name(host, appname, domain, proc): 114 | host_flat = host.replace('.', '-') 115 | domain_flat = domain.replace('.', '-') 116 | if '.' in host: 117 | return f'{host_flat}-{appname}-{proc}' 118 | return f'{host_flat}-{domain_flat}-{appname}-{proc}' 119 | 120 | 121 | def tell_deployed_images(appname): 122 | res = kubectl( 123 | 'get', 124 | 'deploy', 125 | '-ojsonpath={..image}', 126 | '-l', 127 | f'app.kubernetes.io/name={appname}', 128 | capture_output=True, 129 | ) 130 | if rc(res): 131 | error(res.stdout, exit=1) 132 | 133 | images = set(res.stdout.decode('utf-8').split()) 134 | return images 135 | 136 | 137 | def run_under_click_context( 138 | f, args=(), returncode=0, obj=None, kwargs=None 139 | ) -> Tuple[click.testing.Result, Any]: 140 | """to test functions that use click context internally, we must invoke them 141 | under a active click context, and the only way to do that currently is to 142 | wrap the function call in a click command""" 143 | cache = {'func_result': None} 144 | obj = obj or {} 145 | 146 | @lain.command() 147 | @click.pass_context 148 | def wrapper_command(ctx): 149 | try: 150 | ensure_helm_initiated() 151 | except OSError: 152 | pass 153 | func_result = f(*args, **(kwargs or {})) 154 | cache['func_result'] = func_result 155 | 156 | runner = CliRunner() 157 | 158 | res = runner.invoke(lain, args=['wrapper-command'], obj=obj, env=environ) 159 | if returncode is not None: 160 | # when things go wrong but shouldn't, print outupt and traceback 161 | real_code = rc(res) 162 | if real_code != returncode: 163 | print(res.output) 164 | traceback.print_exception(*res.exc_info) 165 | 166 | assert real_code == returncode 167 | 168 | return res, cache['func_result'] 169 | 170 | 171 | @pytest.fixture() 172 | def dummy_rich_ignore(request): 173 | if not getcwd().endswith(DUMMY_REPO): 174 | sys.path.append(TESTS_BASE_DIR) 175 | chdir(DUMMY_REPO) 176 | 177 | ignore_file = join(TESTS_BASE_DIR, DUMMY_APPNAME, GITIGNORE_NAME) 178 | with open(ignore_file) as f: 179 | original = f.read() 180 | 181 | def tear_down(): 182 | with open(ignore_file, 'w') as f: 183 | f.write(original) 184 | 185 | docker_ignore_file = join(TESTS_BASE_DIR, DUMMY_APPNAME, DOCKERIGNORE_NAME) 186 | ensure_absent(docker_ignore_file) 187 | 188 | extra_ignores = [ 189 | '# comment', 190 | '!/f1', 191 | '!f2', 192 | '/f3', 193 | 'f4', 194 | ] 195 | with open(ignore_file, 'a') as f: 196 | f.write('\n') 197 | for ig in extra_ignores: 198 | f.write(ig) 199 | f.write('\n') 200 | 201 | request.addfinalizer(tear_down) 202 | 203 | 204 | @pytest.fixture() 205 | def dummy_helm_chart(request): 206 | def tear_down(): 207 | ensure_absent( 208 | [CHART_DIR_NAME, join(TESTS_BASE_DIR, DUMMY_APPNAME, DOCKERFILE_NAME)] 209 | ) 210 | 211 | if not getcwd().endswith(DUMMY_REPO): 212 | sys.path.append(TESTS_BASE_DIR) 213 | chdir(DUMMY_REPO) 214 | 215 | tear_down() 216 | run(lain, args=['init', '-f']) 217 | request.addfinalizer(tear_down) 218 | 219 | 220 | @pytest.fixture() 221 | def dummy(request): 222 | def tear_down(): 223 | # 拆除测试的结果就不要要求这么高了, 因为有时候会打断点手动调试 224 | # 跑这段拆除代码的时候, 可能东西已经被拆干净了 225 | run(lain, args=['delete', '--purge'], returncode=None) 226 | helm('delete', DUMMY_OVERRIDE_RELEASE_NAME, check=False) 227 | ensure_absent( 228 | [CHART_DIR_NAME, join(TESTS_BASE_DIR, DUMMY_REPO, DOCKERFILE_NAME)] 229 | ) 230 | 231 | if not getcwd().endswith(DUMMY_REPO): 232 | sys.path.append(TESTS_BASE_DIR) 233 | chdir(DUMMY_REPO) 234 | 235 | tear_down() 236 | run(lain, args=['init']) 237 | override_values_for_e2e = { 238 | 'deployments': {'web': {'terminationGracePeriodSeconds': 1}} 239 | } 240 | override_values_file = f'values-{TEST_CLUSTER}.yaml' 241 | yadu(override_values_for_e2e, join(CHART_DIR_NAME, override_values_file)) 242 | # `lain secret show` will create a dummy secret 243 | run(lain, args=['secret', 'show']) 244 | request.addfinalizer(tear_down) 245 | 246 | 247 | @pytest.fixture() 248 | def registry(request): 249 | cc = dict(CLUSTERS[TEST_CLUSTER]) 250 | return tell_registry_client(cc) 251 | 252 | 253 | def dic_contains(big, small): 254 | left = big.copy() 255 | left.update(small) 256 | assert left == big 257 | -------------------------------------------------------------------------------- /tests/editor.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | from ruamel.yaml import YAML 6 | 7 | 8 | yaml = YAML() 9 | target = sys.argv[1] 10 | with open(target) as f: 11 | content = yaml.load(f.read()) 12 | 13 | os.unlink(target) 14 | content['data']['SURPRISE'] = os.environ['PWD'] 15 | with open(target, 'w') as f: 16 | yaml.dump(content, f) 17 | -------------------------------------------------------------------------------- /tests/test_commands.py: -------------------------------------------------------------------------------- 1 | from os.path import isfile, join 2 | 3 | import pytest 4 | 5 | from lain_cli.lain import lain 6 | from lain_cli.utils import CLI_DIR, docker_save_name, ensure_absent, lain_meta, yalo 7 | from tests.conftest import ( 8 | CHART_DIR_NAME, 9 | DUMMY_APPNAME, 10 | DUMMY_IMAGE_TAG, 11 | DUMMY_VALUES_PATH, 12 | TEST_CLUSTER, 13 | run, 14 | run_under_click_context, 15 | ) 16 | 17 | 18 | @pytest.mark.usefixtures('dummy_helm_chart') 19 | def test_lain_init_with_values_j2(): 20 | values_j2 = f'{CHART_DIR_NAME}/values.yaml.j2' 21 | j2 = 'appname: {{ appname }}' 22 | with open(values_j2, 'w') as f: 23 | f.write(j2) 24 | 25 | res = run(lain, args=['init'], returncode=1) 26 | assert 'already exists' in res.output 27 | run(lain, args=['--ignore-lint', 'init', '-f']) 28 | values = yalo(DUMMY_VALUES_PATH) 29 | assert values == {'appname': DUMMY_APPNAME} 30 | 31 | 32 | @pytest.mark.last # this test cannot run in parallel with other e2e tests 33 | @pytest.mark.usefixtures('dummy') 34 | def test_secret_env_edit(): 35 | run(lain, args=['env', 'edit']) 36 | res = run(lain, args=['env', 'show']) 37 | modified_env = yalo(res.output) 38 | # the original data will be preserved 39 | assert 'FOO' in modified_env['data'] 40 | # our fake $EDITOR will write a key called SURPRISE 41 | assert 'SURPRISE' in modified_env['data'] 42 | res = run(lain, args=['secret', 'edit']) 43 | res = run(lain, args=['secret', 'show']) 44 | modified_secret = yalo(res.output) 45 | assert 'topsecret.txt' in modified_secret['data'] 46 | assert 'SURPRISE' in modified_secret['data'] 47 | 48 | 49 | @pytest.mark.last 50 | @pytest.mark.usefixtures('dummy') 51 | def test_lain_save(): 52 | res = run(lain, args=['save', '--force', '--retag', TEST_CLUSTER]) 53 | fname = f'{DUMMY_APPNAME}_{DUMMY_IMAGE_TAG}.tar.gz' 54 | assert res.output.strip().endswith(fname) 55 | retag = f'{DUMMY_APPNAME}-again' 56 | run(lain, args=['save', '--retag', retag]) 57 | _, meta = run_under_click_context(lain_meta) 58 | fname = docker_save_name(f'{retag}:{meta}') 59 | # file is generated in lain-cli repo root, not dummy dir, sorry 60 | exists_and_delete(join(CLI_DIR, '..', fname)) 61 | retag = f'{DUMMY_APPNAME}-again:latest' 62 | run(lain, args=['save', '--retag', retag]) 63 | fname = docker_save_name(retag) 64 | exists_and_delete(join(CLI_DIR, '..', fname)) 65 | 66 | 67 | def exists_and_delete(path): 68 | assert isfile(path) 69 | ensure_absent(path) 70 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from os.path import basename, exists, join 3 | from pathlib import Path 4 | from tempfile import NamedTemporaryFile, TemporaryDirectory 5 | 6 | import pytest 7 | from ruamel.yaml.scalarstring import LiteralScalarString 8 | 9 | from lain_cli.aliyun import AliyunPaaS 10 | from lain_cli.harbor import HarborRegistry 11 | from lain_cli.utils import ( 12 | CLUSTER_VALUES_DIR, 13 | DOCKERIGNORE_NAME, 14 | banyun, 15 | change_dir, 16 | context, 17 | ensure_absent, 18 | ensure_str, 19 | find, 20 | lain_meta, 21 | load_helm_values, 22 | make_docker_ignore, 23 | make_image_str, 24 | make_job_name, 25 | subprocess_run, 26 | tell_all_clusters, 27 | tell_cluster, 28 | tell_cluster_config, 29 | tell_git_ignore, 30 | tell_helm_options, 31 | tell_ingress_urls, 32 | tell_job_names, 33 | tell_release_name, 34 | yadu, 35 | yalo, 36 | ) 37 | from tests.conftest import ( 38 | CHART_DIR_NAME, 39 | DUMMY_APPNAME, 40 | DUMMY_JOBS_CLAUSE, 41 | DUMMY_OVERRIDE_RELEASE_NAME, 42 | DUMMY_REPO, 43 | DUMMY_TESTS_CLAUSE, 44 | DUMMY_URL, 45 | DUMMY_URL_HTTPS, 46 | RANDOM_STRING, 47 | TEST_CLUSTER, 48 | run_under_click_context, 49 | ) 50 | 51 | BULLSHIT = '不过我倒不在乎做什么工作,只要没人认识我,我也不认识他们就行了。我还会装作自己是个又聋又哑的人。这样我就可以不必跟任何人讲些他妈的没意思的废话。' 52 | 53 | 54 | @pytest.mark.usefixtures('dummy_helm_chart') 55 | def test_make_job_name(): 56 | _, res = run_under_click_context(make_job_name, args=['']) 57 | assert res == 'dummy-5562bd9d33e0c6ce' # this is a stable hash value 58 | 59 | 60 | @pytest.mark.usefixtures('dummy_helm_chart') 61 | def test_ensure_absent(): 62 | values_j2 = f'{CHART_DIR_NAME}/values.yaml.j2' 63 | Path(values_j2).touch() 64 | ensure_absent(CHART_DIR_NAME, preserve=[values_j2, f'{CHART_DIR_NAME}/not-here']) 65 | left_over = find(CHART_DIR_NAME) 66 | assert list(left_over) == [basename(values_j2)] 67 | ensure_absent(CHART_DIR_NAME) 68 | assert not exists(CHART_DIR_NAME) 69 | 70 | 71 | def test_ya(): 72 | dic = {'slogan': BULLSHIT} 73 | f = NamedTemporaryFile() 74 | yadu(dic, f) 75 | f.seek(0) 76 | assert yalo(f) == dic 77 | multiline_content = {'so': LiteralScalarString('so\nlong')} 78 | s = yadu(multiline_content) 79 | # should dump multiline string in readable format 80 | assert ': |' in s 81 | 82 | 83 | @pytest.mark.usefixtures('dummy_helm_chart') 84 | def test_subprocess_run(): 85 | cmd = ['helm', 'version', '--bad-flag'] 86 | cmd_result, func_result = run_under_click_context( 87 | subprocess_run, 88 | args=[cmd], 89 | kwargs={'check': True}, 90 | returncode=1, 91 | ) 92 | # sensible output in stderr, rather than python traceback 93 | assert 'unknown flag: --bad-flag' in cmd_result.output 94 | 95 | cmd_result, func_result = run_under_click_context( 96 | subprocess_run, 97 | args=[cmd], 98 | kwargs={'abort_on_fail': True}, 99 | returncode=1, 100 | ) 101 | # abort_on_fail will not capture std 102 | assert 'unknown flag: --bad-flag' not in cmd_result.output 103 | 104 | cmd = ['helm', 'version'] 105 | cmd_result, func_result = run_under_click_context( 106 | subprocess_run, 107 | args=[cmd], 108 | kwargs={'check': True, 'capture_output': True}, 109 | ) 110 | assert 'version' in ensure_str(func_result.stdout) 111 | 112 | cmd = 'pwd | cat' 113 | _, func_result = run_under_click_context( 114 | subprocess_run, 115 | args=[cmd], 116 | kwargs={'shell': True, 'capture_output': True, 'check': True}, 117 | ) 118 | wd = ensure_str(func_result.stdout).strip() 119 | assert wd.endswith(DUMMY_REPO) 120 | 121 | 122 | @pytest.mark.usefixtures('dummy_helm_chart') 123 | def test_tell_cluster(): 124 | _, func_result = run_under_click_context(tell_cluster) 125 | assert func_result == TEST_CLUSTER 126 | 127 | 128 | def test_lain_meta(): 129 | not_a_git_dir = TemporaryDirectory() 130 | with change_dir(not_a_git_dir.name): 131 | assert lain_meta() == 'latest' 132 | 133 | 134 | @pytest.mark.usefixtures('dummy_helm_chart') 135 | def test_banyun(): 136 | cli_result, _ = run_under_click_context(banyun, ('not-a-image',), returncode=1) 137 | assert 'not a valid image tag' in cli_result.stdout 138 | 139 | 140 | @pytest.mark.usefixtures('dummy_helm_chart') 141 | def test_load_helm_values(): 142 | # test internal cluster values are correctly loaded 143 | _, values = run_under_click_context( 144 | load_helm_values, 145 | ) 146 | assert values['registry'] == 'docker.io/timfeirg' 147 | assert values['domain'] == 'info' 148 | dummy_jobs = { 149 | 'init': {'command': ['echo', 'nothing']}, 150 | } 151 | override_values = { 152 | 'jobs': dummy_jobs, 153 | } 154 | yadu(override_values, f'{CHART_DIR_NAME}/values-{TEST_CLUSTER}.yaml') 155 | _, values = run_under_click_context( 156 | load_helm_values, 157 | ) 158 | assert values['jobs'] == dummy_jobs 159 | 160 | 161 | @pytest.mark.usefixtures('dummy_helm_chart') 162 | def test_tell_helm_options(): 163 | _, options = run_under_click_context( 164 | tell_helm_options, 165 | ) 166 | internal_values_file = join(CLUSTER_VALUES_DIR, f'values-{TEST_CLUSTER}.yaml') 167 | assert internal_values_file in set(options) 168 | set_values = parse_helm_set_clause_from_options(options) 169 | assert set_values['cluster'] == 'test' 170 | assert set_values.get('imageTag') 171 | 172 | def no_build_and_override_registry(): 173 | obj = context().obj 174 | values = obj['values'] 175 | del values['build'] 176 | pairs = [('registry', RANDOM_STRING)] 177 | return tell_helm_options(pairs) 178 | 179 | _, options = run_under_click_context( 180 | no_build_and_override_registry, 181 | ) 182 | set_values_again = parse_helm_set_clause_from_options(options) 183 | assert 'imageTag' not in set_values_again 184 | del set_values['imageTag'] 185 | assert set_values_again.pop('registry', None) == RANDOM_STRING 186 | assert set_values_again == set_values 187 | 188 | def with_extra_values_file(): 189 | obj = context().obj 190 | dic = {'labels': {'foo': 'bar'}} 191 | f = NamedTemporaryFile(prefix='values-extra', suffix='.yaml') 192 | yadu(dic, f) 193 | f.seek(0) 194 | obj['extra_values_file'] = f 195 | try: 196 | return tell_helm_options() 197 | finally: 198 | del f 199 | 200 | _, options = run_under_click_context( 201 | with_extra_values_file, 202 | ) 203 | extra_values_file_name = basename(options.pop(-1)) 204 | assert extra_values_file_name.startswith('values-extra') 205 | assert extra_values_file_name.endswith('.yaml') 206 | 207 | 208 | def parse_helm_set_clause_from_options(options): 209 | set_clause = options[options.index('--set') + 1] 210 | pair_list = set_clause.split(',') 211 | res = {} 212 | for pair in pair_list: 213 | print(pair) 214 | k, v = pair.split('=') 215 | res[k] = v 216 | 217 | return res 218 | 219 | 220 | @pytest.mark.usefixtures('dummy_helm_chart') 221 | def test_registry(): 222 | region_id = 'cn-hangzhou' 223 | repo_ns = 'big-company' 224 | registry = f'registry.{region_id}.aliyuncs.com/{repo_ns}' 225 | aliyun_registry = AliyunPaaS( 226 | access_key_id='hh', 227 | access_key_secret='hh', 228 | registry=registry, 229 | ) 230 | tag = 'noway' 231 | _, image = run_under_click_context( 232 | aliyun_registry.make_image, 233 | args=[tag], 234 | ) 235 | assert image == f'{registry}/{DUMMY_APPNAME}:{tag}' 236 | project = 'foo' 237 | registry_url = f'harbor.fake/{project}' 238 | harbor_registry = HarborRegistry(registry_url, 'fake-token') 239 | tag = 'noway' 240 | _, image = run_under_click_context( 241 | harbor_registry.make_image, 242 | args=[tag], 243 | ) 244 | assert harbor_registry.registry == registry_url 245 | assert image == f'{registry_url}/{DUMMY_APPNAME}:{tag}' 246 | 247 | 248 | @pytest.mark.usefixtures('dummy_rich_ignore') 249 | def test_ignore_files(): 250 | _, content = run_under_click_context( 251 | tell_git_ignore, 252 | ) 253 | assert content.splitlines() 254 | 255 | _, content = run_under_click_context( 256 | make_docker_ignore, 257 | ) 258 | with open(DOCKERIGNORE_NAME) as f: 259 | docker_ignore = f.read() 260 | docker_ignores = docker_ignore.splitlines() 261 | 262 | assert 'comment' not in docker_ignore 263 | assert '**/.git' in docker_ignores 264 | assert '!f1' in docker_ignores 265 | assert '!**/f2' in docker_ignores 266 | assert 'f3' in docker_ignores 267 | assert '**/f4' in docker_ignores 268 | 269 | 270 | @pytest.mark.usefixtures('dummy_helm_chart') 271 | def test_tell_job_names(): 272 | override_values = { 273 | 'jobs': DUMMY_JOBS_CLAUSE, 274 | 'tests': DUMMY_TESTS_CLAUSE, 275 | } 276 | yadu(override_values, f'{CHART_DIR_NAME}/values-{TEST_CLUSTER}.yaml') 277 | _, content = run_under_click_context( 278 | tell_job_names, 279 | ) 280 | assert set(content) == {'dummy-init'} 281 | 282 | 283 | @pytest.mark.usefixtures('dummy_helm_chart') 284 | def test_tell_release_name(): 285 | _, content = run_under_click_context( 286 | tell_release_name, 287 | ) 288 | assert content == DUMMY_APPNAME 289 | override_values = {'releaseName': DUMMY_OVERRIDE_RELEASE_NAME} 290 | yadu(override_values, f'{CHART_DIR_NAME}/values-{TEST_CLUSTER}.yaml') 291 | _, content = run_under_click_context( 292 | tell_release_name, 293 | ) 294 | assert content == DUMMY_OVERRIDE_RELEASE_NAME 295 | 296 | 297 | @pytest.mark.usefixtures('dummy_helm_chart') 298 | def test_cluster_values_override(): 299 | fake_registry = 'registry.example.com' 300 | override_values = { 301 | 'registry': fake_registry, 302 | } 303 | yadu(override_values, f'{CHART_DIR_NAME}/values-{TEST_CLUSTER}.yaml') 304 | _, cc = run_under_click_context( 305 | tell_cluster_config, 306 | ) 307 | assert cc['registry'] == fake_registry 308 | 309 | 310 | @pytest.mark.usefixtures('dummy_helm_chart') 311 | def test_tell_all_clusters(mocker): 312 | test_cluster_values_file = f'values-{TEST_CLUSTER}.yaml' 313 | # we need at least two clusters to verify that tell_all_clusters are working correctly 314 | another_cluster_name = 'another' 315 | another_cluster_values_file = f'values-{another_cluster_name}.yaml' 316 | tempd = TemporaryDirectory() 317 | test_cluster_values_path = join(CLUSTER_VALUES_DIR, test_cluster_values_file) 318 | test_cluster_values = yalo(test_cluster_values_path) 319 | test_cluster_values['registry'] = 'another.example.com' 320 | shutil.copyfile( 321 | test_cluster_values_path, join(tempd.name, test_cluster_values_file) 322 | ) 323 | yadu(test_cluster_values, join(tempd.name, another_cluster_values_file)) 324 | mocker.patch('lain_cli.utils.KUBECONFIG_DIR', tempd.name) 325 | mocker.patch('lain_cli.utils.CLUSTER_VALUES_DIR', tempd.name) 326 | # touch kubeconfig-another 327 | Path(join(tempd.name, f'kubeconfig-{another_cluster_name}')).write_text('') 328 | Path(join(tempd.name, f'kubeconfig-{TEST_CLUSTER}')).write_text('') 329 | # now that kubeconfig and cluster values file are present, we can verify 330 | # CLUSTERS is correct 331 | _, ccs = run_under_click_context( 332 | tell_all_clusters, 333 | ) 334 | assert set(ccs) == {TEST_CLUSTER, another_cluster_name} 335 | assert ccs['another']['registry'] == test_cluster_values['registry'] 336 | tempd.cleanup() 337 | 338 | 339 | @pytest.mark.usefixtures('dummy_helm_chart') 340 | def test_tell_ingress_urls(): 341 | _, urls = run_under_click_context( 342 | tell_ingress_urls, 343 | ) 344 | assert set(urls) == set([f'{DUMMY_URL_HTTPS}/', f'{DUMMY_URL}/']) 345 | 346 | 347 | @pytest.mark.usefixtures('dummy_helm_chart') 348 | def test_make_image_str(): 349 | image_tag = '1.0' 350 | _, image = run_under_click_context( 351 | make_image_str, kwargs={'registry': 'private.com', 'image_tag': image_tag} 352 | ) 353 | assert image == f'private.com/{DUMMY_APPNAME}:1.0' 354 | -------------------------------------------------------------------------------- /tests/test_values.py: -------------------------------------------------------------------------------- 1 | from os.path import join 2 | 3 | import pytest 4 | from marshmallow import ValidationError 5 | 6 | from lain_cli.utils import ( 7 | HelmValuesSchema, 8 | IngressSchema, 9 | load_helm_values, 10 | make_wildcard_domain, 11 | tell_domain_tls_name, 12 | yadu, 13 | ) 14 | from tests.conftest import ( 15 | BUILD_TREASURE_NAME, 16 | CHART_DIR_NAME, 17 | DUMMY_APPNAME, 18 | DUMMY_VALUES_PATH, 19 | RANDOM_STRING, 20 | TEST_CLUSTER, 21 | TEST_CLUSTER_CONFIG, 22 | dic_contains, 23 | load_dummy_values, 24 | render_k8s_specs, 25 | run_under_click_context, 26 | tell_ing_name, 27 | ) 28 | 29 | 30 | @pytest.mark.usefixtures('dummy_helm_chart') 31 | def test_values(): 32 | values = load_dummy_values() 33 | domain = TEST_CLUSTER_CONFIG['domain'] 34 | values['env'] = {'SOMETHING': 'ELSE', 'OVERRIDE_BY_PROC': 'old'} 35 | ing_anno = {'fake-annotations': 'bar'} 36 | values['ingresses'] = [ 37 | {'host': 'dummy', 'deployName': 'web', 'paths': ['/'], 'annotations': ing_anno}, 38 | {'host': f'dummy.{domain}', 'deployName': 'web', 'paths': ['/']}, 39 | ] 40 | values['externalIngresses'] = [ 41 | { 42 | 'host': 'dummy.public.com', 43 | 'deployName': 'web', 44 | 'paths': ['/'], 45 | 'annotations': ing_anno, 46 | }, 47 | {'host': 'public.com', 'deployName': 'web', 'paths': ['/']}, 48 | ] 49 | values['labels'] = {'foo': 'bar'} 50 | web_proc = values['deployments']['web'] 51 | nodePort = 32333 52 | fake_proc_sa = 'procsa' 53 | web_proc.update( 54 | { 55 | 'env': {'OVERRIDE_BY_PROC': 'new'}, 56 | 'podAnnotations': {'prometheus.io/scrape': 'true'}, 57 | 'workingDir': RANDOM_STRING, 58 | 'hostNetwork': True, 59 | 'nodePort': nodePort, 60 | 'serviceAccountName': fake_proc_sa, 61 | 'nodes': ['node-1'], 62 | } 63 | ) 64 | yadu(values, DUMMY_VALUES_PATH) 65 | k8s_specs = render_k8s_specs() 66 | ingresses = [spec for spec in k8s_specs if spec['kind'] == 'Ingress'] 67 | domain = TEST_CLUSTER_CONFIG['domain'] 68 | internal_ing = next( 69 | ing 70 | for ing in ingresses 71 | if ing['metadata']['name'] 72 | == tell_ing_name(DUMMY_APPNAME, DUMMY_APPNAME, domain, 'web') 73 | ) 74 | dic_contains(internal_ing['metadata']['annotations'], ing_anno) 75 | dummy_public_com = next( 76 | ing 77 | for ing in ingresses 78 | if ing['metadata']['name'] == 'dummy-public-com-dummy-web' 79 | ) 80 | dic_contains(dummy_public_com['metadata']['annotations'], ing_anno) 81 | if 'clusterIssuer' in TEST_CLUSTER_CONFIG: 82 | # when tls is not available, skip this test 83 | for ing in ingresses: 84 | spec = ing['spec'] 85 | rule = spec['rules'][0] 86 | domain = rule['host'] 87 | tls = ing['spec']['tls'] 88 | tls_name = tls[0]['secretName'] 89 | tls_hosts = tls[0]['hosts'] 90 | assert set(tls_hosts) == set(make_wildcard_domain(domain)) 91 | assert ( 92 | rule['http']['paths'][0]['backend']['service']['port']['number'] 93 | == nodePort 94 | ) 95 | assert tls_name == tell_domain_tls_name(tls_hosts[0]) 96 | 97 | deployment = next(spec for spec in k8s_specs if spec['kind'] == 'Deployment') 98 | sa = deployment['spec']['template']['spec']['serviceAccountName'] 99 | assert sa == fake_proc_sa 100 | # check if podAnnotations work 101 | assert ( 102 | deployment['spec']['template']['metadata']['annotations'][ 103 | 'prometheus.io/scrape' 104 | ] 105 | == 'true' 106 | ) 107 | container_spec = deployment['spec']['template']['spec'] 108 | assert container_spec['hostNetwork'] is True 109 | containers = container_spec['containers'][0] 110 | assert containers['workingDir'] == RANDOM_STRING 111 | env_dic = {} 112 | for pair in container_spec['containers'][0]['env']: 113 | env_dic[pair['name']] = pair['value'] 114 | 115 | assert env_dic == { 116 | 'LAIN_CLUSTER': TEST_CLUSTER, 117 | 'K8S_NAMESPACE': TEST_CLUSTER_CONFIG.get('namespace', 'default'), 118 | 'IMAGE_TAG': 'UNKNOWN', 119 | 'SOMETHING': 'ELSE', 120 | 'OVERRIDE_BY_PROC': 'new', 121 | } 122 | assert container_spec['affinity']['nodeAffinity'] 123 | assert deployment['metadata']['labels']['foo'] == 'bar' 124 | match_expression = container_spec['affinity']['nodeAffinity'][ 125 | 'requiredDuringSchedulingIgnoredDuringExecution' 126 | ]['nodeSelectorTerms'][0]['matchExpressions'][0] 127 | assert match_expression['key'] == f'{DUMMY_APPNAME}-web' 128 | 129 | service = next(spec for spec in k8s_specs if spec['kind'] == 'Service') 130 | service_spec = service['spec'] 131 | port = service_spec['ports'][0] 132 | assert port['nodePort'] == port['port'] == nodePort 133 | assert port['targetPort'] == 5000 134 | 135 | 136 | def tell_deployment_image(deployment): 137 | container_spec = deployment['spec']['template']['spec'] 138 | containers = container_spec['containers'][0] 139 | return containers['image'] 140 | 141 | 142 | def render_with_override_values(dic): 143 | yadu(dic, join(CHART_DIR_NAME, f'values-{TEST_CLUSTER}.yaml')) 144 | k8s_specs = render_k8s_specs() 145 | return k8s_specs 146 | 147 | 148 | @pytest.mark.usefixtures('dummy_helm_chart') 149 | def test_values_override(): 150 | fake_registry = 'registry.fake' 151 | fake_sa = 'fake' 152 | cluster_values = { 153 | 'registry': fake_registry, 154 | 'serviceAccountName': fake_sa, 155 | 'imageTag': 'UNKNOWN', 156 | } 157 | k8s_specs = render_with_override_values(cluster_values) 158 | deployment = next(spec for spec in k8s_specs if spec['kind'] == 'Deployment') 159 | sa = deployment['spec']['template']['spec']['serviceAccountName'] 160 | assert sa == fake_sa 161 | image = tell_deployment_image(deployment) 162 | assert image == f'{fake_registry}/{DUMMY_APPNAME}:UNKNOWN' 163 | internal_registry = 'registry.in.fake' 164 | cluster_values = {'internalRegistry': internal_registry, 'imageTag': 'UNKNOWN'} 165 | k8s_specs = render_with_override_values(cluster_values) 166 | deployment = next(spec for spec in k8s_specs if spec['kind'] == 'Deployment') 167 | image = tell_deployment_image(deployment) 168 | assert image == f'{internal_registry}/{DUMMY_APPNAME}:UNKNOWN' 169 | 170 | 171 | @pytest.mark.usefixtures('dummy_helm_chart') 172 | def test_duplicate_proc_names(): 173 | values = load_dummy_values() 174 | web = values['deployments']['web'].copy() 175 | del web['resources'] 176 | values['cronjobs'] = {'web': web} 177 | with pytest.raises(ValidationError) as e: 178 | HelmValuesSchema().load(values) 179 | 180 | assert 'proc names should not duplicate' in str(e) 181 | 182 | 183 | @pytest.mark.usefixtures('dummy_helm_chart') 184 | def test_reserved_words(): 185 | # test reserved words 186 | bare_values = load_dummy_values() 187 | web_proc = bare_values['deployments']['web'] 188 | bare_values['deployments'] = {'cronjobs': web_proc} 189 | with pytest.raises(ValidationError) as e: 190 | HelmValuesSchema().load(bare_values) 191 | 192 | assert 'this is a reserved word' in str(e) 193 | 194 | 195 | @pytest.mark.usefixtures('dummy_helm_chart') 196 | def test_schemas(): 197 | bare_values = load_dummy_values() 198 | build = bare_values['build'] 199 | build['env'] = {'EDITOR': '${EDITOR}', 'foo': '${BAR}', 'no': '${{no}}'} 200 | web_proc = bare_values['deployments']['web'] 201 | # deploy is an alias for deployments 202 | bare_values['deploy'] = {'web': web_proc, 'another': web_proc} 203 | yadu(bare_values, DUMMY_VALUES_PATH) 204 | _, values = run_under_click_context(load_helm_values, (DUMMY_VALUES_PATH,)) 205 | assert values['deployments']['web'] == values['deployments']['another'] 206 | assert values['cronjobs'] == {} 207 | build = values['build'] 208 | assert build['prepare']['keep'] == [f'./{BUILD_TREASURE_NAME}'] 209 | assert values['build_args'] == {'BAR', 'EDITOR'} 210 | 211 | bare_values['volumeMounts'][0]['subPath'] = 'foo/bar' # should be basename 212 | with pytest.raises(ValidationError) as e: 213 | HelmValuesSchema().load(bare_values) 214 | 215 | assert 'subPath should be' in str(e) 216 | 217 | false_ing = {'host': 'dummy', 'deployName': 'web'} 218 | with pytest.raises(ValidationError): 219 | IngressSchema().load(false_ing) 220 | 221 | bad_web = {'containerPort': 8000} 222 | with pytest.raises(ValidationError): 223 | IngressSchema().load(bad_web) 224 | --------------------------------------------------------------------------------