Terraform provider
Manage a club's configuration as code with the champagne Terraform provider.
Clubs that run several environments keep their configuration in Terraform and promote it
from one environment to the next. The champagne provider manages the configuration of one
club through the public GraphQL API: its settings, its plans, its reservation
catalog with opening hours, recurring sessions, staff in charge, floor plans and
housekeeping checklists, the attributes and tags it keeps on people, its webhook endpoint
and its newsletter sender. It never manages people or activity
(members, applications, reservations, invoices, who carries a tag).
Terraform is the source of truth for what it manages: a change made in the back office shows
up as drift at the next terraform plan and is reverted by the next terraform apply.
Authenticate
A club administrator creates an API token in the back office, under Settings, Integrations, API tokens, and grants it Read and write on each area the configuration manages. The token names the club: the provider takes no club id, and one token never reaches another club.
| Area in the back office | Resources and data sources |
|---|---|
| Club settings | champagne_club_settings, data.champagne_club (read is enough for the data source) |
| Plans | champagne_plan |
| Reservation catalog | champagne_reservation_category, champagne_reservation_category_links, champagne_reservation_resource, champagne_reservation_session, data.champagne_staff_member |
| Floor plans | champagne_floor_plan |
| Housekeeping | champagne_housekeeping_checklist |
| Attributes and tags | champagne_person_attribute, champagne_person_tag |
| Webhooks | champagne_webhook |
| Newsletter sender | champagne_newsletter_sender |
A resource whose area the token lacks fails with
This API token lacks the permission for this operation.
terraform {
required_providers {
champagne = {
source = "ukab-labs/champagne"
}
}
}
provider "champagne" {
endpoint = "https://club.example.com/api/graphql"
token = var.champagne_token
}| Argument | Environment variable | Description |
|---|---|---|
endpoint | CHAMPAGNE_ENDPOINT | URL of the GraphQL API of the environment. |
token | CHAMPAGNE_TOKEN | Club API token with write on the managed areas. Sensitive. |
Use one state per environment: each environment has its own endpoint, its own token and its own ids. A blocked, expired or deleted token, or a token of a suspended club, is refused when the provider starts.
Every resource below is managed with the token of its club, except champagne_club and
champagne_space, which take a platform token.
Resources
Every resource is importable with terraform import <address> <id>.
champagne_club_settings
The settings of the club, one resource per club.
resource "champagne_club_settings" "club" {
description = "A club for content creators."
timezone = "Europe/Paris"
hosted_slug = "astel"
address = {
line1 = "1 rue de la Paix"
postal_code = "75002"
city = "Paris"
country = "France"
}
deposit_refund_days = 7
no_show_sanctions = {
reset_after_days = 90
rules = [
{ no_shows = 2, action = "warn" },
{ no_shows = 4, action = "suspend", suspension_days = 14 },
]
}
}| Argument | Left out | Notes |
|---|---|---|
description | Cleared | At most 2,000 characters. |
hosted_slug | Cleared | 3 to 63 lowercase letters, digits or hyphens; unique across clubs. |
address | Cleared | line1, postal_code, city, country, optional line2, state. |
timezone | Keeps the club's value | IANA timezone. |
reservation_guests_require_account | Keeps the club's value | Boolean. |
deposit_refund_days | Keeps the club's value | 0 to 365. |
credits | Not managed | name, per_euro, low_balance_threshold; needs billing enabled. |
no_show_sanctions | Not managed | reset_after_days and up to 10 rules. |
The club name is not a setting: only platform administrators rename a club. Destroying the resource removes it from the state and leaves the club's settings as they are. The import id is ignored, since the club is the one of the token.
champagne_plan
A membership plan, an add-on or a credit pack. Plans create Stripe products and prices: the club needs billing enabled and an active Stripe merchant.
resource "champagne_plan" "member" {
name = "Member"
amount = 180000 # cents
interval = "year"
}
resource "champagne_plan" "locker" {
name = "Locker"
type = "addon"
amount = 1500
interval = "month"
}Stripe prices are immutable, so a change is applied in one of three ways:
| Argument | Change |
|---|---|
name, description | Updated in place. |
credit_amount | Updated in place. |
amount of an add-on or a pack | Updated in place, with a new Stripe price. |
amount of a subscription | Splits the plan, which keeps its subscribers. |
type, currency, interval | Deletes the plan and creates another one; refused while members hold it. |
A subscription changing price is split, like in the back office. Terraform shows an update
in place whose id is known after apply: the resource then follows a new plan at the new
price, offered from now on. The previous plan is kept as replaced, reserved to the members
who hold it, at the price they pay; Terraform no longer tracks it, and destroying the
resource leaves it to them.
Changing type, currency or interval cannot be a split: Terraform deletes the plan and
creates another one. The API refuses to delete a plan members still hold, so the apply fails
with Members still hold this plan. End or move their memberships before deleting it. and
nothing changes. The same refusal protects terraform destroy.
currency defaults to eur, interval to year and type to subscription.
stripe_product_id is exported.
champagne_reservation_category
A category of things members book.
resource "champagne_reservation_category" "visit" {
name = "Discovery visit"
onboarding_only = true
waitlist_enabled = false
slot_booking = true
default_duration_minutes = 60
slot_interval_minutes = 60
min_notice_minutes = 120
booking_window_days = 30
}name is unique in the club, ignoring case. The flags conversation_required,
create_ticket, requires_session, visualization_enabled, housekeeping_enabled,
customer_sheets_enabled, external_booking_enabled, onboarding_only and slot_booking
default to false, waitlist_enabled to true, slot_interval_minutes to 30 and
currency to eur. default_duration_minutes, booking_window_days and
min_notice_minutes mean "no limit" when left out. See
booking rules for what each setting
does. A category holding reservations cannot be destroyed.
opening_hours lists when slots can be booked, as windows of one day in the club's
timezone; left out, the category has no opening hours:
resource "champagne_reservation_category" "restaurant" {
name = "Restaurant"
slot_booking = true
default_duration_minutes = 120
opening_hours = [
{ weekday = 2, start_minutes = 720, end_minutes = 870 }, # Tuesday 12:00-14:30
{ weekday = 2, start_minutes = 1140, end_minutes = 1380 }, # Tuesday 19:00-23:00
]
}weekday runs from 0 (Sunday) to 6 (Saturday). Windows of one day must not overlap.
champagne_reservation_category_links
The categories a reservation of one category can be moved to, such as the two rooms of a restaurant. Links point one way; they are a resource of their own so two categories can link to each other.
resource "champagne_reservation_category_links" "restaurant" {
category_id = champagne_reservation_category.restaurant.id
linked_category_ids = [champagne_reservation_category.rooftop.id]
}
resource "champagne_reservation_category_links" "rooftop" {
category_id = champagne_reservation_category.rooftop.id
linked_category_ids = [champagne_reservation_category.restaurant.id]
}Declare at most one per category, with up to 50 other categories of the club. Destroying it removes the links. The import id is the category id.
champagne_reservation_resource
One bookable thing of a category: a room, a table, a coach.
resource "champagne_reservation_resource" "podcast" {
category_id = champagne_reservation_category.studios.id
name = "Podcast studio"
amount = 2500 # cents per price_unit
price_unit = "hour"
max_occupants = 4
}name is unique in its category. amount defaults to 0, price_unit (once, hour,
day or night) to once, max_occupants to 1 and external_bookable to false.
deposit_amount, default_check_in_minutes, default_check_out_minutes,
cancellation_notice_days and external_amount are cleared when left out. Changing
category_id replaces the resource. A resource holding reservations cannot be destroyed.
Categories managed by Terraform leave their resources to this resource: adding, renaming or removing a resource never rewrites the category.
staff_user_ids assigns the staff in charge of the resource, such as its coach. Staff
accounts are people: they are invited from the back office and their ids differ between
environments, so look them up by email with champagne_staff_member:
data "champagne_staff_member" "julien" {
email = "julien@example.com"
}
resource "champagne_reservation_resource" "tennis" {
category_id = champagne_reservation_category.lessons.id
name = "Tennis with Julien"
staff_user_ids = [data.champagne_staff_member.julien.id]
}Left out, the assignment is not managed; an empty set removes everyone. The API refuses to remove a staff member whose upcoming bookings would be left without anyone in charge.
champagne_reservation_session
A recurring session of a category with requires_session: a class members book a seat in.
resource "champagne_reservation_session" "pilates" {
category_id = champagne_reservation_category.classes.id
resource_id = champagne_reservation_resource.studio.id
title = "Pilates"
weekday = 1 # Monday
start_minutes = 900 # 15:00
duration_minutes = 60
capacity = 8
}kind is weekly (the default, with a weekday) or daily (without one). capacity is
left out for unlimited sessions. reservation_required = false describes a session members
attend without booking; it takes no capacity. guest_plan_ids restricts the session to the
members of some plans.
The API cannot edit a recurring session in place, so any change replaces the session:
Terraform deletes it and creates another one. A session with bookings or a waitlist cannot be
deleted, and therefore cannot be changed until they are cancelled. Dated one-off sessions are
events rather than configuration: create them in the back office. The import id is
<category id>/<session id>.
champagne_floor_plan
The floor plan of a category with visualization_enabled: its spaces and the elements drawn
in them. Members pick the resource they book by selecting its element.
resource "champagne_floor_plan" "pool" {
category_id = champagne_reservation_category.pool.id
spaces = [{
id = "7b000000-0000-4000-8000-000000000001"
name = "Outdoor pool"
surface = "terrace"
palette = "lagoon"
}]
elements = [
{
id = "7b000000-0000-4000-8000-000000000101"
space_id = "7b000000-0000-4000-8000-000000000001"
kind = "swimmingPool"
label = "Pool"
x = 100
y = 80
width = 400
height = 200
locked = true
},
{
id = "7b000000-0000-4000-8000-000000000102"
space_id = "7b000000-0000-4000-8000-000000000001"
kind = "rectangle"
label = champagne_reservation_resource.lounger.name
resource_id = champagne_reservation_resource.lounger.id
x = 120
y = 320
width = 60
height = 120
rotation = 90
},
]
}The ids of spaces and elements are UUIDs written in the configuration, so they are the same
in every environment. A plan has 1 to 12 spaces (surface is terrace, interior or
garden; palette and color are optional) and at most 200 elements, drawn in order.
| Element argument | Notes |
|---|---|
id, space_id, kind, label | Required. kind is a shape such as rectangle, ellipse, pottedPlant, parasol. |
x, y, width, height | Required, in plan units. |
rotation, locked | Default to 0 and false. |
resource_id | Resource of the category booked through this element; each resource is placed once. |
object_id | Custom object of the category's library. Objects carry artwork made in the back office. |
color, stroke_color, stroke_width, outline_only, label_visible, keep_aspect_ratio | Optional appearance. |
chairs, chair_positions, points, bookable | Optional geometry of tables, polygons and decoration. |
The API names an element placing a resource after that resource: set its label to the
resource's name, as above, or the next plan reports a change. Such an element is a
rectangle or an ellipse. bookable = true without a resource_id is refused, because the
API would create a resource Terraform does not manage.
The plan is saved over the revision currently stored: if someone edits it in the back office during an apply, the apply fails and can be run again. Destroying the resource removes every element and keeps the spaces, since a stored plan always has one. The import id is the category id.
champagne_housekeeping_checklist
The housekeeping checklist of one resource, in a category with housekeeping_enabled.
resource "champagne_housekeeping_checklist" "garden" {
category_id = champagne_reservation_category.suites.id
resource_id = champagne_reservation_resource.garden.id
items = [
{ category = "daily", label = "Air the room" },
{ category = "daily", label = "Restock towels" },
{ category = "after_reservation", label = "Change the sheets" },
]
}items holds at most 100 items in display order. category is daily (every day) or
after_reservation (each departure); labels are distinct within a category. An item is
identified by its category and label: reordering keeps the ticks staff recorded, rewording an
item replaces it and drops them. Destroying the resource empties the checklist. Reading a
checklist needs the club's housekeeping module. The import id is
<category id>/<resource id>.
champagne_webhook
One webhook endpoint of the club. A club has up to 10 of them, each with its own URL, events and signing secret. See webhooks for the events and their signature.
resource "champagne_webhook" "website" {
url = "https://hooks.example.com/champagne"
enabled_events = ["reservation.created", "application.status_changed"]
}
resource "champagne_webhook" "accounting" {
url = "https://accounting.example.com/hooks"
enabled_events = ["invoice.issued", "invoice.paid", "invoice.credited"]
enabled = false
}
output "website_signing_secret" {
value = champagne_webhook.website.signing_secret
sensitive = true
}url is a public HTTPS URL, unique in the club. enabled_events lists the event types to
send; left out, the endpoint keeps its selection, which starts with every event. enabled
defaults to true; set it to false to pause deliveries, knowing that events that happen
meanwhile are not sent later. signing_secret is exported and sensitive: it is read when the
resource is created or imported, then kept in the state, so store the state accordingly.
After rotating a secret in the back office, remove the resource from the state and import it
again to read the new one. Destroying the resource deletes the endpoint and its secret.
Import with the endpoint id.
champagne_newsletter_sender
The address the club sends its newsletters from, one resource per club. It needs newsletter sending configured on the platform.
resource "champagne_newsletter_sender" "club" {
domain = "news.example.com"
sender_name = "Astel"
}
output "newsletter_dns_records" {
value = champagne_newsletter_sender.club.dns_records
}local_part defaults to newsletter. sender_email, status (pending, verified or
failed) and dns_records (type, name, value) are exported: publish the records in
the DNS zone of the domain, with Terraform or by hand, and the status becomes verified
once they are found. The API cannot remove a sender: destroying the resource only removes it
from the state. The import id is ignored.
champagne_person_attribute
A custom field the club keeps on each person.
resource "champagne_person_attribute" "language" {
key = "preferred_language"
label = "Preferred language"
type = "choice"
options = ["French", "English", "German"]
}key is the stable identifier of the attribute, unique in the club: a lowercase letter
followed by up to 39 lowercase letters, digits or underscores. type is text (the
default), number, date, boolean or choice; options lists the 2 to 50 choices of a
choice attribute and is left out otherwise. label and options are updated in place.
Changing key or type replaces the attribute, which erases the values people hold for
it. An attribute used by an onboarding question cannot be destroyed. See
member attributes.
champagne_person_tag
A staff-only label grouping people of the club.
resource "champagne_person_tag" "founder" {
label = "Founding member"
color = "amber"
}label is unique in the club, ignoring case, and holds at most 24 characters. color
defaults to gray; the palette is listed with member tags. Terraform
manages the tag, never who carries it. Destroying a tag removes it from everyone carrying it.
champagne_club
A club of a customer space. It is the one resource managed with a platform token, created by a platform admin in the Platform section under API tokens: configure a provider alias for it, next to the provider of each club.
provider "champagne" {
alias = "platform"
token = var.champagne_platform_token
}
data "champagne_space" "customer" {
provider = champagne.platform
name = "NJJ Hospitality"
}
resource "champagne_club" "astel" {
provider = champagne.platform
space_id = data.champagne_space.customer.id
name = "Astel"
timezone = "Europe/Paris"
disabled_features = ["billing"]
}name is unique in its space, ignoring case, and starts with a capital letter. timezone
defaults to Europe/Paris. disabled_features turns modules off for the club:
reservations, availability, customer_chats, housekeeping, newsletters,
applications, billing. hosted_slug can be set here or in champagne_club_settings, not
in both. status is exported. Changing space_id replaces the club.
The platform never deletes a club: destroying the resource archives it, which closes it to its members and staff and keeps its data. An archived club keeps its name, so a club destroyed this way cannot be created again under the same name in that space; restore it from the back office and import it instead.
A new club has no API token: a club administrator creates one in the back office before the club's own configuration can be applied. Spaces and their owners are created in the back office too.
Data sources
champagne_space
A customer space found by name (ignoring case), with a platform token: id.
champagne_club
The club of the token: id, name, description, timezone, hosted_slug and
billing_enabled.
data "champagne_club" "current" {}champagne_staff_member
A staff member of the club found by email (ignoring case): id and name. Fails when
nobody in the club's team has this email.
Example
terraform-provider/examples/astel in the repository configures the Astel club like the
demo seed does: its settings, four categories of spaces with their resources, the discovery
visit booked during onboarding and the attribute staff fill in for each creator.
Develop the provider
The provider lives in terraform-provider/ and only talks to the GraphQL API. Go and
Terraform run in Docker, behind make targets:
| Target | Does |
|---|---|
make provider-build | Builds the provider into terraform-provider/bin. |
make provider-fix | Formats the Go code and the examples. |
make provider-check | Checks formatting, runs go vet and the tests against an in-memory API, validates the example. |
make provider-testacc | Runs the same tests against the running stack, in a club created for the run. |
make provider-example | Applies the example to a club created for the run, then requires an empty plan. |
make provider-try | Runs Terraform on a copy of the example in .local/provider-try with the token in CHAMPAGNE_TOKEN: shows the plan and asks before applying. TF=plan and TF=destroy run those instead. |
provider-testacc, provider-example and provider-try need a stack started with make worktree-up or
make dev: they sign in with the seeded demo accounts to create a club and its token. The
plan tests need Stripe and are skipped unless run against a club with an active merchant:
make provider-testacc CLUB_ID=51000000-0000-4000-8000-000000000051 CHAMPAGNE_TEST_PLANS=1 ARGS="-run Plan"The provider job of the CI runs provider-check, starts the Compose stack, then runs
provider-example and provider-testacc against it, so every pull request applies the
example to a real API and requires its second plan to be empty.