Skip to content

Commit 7ed8947

Browse files
authored
Merge branch 'main' into feature/73-bundle-provisioner-into-management
2 parents fe4dc86 + b61386f commit 7ed8947

17 files changed

Lines changed: 812 additions & 158 deletions

File tree

modules/management/namespace-credential-provisioner/scripts/reconcile.sh

Lines changed: 156 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,26 @@
66
# 1. Namespace watch (Harvester)
77
# Watches tenant namespaces on the Harvester cluster. For each new namespace
88
# that belongs to a Rancher project it creates:
9+
#
10+
# Cloud-provider credentials (for RKE2 Harvester cloud provider):
911
# - ServiceAccount harvester-cloud-provider-<ns>
1012
# - RoleBinding to harvesterhci.io:cloudprovider in the tenant namespace
1113
# - Optional RoleBinding to view in the project's network namespace
1214
# - Long-lived SA token secret
15+
#
16+
# Consumer VM-access credentials (for tenant teams provisioning VMs/clusters):
17+
# - ServiceAccount harvester-vm-access-<ns>
18+
# - RoleBinding to harvesterhci.io:edit in the tenant namespace
19+
# - RoleBinding to edit (k8s built-in) in the tenant namespace
20+
# - RoleBinding to view in harvester-public (for shared OS images)
21+
# - Long-lived SA token secret
22+
# - Secret "harvester-vm-kubeconfig" in the tenant namespace containing a
23+
# namespace-scoped kubeconfig. The platform team retrieves this once at
24+
# onboarding and hands it to the tenant team.
25+
#
1326
# On namespace deletion it deletes any harvesterconfig-* secrets on Rancher
14-
# whose kubeconfig was built from that namespace's SA token.
27+
# whose kubeconfig was built from that namespace's SA token, and cleans up
28+
# the harvester-public RoleBinding for the VM-access SA.
1529
#
1630
# 2. Cluster watch (Rancher)
1731
# Watches clusters.provisioning.cattle.io on the Rancher cluster. For each
@@ -22,9 +36,6 @@
2236
# v2prov-secret-authorized-for-cluster already set at creation time
2337
# On cluster deletion it removes harvesterconfig-<cluster-name>.
2438
#
25-
# Consumers (tenant teams) only need the rancher2 provider. No Harvester or
26-
# Rancher kubeconfig required on their side.
27-
#
2839
# Environment variables (injected by the Deployment):
2940
# HARVESTER_API_SERVER — Harvester Kubernetes API server URL
3041
# RANCHER_KUBECONFIG — Path to kubeconfig for Rancher's local cluster
@@ -131,6 +142,114 @@ $(echo "$kubeconfig" | sed 's/^/ /')
131142
EOF
132143
}
133144

