Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .env.acceptance.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# Acceptance test credentials for the vm-stack Terraform module.
#
# Usage:
# cp .env.acceptance.example .env.acceptance
# # fill in your values below
# make test-acceptance
#
# This file is checked in as a template. .env.acceptance is gitignored.

# ── Required ──────────────────────────────────────────────────────────────────

HUDDLE_API_KEY=
HUDDLE_REGION=eu2
HUDDLE_FLAVOR_NAME=anton-2
HUDDLE_IMAGE_NAME=ubuntu-22.04

# ── Optional ──────────────────────────────────────────────────────────────────

# ID of a pre-existing network. When set, enables TestExistingNetwork.
# HUDDLE_EXISTING_NETWORK_ID=

# Override the API base URL (default: production).
# Set to http://localhost:8080/api/v1 to test against a local server.
# Do NOT surround values with quotes — Make reads them literally and will
# pass the quote characters through to the provider, breaking URL parsing.
# HUDDLE_LOCAL_BASE_URL=http://localhost:8080/api/v1

# Set to 1 when using provider dev_overrides (skips terraform init).
# TF_SKIP_INIT=1
122 changes: 122 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
name: CI

on:
pull_request:
branches: [main]
types: [opened, synchronize, reopened, labeled]
push:
branches: [main]

jobs:
# ── Formatting ──────────────────────────────────────────────────────────────
fmt:
name: Terraform format
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~> 1.7"

- name: Check formatting
run: terraform fmt -check -recursive .

# ── Validation ──────────────────────────────────────────────────────────────
validate:
name: Terraform validate
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4

- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~> 1.7"

- name: Init (no backend)
run: terraform init -backend=false

- name: Validate
run: terraform validate

# ── Unit tests ──────────────────────────────────────────────────────────────
# Runs on every PR. Uses mock_provider — no credentials required.
unit-tests:
name: Unit tests
runs-on: ubuntu-latest
needs: [fmt, validate]
steps:
- uses: actions/checkout@v4

- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~> 1.7"

- name: Init
run: terraform init -backend=false

- name: Run unit tests
run: terraform test -test-directory=tests

# ── Acceptance tests ────────────────────────────────────────────────────────
# Only runs when a maintainer adds the 'acceptance-tests' label to the PR,
# or on pushes to main. Requires secrets configured in the repository.
acceptance-tests:
name: Acceptance tests
runs-on: ubuntu-latest
needs: [unit-tests]
if: >
github.event_name == 'push' ||
(github.event_name == 'pull_request' &&
github.event.action == 'labeled' &&
github.event.label.name == 'acceptance-tests')

steps:
- name: Verify label was added by a maintainer
if: github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
PERMISSION=$(gh api \
repos/${{ github.repository }}/collaborators/${{ github.event.sender.login }}/permission \
--jq '.permission')
echo "Label added by ${{ github.event.sender.login }} with permission: $PERMISSION"
if [[ "$PERMISSION" != "admin" && "$PERMISSION" != "maintain" && "$PERMISSION" != "write" ]]; then
echo "::error::Only maintainers (write/maintain/admin) may trigger acceptance tests. Removing label."
gh pr edit ${{ github.event.pull_request.number }} --remove-label "acceptance-tests" || true
exit 1
fi

- uses: actions/checkout@v4

- uses: actions/setup-go@v5
with:
go-version: "~1.22"
cache-dependency-path: tests/acceptance/go.sum

- uses: hashicorp/setup-terraform@v3
with:
terraform_version: "~> 1.7"
# Expose the Terraform binary to the Terratest Go process.
terraform_wrapper: false

- name: Download Go dependencies
working-directory: tests/acceptance
run: go mod download

- name: Run acceptance tests
working-directory: tests/acceptance
env:
HUDDLE_API_KEY: ${{ secrets.HUDDLE_API_KEY }}
HUDDLE_REGION: ${{ secrets.HUDDLE_REGION }}
HUDDLE_FLAVOR_NAME: ${{ secrets.HUDDLE_FLAVOR_NAME }}
HUDDLE_IMAGE_NAME: ${{ secrets.HUDDLE_IMAGE_NAME }}
HUDDLE_EXISTING_NETWORK_ID: ${{ secrets.HUDDLE_EXISTING_NETWORK_ID }}
run: go test -v -timeout 30m ./...

- name: Remove trigger label
if: always() && github.event_name == 'pull_request'
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh pr edit ${{ github.event.pull_request.number }} --remove-label "acceptance-tests" || true
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,6 @@
crash.log
*.tfvars
.DS_Store

# Environment (credentials for acceptance tests — never commit)
.env.acceptance
78 changes: 78 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
.PHONY: fmt validate test-unit test-acceptance test acc-deps

