feat(bridges): next generation bridge management

This commit is contained in:
Iain Learmonth 2023-01-26 15:42:25 +00:00
parent 20fad30a06
commit 05285a4ae6
12 changed files with 329 additions and 89 deletions

View file

@ -19,6 +19,7 @@ from app.terraform.block.bridge_roskomsvoboda import BlockBridgeRoskomsvobodaAut
from app.terraform.block_external import BlockExternalAutomation
from app.terraform.block_ooni import BlockOONIAutomation
from app.terraform.block_roskomsvoboda import BlockRoskomsvobodaAutomation
from app.terraform.bridge.meta import BridgeMetaAutomation
from app.terraform.eotk.aws import EotkAWSAutomation
from app.terraform.alarms.eotk_aws import AlarmEotkAwsAutomation
from app.terraform.alarms.proxy_azure_cdn import AlarmProxyAzureCdnAutomation
@ -56,6 +57,7 @@ jobs = {
BridgeAWSAutomation,
BridgeGandiAutomation,
BridgeHcloudAutomation,
BridgeMetaAutomation,
BridgeOvhAutomation,
EotkAWSAutomation,
ListGithubAutomation,

View file

@ -11,7 +11,6 @@ class Group(AbstractConfiguration):
eotk = db.Column(db.Boolean())
origins = db.relationship("Origin", back_populates="group")
bridgeconfs = db.relationship("BridgeConf", back_populates="group")
eotks = db.relationship("Eotk", back_populates="group")
onions = db.relationship("Onion", back_populates="group")
smart_proxies = db.relationship("SmartProxy", back_populates="group")
@ -39,16 +38,17 @@ class Pool(AbstractConfiguration):
api_key = db.Column(db.String(80), nullable=False)
redirector_domain = db.Column(db.String(128), nullable=True)
bridgeconfs = db.relationship("BridgeConf", back_populates="pool")
proxies = db.relationship("Proxy", back_populates="pool")
lists = db.relationship("MirrorList", back_populates="pool")
groups = db.relationship("Group", secondary="pool_group", back_populates="pools")
@classmethod
def csv_header(cls) -> List[str]:
return super().csv_header() + [
"pool_name"
]
proxies = db.relationship("Proxy", back_populates="pool")
lists = db.relationship("MirrorList", back_populates="pool")
groups = db.relationship("Group", secondary="pool_group", back_populates="pools")
@property
def brn(self) -> BRN:
return BRN(

View file

@ -1,3 +1,4 @@
import enum
from datetime import datetime
from typing import List
@ -6,13 +7,20 @@ from app.extensions import db
from app.models import AbstractConfiguration, AbstractResource
class BridgeConf(AbstractConfiguration):
group_id = db.Column(db.Integer, db.ForeignKey("group.id"), nullable=False)
provider = db.Column(db.String(20), nullable=False)
method = db.Column(db.String(20), nullable=False)
number = db.Column(db.Integer())
class ProviderAllocation(enum.Enum):
RANDOM = "random"
COST = "cost"
group = db.relationship("Group", back_populates="bridgeconfs")
class BridgeConf(AbstractConfiguration):
pool_id = db.Column(db.Integer, db.ForeignKey("pool.id"), nullable=False)
method = db.Column(db.String(20), nullable=False)
target_number = db.Column(db.Integer())
max_number = db.Column(db.Integer())
expiry_hours = db.Column(db.Integer())
provider_allocation = db.Column(db.Enum(ProviderAllocation))
pool = db.relationship("Pool", back_populates="bridgeconfs")
bridges = db.relationship("Bridge", back_populates="conf")
@property
@ -36,12 +44,13 @@ class BridgeConf(AbstractConfiguration):
@classmethod
def csv_header(cls) -> List[str]:
return super().csv_header() + [
"group_id", "provider", "method", "description", "number"
"pool_id", "provider", "method", "description", "target_number", "max_number", "expiry_hours"
]
class Bridge(AbstractResource):
conf_id = db.Column(db.Integer, db.ForeignKey("bridge_conf.id"), nullable=False)
provider = db.Column(db.String(), nullable=False)
terraform_updated = db.Column(db.DateTime(), nullable=True)
nickname = db.Column(db.String(255), nullable=True)
fingerprint = db.Column(db.String(255), nullable=True)
@ -53,9 +62,9 @@ class Bridge(AbstractResource):
@property
def brn(self) -> BRN:
return BRN(
group_id=self.conf.group_id,
group_id=0,
product="bridge",
provider=self.conf.provider,
provider=self.provider,
resource_type="bridge",
resource_id=str(self.id)
)

View file

@ -44,6 +44,15 @@ portal.register_blueprint(storage, url_prefix="/state")
portal.register_blueprint(webhook, url_prefix="/webhook")
@portal.app_template_filter("bridge_expiry")
def calculate_bridge_expiry(b: Bridge) -> str:
expiry = b.deprecated + timedelta(hours=b.conf.expiry_hours)
countdown = expiry - datetime.utcnow()
if countdown.days == 0:
return f"{countdown.seconds // 3600} hours"
return f"{countdown.days} days"
@portal.app_template_filter("mirror_expiry")
def calculate_mirror_expiry(s: datetime) -> str:
expiry = s + timedelta(days=3)

View file

@ -9,7 +9,6 @@ from app.portal.util import LifecycleForm
bp = Blueprint("bridge", __name__)
_SECTION_TEMPLATE_VARS = {
"section": "bridge",
"help_url": "https://bypass.censorship.guide/user/bridges.html"
@ -45,3 +44,24 @@ def bridge_blocked(bridge_id: int) -> ResponseReturnValue:
message=bridge.hashed_fingerprint,
form=form,
**_SECTION_TEMPLATE_VARS)
@bp.route("/expire/<bridge_id>", methods=['GET', 'POST'])
def bridge_expire(bridge_id: int) -> ResponseReturnValue:
bridge: Optional[Bridge] = Bridge.query.filter(Bridge.id == bridge_id, Bridge.destroyed.is_(None)).first()
if bridge is None:
return Response(render_template("error.html.j2",
header="404 Proxy Not Found",
message="The requested bridge could not be found.",
**_SECTION_TEMPLATE_VARS))
form = LifecycleForm()
if form.validate_on_submit():
bridge.destroy()
db.session.commit()
flash("Bridge will be shortly destroyed.", "success")
return redirect(url_for("portal.bridgeconf.bridgeconf_edit", bridgeconf_id=bridge.conf_id))
return render_template("lifecycle.html.j2",
header=f"Destroy bridge {bridge.hashed_fingerprint}?",
message=bridge.hashed_fingerprint,
form=form,
**_SECTION_TEMPLATE_VARS)

View file

@ -9,7 +9,7 @@ from wtforms import SelectField, StringField, IntegerField, SubmitField
from wtforms.validators import DataRequired, NumberRange
from app.extensions import db
from app.models.base import Group
from app.models.base import Pool
from app.models.bridges import BridgeConf
from app.portal.util import response_404, view_lifecycle
@ -23,17 +23,34 @@ _SECTION_TEMPLATE_VARS = {
class NewBridgeConfForm(FlaskForm): # type: ignore
provider = SelectField('Provider', validators=[DataRequired()])
method = SelectField('Distribution Method', validators=[DataRequired()])
description = StringField('Description')
group = SelectField('Group', validators=[DataRequired()])
number = IntegerField('Number', validators=[NumberRange(1, message="One or more bridges must be created")])
pool = SelectField('Pool', validators=[DataRequired()])
target_number = IntegerField('Target Number',
description="The number of active bridges to deploy (excluding deprecated bridges).",
validators=[NumberRange(1, message="One or more bridges must be created.")])
max_number = IntegerField('Maximum Number',
description="The maximum number of bridges to deploy (including deprecated bridges).",
validators=[
NumberRange(1, message="Must be at least 1, ideally greater than target number.")])
expiry_hours = IntegerField('Expiry Timer (hours)',
description=("The number of hours to wait after a bridge is deprecated before its "
"destruction."))
submit = SubmitField('Save Changes')
class EditBridgeConfForm(FlaskForm): # type: ignore
description = StringField('Description')
number = IntegerField('Number', validators=[NumberRange(1, message="One or more bridges must be created")])
target_number = IntegerField('Target Number',
description="The number of active bridges to deploy (excluding deprecated bridges).",
validators=[NumberRange(1, message="One or more bridges must be created.")])
max_number = IntegerField('Maximum Number',
description="The maximum number of bridges to deploy (including deprecated bridges).",
validators=[
NumberRange(1, message="Must be at least 1, ideally greater than target number.")])
expiry_hours = IntegerField('Expiry Timer (hours)',
description=("The number of hours to wait after a bridge is deprecated before its "
"destruction."))
submit = SubmitField('Save Changes')
@ -52,13 +69,7 @@ def bridgeconf_list() -> ResponseReturnValue:
@bp.route("/new/<group_id>", methods=['GET', 'POST'])
def bridgeconf_new(group_id: Optional[int] = None) -> ResponseReturnValue:
form = NewBridgeConfForm()
form.group.choices = [(x.id, x.group_name) for x in Group.query.all()]
form.provider.choices = [
("aws", "AWS Lightsail"),
("hcloud", "Hetzner Cloud"),
("ovh", "OVH Public Cloud"),
("gandi", "GandiCloud VPS")
]
form.pool.choices = [(x.id, x.pool_name) for x in Pool.query.filter(Pool.destroyed.is_(None)).all()]
form.method.choices = [
("any", "Any (BridgeDB)"),
("email", "E-Mail (BridgeDB)"),
@ -67,18 +78,19 @@ def bridgeconf_new(group_id: Optional[int] = None) -> ResponseReturnValue:
("none", "None (Private)")
]
if form.validate_on_submit():
bridge_conf = BridgeConf()
bridge_conf.group_id = form.group.data
bridge_conf.provider = form.provider.data
bridge_conf.method = form.method.data
bridge_conf.description = form.description.data
bridge_conf.number = form.number.data
bridge_conf.created = datetime.utcnow()
bridge_conf.updated = datetime.utcnow()
bridgeconf = BridgeConf()
bridgeconf.pool_id = form.pool.data
bridgeconf.method = form.method.data
bridgeconf.description = form.description.data
bridgeconf.target_number = form.target_number.data
bridgeconf.max_number = form.max_number.data
bridgeconf.expiry_hours = form.expiry_hours.data
bridgeconf.created = datetime.utcnow()
bridgeconf.updated = datetime.utcnow()
try:
db.session.add(bridge_conf)
db.session.add(bridgeconf)
db.session.commit()
flash(f"Created new bridge configuration {bridge_conf.id}.", "success")
flash(f"Created new bridge configuration {bridgeconf.id}.", "success")
return redirect(url_for("portal.bridgeconf.bridgeconf_list"))
except exc.SQLAlchemyError:
flash("Failed to create new bridge configuration.", "danger")
@ -100,10 +112,15 @@ def bridgeconf_edit(bridgeconf_id: int) -> ResponseReturnValue:
**_SECTION_TEMPLATE_VARS),
status=404)
form = EditBridgeConfForm(description=bridgeconf.description,
number=bridgeconf.number)
target_number=bridgeconf.target_number,
max_number=bridgeconf.max_number,
expiry_hours=bridgeconf.expiry_hours,
)
if form.validate_on_submit():
bridgeconf.description = form.description.data
bridgeconf.number = form.number.data
bridgeconf.target_number = form.target_number.data
bridgeconf.max_number = form.max_number.data
bridgeconf.expiry_hours = form.expiry_hours.data
bridgeconf.updated = datetime.utcnow()
try:
db.session.commit()

