Skip to content

Commit d7cb483

Browse files
authored
Add a PyInstaller build for unsupported distros (#27)
This adds configuration to build the CLI as a PyInstaller executable along with the necessary supporting code. The PyInstaller executable would be an option for users of distributions whose package manager we don't have support for. Since there's no automatic update mechanism available, this also implements a new self-update command that pulls the latest version of the CLI and replaces the current version with that one. Unfortunately, this version of the CLI does not ship with its own version of Docker Compose. Packaging Compose into PyInstaller isn't straightforward, and it can't be run as a module because the Python interpreter is embedded in the binary. Instead, the expectation is that the user installs Compose with whatever mechanism they prefer. See #13, which this PR does not fix on its own.
1 parent 51d3740 commit d7cb483

23 files changed

Lines changed: 706 additions & 127 deletions

.circleci/config.yml

Lines changed: 70 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,29 @@ workflows:
2828
- test-installation-deb:
2929
requires:
3030
- build-deb
31+
binary:
32+
jobs:
33+
- build-binary
34+
- test-installation-binary:
35+
requires:
36+
- build-binary
37+
- upload-binary:
38+
name: upload-binary-staging
39+
context: aws-staging
40+
requires:
41+
- test-installation-binary
42+
- upload-binary:
43+
name: upload-binary-production
44+
context: aws
45+
requires:
46+
- upload-binary-staging
47+
filters:
48+
# Ignore any commit on any branch by default
49+
branches:
50+
ignore: /.*/
51+
# Run only when a tag is created
52+
tags:
53+
only: /^v.+\..+\..+/
3154
deploy:
3255
jobs:
3356
- upload-to-pypi:
@@ -61,7 +84,7 @@ jobs:
6184
executor: << parameters.os >>
6285
steps:
6386
- run: sudo apt-get update
64-
- run: sudo apt-get install -y software-properties-common dpkg-dev devscripts equivs
87+
- run: sudo apt-get install -y software-properties-common dpkg-dev devscripts equivs python3-pip
6588
- run: sudo add-apt-repository ppa:jyrki-pulliainen/dh-virtualenv
6689
- run: sudo apt-get install -y dh-virtualenv
6790
- install-poetry
@@ -112,7 +135,7 @@ jobs:
112135
image: ubuntu-2004:202010-01
113136
steps:
114137
- run: sudo apt-get update
115-
- run: sudo apt-get install python3 python3-dev curl git -y
138+
- run: sudo apt-get -y install python3-pip python3-dev curl git
116139
- checkout
117140
- install-poetry
118141
- run: sudo $(which poetry) install
@@ -122,10 +145,54 @@ jobs:
122145
- assert-core-responds-to-http
123146
- run: sudo $(which poetry) run brainframe compose down
124147

148+
build-binary:
149+
docker:
150+
# CentOS 7 is the oldest Linux distribution we unofficially support.
151+
# Building on here allows us to link to a very old version of libc,
152+
# which will work on all more modern distributions.
153+
- image: centos:7
154+
environment:
155+
PYTHONIOENCODING: utf8
156+
steps:
157+
- checkout
158+
- run: yum -y install python3
159+
- install-poetry
160+
- run: poetry install --no-root
161+
- run: PYTHONPATH=$PYTHONPATH:. poetry run pyinstaller package/main.spec
162+
- store_artifacts:
163+
path: dist
164+
- persist_to_workspace:
165+
root: .
166+
paths:
167+
- dist/*
168+
169+
upload-binary:
170+
docker:
171+
- image: cimg/python:3.6
172+
steps:
173+
- attach_workspace:
174+
at: /tmp/workspace
175+
- checkout
176+
- run: poetry install
177+
- run: poetry run python package/upload_binary.py --binary-path /tmp/workspace/dist/brainframe
178+
179+
test-installation-binary:
180+
machine:
181+
image: ubuntu-2004:202010-01
182+
steps:
183+
- run: sudo apt-get update
184+
- attach_workspace:
185+
at: /tmp/workspace
186+
- run: sudo cp /tmp/workspace/dist/brainframe /usr/local/bin/brainframe
187+
- run: sudo brainframe install --noninteractive
188+
- run: sudo brainframe compose up -d
189+
- assert-core-container-running
190+
- assert-core-responds-to-http
191+
- run: sudo brainframe compose down
192+
125193
commands:
126194
install-poetry:
127195
steps:
128-
- run: sudo apt-get update && sudo apt-get install -y python3-pip
129196
- run: >
130197
curl -sSL https://raw.githubusercontent.com/python-poetry/poetry/master/get-poetry.py
131198
| python3 - --version << pipeline.parameters.poetry-version >>

.gitignore

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
1+
# Tooling
12
.mypy_cache/
2-
dist/
3+
4+
# IDEs
35
.idea/
6+
7+
# Python
48
__pycache__
59
*.pyc
610
*.egg-info/
11+
12+
# PyInstaller
13+
dist/
14+
build/

brainframe/cli/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
__version__ = "0.2.0"

brainframe/cli/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,6 @@
22
from .compose import compose
33
from .info import info
44
from .install import install
5+
from .self_update import self_update
56
from .update import update
67
from .utils import by_name

brainframe/cli/commands/install.py

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import shutil
12
from argparse import ArgumentParser
23
from pathlib import Path
34

@@ -6,6 +7,7 @@
67
config,
78
dependencies,
89
docker_compose,
10+
frozen_utils,
911
os_utils,
1012
print_utils,
1113
)
@@ -30,10 +32,14 @@ def install():
3032
print()
3133

3234
# Check all dependencies
33-
dependencies.curl.ensure(args.noninteractive, args.install_curl)
3435
dependencies.docker.ensure(args.noninteractive, args.install_docker)
36+
# We only require the Docker Compose command in frozen distributions
37+
if frozen_utils.is_frozen() and shutil.which("docker-compose") is None:
38+
print_utils.fail_translate(
39+
"install.install-dependency-manually", dependency="docker-compose",
40+
)
3541

36-
_, _, download_version = docker_compose.check_download_version()
42+
download_version = docker_compose.get_latest_version()
3743
print_utils.translate("install.install-version", version=download_version)
3844

3945
if not os_utils.added_to_group("docker"):
@@ -123,8 +129,8 @@ def install():
123129
print_utils.translate("install.set-custom-directory-env-vars")
124130
print(
125131
f"\n"
126-
f'export {config.install_path.name}="{install_path}"\n'
127-
f'export {config.data_path.name}="{data_path}"\n'
132+
f'export {config.install_path.env_var_name}="{install_path}"\n'
133+
f'export {config.data_path.env_var_name}="{data_path}"\n'
128134
)
129135

130136

@@ -156,11 +162,6 @@ def _parse_args():
156162
action="store_true",
157163
help=i18n.t("install.install-docker-help"),
158164
)
159-
parser.add_argument(
160-
"--install-curl",
161-
action="store_true",
162-
help=i18n.t("install.install-curl-help"),
163-
)
164165
parser.add_argument(
165166
"--add-to-group",
166167
action="store_true",
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import os
2+
import shutil
3+
import stat
4+
import sys
5+
from argparse import ArgumentParser
6+
from pathlib import Path
7+
from tempfile import NamedTemporaryFile
8+
from typing import Optional, Tuple, Union
9+
10+
import i18n
11+
import requests
12+
from brainframe.cli import __version__, config, frozen_utils, print_utils
13+
from packaging import version
14+
15+
from .utils import command
16+
17+
_RELEASES_URL_PREFIX = "https://{subdomain}aotu.ai"
18+
_BINARY_URL = "{prefix}/releases/brainframe-cli/brainframe"
19+
_LATEST_TAG_URL = "{prefix}/releases/brainframe-cli/latest"
20+
21+
22+
@command("self-update")
23+
def self_update():
24+
_parse_args()
25+
26+
if not frozen_utils.is_frozen():
27+
print_utils.fail_translate("self-update.not-frozen")
28+
29+
executable_path = Path(sys.executable)
30+
31+
# Check if the user has permissions to overwrite the executable in its
32+
# current location
33+
if not os.access(executable_path, os.W_OK):
34+
error_message = i18n.t(
35+
"general.file-bad-write-permissions", path=executable_path
36+
)
37+
error_message += "\n"
38+
error_message += i18n.t("general.retry-as-root")
39+
print_utils.fail(error_message)
40+
41+
credentials = config.staging_credentials()
42+
43+
prefix = _RELEASES_URL_PREFIX.format(
44+
subdomain="staging." if config.is_staging.value else ""
45+
)
46+
binary_url = _BINARY_URL.format(prefix=prefix)
47+
48+
current_version = version.parse(__version__)
49+
latest_version = _latest_version(prefix, credentials)
50+
51+
if current_version >= latest_version:
52+
print_utils.fail_translate(
53+
"self-update.already-up-to-date",
54+
current_version=current_version,
55+
latest_version=latest_version,
56+
)
57+
58+
# Get the updated executable
59+
print_utils.translate("self-update.downloading")
60+
response = requests.get(binary_url, auth=credentials, stream=True)
61+
if not response.ok:
62+
print_utils.fail_translate(
63+
"self-update.error-downloading",
64+
status_code=response.status_code,
65+
error_message=response.text,
66+
)
67+
68+
with NamedTemporaryFile("wb") as new_executable:
69+
for block in response.iter_content(_BLOCK_SIZE):
70+
new_executable.write(block)
71+
new_executable.flush()
72+
73+
# Set the result as executable
74+
current_stat = executable_path.stat()
75+
executable_path.chmod(
76+
current_stat.st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH
77+
)
78+
79+
# Overwrite the existing executable with the new one
80+
# Really excited for copy3, coming this summer
81+
shutil.copy2(new_executable.name, executable_path)
82+
83+
print()
84+
print_utils.translate(
85+
"self-update.complete", color=print_utils.Color.GREEN
86+
)
87+
88+
89+
def _latest_version(
90+
url_prefix: str, credentials: Optional[Tuple[str, str]],
91+
) -> Union[version.LegacyVersion, version.Version]:
92+
latest_tag_url = _LATEST_TAG_URL.format(prefix=url_prefix)
93+
response = requests.get(latest_tag_url, auth=credentials)
94+
95+
if not response.ok:
96+
print_utils.fail_translate(
97+
"self-update.error-getting-latest-version",
98+
status_code=response.status_code,
99+
error_message=response.text,
100+
)
101+
102+
return version.parse(response.text)
103+
104+
105+
def _parse_args():
106+
parser = ArgumentParser(
107+
description=i18n.t("self-update.description"),
108+
usage=i18n.t("self-update.usage"),
109+
)
110+
111+
return parser.parse_args(sys.argv[2:])
112+
113+
114+
_BLOCK_SIZE = 1024000
115+
"""The block size to read files at. Chosen from this answer:
116+
https://stackoverflow.com/a/3673731
117+
"""

brainframe/cli/commands/update.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ def update():
1616
docker_compose.assert_installed(install_path)
1717

1818
if args.version == "latest":
19-
_, _, requested_version_str = docker_compose.check_download_version()
19+
requested_version_str = docker_compose.check_existing_version(
20+
install_path
21+
)
2022
else:
2123
requested_version_str = args.version
2224

brainframe/cli/config.py

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,11 @@
11
import os
22
from distutils.util import strtobool
33
from pathlib import Path
4-
from typing import Callable, Dict, Generic, Optional, TypeVar, Union
4+
from typing import Callable, Dict, Generic, Optional, Tuple, TypeVar, Union
55

66
import yaml
77

8-
from . import print_utils
9-
10-
_DEFAULTS_FILE_PATH = Path(__file__).parent / "defaults.yaml"
11-
8+
from . import frozen_utils, print_utils
129

1310
T = TypeVar("T")
1411

@@ -55,13 +52,7 @@ def load(
5552

5653
def load() -> None:
5754
"""Initializes configuration options"""
58-
if not _DEFAULTS_FILE_PATH.is_file():
59-
print_utils.fail_translate(
60-
"general.missing-defaults-file",
61-
defaults_file_path=_DEFAULTS_FILE_PATH,
62-
)
63-
64-
with _DEFAULTS_FILE_PATH.open("r") as defaults_file:
55+
with frozen_utils.DEFAULTS_FILE_PATH.open("r") as defaults_file:
6556
defaults = yaml.load(defaults_file, Loader=yaml.FullLoader)
6657

6758
install_path.load(Path, defaults)
@@ -72,6 +63,24 @@ def load() -> None:
7263
staging_password.load(str, defaults)
7364

7465

66+
def staging_credentials() -> Optional[Tuple[str, str]]:
67+
if not is_staging.value:
68+
return None
69+
70+
username = staging_username.value
71+
password = staging_password.value
72+
if username is None or password is None:
73+
print_utils.fail_translate(
74+
"general.staging-missing-credentials",
75+
username_env_var=staging_username.env_var_name,
76+
password_env_var=staging_password.env_var_name,
77+
)
78+
79+
# Mypy doesn't understand that fail_translate exits this function, so it
80+
# thinks the return type should be Tuple[Optional[str], Optional[str]]
81+
return username, password # type: ignore
82+
83+
7584
def _bool_converter(value: Union[str, bool]) -> bool:
7685
if isinstance(value, bool):
7786
return value

0 commit comments

Comments
 (0)