# Load credentials from .env.acceptance when it exists (file is gitignored).
# Copy .env.acceptance.example → .env.acceptance and fill in your values.
# Any variable already exported in the shell takes precedence over the file.
ENV_FILE ?= .env.acceptance
-include $(ENV_FILE)
export

# ── Formatting & validation ───────────────────────────────────────────────────

fmt:
terraform fmt -recursive .

validate:
terraform init -backend=false
terraform validate

# ── Unit tests (no credentials required) ─────────────────────────────────────
# Uses the native `terraform test` framework with mock_provider blocks.
# Requires Terraform >= 1.7.

test-unit:
terraform test -test-directory=tests

# ── Acceptance tests (requires live API credentials) ─────────────────────────
# Provisions real infrastructure. Credentials are loaded from .env.acceptance
# automatically (see top of file). The following variables are recognised:
#
# HUDDLE_API_KEY required
# HUDDLE_REGION required (e.g. eu2)
# HUDDLE_FLAVOR_NAME required (e.g. anton-2)
# HUDDLE_IMAGE_NAME required (e.g. ubuntu-22.04)
# HUDDLE_EXISTING_NETWORK_ID optional — enables TestExistingNetwork
#
# HUDDLE_LOCAL_BASE_URL optional — override the API endpoint.
# Use this to point tests at a local API server
# (e.g. http://localhost:8080/api/v1) instead of
# production. Passed to the fixture as the
# provider base_url argument.
#
# TF_SKIP_INIT=1 set this when using provider dev_overrides
# (e.g. `make dev-override` in the provider/
# directory). Terraform itself warns against
# running `init` with dev_overrides in effect.
#
# -count=1 is always passed to disable Go's test result cache. Acceptance tests
# provision real infrastructure whose state can change between runs, so cached
# results are never meaningful here.
#
# Default local API endpoint used by the `*-local` convenience targets.
HUDDLE_LOCAL_BASE_URL ?= http://localhost:8080/api/v1

test-acceptance:
cd tests/acceptance && go test -v -count=1 -timeout 30m ./...

# Convenience target for local dev when provider dev_overrides is configured
# (skips terraform init) but still points at production or whatever
# HUDDLE_LOCAL_BASE_URL is set to.
test-acceptance-dev:
cd tests/acceptance && TF_SKIP_INIT=1 go test -v -count=1 -timeout 30m ./...

# Convenience target: runs acceptance tests against a local API server.
# Start the API first: `cd api && make dev`
# Override the URL with: make test-acceptance-local HUDDLE_LOCAL_BASE_URL=http://localhost:9090/api/v1
test-acceptance-local:
cd tests/acceptance && \
TF_SKIP_INIT=1 \
go test -v -count=1 -timeout 30m ./...

# ── Run both ──────────────────────────────────────────────────────────────────

test: test-unit test-acceptance

# ── Install Go dependencies for acceptance tests ──────────────────────────────

acc-deps:
cd tests/acceptance && go mod tidy
103 changes: 100 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Starter module for provisioning a single VM stack on Huddle01 Cloud.

Creates:
- Optional private network
- Security group + ingress rules
- Security group + ingress/egress rules
- Keypair
- Instance

Expand All @@ -22,17 +22,114 @@ Use explicit volume lifecycle resources in root configuration:
- **No ingress rules are created by default.** You must explicitly define `ingress_rules` — the module will not open any ports unless you ask it to.
- **Restrict CIDR blocks** to known IP ranges rather than `0.0.0.0/0`. Only expose ports to the internet if your workload requires it.
- **Set `assign_public_ip = false`** for internal workloads that do not need a public IP address.
- **Egress is allow-all by default** (provider default). Use `egress_rules` to restrict outbound traffic if needed.

## Usage

### Basic (create everything)

```hcl
module "vm_stack" {
source = "huddle01/vm-stack/cloud"

name_prefix = "demo"
region = "eu2"
flavor_id = "anton-4"
image_id = "ubuntu-22.04"
flavor_name = "anton-4"
image_name = "ubuntu-22.04"
ssh_public_key = file("~/.ssh/id_ed25519.pub")

pool_cidr = "10.0.0.0/8"
primary_subnet_cidr = "10.0.1.0/24"
primary_subnet_size = 24

ingress_rules = [
{ protocol = "tcp", port = 80, cidr = "0.0.0.0/0" },
{ protocol = "tcp", port = 443, cidr = "0.0.0.0/0" },
]
}
```

### Use an existing network