145+
# Build a namespace-scoped VM-access kubeconfig for tenant teams and write it
146+
# as the well-known "harvester-vm-kubeconfig" Secret in the tenant namespace.
147+
# Consumers retrieve this secret once at onboarding to provision VMs and RKE2
148+
# clusters using the workloads/vm and workloads/k8s-cluster OCD modules.
149+
# Args: ns
150+
write_vm_kubeconfig() {
151+
local ns="$1"
152+
local sa_name="harvester-vm-access-${ns}"
153+
local secret_name="harvester-vm-kubeconfig"
154+
155+
# ServiceAccount in tenant namespace.
156+
kubectl create serviceaccount "$sa_name" -n "$ns" \
157+
--dry-run=client -o yaml | kubectl apply -f -
158+
159+
# RoleBinding — Harvester VM lifecycle (VMs, keypairs, images, NADs, backups).
160+
kubectl create rolebinding "${sa_name}" \
161+
--clusterrole=harvesterhci.io:edit \
162+
--serviceaccount="${ns}:${sa_name}" \
163+
-n "$ns" --dry-run=client -o yaml | kubectl apply -f -
164+
165+
# RoleBinding — Kubernetes resource edit (Secrets, PVCs, ConfigMaps).
166+
kubectl create rolebinding "${sa_name}-k8s-edit" \
167+
--clusterrole=edit \
168+
--serviceaccount="${ns}:${sa_name}" \
169+
-n "$ns" --dry-run=client -o yaml | kubectl apply -f -
170+
171+
# RoleBinding — read shared OS images in default namespace.
172+
kubectl create rolebinding "${ns}-${sa_name}-default-view" \
173+
--clusterrole=view \
174+
--serviceaccount="${ns}:${sa_name}" \
175+
-n "default" --dry-run=client -o yaml | kubectl apply -f -
176+
177+
# RoleBinding — read shared OS images in harvester-public.
178+
kubectl create rolebinding "${ns}-${sa_name}-public-view" \
179+
--clusterrole=view \
180+
--serviceaccount="${ns}:${sa_name}" \
181+
-n "harvester-public" --dry-run=client -o yaml | kubectl apply -f -
182+
183+
# Long-lived token secret.
184+
kubectl apply -f - <<EOF
185+
apiVersion: v1
186+
kind: Secret
187+
metadata:
188+
name: ${sa_name}-token
189+
namespace: ${ns}
190+
annotations:
191+
kubernetes.io/service-account.name: ${sa_name}
192+
type: kubernetes.io/service-account-token
193+
EOF
194+
195+
# Wait for the token to be populated by the token controller.
196+
local token=""
197+
for _ in $(seq 1 20); do
198+
token=$(kubectl get secret "${sa_name}-token" -n "$ns" \
199+
-o jsonpath='{.data.token}' 2>/dev/null || true)
200+
[[ -n "$token" ]] && break
201+
sleep 1
202+
done
203+
if [[ -z "$token" ]]; then
204+
log " ERROR: VM access token not populated for ${sa_name} in ${ns}"
205+
return 1
206+
fi
207+
208+
local token_decoded ca_cert_b64
209+
token_decoded=$(echo "$token" | base64 -d)
210+
ca_cert_b64=$(kubectl get configmap kube-root-ca.crt -n kube-system \
211+
-o jsonpath='{.data.ca\.crt}' | base64 | tr -d '\n')
212+
213+
local kubeconfig
214+
kubeconfig=$(cat <<EOF
215+
apiVersion: v1
216+
kind: Config
217+
clusters:
218+
- name: harvester
219+
cluster:
220+
certificate-authority-data: ${ca_cert_b64}
221+
server: ${HARVESTER_API_SERVER}
222+
users:
223+
- name: ${ns}
224+
user:
225+
token: ${token_decoded}
226+
contexts:
227+
- name: ${ns}@harvester
228+
context:
229+
cluster: harvester
230+
namespace: ${ns}
231+
user: ${ns}
232+
current-context: ${ns}@harvester
233+
EOF
234+
)
235+
236+
kubectl apply -f - <<EOF
237+
apiVersion: v1
238+
kind: Secret
239+
metadata:
240+
name: ${secret_name}
241+
namespace: ${ns}
242+
annotations:
243+
platform.wso2.com/vm-access-sa: "${sa_name}"
244+
type: Opaque
245+
stringData:
246+
kubeconfig: |
247+
$(echo "$kubeconfig" | sed 's/^/ /')
248+
EOF
249+
250+
log " [ns] VM access kubeconfig ready: ${secret_name} in ${ns}"
251+
}
252+
134253
# ── Namespace watch handlers ───────────────────────────────────────────────────
135254

136255
on_added_namespace() {
@@ -179,6 +298,11 @@ type: kubernetes.io/service-account-token
179298
EOF
180299

181300
log " [ns] SA ready: ${sa_name} in ${ns}"
301+
302+
# Consumer VM-access kubeconfig — separate SA with broader permissions.
303+
# Explicit return propagates failure to the caller so the namespace is NOT
304+
# marked processed; the watch loop will retry on the next event.
305+
write_vm_kubeconfig "$ns" || return 1
182306
}
183307

