Skip to content

Commit 67392a2

Browse files
authored
Merge pull request #21 from tahoe-lafs/gha-webforge-tf
Webforge server defined as code with OpenToFu and GHA workflow for CI and CD
2 parents 40feb9d + cb7be2f commit 67392a2

10 files changed

+384
-0
lines changed

.github/workflows/_tf.yml

+200
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
# Re-usable workflow to continuously integrate and deploy OpenToFu plan
2+
3+
on:
4+
workflow_call:
5+
inputs:
6+
tf_version:
7+
description: 'Version of OpenToFu runtime to use'
8+
required: false
9+
type: string
10+
default: '1.9.0'
11+
tf_dir:
12+
description: 'Path to the OpenToFu plan to use'
13+
required: false
14+
type: string
15+
default: './'
16+
gh_runner_version:
17+
description: 'Version of the GitHub runner to use'
18+
required: false
19+
type: string
20+
default: 'ubuntu-22.04'
21+
auto_comment:
22+
description: 'Enable automatic comment on GitHub pull request'
23+
required: false
24+
type: boolean
25+
default: true
26+
apply_on_branch:
27+
description: 'Automaticaly apply plan when on a specific branch'
28+
required: false
29+
type: string
30+
default: ''
31+
aws_default_region:
32+
description: 'AWS default region'
33+
required: false
34+
type: string
35+
default: 'eu-central-1'
36+
secrets:
37+
aws_access_key_id:
38+
description: 'AWS access key id'
39+
required: false
40+
aws_secret_access_key:
41+
description: 'AWS secret access key'
42+
required: false
43+
hcloud_token:
44+
description: 'API token for Hetzner Cloud'
45+
required: false
46+
47+
jobs:
48+
tf:
49+
name: OpenToFu
50+
runs-on: ${{ inputs.gh_runner_version }}
51+
defaults:
52+
run:
53+
working-directory: ${{ inputs.tf_dir }}
54+
env:
55+
AWS_ACCESS_KEY_ID: ${{ secrets.aws_access_key_id }}
56+
AWS_SECRET_ACCESS_KEY: ${{ secrets.aws_secret_access_key }}
57+
AWS_DEFAULT_REGION: ${{ inputs.aws_default_region }}
58+
TF_VAR_hcloud_token: ${{ secrets.hcloud_token }}
59+
permissions:
60+
pull-requests: write
61+
contents: read
62+
63+
steps:
64+
- name: Checkout
65+
id: checkout
66+
uses: actions/checkout@v4
67+
68+
- name: Setup
69+
id: setup
70+
uses: opentofu/setup-opentofu@v1
71+
with:
72+
tofu_version: ${{ inputs.tf_version }}
73+
74+
- name: Format
75+
id: fmt
76+
run: tofu fmt -no-color -check -diff
77+
78+
- name: Init
79+
id: init
80+
run: tofu init -no-color -input=false
81+
82+
- name: Validate
83+
id: validate
84+
run: tofu validate -no-color
85+
86+
- name: Plan
87+
id: plan
88+
run: tofu plan -no-color -input=false -compact-warnings -out tf_plan.out
89+
90+
- name: Post Setup
91+
id: post_setup
92+
# Only to avoid wrapper to mess up outputs in later steps
93+
uses: opentofu/setup-opentofu@v1
94+
with:
95+
tofu_version: ${{ inputs.tf_version }}
96+
tofu_wrapper: false
97+
98+
- name: Verify
99+
id: verify
100+
run: |
101+
# Process the plan to verify the presence of some data
102+
# which can be used later to make additional checks
103+
tofu show -no-color tf_plan.out > tf_plan.log 2> >(tee tf_plan.err >&2) && ret=0 || ret=$?
104+
# Export the plan in json too
105+
tofu show -json tf_plan.out > tf_plan.json
106+
# Extract current state from the plan for later comparison
107+
unzip tf_plan.out tfstate
108+
# Extract data from temp files and export them as outputs for next steps
109+
# - changes made, if any
110+
echo "changes<<tf_verify_changes" >> $GITHUB_OUTPUT
111+
awk '/the following actions/,0' tf_plan.log >> $GITHUB_OUTPUT
112+
echo "tf_verify_changes" >> $GITHUB_OUTPUT
113+
# - summary of the change, if any
114+
echo "summary<<tf_verify_summary" >> $GITHUB_OUTPUT
115+
awk '/(Plan: |No changes. )/,1' tf_plan.log | sed -e 's/Plan: /change(s): /' >> $GITHUB_OUTPUT
116+
echo "tf_verify_summary" >> $GITHUB_OUTPUT
117+
# - stderr describing errors, if any
118+
echo "stderr<<tf_verify_stderr" >> $GITHUB_OUTPUT
119+
cat tf_plan.err >> $GITHUB_OUTPUT
120+
echo "tf_verify_stderr" >> $GITHUB_OUTPUT
121+
# Exit with failure if any
122+
exit $ret
123+
124+
- name: Comment
125+
id: update
126+
if: ${{ always() && github.event_name == 'pull_request' && inputs.auto_comment }}
127+
uses: actions/github-script@v7
128+
with:
129+
github-token: ${{ github.token }}
130+
script: |
131+
// 1. Retrieve existing bot comments for the PR
132+
const { data: comments } = await github.rest.issues.listComments({
133+
owner: context.repo.owner,
134+
repo: context.repo.repo,
135+
issue_number: context.issue.number,
136+
})
137+
const botComment = comments.find(comment => {
138+
return comment.user.type === 'Bot' && comment.body.includes('pr-auto-comment-${{ inputs.tf_dir }}')
139+
})
140+
141+
// 2. Prepare format of the comment, using toJSON to escape any special char
142+
const changes = [
143+
${{ toJSON(steps.verify.outputs.changes) }},
144+
]
145+
const errors = [
146+
${{ toJSON(steps.fmt.outputs.stdout) }},
147+
${{ toJSON(steps.init.outputs.stderr) }},
148+
${{ toJSON(steps.validate.outputs.stderr) }},
149+
${{ toJSON(steps.plan.outputs.stderr) }},
150+
${{ toJSON(steps.verify.outputs.stderr) }},
151+
]
152+
const output = `
153+
<!-- pr-auto-comment-${{ inputs.tf_dir }} -->
154+
### ${{ github.workflow }}
155+
| Step | Outcome |
156+
| ---- | ------- |
157+
| :pencil2: **Format** | \`${{ steps.fmt.outcome }}\` |
158+
| :wrench: **Init** ️| \`${{ steps.init.outcome }}\` |
159+
| :mag: **Validate** | \`${{ steps.validate.outcome }}\` |
160+
| :page_facing_up: **Plan** | \`${{ steps.plan.outcome }}\` |
161+
| :passport_control: **Verify** | \`${{ steps.verify.outcome }}\` |
162+
| :point_right: **Result** | ${{ ( steps.plan.outcome == 'success' && steps.verify.outcome == 'success' && steps.verify.outputs.summary ) || 'with error(s) - see below' }} |
163+
164+
<details><summary>show change(s)</summary>
165+
166+
\`\`\`
167+
${ changes.filter(function(entry) { return /\S/.test(entry); }).join('\n') }
168+
\`\`\`
169+
170+
</details>
171+
172+
<details><summary>show error(s)</summary>
173+
174+
\`\`\`
175+
${ errors.filter(function(entry) { return /\S/.test(entry); }).join('\n') }
176+
\`\`\`
177+
178+
</details>
179+
180+
*Pusher: @${{ github.actor }}, Action: \`${{ github.event_name }}\`*
181+
*Workflow: \`${{ github.workflow_ref }}\`*`;
182+
183+
// 3. If we have a comment, update it, otherwise create a new one
184+
const comment_data = {
185+
owner: context.repo.owner,
186+
repo: context.repo.repo,
187+
body: output
188+
}
189+
if (botComment) {
190+
comment_data.comment_id = botComment.id
191+
github.rest.issues.updateComment(comment_data)
192+
} else {
193+
comment_data.issue_number = context.issue.number
194+
github.rest.issues.createComment(comment_data)
195+
}
196+
197+
- name: Apply
198+
id: apply
199+
if: ${{ inputs.apply_on_branch != '' && github.ref == format('refs/heads/{0}', inputs.apply_on_branch) }}
200+
run: tofu apply -no-color -input=false -auto-approve tf_plan.out