```hcl
module "vm_stack" {
source = "huddle01/vm-stack/cloud"

name_prefix = "app"
region = "eu2"
flavor_name = "anton-4"
image_name = "ubuntu-22.04"
ssh_public_key = file("~/.ssh/id_ed25519.pub")

create_network = false
network_id = "existing-network-uuid"

ingress_rules = [
{ protocol = "tcp", port = 443, cidr = "0.0.0.0/0" },
]
}
```

### Internal workload (no public IP, restricted egress)

```hcl
module "vm_stack" {
source = "huddle01/vm-stack/cloud"

name_prefix = "internal"
region = "eu2"
flavor_name = "anton-2"
image_name = "ubuntu-22.04"
ssh_public_key = file("~/.ssh/id_ed25519.pub")
assign_public_ip = false

pool_cidr = "10.0.0.0/8"
primary_subnet_cidr = "10.0.2.0/24"
primary_subnet_size = 24

egress_rules = [
{ protocol = "tcp", port = 443, cidr = "0.0.0.0/0" },
]
}
```

## Input Variables

| Name | Type | Default | Required | Description |
|------|------|---------|----------|-------------|
| `name_prefix` | `string` | — | yes | Prefix applied to all resource names |
| `region` | `string` | — | yes | Huddle01 Cloud region (e.g. `eu2`) |
| `flavor_name` | `string` | — | yes | Instance flavor name (e.g. `anton-2`, `anton-4`) |
| `image_name` | `string` | — | yes | OS image name to boot from (e.g. `ubuntu-22.04`) |
| `ssh_public_key` | `string` | — | yes | OpenSSH public key for SSH access |
| `boot_disk_size` | `number` | `30` | no | Boot disk size in GB (must be > 0) |
| `assign_public_ip` | `bool` | `true` | no | Assign a public IPv4 address |
| `power_state` | `string` | `"active"` | no | Desired power state: `active`, `stopped`, `paused`, `suspended` |
| `create_network` | `bool` | `true` | no | Create a new private network for the instance |
| `network_id` | `string` | `null` | no | Existing network ID; required when `create_network = false` |
| `pool_cidr` | `string` | `null` | no | Floating IP pool CIDR; required when `create_network = true` |
| `primary_subnet_cidr` | `string` | `null` | no | Primary subnet CIDR; required when `create_network = true` |
| `primary_subnet_size` | `number` | `null` | no | Primary subnet prefix length; required when `create_network = true` |
| `no_gateway` | `bool` | `false` | no | Disable default gateway on the subnet |
| `enable_dhcp` | `bool` | `true` | no | Enable DHCP on the primary subnet |
| `ingress_rules` | `list(object)` | `[]` | no | Inbound firewall rules (protocol, port, cidr) |
| `egress_rules` | `list(object)` | `[]` | no | Outbound firewall rules (protocol, port, cidr). Empty = provider default (allow all) |

## Outputs

| Name | Sensitive | Description |
|------|-----------|-------------|
| `instance_id` | no | Unique identifier of the created instance |
| `instance_name` | no | Name of the created instance |
| `instance_status` | no | Current power state of the instance |
| `private_ipv4` | yes | Private IPv4 address within the attached network |
| `public_ipv4` | yes | Public IPv4 address (empty if `assign_public_ip = false`) |
| `network_id` | no | ID of the network the instance is attached to |
| `security_group_id` | no | ID of the security group attached to the instance |

## Limitations

- Provisions a **single instance** per module invocation. For multiple VMs use `count` or `for_each` at the root level.
- Supports **single-port rules only** — each rule maps to one port. For port ranges, add multiple rules.
- Data volumes must be managed separately with `huddle_cloud_volume` and `huddle_cloud_volume_attachment`.
- No built-in load balancing or auto-scaling.
5 changes: 3 additions & 2 deletions examples/basic/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,12 @@ module "vm_stack" {

name_prefix = "demo"
region = var.region
flavor_id = var.flavor_id
image_id = var.image_id
flavor_name = var.flavor_name
image_name = var.image_name
ssh_public_key = var.ssh_public_key

ingress_rules = [
# NOTE: SSH is open to all IPs for ease of testing. Restrict to your IP in production (e.g. "203.0.113.0/32").
{ protocol = "tcp", port = 22, cidr = "0.0.0.0/0" },
{ protocol = "tcp", port = 80, cidr = "0.0.0.0/0" },
{ protocol = "tcp", port = 443, cidr = "0.0.0.0/0" }
Expand Down
4 changes: 2 additions & 2 deletions examples/basic/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ variable "region" {
default = "eu2"
}

variable "flavor_id" {
variable "flavor_name" {
type = string
}

variable "image_id" {
variable "image_name" {
type = string
}

Expand Down
Loading
Loading