diff --git a/apis/cluster/v1beta1/workspace_types.go b/apis/cluster/v1beta1/workspace_types.go index 0695e47..c490790 100644 --- a/apis/cluster/v1beta1/workspace_types.go +++ b/apis/cluster/v1beta1/workspace_types.go @@ -92,6 +92,19 @@ const ( ModuleSourceInline ModuleSource = "Inline" ) +// RemotePullPolicy determines when to pull remote module sources. +// +kubebuilder:validation:Enum=Always;IfNotPresent +type RemotePullPolicy string + +// Remote pull policies. +const ( + // RemotePullPolicyAlways pulls remote source on every reconciliation (default) + RemotePullPolicyAlways RemotePullPolicy = "Always" + + // RemotePullPolicyIfNotPresent pulls remote source only if not already present + RemotePullPolicyIfNotPresent RemotePullPolicy = "IfNotPresent" +) + // WorkspaceParameters are the configurable fields of a Workspace. type WorkspaceParameters struct { // The root module of this workspace; i.e. the module containing its main.tf @@ -145,12 +158,21 @@ type WorkspaceParameters struct { // Boolean value to indicate CLI logging of tofu execution is enabled or not // +optional EnableTofuCLILogging bool `json:"enableTofuCLILogging,omitempty"` + + // RemotePullPolicy determines when to download remote module sources. + // +optional + // +kubebuilder:default=Always + RemotePullPolicy *RemotePullPolicy `json:"remotePullPolicy,omitempty"` } // WorkspaceObservation are the observable fields of a Workspace. type WorkspaceObservation struct { Checksum string `json:"checksum,omitempty"` Outputs map[string]extensionsV1.JSON `json:"outputs,omitempty"` + + // RemoteSource is the remote module URL that was last retrieved + // +optional + RemoteSource string `json:"remoteSource,omitempty"` } // A WorkspaceSpec defines the desired state of a Workspace. diff --git a/apis/cluster/v1beta1/zz_generated.deepcopy.go b/apis/cluster/v1beta1/zz_generated.deepcopy.go index 4e3f136..89161b9 100644 --- a/apis/cluster/v1beta1/zz_generated.deepcopy.go +++ b/apis/cluster/v1beta1/zz_generated.deepcopy.go @@ -412,6 +412,11 @@ func (in *WorkspaceParameters) DeepCopyInto(out *WorkspaceParameters) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.RemotePullPolicy != nil { + in, out := &in.RemotePullPolicy, &out.RemotePullPolicy + *out = new(RemotePullPolicy) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceParameters. diff --git a/apis/namespaced/v1beta1/workspace_types.go b/apis/namespaced/v1beta1/workspace_types.go index c658f2d..afade02 100644 --- a/apis/namespaced/v1beta1/workspace_types.go +++ b/apis/namespaced/v1beta1/workspace_types.go @@ -91,6 +91,19 @@ const ( ModuleSourceInline ModuleSource = "Inline" ) +// RemotePullPolicy determines when to pull remote module sources. +// +kubebuilder:validation:Enum=Always;IfNotPresent +type RemotePullPolicy string + +// Remote pull policies. +const ( + // RemotePullPolicyAlways pulls remote source on every reconciliation (default) + RemotePullPolicyAlways RemotePullPolicy = "Always" + + // RemotePullPolicyIfNotPresent pulls remote source only if not already present + RemotePullPolicyIfNotPresent RemotePullPolicy = "IfNotPresent" +) + // WorkspaceParameters are the configurable fields of a Workspace. type WorkspaceParameters struct { // The root module of this workspace; i.e. the module containing its main.tf @@ -144,12 +157,21 @@ type WorkspaceParameters struct { // Boolean value to indicate CLI logging of tofu execution is enabled or not // +optional EnableTofuCLILogging bool `json:"enableTofuCLILogging,omitempty"` + + // RemotePullPolicy determines when to download remote module sources. + // +optional + // +kubebuilder:default=Always + RemotePullPolicy *RemotePullPolicy `json:"remotePullPolicy,omitempty"` } // WorkspaceObservation are the observable fields of a Workspace. type WorkspaceObservation struct { Checksum string `json:"checksum,omitempty"` Outputs map[string]extensionsV1.JSON `json:"outputs,omitempty"` + + // RemoteSource is the remote module URL that was last retrieved + // +optional + RemoteSource string `json:"remoteSource,omitempty"` } // A WorkspaceSpec defines the desired state of a Workspace. diff --git a/apis/namespaced/v1beta1/zz_generated.deepcopy.go b/apis/namespaced/v1beta1/zz_generated.deepcopy.go index 50439a3..a2b27f6 100644 --- a/apis/namespaced/v1beta1/zz_generated.deepcopy.go +++ b/apis/namespaced/v1beta1/zz_generated.deepcopy.go @@ -471,6 +471,11 @@ func (in *WorkspaceParameters) DeepCopyInto(out *WorkspaceParameters) { *out = make([]string, len(*in)) copy(*out, *in) } + if in.RemotePullPolicy != nil { + in, out := &in.RemotePullPolicy, &out.RemotePullPolicy + *out = new(RemotePullPolicy) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new WorkspaceParameters. diff --git a/docs/monolith/RemoteModulePullPolicy.md b/docs/monolith/RemoteModulePullPolicy.md new file mode 100644 index 0000000..94bcaeb --- /dev/null +++ b/docs/monolith/RemoteModulePullPolicy.md @@ -0,0 +1,356 @@ +# Remote Module Pull Policy + +This guide explains how to use the `remotePullPolicy` feature to control when provider-opentofu downloads remote modules, significantly reducing network costs and improving reconciliation performance. + +## Overview + +By default, provider-opentofu downloads remote modules on every reconciliation. For workspaces that reconcile frequently (every 1-10 minutes), this can result in substantial network egress costs. + +**Example scenario** (50MB module, 10-minute poll interval): +- **Without pull policy control**: 50MB module × 6 reconciliations/hour × 24 hours = 7.2GB/day per workspace +- **With IfNotPresent policy**: 50MB (single download) = 50MB/day per workspace +- **Network reduction**: 98.6% savings in this scenario + +*Actual savings depend on your module size and reconciliation frequency.* + +The `remotePullPolicy` field gives you control over when modules are downloaded, allowing you to optimize for either cost or freshness. + +## Pull Policy Options + +### Always (Default) + +Downloads the remote module on every reconciliation. + +**Use cases:** +- Development workspaces where you need the latest module changes +- Modules without pinned versions (e.g., `ref=main` instead of `ref=v1.0.0`) +- When module content changes frequently without version updates + +**Example:** +```yaml +apiVersion: opentofu.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-always +spec: + forProvider: + source: Remote + module: git::https://github.com/org/repo?ref=main + remotePullPolicy: Always # Explicit (default behavior) +``` + +**Network impact:** +- Downloads on every reconciliation +- Highest network costs +- Ensures latest module content + +### IfNotPresent + +Downloads the remote module only once, reusing it on subsequent reconciliations until the module URL changes. + +**Use cases:** +- Production workspaces with pinned module versions +- Cost-sensitive environments +- Large modules (>10MB) that are expensive to download repeatedly +- High reconciliation frequency (every 1-5 minutes) + +**Example:** +```yaml +apiVersion: opentofu.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-if-not-present +spec: + forProvider: + source: Remote + module: git::https://github.com/org/repo?ref=v1.0.0 # Pinned version + remotePullPolicy: IfNotPresent +``` + +**Network impact:** +- Downloads once on first reconciliation +- Significant network reduction (up to 98%+ depending on module size and poll interval) +- Faster reconciliation (no download time) + +**Automatic re-download triggers:** +- Module URL changes (including git ref) +- Workspace pod restart (without persistent volume) +- `.terraform.lock.hcl` file is deleted or missing +- Interrupted `tofu init` (lock file not created) + +## How It Works + +### Detection Mechanism + +The `IfNotPresent` policy checks for the presence of a valid `.terraform.lock.hcl` file in the workspace. This file is created at the END of a successful `tofu init`, making it the most reliable indicator that the module has been successfully initialized. When an `entrypoint` is specified, the check is performed in the entrypoint subdirectory (where tofu actually runs), not the base workspace directory. + +**Why .terraform.lock.hcl?** +- Created only after successful `tofu init` completion +- Interrupted `tofu init` leaves `.terraform/` directory but NO lock file +- Located at workspace root (no entrypoint path confusion) +- Contains provider dependency information + +**Detection logic:** +``` +workspace_dir = base_workspace_directory +if entrypoint is specified: + workspace_dir = base_workspace_directory/entrypoint + +if .terraform.lock.hcl exists and is valid in workspace_dir: + if module URL == previously downloaded URL: + → Skip download (reuse existing module) + else: + → Download (module URL changed) +else: + → Download (module not initialized) +``` + +**Example with entrypoint:** +```yaml +spec: + forProvider: + source: Remote + module: git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git?ref=v5.1.2 + entrypoint: examples/complete + remotePullPolicy: IfNotPresent +``` + +In this case: +- Module is downloaded to: `/tofu/workspace-uid/` +- Tofu runs in: `/tofu/workspace-uid/examples/complete/` +- `.terraform.lock.hcl` check looks in: `/tofu/workspace-uid/examples/complete/.terraform.lock.hcl` + +### Status Tracking + +The provider tracks the last downloaded module URL in the workspace status: + +```yaml +status: + atProvider: + remoteSource: git::https://github.com/org/repo?ref=v1.0.0 +``` + +This allows automatic detection of module URL changes, triggering a re-download when needed. + +## Migration Guide + +### Migrating Existing Workspaces + +Existing workspaces without `remotePullPolicy` will continue using the default `Always` behavior. To opt-in to cost savings: + +1. **Ensure your module versions are pinned:** + ```yaml + # Good - pinned version + module: git::https://github.com/org/repo?ref=v1.0.0 + + # Avoid - floating ref + module: git::https://github.com/org/repo?ref=main + ``` + +2. **Add the remotePullPolicy field:** + ```yaml + spec: + forProvider: + source: Remote + module: git::https://github.com/org/repo?ref=v1.0.0 + remotePullPolicy: IfNotPresent # Add this line + ``` + +3. **Apply the change:** + ```bash + kubectl apply -f workspace.yaml + ``` + +The first reconciliation after the change will download the module normally. Subsequent reconciliations will skip the download. + +### Updating Module Versions + +When you need to update to a new module version: + +1. **Update the module reference:** + ```yaml + spec: + forProvider: + module: git::https://github.com/org/repo?ref=v2.0.0 # Changed from v1.0.0 + remotePullPolicy: IfNotPresent + ``` + +2. **Apply the change:** + ```bash + kubectl apply -f workspace.yaml + ``` + +The provider will automatically detect the URL change and download the new version. + +## Best Practices + +### 1. Pin Module Versions in Production + +Always use specific version tags or commit SHAs in production: + +```yaml +# Recommended - specific version tag +module: git::https://github.com/terraform-aws-modules/terraform-aws-vpc?ref=v5.1.2 + +# Recommended - specific commit +module: git::https://github.com/org/repo?ref=abc123def + +# Avoid - floating ref +module: git::https://github.com/org/repo?ref=main +``` + +### 2. Use IfNotPresent with Pinned Versions + +Combine version pinning with IfNotPresent for maximum cost savings: + +```yaml +spec: + forProvider: + source: Remote + module: git::https://github.com/org/repo?ref=v1.0.0 + remotePullPolicy: IfNotPresent +``` + +### 3. Use Always for Development + +Keep the Always policy for development workspaces where you need the latest changes: + +```yaml +spec: + forProvider: + source: Remote + module: git::https://github.com/org/repo?ref=develop + remotePullPolicy: Always # Get latest changes +``` + +### 4. Monitor Network Costs + +After enabling IfNotPresent, monitor your cloud provider's network egress metrics to verify cost savings: + +```bash +# Example: Check workspace reconciliation logs +kubectl logs -n upbound-system deploy/provider-opentofu-* | grep "Remote module" + +# Expected with IfNotPresent: +# First reconciliation: "Remote module downloaded" +# Subsequent reconciliations: "Remote module already present, skipping download" +``` + +## Troubleshooting + +### Module Not Updating After URL Change + +**Symptoms:** +- Module URL changed but old module content is still being used +- Status field shows old URL + +**Solution:** +Check that the workspace reconciliation completed successfully: + +```bash +kubectl describe workspace +kubectl logs -n upbound-system deploy/provider-opentofu-* | grep "Remote module URL changed" +``` + +### Module Downloaded Every Time Despite IfNotPresent + +**Possible causes:** + +1. **Workspace pods are restarting frequently** + - Module is downloaded to pod's ephemeral storage + - Pod restart = module is lost + - Solution: Use persistent volumes for `/tofu` directory (future enhancement) + +2. **.terraform.lock.hcl file is being deleted** + - Check if any process is cleaning up the workspace directory + - Verify workspace directory permissions + - Check if `tofu init` is completing successfully (lock file created at END) + +3. **Module URL is changing on every reconciliation** + - Check if dynamic refs are being used + - Verify status.atProvider.remoteSource matches spec.forProvider.module + +### Status Field Not Populated + +**Symptoms:** +- `status.atProvider.remoteSource` is empty +- Module downloads every time even with IfNotPresent + +**Solution:** +Wait for one successful reconciliation. The status field is populated after the first download: + +```bash +kubectl get workspace -o jsonpath='{.status.atProvider.remoteSource}' +``` + +## Cost Analysis + +### Network Savings Example + +**Scenario:** +- 100 workspaces +- 50MB module size +- 6 reconciliations per hour +- $0.12/GB network egress (AWS example) + +**Without IfNotPresent:** +- Per workspace: 50MB × 6 × 24 = 7.2GB/day +- 100 workspaces: 720GB/day +- Monthly cost: 720GB × 30 × $0.12 = $2,592/month + +**With IfNotPresent:** +- Per workspace: 50MB/day (one download) +- 100 workspaces: 5GB/day +- Monthly cost: 5GB × 30 × $0.12 = $18/month + +**Savings: $2,574/month (99.3% reduction)** + +### Performance Improvement + +**Download time savings:** +- 50MB module at 100Mbps = 4 seconds download time +- 6 reconciliations/hour × 4 seconds = 24 seconds/hour wasted +- With IfNotPresent: 4 seconds once, then <1 second for subsequent reconciliations +- **Reconciliation speedup: 75%+ after first reconciliation** + +## Limitations + +### Current Limitations + +1. **No cross-workspace deduplication** + - Each workspace downloads its own copy of the module + - 100 workspaces using the same module = 100 downloads (one per workspace) + - Mitigated by: Each workspace only downloads once with IfNotPresent + +2. **Module lost on pod restart** + - Modules are stored in pod's ephemeral storage + - Pod restart requires re-download + - Mitigation: Use persistent volumes (manual setup) + +3. **No content-based detection** + - Detection is based on URL comparison, not module content hash + - Changing module content without changing URL is not detected + - Best practice: Always update version tags when changing modules + +### Future Enhancements + +Potential improvements under consideration: + +1. **Provider-level cache**: Share modules across all workspaces +2. **Content hashing**: Detect module changes without URL changes +3. **Persistent storage**: Recommend PVC for `/tofu` directory +4. **Cache warming**: Pre-download popular modules + +## Compatible with All Module Sources + +The `remotePullPolicy` field is supported for: +- Remote sources (git, http, S3, etc.) +- Not applicable to Inline sources (module is already in the spec) + +## Summary + +- Use `remotePullPolicy: IfNotPresent` with pinned module versions for 98%+ network cost savings +- Use `remotePullPolicy: Always` (default) for development or when module freshness is critical +- The provider automatically re-downloads modules when URLs change +- Monitor logs and status fields to verify expected behavior +- Combine with persistent volumes for maximum module reuse across pod restarts diff --git a/docs/monolith/Troubleshooting.md b/docs/monolith/Troubleshooting.md new file mode 100644 index 0000000..c9b2440 --- /dev/null +++ b/docs/monolith/Troubleshooting.md @@ -0,0 +1,61 @@ +# Troubleshooting + +## Remote Module Repeatedly Downloaded + +### Problem Description + +Remote modules are downloaded on every reconciliation, causing: + +- High network egress costs (up to 7.2GB/day per workspace) +- Slower reconciliation times due to download latency +- Unnecessary bandwidth usage + +**Example scenario:** +- 50MB remote module +- Reconciles every 10 minutes (6 times/hour) +- Result: 300MB/hour = 7.2GB/day of repeated downloads + +### Solution + +Set `remotePullPolicy: IfNotPresent` in the Workspace spec to download the module once and reuse it: + +```yaml +apiVersion: opentofu.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example +spec: + forProvider: + source: Remote + module: git::https://github.com/terraform-aws-modules/terraform-aws-vpc?ref=v5.1.2 + remotePullPolicy: IfNotPresent # Add this line +``` + +**Benefits:** +- Significant network reduction (example: 50MB module at 10-min poll = 7.2GB/day -> 50MB/day, 98.6% savings) +- Faster reconciliation after initial download +- Module automatically re-downloaded if URL changes + +**Best practices:** +- Always use with pinned module versions (e.g., `?ref=v5.1.2`, not `?ref=main`) +- Verify the module URL includes a specific version tag or commit SHA +- Monitor logs to confirm download skipping behavior + +**Verification:** +```bash +# Check workspace logs +kubectl logs -n upbound-system deploy/provider-opentofu-* | grep "Remote module" + +# First reconciliation: "Remote module downloaded" +# Subsequent: "Remote module already present, skipping download" + +# Check status field +kubectl get workspace -o jsonpath='{.status.atProvider.remoteSource}' +``` + +**When to use Always policy (default):** +- Development workspaces with frequently changing modules +- Modules without pinned versions (floating refs like `main` or `develop`) +- When module freshness is more important than cost + +See the [Remote Module Pull Policy](RemoteModulePullPolicy.md) documentation for detailed information. diff --git a/examples/cluster/workspace-remote-pull-policy-always.yaml b/examples/cluster/workspace-remote-pull-policy-always.yaml new file mode 100644 index 0000000..0c016ec --- /dev/null +++ b/examples/cluster/workspace-remote-pull-policy-always.yaml @@ -0,0 +1,37 @@ +apiVersion: opentofu.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-remote-always + annotations: + meta.upbound.io/example-id: opentofu/v1beta1/workspace + crossplane.io/external-name: myworkspace-always +spec: + forProvider: + # Remote module source with Always pull policy (default behavior) + # The module will be downloaded on every reconciliation + source: Remote + module: git::https://github.com/terraform-aws-modules/terraform-aws-vpc?ref=v5.1.2 + + # RemotePullPolicy determines when to download remote module sources + # Always: Downloads module on every reconciliation (default) + # This is the current behavior and is recommended when you need to ensure + # the latest version of the module is always used + remotePullPolicy: Always + + vars: + - key: name + value: my-vpc + - key: cidr + value: "10.0.0.0/16" + - key: azs + value: '["us-west-2a", "us-west-2b"]' + - key: private_subnets + value: '["10.0.1.0/24", "10.0.2.0/24"]' + - key: public_subnets + value: '["10.0.101.0/24", "10.0.102.0/24"]' + - key: enable_nat_gateway + value: "true" + + writeConnectionSecretToRef: + namespace: default + name: opentofu-workspace-example-remote-always diff --git a/examples/cluster/workspace-remote-pull-policy-entrypoint-iam-outputs.yaml b/examples/cluster/workspace-remote-pull-policy-entrypoint-iam-outputs.yaml new file mode 100644 index 0000000..5ba5a59 --- /dev/null +++ b/examples/cluster/workspace-remote-pull-policy-entrypoint-iam-outputs.yaml @@ -0,0 +1,34 @@ +apiVersion: opentofu.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-iam-with-entrypoint-outputs + annotations: + meta.upbound.io/example-id: opentofu/v1beta1/workspace +spec: + forProvider: + # Use the well-known AWS VPC module + source: Remote + module: git::https://github.com/ytsarev/provider-terraform-test-module.git?ref=main + + # Use one of the example subdirectories as entrypoint + # This tests that .terraform directory is checked in the correct location + entrypoint: iam + + # Set IfNotPresent to avoid re-downloading on every reconciliation + remotePullPolicy: IfNotPresent + + # Provide minimal required variables for the example + vars: + - key: iamRole + value: test-remote-pull-policy-outputs + + # Add plan-only arg to avoid actual infrastructure creation during testing + # planArgs: + # - "-out=tfplan" + + providerConfigRef: + name: default + + writeConnectionSecretToRef: + namespace: crossplane-system + name: example-iam-entrypoint-outputs-test diff --git a/examples/cluster/workspace-remote-pull-policy-entrypoint-iam.yaml b/examples/cluster/workspace-remote-pull-policy-entrypoint-iam.yaml new file mode 100644 index 0000000..1173650 --- /dev/null +++ b/examples/cluster/workspace-remote-pull-policy-entrypoint-iam.yaml @@ -0,0 +1,34 @@ +apiVersion: opentofu.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-iam-with-entrypoint + annotations: + meta.upbound.io/example-id: opentofu/v1beta1/workspace +spec: + forProvider: + # Use the well-known AWS VPC module + source: Remote + module: git::https://github.com/ytsarev/provider-terraform-test-module.git?ref=main + + # Use one of the example subdirectories as entrypoint + # This tests that .terraform directory is checked in the correct location + entrypoint: relative-path-iam + + # Set IfNotPresent to avoid re-downloading on every reconciliation + remotePullPolicy: IfNotPresent + + # Provide minimal required variables for the example + vars: + - key: iamRole + value: test-remote-pull-policy + + # Add plan-only arg to avoid actual infrastructure creation during testing + planArgs: + - "-out=tfplan" + + providerConfigRef: + name: default + + writeConnectionSecretToRef: + namespace: crossplane-system + name: example-iam-entrypoint-outputs diff --git a/examples/cluster/workspace-remote-pull-policy-entrypoint.yaml b/examples/cluster/workspace-remote-pull-policy-entrypoint.yaml new file mode 100644 index 0000000..77f1f59 --- /dev/null +++ b/examples/cluster/workspace-remote-pull-policy-entrypoint.yaml @@ -0,0 +1,46 @@ +apiVersion: opentofu.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-vpc-with-entrypoint + annotations: + meta.upbound.io/example-id: opentofu/v1beta1/workspace +spec: + forProvider: + # Use the well-known AWS VPC module + source: Remote + module: git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git?ref=v5.1.2 + + # Use one of the example subdirectories as entrypoint + # This tests that .terraform directory is checked in the correct location + entrypoint: examples/complete + + # Set IfNotPresent to avoid re-downloading on every reconciliation + remotePullPolicy: IfNotPresent + + # Provide minimal required variables for the example + vars: + - key: name + value: test-vpc + - key: cidr + value: "10.0.0.0/16" + - key: azs + value: '["us-west-2a", "us-west-2b"]' + - key: private_subnets + value: '["10.0.1.0/24", "10.0.2.0/24"]' + - key: public_subnets + value: '["10.0.101.0/24", "10.0.102.0/24"]' + - key: enable_nat_gateway + value: "false" + - key: enable_vpn_gateway + value: "false" + + # Add plan-only arg to avoid actual infrastructure creation during testing + planArgs: + - "-out=tfplan" + + providerConfigRef: + name: default + + writeConnectionSecretToRef: + namespace: upbound-system + name: example-vpc-entrypoint-outputs diff --git a/examples/cluster/workspace-remote-pull-policy-if-not-present.yaml b/examples/cluster/workspace-remote-pull-policy-if-not-present.yaml new file mode 100644 index 0000000..e9fe08d --- /dev/null +++ b/examples/cluster/workspace-remote-pull-policy-if-not-present.yaml @@ -0,0 +1,46 @@ +apiVersion: opentofu.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-remote-if-not-present + annotations: + meta.upbound.io/example-id: opentofu/v1beta1/workspace + crossplane.io/external-name: myworkspace-if-not-present +spec: + forProvider: + # Remote module source with IfNotPresent pull policy + # The module will be downloaded once and reused on subsequent reconciliations + source: Remote + module: git::https://github.com/terraform-aws-modules/terraform-aws-vpc?ref=v5.1.2 + + # RemotePullPolicy determines when to download remote module sources + # IfNotPresent: Downloads module only if not already present locally + # This significantly reduces network costs and speeds up reconciliation + # + # Benefits (example: 50MB module, 10-minute poll interval): + # - Significant network reduction: 7.2GB/day → 50MB/day (98.6% savings) + # - Faster reconciliation after initial download + # - Automatic re-download if module URL changes (including git ref) + # + # Use cases: + # - Production workspaces with pinned module versions + # - Cost-sensitive environments with high reconciliation frequency + # - Large modules that are expensive to download repeatedly + remotePullPolicy: IfNotPresent + + vars: + - key: name + value: my-vpc + - key: cidr + value: "10.0.0.0/16" + - key: azs + value: '["us-west-2a", "us-west-2b"]' + - key: private_subnets + value: '["10.0.1.0/24", "10.0.2.0/24"]' + - key: public_subnets + value: '["10.0.101.0/24", "10.0.102.0/24"]' + - key: enable_nat_gateway + value: "true" + + writeConnectionSecretToRef: + namespace: default + name: opentofu-workspace-example-remote-if-not-present diff --git a/examples/namespaced/workspace-remote-pull-policy-always.yaml b/examples/namespaced/workspace-remote-pull-policy-always.yaml new file mode 100644 index 0000000..902234a --- /dev/null +++ b/examples/namespaced/workspace-remote-pull-policy-always.yaml @@ -0,0 +1,34 @@ +apiVersion: opentofu.m.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-remote-always + namespace: upbound-system +spec: + providerConfigRef: + name: default + kind: ClusterProviderConfig + forProvider: + # Remote module source with Always pull policy (default behavior) + # The module will be downloaded on every reconciliation + source: Remote + module: git::https://github.com/terraform-aws-modules/terraform-aws-vpc?ref=v5.1.2 + + # RemotePullPolicy determines when to download remote module sources + # Always: Downloads module on every reconciliation (default) + # This is the current behavior and is recommended when you need to ensure + # the latest version of the module is always used + remotePullPolicy: Always + + vars: + - key: name + value: my-vpc + - key: cidr + value: "10.0.0.0/16" + - key: azs + value: '["us-west-2a", "us-west-2b"]' + - key: private_subnets + value: '["10.0.1.0/24", "10.0.2.0/24"]' + - key: public_subnets + value: '["10.0.101.0/24", "10.0.102.0/24"]' + - key: enable_nat_gateway + value: "true" diff --git a/examples/namespaced/workspace-remote-pull-policy-entrypoint-iam-outputs.yaml b/examples/namespaced/workspace-remote-pull-policy-entrypoint-iam-outputs.yaml new file mode 100644 index 0000000..50f7f79 --- /dev/null +++ b/examples/namespaced/workspace-remote-pull-policy-entrypoint-iam-outputs.yaml @@ -0,0 +1,31 @@ +apiVersion: opentofu.m.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-iam-with-entrypoint-outputs + namespace: upbound-system + annotations: + meta.upbound.io/example-id: opentofu/v1beta1/workspace +spec: + forProvider: + # Use the well-known AWS VPC module + source: Remote + module: git::https://github.com/ytsarev/provider-terraform-test-module.git?ref=main + + # Use one of the example subdirectories as entrypoint + # This tests that .terraform directory is checked in the correct location + entrypoint: iam + + # Set IfNotPresent to avoid re-downloading on every reconciliation + remotePullPolicy: IfNotPresent + + # Provide minimal required variables for the example + vars: + - key: iamRole + value: test-remote-pull-policy-outputs + + providerConfigRef: + kind: ClusterProviderConfig + name: default + + writeConnectionSecretToRef: + name: example-iam-entrypoint-outputs-test diff --git a/examples/namespaced/workspace-remote-pull-policy-entrypoint-iam.yaml b/examples/namespaced/workspace-remote-pull-policy-entrypoint-iam.yaml new file mode 100644 index 0000000..26d4e61 --- /dev/null +++ b/examples/namespaced/workspace-remote-pull-policy-entrypoint-iam.yaml @@ -0,0 +1,35 @@ +apiVersion: opentofu.m.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-iam-with-entrypoint + namespace: upbound-system + annotations: + meta.upbound.io/example-id: opentofu/v1beta1/workspace +spec: + forProvider: + # Use the well-known AWS VPC module + source: Remote + module: git::https://github.com/ytsarev/provider-terraform-test-module.git?ref=main + + # Use one of the example subdirectories as entrypoint + # This tests that .terraform directory is checked in the correct location + entrypoint: relative-path-iam + + # Set IfNotPresent to avoid re-downloading on every reconciliation + remotePullPolicy: IfNotPresent + + # Provide minimal required variables for the example + vars: + - key: iamRole + value: test-remote-pull-policy + + # Add plan-only arg to avoid actual infrastructure creation during testing + planArgs: + - "-out=tfplan" + + providerConfigRef: + kind: ClusterProviderConfig + name: default + + writeConnectionSecretToRef: + name: example-iam-entrypoint-outputs diff --git a/examples/namespaced/workspace-remote-pull-policy-entrypoint.yaml b/examples/namespaced/workspace-remote-pull-policy-entrypoint.yaml new file mode 100644 index 0000000..b13034d --- /dev/null +++ b/examples/namespaced/workspace-remote-pull-policy-entrypoint.yaml @@ -0,0 +1,47 @@ +apiVersion: opentofu.m.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-vpc-with-entrypoint + namespace: upbound-system + annotations: + meta.upbound.io/example-id: opentofu/v1beta1/workspace +spec: + forProvider: + # Use the well-known AWS VPC module + source: Remote + module: git::https://github.com/terraform-aws-modules/terraform-aws-vpc.git?ref=v5.1.2 + + # Use one of the example subdirectories as entrypoint + # This tests that .terraform directory is checked in the correct location + entrypoint: examples/complete + + # Set IfNotPresent to avoid re-downloading on every reconciliation + remotePullPolicy: IfNotPresent + + # Provide minimal required variables for the example + vars: + - key: name + value: test-vpc + - key: cidr + value: "10.0.0.0/16" + - key: azs + value: '["us-west-2a", "us-west-2b"]' + - key: private_subnets + value: '["10.0.1.0/24", "10.0.2.0/24"]' + - key: public_subnets + value: '["10.0.101.0/24", "10.0.102.0/24"]' + - key: enable_nat_gateway + value: "false" + - key: enable_vpn_gateway + value: "false" + + # Add plan-only arg to avoid actual infrastructure creation during testing + planArgs: + - "-out=tfplan" + + providerConfigRef: + kind: ClusterProviderConfig + name: default + + writeConnectionSecretToRef: + name: example-vpc-entrypoint-outputs diff --git a/examples/namespaced/workspace-remote-pull-policy-if-not-present.yaml b/examples/namespaced/workspace-remote-pull-policy-if-not-present.yaml new file mode 100644 index 0000000..02b7dab --- /dev/null +++ b/examples/namespaced/workspace-remote-pull-policy-if-not-present.yaml @@ -0,0 +1,34 @@ +apiVersion: opentofu.m.upbound.io/v1beta1 +kind: Workspace +metadata: + name: example-remote-if-not-present + namespace: upbound-system +spec: + providerConfigRef: + name: default + kind: ClusterProviderConfig + forProvider: + # Remote module source with IfNotPresent pull policy + # The module will be downloaded once and reused on subsequent reconciliations + source: Remote + module: git::https://github.com/terraform-aws-modules/terraform-aws-vpc?ref=v5.1.2 + + # RemotePullPolicy determines when to download remote module sources + # IfNotPresent: Downloads module only if not already present locally + # This significantly reduces network costs (example: 98.6% reduction for 50MB module at 10-min poll) + # and speeds up reconciliation + remotePullPolicy: IfNotPresent + + vars: + - key: name + value: my-vpc + - key: cidr + value: "10.0.0.0/16" + - key: azs + value: '["us-west-2a", "us-west-2b"]' + - key: private_subnets + value: '["10.0.1.0/24", "10.0.2.0/24"]' + - key: public_subnets + value: '["10.0.101.0/24", "10.0.102.0/24"]' + - key: enable_nat_gateway + value: "true" diff --git a/internal/controller/cluster/workspace/workspace.go b/internal/controller/cluster/workspace/workspace.go index 6128933..d7b8550 100644 --- a/internal/controller/cluster/workspace/workspace.go +++ b/internal/controller/cluster/workspace/workspace.go @@ -225,18 +225,67 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E } } + // Calculate the final tofu working directory (including entrypoint if specified) + // This is where tofu will actually run and create .terraform directory + tofuWorkDir := dir + if len(cr.Spec.ForProvider.Entrypoint) > 0 { + entrypoint := strings.ReplaceAll(cr.Spec.ForProvider.Entrypoint, "../", "") + tofuWorkDir = filepath.Join(dir, entrypoint) + } + switch cr.Spec.ForProvider.Source { case v1beta1.ModuleSourceRemote: - gc := getter.Client{ - Src: cr.Spec.ForProvider.Module, - Dst: dir, - Pwd: dir, + shouldPull := false + + // Determine if we should pull the remote source + switch { + case cr.Spec.ForProvider.RemotePullPolicy == nil || + *cr.Spec.ForProvider.RemotePullPolicy == v1beta1.RemotePullPolicyAlways: + // Always pull (default behavior) + shouldPull = true + l.Debug("Remote module pull policy: Always") + + case *cr.Spec.ForProvider.RemotePullPolicy == v1beta1.RemotePullPolicyIfNotPresent: + // Check if .terraform.lock.hcl exists (indicates successful init) + // This file is created at the END of tofu init, making it the + // most reliable indicator that module initialization completed + lockFile := filepath.Join(tofuWorkDir, ".terraform.lock.hcl") + lockFileValid, err := validateTofuLockFile(c.fs, lockFile) + if err != nil { + return nil, errors.Wrap(err, "failed to validate tofu lock file") + } - Mode: getter.ClientModeDir, + if lockFileValid { + // Module already initialized - check if spec changed + if cr.Spec.ForProvider.Module != cr.Status.AtProvider.RemoteSource { + l.Debug("Remote module URL changed", "old", cr.Status.AtProvider.RemoteSource, "new", cr.Spec.ForProvider.Module) + shouldPull = true + } else { + l.Debug("Remote module already initialized, skipping download", "lockFile", lockFile) + shouldPull = false + } + } else { + l.Debug("Tofu not initialized, downloading module", "lockFile", lockFile) + shouldPull = true + } } - err := gc.Get() - if err != nil { - return nil, errors.Wrap(err, errRemoteModule) + + // Pull remote source if needed + if shouldPull { + gc := getter.Client{ + Src: cr.Spec.ForProvider.Module, + Dst: dir, + Pwd: dir, + Mode: getter.ClientModeDir, + } + err := gc.Get() + if err != nil { + return nil, errors.Wrap(err, errRemoteModule) + } + + // Update status with downloaded module URL + cr.Status.AtProvider.RemoteSource = cr.Spec.ForProvider.Module + l.Debug("Remote module downloaded", "url", cr.Spec.ForProvider.Module) } case v1beta1.ModuleSourceInline: @@ -389,13 +438,15 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex if err != nil { return managed.ExternalObservation{}, errors.Wrap(err, errOutputs) } - cr.Status.AtProvider = generateWorkspaceObservation(op) - + // Generate checksum first checksum, err := c.tofu.GenerateChecksum(ctx) if err != nil { return managed.ExternalObservation{}, errors.Wrap(err, errChecksum) } - cr.Status.AtProvider.Checksum = checksum + + // Preserve remoteSource from previous status (set in Connect) + // Generate observation with all persistent fields + cr.Status.AtProvider = generateWorkspaceObservation(op, checksum, cr.Status.AtProvider.RemoteSource) if !differs { // TODO(negz): Allow Workspaces to optionally derive their readiness from an @@ -438,7 +489,16 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext if err != nil { return managed.ExternalUpdate{}, errors.Wrap(err, errOutputs) } - cr.Status.AtProvider = generateWorkspaceObservation(op) + + // Generate checksum after apply + checksum, err := c.tofu.GenerateChecksum(ctx) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errChecksum) + } + + // Preserve remoteSource and update observation with checksum + cr.Status.AtProvider = generateWorkspaceObservation(op, checksum, cr.Status.AtProvider.RemoteSource) + // TODO(negz): Allow Workspaces to optionally derive their readiness from an // output - similar to the logic XRs use to derive readiness from a field of // a composed resource. @@ -528,11 +588,38 @@ func op2cd(o []opentofu.Output) managed.ConnectionDetails { return cd } +// validateTofuLockFile checks if .terraform.lock.hcl exists and is valid. +// This file is created at the END of successful tofu init, making it the +// most reliable indicator that module initialization completed successfully. +func validateTofuLockFile(fs afero.Afero, lockFilePath string) (bool, error) { + data, err := fs.ReadFile(lockFilePath) + if err != nil { + if os.IsNotExist(err) { + return false, nil // File doesn't exist, not an error + } + return false, err // Read error + } + + if len(data) == 0 { + return false, nil // Empty file (interrupted write) + } + + // Basic validation: lock file should contain "provider" declarations + content := string(data) + if !strings.Contains(content, "provider") { + return false, nil // Doesn't look like a valid lock file + } + + return true, nil +} + // generateWorkspaceObservation is used to produce v1beta1.WorkspaceObservation from // workspace_type.Workspace. -func generateWorkspaceObservation(op []opentofu.Output) v1beta1.WorkspaceObservation { +func generateWorkspaceObservation(op []opentofu.Output, checksum, remoteSource string) v1beta1.WorkspaceObservation { wo := v1beta1.WorkspaceObservation{ - Outputs: make(map[string]extensionsV1.JSON, len(op)), + Outputs: make(map[string]extensionsV1.JSON, len(op)), + Checksum: checksum, + RemoteSource: remoteSource, } for _, o := range op { if !o.Sensitive { diff --git a/internal/controller/cluster/workspace/workspace_test.go b/internal/controller/cluster/workspace/workspace_test.go index 7aaf2df..b62c014 100644 --- a/internal/controller/cluster/workspace/workspace_test.go +++ b/internal/controller/cluster/workspace/workspace_test.go @@ -10,6 +10,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" "github.com/crossplane/crossplane-runtime/v2/pkg/logging" @@ -701,6 +702,231 @@ func TestConnect(t *testing.T) { }, want: nil, }, + "RemotePullPolicyIfNotPresentSkipsDownload": { + reason: "Remote module with IfNotPresent policy should skip download when .terraform.lock.hcl exists and URL unchanged", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + }, + usage: clients.LegacyTrackerFn(func(_ context.Context, _ resource.LegacyManaged) error { return nil }), + fs: func() afero.Afero { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + // Pre-create .terraform.lock.hcl to simulate successful tofu init + lockFileContent := `# This file is maintained automatically by "tofu init". +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.0.0" +} +` + fs.WriteFile(filepath.Join(tfDir, string(uid), ".terraform.lock.hcl"), []byte(lockFileContent), 0644) + return fs + }(), + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &xpv1.Reference{}, + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, + }, + want: nil, + }, + "RemotePullPolicyIfNotPresentSkipsDownloadWithEntrypoint": { + reason: "Remote module with IfNotPresent policy should skip download when .terraform.lock.hcl exists in entrypoint subdirectory", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + }, + usage: clients.LegacyTrackerFn(func(_ context.Context, _ resource.LegacyManaged) error { return nil }), + fs: func() afero.Afero { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + lockFileContent := `# This file is maintained automatically by "tofu init". +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.0.0" +} +` + fs.WriteFile(filepath.Join(tfDir, string(uid), "examples/aws", ".terraform.lock.hcl"), []byte(lockFileContent), 0644) + return fs + }(), + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + Entrypoint: "examples/aws", + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &xpv1.Reference{}, + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, + }, + want: nil, + }, + "RemotePullPolicyIfNotPresentAttemptsDownloadWithEntrypointWrongLocation": { + reason: "Remote module with IfNotPresent and entrypoint should attempt download when .terraform.lock.hcl is in base dir (not entrypoint)", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + }, + usage: clients.LegacyTrackerFn(func(_ context.Context, _ resource.LegacyManaged) error { return nil }), + fs: func() afero.Afero { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + // Create .terraform.lock.hcl in the WRONG location (base dir instead of entrypoint subdir) + lockFileContent := `# This file is maintained automatically by "tofu init". +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.0.0" +} +` + fs.WriteFile(filepath.Join(tfDir, string(uid), ".terraform.lock.hcl"), []byte(lockFileContent), 0644) + return fs + }(), + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + Entrypoint: "examples/aws", // Tofu will run in subdirectory + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &xpv1.Reference{}, + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, + }, + want: nil, // Handled by special case logic + }, + "RemotePullPolicyIfNotPresentAttemptsDownloadWhenURLChanged": { + reason: "Remote module with IfNotPresent policy should attempt download when URL changes", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + }, + usage: clients.LegacyTrackerFn(func(_ context.Context, _ resource.LegacyManaged) error { return nil }), + fs: func() afero.Afero { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + lockFileContent := `# This file is maintained automatically by "tofu init". +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.0.0" +} +` + fs.WriteFile(filepath.Join(tfDir, string(uid), ".terraform.lock.hcl"), []byte(lockFileContent), 0644) + return fs + }(), + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v2.0.0", // Changed + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &xpv1.Reference{}, + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", // Old + }, + }, + }, + }, + want: nil, // Handled by special case logic + }, + "RemotePullPolicyIfNotPresentAttemptsDownloadWhenNotPresent": { + reason: "Remote module with IfNotPresent policy should attempt download when .terraform.lock.hcl missing", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + }, + usage: clients.LegacyTrackerFn(func(_ context.Context, _ resource.LegacyManaged) error { return nil }), + fs: afero.Afero{Fs: afero.NewMemMapFs()}, // No .terraform.lock.hcl file + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ResourceSpec: xpv1.ResourceSpec{ + ProviderConfigReference: &xpv1.Reference{}, + }, + }, + }, + }, + want: nil, // Handled by special case logic + }, "SuccessUsingBackendFile": { reason: "We should not return an error when we successfully 'connect' to tofu using a Backend file", fields: fields{ @@ -760,8 +986,19 @@ func TestConnect(t *testing.T) { logger: logging.NewNopLogger(), } _, err := c.Connect(tc.args.ctx, tc.args.mg) - if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { - t.Errorf("\n%s\ne.Connect(...): -want error, +got error:\n%s\n", tc.reason, diff) + + // Special handling for RemotePullPolicy download attempt tests + // These tests verify download was attempted by checking for errRemoteModule + if strings.Contains(name, "AttemptsDownload") { + if err == nil { + t.Errorf("\n%s\ne.Connect(...): expected error (download attempt), got nil\n", tc.reason) + } else if !strings.Contains(err.Error(), errRemoteModule) { + t.Errorf("\n%s\ne.Connect(...): expected error containing %q, got: %v\n", tc.reason, errRemoteModule, err) + } + } else { + if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\ne.Connect(...): -want error, +got error:\n%s\n", tc.reason, diff) + } } }) } @@ -1082,6 +1319,56 @@ func TestObserve(t *testing.T) { }, }, }, + "RemoteSourcePreserved": { + reason: "RemoteSource field set in Connect should be preserved through Observe call", + fields: fields{ + tofu: &MockTofu{ + MockDiff: func(ctx context.Context, o ...opentofu.Option) (bool, error) { return false, nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockResources: func(ctx context.Context) ([]string, error) { + return []string{"cool_resource.very"}, nil + }, + MockOutputs: func(ctx context.Context) ([]opentofu.Output, error) { + return []opentofu.Output{ + {Name: "string", Type: opentofu.OutputTypeString, Sensitive: false}, + }, nil + }, + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + // Simulating remoteSource was set in Connect + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + ConnectionDetails: managed.ConnectionDetails{ + "string": []byte{}, + }, + }, + wo: v1beta1.WorkspaceObservation{ + Checksum: tfChecksum, + Outputs: map[string]extensionsV1.JSON{ + "string": {Raw: []byte("null")}, + }, + // RemoteSource must be preserved + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, "WorkspaceExistsOnlyOutputs": { reason: "A workspace with only outputs and no resources should set ResourceExists to true", fields: fields{ @@ -1342,6 +1629,7 @@ func TestCreate(t *testing.T) { }, }, wo: v1beta1.WorkspaceObservation{ + Checksum: tfChecksum, Outputs: map[string]extensionsV1.JSON{ "object": {Raw: []byte("null")}, }, diff --git a/internal/controller/namespaced/workspace/workspace.go b/internal/controller/namespaced/workspace/workspace.go index e9fad48..8aa2498 100644 --- a/internal/controller/namespaced/workspace/workspace.go +++ b/internal/controller/namespaced/workspace/workspace.go @@ -225,18 +225,67 @@ func (c *connector) Connect(ctx context.Context, mg resource.Managed) (managed.E } } + // Calculate the final tofu working directory (including entrypoint if specified) + // This is where tofu will actually run and create .terraform directory + tofuWorkDir := dir + if len(cr.Spec.ForProvider.Entrypoint) > 0 { + entrypoint := strings.ReplaceAll(cr.Spec.ForProvider.Entrypoint, "../", "") + tofuWorkDir = filepath.Join(dir, entrypoint) + } + switch cr.Spec.ForProvider.Source { case v1beta1.ModuleSourceRemote: - gc := getter.Client{ - Src: cr.Spec.ForProvider.Module, - Dst: dir, - Pwd: dir, + shouldPull := false + + // Determine if we should pull the remote source + switch { + case cr.Spec.ForProvider.RemotePullPolicy == nil || + *cr.Spec.ForProvider.RemotePullPolicy == v1beta1.RemotePullPolicyAlways: + // Always pull (default behavior) + shouldPull = true + l.Debug("Remote module pull policy: Always") + + case *cr.Spec.ForProvider.RemotePullPolicy == v1beta1.RemotePullPolicyIfNotPresent: + // Check if .terraform.lock.hcl exists (indicates successful init) + // This file is created at the END of tofu init, making it the + // most reliable indicator that module initialization completed + lockFile := filepath.Join(tofuWorkDir, ".terraform.lock.hcl") + lockFileValid, err := validateTofuLockFile(c.fs, lockFile) + if err != nil { + return nil, errors.Wrap(err, "failed to validate tofu lock file") + } - Mode: getter.ClientModeDir, + if lockFileValid { + // Module already initialized - check if spec changed + if cr.Spec.ForProvider.Module != cr.Status.AtProvider.RemoteSource { + l.Debug("Remote module URL changed", "old", cr.Status.AtProvider.RemoteSource, "new", cr.Spec.ForProvider.Module) + shouldPull = true + } else { + l.Debug("Remote module already initialized, skipping download", "lockFile", lockFile) + shouldPull = false + } + } else { + l.Debug("Tofu not initialized, downloading module", "lockFile", lockFile) + shouldPull = true + } } - err := gc.Get() - if err != nil { - return nil, errors.Wrap(err, errRemoteModule) + + // Pull remote source if needed + if shouldPull { + gc := getter.Client{ + Src: cr.Spec.ForProvider.Module, + Dst: dir, + Pwd: dir, + Mode: getter.ClientModeDir, + } + err := gc.Get() + if err != nil { + return nil, errors.Wrap(err, errRemoteModule) + } + + // Update status with downloaded module URL + cr.Status.AtProvider.RemoteSource = cr.Spec.ForProvider.Module + l.Debug("Remote module downloaded", "url", cr.Spec.ForProvider.Module) } case v1beta1.ModuleSourceInline: @@ -389,13 +438,15 @@ func (c *external) Observe(ctx context.Context, mg resource.Managed) (managed.Ex if err != nil { return managed.ExternalObservation{}, errors.Wrap(err, errOutputs) } - cr.Status.AtProvider = generateWorkspaceObservation(op) - + // Generate checksum first checksum, err := c.tofu.GenerateChecksum(ctx) if err != nil { return managed.ExternalObservation{}, errors.Wrap(err, errChecksum) } - cr.Status.AtProvider.Checksum = checksum + + // Preserve remoteSource from previous status (set in Connect) + // Generate observation with all persistent fields + cr.Status.AtProvider = generateWorkspaceObservation(op, checksum, cr.Status.AtProvider.RemoteSource) if !differs { // TODO(negz): Allow Workspaces to optionally derive their readiness from an @@ -438,7 +489,16 @@ func (c *external) Update(ctx context.Context, mg resource.Managed) (managed.Ext if err != nil { return managed.ExternalUpdate{}, errors.Wrap(err, errOutputs) } - cr.Status.AtProvider = generateWorkspaceObservation(op) + + // Generate checksum after apply + checksum, err := c.tofu.GenerateChecksum(ctx) + if err != nil { + return managed.ExternalUpdate{}, errors.Wrap(err, errChecksum) + } + + // Preserve remoteSource and update observation with checksum + cr.Status.AtProvider = generateWorkspaceObservation(op, checksum, cr.Status.AtProvider.RemoteSource) + // TODO(negz): Allow Workspaces to optionally derive their readiness from an // output - similar to the logic XRs use to derive readiness from a field of // a composed resource. @@ -528,11 +588,38 @@ func op2cd(o []opentofu.Output) managed.ConnectionDetails { return cd } +// validateTofuLockFile checks if .terraform.lock.hcl exists and is valid. +// This file is created at the END of successful tofu init, making it the +// most reliable indicator that module initialization completed successfully. +func validateTofuLockFile(fs afero.Afero, lockFilePath string) (bool, error) { + data, err := fs.ReadFile(lockFilePath) + if err != nil { + if os.IsNotExist(err) { + return false, nil // File doesn't exist, not an error + } + return false, err // Read error + } + + if len(data) == 0 { + return false, nil // Empty file (interrupted write) + } + + // Basic validation: lock file should contain "provider" declarations + content := string(data) + if !strings.Contains(content, "provider") { + return false, nil // Doesn't look like a valid lock file + } + + return true, nil +} + // generateWorkspaceObservation is used to produce v1beta1.WorkspaceObservation from // workspace_type.Workspace. -func generateWorkspaceObservation(op []opentofu.Output) v1beta1.WorkspaceObservation { +func generateWorkspaceObservation(op []opentofu.Output, checksum, remoteSource string) v1beta1.WorkspaceObservation { wo := v1beta1.WorkspaceObservation{ - Outputs: make(map[string]extensionsV1.JSON, len(op)), + Outputs: make(map[string]extensionsV1.JSON, len(op)), + Checksum: checksum, + RemoteSource: remoteSource, } for _, o := range op { if !o.Sensitive { diff --git a/internal/controller/namespaced/workspace/workspace_test.go b/internal/controller/namespaced/workspace/workspace_test.go index b265e98..0715970 100644 --- a/internal/controller/namespaced/workspace/workspace_test.go +++ b/internal/controller/namespaced/workspace/workspace_test.go @@ -10,6 +10,7 @@ import ( "context" "os" "path/filepath" + "strings" "testing" xpv2 "github.com/crossplane/crossplane-runtime/v2/apis/common/v2" @@ -839,6 +840,275 @@ func TestConnect(t *testing.T) { }, want: nil, }, + "RemotePullPolicyIfNotPresentSkipsDownload": { + reason: "Remote module with IfNotPresent policy should skip download when .terraform.lock.hcl exists and URL unchanged", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + MockScheme: func() *runtime.Scheme { + s := runtime.NewScheme() + if err := namespaced.AddToScheme(s); err != nil { + t.Fatal(err) + } + return s + }, + }, + usage: clients.ModernTrackerFn(func(_ context.Context, _ resource.ModernManaged) error { return nil }), + fs: func() afero.Afero { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + lockFileContent := `# This file is maintained automatically by "tofu init". +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.0.0" +} +` + fs.WriteFile(filepath.Join(tfDir, string(uid), ".terraform.lock.hcl"), []byte(lockFileContent), 0644) + return fs + }(), + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv1.ProviderConfigReference{ + Kind: "ClusterProviderConfig", + }, + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, + }, + want: nil, + }, + "RemotePullPolicyIfNotPresentSkipsDownloadWithEntrypoint": { + reason: "Remote module with IfNotPresent policy should skip download when .terraform.lock.hcl exists in entrypoint subdirectory", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + MockScheme: func() *runtime.Scheme { + s := runtime.NewScheme() + if err := namespaced.AddToScheme(s); err != nil { + t.Fatal(err) + } + return s + }, + }, + usage: clients.ModernTrackerFn(func(_ context.Context, _ resource.ModernManaged) error { return nil }), + fs: func() afero.Afero { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + lockFileContent := `# This file is maintained automatically by "tofu init". +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.0.0" +} +` + fs.WriteFile(filepath.Join(tfDir, string(uid), "examples/aws", ".terraform.lock.hcl"), []byte(lockFileContent), 0644) + return fs + }(), + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + Entrypoint: "examples/aws", + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv1.ProviderConfigReference{ + Kind: "ClusterProviderConfig", + }, + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, + }, + want: nil, + }, + "RemotePullPolicyIfNotPresentAttemptsDownloadWithEntrypointWrongLocation": { + reason: "Remote module with IfNotPresent and entrypoint should attempt download when .terraform.lock.hcl is in base dir (not entrypoint)", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + MockScheme: func() *runtime.Scheme { + s := runtime.NewScheme() + if err := namespaced.AddToScheme(s); err != nil { + t.Fatal(err) + } + return s + }, + }, + usage: clients.ModernTrackerFn(func(_ context.Context, _ resource.ModernManaged) error { return nil }), + fs: func() afero.Afero { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + // Create .terraform.lock.hcl in the WRONG location (base dir instead of entrypoint subdir) + lockFileContent := `# This file is maintained automatically by "tofu init". +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.0.0" +} +` + fs.WriteFile(filepath.Join(tfDir, string(uid), ".terraform.lock.hcl"), []byte(lockFileContent), 0644) + return fs + }(), + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + Entrypoint: "examples/aws", + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv1.ProviderConfigReference{ + Kind: "ClusterProviderConfig", + }, + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, + }, + want: nil, // Handled by special case logic + }, + "RemotePullPolicyIfNotPresentAttemptsDownloadWhenURLChanged": { + reason: "Remote module with IfNotPresent policy should attempt download when URL changes", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + MockScheme: func() *runtime.Scheme { + s := runtime.NewScheme() + if err := namespaced.AddToScheme(s); err != nil { + t.Fatal(err) + } + return s + }, + }, + usage: clients.ModernTrackerFn(func(_ context.Context, _ resource.ModernManaged) error { return nil }), + fs: func() afero.Afero { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + lockFileContent := `# This file is maintained automatically by "tofu init". +provider "registry.opentofu.org/hashicorp/aws" { + version = "5.0.0" +} +` + fs.WriteFile(filepath.Join(tfDir, string(uid), ".terraform.lock.hcl"), []byte(lockFileContent), 0644) + return fs + }(), + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v2.0.0", + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv1.ProviderConfigReference{ + Kind: "ClusterProviderConfig", + }, + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, + }, + want: nil, + }, + "RemotePullPolicyIfNotPresentAttemptsDownloadWhenNotPresent": { + reason: "Remote module with IfNotPresent policy should attempt download when .terraform.lock.hcl missing", + fields: fields{ + kube: &test.MockClient{ + MockGet: test.NewMockGetFn(nil), + MockScheme: func() *runtime.Scheme { + s := runtime.NewScheme() + if err := namespaced.AddToScheme(s); err != nil { + t.Fatal(err) + } + return s + }, + }, + usage: clients.ModernTrackerFn(func(_ context.Context, _ resource.ModernManaged) error { return nil }), + fs: afero.Afero{Fs: afero.NewMemMapFs()}, + tofu: func(_ string, _ bool, _ bool, _ logging.Logger, _ ...string) tofuclient { + return &MockTofu{ + MockInit: func(ctx context.Context, o ...opentofu.InitOption) error { return nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockWorkspace: func(_ context.Context, _ string) error { return nil }, + } + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + ObjectMeta: metav1.ObjectMeta{UID: uid}, + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + RemotePullPolicy: func() *v1beta1.RemotePullPolicy { p := v1beta1.RemotePullPolicyIfNotPresent; return &p }(), + }, + ManagedResourceSpec: xpv2.ManagedResourceSpec{ + ProviderConfigReference: &xpv1.ProviderConfigReference{ + Kind: "ClusterProviderConfig", + }, + }, + }, + }, + }, + want: nil, + }, "SuccessUsingBackendFile": { reason: "We should not return an error when we successfully 'connect' to tofu using a Backend file", fields: fields{ @@ -907,8 +1177,18 @@ func TestConnect(t *testing.T) { logger: logging.NewNopLogger(), } _, err := c.Connect(tc.args.ctx, tc.args.mg) - if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { - t.Errorf("\n%s\ne.Connect(...): -want error, +got error:\n%s\n", tc.reason, diff) + + // Special handling for RemotePullPolicy download attempt tests + if strings.Contains(name, "AttemptsDownload") { + if err == nil { + t.Errorf("\n%s\ne.Connect(...): expected error (download attempt), got nil\n", tc.reason) + } else if !strings.Contains(err.Error(), errRemoteModule) { + t.Errorf("\n%s\ne.Connect(...): expected error containing %q, got: %v\n", tc.reason, errRemoteModule, err) + } + } else { + if diff := cmp.Diff(tc.want, err, test.EquateErrors()); diff != "" { + t.Errorf("\n%s\ne.Connect(...): -want error, +got error:\n%s\n", tc.reason, diff) + } } }) } @@ -1272,6 +1552,54 @@ func TestObserve(t *testing.T) { }, }, }, + "RemoteSourcePreserved": { + reason: "RemoteSource field set in Connect should be preserved through Observe call", + fields: fields{ + tofu: &MockTofu{ + MockDiff: func(ctx context.Context, o ...opentofu.Option) (bool, error) { return false, nil }, + MockGenerateChecksum: func(ctx context.Context) (string, error) { return tfChecksum, nil }, + MockResources: func(ctx context.Context) ([]string, error) { + return []string{"cool_resource.very"}, nil + }, + MockOutputs: func(ctx context.Context) ([]opentofu.Output, error) { + return []opentofu.Output{ + {Name: "string", Type: opentofu.OutputTypeString, Sensitive: false}, + }, nil + }, + }, + }, + args: args{ + mg: &v1beta1.Workspace{ + Spec: v1beta1.WorkspaceSpec{ + ForProvider: v1beta1.WorkspaceParameters{ + Source: v1beta1.ModuleSourceRemote, + Module: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + Status: v1beta1.WorkspaceStatus{ + AtProvider: v1beta1.WorkspaceObservation{ + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, + }, + want: want{ + o: managed.ExternalObservation{ + ResourceExists: true, + ResourceUpToDate: true, + ConnectionDetails: managed.ConnectionDetails{ + "string": []byte{}, + }, + }, + wo: v1beta1.WorkspaceObservation{ + Checksum: tfChecksum, + Outputs: map[string]extensionsV1.JSON{ + "string": {Raw: []byte("null")}, + }, + RemoteSource: "git::https://github.com/org/repo?ref=v1.0.0", + }, + }, + }, } for name, tc := range cases { @@ -1489,6 +1817,7 @@ func TestCreate(t *testing.T) { }, }, wo: v1beta1.WorkspaceObservation{ + Checksum: tfChecksum, Outputs: map[string]extensionsV1.JSON{ "object": {Raw: []byte("null")}, }, diff --git a/package/crds/opentofu.m.upbound.io_workspaces.yaml b/package/crds/opentofu.m.upbound.io_workspaces.yaml index fb32ff0..1c0730f 100644 --- a/package/crds/opentofu.m.upbound.io_workspaces.yaml +++ b/package/crds/opentofu.m.upbound.io_workspaces.yaml @@ -144,6 +144,14 @@ spec: items: type: string type: array + remotePullPolicy: + default: Always + description: RemotePullPolicy determines when to download remote + module sources. + enum: + - Always + - IfNotPresent + type: string source: description: Source of the root module of this workspace. enum: @@ -293,6 +301,10 @@ spec: additionalProperties: x-kubernetes-preserve-unknown-fields: true type: object + remoteSource: + description: RemoteSource is the remote module URL that was last + retrieved + type: string type: object conditions: description: Conditions of the resource. diff --git a/package/crds/opentofu.upbound.io_workspaces.yaml b/package/crds/opentofu.upbound.io_workspaces.yaml index 0b745dd..b0a2cec 100644 --- a/package/crds/opentofu.upbound.io_workspaces.yaml +++ b/package/crds/opentofu.upbound.io_workspaces.yaml @@ -166,6 +166,14 @@ spec: items: type: string type: array + remotePullPolicy: + default: Always + description: RemotePullPolicy determines when to download remote + module sources. + enum: + - Always + - IfNotPresent + type: string source: description: Source of the root module of this workspace. enum: @@ -351,6 +359,10 @@ spec: additionalProperties: x-kubernetes-preserve-unknown-fields: true type: object + remoteSource: + description: RemoteSource is the remote module URL that was last + retrieved + type: string type: object conditions: description: Conditions of the resource.