├── .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 | [](https://lain-cli.readthedocs.io/en/latest/) [](https://circleci.com/gh/timfeirg/lain-cli) [](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 | [](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 |
--------------------------------------------------------------------------------