184308
on_deleted_namespace() {
@@ -216,6 +340,14 @@ on_deleted_namespace() {
216340
kubectl delete rolebinding "$rb_name_found" -n "$rb_ns" 2>/dev/null \
217341
&& log " [ns] deleted rolebinding ${rb_name_found} from ${rb_ns}"
218342
done || true
343+
344+
# Delete the VM-access SA's cross-namespace RoleBindings.
345+
# (Resources inside the deleted namespace are cleaned up by Kubernetes.)
346+
local vm_sa_name="harvester-vm-access-${ns}"
347+
kubectl delete rolebinding "${ns}-${vm_sa_name}-default-view" -n "default" \
348+
2>/dev/null && log " [ns] deleted default RoleBinding for ${vm_sa_name}" || true
349+
kubectl delete rolebinding "${ns}-${vm_sa_name}-public-view" -n "harvester-public" \
350+
2>/dev/null && log " [ns] deleted harvester-public RoleBinding for ${vm_sa_name}" || true
219351
}
220352

221353
# ── Cluster watch handlers ─────────────────────────────────────────────────────
@@ -408,9 +540,26 @@ kubectl get namespaces -o json | jq -r '
408540
[[ -z "$project_id" ]] && continue
409541
is_system_namespace "$ns" && continue
410542
[[ "$role" == "network-namespace" ]] && continue
411-
log "INIT namespace: ${ns} (project: ${project_id})"
412-
if on_added_namespace "$ns" "$project_id"; then
413-
echo "$ns" >> "$PROCESSED_NS_FILE"
543+
sa_name="harvester-cloud-provider-${ns}"
544+
if kubectl get secret "${sa_name}-token" -n "$ns" &>/dev/null; then
545+
# Cloud-provider credentials already exist. Check for the VM-access kubeconfig
546+
# separately — may be absent on pods that ran before this feature was added.
547+
if kubectl get secret "harvester-vm-kubeconfig" -n "$ns" &>/dev/null; then
548+
log "INIT namespace: ${ns} — already provisioned, skipping"
549+
echo "$ns" >> "$PROCESSED_NS_FILE"
550+
else
551+
log "INIT namespace: ${ns} — backfilling VM access kubeconfig"
552+
if write_vm_kubeconfig "$ns"; then
553+
echo "$ns" >> "$PROCESSED_NS_FILE"
554+
else
555+
log " WARN: VM access kubeconfig backfill failed for ${ns} — will retry on next watch event"
556+
fi
557+
fi
558+
else
559+
log "INIT namespace: ${ns} (project: ${project_id})"
560+
if on_added_namespace "$ns" "$project_id"; then
561+
echo "$ns" >> "$PROCESSED_NS_FILE"
562+
fi
414563
fi
415564
done
416565

modules/management/tenant-space/main.tf

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,11 @@ locals {
1313
# index. Only relevant when vyos_endpoint is set and exactly one VLAN is given.
1414
# Auto-routed environments (physical switch / DigiOps-issued VLANs) use multiple
1515
# VLANs with route_mode=auto; no explicit subnets needed.
16-
use_vyos = var.vlan_id != null && length(var.vlan_id) > 0 && var.vyos_endpoint != null
17-
tenant_subnet = local.use_vyos ? cidrsubnet("10.0.0.0/8", 15, var.vlan_id[0] - 1000) : null
16+
use_vyos = var.vlan_id != null && length(var.vlan_id) > 0 && var.vyos_endpoint != null
17+
# vlan_id[0] must be >= 1000 when VyOS is used — enforced by the precondition below.
18+
# max(..., 0) prevents a negative cidrsubnet index from causing a plan-time panic
19+
# before the precondition fires.
20+
tenant_subnet = local.use_vyos ? cidrsubnet("10.0.0.0/8", 15, max(var.vlan_id[0] - 1000, 0)) : null
1821
tenant_gateway = local.use_vyos ? cidrhost(local.tenant_subnet, 1) : null
1922
}
2023

@@ -59,6 +62,14 @@ resource "rancher2_project" "this" {
5962
condition = !local.use_vyos || length(var.vlan_id) == 1
6063
error_message = "VyOS path requires exactly one VLAN ID. Set vyos_endpoint = null for multi-VLAN auto-route configurations."
6164
}
65+
precondition {
66+
condition = !local.use_vyos || var.vlan_id[0] >= 1000
67+
error_message = "VyOS IPAM uses VLAN IDs >= 1000 (index = vlan_id - 1000). Set vyos_endpoint = null for VLANs below 1000."
68+
}
69+
precondition {
70+
condition = var.vlan_id == null || length(var.vlan_id) == 0 || local.create_net_ns
71+
error_message = "vlan_id requires the network namespace to exist. This should never happen since create_net_ns is always true when vlan_id is set — if you see this, do not set create_network_namespace = false alongside vlan_id."
72+
}
6273
}
6374
}
6475

