Skip to content

Commit 6782684

Browse files
authored
Initial implementation of interface (#1)
* Initial project setup and stub out interfaces * Start using TDD and rework APIs a bit * Refactor test a bit, finish filling in request fields * Finish Request implementation Type hints don't work as well on Python 3.5 and don't provide any actual validation in any case, so switched to Marshmallow for schema definition, validation, and de/serialization. * Implement Response class, except for creating from a Request * Implement requesting and responding via interface APIs There are still some outstanding issues with loading responses on the providing side, as mentioned in the functional test, and the test needs to be finished to verify the return trip of the response. * Refactor how schema is used and complete implementation * Add CODEOWNERS file * Add events and flags, split functional test into separate tox env * Add README and clear flags when all requests or responses have been processed * Add GitHub Actions workflow for tests * Add missing links in README * Make Request.name a property since it should never be changed * Make all_responses exclude empty responses and make address option on failed responses * Split README into separate docs and examples * Fix unnecessary escaping in API reference docs * Add TOC to API reference * Split schema out into versioned submodules to make it more clear how it will be extended in the future
1 parent d28f102 commit 6782684

File tree

20 files changed

+1268
-2
lines changed

20 files changed

+1268
-2
lines changed

.github/workflows/tests.yaml

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
name: Test Suite
2+
3+
on:
4+
- pull_request
5+
6+
jobs:
7+
lint-unit-and-func-tests:
8+
name: Lint, Unit, & Functional Tests
9+
runs-on: ubuntu-latest
10+
strategy:
11+
matrix:
12+
python: [3.5, 3.6, 3.7, 3.8, 3.9]
13+
steps:
14+
- name: Check out code
15+
uses: actions/checkout@v2
16+
- name: Setup Python
17+
uses: actions/setup-python@v2
18+
with:
19+
python-version: ${{ matrix.python }}
20+
- name: Install Tox
21+
run: pip install tox
22+
- name: Run lint, unit, and functional tests
23+
run: tox
24+
25+
# TODO
26+
#integration-test:
27+
# name: Integration test with LXD
28+
# runs-on: ubuntu-latest
29+
# timeout-minutes: 40
30+
# steps:
31+
# - name: Check out code
32+
# uses: actions/checkout@v2
33+
# - name: Setup Python
34+
# uses: actions/setup-python@v2
35+
# with:
36+
# python-version: 3.8
37+
# - name: Fix global gitconfig for confined snap
38+
# run: |
39+
# # GH automatically includes the git-lfs plugin and configures it in
40+
# # /etc/gitconfig. However, the confinement of the charmcraft snap
41+
# # means that it can see that this file exists but cannot read it, even
42+
# # if the file permissions should allow it; this breaks git usage within
43+
# # the snap. To get around this, we move it from the global gitconfig to
44+
# # the user's .gitconfig file.
45+
# cat /etc/gitconfig >> $HOME/.gitconfig
46+
# sudo rm /etc/gitconfig
47+
# - name: Install Dependencies
48+
# run: |
49+
# pip install tox
50+
# sudo apt-get remove -qy lxd lxd-client
51+
# sudo snap install core
52+
# sudo snap install lxd
53+
# sudo lxd waitready
54+
# sudo lxd init --auto
55+
# sudo chmod a+wr /var/snap/lxd/common/lxd/unix.socket
56+
# echo "/snap/bin" >> $GITHUB_PATH
57+
# lxc network set lxdbr0 ipv6.address none
58+
# sudo snap install juju --classic
59+
# sudo snap install juju-wait --classic
60+
# sudo snap install charmcraft --beta
61+
# sudo snap install charm --classic
62+
# - name: Run integration tests
63+
# run: tox -e integration

CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
@johnsca @joedborg

README.md

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,63 @@
1-
# loadbalancer-interface
2-
"loadbalancer" interface protocol API library
1+
# `loadbalancer` Interface Protocol API Library
2+
3+
This library provides an API for requesting and providing load balancers or
4+
ingress endpoints from one charm to another. It can be used in either charms
5+
written in the newer [Operator Framework][] or older charms still using the
6+
[charms.reactive Framework][].
7+
8+
9+
## Installation / Setup
10+
11+
Include this library as a dependency for your charm, either in
12+
`requirements.txt` for Operator Framework charms, or `wheelhouse.txt` for
13+
reactive charms:
14+
15+
```
16+
# TODO: publish this to PyPI
17+
https://github.com/juju-solutions/loadbalancer-interface/archive/master.zip#egg=loadbalancer_interface
18+
```
19+
20+
## Usage
21+
22+
### Requesting Load Balancers
23+
24+
Requesting a load balancer from a provider is done via the `LBProvider` class.
25+
The general pattern for using the class is:
26+
27+
* Wait for the provider to become available
28+
* Get a `Request` object via the `get_request(name)` method
29+
* Set the appropriate fields on the request object
30+
* Send the `Request` via the `send_request(request)` method
31+
* Wait for the `Response` to be provided (or updated)
32+
* Get the `Response` object via either the `get_response(name)` method or
33+
via the `new_responses` property
34+
* Confirm that the request was successful and use the provided LB's address
35+
* Acknowledge the `Response` via `ack_response(response)`
36+
37+
There are examples in the docs on how to do this in [an operator charm](docs/examples/requires_operator.md)
38+
or in [a reactive charm](docs/examples/requires_reactive.md).
39+
40+
41+
### Providing Load Balancers
42+
43+
Providing a load balancer to consumers is done via the `LBConsumers` class. The
44+
general pattern for using the class is:
45+
46+
* Wait for new or updated requests to come in
47+
* Iterate over each request object in the `new_requests` property
48+
* Create a load balancer according to the request's fields
49+
* Set the appropriate fields on the request's `response` object
50+
* Send the request's response via the `send_response(request)` method
51+
52+
There are examples in the docs on how to do this in [an operator charm](docs/examples/provides_operator.md)
53+
or in [a reactive charm](docs/examples/provides_reactive.md).
54+
55+
## API Reference
56+
57+
See [the docs](docs/api.md) for detailed reference on the API.
58+
59+
60+
<!-- Links -->
61+
62+
[Operator Framework]: https://github.com/canonical/operator/
63+
[charms.reactive Framework]: https://charmsreactive.readthedocs.io/

docs/api.md

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
# API Reference
2+
3+
## Table of Contents
4+
5+
* [`LBProvider` Class](#lbprovider-class)
6+
* [`LBConsumers` Class](#lbconsumers-class)
7+
* [`Request` Objects](#request-objects)
8+
* [`HealthCheck` Objects](#healthcheck-objects)
9+
* [`Response` Objects](#response-objects)
10+
11+
-----------------------------------------
12+
13+
## `LBProvider` Class
14+
15+
This is the main API class for requesting load balancers from a provider charm.
16+
When instantiated, it should be passed a charm instance and a relation name.
17+
18+
### Events
19+
20+
* `available` Emitted once the provider is available to take requests
21+
* `responses_changed` Emitted whenever one or more responses have been received or updated
22+
23+
### Methods
24+
25+
* `get_request(name)` Get or create a request with the given name
26+
* `send_request(request)` Send the completed request to the provider
27+
* `get_response(name)` Get the response to a specific request (equivalent to `get_request(name).response`)
28+
* `ack_response(response)` Acknowledge a response so that it is no longer considered new or changed
29+
30+
### Properties
31+
32+
* `is_available` Whether the provider is available to take requests
33+
* `is_changed` Whether there are any new or changed responses which have not been acknowledged
34+
* `all_responses` A list of all received responses, even if they have not changed
35+
* `new_responses` A list of all responses which are new or have changed and not been acknowledged
36+
37+
### Flags
38+
39+
For charms using the older charms.reactive framework, the following flags will
40+
be automatically managed:
41+
42+
* `endpoint.{relation_name}.available` Set or cleared based on `is_available`
43+
* `endpoint.{relation_name}.responses_changed` Set or cleared based on `is_changed`
44+
45+
46+
## `LBConsumers` Class
47+
48+
This is the main API class for providing load balancers to consumer charms.
49+
When instantiated, it should be passed a charm instance and a relation name.
50+
51+
### Events
52+
53+
* `requests_changed` Emitted whenever one or more requests have been received or updated
54+
55+
### Methods
56+
57+
* `send_response(request)` Send the completed `Response` attached to the given `Request`
58+
59+
### Properties
60+
61+
* `is_changed` Whether there are any new or changed requests which have not been responded to
62+
* `all_requests` A list of all received requests, even if they have not changed
63+
* `new_requests` A list of all requests which are new or have changed and not been responded to
64+
65+
### Flags
66+
67+
For charms using the older charms.reactive framework, the following flags will
68+
be automatically managed:
69+
70+
* `endpoint.{relation_name}.requests_changed` Set or cleared based on `is_changed`
71+
72+
73+
## `Request` Objects
74+
75+
Represents a request for a load balancer.
76+
77+
Acquired from `LBProvider.get_request(name)`, `LBConsumers.all_requests`, or `LBConsumers.new_requests`.
78+
79+
### Properties
80+
81+
* `name` Name of the request (`str`, read-only)
82+
* `response` `Reponse` to this request (will always exist, but may be "empty"; see below)
83+
* `hash` An MD5 hash of the data for the request (`str`, read-only)
84+
85+
### Fields
86+
87+
* `traffic_type` Type of traffic to route (`str`, required)
88+
* `backends` List of backend addresses (`str`s, default: every units' `ingress-address`)
89+
* `backend_ports` List of ports or `range`s to route (`int`s or `range(int, int)`, required)
90+
* `algorithm` List of traffic distribution algorithms, in order of preference (`str`s, optional)
91+
* `sticky` Whether traffic "sticks" to a given backend (`bool`, default: `False`)
92+
* `health_checks` List of `HealthCheck` objects (see below, optional)
93+
* `public` Whether the address should be public (`bool`, default: `True`)
94+
* `tls_termination` Whether to enable TLS termination (`bool`, default: `False`)
95+
* `tls_cert` If TLS termination is enabled, a manually provided cert (`str`, optional)
96+
* `tls_key` If TLS termination is enabled, a manually provided key (`str`, optional)
97+
* `ingress_address` A manually provided ingress address (optional, may not be supported)
98+
* `ingress_ports` A list of ingress ports or `range`s (`int`s or `range(int, int)`; default: `backend_ports`)
99+
100+
### Methods
101+
102+
* `add_health_check(**fields)` Create a `HealthCheck` object (see below) with the given fields and add it to the list.
103+
104+
105+
## `HealthCheck` Objects
106+
107+
Represents a health check endpoint to determine if a backend is functional.
108+
109+
Acquired from `Request.add_health_check()`, or `Request.health_checks`.
110+
111+
### Fields
112+
113+
* `traffic_type` Type of traffic to use to make the check (e.g., "https", "udp", etc.) (`str`, required)
114+
* `port` Port to check on (`int`, required)
115+
* `path` Path on the backend to check (e.g., for "https" or "http" types) (`str`, optional)
116+
* `interval` How many seconds to wait between checks (`int`, default: 30)
117+
* `retries` How many failed attempts before considering a backend down (`int`, default: 3)
118+
119+
120+
## `Response` Objects
121+
122+
Represents a response to a load balancer request.
123+
124+
Acquired from `Request.response`. A `Request` object will always have a
125+
`response` attribute, but the response might be "empty" if it has not been
126+
filled in with valid data, in which case `bool(response)` will be `False`.
127+
128+
### Properties
129+
130+
* `name` Name of the request (`str`, read-only)
131+
* `hash` An MD5 hash of the data for the request (read-only)
132+
133+
### Fields
134+
135+
* `success` Whether the request was successful (`bool`, required)
136+
* `message` If not successful, an indication as to why (`str`, required if `success` is `False`)
137+
* `address` The address of the LB (`str`, required if `success` is `True`)
138+
* `request_hash` The hash of the `Request` when this `Response` was sent (`str`, set automatically)

docs/examples/provides_operator.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
# Operator Framework Provides Charm Example
2+
3+
## `metadata.yaml`
4+
5+
```yaml
6+
provides: # provides LBs to consumers
7+
lb-consumers:
8+
interface: loadbalancer
9+
```
10+
11+
## `src/charm.py`
12+
13+
```python
14+
import logging
15+
16+
from ops.charm import CharmBase
17+
from ops.model import ActiveStatus, BlockedStatus
18+
19+
from loadbalancer_interface import LBConsumers
20+
21+
22+
log = logging.getLogger(__name__)
23+
24+
25+
class MyCharm(CharmBase):
26+
def __init__(self, *args):
27+
super().__init__(*args)
28+
self.lb_consumers = LBConsumers(self, 'lb-consumers')
29+
30+
self.framework.observe(self.lb_consumers.on.requests_changed,
31+
self._provide_lbs)
32+
33+
def _provide_lbs(self, event):
34+
for request in self.lb_consumers.new_requests:
35+
try:
36+
request.response.address = self._create_lb(request)
37+
request.response.success = True
38+
except LBError as e:
39+
request.response.success = False
40+
request.response.message = e.message
41+
self.lb_consumers.send_response(request)
42+
```

docs/examples/provides_reactive.md

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
# charms.reactive Framework Provides Charm Example
2+
3+
## `metadata.yaml`
4+
5+
```yaml
6+
provides: # provides LBs to consumers
7+
lb-consumers:
8+
interface: loadbalancer
9+
```
10+
11+
## `reactive/my_charm.py`
12+
13+
```python
14+
from charms.reactive import when, endpoint_from_name
15+
from charms import layer
16+
17+
18+
@when('endpoint.lb-consumers.requests_changed')
19+
def get_lb():
20+
lb_consumers = endpoint_from_name('lb-consumers')
21+
for request in lb_consumers.new_requests:
22+
try:
23+
request.response.address = layer.my_charm.create_lb(request)
24+
request.response.success = True
25+
except LBError as e:
26+
request.response.success = False
27+
request.response.message = e.message
28+
lb_consumers.send_response(request)
29+
```

docs/examples/requires_operator.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Operator Framework Requires Charm Example
2+
3+
## `metadata.yaml`
4+
5+
```yaml
6+
requires: # consumes a LB from a provider
7+
lb-provider:
8+
interface: loadbalancer
9+
limit: 1 # only supports a single LB provider per relation endpoint
10+
```
11+
12+
## `src/charm.py`
13+
14+
```python
15+
import logging
16+
17+
from ops.charm import CharmBase
18+
from ops.model import ActiveStatus, BlockedStatus
19+
20+
from loadbalancer_interface import LBProvider
21+
22+
23+
log = logging.getLogger(__name__)
24+
25+
26+
class MyCharm(CharmBase):
27+
def __init__(self, *args):
28+
super().__init__(*args)
29+
self.lb_provider = LBProvider(self, 'lb-provider')
30+
31+
self.framework.observe(self.lb_provider.on.available, self._request_lb)
32+
self.framework.observe(self.lb_provider.on.responses_changed, self._get_lb)
33+
34+
def _request_lb(self, event):
35+
request = self.lb_provider.get_request('my-service')
36+
request.traffic_type = 'https'
37+
request.backend_ports = [443]
38+
self.lb_provider.send_request(request)
39+
40+
def _get_lb(self, event):
41+
response = self.lb_provider.get_response('my-service')
42+
if not response.success:
43+
self.unit.status = BlockedStatus(response.message)
44+
return
45+
log.info(f'LB is available at {response.address}')
46+
self.lb_provider.ack_response(response)
47+
self.unit.status = ActiveStatus()
48+
```

0 commit comments

Comments
 (0)