This commit is contained in:
SporeStack 2022-02-10 21:47:57 +00:00
commit 39d0e41f29
25 changed files with 2282 additions and 0 deletions

12
.editorconfig Normal file
View File

@ -0,0 +1,12 @@
# https://editorconfig.org
root = true
[*]
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[Makefile]
indent_style=tab

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
*.pyc
.venv
venv
build
dist
*.egg-info
.eggs
__pycache__
.pytest_cache
.coverage

34
.pre-commit-config.yaml Normal file
View File

@ -0,0 +1,34 @@
# See https://pre-commit.com for more information
# See https://pre-commit.com/hooks.html for more hooks
repos:
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: 8fe62d14e0b4d7d845a7022c5c2c3ae41bdd3f26 # frozen: v4.1.0
hooks:
- id: trailing-whitespace
- repo: https://github.com/psf/black
rev: fc0be6eb1e2a96091e6f64009ee5e9081bf8b6c6 # frozen: 22.1.0
hooks:
- id: black
- repo: https://github.com/PyCQA/isort
rev: c5e8fa75dda5f764d20f66a215d71c21cfa198e1 # frozen: 5.10.1
hooks:
- id: isort
- repo: https://github.com/myint/autoflake
rev: 7a53fdafc82c33f446915b60fcac947c51279260 # frozen: v1.4
hooks:
- id: autoflake
- repo: https://github.com/asottile/pyupgrade
rev: e695ecd365119ab4e5463f6e49bea5f4b7ca786b # frozen: v2.31.0
hooks:
- id: pyupgrade
args: [--py37-plus]
- repo: https://github.com/asottile/setup-cfg-fmt
rev: 58b14248db425913ea7502c0b1af9d6653403e07 # frozen: v1.20.0
hooks:
- id: setup-cfg-fmt
- repo: https://github.com/jackdewinter/pymarkdown
rev: cf71b3c9cb0c361c4a17eacb80ed52432b57b420 # frozen: 0.9.4
hooks:
- id: pymarkdown
args: [--disable-rules=MD013, --set=plugins.md024.siblings_only=$!True, scan]

39
.woodpecker.yml Normal file
View File