@@ -138,6 +149,49 @@ module "vyos_tenant" {
138149
vyos_api_key = var.vyos_api_key
139150
}
140151

152+
# ── Consumer VM access kubeconfig (read from provisioner-created secret) ───────
153+
# The namespace-credential-provisioner automatically creates a "harvester-vm-kubeconfig"
154+
# Secret in each tenant namespace containing a namespace-scoped Harvester kubeconfig.
155+
# This data source surfaces it as a Terraform output so the platform team can
156+
# retrieve it once at onboarding and hand it to the tenant team:
157+
#
158+
# terraform output -raw <tenant>_vm_kubeconfig > <team>.harvester.kubeconfig.secret
159+
#
160+
# Requires the kubernetes.harvester provider to be passed in (set expose_vm_kubeconfig = true
161+
# and configure kubernetes.harvester in the caller's provider block).
162+
#
163+
# IMPORTANT — two-pass apply required:
164+
# This data source races with the out-of-band reconciler. On a fresh namespace the
165+
# provisioner may not have created the secret yet when Terraform runs the data read.
166+
# Apply in two steps:
167+
# 1. terraform apply # creates namespaces; provisioner reconciles
168+
# 2. terraform apply # reads the now-present secret
169+
# Alternatively, run `terraform apply -target=rancher2_namespace.this` first, wait
170+
# for the provisioner, then run the full apply.
171+
172+
locals {
173+
# Primary workload namespace for the VM kubeconfig.
174+
# Uses var.vm_access_namespace when explicitly set; falls back to the first
175+
# resolved namespace (or project_name when no namespaces are configured).
176+
vm_access_ns = var.vm_access_namespace != null ? var.vm_access_namespace : (
177+
length(local.namespaces) > 0 ? local.namespaces[0] : var.project_name
178+
)
179+
}
180+
181+
data "kubernetes_secret_v1" "vm_access_kubeconfig" {
182+
count = var.expose_vm_kubeconfig ? 1 : 0
183+
provider = kubernetes.harvester
184+
metadata {
185+
name = "harvester-vm-kubeconfig"
186+
namespace = local.vm_access_ns
187+
}
188+
depends_on = [rancher2_namespace.this]
189+
}
190+
191+
locals {
192+
vm_access_kubeconfig = var.expose_vm_kubeconfig ? data.kubernetes_secret_v1.vm_access_kubeconfig[0].data["kubeconfig"] : null
193+
}
194+
141195
# ── One binding per (group, role) pair. ───────────────────────────────────────
142196
resource "rancher2_project_role_template_binding" "this" {
143197
for_each = {

modules/management/tenant-space/outputs.tf

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,3 +37,9 @@ output "gateway_ip" {
3737
value = local.tenant_gateway
3838
description = "VyOS gateway IP for this tenant (first host in subnet_cidr). Non-null only when vlan_id and vyos_endpoint are both set."
3939
}
40+
41+
output "vm_access_kubeconfig" {
42+
value = local.vm_access_kubeconfig
43+
sensitive = true
44+
description = "Namespace-scoped Harvester kubeconfig for the tenant team. Non-null when expose_vm_kubeconfig = true and the namespace-credential-provisioner has already created the secret. Hand to the tenant team once at onboarding. See examples/consumer-workloads in wso2-datacenter-project for usage."
45+
}

modules/management/tenant-space/variables.tf

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,22 @@ variable "group_role_bindings" {
170170
default = []
171171
}
172172

173+
variable "expose_vm_kubeconfig" {
174+
type = bool
175+
description = "When true, reads the 'harvester-vm-kubeconfig' Secret created by the namespace-credential-provisioner and exposes it via the vm_access_kubeconfig output. Requires the kubernetes.harvester provider alias to be configured in the caller. The provisioner must have run before apply."
176+
default = false
177+
}
178+
179+
variable "vm_access_namespace" {
180+
type = string
181+
description = "Namespace from which to read the 'harvester-vm-kubeconfig' Secret when expose_vm_kubeconfig = true. Defaults to the first resolved namespace (or project_name when no namespaces are configured). Set explicitly when the tenant has multiple namespaces and the kubeconfig should target a specific one."
182+
default = null
183+
validation {
184+
condition = var.vm_access_namespace == null ? true : trimspace(var.vm_access_namespace) != ""
185+
error_message = "vm_access_namespace must be null or a non-empty namespace name."
186+
}
187+
}
188+
173189
variable "vyos_endpoint" {
174190
type = string
175191
description = "VyOS HTTPS API endpoint (e.g. 'https://172.22.100.50'). Required when vlan_id is set."

modules/management/tenant-space/versions.tf

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,5 +9,10 @@ terraform {
99
source = "harvester/harvester"
1010
version = "~> 1.7"
1111
}
12+
kubernetes = {
13+
source = "hashicorp/kubernetes"
14+
version = "~> 2.35"
15+
configuration_aliases = [kubernetes.harvester]
16+
}
1217
}
1318
}

modules/monitoring/examples/basic/main.tf

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,17 +23,15 @@ terraform {
2323
}
2424

2525
provider "kubernetes" {
26-
config_path = var.kubeconfig_path
27-
config_context = var.kubeconfig_context
26+
config_path = var.kubeconfig_path
2827
}
2928

3029
module "monitoring" {
3130
source = "../../"
3231

3332
# Identifiers
34-
environment = var.environment
35-
kubeconfig_path = var.kubeconfig_path
36-
kubeconfig_context = var.kubeconfig_context
33+
environment = var.environment
34+
kubeconfig_path = var.kubeconfig_path
3735

3836
# Notification
3937
google_chat_webhook_url = var.google_chat_webhook_url
@@ -71,11 +69,6 @@ variable "kubeconfig_path" {
7169
default = "~/.kube/harvester-lk.yaml"
7270
}
7371

74-
variable "kubeconfig_context" {
75-
type = string
76-
default = "local"
77-
}
78-
7972
variable "google_chat_webhook_url" {
8073
type = string
8174
sensitive = true

modules/monitoring/main.tf

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -304,14 +304,11 @@ resource "null_resource" "alertmanager_base_config" {
304304
command = <<-BASH
305305
set -e
306306
kubectl create secret generic alertmanager-rancher-monitoring-alertmanager \
307-
--context '${var.kubeconfig_context}' \
308307
-n '${local.ns}' \
309308
--from-file=alertmanager.yaml=<(base64 -d <<< "$AM_CONFIG_B64") \
310309
--from-file=rancher_defaults.tmpl=<(base64 -d <<< "$AM_TMPL_B64") \
311310
--dry-run=client -o yaml \
312-
| kubectl apply \
313-
--context '${var.kubeconfig_context}' \
314-
-f -
311+
| kubectl apply -f -
315312
BASH
316313
}
317314
}
@@ -337,14 +334,11 @@ resource "null_resource" "calert_config" {
337334
command = <<-BASH
338335
set -e
339336
kubectl create secret generic calert-config \
340-
--context '${var.kubeconfig_context}' \
341337
-n '${local.ns}' \
342338
--from-file=config.toml=<(base64 -d <<< "$CONFIG_B64") \
343339
--from-file=message.tmpl=<(base64 -d <<< "$TMPL_B64") \
344340
--dry-run=client -o yaml \
345-
| kubectl apply \
346-
--context '${var.kubeconfig_context}' \
347-
-f -
341+
| kubectl apply -f -
348342
BASH
349343
}
350344
}

0 commit comments

Comments
 (0)