View file

@ -4,7 +4,7 @@
{% block content %}
<h1 class="h2 mt-3">Tor Bridge Configuration</h1>
<h2 class="h3">{{ bridgeconf.group.group_name }}: {{ bridgeconf.provider }}/{{ bridgeconf.method }}</h2>
<h2 class="h3">{{ bridgeconf.pool.pool_name }}: {{ bridgeconf.method }}</h2>
<div style="border: 1px solid #666;" class="p-3">
{{ render_form(form) }}

View file

@ -453,10 +453,11 @@
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Group</th>
<th scope="col">Provider</th>
<th scope="col">Pool</th>
<th scope="col">Distribution Method</th>
<th scope="col">Number</th>
<th scope="col">Target Number</th>
<th scope="col">Max Number</th>
<th scope="col">Expiry Timer</th>
<th scope="col">Actions</th>
</tr>
</thead>
@ -465,11 +466,12 @@
{% if not bridgeconf.destroyed %}
<tr class="align-middle">
<td>
<a href="{{ url_for("portal.group.group_edit", group_id=bridgeconf.group.id) }}">{{ bridgeconf.group.group_name }}</a>
<a href="{{ url_for("portal.pool.pool_edit", pool_id=bridgeconf.pool.id) }}" title="{{ bridgeconf.pool.description }}">{{ bridgeconf.pool.pool_name }}</a>
</td>
<td>{{ bridgeconf.provider }}</td>
<td>{{ bridgeconf.method }}</td>
<td>{{ bridgeconf.number }}</td>
<td>{{ bridgeconf.target_number }}</td>
<td>{{ bridgeconf.max_number }}</td>
<td>{{ bridgeconf.expiry_hours }}</td>
<td>
<a href="{{ url_for("portal.bridgeconf.bridgeconf_edit", bridgeconf_id=bridgeconf.id) }}"
class="btn btn-primary btn-sm">View/Edit</a>
@ -489,7 +491,7 @@
<table class="table table-striped table-sm">
<thead>
<tr>
<th scope="col">Group</th>
<th scope="col">Pool</th>
<th scope="col">Configuration</th>
<th scope="col">Nickname</th>
<th scope="col">Hashed Fingerprint</th>
@ -502,9 +504,9 @@
{% if not bridge.destroyed %}
<tr class="align-middle{% if bridge.deprecated %} bg-warning{% endif %}">
<td>
<a href="{{ url_for("portal.group.group_edit", group_id=bridge.conf.group.id) }}">{{ bridge.conf.group.group_name }}</a>
<a href="{{ url_for("portal.pool.pool_edit", pool_id=bridge.conf.pool_id) }}">{{ bridge.conf.pool.pool_name }}</a>
</td>
<td>{{ bridge.conf.description }} ({{ bridge.conf.provider }}/{{ bridge.conf.method }})</td>
<td>{{ bridge.conf.description }} ({{ bridge.provider }}/{{ bridge.conf.method }})</td>
<td>
<a href="https://metrics.torproject.org/rs.html#details/{{ bridge.hashed_fingerprint }}">
{{ bridge.nickname }}
@ -541,7 +543,9 @@
<td>
{% if bridge.deprecated %}
<a href="#" class="disabled btn btn-sm btn-outline-dark">Expiring
in {{ bridge.deprecated | mirror_expiry }}</a>
in {{ bridge | bridge_expiry }}</a>
<a href="{{ url_for("portal.bridge.bridge_expire", bridge_id=bridge.id) }}"
class="btn btn-danger btn-sm">Expire</a>
{% else %}
<a href="{{ url_for("portal.bridge.bridge_blocked", bridge_id=bridge.id) }}"
class="btn btn-warning btn-sm">Mark blocked</a>