@ -0,0 +1,39 @@
pipeline:
python-3.7:
group: test
image: python:3.7
commands:
- pip install pipenv==2022.1.8
- pipenv install --dev --deploy
- pipenv run almake test-pytest # We only test with pytest on 3.7
# More than three jobs seems to cause issues with Woodpecker?
# python-3.8:
# group: test
# image: python:3.8-slim
# commands:
# - pip install pipenv==2021.11.23
# - pipenv install --dev --deploy
# - pipenv run almake test
# - pipenv run almake build-dist
# - sha256sum dist/*
python-3.9:
group: test
image: python:3.9
commands:
- pip install pipenv==2022.1.8 pre-commit==2.17.0
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist
- sha256sum dist/*
python-3.10:
group: test
image: python:3.10
commands:
- pip install pipenv==2022.1.8 pre-commit==2.17.0
- pipenv install --dev --deploy
- pipenv run almake test
- pipenv run almake build-dist
- sha256sum dist/*

35
CHANGELOG.md Normal file
View File

@ -0,0 +1,35 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
### Added
- Nothing yet.
### Fixed
- Nothing yet.
## [5.2.1 - 2022-02-10]
### Added
- New, 32 character machine ID format. (Old, 64 hex character format still supported.)
- CHANGELOG.md in Keep a Changelog format.
## [5.2.0 - 2022-01-31]
### Added
- `sporestack rebuild` command.
## [5.1.2 - 2021-10-18]
### Added
- Send `sporestack-python/version` in Use-Agent header.

24
LICENSE.txt Normal file
View File

@ -0,0 +1,24 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
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 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.
For more information, please refer to <http://unlicense.org/>

21
Makefile Normal file
View File

@ -0,0 +1,21 @@
test:
pre-commit run --all-files
python -m pflake8 .
python -m mypy --strict .
$(MAKE) test-pytest
test-pytest:
python -m pytest --cov=sporestack --cov-fail-under=49 --cov-report=term --durations=3 --cache-clear
build-dist:
rm dist/* || true
# This should result in a reproducible wheel.
SOURCE_DATE_EPOCH=1309379017 python -m build --no-isolation
python -m twine check --strict dist/*
servedocs:
pdoc sporestack
publish: build-dist
# The sdist isn't reproducible, but the wheel is.
python -m twine upload dist/*

30
Pipfile Normal file
View File

@ -0,0 +1,30 @@
[[source]]
url = "https://pypi.org/simple"
verify_ssl = true
name = "pypi"
[packages]
sporestack = {editable = true, path = "."}
[dev-packages]
flake8 = "~=4.0"
pyproject-flake8 = "==0.0.1a2"
flake8-noqa = "~=1.2"
pep8-naming = "~=0.12.1"
mypy = "==0.931"
pytest = "~=6.2"
pytest-cov = "~=3.0"
types-requests = "~=2.25"
# Building
wheel = "~=0.37.0"
build = "~=0.7.0"
# Publishing
twine = "~=3.4"
# Docs
pdoc = "~=9.0"
# Python `make` implementation
almost-make = "~=0.5.1"

681
Pipfile.lock generated Normal file
View File

@ -0,0 +1,681 @@
{
"_meta": {
"hash": {
"sha256": "a28c5484b41e439216180f4b486774887c8a49cf9b36f8997ed098d7462ceb2e"
},
"pipfile-spec": 6,
"requires": {},
"sources": [
{
"name": "pypi",
"url": "https://pypi.org/simple",
"verify_ssl": true
}
]
},
"default": {
"certifi": {
"hashes": [
"sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
"sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
],
"version": "==2021.10.8"
},
"charset-normalizer": {
"hashes": [
"sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45",
"sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c"
],
"markers": "python_version >= '3'",
"version": "==2.0.11"
},
"click": {
"hashes": [
"sha256:353f466495adaeb40b6b5f592f9f91cb22372351c84caeb068132442a4518ef3",
"sha256:410e932b050f5eed773c4cda94de75971c89cdb3155a72a0831139a79e5ecb5b"
],
"markers": "python_version >= '3.6'",
"version": "==8.0.3"
},
"idna": {
"hashes": [
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
],
"markers": "python_version >= '3'",
"version": "==3.3"
},
"pydantic": {
"hashes": [
"sha256:085ca1de245782e9b46cefcf99deecc67d418737a1fd3f6a4f511344b613a5b3",
"sha256:086254884d10d3ba16da0588604ffdc5aab3f7f09557b998373e885c690dd398",
"sha256:0b6037175234850ffd094ca77bf60fb54b08b5b22bc85865331dd3bda7a02fa1",
"sha256:0fe476769acaa7fcddd17cadd172b156b53546ec3614a4d880e5d29ea5fbce65",
"sha256:1d5278bd9f0eee04a44c712982343103bba63507480bfd2fc2790fa70cd64cf4",
"sha256:2cc6a4cb8a118ffec2ca5fcb47afbacb4f16d0ab8b7350ddea5e8ef7bcc53a16",
"sha256:2ee7e3209db1e468341ef41fe263eb655f67f5c5a76c924044314e139a1103a2",
"sha256:3011b975c973819883842c5ab925a4e4298dffccf7782c55ec3580ed17dc464c",
"sha256:3c3b035103bd4e2e4a28da9da7ef2fa47b00ee4a9cf4f1a735214c1bcd05e0f6",
"sha256:4c68c3bc88dbda2a6805e9a142ce84782d3930f8fdd9655430d8576315ad97ce",
"sha256:574936363cd4b9eed8acdd6b80d0143162f2eb654d96cb3a8ee91d3e64bf4cf9",
"sha256:5a79330f8571faf71bf93667d3ee054609816f10a259a109a0738dac983b23c3",
"sha256:5e48ef4a8b8c066c4a31409d91d7ca372a774d0212da2787c0d32f8045b1e034",
"sha256:6c5b77947b9e85a54848343928b597b4f74fc364b70926b3c4441ff52620640c",
"sha256:742645059757a56ecd886faf4ed2441b9c0cd406079c2b4bee51bcc3fbcd510a",
"sha256:7bdfdadb5994b44bd5579cfa7c9b0e1b0e540c952d56f627eb227851cda9db77",
"sha256:815ddebb2792efd4bba5488bc8fde09c29e8ca3227d27cf1c6990fc830fd292b",
"sha256:8b5ac0f1c83d31b324e57a273da59197c83d1bb18171e512908fe5dc7278a1d6",
"sha256:96f240bce182ca7fe045c76bcebfa0b0534a1bf402ed05914a6f1dadff91877f",
"sha256:a733965f1a2b4090a5238d40d983dcd78f3ecea221c7af1497b845a9709c1721",
"sha256:ab624700dc145aa809e6f3ec93fb8e7d0f99d9023b713f6a953637429b437d37",
"sha256:b2571db88c636d862b35090ccf92bf24004393f85c8870a37f42d9f23d13e032",
"sha256:bbbc94d0c94dd80b3340fc4f04fd4d701f4b038ebad72c39693c794fd3bc2d9d",
"sha256:c0727bda6e38144d464daec31dff936a82917f431d9c39c39c60a26567eae3ed",
"sha256:c556695b699f648c58373b542534308922c46a1cda06ea47bc9ca45ef5b39ae6",
"sha256:c86229333cabaaa8c51cf971496f10318c4734cf7b641f08af0a6fbf17ca3054",
"sha256:c8d7da6f1c1049eefb718d43d99ad73100c958a5367d30b9321b092771e96c25",
"sha256:c8e9dcf1ac499679aceedac7e7ca6d8641f0193c591a2d090282aaf8e9445a46",
"sha256:cb23bcc093697cdea2708baae4f9ba0e972960a835af22560f6ae4e7e47d33f5",
"sha256:d1e4c28f30e767fd07f2ddc6f74f41f034d1dd6bc526cd59e63a82fe8bb9ef4c",
"sha256:d9c9bdb3af48e242838f9f6e6127de9be7063aad17b32215ccc36a09c5cf1070",
"sha256:dee5ef83a76ac31ab0c78c10bd7d5437bfdb6358c95b91f1ba7ff7b76f9996a1",
"sha256:e0896200b6a40197405af18828da49f067c2fa1f821491bc8f5bde241ef3f7d7",
"sha256:f5a64b64ddf4c99fe201ac2724daada8595ada0d102ab96d019c1555c2d6441d",
"sha256:f947352c3434e8b937e3aa8f96f47bdfe6d92779e44bb3f41e4c213ba6a32145"
],
"markers": "python_full_version >= '3.6.1'",
"version": "==1.9.0"
},
"pysocks": {
"hashes": [
"sha256:08e69f092cc6dbe92a0fdd16eeb9b9ffbc13cadfe5ca4c7bd92ffb078b293299",
"sha256:2725bd0a9925919b9b51739eea5f9e2bae91e83288108a9ad338b2e3a4435ee5",
"sha256:3f8804571ebe159c380ac6de37643bb4685970655d3bba243530d6558b799aa0"
],
"version": "==1.7.1"
},
"requests": {
"extras": [
"socks"
],
"hashes": [
"sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
"sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==2.27.1"
},
"segno": {
"hashes": [
"sha256:79d1d7b9c893243411acd031682e0ece007fbd632885c6c650186871be572111",
"sha256:b8e90823b7ab5249044d22f022291bb06e112104779d6339baf0997fad656c9a"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==1.4.1"
},
"sporestack": {
"editable": true,
"path": "."
},
"typer": {
"hashes": [
"sha256:63c3aeab0549750ffe40da79a1b524f60e08a2cbc3126c520ebf2eeaf507f5dd",
"sha256:d81169725140423d072df464cad1ff25ee154ef381aaf5b8225352ea187ca338"
],
"markers": "python_version >= '3.6'",
"version": "==0.4.0"
},
"typing-extensions": {
"hashes": [
"sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e",
"sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"
],
"markers": "python_version >= '3.6'",
"version": "==4.0.1"
},
"urllib3": {
"hashes": [
"sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed",
"sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.26.8"
}
},
"develop": {
"almost-make": {
"hashes": [
"sha256:57a3fd147074a041f6b8735e239a4d425bf3944b3c68a52e6225dab25caef4e0",
"sha256:b227ad53d27f767ea600cc97b5165bef94e6f4ec24ccefe4dc52ed532f6b6f71"
],
"index": "pypi",
"version": "==0.5.1"
},
"astunparse": {
"hashes": [
"sha256:5ad93a8456f0d084c3456d059fd9a92cce667963232cbf763eac3bc5b7940872",
"sha256:c2652417f2c8b5bb325c885ae329bdf3f86424075c4fd1a128674bc6fba4b8e8"
],
"markers": "python_version < '3.9'",
"version": "==1.6.3"
},
"attrs": {
"hashes": [
"sha256:2d27e3784d7a565d36ab851fe94887c5eccd6a463168875832a1be79c82828b4",
"sha256:626ba8234211db98e869df76230a137c4c40a12d72445c45d5f5b716f076e2fd"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==21.4.0"
},
"bleach": {
"hashes": [
"sha256:0900d8b37eba61a802ee40ac0061f8c2b5dee29c1927dd1d233e075ebf5a71da",
"sha256:4d2651ab93271d1129ac9cbc679f524565cc8a1b791909c4a51eac4446a15994"
],
"markers": "python_version >= '3.6'",
"version": "==4.1.0"
},
"build": {
"hashes": [
"sha256:1aaadcd69338252ade4f7ec1265e1a19184bf916d84c9b7df095f423948cb89f",
"sha256:21b7ebbd1b22499c4dac536abc7606696ea4d909fd755e00f09f3c0f2c05e3c8"
],
"index": "pypi",
"version": "==0.7.0"
},
"certifi": {
"hashes": [
"sha256:78884e7c1d4b00ce3cea67b44566851c4343c120abd683433ce934a68ea58872",
"sha256:d62a0163eb4c2344ac042ab2bdf75399a71a2d8c7d47eac2e2ee91b9d6339569"
],
"version": "==2021.10.8"
},
"charset-normalizer": {
"hashes": [
"sha256:2842d8f5e82a1f6aa437380934d5e1cd4fcf2003b06fed6940769c164a480a45",
"sha256:98398a9d69ee80548c762ba991a4728bfc3836768ed226b3945908d1a688371c"
],
"markers": "python_version >= '3'",
"version": "==2.0.11"
},
"colorama": {
"hashes": [
"sha256:5941b2b48a20143d2267e95b1c2a7603ce057ee39fd88e7329b0c292aa16869b",
"sha256:9f47eda37229f68eee03b24b9748937c7dc3868f906e8ba69fbcbdd3bc5dc3e2"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==0.4.4"
},
"coverage": {
"extras": [
"toml"
],
"hashes": [
"sha256:012157499ec4f135fc36cd2177e3d1a1840af9b236cbe80e9a5ccfc83d912a69",
"sha256:0a34d313105cdd0d3644c56df2d743fe467270d6ab93b5d4a347eb9fec8924d6",
"sha256:11e61c5548ecf74ea1f8b059730b049871f0e32b74f88bd0d670c20c819ad749",
"sha256:152cc2624381df4e4e604e21bd8e95eb8059535f7b768c1fb8b8ae0b26f47ab0",
"sha256:1b4285fde5286b946835a1a53bba3ad41ef74285ba9e8013e14b5ea93deaeafc",
"sha256:27a94db5dc098c25048b0aca155f5fac674f2cf1b1736c5272ba28ead2fc267e",
"sha256:27ac7cb84538e278e07569ceaaa6f807a029dc194b1c819a9820b9bb5dbf63ab",
"sha256:2a491e159294d756e7fc8462f98175e2d2225e4dbe062cca7d3e0d5a75ba6260",
"sha256:2bc85664b06ba42d14bb74d6ddf19d8bfc520cb660561d2d9ce5786ae72f71b5",
"sha256:32168001f33025fd756884d56d01adebb34e6c8c0b3395ca8584cdcee9c7c9d2",
"sha256:3c4ce3b647bd1792d4394f5690d9df6dc035b00bcdbc5595099c01282a59ae01",
"sha256:433b99f7b0613bdcdc0b00cc3d39ed6d756797e3b078d2c43f8a38288520aec6",
"sha256:4578728c36de2801c1deb1c6b760d31883e62e33f33c7ba8f982e609dc95167d",
"sha256:51372e24b1f7143ee2df6b45cff6a721f3abe93b1e506196f3ffa4155c2497f7",
"sha256:5d008e0f67ac800b0ca04d7914b8501312c8c6c00ad8c7ba17754609fae1231a",
"sha256:649df3641eb351cdfd0d5533c92fc9df507b6b2bf48a7ef8c71ab63cbc7b5c3c",
"sha256:6e78b1e25e5c5695dea012be473e442f7094d066925604be20b30713dbd47f89",
"sha256:72d9d186508325a456475dd05b1756f9a204c7086b07fffb227ef8cee03b1dc2",
"sha256:7d82c610a2e10372e128023c5baf9ce3d270f3029fe7274ff5bc2897c68f1318",
"sha256:7ee317486593193e066fc5e98ac0ce712178c21529a85c07b7cb978171f25d53",
"sha256:7eed8459a2b81848cafb3280b39d7d49950d5f98e403677941c752e7e7ee47cb",
"sha256:823f9325283dc9565ba0aa2d240471a93ca8999861779b2b6c7aded45b58ee0f",
"sha256:85c5fc9029043cf8b07f73fbb0a7ab6d3b717510c3b5642b77058ea55d7cacde",
"sha256:86c91c511853dfda81c2cf2360502cb72783f4b7cebabef27869f00cbe1db07d",
"sha256:8e0c3525b1a182c8ffc9bca7e56b521e0c2b8b3e82f033c8e16d6d721f1b54d6",
"sha256:987a84ff98a309994ca77ed3cc4b92424f824278e48e4bf7d1bb79a63cfe2099",
"sha256:9ed3244b415725f08ca3bdf02ed681089fd95e9465099a21c8e2d9c5d6ca2606",
"sha256:a189036c50dcd56100746139a459f0d27540fef95b09aba03e786540b8feaa5f",
"sha256:a4748349734110fd32d46ff8897b561e6300d8989a494ad5a0a2e4f0ca974fc7",
"sha256:a5d79c9af3f410a2b5acad91258b4ae179ee9c83897eb9de69151b179b0227f5",
"sha256:a7596aa2f2b8fa5604129cfc9a27ad9beec0a96f18078cb424d029fdd707468d",
"sha256:ab4fc4b866b279740e0d917402f0e9a08683e002f43fa408e9655818ed392196",
"sha256:bde4aeabc0d1b2e52c4036c54440b1ad05beeca8113f47aceb4998bb7471e2c2",
"sha256:c72bb4679283c6737f452eeb9b2a0e570acaef2197ad255fb20162adc80bea76",
"sha256:c8582e9280f8d0f38114fe95a92ae8d0790b56b099d728cc4f8a2e14b1c4a18c",
"sha256:ca29c352389ea27a24c79acd117abdd8a865c6eb01576b6f0990cd9a4e9c9f48",
"sha256:ce443a3e6df90d692c38762f108fc4c88314bf477689f04de76b3f252e7a351c",
"sha256:da1a428bdbe71f9a8c270c7baab29e9552ac9d0e0cba5e7e9a4c9ee6465d258d",
"sha256:e67ccd53da5958ea1ec833a160b96357f90859c220a00150de011b787c27b98d",
"sha256:e8071e7d9ba9f457fc674afc3de054450be2c9b195c470147fbbc082468d8ff7",
"sha256:fff16a30fdf57b214778eff86391301c4509e327a65b877862f7c929f10a4253"
],
"markers": "python_version >= '3.7'",
"version": "==6.3"
},
"docutils": {
"hashes": [
"sha256:23010f129180089fbcd3bc08cfefccb3b890b0050e1ca00c867036e9d161b98c",
"sha256:679987caf361a7539d76e584cbeddc311e3aee937877c87346f31debc63e9d06"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==0.18.1"
},
"flake8": {
"hashes": [
"sha256:479b1304f72536a55948cb40a32dce8bb0ffe3501e26eaf292c7e60eb5e0428d",
"sha256:806e034dda44114815e23c16ef92f95c91e4c71100ff52813adf7132a6ad870d"
],
"index": "pypi",
"version": "==4.0.1"
},
"flake8-noqa": {
"hashes": [
"sha256:629b87b542f9b4cbd7ee6de10b2c669e460a200145a7577b98092b7e94373153"
],
"index": "pypi",
"version": "==1.2.1"
},
"flake8-polyfill": {
"hashes": [
"sha256:12be6a34ee3ab795b19ca73505e7b55826d5f6ad7230d31b18e106400169b9e9",
"sha256:e44b087597f6da52ec6393a709e7108b2905317d0c0b744cdca6208e670d8eda"
],
"version": "==1.0.2"
},
"idna": {
"hashes": [
"sha256:84d9dd047ffa80596e0f246e2eab0b391788b0503584e8945f2368256d2735ff",
"sha256:9d643ff0a55b762d5cdb124b8eaa99c66322e2157b69160bc32796e824360e6d"
],
"markers": "python_version >= '3'",
"version": "==3.3"
},
"importlib-metadata": {
"hashes": [
"sha256:899e2a40a8c4a1aec681feef45733de8a6c58f3f6a0dbed2eb6574b4387a77b6",
"sha256:951f0d8a5b7260e9db5e41d429285b5f451e928479f19d80818878527d36e95e"
],
"markers": "python_version >= '3.7'",
"version": "==4.10.1"
},
"iniconfig": {
"hashes": [
"sha256:011e24c64b7f47f6ebd835bb12a743f2fbe9a26d4cecaa7f53bc4f35ee9da8b3",
"sha256:bc3af051d7d14b2ee5ef9969666def0cd1a000e121eaea580d4a313df4b37f32"
],
"version": "==1.1.1"
},
"jinja2": {
"hashes": [
"sha256:077ce6014f7b40d03b47d1f1ca4b0fc8328a692bd284016f806ed0eaca390ad8",
"sha256:611bb273cd68f3b993fabdc4064fc858c5b47a973cb5aa7999ec1ba405c87cd7"
],
"markers": "python_version >= '3.6'",
"version": "==3.0.3"
},
"keyring": {
"hashes": [
"sha256:9012508e141a80bd1c0b6778d5c610dd9f8c464d75ac6774248500503f972fb9",
"sha256:b0d28928ac3ec8e42ef4cc227822647a19f1d544f21f96457965dc01cf555261"
],
"markers": "python_version >= '3.7'",
"version": "==23.5.0"
},
"markupsafe": {
"hashes": [
"sha256:01a9b8ea66f1658938f65b93a85ebe8bc016e6769611be228d797c9d998dd298",
"sha256:023cb26ec21ece8dc3907c0e8320058b2e0cb3c55cf9564da612bc325bed5e64",
"sha256:0446679737af14f45767963a1a9ef7620189912317d095f2d9ffa183a4d25d2b",
"sha256:04635854b943835a6ea959e948d19dcd311762c5c0c6e1f0e16ee57022669194",
"sha256:0717a7390a68be14b8c793ba258e075c6f4ca819f15edfc2a3a027c823718567",
"sha256:0955295dd5eec6cb6cc2fe1698f4c6d84af2e92de33fbcac4111913cd100a6ff",
"sha256:0d4b31cc67ab36e3392bbf3862cfbadac3db12bdd8b02a2731f509ed5b829724",
"sha256:10f82115e21dc0dfec9ab5c0223652f7197feb168c940f3ef61563fc2d6beb74",
"sha256:168cd0a3642de83558a5153c8bd34f175a9a6e7f6dc6384b9655d2697312a646",
"sha256:1d609f577dc6e1aa17d746f8bd3c31aa4d258f4070d61b2aa5c4166c1539de35",
"sha256:1f2ade76b9903f39aa442b4aadd2177decb66525062db244b35d71d0ee8599b6",
"sha256:20dca64a3ef2d6e4d5d615a3fd418ad3bde77a47ec8a23d984a12b5b4c74491a",
"sha256:2a7d351cbd8cfeb19ca00de495e224dea7e7d919659c2841bbb7f420ad03e2d6",
"sha256:2d7d807855b419fc2ed3e631034685db6079889a1f01d5d9dac950f764da3dad",
"sha256:2ef54abee730b502252bcdf31b10dacb0a416229b72c18b19e24a4509f273d26",
"sha256:36bc903cbb393720fad60fc28c10de6acf10dc6cc883f3e24ee4012371399a38",
"sha256:37205cac2a79194e3750b0af2a5720d95f786a55ce7df90c3af697bfa100eaac",
"sha256:3c112550557578c26af18a1ccc9e090bfe03832ae994343cfdacd287db6a6ae7",
"sha256:3dd007d54ee88b46be476e293f48c85048603f5f516008bee124ddd891398ed6",
"sha256:4296f2b1ce8c86a6aea78613c34bb1a672ea0e3de9c6ba08a960efe0b0a09047",
"sha256:47ab1e7b91c098ab893b828deafa1203de86d0bc6ab587b160f78fe6c4011f75",
"sha256:49e3ceeabbfb9d66c3aef5af3a60cc43b85c33df25ce03d0031a608b0a8b2e3f",
"sha256:4dc8f9fb58f7364b63fd9f85013b780ef83c11857ae79f2feda41e270468dd9b",
"sha256:4efca8f86c54b22348a5467704e3fec767b2db12fc39c6d963168ab1d3fc9135",
"sha256:53edb4da6925ad13c07b6d26c2a852bd81e364f95301c66e930ab2aef5b5ddd8",
"sha256:5855f8438a7d1d458206a2466bf82b0f104a3724bf96a1c781ab731e4201731a",
"sha256:594c67807fb16238b30c44bdf74f36c02cdf22d1c8cda91ef8a0ed8dabf5620a",
"sha256:5b6d930f030f8ed98e3e6c98ffa0652bdb82601e7a016ec2ab5d7ff23baa78d1",
"sha256:5bb28c636d87e840583ee3adeb78172efc47c8b26127267f54a9c0ec251d41a9",
"sha256:60bf42e36abfaf9aff1f50f52644b336d4f0a3fd6d8a60ca0d054ac9f713a864",
"sha256:611d1ad9a4288cf3e3c16014564df047fe08410e628f89805e475368bd304914",
"sha256:6300b8454aa6930a24b9618fbb54b5a68135092bc666f7b06901f897fa5c2fee",
"sha256:63f3268ba69ace99cab4e3e3b5840b03340efed0948ab8f78d2fd87ee5442a4f",
"sha256:6557b31b5e2c9ddf0de32a691f2312a32f77cd7681d8af66c2692efdbef84c18",
"sha256:693ce3f9e70a6cf7d2fb9e6c9d8b204b6b39897a2c4a1aa65728d5ac97dcc1d8",
"sha256:6a7fae0dd14cf60ad5ff42baa2e95727c3d81ded453457771d02b7d2b3f9c0c2",
"sha256:6c4ca60fa24e85fe25b912b01e62cb969d69a23a5d5867682dd3e80b5b02581d",
"sha256:6fcf051089389abe060c9cd7caa212c707e58153afa2c649f00346ce6d260f1b",
"sha256:7d91275b0245b1da4d4cfa07e0faedd5b0812efc15b702576d103293e252af1b",
"sha256:89c687013cb1cd489a0f0ac24febe8c7a666e6e221b783e53ac50ebf68e45d86",
"sha256:8d206346619592c6200148b01a2142798c989edcb9c896f9ac9722a99d4e77e6",
"sha256:905fec760bd2fa1388bb5b489ee8ee5f7291d692638ea5f67982d968366bef9f",
"sha256:97383d78eb34da7e1fa37dd273c20ad4320929af65d156e35a5e2d89566d9dfb",
"sha256:984d76483eb32f1bcb536dc27e4ad56bba4baa70be32fa87152832cdd9db0833",
"sha256:99df47edb6bda1249d3e80fdabb1dab8c08ef3975f69aed437cb69d0a5de1e28",
"sha256:9f02365d4e99430a12647f09b6cc8bab61a6564363f313126f775eb4f6ef798e",
"sha256:a30e67a65b53ea0a5e62fe23682cfe22712e01f453b95233b25502f7c61cb415",
"sha256:ab3ef638ace319fa26553db0624c4699e31a28bb2a835c5faca8f8acf6a5a902",
"sha256:aca6377c0cb8a8253e493c6b451565ac77e98c2951c45f913e0b52facdcff83f",
"sha256:add36cb2dbb8b736611303cd3bfcee00afd96471b09cda130da3581cbdc56a6d",
"sha256:b2f4bf27480f5e5e8ce285a8c8fd176c0b03e93dcc6646477d4630e83440c6a9",
"sha256:b7f2d075102dc8c794cbde1947378051c4e5180d52d276987b8d28a3bd58c17d",
"sha256:baa1a4e8f868845af802979fcdbf0bb11f94f1cb7ced4c4b8a351bb60d108145",
"sha256:be98f628055368795d818ebf93da628541e10b75b41c559fdf36d104c5787066",
"sha256:bf5d821ffabf0ef3533c39c518f3357b171a1651c1ff6827325e4489b0e46c3c",
"sha256:c47adbc92fc1bb2b3274c4b3a43ae0e4573d9fbff4f54cd484555edbf030baf1",
"sha256:cdfba22ea2f0029c9261a4bd07e830a8da012291fbe44dc794e488b6c9bb353a",
"sha256:d6c7ebd4e944c85e2c3421e612a7057a2f48d478d79e61800d81468a8d842207",
"sha256:d7f9850398e85aba693bb640262d3611788b1f29a79f0c93c565694658f4071f",
"sha256:d8446c54dc28c01e5a2dbac5a25f071f6653e6e40f3a8818e8b45d790fe6ef53",
"sha256:deb993cacb280823246a026e3b2d81c493c53de6acfd5e6bfe31ab3402bb37dd",
"sha256:e0f138900af21926a02425cf736db95be9f4af72ba1bb21453432a07f6082134",
"sha256:e9936f0b261d4df76ad22f8fee3ae83b60d7c3e871292cd42f40b81b70afae85",
"sha256:f0567c4dc99f264f49fe27da5f735f414c4e7e7dd850cfd8e69f0862d7c74ea9",
"sha256:f5653a225f31e113b152e56f154ccbe59eeb1c7487b39b9d9f9cdb58e6c79dc5",
"sha256:f826e31d18b516f653fe296d967d700fddad5901ae07c622bb3705955e1faa94",
"sha256:f8ba0e8349a38d3001fae7eadded3f6606f0da5d748ee53cc1dab1d6527b9509",
"sha256:f9081981fe268bd86831e5c75f7de206ef275defcb82bc70740ae6dc507aee51",
"sha256:fa130dd50c57d53368c9d59395cb5526eda596d3ffe36666cd81a44d56e48872"
],
"markers": "python_version >= '3.6'",
"version": "==2.0.1"
},
"mccabe": {
"hashes": [
"sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42",
"sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f"
],
"version": "==0.6.1"
},
"mypy": {
"hashes": [
"sha256:0038b21890867793581e4cb0d810829f5fd4441aa75796b53033af3aa30430ce",
"sha256:1171f2e0859cfff2d366da2c7092b06130f232c636a3f7301e3feb8b41f6377d",
"sha256:1b06268df7eb53a8feea99cbfff77a6e2b205e70bf31743e786678ef87ee8069",
"sha256:1b65714dc296a7991000b6ee59a35b3f550e0073411ac9d3202f6516621ba66c",
"sha256:1bf752559797c897cdd2c65f7b60c2b6969ffe458417b8d947b8340cc9cec08d",
"sha256:300717a07ad09525401a508ef5d105e6b56646f7942eb92715a1c8d610149714",
"sha256:3c5b42d0815e15518b1f0990cff7a705805961613e701db60387e6fb663fe78a",
"sha256:4365c60266b95a3f216a3047f1d8e3f895da6c7402e9e1ddfab96393122cc58d",
"sha256:50c7346a46dc76a4ed88f3277d4959de8a2bd0a0fa47fa87a4cde36fe247ac05",
"sha256:5b56154f8c09427bae082b32275a21f500b24d93c88d69a5e82f3978018a0266",
"sha256:74f7eccbfd436abe9c352ad9fb65872cc0f1f0a868e9d9c44db0893440f0c697",
"sha256:7b3f6f557ba4afc7f2ce6d3215d5db279bcf120b3cfd0add20a5d4f4abdae5bc",
"sha256:8c11003aaeaf7cc2d0f1bc101c1cc9454ec4cc9cb825aef3cafff8a5fdf4c799",
"sha256:8ca7f8c4b1584d63c9a0f827c37ba7a47226c19a23a753d52e5b5eddb201afcd",
"sha256:c89702cac5b302f0c5d33b172d2b55b5df2bede3344a2fbed99ff96bddb2cf00",
"sha256:d8f1ff62f7a879c9fe5917b3f9eb93a79b78aad47b533911b853a757223f72e7",
"sha256:d9d2b84b2007cea426e327d2483238f040c49405a6bf4074f605f0156c91a47a",
"sha256:e839191b8da5b4e5d805f940537efcaa13ea5dd98418f06dc585d2891d228cf0",
"sha256:f9fe20d0872b26c4bba1c1be02c5340de1019530302cf2dcc85c7f9fc3252ae0",
"sha256:ff3bf387c14c805ab1388185dd22d6b210824e164d4bb324b195ff34e322d166"
],
"index": "pypi",
"version": "==0.931"
},
"mypy-extensions": {
"hashes": [
"sha256:090fedd75945a69ae91ce1303b5824f428daf5a028d2f6ab8a299250a846f15d",
"sha256:2d82818f5bb3e369420cb3c4060a7970edba416647068eb4c5343488a6c604a8"
],
"version": "==0.4.3"
},
"packaging": {
"hashes": [
"sha256:dd47c42927d89ab911e606518907cc2d3a1f38bbd026385970643f9c5b8ecfeb",
"sha256:ef103e05f519cdc783ae24ea4e2e0f508a9c99b2d4969652eed6a2e1ea5bd522"
],
"markers": "python_version >= '3.6'",
"version": "==21.3"
},
"pdoc": {
"hashes": [
"sha256:de3784b5692397fa2f9ddb57a61513a1cabf38281f084aef32b5ed4ee6fbc09e"
],
"index": "pypi",
"version": "==9.0.1"
},
"pep517": {
"hashes": [
"sha256:931378d93d11b298cf511dd634cf5ea4cb249a28ef84160b3247ee9afb4e8ab0",
"sha256:dd884c326898e2c6e11f9e0b64940606a93eb10ea022a2e067959f3a110cf161"
],
"version": "==0.12.0"
},
"pep8-naming": {
"hashes": [
"sha256:4a8daeaeb33cfcde779309fc0c9c0a68a3bbe2ad8a8308b763c5068f86eb9f37",
"sha256:bb2455947757d162aa4cad55dba4ce029005cd1692f2899a21d51d8630ca7841"
],
"index": "pypi",
"version": "==0.12.1"
},
"pkginfo": {
"hashes": [
"sha256:542e0d0b6750e2e21c20179803e40ab50598d8066d51097a0e382cba9eb02bff",
"sha256:c24c487c6a7f72c66e816ab1796b96ac6c3d14d49338293d2141664330b55ffc"
],
"version": "==1.8.2"
},
"pluggy": {
"hashes": [
"sha256:4224373bacce55f955a878bf9cfa763c1e360858e330072059e10bad68531159",
"sha256:74134bbf457f031a36d68416e1509f34bd5ccc019f0bcc952c7b909d06b37bd3"
],
"markers": "python_version >= '3.6'",
"version": "==1.0.0"
},
"py": {
"hashes": [
"sha256:51c75c4126074b472f746a24399ad32f6053d1b34b68d2fa41e558e6f4a98719",
"sha256:607c53218732647dff4acdfcd50cb62615cedf612e72d1724fb1a0cc6405b378"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==1.11.0"
},
"pycodestyle": {
"hashes": [
"sha256:720f8b39dde8b293825e7ff02c475f3077124006db4f440dcbc9a20b76548a20",
"sha256:eddd5847ef438ea1c7870ca7eb78a9d47ce0cdb4851a5523949f2601d0cbbe7f"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4'",
"version": "==2.8.0"
},
"pyflakes": {
"hashes": [
"sha256:05a85c2872edf37a4ed30b0cce2f6093e1d0581f8c19d7393122da7e25b2b24c",
"sha256:3bb3a3f256f4b7968c9c788781e4ff07dce46bdf12339dcda61053375426ee2e"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==2.4.0"
},
"pygments": {
"hashes": [
"sha256:44238f1b60a76d78fc8ca0528ee429702aae011c265fe6a8dd8b63049ae41c65",
"sha256:4e426f72023d88d03b2fa258de560726ce890ff3b630f88c21cbb8b2503b8c6a"
],
"markers": "python_version >= '3.5'",
"version": "==2.11.2"
},
"pyparsing": {
"hashes": [
"sha256:18ee9022775d270c55187733956460083db60b37d0d0fb357445f3094eed3eea",
"sha256:a6c06a88f252e6c322f65faf8f418b16213b51bdfaece0524c1c1bc30c63c484"
],
"markers": "python_version >= '3.6'",
"version": "==3.0.7"
},
"pyproject-flake8": {
"hashes": [
"sha256:bdeca37f78ecd34bd64a49d3657d53d099f5445831071a31c46e1fe20cd61461",
"sha256:e61ed1dc088e9f9f8a7170967ac4ec135acfef3a59ab9738c7b58cc11f294a7e"
],
"index": "pypi",
"version": "==0.0.1a2"
},
"pytest": {
"hashes": [
"sha256:131b36680866a76e6781d13f101efb86cf674ebb9762eb70d3082b6f29889e89",
"sha256:7310f8d27bc79ced999e760ca304d69f6ba6c6649c0b60fb0e04a4a77cacc134"
],
"index": "pypi",
"version": "==6.2.5"
},
"pytest-cov": {
"hashes": [
"sha256:578d5d15ac4a25e5f961c938b85a05b09fdaae9deef3bb6de9a6e766622ca7a6",
"sha256:e7f0f5b1617d2210a2cabc266dfe2f4c75a8d32fb89eafb7ad9d06f6d076d470"
],
"index": "pypi",
"version": "==3.0.0"
},
"readme-renderer": {
"hashes": [
"sha256:a50a0f2123a4c1145ac6f420e1a348aafefcc9211c846e3d51df05fe3d865b7d",
"sha256:b512beafa6798260c7d5af3e1b1f097e58bfcd9a575da7c4ddd5e037490a5b85"
],
"markers": "python_version >= '3.6'",
"version": "==32.0"
},
"requests": {
"extras": [
"socks"
],
"hashes": [
"sha256:68d7c56fd5a8999887728ef304a6d12edc7be74f1cfa47714fc8b414525c9a61",
"sha256:f22fa1e554c9ddfd16e6e41ac79759e17be9e492b3587efa038054674760e72d"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'",
"version": "==2.27.1"
},
"requests-toolbelt": {
"hashes": [
"sha256:380606e1d10dc85c3bd47bf5a6095f815ec007be7a8b69c878507068df059e6f",
"sha256:968089d4584ad4ad7c171454f0a5c6dac23971e9472521ea3b6d49d610aa6fc0"
],
"version": "==0.9.1"
},
"rfc3986": {
"hashes": [
"sha256:50b1502b60e289cb37883f3dfd34532b8873c7de9f49bb546641ce9cbd256ebd",
"sha256:97aacf9dbd4bfd829baad6e6309fa6573aaf1be3f6fa735c8ab05e46cecb261c"
],
"markers": "python_version >= '3.7'",
"version": "==2.0.0"
},
"six": {
"hashes": [
"sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926",
"sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.16.0"
},
"toml": {
"hashes": [
"sha256:806143ae5bfb6a3c6e736a764057db0e6a0e05e338b5630894a5f779cabb4f9b",
"sha256:b3bda1d108d5dd99f4a20d24d9c348e91c4db7ab1b749200bded2f839ccbe68f"
],
"markers": "python_version >= '2.6' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==0.10.2"
},
"tomli": {
"hashes": [
"sha256:b5bde28da1fed24b9bd1d4d2b8cba62300bfb4ec9a6187a957e8ddb9434c5224",
"sha256:c292c34f58502a1eb2bbb9f5bbc9a5ebc37bee10ffb8c2d6bbdfa8eb13cc14e1"
],
"markers": "python_version >= '3.7'",
"version": "==2.0.0"
},
"tqdm": {
"hashes": [
"sha256:8dd278a422499cd6b727e6ae4061c40b48fce8b76d1ccbf5d34fca9b7f925b0c",
"sha256:d359de7217506c9851b7869f3708d8ee53ed70a1b8edbba4dbcb47442592920d"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==4.62.3"
},
"twine": {
"hashes": [
"sha256:28460a3db6b4532bde6a5db6755cf2dce6c5020bada8a641bb2c5c7a9b1f35b8",
"sha256:8c120845fc05270f9ee3e9d7ebbed29ea840e41f48cd059e04733f7e1d401345"
],
"index": "pypi",
"version": "==3.7.1"
},
"types-requests": {
"hashes": [
"sha256:8ec9f5f84adc6f579f53943312c28a84e87dc70201b54f7c4fbc7d22ecfa8a3e",
"sha256:c2f4e4754d07ca0a88fd8a89bbc6c8a9f90fb441f9c9b572fd5c484f04817486"
],
"index": "pypi",
"version": "==2.27.8"
},
"types-urllib3": {
"hashes": [
"sha256:a929c68a57b24eee8f7003357b935b802e17767e752d9237266f799ca48ee326",
"sha256:aa0de26893f138523d5552bbb023826c0cc7ea5749d80c1693c57aab7b55f469"
],
"version": "==1.26.8"
},
"typing-extensions": {
"hashes": [
"sha256:4ca091dea149f945ec56afb48dae714f21e8692ef22a395223bcd328961b6a0e",
"sha256:7f001e5ac290a0c0401508864c7ec868be4e701886d5b573a9528ed3973d9d3b"
],
"markers": "python_version >= '3.6'",
"version": "==4.0.1"
},
"urllib3": {
"hashes": [
"sha256:000ca7f471a233c2251c6c7023ee85305721bfdf18621ebff4fd17a8653427ed",
"sha256:0e7c33d9a63e7ddfcb86780aac87befc2fbddf46c58dbb487e0855f7ceec283c"
],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4' and python_version < '4'",
"version": "==1.26.8"
},
"webencodings": {
"hashes": [
"sha256:a0af1213f3c2226497a97e2b3aa01a7e4bee4f403f95be16fc9acd2947514a78",
"sha256:b36a1c245f2d304965eb4e0a82848379241dc04b865afcc4aab16748587e1923"
],
"version": "==0.5.1"
},
"wheel": {
"hashes": [
"sha256:4bdcd7d840138086126cd09254dc6195fb4fc6f01c050a1d7236f2630db1d22a",
"sha256:e9a504e793efbca1b8e0e9cb979a249cf4a0a7b5b8c9e8b65a5e39d49529c1c4"
],
"index": "pypi",
"version": "==0.37.1"
},
"zipp": {
"hashes": [
"sha256:9f50f446828eb9d45b267433fd3e9da8d801f614129124863f9c51ebceafb87d",
"sha256:b47250dd24f92b7dd6a0a8fc5244da14608f3ca90a5efcd37a3b1642fac9a375"
],
"markers": "python_version >= '3.7'",
"version": "==3.7.0"
}
}
}

52
README.md Normal file
View File

@ -0,0 +1,52 @@
# Python 3 library and CLI for [SporeStack](https://sporestack.com) [.onion](http://spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion)
## Requirements
* Python 3.7-3.10 (or maybe newer)
## Installation
* `pip install sporestack`
* Recommended: Create a virtual environment, first. Can use `pipenv`, as well.
## Running without installing (preferred)
* Make sure `pipx` is installed.
* `pipx run sporestack`
* Make sure you're on the latest version with `sporestack version`.
## Screenshot
![sporestack CLI screenshot](https://sporestack.com/static/sporestackv2-screenshot.png)
## Usage
* `sporestack launch SomeHostname --flavor vps-1vcpu-1gb --days 7 --ssh-key ~/.ssh/id_rsa.pub --operating-system debian-10 --currency btc`
* `sporestack topup SomeHostname --days 3 --currency xmr`
* `sporestack launch SomeOtherHostname --flavor vps-1vcpu-2gb --days 7 --ssh-key ~/.ssh/id_rsa.pub --operating-system debian-11 --currency btc`
* `sporestack stop SomeHostname`
* `sporestack start SomeHostname`
* `sporestack list`
* `sporestack remove SomeHostname # If expired`
* `sporestack settlement-token-generate`
* `sporestack settlement-token-enable (token) --dollars 10 --currency xmr`
* `sporestack settlement-token-add (token) --dollars 25 --currency btc`
* `sporestack settlement-token-balance (token)`
More examples on the [website](https://sporestack.com).
## Notes
* You can use `--settlement-token` if you don't want to pay with QR codes all the time.
* If using a .onion API endpoint, will try to use a local Tor proxy if connecting to a .onion URL. (127.0.0.1:9050)
## Developing
* `pip install pipenv pre-commit`
* `pipenv install --deploy --dev`
* `pipenv run make test` (If you don't have `make`, use `almake`)
* Hint: `pre-commit run` is a faster way to run some of the tests/autofixers.
## Licence
[Unlicense/Public domain](LICENSE.txt)

30
pyproject.toml Normal file
View File

@ -0,0 +1,30 @@
[tool.coverage.report]
show_missing = true
[tool.coverage.run]
omit = ["tests/*", "build/*"]
# Have to use `pflake8` instead of `flake8`
[tool.flake8]
max-line-length = 88
noqa-require-code = "true"
exclude = ".git,__pycache__,build,dist"
max-complexity = 15
[tool.isort]
profile = "black"
[tool.mypy]
files = "."
plugins = ["pydantic.mypy"]
exclude = "(build|site-packages|__pycache__)"
[tool.pydantic-mypy]
init_forbid_extra = true
init_typed = true
warn_required_dynamic_aliases = true
warn_untyped_fields = true
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

50
setup.cfg Normal file
View File

@ -0,0 +1,50 @@
[metadata]
name = sporestack
version = 5.2.1
description = SporeStack.com library and client. Launch servers with Monero or Bitcoin.
long_description = file: README.md
long_description_content_type = text/markdown
url = https://sporestack.com/
author = SporeStack
author_email = admin@sporestack.com
license = Unlicense
license_file = LICENSE.txt
classifiers =
Programming Language :: Python :: 3
Programming Language :: Python :: 3 :: Only
Programming Language :: Python :: 3.7
Programming Language :: Python :: 3.8
Programming Language :: Python :: 3.9
Programming Language :: Python :: 3.10
keywords =
bitcoin
bitcoincash
bitcoinsv
monero
servers
infrastructure
vps
virtual private server
[options]
packages = find:
install_requires =
pydantic
requests[socks]>=2.22.0
segno
typer
importlib-metadata;python_version<"3.8"
python_requires = >=3.7
package_dir = =src
zip_safe = False
[options.packages.find]
where = src
[options.entry_points]
console_scripts =
sporestack = sporestack.cli:cli
[options.package_data]
sporestack =
py.typed

View File

@ -0,0 +1 @@
__all__ = ["api", "api_client", "exceptions"]

136
src/sporestack/api.py Normal file
View File

@ -0,0 +1,136 @@
"""
SporeStack API request/response models
"""
from typing import List, Optional
from pydantic import BaseModel
from .models import NetworkInterface, Payment
class TokenEnable:
url = "/token/{token}/enable"
method = "POST"
class Request(BaseModel):
currency: str
dollars: int
class Response(BaseModel):
token: str
payment: Payment
class TokenAdd:
url = "/token/{token}/add"
method = "POST"
class Request(BaseModel):
currency: str
dollars: int
class Response(BaseModel):
token: str
payment: Payment
class TokenBalance:
url = "/token/{token}/balance"
method = "GET"
class Response(BaseModel):
token: str
cents: int
usd: str
class ServerLaunch:
url = "/server/{machine_id}/launch"
method = "POST"
class Request(BaseModel):
machine_id: str
days: int
currency: str
flavor: str
ssh_key: str
operating_system: str
region: Optional[str]
organization: Optional[str]
settlement_token: Optional[str]
affiliate_amount: Optional[int]
affiliate_token: Optional[str]
class Response(BaseModel):
created_at: Optional[int]
payment: Payment
expiration: Optional[int]
machine_id: str
network_interfaces: List[NetworkInterface]
region: str
latest_api_version: int
created: bool
paid: bool
warning: Optional[str]
txid: Optional[str]
operating_system: str
flavor: str
class ServerTopup:
url = "/server/{machine_id}/topup"
method = "POST"
class Request(BaseModel):
machine_id: str
days: int
currency: str
settlement_token: Optional[str]
affiliate_amount: Optional[int]
affiliate_token: Optional[str]
class Response(BaseModel):
machine_id: str
payment: Payment
paid: bool
warning: Optional[str]
expiration: int
txid: Optional[str]
latest_api_version: int
class ServerInfo:
url = "/server/{machine_id}/info"
method = "GET"
class Response(BaseModel):
created_at: int
expiration: int
running: bool
machine_id: str
network_interfaces: List[NetworkInterface]
region: str
class ServerStart:
url = "/server/{machine_id}/start"
method = "POST"
class ServerStop:
url = "/server/{machine_id}/stop"
method = "POST"
class ServerDelete:
url = "/server/{machine_id}/delete"
method = "POST"
class ServerRebuild:
url = "/server/{machine_id}/rebuild"
method = "POST"

View File

@ -0,0 +1,274 @@
import logging
import os
from time import sleep
from typing import Any, Dict, Optional
import requests
from . import api, exceptions
from .version import __version__
log = logging.getLogger(__name__)
LATEST_API_VERSION = 2
CLEARNET_ENDPOINT = "https://api.sporestack.com"
TOR_ENDPOINT = (
"http://api.spore64i5sofqlfz5gq2ju4msgzojjwifls7rok2cti624zyq3fcelad.onion"
)
API_ENDPOINT = CLEARNET_ENDPOINT
GET_TIMEOUT = 60
POST_TIMEOUT = 90
USE_TOR_PROXY = "auto"
def _get_tor_proxy() -> str:
"""
This makes testing easier.
"""
return os.getenv("TOR_PROXY", "socks5h://127.0.0.1:9050")
# For requests module
TOR_PROXY_REQUESTS = {"http": _get_tor_proxy(), "https": _get_tor_proxy()}
def _is_onion_url(url: str) -> bool:
"""
returns True/False depending on if a URL looks like a Tor hidden service
(.onion) or not.
This is designed to false as non-onion just to be on the safe-ish side,
depending on your view point. It requires URLs like: http://domain.tld/,
not http://domain.tld or domain.tld/.
This can be optimized a lot.
"""
try:
url_parts = url.split("/")
domain = url_parts[2]
tld = domain.split(".")[-1]
if tld == "onion":
return True
except Exception:
pass
return False
def _api_request(
url: str,
empty_post: bool = False,
json_params: Optional[Dict[str, Any]] = None,
retry: bool = False,
) -> Any:
headers = {"User-Agent": f"sporestack-python/{__version__}"}
proxies = {}
if _is_onion_url(url) is True:
log.debug("Got a .onion API endpoint, using local Tor SOCKS proxy.")
proxies = TOR_PROXY_REQUESTS
try:
if empty_post is True:
request = requests.post(
url, timeout=POST_TIMEOUT, proxies=proxies, headers=headers
)
elif json_params is None:
request = requests.get(
url, timeout=GET_TIMEOUT, proxies=proxies, headers=headers
)
else:
request = requests.post(
url,
json=json_params,
timeout=POST_TIMEOUT,
proxies=proxies,
headers=headers,
)
except Exception as e:
if retry is True:
log.warning(f"Got an error, but retrying: {e}")
sleep(5)
# Try again.
return _api_request(
url,
empty_post=empty_post,
json_params=json_params,
retry=retry,
)
else:
raise
status_code_first_digit = request.status_code // 100
if status_code_first_digit == 2:
try:
return request.json()
except Exception:
return request.content
elif status_code_first_digit == 4:
log.debug("HTTP status code: {request.status_code}")
raise exceptions.SporeStackUserError(request.content.decode("utf-8"))
elif status_code_first_digit == 5:
if retry is True:
log.warning(request.content.decode("utf-8"))
log.warning("Got a 500, retrying in 5 seconds...")
sleep(5)
# Try again if we get a 500
return _api_request(
url,
empty_post=empty_post,
json_params=json_params,
retry=retry,
)
else:
raise exceptions.SporeStackServerError(str(request.content))
else:
# Not sure why we'd get this.
request.raise_for_status()
raise Exception("Stuff broke strangely. Please contact SporeStack support.")
def launch(
machine_id: str,
days: int,
currency: str,
flavor: str,
operating_system: str,
ssh_key: str,
api_endpoint: str = API_ENDPOINT,
region: Optional[str] = None,
settlement_token: Optional[str] = None,
retry: bool = False,
affiliate_amount: Optional[int] = None,
affiliate_token: Optional[str] = None,
) -> api.ServerLaunch.Response:
request = api.ServerLaunch.Request(
machine_id=machine_id,
days=days,
currency=currency,
settlement_token=settlement_token,
affiliate_amount=affiliate_amount,
affiliate_token=affiliate_token,
flavor=flavor,
region=region,
operating_system=operating_system,
ssh_key=ssh_key,
)
url = api_endpoint + api.ServerLaunch.url.format(machine_id=machine_id)
response = _api_request(url=url, json_params=request.dict(), retry=retry)
response_object = api.ServerLaunch.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object
def topup(
machine_id: str,
days: int,
currency: str,
api_endpoint: str = API_ENDPOINT,
settlement_token: Optional[str] = None,
retry: bool = False,
affiliate_amount: Optional[int] = None,
affiliate_token: Optional[str] = None,
) -> api.ServerTopup.Response:
"""
Topup a server.
"""
request = api.ServerTopup.Request(
machine_id=machine_id,
days=days,
currency=currency,
settlement_token=settlement_token,
affiliate_amount=affiliate_amount,
affiliate_token=affiliate_token,
)
url = api_endpoint + api.ServerTopup.url.format(machine_id=machine_id)
response = _api_request(url=url, json_params=request.dict(), retry=retry)
response_object = api.ServerTopup.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object
def start(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
"""
Boots the server.
"""
url = api_endpoint + api.ServerStart.url.format(machine_id=machine_id)
_api_request(url, empty_post=True)
def stop(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
"""
Powers off the server.
"""
url = api_endpoint + api.ServerStop.url.format(machine_id=machine_id)
_api_request(url, empty_post=True)
def delete(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
"""
Deletes the server.
"""
url = api_endpoint + api.ServerDelete.url.format(machine_id=machine_id)
_api_request(url, empty_post=True)
def rebuild(machine_id: str, api_endpoint: str = API_ENDPOINT) -> None:
"""
Rebuilds the server with the operating system and SSH key set at launch time.
Deletes all of the data on the server!
"""
url = api_endpoint + api.ServerRebuild.url.format(machine_id=machine_id)
_api_request(url, empty_post=True)
def info(machine_id: str, api_endpoint: str = API_ENDPOINT) -> api.ServerInfo.Response:
"""
Returns info about the server.
"""
url = api_endpoint + api.ServerInfo.url.format(machine_id=machine_id)
response = _api_request(url)
response_object = api.ServerInfo.Response.parse_obj(response)
assert response_object.machine_id == machine_id
return response_object
def token_enable(
token: str,
dollars: int,
currency: str,
api_endpoint: str = API_ENDPOINT,
retry: bool = False,
) -> api.TokenEnable.Response:
request = api.TokenEnable.Request(dollars=dollars, currency=currency)
url = api_endpoint + api.TokenEnable.url.format(token=token)
response = _api_request(url=url, json_params=request.dict(), retry=retry)
response_object = api.TokenEnable.Response.parse_obj(response)
assert response_object.token == token
return response_object
def token_add(
token: str,
dollars: int,
currency: str,
api_endpoint: str = API_ENDPOINT,
retry: bool = False,
) -> api.TokenAdd.Response:
request = api.TokenAdd.Request(dollars=dollars, currency=currency)
url = api_endpoint + api.TokenAdd.url.format(token=token)
response = _api_request(url=url, json_params=request.dict(), retry=retry)
response_object = api.TokenAdd.Response.parse_obj(response)
assert response_object.token == token
return response_object
def token_balance(
token: str, api_endpoint: str = API_ENDPOINT
) -> api.TokenBalance.Response:
url = api_endpoint + api.TokenBalance.url.format(token=token)
response = _api_request(url=url)
response_object = api.TokenBalance.Response.parse_obj(response)
assert response_object.token == token
return response_object

593
src/sporestack/cli.py Normal file
View File

@ -0,0 +1,593 @@
"""
SporeStack CLI: `sporestack`
"""
import importlib.util
import json
import logging
import os
import sys
import time
from pathlib import Path
from types import ModuleType
from typing import TYPE_CHECKING, Any, Dict, Optional
if sys.version_info[:2] >= (3, 8): # pragma: nocover
from importlib.metadata import version as importlib_metadata_version
else: # pragma: nocover
# Python 3.7 doesn't have this.
from importlib_metadata import version as importlib_metadata_version
import typer
def lazy_import(name: str) -> ModuleType:
"""
Lazily import a module. Helps speed up CLI performance.
"""
spec = importlib.util.find_spec(name)
assert spec is not None
assert spec.loader is not None
loader = importlib.util.LazyLoader(spec.loader)
spec.loader = loader
module = importlib.util.module_from_spec(spec)
sys.modules[name] = module
loader.exec_module(module)
return module
# For mypy
if TYPE_CHECKING:
from . import api_client
else:
api_client = lazy_import("sporestack.api_client")
HELP = """
SporeStack Python CLI
Optional environment variables:
SPORESTACK_ENDPOINT
*or*
SPORESTACK_USE_TOR_ENDPOINT
TOR_PROXY (defaults to socks5h://127.0.0.1:9050 which is fine for most)
"""
cli = typer.Typer(help=HELP)
logging.basicConfig(level=logging.INFO)
DEFAULT_FLAVOR = "vps-1vcpu-1gb"
WAITING_PAYMENT_TO_PROCESS = "Waiting for payment to process..."
def get_api_endpoint() -> str:
api_endpoint = os.getenv("SPORESTACK_ENDPOINT", api_client.CLEARNET_ENDPOINT)
if os.getenv("SPORESTACK_USE_TOR_ENDPOINT", None) is not None:
api_endpoint = api_client.TOR_ENDPOINT
return api_endpoint
def make_payment(currency: str, uri: str, usd: str) -> None:
import segno
premessage = """Payment URI: {}
Pay *exactly* the specified amount. No more, no less. Pay within
one hour at the very most.
Resize your terminal and try again if QR code above is not readable.
Press ctrl+c to abort."""
message = premessage.format(uri)
qr = segno.make(uri)
# This typer.echos.
qr.terminal()
typer.echo(message)
typer.echo(f"Approximate price in USD: {usd}")
input("[Press enter once you have made payment.]")
@cli.command()
def launch(
hostname: str,
days: int = typer.Option(...),
ssh_key_file: Path = typer.Option(...),
operating_system: str = typer.Option(...),
flavor: str = DEFAULT_FLAVOR,
currency: Optional[str] = None,
settlement_token: Optional[str] = None,
region: Optional[str] = None,
) -> None:
"""
Attempts to launch a server.
"""
from . import utils
if settlement_token is not None:
if currency is None or currency == "settlement":
currency = "settlement"
else:
msg = "Cannot use non-settlement --currency with --settlement-token"
typer.echo(msg, err=True)
raise typer.Exit(code=2)
if currency is None:
typer.echo("--currency must be set.", err=True)
raise typer.Exit(code=2)
if machine_exists(hostname):
typer.echo(f"{hostname} already created.")
raise typer.Exit(code=1)
ssh_key = ssh_key_file.read_text()
machine_id = utils.random_machine_id()
assert currency is not None
response = api_client.launch(
machine_id=machine_id,
days=days,
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
currency=currency,
region=region,
settlement_token=settlement_token,
api_endpoint=get_api_endpoint(),
retry=True,
)
# This will be false at least the first time if paying with BTC or BCH.
if response.payment.paid is False:
assert response.payment.uri is not None
make_payment(
currency=currency,
uri=response.payment.uri,
usd=response.payment.usd,
)
tries = 360
while tries > 0:
tries = tries - 1
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
# FIXME: Wait one hour in a smarter way.
# Waiting for payment to set in.
time.sleep(10)
response = api_client.launch(
machine_id=machine_id,
days=days,
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
currency=currency,
region=region,
settlement_token=settlement_token,
api_endpoint=get_api_endpoint(),
retry=True,
)
if response.payment.paid is True:
break
if response.created is False:
tries = 360
while tries > 0:
typer.echo("Waiting for server to build...", err=True)
tries = tries + 1
# Waiting for server to spin up.
time.sleep(10)
response = api_client.launch(
machine_id=machine_id,
days=days,
flavor=flavor,
operating_system=operating_system,
ssh_key=ssh_key,
currency=currency,
region=region,
settlement_token=settlement_token,
api_endpoint=get_api_endpoint(),
retry=True,
)
if response.created is True:
break
if response.created is False:
typer.echo("Server creation failed, tries exceeded.", err=True)
raise typer.Exit(code=1)
created_dict = response.dict()
created_dict["vm_hostname"] = hostname
save_machine_info(created_dict)
typer.echo(pretty_machine_info(created_dict), err=True)
typer.echo(json.dumps(created_dict, indent=4))
@cli.command()
def topup(
hostname: str,
days: int = typer.Option(...),
currency: Optional[str] = None,
settlement_token: Optional[str] = None,
) -> None:
"""
tops up an existing vm.
"""
if settlement_token is not None:
if currency is None or currency == "settlement":
currency = "settlement"
else:
msg = "Cannot use non-settlement --currency with --settlement-token"
typer.echo(msg, err=True)
raise typer.Exit(code=2)
if currency is None:
typer.echo("--currency must be set.", err=True)
raise typer.Exit(code=2)
if not machine_exists(hostname):
typer.echo(f"{hostname} does not exist.")
raise typer.Exit(code=1)
machine_info = get_machine_info(hostname)
machine_id = machine_info["machine_id"]
assert currency is not None
response = api_client.topup(
machine_id=machine_id,
days=days,
currency=currency,
api_endpoint=get_api_endpoint(),
settlement_token=settlement_token,
retry=True,
)
# This will be false at least the first time if paying with anything
# but settlement.
if response.payment.paid is False:
assert response.payment.uri is not None
make_payment(
currency=currency,
uri=response.payment.uri,
usd=response.payment.usd,
)
tries = 360
while tries > 0:
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
tries = tries - 1
# FIXME: Wait one hour in a smarter way.
# Waiting for payment to set in.
time.sleep(10)
response = api_client.topup(
machine_id=machine_id,
days=days,
currency=currency,
api_endpoint=get_api_endpoint(),
settlement_token=settlement_token,
retry=True,
)
if response.payment.paid is True:
break
machine_info["expiration"] = response.expiration
save_machine_info(machine_info, overwrite=True)
typer.echo(machine_info["expiration"])
def machine_info_path() -> Path:
home = os.getenv("HOME")
assert home is not None, "Unable to detect $HOME environment variable?"
sporestack_dir = Path(home, ".sporestack")
old_sporestack_dir = Path(home, ".sporestackv2")
if old_sporestack_dir.exists():
typer.echo(
"~/.sporestackv2 will be renamed to ~/.sporestack, this is backwards incompatible!!", # noqa: E501
err=True,
)
if sporestack_dir.exists():
typer.echo(
"~/.sporestackv2 AND ~/.sporestack detected. ABORTING! Contact support.", # noqa: E501
err=True,
)
sys.exit(1)
else:
old_sporestack_dir.rename(sporestack_dir)
# Make it, if it doesn't exist already.
sporestack_dir.mkdir(exist_ok=True)
return sporestack_dir
def save_machine_info(machine_info: Dict[str, Any], overwrite: bool = False) -> None:
"""
Save info to disk.
"""
os.umask(0o0077)
directory = machine_info_path()
hostname = machine_info["vm_hostname"]
json_file = directory.joinpath(f"{hostname}.json")
if overwrite is False:
assert json_file.exists() is False, f"{json_file} already exists."
json_file.write_text(json.dumps(machine_info))
def get_machine_info(hostname: str) -> Dict[str, Any]:
"""
Get info from disk.
"""
directory = machine_info_path()
json_file = directory.joinpath(f"{hostname}.json")
if not json_file.exists():
raise ValueError(f"{hostname} does not exist in {directory} as {json_file}")
machine_info = json.loads(json_file.read_bytes())
assert isinstance(machine_info, dict)
if machine_info["vm_hostname"] != hostname:
raise ValueError("hostname does not match filename.")
return machine_info
def pretty_machine_info(info: Dict[str, Any]) -> str:
msg = "Hostname: {}\n".format(info["vm_hostname"])
msg += "Machine ID (keep this secret!): {}\n".format(info["machine_id"])
if "ipv6" in info["network_interfaces"][0]:
msg += "IPv6: {}\n".format(info["network_interfaces"][0]["ipv6"])
if "ipv4" in info["network_interfaces"][0]:
msg += "IPv4: {}\n".format(info["network_interfaces"][0]["ipv4"])
expiration = info["expiration"]
human_expiration = time.strftime("%Y-%m-%d %H:%M:%S %z", time.localtime(expiration))
if "running" in info:
msg += "Running: {}\n".format(info["running"])
msg += f"Expiration: {expiration} ({human_expiration})\n"
time_to_live = expiration - int(time.time())
hours = time_to_live // 3600
msg += f"Server will be deleted in {hours} hours."
return msg
@cli.command()
def list() -> None:
"""
List all locally known servers.
"""
directory = machine_info_path()
infos = []
for hostname_json in os.listdir(directory):
hostname = hostname_json.split(".")[0]
saved_vm_info = get_machine_info(hostname)
try:
upstream_vm_info = api_client.info(
machine_id=saved_vm_info["machine_id"],
)
saved_vm_info["expiration"] = upstream_vm_info.expiration
saved_vm_info["running"] = upstream_vm_info.running
infos.append(saved_vm_info)
except ValueError as e:
expiration = saved_vm_info["expiration"]
human_expiration = time.strftime(
"%Y-%m-%d %H:%M:%S %z", time.localtime(expiration)
)
msg = hostname
msg += f" expired ({expiration} {human_expiration}): "
msg += str(e)
typer.echo(msg)
for info in infos:
typer.echo()
typer.echo(pretty_machine_info(info))
typer.echo()
def machine_exists(hostname: str) -> bool:
"""
Check if the VM's JSON exists locally.
"""
return machine_info_path().joinpath(f"{hostname}.json").exists()
@cli.command()
def get_attribute(hostname: str, attribute: str) -> None:
"""
Returns an attribute about the VM.
"""
machine_info = get_machine_info(hostname)
typer.echo(machine_info[attribute])
@cli.command()
def info(hostname: str) -> None:
"""
Info on the VM
"""
machine_info = get_machine_info(hostname)
machine_id = machine_info["machine_id"]
typer.echo(
api_client.info(machine_id=machine_id, api_endpoint=get_api_endpoint()).json()
)
@cli.command()
def start(hostname: str) -> None:
"""
Boots the VM.
"""
machine_info = get_machine_info(hostname)
machine_id = machine_info["machine_id"]
api_client.start(machine_id=machine_id, api_endpoint=get_api_endpoint())
typer.echo(f"{hostname} started.")
@cli.command()
def stop(hostname: str) -> None:
"""
Immediately kills the VM.
"""
machine_info = get_machine_info(hostname)
machine_id = machine_info["machine_id"]
api_client.stop(machine_id=machine_id, api_endpoint=get_api_endpoint())
typer.echo(f"{hostname} stopped.")
@cli.command()
def delete(hostname: str) -> None:
"""
Deletes the VM (most likely prematurely.
"""
machine_info = get_machine_info(hostname)
machine_id = machine_info["machine_id"]
api_client.delete(machine_id=machine_id, api_endpoint=get_api_endpoint())
# Also remove the .json file
machine_info_path().joinpath(f"{hostname}.json").unlink()
typer.echo(f"{hostname} was deleted.")
@cli.command()
def rebuild(hostname: str) -> None:
"""
Rebuilds the VM with the operating system and SSH key given at launch time.
Will take a couple minutes to complete after the request is made.
"""
machine_info = get_machine_info(hostname)
machine_id = machine_info["machine_id"]
api_client.rebuild(machine_id=machine_id, api_endpoint=get_api_endpoint())
typer.echo(f"{hostname} rebuilding.")
@cli.command()
def settlement_token_enable(
token: str,
dollars: int = typer.Option(...),
currency: str = typer.Option(...),
) -> None:
"""
Enables a new settlement token.
Dollars is starting balance.
"""
response = api_client.token_enable(
token=token,
dollars=dollars,
currency=currency,
api_endpoint=get_api_endpoint(),
retry=True,
)
uri = response.payment.uri
assert uri is not None
usd = response.payment.usd
make_payment(currency=currency, uri=uri, usd=usd)
tries = 360 * 2
while tries > 0:
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
tries = tries - 1
# FIXME: Wait two hours in a smarter way.
# Waiting for payment to set in.
time.sleep(10)
response = api_client.token_enable(
token=token,
dollars=dollars,
currency=currency,
api_endpoint=get_api_endpoint(),
retry=True,
)
if response.payment.paid is True:
typer.echo(
f"{token} has been enabled with ${dollars}. Save it and don't lose it!"
)
return
raise ValueError(f"{token} did not get enabled in time.")
@cli.command()
def settlement_token_add(
token: str,
dollars: int = typer.Option(...),
currency: str = typer.Option(...),
) -> None:
"""
Adds balance to an existing settlement token.
"""
response = api_client.token_add(
token,
dollars,
currency=currency,
api_endpoint=get_api_endpoint(),
retry=True,
)
uri = response.payment.uri
assert uri is not None
usd = response.payment.usd
make_payment(currency=currency, uri=uri, usd=usd)
tries = 360 * 2
while tries > 0:
typer.echo(WAITING_PAYMENT_TO_PROCESS, err=True)
tries = tries - 1
# FIXME: Wait two hours in a smarter way.
response = api_client.token_add(
token,
dollars,
currency=currency,
api_endpoint=get_api_endpoint(),
retry=True,
)
# Waiting for payment to set in.
time.sleep(10)
if response.payment.paid is True:
typer.echo(f"Added {dollars} dollars to {token}")
return
raise ValueError(f"{token} did not get enabled in time.")
@cli.command()
def settlement_token_balance(token: str) -> None:
"""
Gets balance for a settlement token.
"""
typer.echo(
api_client.token_balance(token=token, api_endpoint=get_api_endpoint()).usd
)
@cli.command()
def settlement_token_generate() -> None:
"""
Generates a settlement token that can be enabled.
"""
from . import utils
typer.echo(utils.random_token())
@cli.command()
def version() -> None:
"""
Returns the installed version.
"""
typer.echo(importlib_metadata_version(__package__))
@cli.command()
def api_endpoint() -> None:
"""
Prints the selected API endpoint: Env var: SPORESTACK_ENDPOINT,
or, SPORESTACK_USE_TOR=1
"""
endpoint = get_api_endpoint()
if ".onion" in endpoint:
typer.echo(f"{endpoint} using {api_client._get_tor_proxy()}")
return
else:
typer.echo(endpoint)
return
if __name__ == "__main__":
cli()

View File

@ -0,0 +1,14 @@
class SporeStackError(Exception):
pass
class SporeStackUserError(SporeStackError):
"""HTTP 4XX"""
pass
class SporeStackServerError(SporeStackError):
"""HTTP 5XX"""
pass

22
src/sporestack/models.py Normal file
View File

@ -0,0 +1,22 @@
"""
SporeStack API supplemental models
"""
from typing import Optional
from pydantic import BaseModel
class NetworkInterface(BaseModel):
ipv4: str
ipv6: str
class Payment(BaseModel):
txid: Optional[str]
uri: Optional[str]
usd: str
paid: bool

0
src/sporestack/py.typed Normal file
View File

27
src/sporestack/utils.py Normal file
View File

@ -0,0 +1,27 @@
import secrets
from base64 import b16encode
from struct import pack
from zlib import adler32
def checksum(to_hash: str) -> str:
"""
Base 16 string of half the adler32 checksum
"""
adler32_hash = adler32(bytes(to_hash, "utf-8"))
return b16encode(pack("I", adler32_hash)).decode("utf-8").lower()[-4:]
def random_machine_id() -> str:
"""
These used to be 64 hex characters. Now they have a new format.
"""
to_hash = f"ss_m_{secrets.token_hex(11)}"
return f"{to_hash}_{checksum(to_hash)}"
def random_token() -> str:
"""
64 hex characters.
"""
return secrets.token_hex(32)

10
src/sporestack/version.py Normal file
View File

@ -0,0 +1,10 @@
import sys
if sys.version_info[:2] >= (3, 8): # pragma: nocover
from importlib.metadata import version as importlib_metadata_version
else: # pragma: nocover
# Python 3.7 doesn't have this.
from importlib_metadata import version as importlib_metadata_version
__version__ = importlib_metadata_version(__package__)

0
tests/__init__.py Normal file
View File

132
tests/test_api_client.py Normal file
View File

@ -0,0 +1,132 @@
from unittest.mock import MagicMock, patch
import pytest
from pydantic import ValidationError
from sporestack import api_client
def test__is_onion_url() -> None:
onion_url = "http://spore64i5sofqlfz5gq2ju4msgzojjwifls7"
onion_url += "rok2cti624zyq3fcelad.onion/v2/"
assert api_client._is_onion_url(onion_url) is True
# This is a good, unusual test.
onion_url = "https://www.facebookcorewwwi.onion/"
assert api_client._is_onion_url(onion_url) is True
assert api_client._is_onion_url("http://domain.com") is False
assert api_client._is_onion_url("domain.com") is False
assert api_client._is_onion_url("http://onion.domain.com/.onion/") is False
assert api_client._is_onion_url("http://me.me/file.onion/") is False
assert api_client._is_onion_url("http://me.me/file.onion") is False
@patch("sporestack.api_client._api_request")
def test_launch(mock_api_request: MagicMock) -> None:
with pytest.raises(ValidationError):
api_client.launch(
"dummymachineid",
currency="xmr",
days=1,
operating_system="freebsd-12",
ssh_key="id-rsa...",
flavor="aflavor",
)
json_params = {
"machine_id": "dummymachineid",
"days": 1,
"currency": "xmr",
"flavor": "aflavor",
"ssh_key": "id-rsa...",
"operating_system": "freebsd-12",
"region": None,
"organization": None,
"settlement_token": None,
"affiliate_amount": None,
"affiliate_token": None,
}
mock_api_request.assert_called_once_with(
url="https://api.sporestack.com/server/dummymachineid/launch",
json_params=json_params,
retry=False,
)
@patch("sporestack.api_client._api_request")
def test_topup(mock_api_request: MagicMock) -> None:
with pytest.raises(ValidationError):
api_client.topup("dummymachineid", currency="xmr", days=1)
json_params = {
"machine_id": "dummymachineid",
"days": 1,
"currency": "xmr",
"settlement_token": None,
"affiliate_amount": None,
"affiliate_token": None,
}
mock_api_request.assert_called_once_with(
url="https://api.sporestack.com/server/dummymachineid/topup",
json_params=json_params,
retry=False,
)
@patch("sporestack.api_client._api_request")
def test_start(mock_api_request: MagicMock) -> None:
api_client.start("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/start", empty_post=True
)
@patch("sporestack.api_client._api_request")
def test_stop(mock_api_request: MagicMock) -> None:
api_client.stop("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/stop", empty_post=True
)
@patch("sporestack.api_client._api_request")
def test_rebuild(mock_api_request: MagicMock) -> None:
api_client.rebuild("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/rebuild", empty_post=True
)
@patch("sporestack.api_client._api_request")
def test_info(mock_api_request: MagicMock) -> None:
with pytest.raises(ValidationError):
api_client.info("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/info"
)
@patch("sporestack.api_client._api_request")
def test_delete(mock_api_request: MagicMock) -> None:
api_client.delete("dummymachineid")
mock_api_request.assert_called_once_with(
"https://api.sporestack.com/server/dummymachineid/delete", empty_post=True
)
@patch("sporestack.api_client._api_request")
def test_token_balance(mock_api_request: MagicMock) -> None:
with pytest.raises(ValidationError):
api_client.token_balance("dummytoken")
mock_api_request.assert_called_once_with(
url="https://api.sporestack.com/token/dummytoken/balance"
)
@patch("sporestack.api_client._api_request")
def test_token_enable(mock_api_request: MagicMock) -> None:
with pytest.raises(ValidationError):
api_client.token_enable("dummytoken", currency="xmr", dollars=20)
json_params = {"currency": "xmr", "dollars": 20}
mock_api_request.assert_called_once_with(
url="https://api.sporestack.com/token/dummytoken/enable",
json_params=json_params,
retry=False,
)

44
tests/test_cli.py Normal file
View File

@ -0,0 +1,44 @@
from _pytest.monkeypatch import MonkeyPatch
from typer.testing import CliRunner
from sporestack import cli
from sporestack.api_client import TOR_ENDPOINT
runner = CliRunner()
def test_version() -> None:
result = runner.invoke(cli.cli, ["version"])
assert "." in result.output
assert result.exit_code == 0
def test_get_api_endpoint(monkeypatch: MonkeyPatch) -> None:
monkeypatch.delenv("SPORESTACK_ENDPOINT", raising=False)
monkeypatch.delenv("SPORESTACK_USE_TOR_ENDPOINT", raising=False)
assert cli.get_api_endpoint() == "https://api.sporestack.com"
monkeypatch.setenv("SPORESTACK_USE_TOR_ENDPOINT", "1")
assert ".onion" in cli.get_api_endpoint()
monkeypatch.delenv("SPORESTACK_USE_TOR_ENDPOINT")
monkeypatch.setenv("SPORESTACK_ENDPOINT", "oog.boog")
assert cli.get_api_endpoint() == "oog.boog"
def test_cli_api_endpoint(monkeypatch: MonkeyPatch) -> None:
# So tests pass locally, even if these are set.
monkeypatch.delenv("SPORESTACK_ENDPOINT", raising=False)
monkeypatch.delenv("SPORESTACK_USE_TOR_ENDPOINT", raising=False)
monkeypatch.delenv("TOR_PROXY", raising=False)
result = runner.invoke(cli.cli, ["api-endpoint"])
assert result.output == "https://api.sporestack.com" + "\n"
assert result.exit_code == 0
monkeypatch.setenv("SPORESTACK_USE_TOR_ENDPOINT", "1")
result = runner.invoke(cli.cli, ["api-endpoint"])
assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:9050\n"
assert result.exit_code == 0
monkeypatch.setenv("TOR_PROXY", "socks5h://127.0.0.1:1337")
result = runner.invoke(cli.cli, ["api-endpoint"])
assert result.output == TOR_ENDPOINT + " using socks5h://127.0.0.1:1337\n"
assert result.exit_code == 0

11
tests/test_utils.py Normal file
View File

@ -0,0 +1,11 @@
from sporestack import utils
def test_random_machine_id() -> None:
assert utils.random_machine_id() != utils.random_machine_id()
assert len(utils.random_machine_id()) == 32
assert utils.random_machine_id().startswith("ss_m_")
def test_hash() -> None:
assert utils.checksum("ss_m_1deadbeefcafedeadbeef1") == "0892"