.github/workflows/tf-core.yml

+33
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
# Workflow to continuously integrate and deploy OpenToFu plan for
2+
# the core resources of Tahoe-LAFS (e.g.: DNS, self-hosted vps, ...)
3+
name: ToFu - core
4+
concurrency: tf-core_state
5+
6+
on:
7+
push:
8+
branches:
9+
- main
10+
paths:
11+
- '.github/workflows/_tf.yml'
12+
- '.github/workflows/tf-core.yml'
13+
- 'tf/core/**'
14+
pull_request:
15+
paths:
16+
- '.github/workflows/_tf.yml'
17+
- '.github/workflows/tf-core.yml'
18+
- 'tf/core/**'
19+
workflow_dispatch:
20+
21+
jobs:
22+
call-workflow-passing-data:
23+
# Call the re-usable ToFu workflow
24+
uses: ./.github/workflows/_tf.yml
25+
with:
26+
tf_version: '1.9.0'
27+
tf_dir: tf/core
28+
apply_on_branch: 'main'
29+
aws_default_region: 'eu-central-1'
30+
secrets:
31+
aws_access_key_id: ${{ secrets.AWS_ACCESS_KEY_ID }}
32+
aws_secret_access_key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
33+
hcloud_token: ${{ secrets.HCLOUD_TOKEN }}