View file

@ -1,6 +1,7 @@
import datetime
import os
from typing import Iterable, Optional, Any, List
import sys
from typing import Optional, Any, List
from app import app
from app.extensions import db
@ -21,45 +22,12 @@ class BridgeAutomation(TerraformAutomation):
in the templating of the Terraform configuration.
"""
def create_missing(self) -> None:
bridgeconfs: Iterable[BridgeConf] = BridgeConf.query.filter(
BridgeConf.provider == self.provider,
BridgeConf.destroyed.is_(None)
).all()
for bridgeconf in bridgeconfs:
active_bridges = Bridge.query.filter(
Bridge.conf_id == bridgeconf.id,
Bridge.deprecated.is_(None)
).all()
if len(active_bridges) < bridgeconf.number:
for _idx in range(bridgeconf.number - len(active_bridges)):
bridge = Bridge()
bridge.conf_id = bridgeconf.id
bridge.added = datetime.datetime.utcnow()
bridge.updated = datetime.datetime.utcnow()
db.session.add(bridge)
elif len(active_bridges) > bridgeconf.number:
active_bridge_count = len(active_bridges)
for bridge in active_bridges:
bridge.deprecate(reason="redundant")
active_bridge_count -= 1
if active_bridge_count == bridgeconf.number:
break
db.session.commit()
max_bridges = sys.maxsize
def destroy_expired(self) -> None:
cutoff = datetime.datetime.utcnow() - datetime.timedelta(days=0)
bridges = [b for b in Bridge.query.filter(
Bridge.destroyed.is_(None),
Bridge.deprecated < cutoff
).all() if b.conf.provider == self.provider]
for bridge in bridges:
bridge.destroy()
db.session.commit()
# TODO: Only enable providers that have details configured
enabled = True
def tf_prehook(self) -> Optional[Any]: # pylint: disable=useless-return
self.create_missing()
self.destroy_expired()
return None
def tf_generate(self) -> None:
@ -103,3 +71,11 @@ class BridgeAutomation(TerraformAutomation):
bridge.bridgeline = " ".join(parts)
bridge.terraform_updated = datetime.datetime.utcnow()
db.session.commit()
@classmethod
def active_bridges_count(self) -> int:
active_bridges = Bridge.query.filter(
Bridge.provider == self.provider,
Bridge.destroyed.is_(None),
).all()
return len(active_bridges)

View file

@ -0,0 +1,122 @@
import datetime
import logging
from typing import Tuple, List
from app import db
from app.models.bridges import BridgeConf, Bridge
from app.terraform import BaseAutomation
from app.terraform.bridge.gandi import BridgeGandiAutomation
from app.terraform.bridge.hcloud import BridgeHcloudAutomation
from app.terraform.bridge.ovh import BridgeOvhAutomation
BRIDGE_PROVIDERS = {p.provider: p for p in [
# In order of cost
BridgeHcloudAutomation,
BridgeGandiAutomation,
BridgeOvhAutomation,
# BridgeAWSAutomation, TODO: This module is broken right now
] if p.enabled}
def create_bridges(bridgeconf: BridgeConf, count: int) -> int:
"""
Creates a bridge resource for the given bridge configuration.
"""
logging.debug("Creating %s bridges for configuration %s", count, bridgeconf.id)
created = 0
# TODO: deal with the fact that I created a dictionary and then forgot it wasn't ordered
while created < count:
for provider in BRIDGE_PROVIDERS:
if BRIDGE_PROVIDERS[provider].max_bridges > BRIDGE_PROVIDERS[provider].active_bridges_count():
logging.debug("Creating bridge for configuration %s with provider %s", bridgeconf.id, provider)
bridge = Bridge()
bridge.pool_id = bridgeconf.pool.id
bridge.conf_id = bridgeconf.id
bridge.provider = provider
bridge.added = datetime.datetime.utcnow()
bridge.updated = datetime.datetime.utcnow()
logging.debug("Creating bridge %s", bridge)
db.session.add(bridge)
created += 1
break
else:
logging.debug("No provider has available quota to create missing bridge for configuration %s",
bridgeconf.id)
logging.debug("Created %s bridges", created)
return created
def deprecate_bridges(bridgeconf: BridgeConf, count: int, reason: str = "redundant") -> int:
logging.debug("Deprecating %s bridges (%s) for configuration %s", count, reason, bridgeconf.id)
deprecated = 0
active_conf_bridges = iter(Bridge.query.filter(
Bridge.conf_id == bridgeconf.id,
Bridge.deprecated.is_(None),
Bridge.destroyed.is_(None),
).all())
while deprecated < count:
logging.debug("Deprecating bridge %s for configuration %s", deprecated + 1, bridgeconf.id)
bridge = next(active_conf_bridges)
logging.debug("Bridge %r", bridge)
bridge.deprecate(reason=reason)
deprecated += 1
return deprecated
class BridgeMetaAutomation(BaseAutomation):
short_name = "bridge_meta"
description = "Housekeeping for bridges"
frequency = 1
def automate(self, full: bool = False) -> Tuple[bool, str]:
# Destroy expired bridges
deprecated_bridges: List[Bridge] = Bridge.query.filter(
Bridge.destroyed.is_(None),
Bridge.deprecated.is_not(None),
).all()
logging.debug("Found %s deprecated bridges", len(deprecated_bridges))
for bridge in deprecated_bridges:
cutoff = datetime.datetime.utcnow() - datetime.timedelta(hours=bridge.conf.expiry_hours)
if bridge.deprecated < cutoff:
logging.debug("Destroying expired bridge")
bridge.destroy()
# Deprecate orphaned bridges
active_bridges = Bridge.query.filter(
Bridge.deprecated.is_(None),
Bridge.destroyed.is_(None),
).all()
logging.debug("Found %s active bridges", len(active_bridges))
for bridge in active_bridges:
if bridge.conf.destroyed is not None:
bridge.deprecate(reason="conf_destroyed")
# Create new bridges
activate_bridgeconfs = BridgeConf.query.filter(
BridgeConf.destroyed.is_(None),
).all()
logging.debug("Found %s active bridge configurations", len(activate_bridgeconfs))
for bridgeconf in activate_bridgeconfs:
active_conf_bridges = Bridge.query.filter(
Bridge.conf_id == bridgeconf.id,
Bridge.deprecated.is_(None),
Bridge.destroyed.is_(None),
).all()
total_conf_bridges = Bridge.query.filter(
Bridge.conf_id == bridgeconf.id,
Bridge.destroyed.is_(None),
).all()
logging.debug("Generating new bridges for %s (active: %s, total: %s, target: %s, max: %s)",
bridgeconf.id,
len(active_conf_bridges),
len(total_conf_bridges),
bridgeconf.target_number,
bridgeconf.max_number
)
missing = min(
bridgeconf.target_number - len(active_conf_bridges),
bridgeconf.max_number - len(total_conf_bridges))
if missing > 0:
create_bridges(bridgeconf, missing)
elif missing < 0:
deprecate_bridges(bridgeconf, 0 - missing)
db.session.commit()
return True, ""

View file

@ -23,6 +23,19 @@ PROXY_PROVIDERS = {p.provider: p for p in [ # type: ignore[attr-defined]
def create_proxy(pool: Pool, origin: Origin) -> bool:
"""
Creates a web proxy resource for the given origin and pool combination.
Initially it will attempt to create smart proxies on providers that support smart proxies,
and "simple" proxies on other providers. If other providers have exhausted their quota
already then a "simple" proxy may be created on a platform that supports smart proxies.
A boolean is returned to indicate whether a proxy resource was created.
:param pool: pool to create the resource for
:param origin: origin to create the resource for
:return: whether a proxy resource was created
"""
for desperate in [False, True]:
for provider in PROXY_PROVIDERS.values():
if origin.smart and not provider.smart_proxies: # type: ignore[attr-defined]
@ -31,12 +44,12 @@ def create_proxy(pool: Pool, origin: Origin) -> bool:
continue
next_subgroup = provider.next_subgroup(origin.group_id) # type: ignore[attr-defined]
if next_subgroup is None:
continue
continue # Exceeded maximum number of subgroups and last subgroup is full
proxy = Proxy()
proxy.pool_id = pool.id
proxy.origin_id = origin.id
proxy.provider = provider.provider # type: ignore[attr-defined]
proxy.psg = provider.next_subgroup(origin.group_id) # type: ignore[attr-defined]
proxy.psg = next_subgroup
# The random usage below is good enough for its purpose: to create a slug that
# hasn't been used recently.
proxy.slug = tldextract.extract(origin.domain_name).domain[:5] + ''.join(

View file

@ -0,0 +1,68 @@
"""next generation bridge management
Revision ID: 89a74e347d85
Revises: 09b5f4bd75b8
Create Date: 2023-01-25 12:51:28.620426
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '89a74e347d85'
down_revision = '09b5f4bd75b8'
branch_labels = None
depends_on = None
def upgrade():
op.drop_constraint('fk_bridge_conf_id_bridge_conf', 'bridge')
op.drop_constraint('fk_bridge_conf_group_id_group', 'bridge_conf')
op.drop_constraint('pk_bridge', 'bridge')
op.drop_constraint('pk_bridge_conf', 'bridge_conf')
op.rename_table('bridge', 'original_bridge')
op.rename_table('bridge_conf', 'original_bridge_conf')
op.create_table('bridge_conf',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('description', sa.String(length=255), nullable=False),
sa.Column('added', sa.DateTime(), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=False),
sa.Column('destroyed', sa.DateTime(), nullable=True),
sa.Column('pool_id', sa.Integer(), nullable=False),
sa.Column('method', sa.String(length=20), nullable=False),
sa.Column('target_number', sa.Integer(), nullable=True),
sa.Column('max_number', sa.Integer(), nullable=True),
sa.Column('expiry_hours', sa.Integer(), nullable=True),
sa.Column('provider_allocation', sa.Enum('RANDOM', 'COST', name='providerallocation'), nullable=True),
sa.ForeignKeyConstraint(['pool_id'], ['pool.id'], name=op.f('fk_bridge_conf_pool_id_pool')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_bridge_conf'))
)
op.create_table('bridge',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('added', sa.DateTime(), nullable=False),
sa.Column('updated', sa.DateTime(), nullable=False),
sa.Column('deprecated', sa.DateTime(), nullable=True),
sa.Column('deprecation_reason', sa.String(), nullable=True),
sa.Column('destroyed', sa.DateTime(), nullable=True),
sa.Column('conf_id', sa.Integer(), nullable=False),
sa.Column('provider', sa.String(), nullable=False),
sa.Column('terraform_updated', sa.DateTime(), nullable=True),
sa.Column('nickname', sa.String(length=255), nullable=True),
sa.Column('fingerprint', sa.String(length=255), nullable=True),
sa.Column('hashed_fingerprint', sa.String(length=255), nullable=True),
sa.Column('bridgeline', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['conf_id'], ['bridge_conf.id'], name=op.f('fk_bridge_conf_id_bridge_conf')),
sa.PrimaryKeyConstraint('id', name=op.f('pk_bridge'))
)
def downgrade():
op.drop_table('bridge')
op.drop_table('bridge_conf')
op.rename_table('original_bridge_conf', 'bridge_conf')
op.create_primary_key('pk_bridge_conf', 'bridge_conf', ['id'])
op.create_foreign_key('fk_bridge_conf_group_id_group', 'bridge_conf', 'group', ['group_id'], ['id'])
op.rename_table('original_bridge', 'bridge')
op.create_primary_key('pk_bridge', 'bridge', ['id'])
op.create_foreign_key('fk_bridge_conf_id_bridge_conf', 'bridge', 'bridge_conf', ['conf_id'], ['id'])