Skip to content

Commit

Permalink
Deploy and install Air Link as pip package (#10)
Browse files Browse the repository at this point in the history
* deployment and installation assuming air link will be pip-installed

* cleanup

* add air-link script to pyproject.toml

* add GitHub workflow

* update readme

* add license

* add shields

* code review

* adjustments for Python3.8

* fix working directory
  • Loading branch information
falkoschindler authored Jul 5, 2024
1 parent 45d6274 commit b276797
Show file tree
Hide file tree
Showing 24 changed files with 1,580 additions and 219 deletions.
59 changes: 59 additions & 0 deletions .github/workflows/publish.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
name: Publish Release

on:
workflow_dispatch:
push:
tags:
- v**

jobs:
pypi:
runs-on: ubuntu-latest

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.x"

- name: Set up Poetry
uses: abatilo/actions-poetry@v2
with:
poetry-version: "1.8.3"

- name: Get version
id: get_version
run: echo "VERSION=$(echo ${GITHUB_REF/refs\/tags\//})" >> $GITHUB_ENV

- name: Set version
run: poetry version ${{ env.VERSION }}

- name: Publish PyPI package
env:
POETRY_PYPI_TOKEN_PYPI: ${{ secrets.LIVESYNC_PYPI_API_TOKEN }}
run: poetry publish --build

- name: Create GitHub release entry
uses: softprops/action-gh-release@v1
id: create_release
with:
draft: true
prerelease: false
name: ${{ env.VERSION }}
tag_name: ${{ env.VERSION }}
env:
GITHUB_TOKEN: ${{ github.token }}

- name: Update version in pyproject.toml
run: python .github/workflows/update_pyproject.py $(echo ${GITHUB_REF/refs\/tags\//})

- name: Commit and push changes
run: |
git config --global user.name "github-actions[bot]"
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add pyproject.toml
git commit -m "Update pyproject.toml"
git push origin HEAD:main
11 changes: 11 additions & 0 deletions .github/workflows/update_pyproject.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
import re
import sys
from pathlib import Path

if __name__ == '__main__':
path = Path('pyproject.toml')
content = path.read_text(encoding='utf-8')
version = sys.argv[1].removeprefix('v')
content = re.sub(r'version = "[0-9]+\.[0-9]+\.[0-9]+-dev"', f'version = "{version}-dev"', content)
path.write_text(content, encoding='utf-8')
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ default_stages: [commit]

repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.2
rev: v0.5.0
hooks:
- id: ruff
args: [--fix]
Expand Down
21 changes: 21 additions & 0 deletions LICENSE
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
MIT License

Copyright (c) 2024 Zauberzeug GmbH

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
24 changes: 18 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

Air Link is a standalone service to manage remote access to an edge device and to install user apps.

[![PyPI](https://img.shields.io/pypi/v/air-link?color=dark-green)](https://pypi.org/project/air-link/)
[![PyPI downloads](https://img.shields.io/pypi/dm/air-link?color=dark-green)](https://pypi.org/project/air-link/)
[![GitHub license](https://img.shields.io/github/license/zauberzeug/air-link?color=orange)](https://github.com/zauberzeug/air-link/blob/main/LICENSE)
[![GitHub commit activity](https://img.shields.io/github/commit-activity/m/zauberzeug/air-link)](https://github.com/zauberzeug/air-link/graphs/commit-activity)
[![GitHub issues](https://img.shields.io/github/issues/zauberzeug/air-link?color=blue)](https://github.com/zauberzeug/air-link/issues)
[![GitHub forks](https://img.shields.io/github/forks/zauberzeug/air-link)](https://github.com/zauberzeug/air-link/network)
[![GitHub stars](https://img.shields.io/github/stars/zauberzeug/air-link)](https://github.com/zauberzeug/air-link/stargazers)

## Prerequisites

The edge device needs to run a Linux-based OS and have Python >=3.8 installed.
Expand Down Expand Up @@ -34,15 +42,16 @@ The edge device needs to run a Linux-based OS and have Python >=3.8 installed.
## Setup
### 1. Deploy the Air Link app to a new device
### 1. Install the Air Link app on an edge device
Run the following command on the developer machine to deploy the Air Link app to an edge device.
Air link can be installed using pip.
To run the app automatically after a reboot, you can install it as a system service using its `install` command.
```bash
./deploy.py <target device>
pip install air-link
air-link install
```
It will copy the Air Link app into the home directory of the edge device and start it in a system service.
The app is accessible on port 8080 and can be reached via the IP address of the edge device.

> [!NOTE]
Expand All @@ -54,8 +63,6 @@ The app is accessible on port 8080 and can be reached via the IP address of the
>
> The app will then be reachable at `localhost:8888` on the developer machine.
The system service will automatically start the app after a reboot.
> [!TIP]
> To display the logs of the Air Link service, use the following command:
>
Expand Down Expand Up @@ -153,3 +160,8 @@ pre-commit run --all-files
```

These checks will also run automatically before every commit.

### Deployment

To deploy a new version of Air Link, add a new tag with the format `vX.Y.Z` and push it to the repository.
The CI pipeline will then build the new version, upload it to PyPI, and create a new draft release on GitHub.
5 changes: 0 additions & 5 deletions air_link/.syncignore

This file was deleted.

5 changes: 5 additions & 0 deletions air_link/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .main import main

__all__ = [
'main',
]
4 changes: 2 additions & 2 deletions air_link/air_link.service
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ After=multi-user.target

[Service]
Type=simple
ExecStart=/usr/bin/env bash -i -c ./main.py
WorkingDirectory=/home/USER/air_link
WorkingDirectory=/home/USER/
ExecStart=/home/USER/.local/bin/air-link run
User=USER
Group=USER
Restart=always
Expand Down
7 changes: 0 additions & 7 deletions air_link/app/__init__.py

This file was deleted.

File renamed without changes.
42 changes: 30 additions & 12 deletions air_link/install.py
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,17 +1,35 @@
#!/usr/bin/env python3
import getpass
import logging
import subprocess
from pathlib import Path

from utils import run
SERVICE_FILE = Path(__file__).parent / 'air_link.service'

if not run.sudo_password:
run.sudo_password = getpass.getpass(prompt='Enter sudo password: ')

run.pip('install -r requirements.txt')

run.sudo(
'cp air_link.service /etc/systemd/system/air_link.service',
'sed -i "s/USER/$USER/g" /etc/systemd/system/air_link.service',
'systemctl daemon-reload',
'systemctl enable air_link.service',
'systemctl restart air_link.service',
)
def install() -> None:
sudo_password = getpass.getpass(prompt='Enter sudo password: ')
for cmd in [
f'cp {SERVICE_FILE} /etc/systemd/system/air_link.service',
'sed -i "s/USER/$USER/g" /etc/systemd/system/air_link.service',
'systemctl daemon-reload',
'systemctl enable air_link.service',
'systemctl restart air_link.service',
]:
sudo_cmd = f'sudo -S {cmd}'
try:
with subprocess.Popen(sudo_cmd,
shell=True,
stdin=subprocess.PIPE,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
encoding='utf8') as process:
process.communicate(input=sudo_password + '\n')
if process.wait() != 0:
return
except subprocess.CalledProcessError as e:
logging.error(f'failed to run {sudo_cmd}: {e.output}')
return
except Exception as e:
logging.exception(f'failed to run {sudo_cmd}: {e}')
return
27 changes: 12 additions & 15 deletions air_link/main.py
100755 → 100644
Original file line number Diff line number Diff line change
@@ -1,20 +1,17 @@
#!/usr/bin/env python3
import logging
import argparse

from app import create_main_page, setup_ssh
from nicegui import app, ui
from .install import install
from .run import run

logging.basicConfig(level=logging.INFO)
logging.getLogger('watchfiles').setLevel(logging.WARNING)

if app.storage.general.get('air_link_token'):
app.on_startup(setup_ssh)
def main() -> None:
parser = argparse.ArgumentParser(
'Air Link', description='SSH access, diagnostics and administration for edge devices.')
parser.add_argument('action', choices=['install', 'run'], help='action to perform', default='run')
args = parser.parse_args()

create_main_page()
if args.action == 'run':
run()

ui.run(
title='Air Link',
favicon='⛑',
reload=False,
on_air=app.storage.general.get('air_link_token', False),
)
if args.action == 'install':
install()
File renamed without changes.
File renamed without changes.
2 changes: 0 additions & 2 deletions air_link/requirements.txt

This file was deleted.

19 changes: 19 additions & 0 deletions air_link/run.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import logging

from nicegui import app, ui

from .main_page import create_page
from .ssh import setup


def run() -> None:
logging.basicConfig(level=logging.INFO)
logging.getLogger('watchfiles').setLevel(logging.WARNING)

on_air = app.storage.general.get('air_link_token', False)
if on_air:
app.on_startup(setup)

create_page()

ui.run(title='Air Link', favicon='⛑', reload=False, on_air=on_air)
File renamed without changes.
40 changes: 20 additions & 20 deletions air_link/app/system.py → air_link/system.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import shutil
from typing import Optional, Tuple

import docker
from nicegui import ui
Expand All @@ -18,15 +19,15 @@ def show_disk_space() -> None:
label.classes('text-negative').tooltip(f'Low disk space! {free / total:.1%} left')


def _get_docker_client() -> docker.DockerClient | None:
def _get_docker_client() -> Optional[docker.DockerClient]:
try:
return docker.DockerClient(base_url='unix://var/run/docker.sock')
except Exception:
ui.notify('Could not connect to Docker API', type='negative')
return None


def docker_prune_dry_run() -> tuple[int, int, int, int]:
def docker_prune_dry_run() -> Tuple[int, int, int, int]:
client = _get_docker_client()
if not client:
return 0, 0, 0, 0
Expand All @@ -42,24 +43,23 @@ def docker_prune(what: str) -> None:
client = _get_docker_client()
if not client:
return
match what:
case 'images':
result = client.images.prune()
num_deleted = len(result.get('ImagesDeleted') or [])
case 'containers':
result = client.containers.prune()
num_deleted = len(result.get('ContainersDeleted') or [])
case 'volumes':
result = client.volumes.prune(filters={'all': True})
num_deleted = len(result.get('VolumesDeleted') or [])
case 'networks':
result = client.networks.prune()
num_deleted = len(result.get('NetworksDeleted') or [])
case 'caches':
result = client.api.prune_builds()
num_deleted = len(result.get('CachesDeleted') or [])
case _:
raise ValueError(f'Invalid argument: {what}')
if what == 'images':
result = client.images.prune()
num_deleted = len(result.get('ImagesDeleted') or [])
elif what == 'containers':
result = client.containers.prune()
num_deleted = len(result.get('ContainersDeleted') or [])
elif what == 'volumes':
result = client.volumes.prune(filters={'all': True})
num_deleted = len(result.get('VolumesDeleted') or [])
elif what == 'networks':
result = client.networks.prune()
num_deleted = len(result.get('NetworksDeleted') or [])
elif what == 'caches':
result = client.api.prune_builds()
num_deleted = len(result.get('CachesDeleted') or [])
else:
raise ValueError(f'Invalid argument: {what}')
ui.notify(f'{num_deleted or 0} {what} deleted, {result.get("SpaceReclaimed", 0) / 2**30:.1f} GB reclaimed')
docker_prune_preview.refresh()

Expand Down
5 changes: 0 additions & 5 deletions air_link/utils/__init__.py

This file was deleted.

Loading

0 comments on commit b276797

Please sign in to comment.