.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
*~
2+
.env*

tf/.gitignore

+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
*/.terraform/*

tf/core/.terraform.lock.hcl

+24
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tf/core/main.tf

+16
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
# There is no remote state backend for the resources defined in this project.
2+
# TODO: Assess if we could/should use one instead of committing the tfstate file.
3+
4+
# Lets make the local state explicit and remind contributors to commit changes
5+
terraform {
6+
# https://opentofu.org/docs/language/settings/backends/s3/
7+
backend "s3" {
8+
bucket = "tf-state-tahoe-infra"
9+
encrypt = true
10+
key = "state"
11+
workspace_key_prefix = "wks:"
12+
13+
region = "eu-central-1"
14+
dynamodb_table = "tf-state-tahoe-infra"
15+
}
16+
}

tf/core/providers.tf

+14
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
terraform {
2+
required_version = "~> 1.4"
3+
4+
required_providers {
5+
hcloud = {
6+
source = "hetznercloud/hcloud"
7+
version = "= 1.49.1"
8+
}
9+
}
10+
}
11+
12+
provider "hcloud" {
13+
token = var.hcloud_token
14+
}

tf/core/srv_webforge.tf

+46
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# System name: webforge
2+
# Main FQDN: webforge.tahoe-lafs.org
3+
# Provider: Hetzner
4+
# OS: NixOS
5+
# Description: Web-based collaborative version control server for Tahoe-LAFS
6+
resource "hcloud_server" "webforge" {
7+
name = "webforge"
8+
server_type = "cx32"
9+
image = "debian-12"
10+
location = "hel1"
11+
backups = true
12+
labels = {
13+
"env" : "prod"
14+
"source" : "tf-tahoe-lafs-core"
15+
}
16+
ssh_keys = [for k, v in local.ssh_keys : "tf-${v.name}"]
17+
user_data = <<EOF
18+
#cloud-config
19+
20+
runcmd:
21+
- curl https://raw.githubusercontent.com/elitak/nixos-infect/5ef3f953d32ab92405b280615718e0b80da2ebe6/nixos-infect | PROVIDER=hetznercloud NIX_CHANNEL=nixos-24.11 bash 2>&1 | tee /tmp/infect.log
22+
EOF
23+
# Wait for the ssh key(s)
24+
depends_on = [
25+
hcloud_ssh_key.ssh_keys
26+
]
27+
lifecycle {
28+
ignore_changes = [
29+
# Ignore changes to ssh_keys post installation
30+
ssh_keys,
31+
]
32+
}
33+
}
34+
35+
# System PTR records
36+
resource "hcloud_rdns" "webforge_ipv4" {
37+
server_id = hcloud_server.webforge.id
38+
ip_address = hcloud_server.webforge.ipv4_address
39+
dns_ptr = "webforge.tahoe-lafs.org"
40+
}
41+
42+
resource "hcloud_rdns" "webforge_ipv6" {
43+
server_id = hcloud_server.webforge.id
44+
ip_address = hcloud_server.webforge.ipv6_address
45+
dns_ptr = "webforge.tahoe-lafs.org"
46+
}

tf/core/users.tf

+44
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# This file only list our user's email and public keys,
2+
# so those can be re-used elsewhere (e.g.: hcloud, gandi, ...)
3+
locals {
4+
users = {
5+
benoit = {
6+
email = "benoit@leastauthority.com",
7+
ssh_keys = [
8+
{
9+
id = "000619776016",
10+
key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIIZtWY7t8HVnaz6bluYsrAlzZC3MZtb8g0nO5L5fCQKR benoit@leastauthority.com",
11+
},
12+
],
13+
},
14+
florian = {
15+
email = "florian@leastauthority.com",
16+
ssh_keys = [
17+
{
18+
id = "000018054987",
19+
key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIJlPneIaRT/mqu13N83ctEftub4O6zAfi6qgzZKerU5o florian@leastauthority.com",
20+
},
21+
],
22+
},
23+
}
24+
25+
# Flatten all the ssh keys of each users
26+
ssh_keys = flatten([
27+
for username, values in local.users : [
28+
for v in values.ssh_keys : {
29+
name = format("%s-%s", username, v.id)
30+
public_key = v.key
31+
}
32+
]
33+
])
34+
}
35+
36+
# Manage ssh keys
37+
resource "hcloud_ssh_key" "ssh_keys" {
38+
for_each = {
39+
for key in local.ssh_keys : "tf-${key.name}" => key.public_key
40+
}
41+
42+
name = each.key
43+
public_key = each.value
44+
}

tf/core/variables.tf

+5
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# The API token to interact with Hetzner Cloud
2+
variable "hcloud_token" {
3+
type = string
4+
sensitive = true
5+
}

0 commit comments

Comments
 (0)