Skip to content

Commit 449abc9

Browse files
authored
Merge pull request GoogleCloudPlatform#4352 from shubpal07/shubham/bugs/apply-manifest-from-url-v2
[v2][Bugfix] Applying K8s manifests to GKE clusters via URL
2 parents b85b803 + 3036df0 commit 449abc9

7 files changed

Lines changed: 130 additions & 24 deletions

File tree

modules/management/kubectl-apply/README.md

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ This module simplifies the following functionality:
1313
* **Raw String:** Specify manifests directly within the module configuration using the `content: manifest_body` format.
1414
* **File/Template/Directory Reference:** Set `source` to the path to:
1515
* A single URL to a manifest file. Ex.: `https://github.com/.../myrepo/manifest.yaml`.
16+
17+
> **Note:** Applying from a URL has important limitations. Please review the [Considerations & Callouts for Applying from URLs](#applying-manifests-from-urls-considerations--callouts) section below.
1618
* A single local YAML manifest file (`.yaml`). Ex.: `./manifest.yaml`.
1719
* A template file (`.tftpl`) to generate a manifest. Ex.: `./template.yaml.tftpl`. You can pass the variables to format the template file in `template_vars`.
1820
* A directory containing multiple YAML or template files. Ex: `./manifests/`. You can pass the variables to format the template files in `template_vars`.
@@ -95,6 +97,73 @@ You can specify a particular kueue version that you would like to use using the
9597
>
9698
> Terraform may apply resources in parallel, leading to potential dependency issues. If a resource's dependencies aren't ready, it will be applied again up to 15 times.
9799

100+
## Callouts
101+
102+
### Applying Manifests from URLs: Considerations & Callouts
103+
104+
While this module supports applying manifests directly from remote `http://` or `https://` URLs, this method introduces complexities not present when using local files. For production environments, we recommend sourcing manifests from local paths or a version-controlled Git repository. Moreover, this method will be deprecated soon. Hence we recommend to use other methods to source manifests.
105+
106+
If you choose to use the URL method, be aware of the following potential issues and their solutions.
107+
108+
#### **1. Apply Order and Race Conditions**
109+
110+
The module applies manifests from the `apply_manifests` list in parallel. This can create a **race condition** if one manifest depends on another. The most common example is applying a manifest with custom resources (like a `ClusterQueue`) at the same time as the manifest that defines it (the `CustomResourceDefinition` or CRD).
111+
112+
There is **no guarantee** that the CRD will be applied before the resource that uses it. This can lead to non-deterministic deployment failures with errors like:
113+
114+
```Error: resource [kueue.x-k8s.io/v1beta1/ClusterQueue] isn't valid for cluster```
115+
116+
##### **Recommended Workaround: Two-Stage Apply**
117+
118+
To ensure a reliable deployment, you must manually enforce the correct order of operations.
119+
120+
1. **Initial Deployment:** In your blueprint, include **only** the manifest(s) containing the `CustomResourceDefinition` (CRD) resources in the `apply_manifests` list.
121+
122+
*Example `settings` for the first run:*
123+
124+
```yaml
125+
settings:
126+
apply_manifests:
127+
# This manifest contains the CRDs for Kueue
128+
- source: "https://raw.githubusercontent.com/GoogleCloudPlatform/cluster-toolkit/refs/heads/develop/modules/management/kubectl-apply/manifests/kueue-v0.11.4.yaml"
129+
server_side_apply: true
130+
```
131+
132+
2. **Run the deployment** (`gcluster deploy` or `terraform apply`).
133+
134+
3. **Second Deployment:** Once the first apply is successful, **add** the manifests containing your custom resources (like `ClusterQueue`, `LocalQueue`) to the list.
135+
136+
*Example `settings` for the second run:*
137+
138+
```yaml
139+
settings:
140+
apply_manifests:
141+
# The CRD manifest is still present
142+
- source: "https://raw.githubusercontent.com/GoogleCloudPlatform/cluster-toolkit/refs/heads/develop/modules/management/kubectl-apply/manifests/kueue-v0.11.4.yaml"
143+
server_side_apply: true
144+
145+
# Now, add your configuration manifest
146+
- source: "https://gist.githubusercontent.com/YourUser/..." # Your configuration URL
147+
server_side_apply: true
148+
```
149+
150+
4. **Run the deployment command again.** Since the CRDs are now guaranteed to exist in the cluster, this second apply will succeed reliably.
151+
152+
#### **2. Large Manifests (CRDs)**
153+
154+
* **Issue:** Applying very large manifests can fail with a `metadata.annotations: Too long` error.
155+
* **Solution:** Enable Server-Side Apply by setting `server_side_apply: true` for the manifest entry.
156+
157+
#### **3. Conflicts on Re-application**
158+
159+
* **Issue:** Re-running a deployment after a partial failure can cause server-side apply field manager `conflicts`.
160+
* **Solution:** Forcibly take ownership of the resource fields by setting `force_conflicts: true`.
161+
162+
#### **4. Terraform Template Files (`.tftpl`)**
163+
164+
* **Limitation:** This module **cannot** render a template file (`.tftpl`) when sourced from a remote URL.
165+
* **Workaround:** You must render the template into a pure YAML file locally, host that rendered file at a URL, and provide the URL of the rendered file in your blueprint.
166+
98167
## License
99168

100169
<!-- BEGINNING OF PRE-COMMIT-TERRAFORM DOCS HOOK -->
@@ -119,13 +188,15 @@ limitations under the License.
119188
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | >= 1.3 |
120189
| <a name="requirement_google"></a> [google](#requirement\_google) | > 5.0 |
121190
| <a name="requirement_helm"></a> [helm](#requirement\_helm) | ~> 2.17 |
191+
| <a name="requirement_http"></a> [http](#requirement\_http) | ~> 3.0 |
122192
| <a name="requirement_kubectl"></a> [kubectl](#requirement\_kubectl) | >= 1.7.0 |
123193

124194
## Providers
125195

126196
| Name | Version |
127197
|------|---------|
128198
| <a name="provider_google"></a> [google](#provider\_google) | > 5.0 |
199+
| <a name="provider_http"></a> [http](#provider\_http) | ~> 3.0 |
129200
| <a name="provider_terraform"></a> [terraform](#provider\_terraform) | n/a |
130201

131202
## Modules
@@ -150,12 +221,13 @@ limitations under the License.
150221
| [terraform_data.kueue_validations](https://registry.terraform.io/providers/hashicorp/terraform/latest/docs/resources/data) | resource |
151222
| [google_client_config.default](https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/client_config) | data source |
152223
| [google_container_cluster.gke_cluster](https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/container_cluster) | data source |
224+
| [http_http.manifest_from_url](https://registry.terraform.io/providers/hashicorp/http/latest/docs/data-sources/http) | data source |
153225

154226
## Inputs
155227

156228
| Name | Description | Type | Default | Required |
157229
|------|-------------|------|---------|:--------:|
158-
| <a name="input_apply_manifests"></a> [apply\_manifests](#input\_apply\_manifests) | A list of manifests to apply to GKE cluster using kubectl. For more details see [kubectl module's inputs](kubectl/README.md). | <pre>list(object({<br/> enable = optional(bool, true)<br/> content = optional(string, null)<br/> source = optional(string, null)<br/> template_vars = optional(map(any), null)<br/> server_side_apply = optional(bool, false)<br/> wait_for_rollout = optional(bool, true)<br/> }))</pre> | `[]` | no |
230+
| <a name="input_apply_manifests"></a> [apply\_manifests](#input\_apply\_manifests) | A list of manifests to apply to GKE cluster using kubectl. For more details see [kubectl module's inputs](kubectl/README.md).<br/> NOTE: The `enable` input acts as a FF to apply a manifest or not. By default it is always set to `true`. | <pre>list(object({<br/> enable = optional(bool, true)<br/> content = optional(string, null)<br/> source = optional(string, null)<br/> template_vars = optional(map(any), null)<br/> server_side_apply = optional(bool, false)<br/> wait_for_rollout = optional(bool, true)<br/> }))</pre> | `[]` | no |
159231
| <a name="input_cluster_id"></a> [cluster\_id](#input\_cluster\_id) | An identifier for the gke cluster resource with format projects/<project\_id>/locations/<region>/clusters/<name>. | `string` | n/a | yes |
160232
| <a name="input_gib"></a> [gib](#input\_gib) | Install the NCCL gIB plugin | <pre>object({<br/> install = bool<br/> path = string<br/> template_vars = object({<br/> image = optional(string, "us-docker.pkg.dev/gce-ai-infra/gpudirect-gib/nccl-plugin-gib")<br/> version = string<br/> node_affinity = optional(any, {<br/> requiredDuringSchedulingIgnoredDuringExecution = {<br/> nodeSelectorTerms = [{<br/> matchExpressions = [{<br/> key = "cloud.google.com/gke-gpu",<br/> operator = "In",<br/> values = ["true"]<br/> }]<br/> }]<br/> }<br/> })<br/> accelerator_count = number<br/> })<br/> })</pre> | <pre>{<br/> "install": false,<br/> "path": "",<br/> "template_vars": {<br/> "accelerator_count": 0,<br/> "version": ""<br/> }<br/>}</pre> | no |
161233
| <a name="input_gke_cluster_exists"></a> [gke\_cluster\_exists](#input\_gke\_cluster\_exists) | A static flag that signals to downstream modules that a cluster has been created. Needed by community/modules/scripts/kubernetes-operations. | `bool` | `false` | no |

modules/management/kubectl-apply/kubectl/README.md

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,14 +18,12 @@ limitations under the License.
1818
| Name | Version |
1919
|------|---------|
2020
| <a name="requirement_terraform"></a> [terraform](#requirement\_terraform) | >= 1.3 |
21-
| <a name="requirement_http"></a> [http](#requirement\_http) | ~> 3.0 |
2221
| <a name="requirement_kubectl"></a> [kubectl](#requirement\_kubectl) | >= 1.7.0 |
2322

2423
## Providers
2524

2625
| Name | Version |
2726
|------|---------|
28-
| <a name="provider_http"></a> [http](#provider\_http) | ~> 3.0 |
2927
| <a name="provider_kubectl"></a> [kubectl](#provider\_kubectl) | >= 1.7.0 |
3028

3129
## Modules
@@ -37,7 +35,6 @@ No modules.
3735
| Name | Type |
3836
|------|------|
3937
| [kubectl_manifest.apply_doc](https://registry.terraform.io/providers/gavinbunney/kubectl/latest/docs/resources/manifest) | resource |
40-
| [http_http.yaml_content](https://registry.terraform.io/providers/hashicorp/http/latest/docs/data-sources/http) | data source |
4138
| [kubectl_path_documents.templates](https://registry.terraform.io/providers/gavinbunney/kubectl/latest/docs/data-sources/path_documents) | data source |
4239
| [kubectl_path_documents.yamls](https://registry.terraform.io/providers/gavinbunney/kubectl/latest/docs/data-sources/path_documents) | data source |
4340

modules/management/kubectl-apply/kubectl/main.tf

Lines changed: 21 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -17,35 +17,44 @@
1717
locals {
1818
yaml_separator = "\n---"
1919

20+
# This locals block processes manifest inputs from one of four methods,
21+
# evaluated in order of precedence using coalesce.
22+
23+
# --- METHOD 1: Direct Content Input ---
24+
# Used when manifest content is passed directly as a string.
2025
content_yaml_body = var.content
2126

27+
# Fallback for safe path checking in subsequent methods.
2228
null_safe_source = coalesce(var.source_path, " ")
2329

24-
url = startswith(local.null_safe_source, "http://") || startswith(local.null_safe_source, "https://") ? var.source_path : null
25-
url_content = local.url != null ? data.http.yaml_content[0].response_body : null
26-
27-
yaml_file = local.url == null && length(regexall("\\.yaml(_.*)?$", lower(local.null_safe_source))) == 1 ? abspath(var.source_path) : null
30+
# --- METHOD 2: Single Local YAML File ---
31+
# Used when var.source_path points to a local .yaml file.
32+
yaml_file = length(regexall("\\.yaml(_.*)?$", lower(local.null_safe_source))) == 1 ? abspath(var.source_path) : null
2833
yaml_file_content = local.yaml_file != null ? file(local.yaml_file) : null
2934

30-
template_file = local.url == null && length(regexall("\\.tftpl(_.*)?$", lower(local.null_safe_source))) == 1 ? abspath(var.source_path) : null
35+
# --- METHOD 3: Single Local Template File ---
36+
# Used when var.source_path points to a local .tftpl file.
37+
template_file = length(regexall("\\.tftpl(_.*)?$", lower(local.null_safe_source))) == 1 ? abspath(var.source_path) : null
3138
template_file_content = local.template_file != null ? templatefile(local.template_file, var.template_vars) : null
3239

33-
yaml_body = coalesce(local.content_yaml_body, local.url_content, local.yaml_file_content, local.template_file_content, " ")
34-
yaml_body_docs = [for doc in split(local.yaml_separator, local.yaml_body) : trimspace(doc) if length(trimspace(doc)) > 0] # Split yaml to single docs because the kubectl provider only supports single resource
40+
# --- CONSOLIDATE & PROCESS ---
41+
# Coalesce finds the first non-null content from the methods above.
42+
yaml_body = coalesce(local.content_yaml_body, local.yaml_file_content, local.template_file_content, " ")
43+
yaml_body_docs = [for doc in split(local.yaml_separator, local.yaml_body) : trimspace(doc) if length(trimspace(doc)) > 0]
3544

45+
# --- METHOD 4: Directory of Files ---
46+
# If no content was found via the methods above AND the source path looks like a directory,
47+
# we assume this is the desired method. The data blocks below will handle it.
3648
directory = length(local.yaml_body_docs) == 0 && endswith(local.null_safe_source, "/") ? abspath(var.source_path) : null
3749

50+
# --- FINAL AGGREGATION ---
51+
# Combine documents from single-source methods and directory-scan methods into one list.
3852
docs_list = concat(try(local.yaml_body_docs, []), try(data.kubectl_path_documents.yamls[0].documents, []), try(data.kubectl_path_documents.templates[0].documents, []))
3953
docs_map = tomap({
4054
for index, doc in local.docs_list : index => doc
4155
})
4256
}
4357

44-
data "http" "yaml_content" {
45-
count = local.url != null ? 1 : 0
46-
url = local.url
47-
}
48-
4958
data "kubectl_path_documents" "yamls" {
5059
count = local.directory != null ? 1 : 0
5160
pattern = "${local.directory}/*.yaml"

modules/management/kubectl-apply/kubectl/versions.tf

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,6 @@ terraform {
2020
source = "gavinbunney/kubectl"
2121
version = ">= 1.7.0"
2222
}
23-
http = {
24-
source = "hashicorp/http"
25-
version = "~> 3.0"
26-
}
2723
}
2824

2925
required_version = ">= 1.3"

modules/management/kubectl-apply/main.tf

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,32 @@ locals {
2020
cluster_location = local.cluster_id_parts[3]
2121
project_id = var.project_id != null ? var.project_id : local.cluster_id_parts[1]
2222

23-
apply_manifests_map = tomap({
23+
# 1. First, Identify manifests that are explicitly enabled.
24+
enabled_manifests = {
2425
for index, manifest in var.apply_manifests : index => manifest
25-
if manifest.enable
26+
if try(manifest.enable, true)
27+
}
28+
29+
# 2. Identify URL-based manifests
30+
url_manifests = {
31+
for index, manifest in local.enabled_manifests : index => manifest
32+
if try(manifest.source, null) != null && (startswith(manifest.source, "http://") || startswith(manifest.source, "https://"))
33+
}
34+
35+
# 3. Rebuild the map by populating the 'content' field for URLs based manifest
36+
processed_apply_manifests_map = tomap({
37+
for index, manifest in local.enabled_manifests : tostring(index) => {
38+
# If this manifest was a URL, its content is the body from the HTTP call.
39+
content = contains(keys(local.url_manifests), tostring(index)) ? data.http.manifest_from_url[tostring(index)].body : manifest.content
40+
41+
# If this was a URL, its source path is now null. Otherwise, use original.
42+
source = contains(keys(local.url_manifests), tostring(index)) ? null : manifest.source
43+
44+
# Pass other vars
45+
template_vars = manifest.template_vars
46+
server_side_apply = manifest.server_side_apply
47+
wait_for_rollout = manifest.wait_for_rollout
48+
}
2649
})
2750

2851
install_kueue = try(var.kueue.install, false)
@@ -34,6 +57,11 @@ locals {
3457
jobset_install_source = format("${path.module}/manifests/jobset-%s.yaml", try(var.jobset.version, ""))
3558
}
3659

60+
data "http" "manifest_from_url" {
61+
for_each = local.url_manifests
62+
url = each.value.source
63+
}
64+
3765
data "google_container_cluster" "gke_cluster" {
3866
project = local.project_id
3967
name = local.cluster_name
@@ -43,7 +71,7 @@ data "google_container_cluster" "gke_cluster" {
4371
data "google_client_config" "default" {}
4472

4573
module "kubectl_apply_manifests" {
46-
for_each = local.apply_manifests_map
74+
for_each = local.processed_apply_manifests_map
4775
source = "./kubectl"
4876
depends_on = [var.gke_cluster_exists]
4977

modules/management/kubectl-apply/variables.tf

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ variable "cluster_id" {
7575
}
7676

7777
variable "apply_manifests" {
78-
description = "A list of manifests to apply to GKE cluster using kubectl. For more details see [kubectl module's inputs](kubectl/README.md)."
78+
description = "A list of manifests to apply to GKE cluster using kubectl. For more details see [kubectl module's inputs](kubectl/README.md).\n NOTE: The `enable` input acts as a FF to apply a manifest or not. By default it is always set to `true`. "
7979
type = list(object({
8080
enable = optional(bool, true)
8181
content = optional(string, null)

modules/management/kubectl-apply/versions.tf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ terraform {
2828
source = "hashicorp/helm"
2929
version = "~> 2.17"
3030
}
31+
http = {
32+
source = "hashicorp/http"
33+
version = "~> 3.0"
34+
}
3135
}
3236

3337
provider_meta "google" {

0 commit comments

Comments
 (0)