diff --git a/README.md b/README.md
index 2f3a58e2c..fa6b7d40b 100644
--- a/README.md
+++ b/README.md
@@ -185,7 +185,7 @@ To enable experiments set the experiments field in the provider definition:
```hcl
provider "stackit" {
default_region = "eu01"
- experiments = ["iam", "routing-tables", "network"]
+ experiments = ["iam", "routing-tables", "network", "workflows"]
}
```
@@ -211,6 +211,11 @@ Enables the usage and provisioning of STACKIT Dremio resources.
The STACKIT Dremio API is currently in alpha state.
The fields of the resources are still subject to change.
+#### `workflows`
+
+Enables the STACKIT Workflows resources (`stackit_workflows_instance`, `stackit_workflows_dag_bundle`) and their data sources.
+The underlying API is in alpha and may change; the experiment flag must be set to use these resources.
+
## Acceptance Tests
> [!WARNING]
diff --git a/docs/data-sources/workflows_dag_bundle.md b/docs/data-sources/workflows_dag_bundle.md
new file mode 100644
index 000000000..1fa0852fa
--- /dev/null
+++ b/docs/data-sources/workflows_dag_bundle.md
@@ -0,0 +1,86 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_workflows_dag_bundle Data Source - stackit"
+subcategory: ""
+description: |-
+ Workflows DAG bundle data source. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on datasource level.
+ ~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+---
+
+# stackit_workflows_dag_bundle (Data Source)
+
+Workflows DAG bundle data source. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level.
+
+~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+
+## Example Usage
+
+```terraform
+data "stackit_workflows_dag_bundle" "bundle" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ name = "main-dags"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `instance_id` (String) ID of the Workflows instance this bundle belongs to.
+- `name` (String) Bundle name. Must be a DNS label: lowercase alphanumeric and hyphens, starting with a letter, max 63 characters. Immutable.
+- `project_id` (String) STACKIT project ID associated with the Workflows instance.
+
+### Optional
+
+- `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used.
+
+### Read-Only
+
+- `git` (Attributes) Git-backed DAG bundle source. Populated when the bundle is git-typed. (see [below for nested schema](#nestedatt--git))
+- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`instance_id`,`name`".
+- `s3` (Attributes) S3-backed DAG bundle source. Populated when the bundle is s3-typed. (see [below for nested schema](#nestedatt--s3))
+
+
+### Nested Schema for `git`
+
+Read-Only:
+
+- `auth` (Attributes) Authentication for the Git source. (see [below for nested schema](#nestedatt--git--auth))
+- `branch` (String) Branch, tag, or ref to track.
+- `refresh_interval` (Number) How often (in seconds) the bundle is re-scanned for changes.
+- `subdir` (String) Optional subdirectory inside the Git repository that contains the DAGs. Leading/trailing slashes are stripped by both the server and provider.
+- `url` (String) Git repository URL.
+
+
+### Nested Schema for `git.auth`
+
+Read-Only:
+
+- `password` (String, Sensitive) Git password or PAT. Never returned by the API; always null when read.
+- `type` (String) Authentication scheme: `basic` (username + password) or `none` (public repos).
+- `username` (String) Git username. Required when `git.auth.type = basic`.
+
+
+
+
+### Nested Schema for `s3`
+
+Read-Only:
+
+- `auth` (Attributes) Authentication for the S3 source. (see [below for nested schema](#nestedatt--s3--auth))
+- `bucket_name` (String) S3 bucket name containing the DAGs.
+- `endpoint` (String) S3-compatible endpoint URL. Defaults to STACKIT Object Storage in the region.
+- `prefix` (String) Optional key prefix inside the bucket.
+- `refresh_interval` (Number) How often (in seconds) the bundle is re-scanned for changes.
+
+
+### Nested Schema for `s3.auth`
+
+Read-Only:
+
+- `access_key_id` (String) S3 access key ID. Required when `s3.auth.type = access_key`.
+- `secret_access_key` (String, Sensitive) S3 secret access key. Never returned by the API; always null when read.
+- `type` (String) Authentication scheme: `access_key` or `none` (public buckets).
diff --git a/docs/data-sources/workflows_dag_bundles.md b/docs/data-sources/workflows_dag_bundles.md
new file mode 100644
index 000000000..5fc68dc32
--- /dev/null
+++ b/docs/data-sources/workflows_dag_bundles.md
@@ -0,0 +1,56 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_workflows_dag_bundles Data Source - stackit"
+subcategory: ""
+description: |-
+ Lists all DAG bundles attached to a Workflows instance. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on datasource level.
+ ~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+---
+
+# stackit_workflows_dag_bundles (Data Source)
+
+Lists all DAG bundles attached to a Workflows instance. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level.
+
+~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+
+## Example Usage
+
+```terraform
+data "stackit_workflows_dag_bundles" "all" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `instance_id` (String) Workflows instance ID.
+- `project_id` (String) STACKIT project ID.
+
+### Optional
+
+- `region` (String) STACKIT region name.
+
+### Read-Only
+
+- `dag_bundles` (Attributes List) DAG bundles attached to the instance. (see [below for nested schema](#nestedatt--dag_bundles))
+- `id` (String) Terraform's internal data-source ID. It is structured as "`project_id`,`region`,`instance_id`".
+
+
+### Nested Schema for `dag_bundles`
+
+Read-Only:
+
+- `branch` (String) Git branch (git bundles only).
+- `bucket_name` (String) Bucket name (s3 bundles only).
+- `endpoint` (String) S3 endpoint (s3 bundles only).
+- `name` (String) Bundle name.
+- `prefix` (String) Key prefix (s3 bundles only).
+- `refresh_interval` (Number) Refresh interval (seconds).
+- `subdir` (String) Subdirectory inside the repository (git bundles only).
+- `type` (String) Bundle source type (`git` or `s3`).
+- `url` (String) Git repository URL (git bundles only).
diff --git a/docs/data-sources/workflows_instance.md b/docs/data-sources/workflows_instance.md
new file mode 100644
index 000000000..a4e12324a
--- /dev/null
+++ b/docs/data-sources/workflows_instance.md
@@ -0,0 +1,101 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_workflows_instance Data Source - stackit"
+subcategory: ""
+description: |-
+ Workflows instance data source schema. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on datasource level.
+ ~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+---
+
+# stackit_workflows_instance (Data Source)
+
+Workflows instance data source schema. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level.
+
+~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+
+## Example Usage
+
+```terraform
+data "stackit_workflows_instance" "workflow" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `instance_id` (String) Workflows instance ID.
+- `project_id` (String) STACKIT project ID associated with the Workflows instance.
+
+### Optional
+
+- `region` (String) STACKIT region. If not set, the provider region is used.
+
+### Read-Only
+
+- `created_at` (String) Creation timestamp (RFC 3339).
+- `dag_bundles` (Attributes List) DAG bundles attached to this instance. Manage individual bundles via `stackit_workflows_dag_bundle`. (see [below for nested schema](#nestedatt--dag_bundles))
+- `description` (String) Instance description. Max 256 characters.
+- `display_name` (String) Instance display name. Max 25 characters.
+- `enable_airflow_example_dags` (Boolean) Enable the Airflow built-in example DAGs. Honored on Airflow 3 instances; older versions may reject.
+- `enable_stackit_example_dags` (Boolean) Include the STACKIT sample DAGs. Honored on Airflow 3 instances; older versions may reject.
+- `endpoints` (Attributes) Instance endpoints. Populated by the server. (see [below for nested schema](#nestedatt--endpoints))
+- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`instance_id`".
+- `identity_provider` (Attributes) Identity provider configuration. Only `oauth2` is currently supported. (see [below for nested schema](#nestedatt--identity_provider))
+- `network` (Attributes) Attach the instance to a STACKIT network. Changes force replacement. (see [below for nested schema](#nestedatt--network))
+- `observability_id` (String) STACKIT Observability instance to receive metrics and logs.
+- `status` (String) Lifecycle status of the Workflows instance. Possible values are: `creating`, `active`, `updating`, `deleting`, `failed`.
+- `status_message` (String) Human-readable status detail. Populated by the server when status is `failed` or during convergence; empty otherwise.
+- `version` (String) Workflows version (e.g. `workflows-3.0-airflow-3.1`). Discover valid values via the `stackit_workflows_provider_options` data source.
+
+
+### Nested Schema for `dag_bundles`
+
+Read-Only:
+
+- `branch` (String) Git branch (git bundles only).
+- `bucket_name` (String) S3 bucket name (s3 bundles only).
+- `endpoint` (String) S3 endpoint (s3 bundles only).
+- `name` (String) Bundle name.
+- `prefix` (String) S3 key prefix (s3 bundles only).
+- `refresh_interval` (Number) Bundle refresh interval in seconds.
+- `subdir` (String) Subdirectory inside the Git repository (git bundles only).
+- `type` (String) Bundle type: `git` or `s3`.
+- `url` (String) Git repository URL (git bundles only).
+
+
+
+### Nested Schema for `endpoints`
+
+Read-Only:
+
+- `redirect_url` (String) OAuth2 redirect URL configured on the instance.
+- `url` (String) Primary endpoint URL (Airflow UI).
+
+
+
+### Nested Schema for `identity_provider`
+
+Read-Only:
+
+- `api_audience` (Set of String) Allowed audiences for the ID token.
+- `client_id` (String) OAuth2 client ID.
+- `client_secret` (String, Sensitive) OAuth2 client secret. Never returned by the API; always null when read.
+- `discovery_endpoint` (String) OAuth2 discovery endpoint (`.well-known/openid-configuration`).
+- `name` (String) Display name for the IdP. `azure`, `okta`, `aws_cognito`, `keycloak` enable provider-specific token parsing.
+- `resource` (String) OAuth2 resource indicator.
+- `roles_claim` (String) Name of the claim that carries the user's roles.
+- `scope` (String) OAuth2 scopes (space-separated, e.g. `openid email`).
+- `type` (String) Identity provider type (`oauth2`).
+
+
+
+### Nested Schema for `network`
+
+Read-Only:
+
+- `id` (String) STACKIT network ID.
diff --git a/docs/data-sources/workflows_instances.md b/docs/data-sources/workflows_instances.md
new file mode 100644
index 000000000..25662a21d
--- /dev/null
+++ b/docs/data-sources/workflows_instances.md
@@ -0,0 +1,51 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_workflows_instances Data Source - stackit"
+subcategory: ""
+description: |-
+ Lists all Workflows instances in a project. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on datasource level.
+ ~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+---
+
+# stackit_workflows_instances (Data Source)
+
+Lists all Workflows instances in a project. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level.
+
+~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+
+## Example Usage
+
+```terraform
+data "stackit_workflows_instances" "all" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `project_id` (String) STACKIT project ID.
+
+### Optional
+
+- `region` (String) STACKIT region name. If not defined, the provider region is used.
+
+### Read-Only
+
+- `id` (String) Terraform's internal data-source ID. It is structured as "`project_id`,`region`".
+- `instances` (Attributes List) All Workflows instances in this project + region. (see [below for nested schema](#nestedatt--instances))
+
+
+### Nested Schema for `instances`
+
+Read-Only:
+
+- `created_at` (String) Creation timestamp (RFC 3339).
+- `description` (String) User-provided description.
+- `display_name` (String) Display name of the instance.
+- `instance_id` (String) The Workflows instance ID.
+- `status` (String) Lifecycle status.
+- `version` (String) Workflows version.
diff --git a/docs/data-sources/workflows_provider_options.md b/docs/data-sources/workflows_provider_options.md
new file mode 100644
index 000000000..ea59ba912
--- /dev/null
+++ b/docs/data-sources/workflows_provider_options.md
@@ -0,0 +1,58 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_workflows_provider_options Data Source - stackit"
+subcategory: ""
+description: |-
+ Lists Workflows versions supported by the Workflows API in a region. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on datasource level.
+ ~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+---
+
+# stackit_workflows_provider_options (Data Source)
+
+Lists Workflows versions supported by the Workflows API in a region. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on datasource level.
+
+~> This datasource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+
+## Example Usage
+
+```terraform
+data "stackit_workflows_provider_options" "options" {
+ region = "eu01"
+}
+
+resource "stackit_workflows_instance" "instance" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ display_name = "my-instance"
+ version = data.stackit_workflows_provider_options.options.versions.0.version
+
+ identity_provider = {
+ type = "oauth2"
+ name = "azure"
+ client_id = "xxx"
+ client_secret = "xxx"
+ scope = "openid email"
+ discovery_endpoint = "https://login.microsoftonline.com/.../v2.0/.well-known/openid-configuration"
+ }
+}
+```
+
+
+## Schema
+
+### Optional
+
+- `region` (String) STACKIT region to query. If not defined, the provider region is used.
+
+### Read-Only
+
+- `versions` (Attributes List) Supported Workflows versions. (see [below for nested schema](#nestedatt--versions))
+
+
+### Nested Schema for `versions`
+
+Read-Only:
+
+- `expiration_date` (String) RFC 3339 timestamp at which the version expires, or null if there is no scheduled expiry.
+- `state` (String) Lifecycle state of the version.
+- `version` (String) Version identifier (e.g. `workflows-3.0-airflow-3.1`).
diff --git a/docs/index.md b/docs/index.md
index 368beb47e..772a44167 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -173,7 +173,7 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de
- `dremio_custom_endpoint` (String) Custom endpoint for the Dremio service
- `edgecloud_custom_endpoint` (String) Custom endpoint for the Edge Cloud service
- `enable_beta_resources` (Boolean) Enable beta resources. Default is false.
-- `experiments` (List of String) Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: dremio, iam, routing-tables, network
+- `experiments` (List of String) Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: dremio, iam, network, routing-tables, workflows
- `git_custom_endpoint` (String) Custom endpoint for the Git service
- `iaas_custom_endpoint` (String) Custom endpoint for the IaaS service
- `intake_custom_endpoint` (String) Custom endpoint for the Intake service
@@ -216,3 +216,4 @@ Note: AWS specific checks must be skipped as they do not work on STACKIT. For de
- `token_custom_endpoint` (String) Custom endpoint for the token API, which is used to request access tokens when using the key flow
- `use_oidc` (Boolean) Enables OIDC for Authentication. This can also be sourced from the `STACKIT_USE_OIDC` Environment Variable. Defaults to `false`.
- `vpn_custom_endpoint` (String) Custom endpoint for the VPN service
+- `workflows_custom_endpoint` (String) Custom endpoint for the Workflows service
diff --git a/docs/resources/workflows_dag_bundle.md b/docs/resources/workflows_dag_bundle.md
new file mode 100644
index 000000000..5be65bcc9
--- /dev/null
+++ b/docs/resources/workflows_dag_bundle.md
@@ -0,0 +1,146 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_workflows_dag_bundle Resource - stackit"
+subcategory: ""
+description: |-
+ Workflows DAG bundle resource (Airflow 3 only). Bundle CRUD is synchronous server-side. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on resource level.
+ ~> This resource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+---
+
+# stackit_workflows_dag_bundle (Resource)
+
+Workflows DAG bundle resource (Airflow 3 only). Bundle CRUD is synchronous server-side. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on resource level.
+
+~> This resource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+
+## Example Usage
+
+```terraform
+resource "stackit_workflows_dag_bundle" "git" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+ name = "main-dags"
+ type = "git"
+ url = "https://git.example.com/my-org/my-dags.git"
+ branch = "main"
+ subdir = "dags/"
+ auth = {
+ type = "basic"
+ username = "git-user"
+ password = "personal-access-token"
+ }
+}
+
+resource "stackit_workflows_dag_bundle" "git_public" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+ name = "public-dags"
+ type = "git"
+ url = "https://github.com/example/public-airflow-dags.git"
+ branch = "main"
+ auth = {
+ type = "none"
+ }
+}
+
+resource "stackit_workflows_dag_bundle" "s3" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+ name = "backup-dags"
+ type = "s3"
+ bucket_name = "my-airflow-dags"
+ prefix = "dags/"
+ refresh_interval = 60
+ s3_auth = {
+ type = "access_key"
+ access_key_id = "AKIA..."
+ secret_access_key = "shhh"
+ }
+}
+
+# Only use the import statement, if you want to import an existing Workflows DAG bundle.
+# Sensitive fields (password, secret_access_key) cannot be imported — they are never returned by the API.
+import {
+ to = stackit_workflows_dag_bundle.import-example
+ id = "${var.project_id},${var.region},${var.workflows_instance_id},${var.bundle_name}"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `instance_id` (String) ID of the Workflows instance this bundle belongs to.
+- `name` (String) Bundle name. Must be a DNS label: lowercase alphanumeric and hyphens, starting with a letter, max 63 characters. Immutable.
+- `project_id` (String) STACKIT project ID associated with the Workflows instance.
+
+### Optional
+
+- `git` (Attributes) Git-backed DAG bundle source. Exactly one of `git` or `s3` must be set. (see [below for nested schema](#nestedatt--git))
+- `region` (String) STACKIT region name the resource is located in. If not defined, the provider region is used.
+- `s3` (Attributes) S3-backed DAG bundle source. Exactly one of `git` or `s3` must be set. (see [below for nested schema](#nestedatt--s3))
+
+### Read-Only
+
+- `id` (String) Terraform's internal resource identifier. It is structured as "`project_id`,`region`,`instance_id`,`name`".
+
+
+### Nested Schema for `git`
+
+Required:
+
+- `auth` (Attributes) Authentication for the Git source. (see [below for nested schema](#nestedatt--git--auth))
+- `branch` (String) Branch, tag, or ref to track.
+- `url` (String) Git repository URL.
+
+Optional:
+
+- `refresh_interval` (Number) How often (in seconds) the bundle is re-scanned for changes.
+- `subdir` (String) Optional subdirectory inside the Git repository that contains the DAGs. Leading/trailing slashes are stripped by both the server and provider.
+
+
+### Nested Schema for `git.auth`
+
+Required:
+
+- `type` (String) Authentication scheme: `basic` (username + password) or `none` (public repos).
+
+Optional:
+
+- `password` (String, Sensitive) Git password or personal access token. Required when `git.auth.type = basic`. Sensitive. The API never returns this value back.
+- `username` (String) Git username. Required when `git.auth.type = basic`.
+
+
+
+
+### Nested Schema for `s3`
+
+Required:
+
+- `auth` (Attributes) Authentication for the S3 source. (see [below for nested schema](#nestedatt--s3--auth))
+- `bucket_name` (String) S3 bucket name containing the DAGs.
+
+Optional:
+
+- `endpoint` (String) S3-compatible endpoint URL. Defaults to STACKIT Object Storage in the region.
+- `prefix` (String) Optional key prefix inside the bucket.
+- `refresh_interval` (Number) How often (in seconds) the bundle is re-scanned for changes.
+
+
+### Nested Schema for `s3.auth`
+
+Required:
+
+- `type` (String) Authentication scheme: `access_key` or `none` (public buckets).
+
+Optional:
+
+- `access_key_id` (String) S3 access key ID. Required when `s3.auth.type = access_key`.
+- `secret_access_key` (String, Sensitive) S3 secret access key. Required when `s3.auth.type = access_key`. Sensitive. The API never returns this value back.
diff --git a/docs/resources/workflows_instance.md b/docs/resources/workflows_instance.md
new file mode 100644
index 000000000..88b58b1ee
--- /dev/null
+++ b/docs/resources/workflows_instance.md
@@ -0,0 +1,131 @@
+---
+# generated by https://github.com/hashicorp/terraform-plugin-docs
+page_title: "stackit_workflows_instance Resource - stackit"
+subcategory: ""
+description: |-
+ Workflows instance resource schema. Uses the default_region specified in the provider configuration as a fallback in case no region is defined on resource level.
+ ~> This resource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+---
+
+# stackit_workflows_instance (Resource)
+
+Workflows instance resource schema. Uses the `default_region` specified in the provider configuration as a fallback in case no `region` is defined on resource level.
+
+~> This resource is part of the workflows experiment and is likely going to undergo significant changes or be removed in the future. Use it at your own discretion.
+
+## Example Usage
+
+```terraform
+resource "stackit_workflows_instance" "minimal" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ display_name = "my-workflows"
+ version = "workflows-3.0-airflow-3.1"
+
+ # OAuth2 identity provider is required — the STACKIT IdP variant is not yet
+ # accepted by the backend.
+ identity_provider = {
+ type = "oauth2"
+ name = "azure"
+ client_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ client_secret = "shhh"
+ scope = "openid email"
+ discovery_endpoint = "https://login.microsoftonline.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/.well-known/openid-configuration"
+ }
+}
+
+resource "stackit_workflows_instance" "full" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ display_name = "production-workflows"
+ description = "Production STACKIT Workflows instance."
+ version = "workflows-3.0-airflow-3.1"
+ enable_stackit_example_dags = false
+ enable_airflow_example_dags = false
+ observability_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+ network = {
+ id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ }
+
+ identity_provider = {
+ type = "oauth2"
+ name = "azure"
+ client_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ client_secret = "shhh"
+ scope = "openid email"
+ discovery_endpoint = "https://login.microsoftonline.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/.well-known/openid-configuration"
+ api_audience = ["api://workflows"]
+ }
+}
+
+# Only use the import statement, if you want to import an existing Workflows instance.
+# The client_secret cannot be imported — it is never returned by the API.
+import {
+ to = stackit_workflows_instance.import-example
+ id = "${var.project_id},${var.region},${var.workflows_instance_id}"
+}
+```
+
+
+## Schema
+
+### Required
+
+- `display_name` (String) Instance display name. Max 25 characters.
+- `identity_provider` (Attributes) Identity provider configuration. Only `oauth2` is currently supported. (see [below for nested schema](#nestedatt--identity_provider))
+- `project_id` (String) STACKIT project ID associated with the Workflows instance.
+- `version` (String) Workflows version (e.g. `workflows-3.0-airflow-3.1`). Discover valid values via the `stackit_workflows_provider_options` data source.
+
+### Optional
+
+- `description` (String) Instance description. Max 256 characters.
+- `enable_airflow_example_dags` (Boolean) Enable the Airflow built-in example DAGs. Honored on Airflow 3 instances; older versions may reject.
+- `enable_stackit_example_dags` (Boolean) Include the STACKIT sample DAGs. Honored on Airflow 3 instances; older versions may reject.
+- `network` (Attributes) Attach the instance to a STACKIT network. Changes force replacement. (see [below for nested schema](#nestedatt--network))
+- `observability_id` (String) STACKIT Observability instance to receive metrics and logs.
+- `region` (String) STACKIT region. If not set, the provider region is used.
+
+### Read-Only
+
+- `created_at` (String) Creation timestamp (RFC 3339).
+- `endpoints` (Attributes) Instance endpoints. Populated by the server. (see [below for nested schema](#nestedatt--endpoints))
+- `id` (String) Terraform's internal resource ID. It is structured as "`project_id`,`region`,`instance_id`".
+- `instance_id` (String) Workflows instance ID.
+- `status` (String) Lifecycle status of the Workflows instance. Possible values are: `creating`, `active`, `updating`, `deleting`, `failed`.
+- `status_message` (String) Human-readable status detail. Populated by the server when status is `failed` or during convergence; empty otherwise.
+
+
+### Nested Schema for `identity_provider`
+
+Required:
+
+- `type` (String) Identity provider type (`oauth2`).
+
+Optional:
+
+- `api_audience` (Set of String) Allowed audiences for the ID token.
+- `client_id` (String) OAuth2 client ID.
+- `client_secret` (String, Sensitive) OAuth2 client secret. Sensitive; must be re-sent on every IdP update.
+- `discovery_endpoint` (String) OAuth2 discovery endpoint (`.well-known/openid-configuration`).
+- `name` (String) Display name for the IdP. `azure`, `okta`, `aws_cognito`, `keycloak` enable provider-specific token parsing.
+- `resource` (String) OAuth2 resource indicator.
+- `roles_claim` (String) Name of the claim that carries the user's roles.
+- `scope` (String) OAuth2 scopes (space-separated, e.g. `openid email`).
+
+
+
+### Nested Schema for `network`
+
+Required:
+
+- `id` (String) STACKIT network ID.
+
+
+
+### Nested Schema for `endpoints`
+
+Read-Only:
+
+- `redirect_url` (String) OAuth2 redirect URL configured on the instance.
+- `url` (String) Primary endpoint URL (Airflow UI).
diff --git a/examples/data-sources/stackit_workflows_dag_bundle/data-source.tf b/examples/data-sources/stackit_workflows_dag_bundle/data-source.tf
new file mode 100644
index 000000000..884edde4f
--- /dev/null
+++ b/examples/data-sources/stackit_workflows_dag_bundle/data-source.tf
@@ -0,0 +1,6 @@
+data "stackit_workflows_dag_bundle" "bundle" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ name = "main-dags"
+}
diff --git a/examples/data-sources/stackit_workflows_dag_bundles/data-source.tf b/examples/data-sources/stackit_workflows_dag_bundles/data-source.tf
new file mode 100644
index 000000000..d2d3a9a72
--- /dev/null
+++ b/examples/data-sources/stackit_workflows_dag_bundles/data-source.tf
@@ -0,0 +1,5 @@
+data "stackit_workflows_dag_bundles" "all" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+}
diff --git a/examples/data-sources/stackit_workflows_instance/data-source.tf b/examples/data-sources/stackit_workflows_instance/data-source.tf
new file mode 100644
index 000000000..9309fbf47
--- /dev/null
+++ b/examples/data-sources/stackit_workflows_instance/data-source.tf
@@ -0,0 +1,5 @@
+data "stackit_workflows_instance" "workflow" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+}
diff --git a/examples/data-sources/stackit_workflows_instances/data-source.tf b/examples/data-sources/stackit_workflows_instances/data-source.tf
new file mode 100644
index 000000000..19a21c476
--- /dev/null
+++ b/examples/data-sources/stackit_workflows_instances/data-source.tf
@@ -0,0 +1,4 @@
+data "stackit_workflows_instances" "all" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+}
diff --git a/examples/data-sources/stackit_workflows_provider_options/data-source.tf b/examples/data-sources/stackit_workflows_provider_options/data-source.tf
new file mode 100644
index 000000000..7dd1b369c
--- /dev/null
+++ b/examples/data-sources/stackit_workflows_provider_options/data-source.tf
@@ -0,0 +1,19 @@
+data "stackit_workflows_provider_options" "options" {
+ region = "eu01"
+}
+
+resource "stackit_workflows_instance" "instance" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ display_name = "my-instance"
+ version = data.stackit_workflows_provider_options.options.versions.0.version
+
+ identity_provider = {
+ type = "oauth2"
+ name = "azure"
+ client_id = "xxx"
+ client_secret = "xxx"
+ scope = "openid email"
+ discovery_endpoint = "https://login.microsoftonline.com/.../v2.0/.well-known/openid-configuration"
+ }
+}
diff --git a/examples/resources/stackit_workflows_dag_bundle/resource.tf b/examples/resources/stackit_workflows_dag_bundle/resource.tf
new file mode 100644
index 000000000..1b1a1cba0
--- /dev/null
+++ b/examples/resources/stackit_workflows_dag_bundle/resource.tf
@@ -0,0 +1,54 @@
+resource "stackit_workflows_dag_bundle" "git" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+ name = "main-dags"
+ type = "git"
+ url = "https://git.example.com/my-org/my-dags.git"
+ branch = "main"
+ subdir = "dags/"
+ auth = {
+ type = "basic"
+ username = "git-user"
+ password = "personal-access-token"
+ }
+}
+
+resource "stackit_workflows_dag_bundle" "git_public" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+ name = "public-dags"
+ type = "git"
+ url = "https://github.com/example/public-airflow-dags.git"
+ branch = "main"
+ auth = {
+ type = "none"
+ }
+}
+
+resource "stackit_workflows_dag_bundle" "s3" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ instance_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+ name = "backup-dags"
+ type = "s3"
+ bucket_name = "my-airflow-dags"
+ prefix = "dags/"
+ refresh_interval = 60
+ s3_auth = {
+ type = "access_key"
+ access_key_id = "AKIA..."
+ secret_access_key = "shhh"
+ }
+}
+
+# Only use the import statement, if you want to import an existing Workflows DAG bundle.
+# Sensitive fields (password, secret_access_key) cannot be imported — they are never returned by the API.
+import {
+ to = stackit_workflows_dag_bundle.import-example
+ id = "${var.project_id},${var.region},${var.workflows_instance_id},${var.bundle_name}"
+}
diff --git a/examples/resources/stackit_workflows_instance/resource.tf b/examples/resources/stackit_workflows_instance/resource.tf
new file mode 100644
index 000000000..e3e80fdb7
--- /dev/null
+++ b/examples/resources/stackit_workflows_instance/resource.tf
@@ -0,0 +1,49 @@
+resource "stackit_workflows_instance" "minimal" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ display_name = "my-workflows"
+ version = "workflows-3.0-airflow-3.1"
+
+ # OAuth2 identity provider is required — the STACKIT IdP variant is not yet
+ # accepted by the backend.
+ identity_provider = {
+ type = "oauth2"
+ name = "azure"
+ client_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ client_secret = "shhh"
+ scope = "openid email"
+ discovery_endpoint = "https://login.microsoftonline.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/.well-known/openid-configuration"
+ }
+}
+
+resource "stackit_workflows_instance" "full" {
+ project_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ region = "eu01"
+ display_name = "production-workflows"
+ description = "Production STACKIT Workflows instance."
+ version = "workflows-3.0-airflow-3.1"
+ enable_stackit_example_dags = false
+ enable_airflow_example_dags = false
+ observability_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+
+ network = {
+ id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ }
+
+ identity_provider = {
+ type = "oauth2"
+ name = "azure"
+ client_id = "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
+ client_secret = "shhh"
+ scope = "openid email"
+ discovery_endpoint = "https://login.microsoftonline.com/xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx/.well-known/openid-configuration"
+ api_audience = ["api://workflows"]
+ }
+}
+
+# Only use the import statement, if you want to import an existing Workflows instance.
+# The client_secret cannot be imported — it is never returned by the API.
+import {
+ to = stackit_workflows_instance.import-example
+ id = "${var.project_id},${var.region},${var.workflows_instance_id}"
+}
diff --git a/go.mod b/go.mod
index 9fb544b8b..408c1339d 100644
--- a/go.mod
+++ b/go.mod
@@ -48,6 +48,7 @@ require (
github.com/stackitcloud/stackit-sdk-go/services/telemetrylink v0.2.0
github.com/stackitcloud/stackit-sdk-go/services/telemetryrouter v0.3.0
github.com/stackitcloud/stackit-sdk-go/services/vpn v0.14.0
+ github.com/stackitcloud/stackit-sdk-go/services/workflows v0.0.0-00010101000000-000000000000
github.com/teambition/rrule-go v1.8.2
golang.org/x/mod v0.37.0
)
@@ -302,6 +303,8 @@ require (
google.golang.org/protobuf v1.36.11 // indirect
)
+replace github.com/stackitcloud/stackit-sdk-go/services/workflows => /Users/cht/code/schwarz/stackit-sdk-go/services/workflows
+
tool (
github.com/golangci/golangci-lint/v2/cmd/golangci-lint
golang.org/x/tools/cmd/goimports
diff --git a/stackit/internal/conversion/conversion.go b/stackit/internal/conversion/conversion.go
index 724eb7f35..028a013c5 100644
--- a/stackit/internal/conversion/conversion.go
+++ b/stackit/internal/conversion/conversion.go
@@ -98,6 +98,50 @@ func StringValueToEnumPointer[T ~string](s basetypes.StringValue) *T {
return new(T(s.ValueString()))
}
+// StringOrEmpty returns the underlying string, treating null and unknown as "".
+func StringOrEmpty(s basetypes.StringValue) string {
+ if s.IsNull() || s.IsUnknown() {
+ return ""
+ }
+ return s.ValueString()
+}
+
+// NilIfEmpty returns nil for null/unknown/empty values, otherwise a pointer to
+// the string. Use for Create payloads against servers that normalize "" → null
+// on subsequent updates — seeding "" would produce a perpetual "" → null diff
+// on the next Read.
+func NilIfEmpty(s basetypes.StringValue) *string {
+ v := StringOrEmpty(s)
+ if v == "" {
+ return nil
+ }
+ return &v
+}
+
+// ClearableString returns the value to send in a PATCH payload for an optional
+// string with "empty string clears" semantics on the server. Plan "" and null
+// are treated equivalently — both mean "field is not set" — so a user writing
+// `field = ""` doesn't accidentally trigger a clear (which would then produce a
+// perpetual "" → null diff). Unknown plan values are deferred (nil). Only when
+// the prior state held a non-empty value AND the plan is empty/null do we send
+// "" to clear the field server-side.
+func ClearableString(plan, state basetypes.StringValue) *string {
+ if plan.IsUnknown() {
+ return nil
+ }
+ planVal := StringOrEmpty(plan)
+ stateVal := StringOrEmpty(state)
+ if planVal == "" {
+ if stateVal == "" {
+ return nil
+ }
+ empty := ""
+ return &empty
+ }
+ v := planVal
+ return &v
+}
+
// Int32ValueToPointer converts basetypes.Int32Value to a pointer to int32.
// It returns nil if the value is null or unknown.
func Int32ValueToPointer(s basetypes.Int32Value) *int32 {
diff --git a/stackit/internal/conversion/conversion_test.go b/stackit/internal/conversion/conversion_test.go
index 05b6d3c59..b37733d2b 100644
--- a/stackit/internal/conversion/conversion_test.go
+++ b/stackit/internal/conversion/conversion_test.go
@@ -788,3 +788,74 @@ func TestStringListToEnumSlice(t *testing.T) {
})
}
}
+
+func TestClearableString(t *testing.T) {
+ tests := []struct {
+ desc string
+ plan, state types.String
+ wantNil bool
+ wantValue string
+ }{
+ // "I want this field empty" expressed three different ways: all collapse to nil-or-clear.
+ {"null plan, null state → nil", types.StringNull(), types.StringNull(), true, ""},
+ {"null plan, empty state → nil", types.StringNull(), types.StringValue(""), true, ""},
+ {"null plan, prior value → ptr to empty (clear)", types.StringNull(), types.StringValue("had"), false, ""},
+ {"empty plan, null state → nil", types.StringValue(""), types.StringNull(), true, ""},
+ {"empty plan, empty state → nil", types.StringValue(""), types.StringValue(""), true, ""},
+ {"empty plan, prior value → ptr to empty (clear)", types.StringValue(""), types.StringValue("had"), false, ""},
+
+ // "I want this field set"
+ {"value plan, null state → ptr to value", types.StringValue("v"), types.StringNull(), false, "v"},
+ {"value plan, prior value (changed) → ptr to value", types.StringValue("new"), types.StringValue("old"), false, "new"},
+ {"value plan, same value → ptr to value", types.StringValue("v"), types.StringValue("v"), false, "v"},
+
+ // Unknown plan defers: never accidentally clear a credential mid-dependency-resolution.
+ {"unknown plan, prior value → nil (defer)", types.StringUnknown(), types.StringValue("had"), true, ""},
+ {"unknown plan, null state → nil (defer)", types.StringUnknown(), types.StringNull(), true, ""},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ got := ClearableString(tt.plan, tt.state)
+ if tt.wantNil {
+ if got != nil {
+ t.Errorf("got %q, want nil", *got)
+ }
+ return
+ }
+ if got == nil {
+ t.Fatalf("got nil, want %q", tt.wantValue)
+ }
+ if *got != tt.wantValue {
+ t.Errorf("got %q, want %q", *got, tt.wantValue)
+ }
+ })
+ }
+}
+
+func TestStringOrEmpty(t *testing.T) {
+ if got := StringOrEmpty(types.StringNull()); got != "" {
+ t.Errorf("null: got %q, want empty", got)
+ }
+ if got := StringOrEmpty(types.StringUnknown()); got != "" {
+ t.Errorf("unknown: got %q, want empty", got)
+ }
+ if got := StringOrEmpty(types.StringValue("hello")); got != "hello" {
+ t.Errorf("value: got %q, want hello", got)
+ }
+}
+
+func TestNilIfEmpty(t *testing.T) {
+ if got := NilIfEmpty(types.StringNull()); got != nil {
+ t.Errorf("null: got %q, want nil", *got)
+ }
+ if got := NilIfEmpty(types.StringUnknown()); got != nil {
+ t.Errorf("unknown: got %q, want nil", *got)
+ }
+ if got := NilIfEmpty(types.StringValue("")); got != nil {
+ t.Errorf("empty: got %q, want nil", *got)
+ }
+ if got := NilIfEmpty(types.StringValue("hello")); got == nil || *got != "hello" {
+ t.Fatalf("hello: got %v, want ptr to hello", got)
+ }
+}
diff --git a/stackit/internal/core/core.go b/stackit/internal/core/core.go
index d71a0cfed..cbf22f5ed 100644
--- a/stackit/internal/core/core.go
+++ b/stackit/internal/core/core.go
@@ -76,6 +76,7 @@ type ProviderData struct {
TelemetryLinkCustomEndpoint string
TelemetryRouterCustomEndpoint string
VpnCustomEndpoint string
+ WorkflowsCustomEndpoint string
EnableBetaResources bool
Experiments []string
diff --git a/stackit/internal/features/experiments.go b/stackit/internal/features/experiments.go
index aea97d293..6abe16971 100644
--- a/stackit/internal/features/experiments.go
+++ b/stackit/internal/features/experiments.go
@@ -17,9 +17,10 @@ const (
NetworkExperiment = "network"
IamExperiment = "iam"
DremioExperiment = "dremio"
+ WorkflowsExperiment = "workflows"
)
-var AvailableExperiments = []string{DremioExperiment, IamExperiment, RoutingTablesExperiment, NetworkExperiment}
+var AvailableExperiments = []string{DremioExperiment, IamExperiment, NetworkExperiment, RoutingTablesExperiment, WorkflowsExperiment}
// Check if an experiment is valid.
func ValidExperiment(experiment string, diags *diag.Diagnostics) bool {
diff --git a/stackit/internal/services/workflows/dagbundle/datasource.go b/stackit/internal/services/workflows/dagbundle/datasource.go
new file mode 100644
index 000000000..0eda5cc19
--- /dev/null
+++ b/stackit/internal/services/workflows/dagbundle/datasource.go
@@ -0,0 +1,161 @@
+package dagbundle
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
+ workflowsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/utils"
+ tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+var _ datasource.DataSource = &dagBundleDataSource{}
+
+func NewWorkflowsDagBundleDataSource() datasource.DataSource {
+ return &dagBundleDataSource{}
+}
+
+type dagBundleDataSource struct {
+ client *workflows.APIClient
+ providerData core.ProviderData
+}
+
+func (d *dagBundleDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_workflows_dag_bundle"
+}
+
+func (d *dagBundleDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
+ if !ok {
+ return
+ }
+ d.providerData = providerData
+
+ features.CheckExperimentEnabled(ctx, &d.providerData, features.WorkflowsExperiment, "stackit_workflows_dag_bundle", core.Datasource, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ apiClient := workflowsUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ d.client = apiClient
+}
+
+func (d *dagBundleDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ description := fmt.Sprintf("Workflows DAG bundle data source. %s", core.DatasourceRegionFallbackDocstring)
+ resp.Schema = schema.Schema{
+ Description: description,
+ MarkdownDescription: features.AddExperimentDescription(description, features.WorkflowsExperiment, core.Datasource),
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{Description: schemaDescriptions["id"], Computed: true},
+ "project_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Required: true,
+ Validators: []validator.String{validate.UUID(), validate.NoSeparator()},
+ },
+ "region": schema.StringAttribute{Description: schemaDescriptions["region"], Optional: true, Computed: true},
+ "instance_id": schema.StringAttribute{
+ Description: schemaDescriptions["instance_id"],
+ Required: true,
+ Validators: []validator.String{validate.UUID(), validate.NoSeparator()},
+ },
+ "name": schema.StringAttribute{Description: schemaDescriptions["name"], Required: true},
+ "git": schema.SingleNestedAttribute{
+ Description: "Git-backed DAG bundle source. Populated when the bundle is git-typed.",
+ Computed: true,
+ Attributes: map[string]schema.Attribute{
+ "url": schema.StringAttribute{Description: schemaDescriptions["git.url"], Computed: true},
+ "branch": schema.StringAttribute{Description: schemaDescriptions["git.branch"], Computed: true},
+ "subdir": schema.StringAttribute{Description: schemaDescriptions["git.subdir"], Computed: true},
+ "refresh_interval": schema.Int32Attribute{Description: schemaDescriptions["git.refresh_interval"], Computed: true},
+ "auth": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["git.auth"],
+ Computed: true,
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{Description: schemaDescriptions["git.auth.type"], Computed: true},
+ "username": schema.StringAttribute{Description: schemaDescriptions["git.auth.username"], Computed: true},
+ "password": schema.StringAttribute{Description: "Git password or PAT. Never returned by the API; always null when read.", Computed: true, Sensitive: true},
+ },
+ },
+ },
+ },
+ "s3": schema.SingleNestedAttribute{
+ Description: "S3-backed DAG bundle source. Populated when the bundle is s3-typed.",
+ Computed: true,
+ Attributes: map[string]schema.Attribute{
+ "bucket_name": schema.StringAttribute{Description: schemaDescriptions["s3.bucket_name"], Computed: true},
+ "endpoint": schema.StringAttribute{Description: schemaDescriptions["s3.endpoint"], Computed: true},
+ "prefix": schema.StringAttribute{Description: schemaDescriptions["s3.prefix"], Computed: true},
+ "refresh_interval": schema.Int32Attribute{Description: schemaDescriptions["s3.refresh_interval"], Computed: true},
+ "auth": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["s3.auth"],
+ Computed: true,
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{Description: schemaDescriptions["s3.auth.type"], Computed: true},
+ "access_key_id": schema.StringAttribute{Description: schemaDescriptions["s3.auth.access_key_id"], Computed: true},
+ "secret_access_key": schema.StringAttribute{Description: "S3 secret access key. Never returned by the API; always null when read.", Computed: true, Sensitive: true},
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func (d *dagBundleDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := model.ProjectID.ValueString()
+ region := d.providerData.GetRegionWithOverride(model.Region)
+ instanceID := model.InstanceID.ValueString()
+ name := model.Name.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "instance_id", instanceID)
+ ctx = tflog.SetField(ctx, "bundle_name", name)
+
+ bundle, err := d.client.DefaultAPI.GetDagBundle(ctx, projectID, region, instanceID, name).Execute()
+ if err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ tfutils.LogError(
+ ctx, &resp.Diagnostics, err,
+ "Error reading Workflows DAG bundle",
+ fmt.Sprintf("Bundle %q does not exist on instance %q.", name, instanceID),
+ map[int]string{http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectID)},
+ )
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ if err := mapFields(ctx, bundle, &model, region); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Workflows DAG bundle", fmt.Sprintf("Processing response: %v", err))
+ return
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
+ tflog.Debug(ctx, "Workflows DAG bundle read")
+}
diff --git a/stackit/internal/services/workflows/dagbundle/resource.go b/stackit/internal/services/workflows/dagbundle/resource.go
new file mode 100644
index 000000000..cc446a9ac
--- /dev/null
+++ b/stackit/internal/services/workflows/dagbundle/resource.go
@@ -0,0 +1,1109 @@
+package dagbundle
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "math"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/int32validator"
+ "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/int32planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+ "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi/wait"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
+ workflowsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/utils"
+ tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+const (
+ gitAuthTypeBasic = "basic"
+ gitAuthTypeNone = "none"
+ s3AuthTypeAccessKey = "access_key"
+ s3AuthTypeNone = "none"
+)
+
+var bundleNamePattern = regexp.MustCompile(`^[a-z][a-z0-9-]{0,62}$`)
+
+var (
+ _ resource.Resource = &dagBundleResource{}
+ _ resource.ResourceWithConfigure = &dagBundleResource{}
+ _ resource.ResourceWithImportState = &dagBundleResource{}
+ _ resource.ResourceWithModifyPlan = &dagBundleResource{}
+ _ resource.ResourceWithValidateConfig = &dagBundleResource{}
+)
+
+var schemaDescriptions = map[string]string{
+ "id": "Terraform's internal resource identifier. It is structured as \"`project_id`,`region`,`instance_id`,`name`\".",
+ "name": "Bundle name. Must be a DNS label: lowercase alphanumeric and hyphens, starting with a letter, max 63 characters. Immutable.",
+ "region": "STACKIT region name the resource is located in. If not defined, the provider region is used.",
+ "project_id": "STACKIT project ID associated with the Workflows instance.",
+ "instance_id": "ID of the Workflows instance this bundle belongs to.",
+ "git": "Git-backed DAG bundle source. Exactly one of `git` or `s3` must be set.",
+ "s3": "S3-backed DAG bundle source. Exactly one of `git` or `s3` must be set.",
+ "git.url": "Git repository URL.",
+ "git.branch": "Branch, tag, or ref to track.",
+ "git.subdir": "Optional subdirectory inside the Git repository that contains the DAGs. Leading/trailing slashes are stripped by both the server and provider.",
+ "git.refresh_interval": "How often (in seconds) the bundle is re-scanned for changes.",
+ "git.auth": "Authentication for the Git source.",
+ "git.auth.type": "Authentication scheme: `basic` (username + password) or `none` (public repos).",
+ "git.auth.username": "Git username. Required when `git.auth.type = basic`.",
+ "git.auth.password": "Git password or personal access token. Required when `git.auth.type = basic`. Sensitive. The API never returns this value back.",
+ "s3.bucket_name": "S3 bucket name containing the DAGs.",
+ "s3.endpoint": "S3-compatible endpoint URL. Defaults to STACKIT Object Storage in the region.",
+ "s3.prefix": "Optional key prefix inside the bucket.",
+ "s3.refresh_interval": "How often (in seconds) the bundle is re-scanned for changes.",
+ "s3.auth": "Authentication for the S3 source.",
+ "s3.auth.type": "Authentication scheme: `access_key` or `none` (public buckets).",
+ "s3.auth.access_key_id": "S3 access key ID. Required when `s3.auth.type = access_key`.",
+ "s3.auth.secret_access_key": "S3 secret access key. Required when `s3.auth.type = access_key`. Sensitive. The API never returns this value back.",
+}
+
+type Model struct {
+ ID types.String `tfsdk:"id"`
+ ProjectID types.String `tfsdk:"project_id"`
+ Region types.String `tfsdk:"region"`
+ InstanceID types.String `tfsdk:"instance_id"`
+ Name types.String `tfsdk:"name"`
+ Git types.Object `tfsdk:"git"`
+ S3 types.Object `tfsdk:"s3"`
+}
+
+type gitModel struct {
+ URL types.String `tfsdk:"url"`
+ Branch types.String `tfsdk:"branch"`
+ Subdir types.String `tfsdk:"subdir"`
+ RefreshInterval types.Int32 `tfsdk:"refresh_interval"`
+ Auth types.Object `tfsdk:"auth"`
+}
+
+type gitAuthModel struct {
+ Type types.String `tfsdk:"type"`
+ Username types.String `tfsdk:"username"`
+ Password types.String `tfsdk:"password"`
+}
+
+type s3Model struct {
+ BucketName types.String `tfsdk:"bucket_name"`
+ Endpoint types.String `tfsdk:"endpoint"`
+ Prefix types.String `tfsdk:"prefix"`
+ RefreshInterval types.Int32 `tfsdk:"refresh_interval"`
+ Auth types.Object `tfsdk:"auth"`
+}
+
+type s3AuthModel struct {
+ Type types.String `tfsdk:"type"`
+ AccessKeyID types.String `tfsdk:"access_key_id"`
+ SecretAccessKey types.String `tfsdk:"secret_access_key"`
+}
+
+var gitAuthTypes = map[string]attr.Type{
+ "type": basetypes.StringType{},
+ "username": basetypes.StringType{},
+ "password": basetypes.StringType{},
+}
+
+var s3AuthTypes = map[string]attr.Type{
+ "type": basetypes.StringType{},
+ "access_key_id": basetypes.StringType{},
+ "secret_access_key": basetypes.StringType{},
+}
+
+var gitTypes = map[string]attr.Type{
+ "url": basetypes.StringType{},
+ "branch": basetypes.StringType{},
+ "subdir": basetypes.StringType{},
+ "refresh_interval": basetypes.Int32Type{},
+ "auth": basetypes.ObjectType{AttrTypes: gitAuthTypes},
+}
+
+var s3Types = map[string]attr.Type{
+ "bucket_name": basetypes.StringType{},
+ "endpoint": basetypes.StringType{},
+ "prefix": basetypes.StringType{},
+ "refresh_interval": basetypes.Int32Type{},
+ "auth": basetypes.ObjectType{AttrTypes: s3AuthTypes},
+}
+
+type dagBundleResource struct {
+ client *workflows.APIClient
+ providerData core.ProviderData
+}
+
+func NewWorkflowsDagBundleResource() resource.Resource {
+ return &dagBundleResource{}
+}
+
+func (r *dagBundleResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_workflows_dag_bundle"
+}
+
+func (r *dagBundleResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
+ if !ok {
+ return
+ }
+ r.providerData = providerData
+
+ features.CheckExperimentEnabled(ctx, &r.providerData, features.WorkflowsExperiment, "stackit_workflows_dag_bundle", core.Resource, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ apiClient := workflowsUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ r.client = apiClient
+}
+
+func (r *dagBundleResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { //nolint:gocritic // function signature required by Terraform
+ if req.Config.Raw.IsNull() {
+ return
+ }
+ var configModel Model
+ resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ var planModel Model
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tfutils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
+}
+
+func (r *dagBundleResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+ var model Model
+ resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ validateBundleConfig(ctx, &model, &resp.Diagnostics)
+}
+
+func validateBundleConfig(ctx context.Context, model *Model, diags *diag.Diagnostics) {
+ gitSet := isSetObj(model.Git)
+ s3Set := isSetObj(model.S3)
+ gitUnset := model.Git.IsNull()
+ s3Unset := model.S3.IsNull()
+
+ // Exactly one source block must be set. Unknown values defer (the variant
+ // will be known by apply time).
+ switch {
+ case gitUnset && s3Unset:
+ diags.AddError(
+ "Invalid Workflows DAG bundle config",
+ "Exactly one of `git` or `s3` must be set.",
+ )
+ return
+ case gitSet && s3Set:
+ diags.AddError(
+ "Invalid Workflows DAG bundle config",
+ "Only one of `git` or `s3` may be set, not both.",
+ )
+ return
+ }
+
+ if gitSet {
+ validateGitBlock(ctx, model.Git, diags)
+ }
+ if s3Set {
+ validateS3Block(ctx, model.S3, diags)
+ }
+}
+
+func isSetObj(o types.Object) bool { return !o.IsNull() && !o.IsUnknown() }
+
+func validateGitBlock(ctx context.Context, gitObj types.Object, diags *diag.Diagnostics) {
+ var gm gitModel
+ if d := gitObj.As(ctx, &gm, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true}); d.HasError() {
+ return
+ }
+ // The server stores subdir without leading/trailing slashes. Rejecting them
+ // here keeps state/config in sync — Terraform forbids ModifyPlan from
+ // rewriting user-provided values, so the canonical form must come from the
+ // user.
+ if !gm.Subdir.IsNull() && !gm.Subdir.IsUnknown() {
+ v := gm.Subdir.ValueString()
+ if v != "" && v != strings.Trim(v, "/") {
+ diags.AddError(
+ "Invalid Workflows DAG bundle config",
+ fmt.Sprintf("git.subdir %q must not have leading or trailing slashes; use %q.", v, strings.Trim(v, "/")),
+ )
+ }
+ }
+ if gm.Auth.IsNull() || gm.Auth.IsUnknown() {
+ return
+ }
+ validateGitAuth(ctx, gm.Auth, diags)
+}
+
+func validateS3Block(ctx context.Context, s3Obj types.Object, diags *diag.Diagnostics) {
+ var sm s3Model
+ if d := s3Obj.As(ctx, &sm, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true}); d.HasError() {
+ return
+ }
+ if sm.Auth.IsNull() || sm.Auth.IsUnknown() {
+ return
+ }
+ validateS3Auth(ctx, sm.Auth, diags)
+}
+
+func validateGitAuth(ctx context.Context, authObj types.Object, diags *diag.Diagnostics) {
+ var am gitAuthModel
+ if d := authObj.As(ctx, &am, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true}); d.HasError() {
+ return
+ }
+ if am.Type.IsNull() || am.Type.IsUnknown() {
+ return
+ }
+ switch am.Type.ValueString() {
+ case gitAuthTypeBasic:
+ if isEmptyStr(am.Username) {
+ diags.AddError("Invalid Workflows DAG bundle config", "git.auth.username is required when git.auth.type = basic.")
+ }
+ if isEmptyStr(am.Password) {
+ diags.AddError("Invalid Workflows DAG bundle config", "git.auth.password is required when git.auth.type = basic.")
+ }
+ case gitAuthTypeNone:
+ if isNonEmptyStr(am.Username) || isNonEmptyStr(am.Password) {
+ diags.AddError("Invalid Workflows DAG bundle config", "git.auth.username/git.auth.password must not be set when git.auth.type = none.")
+ }
+ }
+}
+
+func validateS3Auth(ctx context.Context, authObj types.Object, diags *diag.Diagnostics) {
+ var sm s3AuthModel
+ if d := authObj.As(ctx, &sm, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true}); d.HasError() {
+ return
+ }
+ if sm.Type.IsNull() || sm.Type.IsUnknown() {
+ return
+ }
+ switch sm.Type.ValueString() {
+ case s3AuthTypeAccessKey:
+ if isEmptyStr(sm.AccessKeyID) {
+ diags.AddError("Invalid Workflows DAG bundle config", "s3.auth.access_key_id is required when s3.auth.type = access_key.")
+ }
+ if isEmptyStr(sm.SecretAccessKey) {
+ diags.AddError("Invalid Workflows DAG bundle config", "s3.auth.secret_access_key is required when s3.auth.type = access_key.")
+ }
+ case s3AuthTypeNone:
+ if isNonEmptyStr(sm.AccessKeyID) || isNonEmptyStr(sm.SecretAccessKey) {
+ diags.AddError("Invalid Workflows DAG bundle config", "s3.auth.access_key_id/s3.auth.secret_access_key must not be set when s3.auth.type = none.")
+ }
+ }
+}
+
+// isEmptyStr is the must-BE-set check used inside auth blocks: the empty
+// literal is treated as "absent" because the server rejects empty credentials
+// the same way as missing ones. Unknown defers.
+func isEmptyStr(s types.String) bool {
+ if s.IsUnknown() {
+ return false
+ }
+ return s.IsNull() || s.ValueString() == ""
+}
+
+func isNonEmptyStr(s types.String) bool {
+ return !s.IsNull() && !s.IsUnknown() && s.ValueString() != ""
+}
+
+func (r *dagBundleResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ description := fmt.Sprintf("Workflows DAG bundle resource (Airflow 3 only). Bundle CRUD is synchronous server-side. %s", core.ResourceRegionFallbackDocstring)
+ resp.Schema = schema.Schema{
+ Description: description,
+ MarkdownDescription: features.AddExperimentDescription(description, features.WorkflowsExperiment, core.Resource),
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: schemaDescriptions["id"],
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "project_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Required: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ },
+ "region": schema.StringAttribute{
+ Description: schemaDescriptions["region"],
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "instance_id": schema.StringAttribute{
+ Description: schemaDescriptions["instance_id"],
+ Required: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ },
+ "name": schema.StringAttribute{
+ Description: schemaDescriptions["name"],
+ Required: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ Validators: []validator.String{
+ stringvalidator.RegexMatches(bundleNamePattern, "must be a DNS label: lowercase alphanumeric and hyphens, starting with a letter, max 63 characters"),
+ },
+ },
+ "git": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["git"],
+ Optional: true,
+ PlanModifiers: []planmodifier.Object{
+ objectplanmodifier.UseStateForUnknown(),
+ },
+ Attributes: map[string]schema.Attribute{
+ "url": schema.StringAttribute{
+ Description: schemaDescriptions["git.url"],
+ Required: true,
+ Validators: []validator.String{
+ workflowsUtils.URL(),
+ },
+ },
+ "branch": schema.StringAttribute{
+ Description: schemaDescriptions["git.branch"],
+ Required: true,
+ },
+ "subdir": schema.StringAttribute{
+ Description: schemaDescriptions["git.subdir"],
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "refresh_interval": schema.Int32Attribute{
+ Description: schemaDescriptions["git.refresh_interval"],
+ Optional: true,
+ Computed: true,
+ Validators: []validator.Int32{
+ int32validator.Between(10, math.MaxInt32),
+ },
+ PlanModifiers: []planmodifier.Int32{
+ int32planmodifier.UseStateForUnknown(),
+ },
+ },
+ "auth": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["git.auth"],
+ Required: true,
+ PlanModifiers: []planmodifier.Object{
+ objectplanmodifier.UseStateForUnknown(),
+ },
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{
+ Description: schemaDescriptions["git.auth.type"],
+ Required: true,
+ Validators: []validator.String{
+ stringvalidator.OneOf(gitAuthTypeBasic, gitAuthTypeNone),
+ },
+ },
+ "username": schema.StringAttribute{
+ Description: schemaDescriptions["git.auth.username"],
+ Optional: true,
+ },
+ "password": schema.StringAttribute{
+ Description: schemaDescriptions["git.auth.password"],
+ Optional: true,
+ Sensitive: true,
+ },
+ },
+ },
+ },
+ },
+ "s3": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["s3"],
+ Optional: true,
+ PlanModifiers: []planmodifier.Object{
+ objectplanmodifier.UseStateForUnknown(),
+ },
+ Attributes: map[string]schema.Attribute{
+ "bucket_name": schema.StringAttribute{
+ Description: schemaDescriptions["s3.bucket_name"],
+ Required: true,
+ },
+ "endpoint": schema.StringAttribute{
+ Description: schemaDescriptions["s3.endpoint"],
+ Optional: true,
+ Validators: []validator.String{
+ workflowsUtils.URL(),
+ },
+ },
+ "prefix": schema.StringAttribute{
+ Description: schemaDescriptions["s3.prefix"],
+ Optional: true,
+ },
+ "refresh_interval": schema.Int32Attribute{
+ Description: schemaDescriptions["s3.refresh_interval"],
+ Optional: true,
+ Computed: true,
+ Validators: []validator.Int32{
+ int32validator.Between(10, math.MaxInt32),
+ },
+ PlanModifiers: []planmodifier.Int32{
+ int32planmodifier.UseStateForUnknown(),
+ },
+ },
+ "auth": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["s3.auth"],
+ Required: true,
+ PlanModifiers: []planmodifier.Object{
+ objectplanmodifier.UseStateForUnknown(),
+ },
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{
+ Description: schemaDescriptions["s3.auth.type"],
+ Required: true,
+ Validators: []validator.String{
+ stringvalidator.OneOf(s3AuthTypeAccessKey, s3AuthTypeNone),
+ },
+ },
+ "access_key_id": schema.StringAttribute{
+ Description: schemaDescriptions["s3.auth.access_key_id"],
+ Optional: true,
+ },
+ "secret_access_key": schema.StringAttribute{
+ Description: schemaDescriptions["s3.auth.secret_access_key"],
+ Optional: true,
+ Sensitive: true,
+ },
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+func (r *dagBundleResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := model.ProjectID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ instanceID := model.InstanceID.ValueString()
+ name := model.Name.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "instance_id", instanceID)
+ ctx = tflog.SetField(ctx, "bundle_name", name)
+
+ payload, err := toCreatePayload(ctx, &model)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Workflows DAG bundle", fmt.Sprintf("Building payload: %v", err))
+ return
+ }
+
+ createResp, err := r.client.DefaultAPI.CreateDagBundle(ctx, projectID, region, instanceID).CreateDagBundlePayload(*payload).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Workflows DAG bundle", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ // Persist composite ID before mapFields can fail. The bundle is on the
+ // server; without this, a mapFields error orphans it (the next apply hits
+ // the RequiresReplace `name` field and gets a duplicate-name error).
+ ctx = tfutils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
+ "project_id": projectID,
+ "region": region,
+ "instance_id": instanceID,
+ "name": name,
+ })
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if err := mapFields(ctx, createResp, &model, region); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Workflows DAG bundle", fmt.Sprintf("Processing response: %v", err))
+ return
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Bundle CRUD is synchronous, but the parent instance briefly enters
+ // `updating` while the change is reconciled; wait for it to settle.
+ if _, err := wait.UpdateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectID, region, instanceID).WaitWithContext(ctx); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Workflows DAG bundle", fmt.Sprintf("Waiting for instance to settle: %v", err))
+ return
+ }
+ tflog.Debug(ctx, "Workflows DAG bundle created")
+}
+
+func (r *dagBundleResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.State.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := model.ProjectID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ instanceID := model.InstanceID.ValueString()
+ name := model.Name.ValueString()
+
+ if name == "" {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "instance_id", instanceID)
+ ctx = tflog.SetField(ctx, "bundle_name", name)
+
+ bundle, err := r.client.DefaultAPI.GetDagBundle(ctx, projectID, region, instanceID, name).Execute()
+ if err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Workflows DAG bundle", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ if err := mapFields(ctx, bundle, &model, region); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Workflows DAG bundle", fmt.Sprintf("Processing response: %v", err))
+ return
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
+ tflog.Debug(ctx, "Workflows DAG bundle read")
+}
+
+func (r *dagBundleResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { //nolint:gocritic // function signature required by Terraform
+ var plan, state Model
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := plan.ProjectID.ValueString()
+ region := r.providerData.GetRegionWithOverride(plan.Region)
+ instanceID := plan.InstanceID.ValueString()
+ name := plan.Name.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "instance_id", instanceID)
+ ctx = tflog.SetField(ctx, "bundle_name", name)
+
+ payload, err := toUpdatePayload(ctx, &plan, &state)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows DAG bundle", fmt.Sprintf("Building payload: %v", err))
+ return
+ }
+
+ updateResp, err := r.client.DefaultAPI.UpdateDagBundle(ctx, projectID, region, instanceID, name).UpdateDagBundlePayload(*payload).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows DAG bundle", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ if err := mapFields(ctx, updateResp, &plan, region); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows DAG bundle", fmt.Sprintf("Processing response: %v", err))
+ return
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ if _, err := wait.UpdateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectID, region, instanceID).WaitWithContext(ctx); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows DAG bundle", fmt.Sprintf("Waiting for instance to settle: %v", err))
+ return
+ }
+ tflog.Debug(ctx, "Workflows DAG bundle updated")
+}
+
+func (r *dagBundleResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.State.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := model.ProjectID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ instanceID := model.InstanceID.ValueString()
+ name := model.Name.ValueString()
+
+ if err := r.client.DefaultAPI.DeleteDagBundle(ctx, projectID, region, instanceID, name).Execute(); err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ // Tolerate 404: the bundle may have already been deleted (e.g. if the
+ // parent instance is being torn down and cascaded the delete).
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Workflows DAG bundle", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ // Bundle is gone server-side; remove from state now so a wait failure
+ // below does not leave Terraform thinking the bundle still exists.
+ resp.State.RemoveResource(ctx)
+
+ if _, err := wait.UpdateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectID, region, instanceID).WaitWithContext(ctx); err != nil {
+ // If the parent instance itself is gone, the bundle is gone too.
+ var oapiErr *oapierror.GenericOpenAPIError
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ tflog.Debug(ctx, "Workflows DAG bundle deleted (parent instance gone)")
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Workflows DAG bundle", fmt.Sprintf("Waiting for instance to settle: %v", err))
+ return
+ }
+ tflog.Debug(ctx, "Workflows DAG bundle deleted")
+}
+
+// The expected format of the resource import identifier is: project_id,region,instance_id,name
+func (r *dagBundleResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ idParts := strings.Split(req.ID, core.Separator)
+ if len(idParts) != 4 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" || idParts[3] == "" {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing Workflows DAG bundle", fmt.Sprintf("Invalid import ID %q: expected format is `project_id`,`region`,`instance_id`,`name`", req.ID))
+ return
+ }
+ // Cheap precondition: project_id and instance_id must be UUIDs. Caught
+ // here so a typo in the import string fails fast instead of returning
+ // an opaque server error on the subsequent Read.
+ if !uuidRE.MatchString(idParts[0]) {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing Workflows DAG bundle", fmt.Sprintf("Invalid import ID %q: project_id segment %q is not a UUID", req.ID, idParts[0]))
+ return
+ }
+ if !uuidRE.MatchString(idParts[2]) {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing Workflows DAG bundle", fmt.Sprintf("Invalid import ID %q: instance_id segment %q is not a UUID", req.ID, idParts[2]))
+ return
+ }
+ ctx = tfutils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
+ "project_id": idParts[0],
+ "region": idParts[1],
+ "instance_id": idParts[2],
+ "name": idParts[3],
+ })
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Debug(ctx, "Workflows DAG bundle state imported")
+}
+
+var uuidRE = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+
+func toCreatePayload(ctx context.Context, model *Model) (*workflows.CreateDagBundlePayload, error) {
+ switch {
+ case !model.Git.IsNull() && !model.Git.IsUnknown():
+ git, err := buildGitDagBundle(ctx, model)
+ if err != nil {
+ return nil, err
+ }
+ wrapped := workflows.GitDagBundleAsCreateDagBundlePayload(git)
+ return &wrapped, nil
+ case !model.S3.IsNull() && !model.S3.IsUnknown():
+ s3, err := buildS3DagBundle(ctx, model)
+ if err != nil {
+ return nil, err
+ }
+ wrapped := workflows.S3DagBundleAsCreateDagBundlePayload(s3)
+ return &wrapped, nil
+ default:
+ return nil, errors.New("exactly one of `git` or `s3` must be set")
+ }
+}
+
+func toUpdatePayload(ctx context.Context, plan, state *Model) (*workflows.UpdateDagBundlePayload, error) {
+ switch {
+ case !plan.Git.IsNull() && !plan.Git.IsUnknown():
+ patch, err := toUpdateGitDagBundlePayload(ctx, plan, state)
+ if err != nil {
+ return nil, err
+ }
+ wrapped := workflows.UpdateGitDagBundlePayloadAsUpdateDagBundlePayload(patch)
+ return &wrapped, nil
+ case !plan.S3.IsNull() && !plan.S3.IsUnknown():
+ patch, err := toUpdateS3DagBundlePayload(ctx, plan, state)
+ if err != nil {
+ return nil, err
+ }
+ wrapped := workflows.UpdateS3DagBundlePayloadAsUpdateDagBundlePayload(patch)
+ return &wrapped, nil
+ default:
+ return nil, errors.New("exactly one of `git` or `s3` must be set")
+ }
+}
+
+func extractGitModel(ctx context.Context, m *Model) (*gitModel, error) {
+ var gm gitModel
+ diags := m.Git.As(ctx, &gm, basetypes.ObjectAsOptions{})
+ if diags.HasError() {
+ return nil, fmt.Errorf("converting git object: %w", core.DiagsToError(diags))
+ }
+ return &gm, nil
+}
+
+func extractS3Model(ctx context.Context, m *Model) (*s3Model, error) {
+ var sm s3Model
+ diags := m.S3.As(ctx, &sm, basetypes.ObjectAsOptions{})
+ if diags.HasError() {
+ return nil, fmt.Errorf("converting s3 object: %w", core.DiagsToError(diags))
+ }
+ return &sm, nil
+}
+
+func buildGitDagBundle(ctx context.Context, model *Model) (*workflows.GitDagBundle, error) {
+ gm, err := extractGitModel(ctx, model)
+ if err != nil {
+ return nil, err
+ }
+ if gm.URL.IsNull() || gm.URL.IsUnknown() {
+ return nil, errors.New("git.url is required")
+ }
+ if gm.Branch.IsNull() || gm.Branch.IsUnknown() {
+ return nil, errors.New("git.branch is required")
+ }
+ auth, err := buildGitAuth(ctx, gm)
+ if err != nil {
+ return nil, err
+ }
+ bundle := &workflows.GitDagBundle{
+ Type: workflows.GITDAGBUNDLETYPE_GIT,
+ Name: model.Name.ValueString(),
+ Url: gm.URL.ValueString(),
+ Branch: gm.Branch.ValueString(),
+ Subdir: conversion.StringValueToPointer(gm.Subdir),
+ RefreshInterval: conversion.Int32ValueToPointer(gm.RefreshInterval),
+ Auth: *auth,
+ }
+ return bundle, nil
+}
+
+func buildS3DagBundle(ctx context.Context, model *Model) (*workflows.S3DagBundle, error) {
+ sm, err := extractS3Model(ctx, model)
+ if err != nil {
+ return nil, err
+ }
+ if sm.BucketName.IsNull() || sm.BucketName.IsUnknown() {
+ return nil, errors.New("s3.bucket_name is required")
+ }
+ auth, err := buildS3Auth(ctx, sm)
+ if err != nil {
+ return nil, err
+ }
+ bundle := &workflows.S3DagBundle{
+ Type: workflows.S3DAGBUNDLETYPE_S3,
+ Name: model.Name.ValueString(),
+ BucketName: sm.BucketName.ValueString(),
+ Endpoint: conversion.StringValueToPointer(sm.Endpoint),
+ Prefix: conversion.StringValueToPointer(sm.Prefix),
+ RefreshInterval: conversion.Int32ValueToPointer(sm.RefreshInterval),
+ S3Auth: *auth,
+ }
+ return bundle, nil
+}
+
+// toUpdateGitDagBundlePayload builds a PATCH payload. Subdir uses "empty
+// string clears" semantics. Auth is only attached when set in the plan — when
+// omitted, the server leaves the stored credentials untouched, which lets the
+// user update other fields without rotating the password every time.
+func toUpdateGitDagBundlePayload(ctx context.Context, plan, state *Model) (*workflows.UpdateGitDagBundlePayload, error) {
+ gm, err := extractGitModel(ctx, plan)
+ if err != nil {
+ return nil, err
+ }
+ var prior gitModel
+ if !state.Git.IsNull() && !state.Git.IsUnknown() {
+ if d := state.Git.As(ctx, &prior, basetypes.ObjectAsOptions{}); d.HasError() {
+ return nil, fmt.Errorf("converting prior git object: %w", core.DiagsToError(d))
+ }
+ }
+ patch := &workflows.UpdateGitDagBundlePayload{
+ Type: workflows.UPDATEGITDAGBUNDLEPAYLOADTYPE_GIT,
+ Url: conversion.StringValueToPointer(gm.URL),
+ Branch: conversion.StringValueToPointer(gm.Branch),
+ Subdir: conversion.ClearableString(gm.Subdir, prior.Subdir),
+ RefreshInterval: conversion.Int32ValueToPointer(gm.RefreshInterval),
+ }
+ if !gm.Auth.IsNull() && !gm.Auth.IsUnknown() {
+ auth, err := buildGitAuth(ctx, gm)
+ if err != nil {
+ return nil, err
+ }
+ patch.Auth = auth
+ }
+ return patch, nil
+}
+
+// toUpdateS3DagBundlePayload builds a PATCH payload. Endpoint and prefix use
+// "empty string clears" semantics. Auth is only attached when set (see
+// toUpdateGitDagBundlePayload for rationale).
+func toUpdateS3DagBundlePayload(ctx context.Context, plan, state *Model) (*workflows.UpdateS3DagBundlePayload, error) {
+ sm, err := extractS3Model(ctx, plan)
+ if err != nil {
+ return nil, err
+ }
+ var prior s3Model
+ if !state.S3.IsNull() && !state.S3.IsUnknown() {
+ if d := state.S3.As(ctx, &prior, basetypes.ObjectAsOptions{}); d.HasError() {
+ return nil, fmt.Errorf("converting prior s3 object: %w", core.DiagsToError(d))
+ }
+ }
+ patch := &workflows.UpdateS3DagBundlePayload{
+ Type: workflows.UPDATES3DAGBUNDLEPAYLOADTYPE_S3,
+ BucketName: conversion.StringValueToPointer(sm.BucketName),
+ Endpoint: conversion.ClearableString(sm.Endpoint, prior.Endpoint),
+ Prefix: conversion.ClearableString(sm.Prefix, prior.Prefix),
+ RefreshInterval: conversion.Int32ValueToPointer(sm.RefreshInterval),
+ }
+ if !sm.Auth.IsNull() && !sm.Auth.IsUnknown() {
+ auth, err := buildS3Auth(ctx, sm)
+ if err != nil {
+ return nil, err
+ }
+ patch.S3Auth = auth
+ }
+ return patch, nil
+}
+
+func buildGitAuth(ctx context.Context, gm *gitModel) (*workflows.GitAuth, error) {
+ if gm.Auth.IsNull() || gm.Auth.IsUnknown() {
+ return nil, errors.New("git.auth is required")
+ }
+ var am gitAuthModel
+ diags := gm.Auth.As(ctx, &am, basetypes.ObjectAsOptions{})
+ if diags.HasError() {
+ return nil, fmt.Errorf("converting git auth object: %w", core.DiagsToError(diags))
+ }
+
+ switch am.Type.ValueString() {
+ case gitAuthTypeBasic:
+ basic := &workflows.BasicAuth{
+ Type: conversion.StringValueToPointer(am.Type),
+ Username: conversion.StringValueToPointer(am.Username),
+ Password: conversion.StringValueToPointer(am.Password),
+ }
+ wrapped := workflows.BasicAuthAsGitAuth(basic)
+ return &wrapped, nil
+ case gitAuthTypeNone:
+ none := &workflows.NoAuth{
+ Type: conversion.StringValueToPointer(am.Type),
+ }
+ wrapped := workflows.NoAuthAsGitAuth(none)
+ return &wrapped, nil
+ default:
+ return nil, fmt.Errorf("unsupported git.auth.type %q", am.Type.ValueString())
+ }
+}
+
+func buildS3Auth(ctx context.Context, sm *s3Model) (*workflows.S3Auth, error) {
+ if sm.Auth.IsNull() || sm.Auth.IsUnknown() {
+ return nil, errors.New("s3.auth is required")
+ }
+ var am s3AuthModel
+ diags := sm.Auth.As(ctx, &am, basetypes.ObjectAsOptions{})
+ if diags.HasError() {
+ return nil, fmt.Errorf("converting s3 auth object: %w", core.DiagsToError(diags))
+ }
+
+ switch am.Type.ValueString() {
+ case s3AuthTypeAccessKey:
+ ak := &workflows.S3AccessKeyAuth{
+ Type: workflows.S3ACCESSKEYAUTHTYPE_ACCESS_KEY,
+ AccessKeyId: am.AccessKeyID.ValueString(),
+ SecretAccessKey: am.SecretAccessKey.ValueString(),
+ }
+ wrapped := workflows.S3AccessKeyAuthAsS3Auth(ak)
+ return &wrapped, nil
+ case s3AuthTypeNone:
+ none := &workflows.NoAuth{
+ Type: conversion.StringValueToPointer(am.Type),
+ }
+ wrapped := workflows.NoAuthAsS3Auth(none)
+ return &wrapped, nil
+ default:
+ return nil, fmt.Errorf("unsupported s3.auth.type %q", am.Type.ValueString())
+ }
+}
+
+// mapFields populates `model` from a server response. It carries forward the
+// sensitive password / secret_access_key from the prior plan/state because the
+// API never returns them.
+func mapFields(ctx context.Context, bundle *workflows.DagBundleResponse, model *Model, region string) error {
+ if bundle == nil {
+ return errors.New("bundle is nil")
+ }
+ if model == nil {
+ return errors.New("model is nil")
+ }
+
+ priorPassword := priorGitPassword(ctx, model)
+ priorSecret := priorS3Secret(ctx, model)
+
+ switch {
+ case bundle.GitDagBundleResponse != nil:
+ g := bundle.GitDagBundleResponse
+ model.Name = types.StringValue(g.Name)
+
+ auth := gitAuthModel{Password: priorPassword}
+ switch {
+ case g.Auth.BasicAuthResponse != nil:
+ auth.Type = types.StringValue(gitAuthTypeBasic)
+ auth.Username = types.StringPointerValue(g.Auth.BasicAuthResponse.Username)
+ case g.Auth.NoAuth != nil:
+ auth.Type = types.StringValue(gitAuthTypeNone)
+ default:
+ return errors.New("server returned an unknown git auth variant; upgrade the provider")
+ }
+ authObj, diags := types.ObjectValueFrom(ctx, gitAuthTypes, auth)
+ if diags.HasError() {
+ return fmt.Errorf("mapping git auth: %w", core.DiagsToError(diags))
+ }
+ gitObj, diags := types.ObjectValueFrom(ctx, gitTypes, gitModel{
+ URL: types.StringValue(g.Url),
+ Branch: types.StringValue(g.Branch),
+ Subdir: types.StringPointerValue(g.Subdir),
+ RefreshInterval: types.Int32PointerValue(g.RefreshInterval),
+ Auth: authObj,
+ })
+ if diags.HasError() {
+ return fmt.Errorf("mapping git block: %w", core.DiagsToError(diags))
+ }
+ model.Git = gitObj
+ model.S3 = types.ObjectNull(s3Types)
+
+ case bundle.S3DagBundleResponse != nil:
+ s := bundle.S3DagBundleResponse
+ model.Name = types.StringValue(s.Name)
+
+ auth := s3AuthModel{SecretAccessKey: priorSecret}
+ switch {
+ case s.S3Auth.S3AccessKeyAuthResponse != nil:
+ auth.Type = types.StringValue(s3AuthTypeAccessKey)
+ auth.AccessKeyID = types.StringValue(s.S3Auth.S3AccessKeyAuthResponse.AccessKeyId)
+ case s.S3Auth.NoAuth != nil:
+ auth.Type = types.StringValue(s3AuthTypeNone)
+ default:
+ return errors.New("server returned an unknown s3 auth variant; upgrade the provider")
+ }
+ authObj, diags := types.ObjectValueFrom(ctx, s3AuthTypes, auth)
+ if diags.HasError() {
+ return fmt.Errorf("mapping s3 auth: %w", core.DiagsToError(diags))
+ }
+ s3Obj, diags := types.ObjectValueFrom(ctx, s3Types, s3Model{
+ BucketName: types.StringValue(s.BucketName),
+ Endpoint: types.StringPointerValue(s.Endpoint),
+ Prefix: types.StringPointerValue(s.Prefix),
+ RefreshInterval: types.Int32PointerValue(s.RefreshInterval),
+ Auth: authObj,
+ })
+ if diags.HasError() {
+ return fmt.Errorf("mapping s3 block: %w", core.DiagsToError(diags))
+ }
+ model.S3 = s3Obj
+ model.Git = types.ObjectNull(gitTypes)
+
+ default:
+ return errors.New("server returned an unknown DagBundle variant; upgrade the provider")
+ }
+
+ model.Region = types.StringValue(region)
+ model.ID = tfutils.BuildInternalTerraformId(model.ProjectID.ValueString(), region, model.InstanceID.ValueString(), model.Name.ValueString())
+ return nil
+}
+
+// priorGitPassword extracts the password from the prior model (if any) so the
+// caller can carry it forward — the API never returns the password back.
+func priorGitPassword(ctx context.Context, model *Model) types.String {
+ if model.Git.IsNull() || model.Git.IsUnknown() {
+ return types.StringNull()
+ }
+ var gm gitModel
+ if d := model.Git.As(ctx, &gm, basetypes.ObjectAsOptions{}); d.HasError() {
+ return types.StringNull()
+ }
+ if gm.Auth.IsNull() || gm.Auth.IsUnknown() {
+ return types.StringNull()
+ }
+ var am gitAuthModel
+ if d := gm.Auth.As(ctx, &am, basetypes.ObjectAsOptions{}); d.HasError() {
+ return types.StringNull()
+ }
+ return am.Password
+}
+
+// priorS3Secret extracts the secret_access_key from the prior model (if any)
+// so the caller can carry it forward — the API never returns it back.
+func priorS3Secret(ctx context.Context, model *Model) types.String {
+ if model.S3.IsNull() || model.S3.IsUnknown() {
+ return types.StringNull()
+ }
+ var sm s3Model
+ if d := model.S3.As(ctx, &sm, basetypes.ObjectAsOptions{}); d.HasError() {
+ return types.StringNull()
+ }
+ if sm.Auth.IsNull() || sm.Auth.IsUnknown() {
+ return types.StringNull()
+ }
+ var am s3AuthModel
+ if d := sm.Auth.As(ctx, &am, basetypes.ObjectAsOptions{}); d.HasError() {
+ return types.StringNull()
+ }
+ return am.SecretAccessKey
+}
diff --git a/stackit/internal/services/workflows/dagbundle/resource_test.go b/stackit/internal/services/workflows/dagbundle/resource_test.go
new file mode 100644
index 000000000..d0647afda
--- /dev/null
+++ b/stackit/internal/services/workflows/dagbundle/resource_test.go
@@ -0,0 +1,756 @@
+package dagbundle
+
+import (
+ "context"
+ "strings"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+)
+
+func ptrString(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+}
+
+func mustGitAuth(t *testing.T, am gitAuthModel) basetypes.ObjectValue {
+ t.Helper()
+ o, diags := types.ObjectValueFrom(context.Background(), gitAuthTypes, am)
+ if diags.HasError() {
+ t.Fatalf("building git auth fixture: %v", diags.Errors())
+ }
+ return o
+}
+
+func mustS3Auth(t *testing.T, am s3AuthModel) basetypes.ObjectValue {
+ t.Helper()
+ o, diags := types.ObjectValueFrom(context.Background(), s3AuthTypes, am)
+ if diags.HasError() {
+ t.Fatalf("building s3 auth fixture: %v", diags.Errors())
+ }
+ return o
+}
+
+func mustGit(t *testing.T, gm gitModel) basetypes.ObjectValue {
+ t.Helper()
+ o, diags := types.ObjectValueFrom(context.Background(), gitTypes, gm)
+ if diags.HasError() {
+ t.Fatalf("building git fixture: %v", diags.Errors())
+ }
+ return o
+}
+
+func mustS3(t *testing.T, sm s3Model) basetypes.ObjectValue {
+ t.Helper()
+ o, diags := types.ObjectValueFrom(context.Background(), s3Types, sm)
+ if diags.HasError() {
+ t.Fatalf("building s3 fixture: %v", diags.Errors())
+ }
+ return o
+}
+
+func gitBundleResponse() *workflows.DagBundleResponse {
+ resp := workflows.GitDagBundleResponseAsDagBundleResponse(&workflows.GitDagBundleResponse{
+ Type: workflows.GITDAGBUNDLERESPONSETYPE_GIT,
+ Name: "main-dags",
+ Url: "https://git.example.com/repo.git",
+ Branch: "main",
+ Subdir: sdkUtils.Ptr("dags/"),
+ RefreshInterval: sdkUtils.Ptr(int32(60)),
+ Auth: workflows.BasicAuthResponseAsGitAuthResponse(&workflows.BasicAuthResponse{
+ Type: sdkUtils.Ptr("basic"),
+ Username: sdkUtils.Ptr("git-user"),
+ }),
+ })
+ return &resp
+}
+
+func s3BundleResponse() *workflows.DagBundleResponse {
+ resp := workflows.S3DagBundleResponseAsDagBundleResponse(&workflows.S3DagBundleResponse{
+ Type: workflows.S3DAGBUNDLERESPONSETYPE_S3,
+ Name: "backup-dags",
+ BucketName: "my-bucket",
+ Endpoint: sdkUtils.Ptr("https://object.storage.eu01.onstackit.cloud"),
+ Prefix: sdkUtils.Ptr("dags/"),
+ RefreshInterval: sdkUtils.Ptr(int32(120)),
+ S3Auth: workflows.S3AccessKeyAuthResponseAsS3AuthResponse(&workflows.S3AccessKeyAuthResponse{
+ Type: workflows.S3ACCESSKEYAUTHRESPONSETYPE_ACCESS_KEY,
+ AccessKeyId: "AKIA...",
+ }),
+ })
+ return &resp
+}
+
+func priorGitModel(t *testing.T) *Model {
+ t.Helper()
+ auth := mustGitAuth(t, gitAuthModel{
+ Type: types.StringValue("basic"),
+ Username: types.StringValue("git-user"),
+ Password: types.StringValue("PLANNED-PASSWORD"),
+ })
+ git := mustGit(t, gitModel{
+ URL: types.StringValue("https://git.example.com/repo.git"),
+ Branch: types.StringValue("main"),
+ Subdir: types.StringNull(),
+ RefreshInterval: types.Int32Null(),
+ Auth: auth,
+ })
+ return &Model{
+ ProjectID: types.StringValue("pid"),
+ InstanceID: types.StringValue("iid"),
+ Name: types.StringValue("main-dags"),
+ Git: git,
+ S3: types.ObjectNull(s3Types),
+ }
+}
+
+func priorS3Model(t *testing.T) *Model {
+ t.Helper()
+ auth := mustS3Auth(t, s3AuthModel{
+ Type: types.StringValue("access_key"),
+ AccessKeyID: types.StringValue("AKIA..."),
+ SecretAccessKey: types.StringValue("PLANNED-S3-SECRET"),
+ })
+ s3 := mustS3(t, s3Model{
+ BucketName: types.StringValue("my-bucket"),
+ Endpoint: types.StringNull(),
+ Prefix: types.StringNull(),
+ RefreshInterval: types.Int32Null(),
+ Auth: auth,
+ })
+ return &Model{
+ ProjectID: types.StringValue("pid"),
+ InstanceID: types.StringValue("iid"),
+ Name: types.StringValue("backup-dags"),
+ S3: s3,
+ Git: types.ObjectNull(gitTypes),
+ }
+}
+
+func TestMapFields_Git(t *testing.T) {
+ model := priorGitModel(t)
+ err := mapFields(context.Background(), gitBundleResponse(), model, "eu01")
+ if err != nil {
+ t.Fatalf("mapFields error: %v", err)
+ }
+
+ if got, want := model.ID.ValueString(), "pid,eu01,iid,main-dags"; got != want {
+ t.Errorf("ID = %q, want %q", got, want)
+ }
+ if model.Git.IsNull() {
+ t.Fatalf("Git block should be set")
+ }
+ if !model.S3.IsNull() {
+ t.Errorf("S3 block should be null")
+ }
+ var gm gitModel
+ if d := model.Git.As(context.Background(), &gm, basetypes.ObjectAsOptions{}); d.HasError() {
+ t.Fatalf("reading git: %v", d.Errors())
+ }
+ if got, want := gm.URL.ValueString(), "https://git.example.com/repo.git"; got != want {
+ t.Errorf("URL = %q, want %q", got, want)
+ }
+ if got, want := gm.Branch.ValueString(), "main"; got != want {
+ t.Errorf("Branch = %q, want %q", got, want)
+ }
+ if got, want := gm.RefreshInterval.ValueInt32(), int32(60); got != want {
+ t.Errorf("RefreshInterval = %d, want %d", got, want)
+ }
+ var auth gitAuthModel
+ if d := gm.Auth.As(context.Background(), &auth, basetypes.ObjectAsOptions{}); d.HasError() {
+ t.Fatalf("reading auth: %v", d.Errors())
+ }
+ if auth.Password.ValueString() != "PLANNED-PASSWORD" {
+ t.Errorf("Password should be preserved as %q, got %q", "PLANNED-PASSWORD", auth.Password.ValueString())
+ }
+ if auth.Type.ValueString() != "basic" {
+ t.Errorf("auth.Type = %q, want basic", auth.Type.ValueString())
+ }
+ if auth.Username.ValueString() != "git-user" {
+ t.Errorf("auth.Username = %q, want git-user", auth.Username.ValueString())
+ }
+}
+
+// TestMapFields_Git_SubdirAndRefreshIntervalEdgeCases pins behavior the
+// production code relies on: mapFields writes the server's verbatim subdir
+// (including any trailing slash) and tolerates a missing refresh_interval.
+func TestMapFields_Git_SubdirAndRefreshIntervalEdgeCases(t *testing.T) {
+ tests := []struct {
+ desc string
+ subdir *string
+ refreshInterval *int32
+ wantSubdir types.String
+ wantRefreshInterval types.Int32
+ }{
+ {"server returns trailing slash subdir verbatim", sdkUtils.Ptr("dags/"), sdkUtils.Ptr(int32(60)), types.StringValue("dags/"), types.Int32Value(60)},
+ {"server returns null subdir + null refresh_interval", nil, nil, types.StringNull(), types.Int32Null()},
+ {"server returns empty subdir literal", sdkUtils.Ptr(""), sdkUtils.Ptr(int32(0)), types.StringValue(""), types.Int32Value(0)},
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ resp := workflows.GitDagBundleResponseAsDagBundleResponse(&workflows.GitDagBundleResponse{
+ Type: workflows.GITDAGBUNDLERESPONSETYPE_GIT,
+ Name: "main-dags",
+ Url: "https://git.example.com/repo.git",
+ Branch: "main",
+ Subdir: tt.subdir,
+ RefreshInterval: tt.refreshInterval,
+ Auth: workflows.BasicAuthResponseAsGitAuthResponse(&workflows.BasicAuthResponse{
+ Type: sdkUtils.Ptr("basic"),
+ Username: sdkUtils.Ptr("git-user"),
+ }),
+ })
+ model := priorGitModel(t)
+ if err := mapFields(context.Background(), &resp, model, "eu01"); err != nil {
+ t.Fatalf("mapFields: %v", err)
+ }
+ var gm gitModel
+ if d := model.Git.As(context.Background(), &gm, basetypes.ObjectAsOptions{}); d.HasError() {
+ t.Fatalf("reading git: %v", d.Errors())
+ }
+ if !gm.Subdir.Equal(tt.wantSubdir) {
+ t.Errorf("Subdir = %v, want %v", gm.Subdir, tt.wantSubdir)
+ }
+ if !gm.RefreshInterval.Equal(tt.wantRefreshInterval) {
+ t.Errorf("RefreshInterval = %v, want %v", gm.RefreshInterval, tt.wantRefreshInterval)
+ }
+ })
+ }
+}
+
+func TestMapFields_S3(t *testing.T) {
+ model := priorS3Model(t)
+ err := mapFields(context.Background(), s3BundleResponse(), model, "eu01")
+ if err != nil {
+ t.Fatalf("mapFields error: %v", err)
+ }
+
+ if model.S3.IsNull() {
+ t.Fatalf("S3 block should be set")
+ }
+ if !model.Git.IsNull() {
+ t.Errorf("Git block should be null")
+ }
+ var sm s3Model
+ if d := model.S3.As(context.Background(), &sm, basetypes.ObjectAsOptions{}); d.HasError() {
+ t.Fatalf("reading s3: %v", d.Errors())
+ }
+ if got, want := sm.BucketName.ValueString(), "my-bucket"; got != want {
+ t.Errorf("BucketName = %q, want %q", got, want)
+ }
+ if got, want := sm.RefreshInterval.ValueInt32(), int32(120); got != want {
+ t.Errorf("RefreshInterval = %d, want %d", got, want)
+ }
+ var auth s3AuthModel
+ if d := sm.Auth.As(context.Background(), &auth, basetypes.ObjectAsOptions{}); d.HasError() {
+ t.Fatalf("reading s3 auth: %v", d.Errors())
+ }
+ if auth.SecretAccessKey.ValueString() != "PLANNED-S3-SECRET" {
+ t.Errorf("SecretAccessKey should be preserved as %q, got %q", "PLANNED-S3-SECRET", auth.SecretAccessKey.ValueString())
+ }
+ if auth.AccessKeyID.ValueString() != "AKIA..." {
+ t.Errorf("AccessKeyID = %q, want AKIA...", auth.AccessKeyID.ValueString())
+ }
+}
+
+func TestMapFields_Errors(t *testing.T) {
+ tests := []struct {
+ desc string
+ in *workflows.DagBundleResponse
+ }{
+ {"nil bundle", nil},
+ {"empty discriminator", &workflows.DagBundleResponse{}},
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ model := priorGitModel(t)
+ if err := mapFields(context.Background(), tt.in, model, "eu01"); err == nil {
+ t.Errorf("expected error, got nil")
+ }
+ })
+ }
+}
+
+func TestBuildCreatePayload_Git(t *testing.T) {
+ auth := mustGitAuth(t, gitAuthModel{
+ Type: types.StringValue("basic"),
+ Username: types.StringValue("u"),
+ Password: types.StringValue("p"),
+ })
+ git := mustGit(t, gitModel{
+ URL: types.StringValue("https://git.example.com/repo.git"),
+ Branch: types.StringValue("main"),
+ Subdir: types.StringValue("dags/"),
+ RefreshInterval: types.Int32Value(60),
+ Auth: auth,
+ })
+ model := &Model{
+ Name: types.StringValue("main-dags"),
+ Git: git,
+ S3: types.ObjectNull(s3Types),
+ }
+
+ payload, err := toCreatePayload(context.Background(), model)
+ if err != nil {
+ t.Fatalf("toCreatePayload: %v", err)
+ }
+ if payload.GitDagBundle == nil {
+ t.Fatalf("expected GitDagBundle variant")
+ }
+ g := payload.GitDagBundle
+ if g.Type != workflows.GITDAGBUNDLETYPE_GIT {
+ t.Errorf("Type = %v", g.Type)
+ }
+ if g.Url != "https://git.example.com/repo.git" {
+ t.Errorf("Url = %q", g.Url)
+ }
+ if g.Branch != "main" {
+ t.Errorf("Branch = %q", g.Branch)
+ }
+ if ptrString(g.Subdir) != "dags/" {
+ t.Errorf("Subdir = %q", ptrString(g.Subdir))
+ }
+ if g.RefreshInterval == nil || *g.RefreshInterval != 60 {
+ t.Errorf("RefreshInterval = %v", g.RefreshInterval)
+ }
+ if g.Auth.BasicAuth == nil {
+ t.Fatalf("expected BasicAuth variant")
+ }
+ if ptrString(g.Auth.BasicAuth.Username) != "u" {
+ t.Errorf("Username = %q", ptrString(g.Auth.BasicAuth.Username))
+ }
+}
+
+func TestBuildCreatePayload_S3(t *testing.T) {
+ auth := mustS3Auth(t, s3AuthModel{
+ Type: types.StringValue("access_key"),
+ AccessKeyID: types.StringValue("AKIA"),
+ SecretAccessKey: types.StringValue("SECRET"),
+ })
+ s3 := mustS3(t, s3Model{
+ BucketName: types.StringValue("my-bucket"),
+ Endpoint: types.StringValue("https://object.example.com"),
+ Prefix: types.StringValue("dags/"),
+ RefreshInterval: types.Int32Null(),
+ Auth: auth,
+ })
+ model := &Model{
+ Name: types.StringValue("backup-dags"),
+ S3: s3,
+ Git: types.ObjectNull(gitTypes),
+ }
+
+ payload, err := toCreatePayload(context.Background(), model)
+ if err != nil {
+ t.Fatalf("toCreatePayload: %v", err)
+ }
+ if payload.S3DagBundle == nil {
+ t.Fatalf("expected S3DagBundle variant")
+ }
+ s := payload.S3DagBundle
+ if s.BucketName != "my-bucket" {
+ t.Errorf("BucketName = %q", s.BucketName)
+ }
+ if s.S3Auth.S3AccessKeyAuth == nil {
+ t.Fatalf("expected S3AccessKeyAuth variant")
+ }
+ if s.S3Auth.S3AccessKeyAuth.AccessKeyId != "AKIA" {
+ t.Errorf("AccessKeyId = %q", s.S3Auth.S3AccessKeyAuth.AccessKeyId)
+ }
+}
+
+func TestBuildCreatePayload_NeitherSet(t *testing.T) {
+ model := &Model{
+ Git: types.ObjectNull(gitTypes),
+ S3: types.ObjectNull(s3Types),
+ }
+ if _, err := toCreatePayload(context.Background(), model); err == nil {
+ t.Errorf("expected error when neither git nor s3 is set")
+ }
+}
+
+func TestBuildUpdatePayload_Git(t *testing.T) {
+ plan := &Model{
+ Name: types.StringValue("main-dags"),
+ Git: mustGit(t, gitModel{
+ URL: types.StringValue("https://git.example.com/repo.git"),
+ Branch: types.StringValue("dev"),
+ Subdir: types.StringValue("dags"),
+ RefreshInterval: types.Int32Value(120),
+ Auth: types.ObjectNull(gitAuthTypes),
+ }),
+ S3: types.ObjectNull(s3Types),
+ }
+ state := &Model{
+ Git: mustGit(t, gitModel{
+ URL: types.StringValue("https://git.example.com/repo.git"),
+ Branch: types.StringValue("main"),
+ Subdir: types.StringValue("dags"),
+ RefreshInterval: types.Int32Null(),
+ Auth: types.ObjectNull(gitAuthTypes),
+ }),
+ S3: types.ObjectNull(s3Types),
+ }
+ payload, err := toUpdatePayload(context.Background(), plan, state)
+ if err != nil {
+ t.Fatalf("toUpdatePayload: %v", err)
+ }
+ if payload.UpdateGitDagBundlePayload == nil {
+ t.Fatalf("expected UpdateGitDagBundlePayload variant")
+ }
+ g := payload.UpdateGitDagBundlePayload
+ if ptrString(g.Url) != "https://git.example.com/repo.git" {
+ t.Errorf("Url = %q", ptrString(g.Url))
+ }
+ if ptrString(g.Branch) != "dev" {
+ t.Errorf("Branch = %q", ptrString(g.Branch))
+ }
+ if g.Auth != nil {
+ t.Errorf("Auth should be nil when not set (server-side: leaves credentials untouched), got %+v", g.Auth)
+ }
+}
+
+func TestBuildUpdatePayload_GitWithAuth(t *testing.T) {
+ auth := mustGitAuth(t, gitAuthModel{
+ Type: types.StringValue("basic"),
+ Username: types.StringValue("u"),
+ Password: types.StringValue("p"),
+ })
+ plan := &Model{
+ Git: mustGit(t, gitModel{
+ URL: types.StringValue("https://git.example.com/repo.git"),
+ Branch: types.StringValue("main"),
+ Subdir: types.StringNull(),
+ RefreshInterval: types.Int32Null(),
+ Auth: auth,
+ }),
+ S3: types.ObjectNull(s3Types),
+ }
+ state := &Model{Git: types.ObjectNull(gitTypes), S3: types.ObjectNull(s3Types)}
+ payload, err := toUpdatePayload(context.Background(), plan, state)
+ if err != nil {
+ t.Fatalf("toUpdatePayload: %v", err)
+ }
+ if payload.UpdateGitDagBundlePayload.Auth == nil {
+ t.Fatalf("Auth should be set")
+ }
+ if payload.UpdateGitDagBundlePayload.Auth.BasicAuth == nil {
+ t.Errorf("expected BasicAuth variant")
+ }
+}
+
+func TestBuildUpdatePayload_S3(t *testing.T) {
+ plan := &Model{
+ S3: mustS3(t, s3Model{
+ BucketName: types.StringValue("my-bucket"),
+ Endpoint: types.StringValue("https://example.com"),
+ Prefix: types.StringValue("dags"),
+ RefreshInterval: types.Int32Null(),
+ Auth: types.ObjectNull(s3AuthTypes),
+ }),
+ Git: types.ObjectNull(gitTypes),
+ }
+ state := &Model{Git: types.ObjectNull(gitTypes), S3: types.ObjectNull(s3Types)}
+ payload, err := toUpdatePayload(context.Background(), plan, state)
+ if err != nil {
+ t.Fatalf("toUpdatePayload: %v", err)
+ }
+ if payload.UpdateS3DagBundlePayload == nil {
+ t.Fatalf("expected UpdateS3DagBundlePayload variant")
+ }
+ if payload.UpdateS3DagBundlePayload.S3Auth != nil {
+ t.Errorf("S3Auth should be nil when not set")
+ }
+}
+
+func TestBuildUpdatePayload_NeitherSet(t *testing.T) {
+ plan := &Model{Git: types.ObjectNull(gitTypes), S3: types.ObjectNull(s3Types)}
+ state := &Model{Git: types.ObjectNull(gitTypes), S3: types.ObjectNull(s3Types)}
+ if _, err := toUpdatePayload(context.Background(), plan, state); err == nil {
+ t.Errorf("expected error when neither git nor s3 is set")
+ }
+}
+
+// TestBuildUpdatePayload_ClearsGitSubdir verifies that removing subdir from the
+// config translates to "" so the server clears it (server uses "" as the clear
+// signal — see workflows_service.py).
+func TestBuildUpdatePayload_ClearsGitSubdir(t *testing.T) {
+ plan := &Model{
+ Git: mustGit(t, gitModel{
+ URL: types.StringValue("https://git.example.com/repo.git"),
+ Branch: types.StringValue("main"),
+ Subdir: types.StringNull(),
+ RefreshInterval: types.Int32Null(),
+ Auth: types.ObjectNull(gitAuthTypes),
+ }),
+ S3: types.ObjectNull(s3Types),
+ }
+ state := &Model{
+ Git: mustGit(t, gitModel{
+ URL: types.StringValue("https://git.example.com/repo.git"),
+ Branch: types.StringValue("main"),
+ Subdir: types.StringValue("had-subdir"),
+ RefreshInterval: types.Int32Null(),
+ Auth: types.ObjectNull(gitAuthTypes),
+ }),
+ S3: types.ObjectNull(s3Types),
+ }
+ payload, err := toUpdatePayload(context.Background(), plan, state)
+ if err != nil {
+ t.Fatalf("toUpdatePayload: %v", err)
+ }
+ g := payload.UpdateGitDagBundlePayload
+ if g.Subdir == nil || *g.Subdir != "" {
+ t.Errorf("Subdir = %v, want pointer to \"\"", g.Subdir)
+ }
+}
+
+// TestBuildUpdatePayload_ClearsS3PrefixAndEndpoint verifies "" clearing for s3
+// fields with the same server contract.
+func TestBuildUpdatePayload_ClearsS3PrefixAndEndpoint(t *testing.T) {
+ plan := &Model{
+ S3: mustS3(t, s3Model{
+ BucketName: types.StringValue("my-bucket"),
+ Endpoint: types.StringNull(),
+ Prefix: types.StringNull(),
+ RefreshInterval: types.Int32Null(),
+ Auth: types.ObjectNull(s3AuthTypes),
+ }),
+ Git: types.ObjectNull(gitTypes),
+ }
+ state := &Model{
+ S3: mustS3(t, s3Model{
+ BucketName: types.StringValue("my-bucket"),
+ Endpoint: types.StringValue("had-endpoint"),
+ Prefix: types.StringValue("had-prefix"),
+ RefreshInterval: types.Int32Null(),
+ Auth: types.ObjectNull(s3AuthTypes),
+ }),
+ Git: types.ObjectNull(gitTypes),
+ }
+ payload, err := toUpdatePayload(context.Background(), plan, state)
+ if err != nil {
+ t.Fatalf("toUpdatePayload: %v", err)
+ }
+ s := payload.UpdateS3DagBundlePayload
+ if s.Endpoint == nil || *s.Endpoint != "" {
+ t.Errorf("Endpoint = %v, want pointer to \"\"", s.Endpoint)
+ }
+ if s.Prefix == nil || *s.Prefix != "" {
+ t.Errorf("Prefix = %v, want pointer to \"\"", s.Prefix)
+ }
+}
+
+// TestBuildUpdatePayload_OmitsUnsetSubdir verifies an always-unset subdir is
+// omitted, not sent as "". Otherwise every update would clear it.
+func TestBuildUpdatePayload_OmitsUnsetSubdir(t *testing.T) {
+ plan := &Model{
+ Git: mustGit(t, gitModel{
+ URL: types.StringValue("https://git.example.com/repo.git"),
+ Branch: types.StringValue("main"),
+ Subdir: types.StringNull(),
+ RefreshInterval: types.Int32Null(),
+ Auth: types.ObjectNull(gitAuthTypes),
+ }),
+ S3: types.ObjectNull(s3Types),
+ }
+ state := &Model{
+ Git: mustGit(t, gitModel{
+ URL: types.StringValue("https://git.example.com/repo.git"),
+ Branch: types.StringValue("main"),
+ Subdir: types.StringNull(),
+ RefreshInterval: types.Int32Null(),
+ Auth: types.ObjectNull(gitAuthTypes),
+ }),
+ S3: types.ObjectNull(s3Types),
+ }
+ payload, err := toUpdatePayload(context.Background(), plan, state)
+ if err != nil {
+ t.Fatalf("toUpdatePayload: %v", err)
+ }
+ if payload.UpdateGitDagBundlePayload.Subdir != nil {
+ t.Errorf("Subdir should be nil when never set, got %q", *payload.UpdateGitDagBundlePayload.Subdir)
+ }
+}
+
+// TestIsEmptyStr pins the non-trivial semantic: the empty literal is treated as
+// "absent" inside auth blocks (so a server-rejected empty credential surfaces at
+// plan time), while Unknown defers.
+func TestIsEmptyStr(t *testing.T) {
+ cases := []struct {
+ in types.String
+ want bool
+ }{
+ {types.StringNull(), true},
+ {types.StringValue(""), true},
+ {types.StringUnknown(), false}, // defer
+ {types.StringValue("x"), false},
+ }
+ for _, c := range cases {
+ if got := isEmptyStr(c.in); got != c.want {
+ t.Errorf("isEmptyStr(%v) = %v, want %v", c.in, got, c.want)
+ }
+ }
+}
+
+func TestValidateGitAuth(t *testing.T) {
+ mkAuth := func(typ string, user, pw types.String) basetypes.ObjectValue {
+ return mustGitAuth(t, gitAuthModel{
+ Type: types.StringValue(typ),
+ Username: user,
+ Password: pw,
+ })
+ }
+ tests := []struct {
+ desc string
+ auth basetypes.ObjectValue
+ wantErr bool
+ }{
+ {"basic with creds OK", mkAuth("basic", types.StringValue("u"), types.StringValue("p")), false},
+ {"basic missing username", mkAuth("basic", types.StringNull(), types.StringValue("p")), true},
+ {"basic missing password", mkAuth("basic", types.StringValue("u"), types.StringNull()), true},
+ {"basic empty username literal", mkAuth("basic", types.StringValue(""), types.StringValue("p")), true},
+ {"basic unknown username defers", mkAuth("basic", types.StringUnknown(), types.StringValue("p")), false},
+ {"none + no creds OK", mkAuth("none", types.StringNull(), types.StringNull()), false},
+ {"none + username forbidden", mkAuth("none", types.StringValue("u"), types.StringNull()), true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ var diags diag.Diagnostics
+ validateGitAuth(context.Background(), tt.auth, &diags)
+ if tt.wantErr && !diags.HasError() {
+ t.Errorf("expected diagnostics, got none")
+ }
+ if !tt.wantErr && diags.HasError() {
+ t.Errorf("expected no diagnostics, got %v", diags.Errors())
+ }
+ })
+ }
+}
+
+func TestValidateS3Auth(t *testing.T) {
+ mkAuth := func(typ string, id, key types.String) basetypes.ObjectValue {
+ return mustS3Auth(t, s3AuthModel{
+ Type: types.StringValue(typ),
+ AccessKeyID: id,
+ SecretAccessKey: key,
+ })
+ }
+ tests := []struct {
+ desc string
+ auth basetypes.ObjectValue
+ wantErr bool
+ }{
+ {"access_key with creds OK", mkAuth("access_key", types.StringValue("AKIA"), types.StringValue("secret")), false},
+ {"access_key missing id", mkAuth("access_key", types.StringNull(), types.StringValue("secret")), true},
+ {"access_key empty literal id", mkAuth("access_key", types.StringValue(""), types.StringValue("secret")), true},
+ {"access_key unknown defers", mkAuth("access_key", types.StringUnknown(), types.StringValue("secret")), false},
+ {"none + no creds OK", mkAuth("none", types.StringNull(), types.StringNull()), false},
+ {"none + id forbidden", mkAuth("none", types.StringValue("AKIA"), types.StringNull()), true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ var diags diag.Diagnostics
+ validateS3Auth(context.Background(), tt.auth, &diags)
+ if tt.wantErr && !diags.HasError() {
+ t.Errorf("expected diagnostics, got none")
+ }
+ if !tt.wantErr && diags.HasError() {
+ t.Errorf("expected no diagnostics, got %v", diags.Errors())
+ }
+ })
+ }
+}
+
+// TestValidateBundleConfig covers the top-level "exactly one of git/s3" gate.
+// Per-block content checks are exercised separately by TestValidateGitAuth /
+// TestValidateS3Auth above.
+func TestValidateBundleConfig(t *testing.T) {
+ ctx := context.Background()
+
+ gitOK := mustGit(t, gitModel{
+ URL: types.StringValue("https://example.com/r.git"),
+ Branch: types.StringValue("main"),
+ Auth: mustGitAuth(t, gitAuthModel{
+ Type: types.StringValue("none"),
+ Username: types.StringNull(),
+ Password: types.StringNull(),
+ }),
+ })
+ s3OK := mustS3(t, s3Model{
+ BucketName: types.StringValue("b"),
+ Auth: mustS3Auth(t, s3AuthModel{
+ Type: types.StringValue("none"),
+ AccessKeyID: types.StringNull(),
+ SecretAccessKey: types.StringNull(),
+ }),
+ })
+
+ tests := []struct {
+ desc string
+ model Model
+ wantErr string // substring; "" → no error expected
+ }{
+ {
+ desc: "git only OK",
+ model: Model{Git: gitOK, S3: types.ObjectNull(s3Types)},
+ },
+ {
+ desc: "s3 only OK",
+ model: Model{Git: types.ObjectNull(gitTypes), S3: s3OK},
+ },
+ {
+ desc: "neither set → error",
+ model: Model{Git: types.ObjectNull(gitTypes), S3: types.ObjectNull(s3Types)},
+ wantErr: "Exactly one of `git` or `s3` must be set",
+ },
+ {
+ desc: "both set → error",
+ model: Model{Git: gitOK, S3: s3OK},
+ wantErr: "Only one of `git` or `s3` may be set",
+ },
+ {
+ desc: "git unknown defers (no error)",
+ model: Model{Git: types.ObjectUnknown(gitTypes), S3: types.ObjectNull(s3Types)},
+ },
+ {
+ desc: "s3 unknown defers (no error)",
+ model: Model{Git: types.ObjectNull(gitTypes), S3: types.ObjectUnknown(s3Types)},
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ var diags diag.Diagnostics
+ validateBundleConfig(ctx, &tt.model, &diags)
+ if tt.wantErr == "" {
+ if diags.HasError() {
+ t.Fatalf("expected no errors, got: %v", diags.Errors())
+ }
+ return
+ }
+ if !diags.HasError() {
+ t.Fatalf("expected an error containing %q, got none", tt.wantErr)
+ }
+ found := false
+ for _, d := range diags.Errors() {
+ if strings.Contains(d.Detail(), tt.wantErr) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ msgs := make([]string, 0, len(diags.Errors()))
+ for _, d := range diags.Errors() {
+ msgs = append(msgs, d.Detail())
+ }
+ t.Errorf("expected an error containing %q, got: %v", tt.wantErr, msgs)
+ }
+ })
+ }
+}
diff --git a/stackit/internal/services/workflows/dagbundles/datasource.go b/stackit/internal/services/workflows/dagbundles/datasource.go
new file mode 100644
index 000000000..2061a46f9
--- /dev/null
+++ b/stackit/internal/services/workflows/dagbundles/datasource.go
@@ -0,0 +1,195 @@
+package dagbundles
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
+ workflowsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/utils"
+ tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+var _ datasource.DataSource = &dagBundlesDataSource{}
+
+type Model struct {
+ ID types.String `tfsdk:"id"`
+ ProjectID types.String `tfsdk:"project_id"`
+ Region types.String `tfsdk:"region"`
+ InstanceID types.String `tfsdk:"instance_id"`
+ DagBundles types.List `tfsdk:"dag_bundles"`
+}
+
+type bundleSummary struct {
+ Name types.String `tfsdk:"name"`
+ Type types.String `tfsdk:"type"`
+ RefreshInterval types.Int32 `tfsdk:"refresh_interval"`
+ URL types.String `tfsdk:"url"`
+ Branch types.String `tfsdk:"branch"`
+ Subdir types.String `tfsdk:"subdir"`
+ BucketName types.String `tfsdk:"bucket_name"`
+ Endpoint types.String `tfsdk:"endpoint"`
+ Prefix types.String `tfsdk:"prefix"`
+}
+
+var bundleSummaryTypes = map[string]attr.Type{
+ "name": basetypes.StringType{},
+ "type": basetypes.StringType{},
+ "refresh_interval": basetypes.Int32Type{},
+ "url": basetypes.StringType{},
+ "branch": basetypes.StringType{},
+ "subdir": basetypes.StringType{},
+ "bucket_name": basetypes.StringType{},
+ "endpoint": basetypes.StringType{},
+ "prefix": basetypes.StringType{},
+}
+
+type dagBundlesDataSource struct {
+ client *workflows.APIClient
+ providerData core.ProviderData
+}
+
+func NewWorkflowsDagBundlesDataSource() datasource.DataSource {
+ return &dagBundlesDataSource{}
+}
+
+// Metadata returns the data source type name.
+func (d *dagBundlesDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_workflows_dag_bundles"
+}
+
+// Configure adds the provider configured client to the data source.
+func (d *dagBundlesDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
+ if !ok {
+ return
+ }
+ d.providerData = providerData
+
+ features.CheckExperimentEnabled(ctx, &d.providerData, features.WorkflowsExperiment, "stackit_workflows_dag_bundles", core.Datasource, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ apiClient := workflowsUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ d.client = apiClient
+}
+
+// Schema defines the schema for the data source.
+func (d *dagBundlesDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ description := fmt.Sprintf("Lists all DAG bundles attached to a Workflows instance. %s", core.DatasourceRegionFallbackDocstring)
+ resp.Schema = schema.Schema{
+ Description: description,
+ MarkdownDescription: features.AddExperimentDescription(description, features.WorkflowsExperiment, core.Datasource),
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{Description: "Terraform's internal data-source ID. It is structured as \"`project_id`,`region`,`instance_id`\".", Computed: true},
+ "project_id": schema.StringAttribute{Description: "STACKIT project ID.", Required: true, Validators: []validator.String{validate.UUID(), validate.NoSeparator()}},
+ "region": schema.StringAttribute{Description: "STACKIT region name.", Optional: true, Computed: true},
+ "instance_id": schema.StringAttribute{
+ Description: "Workflows instance ID.",
+ Required: true,
+ Validators: []validator.String{validate.UUID(), validate.NoSeparator()},
+ },
+ "dag_bundles": schema.ListNestedAttribute{
+ Description: "DAG bundles attached to the instance.",
+ Computed: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "name": schema.StringAttribute{Description: "Bundle name.", Computed: true},
+ "type": schema.StringAttribute{Description: "Bundle source type (`git` or `s3`).", Computed: true},
+ "refresh_interval": schema.Int32Attribute{Description: "Refresh interval (seconds).", Computed: true},
+ "url": schema.StringAttribute{Description: "Git repository URL (git bundles only).", Computed: true},
+ "branch": schema.StringAttribute{Description: "Git branch (git bundles only).", Computed: true},
+ "subdir": schema.StringAttribute{Description: "Subdirectory inside the repository (git bundles only).", Computed: true},
+ "bucket_name": schema.StringAttribute{Description: "Bucket name (s3 bundles only).", Computed: true},
+ "endpoint": schema.StringAttribute{Description: "S3 endpoint (s3 bundles only).", Computed: true},
+ "prefix": schema.StringAttribute{Description: "Key prefix (s3 bundles only).", Computed: true},
+ },
+ },
+ },
+ },
+ }
+}
+
+// Read reads the data source and writes its result to Terraform state.
+func (d *dagBundlesDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := model.ProjectID.ValueString()
+ region := d.providerData.GetRegionWithOverride(model.Region)
+ instanceID := model.InstanceID.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "instance_id", instanceID)
+
+ listResp, err := d.client.DefaultAPI.ListDagBundles(ctx, projectID, region, instanceID).Execute()
+ if err != nil {
+ tfutils.LogError(ctx, &resp.Diagnostics, err, "Error listing Workflows DAG bundles", fmt.Sprintf("Instance %q", instanceID), nil)
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ model.Region = types.StringValue(region)
+ model.ID = types.StringValue(fmt.Sprintf("%s,%s,%s", projectID, region, instanceID))
+ objType := types.ObjectType{AttrTypes: bundleSummaryTypes}
+ elements := make([]attr.Value, 0, len(listResp.DagBundles))
+ for i := range listResp.DagBundles {
+ bs := bundleSummary{}
+ switch {
+ case listResp.DagBundles[i].GitDagBundleResponse != nil:
+ g := listResp.DagBundles[i].GitDagBundleResponse
+ bs.Type = types.StringValue(workflowsUtils.BundleTypeGit)
+ bs.Name = types.StringValue(g.Name)
+ bs.URL = types.StringValue(g.Url)
+ bs.Branch = types.StringValue(g.Branch)
+ bs.Subdir = types.StringPointerValue(g.Subdir)
+ bs.RefreshInterval = types.Int32PointerValue(g.RefreshInterval)
+ case listResp.DagBundles[i].S3DagBundleResponse != nil:
+ s := listResp.DagBundles[i].S3DagBundleResponse
+ bs.Type = types.StringValue(workflowsUtils.BundleTypeS3)
+ bs.Name = types.StringValue(s.Name)
+ bs.BucketName = types.StringValue(s.BucketName)
+ bs.Endpoint = types.StringPointerValue(s.Endpoint)
+ bs.Prefix = types.StringPointerValue(s.Prefix)
+ bs.RefreshInterval = types.Int32PointerValue(s.RefreshInterval)
+ default:
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error listing Workflows DAG bundles", fmt.Sprintf("Unknown bundle variant at index %d", i))
+ return
+ }
+ obj, diags := types.ObjectValueFrom(ctx, bundleSummaryTypes, bs)
+ if diags.HasError() {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error listing Workflows DAG bundles", fmt.Sprintf("Mapping bundle at index %d: %v", i, diags.Errors()))
+ return
+ }
+ elements = append(elements, obj)
+ }
+ list, diags := types.ListValue(objType, elements)
+ if diags.HasError() {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error listing Workflows DAG bundles", fmt.Sprintf("Building list: %v", diags.Errors()))
+ return
+ }
+ model.DagBundles = list
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
+ tflog.Info(ctx, "Workflows DAG bundles listed", map[string]any{"count": len(elements)})
+}
diff --git a/stackit/internal/services/workflows/instance/datasource.go b/stackit/internal/services/workflows/instance/datasource.go
new file mode 100644
index 000000000..bb09f2532
--- /dev/null
+++ b/stackit/internal/services/workflows/instance/datasource.go
@@ -0,0 +1,347 @@
+package instance
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
+ workflowsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/utils"
+ tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+var _ datasource.DataSource = &instanceDataSource{}
+
+func NewWorkflowsInstanceDataSource() datasource.DataSource {
+ return &instanceDataSource{}
+}
+
+type instanceDataSource struct {
+ client *workflows.APIClient
+ providerData core.ProviderData
+}
+
+// datasourceModel is the resource Model plus the embedded dag_bundles read-only
+// list. The bundles list lives only on the datasource because individual
+// bundles are managed by the stackit_workflows_dag_bundle resource — exposing
+// the list on the parent resource would create a two-writer state model.
+type datasourceModel struct {
+ ID types.String `tfsdk:"id"`
+ InstanceID types.String `tfsdk:"instance_id"`
+ Region types.String `tfsdk:"region"`
+ ProjectID types.String `tfsdk:"project_id"`
+ DisplayName types.String `tfsdk:"display_name"`
+ Description types.String `tfsdk:"description"`
+ Version types.String `tfsdk:"version"`
+ EnableStackitExampleDags types.Bool `tfsdk:"enable_stackit_example_dags"`
+ EnableAirflowExampleDags types.Bool `tfsdk:"enable_airflow_example_dags"`
+ ObservabilityID types.String `tfsdk:"observability_id"`
+ Network types.Object `tfsdk:"network"`
+ IdentityProvider types.Object `tfsdk:"identity_provider"`
+ Endpoints types.Object `tfsdk:"endpoints"`
+ DagBundles types.List `tfsdk:"dag_bundles"`
+ Status types.String `tfsdk:"status"`
+ StatusMessage types.String `tfsdk:"status_message"`
+ CreatedAt types.String `tfsdk:"created_at"`
+}
+
+type dagBundleSummary struct {
+ Name types.String `tfsdk:"name"`
+ Type types.String `tfsdk:"type"`
+ RefreshInterval types.Int32 `tfsdk:"refresh_interval"`
+ URL types.String `tfsdk:"url"`
+ Branch types.String `tfsdk:"branch"`
+ Subdir types.String `tfsdk:"subdir"`
+ BucketName types.String `tfsdk:"bucket_name"`
+ Endpoint types.String `tfsdk:"endpoint"`
+ Prefix types.String `tfsdk:"prefix"`
+}
+
+var dagBundleSummaryTypes = map[string]attr.Type{
+ "name": basetypes.StringType{},
+ "type": basetypes.StringType{},
+ "refresh_interval": basetypes.Int32Type{},
+ "url": basetypes.StringType{},
+ "branch": basetypes.StringType{},
+ "subdir": basetypes.StringType{},
+ "bucket_name": basetypes.StringType{},
+ "endpoint": basetypes.StringType{},
+ "prefix": basetypes.StringType{},
+}
+
+func (d *instanceDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_workflows_instance"
+}
+
+func (d *instanceDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
+ if !ok {
+ return
+ }
+ d.providerData = providerData
+
+ features.CheckExperimentEnabled(ctx, &d.providerData, features.WorkflowsExperiment, "stackit_workflows_instance", core.Datasource, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ apiClient := workflowsUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ d.client = apiClient
+}
+
+func (d *instanceDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ description := fmt.Sprintf("Workflows instance data source schema. %s", core.DatasourceRegionFallbackDocstring)
+ resp.Schema = schema.Schema{
+ Description: description,
+ MarkdownDescription: features.AddExperimentDescription(description, features.WorkflowsExperiment, core.Datasource),
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: schemaDescriptions["id"],
+ Computed: true,
+ },
+ "instance_id": schema.StringAttribute{
+ Description: schemaDescriptions["instance_id"],
+ Required: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ },
+ "project_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Required: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ },
+ "region": schema.StringAttribute{
+ Description: schemaDescriptions["region"],
+ Optional: true,
+ Computed: true,
+ },
+ "display_name": schema.StringAttribute{
+ Description: schemaDescriptions["display_name"],
+ Computed: true,
+ },
+ "description": schema.StringAttribute{
+ Description: schemaDescriptions["description"],
+ Computed: true,
+ },
+ "version": schema.StringAttribute{
+ Description: schemaDescriptions["version"],
+ Computed: true,
+ },
+ "enable_stackit_example_dags": schema.BoolAttribute{
+ Description: schemaDescriptions["enable_stackit_example_dags"],
+ Computed: true,
+ },
+ "enable_airflow_example_dags": schema.BoolAttribute{
+ Description: schemaDescriptions["enable_airflow_example_dags"],
+ Computed: true,
+ },
+ "observability_id": schema.StringAttribute{
+ Description: schemaDescriptions["observability_id"],
+ Computed: true,
+ },
+ "network": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["network"],
+ Computed: true,
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: schemaDescriptions["network.id"],
+ Computed: true,
+ },
+ },
+ },
+ "identity_provider": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["identity_provider"],
+ Computed: true,
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{Description: schemaDescriptions["identity_provider.type"], Computed: true},
+ "name": schema.StringAttribute{Description: schemaDescriptions["identity_provider.name"], Computed: true},
+ "client_id": schema.StringAttribute{Description: schemaDescriptions["identity_provider.client_id"], Computed: true},
+ "client_secret": schema.StringAttribute{Description: "OAuth2 client secret. Never returned by the API; always null when read.", Computed: true, Sensitive: true},
+ "scope": schema.StringAttribute{Description: schemaDescriptions["identity_provider.scope"], Computed: true},
+ "discovery_endpoint": schema.StringAttribute{Description: schemaDescriptions["identity_provider.discovery_endpoint"], Computed: true},
+ "api_audience": schema.SetAttribute{Description: schemaDescriptions["identity_provider.api_audience"], Computed: true, ElementType: types.StringType},
+ "resource": schema.StringAttribute{Description: schemaDescriptions["identity_provider.resource"], Computed: true},
+ "roles_claim": schema.StringAttribute{Description: schemaDescriptions["identity_provider.roles_claim"], Computed: true},
+ },
+ },
+ "endpoints": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["endpoints"],
+ Computed: true,
+ Attributes: map[string]schema.Attribute{
+ "url": schema.StringAttribute{Description: schemaDescriptions["endpoints.url"], Computed: true},
+ "redirect_url": schema.StringAttribute{Description: schemaDescriptions["endpoints.redirect_url"], Computed: true},
+ },
+ },
+ "dag_bundles": schema.ListNestedAttribute{
+ Description: "DAG bundles attached to this instance. Manage individual bundles via `stackit_workflows_dag_bundle`.",
+ Computed: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "name": schema.StringAttribute{Description: "Bundle name.", Computed: true},
+ "type": schema.StringAttribute{Description: "Bundle type: `git` or `s3`.", Computed: true},
+ "refresh_interval": schema.Int32Attribute{Description: "Bundle refresh interval in seconds.", Computed: true},
+ "url": schema.StringAttribute{Description: "Git repository URL (git bundles only).", Computed: true},
+ "branch": schema.StringAttribute{Description: "Git branch (git bundles only).", Computed: true},
+ "subdir": schema.StringAttribute{Description: "Subdirectory inside the Git repository (git bundles only).", Computed: true},
+ "bucket_name": schema.StringAttribute{Description: "S3 bucket name (s3 bundles only).", Computed: true},
+ "endpoint": schema.StringAttribute{Description: "S3 endpoint (s3 bundles only).", Computed: true},
+ "prefix": schema.StringAttribute{Description: "S3 key prefix (s3 bundles only).", Computed: true},
+ },
+ },
+ },
+ "status": schema.StringAttribute{
+ Description: schemaDescriptions["status"],
+ Computed: true,
+ },
+ "status_message": schema.StringAttribute{
+ Description: schemaDescriptions["status_message"],
+ Computed: true,
+ },
+ "created_at": schema.StringAttribute{
+ Description: schemaDescriptions["created_at"],
+ Computed: true,
+ },
+ },
+ }
+}
+
+func (d *instanceDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { //nolint:gocritic // function signature required by Terraform
+ var dsm datasourceModel
+ resp.Diagnostics.Append(req.Config.Get(ctx, &dsm)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := dsm.ProjectID.ValueString()
+ region := d.providerData.GetRegionWithOverride(dsm.Region)
+ instanceID := dsm.InstanceID.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "instance_id", instanceID)
+
+ instance, err := d.client.DefaultAPI.GetInstance(ctx, projectID, region, instanceID).Execute()
+ if err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ tfutils.LogError(
+ ctx, &resp.Diagnostics, err,
+ "Error reading Workflows instance",
+ fmt.Sprintf("Instance with ID %q does not exist in project %q.", instanceID, projectID),
+ map[int]string{
+ http.StatusForbidden: fmt.Sprintf("Project with ID %q not found or forbidden access", projectID),
+ },
+ )
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ m := Model{
+ InstanceID: dsm.InstanceID,
+ Region: dsm.Region,
+ ProjectID: dsm.ProjectID,
+ IdentityProvider: dsm.IdentityProvider,
+ }
+ if err := mapFields(ctx, instance, &m, region); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Workflows instance", fmt.Sprintf("Processing response: %v", err))
+ return
+ }
+ dagBundles, err := mapInstanceDagBundles(ctx, instance.DagBundles)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Workflows instance", fmt.Sprintf("Mapping dag_bundles: %v", err))
+ return
+ }
+
+ dsm.ID = m.ID
+ dsm.InstanceID = m.InstanceID
+ dsm.Region = m.Region
+ dsm.ProjectID = m.ProjectID
+ dsm.DisplayName = m.DisplayName
+ dsm.Description = m.Description
+ dsm.Version = m.Version
+ dsm.EnableStackitExampleDags = m.EnableStackitExampleDags
+ dsm.EnableAirflowExampleDags = m.EnableAirflowExampleDags
+ dsm.ObservabilityID = m.ObservabilityID
+ dsm.Network = m.Network
+ dsm.IdentityProvider = m.IdentityProvider
+ dsm.Endpoints = m.Endpoints
+ dsm.DagBundles = dagBundles
+ dsm.Status = m.Status
+ dsm.StatusMessage = m.StatusMessage
+ dsm.CreatedAt = m.CreatedAt
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, dsm)...)
+ tflog.Debug(ctx, "Workflows instance read", map[string]any{"instance_id": instanceID})
+}
+
+// mapInstanceDagBundles converts the embedded bundle list returned by the
+// Instance GET into a Terraform List value. The Instance variant of DagBundle
+// uses the request shape (GitDagBundle/S3DagBundle) rather than the response
+// shape that the per-bundle endpoint returns.
+func mapInstanceDagBundles(ctx context.Context, bundles []workflows.DagBundle) (types.List, error) {
+ objType := types.ObjectType{AttrTypes: dagBundleSummaryTypes}
+ if bundles == nil {
+ return types.ListNull(objType), nil
+ }
+ elements := make([]attr.Value, 0, len(bundles))
+ for i, b := range bundles {
+ bs := dagBundleSummary{}
+ switch {
+ case b.GitDagBundle != nil:
+ g := b.GitDagBundle
+ bs.Type = types.StringValue(workflowsUtils.BundleTypeGit)
+ bs.Name = types.StringValue(g.Name)
+ bs.URL = types.StringValue(g.Url)
+ bs.Branch = types.StringValue(g.Branch)
+ bs.Subdir = types.StringPointerValue(g.Subdir)
+ bs.RefreshInterval = types.Int32PointerValue(g.RefreshInterval)
+ case b.S3DagBundle != nil:
+ s := b.S3DagBundle
+ bs.Type = types.StringValue(workflowsUtils.BundleTypeS3)
+ bs.Name = types.StringValue(s.Name)
+ bs.BucketName = types.StringValue(s.BucketName)
+ bs.Endpoint = types.StringPointerValue(s.Endpoint)
+ bs.Prefix = types.StringPointerValue(s.Prefix)
+ bs.RefreshInterval = types.Int32PointerValue(s.RefreshInterval)
+ default:
+ return types.ListNull(objType), fmt.Errorf("unknown dag_bundle variant at index %d; upgrade the provider", i)
+ }
+ obj, diags := types.ObjectValueFrom(ctx, dagBundleSummaryTypes, bs)
+ if diags.HasError() {
+ return types.ListNull(objType), fmt.Errorf("mapping bundle at index %d: %w", i, core.DiagsToError(diags))
+ }
+ elements = append(elements, obj)
+ }
+ list, diags := types.ListValue(objType, elements)
+ if diags.HasError() {
+ return types.ListNull(objType), fmt.Errorf("building list: %w", core.DiagsToError(diags))
+ }
+ return list, nil
+}
diff --git a/stackit/internal/services/workflows/instance/resource.go b/stackit/internal/services/workflows/instance/resource.go
new file mode 100644
index 000000000..f27038a9f
--- /dev/null
+++ b/stackit/internal/services/workflows/instance/resource.go
@@ -0,0 +1,1056 @@
+package instance
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "net/http"
+ "regexp"
+ "strings"
+ "time"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/resource"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/objectplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/setplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+ "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi/wait"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
+ workflowsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/utils"
+ tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+const identityProviderTypeOAuth2 = "oauth2"
+
+var (
+ _ resource.Resource = &instanceResource{}
+ _ resource.ResourceWithConfigure = &instanceResource{}
+ _ resource.ResourceWithImportState = &instanceResource{}
+ _ resource.ResourceWithModifyPlan = &instanceResource{}
+ _ resource.ResourceWithValidateConfig = &instanceResource{}
+)
+
+var schemaDescriptions = map[string]string{
+ "id": "Terraform's internal resource ID. It is structured as \"`project_id`,`region`,`instance_id`\".",
+ "instance_id": "Workflows instance ID.",
+ "region": "STACKIT region. If not set, the provider region is used.",
+ "project_id": "STACKIT project ID associated with the Workflows instance.",
+ "display_name": "Instance display name. Max 25 characters.",
+ "description": "Instance description. Max 256 characters.",
+ "version": "Workflows version (e.g. `workflows-3.0-airflow-3.1`). Discover valid values via the `stackit_workflows_provider_options` data source.",
+ "enable_stackit_example_dags": "Include the STACKIT sample DAGs. Honored on Airflow 3 instances; older versions may reject.",
+ "enable_airflow_example_dags": "Enable the Airflow built-in example DAGs. Honored on Airflow 3 instances; older versions may reject.",
+ "observability_id": "STACKIT Observability instance to receive metrics and logs.",
+ "network": "Attach the instance to a STACKIT network. Changes force replacement.",
+ "network.id": "STACKIT network ID.",
+ "identity_provider": "Identity provider configuration. Only `oauth2` is currently supported.",
+ "identity_provider.type": "Identity provider type (`oauth2`).",
+ "identity_provider.name": "Display name for the IdP. `azure`, `okta`, `aws_cognito`, `keycloak` enable provider-specific token parsing.",
+ "identity_provider.client_id": "OAuth2 client ID.",
+ "identity_provider.client_secret": "OAuth2 client secret. Sensitive; must be re-sent on every IdP update.",
+ "identity_provider.scope": "OAuth2 scopes (space-separated, e.g. `openid email`).",
+ "identity_provider.discovery_endpoint": "OAuth2 discovery endpoint (`.well-known/openid-configuration`).",
+ "identity_provider.api_audience": "Allowed audiences for the ID token.",
+ "identity_provider.resource": "OAuth2 resource indicator.",
+ "identity_provider.roles_claim": "Name of the claim that carries the user's roles.",
+ "endpoints": "Instance endpoints. Populated by the server.",
+ "endpoints.url": "Primary endpoint URL (Airflow UI).",
+ "endpoints.redirect_url": "OAuth2 redirect URL configured on the instance.",
+ "status": fmt.Sprintf(
+ "Lifecycle status of the Workflows instance. %s",
+ tfutils.FormatPossibleValues(sdkUtils.EnumSliceToStringSlice(workflows.AllowedInstanceStatusEnumValues)...),
+ ),
+ "status_message": "Human-readable status detail. Populated by the server when status is `failed` or during convergence; empty otherwise.",
+ "created_at": "Creation timestamp (RFC 3339).",
+}
+
+type Model struct {
+ ID types.String `tfsdk:"id"`
+ InstanceID types.String `tfsdk:"instance_id"`
+ Region types.String `tfsdk:"region"`
+ ProjectID types.String `tfsdk:"project_id"`
+ DisplayName types.String `tfsdk:"display_name"`
+ Description types.String `tfsdk:"description"`
+ Version types.String `tfsdk:"version"`
+ EnableStackitExampleDags types.Bool `tfsdk:"enable_stackit_example_dags"`
+ EnableAirflowExampleDags types.Bool `tfsdk:"enable_airflow_example_dags"`
+ ObservabilityID types.String `tfsdk:"observability_id"`
+ Network types.Object `tfsdk:"network"`
+ IdentityProvider types.Object `tfsdk:"identity_provider"`
+ Endpoints types.Object `tfsdk:"endpoints"`
+ Status types.String `tfsdk:"status"`
+ StatusMessage types.String `tfsdk:"status_message"`
+ CreatedAt types.String `tfsdk:"created_at"`
+}
+
+type networkModel struct {
+ ID types.String `tfsdk:"id"`
+}
+
+var networkTypes = map[string]attr.Type{
+ "id": basetypes.StringType{},
+}
+
+type identityProviderModel struct {
+ Type types.String `tfsdk:"type"`
+ Name types.String `tfsdk:"name"`
+ ClientID types.String `tfsdk:"client_id"`
+ ClientSecret types.String `tfsdk:"client_secret"`
+ Scope types.String `tfsdk:"scope"`
+ DiscoveryEndpoint types.String `tfsdk:"discovery_endpoint"`
+ APIAudience types.Set `tfsdk:"api_audience"`
+ Resource types.String `tfsdk:"resource"`
+ RolesClaim types.String `tfsdk:"roles_claim"`
+}
+
+var identityProviderTypes = map[string]attr.Type{
+ "type": basetypes.StringType{},
+ "name": basetypes.StringType{},
+ "client_id": basetypes.StringType{},
+ "client_secret": basetypes.StringType{},
+ "scope": basetypes.StringType{},
+ "discovery_endpoint": basetypes.StringType{},
+ "api_audience": basetypes.SetType{ElemType: types.StringType},
+ "resource": basetypes.StringType{},
+ "roles_claim": basetypes.StringType{},
+}
+
+type endpointsModel struct {
+ URL types.String `tfsdk:"url"`
+ RedirectURL types.String `tfsdk:"redirect_url"`
+}
+
+var endpointsTypes = map[string]attr.Type{
+ "url": basetypes.StringType{},
+ "redirect_url": basetypes.StringType{},
+}
+
+type instanceResource struct {
+ client *workflows.APIClient
+ providerData core.ProviderData
+}
+
+func NewWorkflowsInstanceResource() resource.Resource {
+ return &instanceResource{}
+}
+
+func (r *instanceResource) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) {
+ providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
+ if !ok {
+ return
+ }
+ r.providerData = providerData
+
+ features.CheckExperimentEnabled(ctx, &r.providerData, features.WorkflowsExperiment, "stackit_workflows_instance", core.Resource, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ apiClient := workflowsUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ r.client = apiClient
+}
+
+// ModifyPlan normalizes the planned state before apply.
+func (r *instanceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) { //nolint:gocritic // function signature required by Terraform
+ var configModel Model
+ if req.Config.Raw.IsNull() {
+ return
+ }
+ resp.Diagnostics.Append(req.Config.Get(ctx, &configModel)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ var planModel Model
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &planModel)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ tfutils.AdaptRegion(ctx, configModel.Region, &planModel.Region, r.providerData.GetRegion(), resp)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ // Normalize description so plan == state regardless of how the user spells
+ // "no description":
+ // - empty literal (description = "") → null
+ // - attribute omitted from HCL (config null) → null, overriding any
+ // Unknown the framework would otherwise compute for an Optional+Computed
+ // attribute. This lets ClearableString send "" to the server on Update
+ // and treats "remove the line" as an intentional clear.
+ if configModel.Description.IsNull() {
+ planModel.Description = types.StringNull()
+ } else if !planModel.Description.IsUnknown() && planModel.Description.ValueString() == "" {
+ planModel.Description = types.StringNull()
+ }
+
+ resp.Diagnostics.Append(resp.Plan.Set(ctx, planModel)...)
+}
+
+// ValidateConfig catches misconfigured identity providers at plan time so the
+// user sees a precise error instead of a generic server rejection on Create.
+// Unknown values (e.g. unresolved variables) are deferred.
+func (r *instanceResource) ValidateConfig(ctx context.Context, req resource.ValidateConfigRequest, resp *resource.ValidateConfigResponse) {
+ var model Model
+ resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ validateInstanceConfig(ctx, &model, &resp.Diagnostics)
+}
+
+func validateInstanceConfig(ctx context.Context, model *Model, diags *diag.Diagnostics) {
+ if model.IdentityProvider.IsNull() || model.IdentityProvider.IsUnknown() {
+ return
+ }
+ var ipm identityProviderModel
+ if d := model.IdentityProvider.As(ctx, &ipm, basetypes.ObjectAsOptions{UnhandledNullAsEmpty: true, UnhandledUnknownAsEmpty: true}); d.HasError() {
+ return
+ }
+ if ipm.Type.IsNull() || ipm.Type.IsUnknown() || ipm.Type.ValueString() != identityProviderTypeOAuth2 {
+ return
+ }
+ required := []struct {
+ name string
+ val types.String
+ }{
+ {"client_id", ipm.ClientID},
+ {"client_secret", ipm.ClientSecret},
+ {"scope", ipm.Scope},
+ {"discovery_endpoint", ipm.DiscoveryEndpoint},
+ }
+ for _, f := range required {
+ if f.val.IsUnknown() {
+ continue
+ }
+ if f.val.IsNull() || f.val.ValueString() == "" {
+ diags.AddError(
+ "Invalid Workflows instance config",
+ fmt.Sprintf("identity_provider.%s is required when identity_provider.type = oauth2.", f.name),
+ )
+ }
+ }
+}
+
+func (r *instanceResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_workflows_instance"
+}
+
+func (r *instanceResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) {
+ description := fmt.Sprintf("Workflows instance resource schema. %s", core.ResourceRegionFallbackDocstring)
+ resp.Schema = schema.Schema{
+ Description: description,
+ MarkdownDescription: features.AddExperimentDescription(description, features.WorkflowsExperiment, core.Resource),
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: schemaDescriptions["id"],
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "instance_id": schema.StringAttribute{
+ Description: schemaDescriptions["instance_id"],
+ Computed: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "project_id": schema.StringAttribute{
+ Description: schemaDescriptions["project_id"],
+ Required: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ },
+ "region": schema.StringAttribute{
+ Description: schemaDescriptions["region"],
+ Optional: true,
+ // must be computed to allow for storing the override value from the provider
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ },
+ "display_name": schema.StringAttribute{
+ Description: schemaDescriptions["display_name"],
+ Required: true,
+ // Server rejects displayName on UpdateInstance (FieldNotAllowed),
+ // so any change must recreate the instance.
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.RequiresReplace(),
+ },
+ Validators: []validator.String{
+ stringvalidator.UTF8LengthAtMost(25),
+ },
+ },
+ "description": schema.StringAttribute{
+ Description: schemaDescriptions["description"],
+ Optional: true,
+ Computed: true,
+ Validators: []validator.String{
+ stringvalidator.UTF8LengthAtMost(256),
+ },
+ },
+ "version": schema.StringAttribute{
+ Description: schemaDescriptions["version"],
+ Required: true,
+ Validators: []validator.String{
+ workflowsUtils.Airflow3Version(),
+ },
+ },
+ "enable_stackit_example_dags": schema.BoolAttribute{
+ Description: schemaDescriptions["enable_stackit_example_dags"],
+ Optional: true,
+ Computed: true,
+ Default: booldefault.StaticBool(false),
+ },
+ "enable_airflow_example_dags": schema.BoolAttribute{
+ Description: schemaDescriptions["enable_airflow_example_dags"],
+ Optional: true,
+ Computed: true,
+ Default: booldefault.StaticBool(false),
+ },
+ "observability_id": schema.StringAttribute{
+ Description: schemaDescriptions["observability_id"],
+ Optional: true,
+ Computed: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "network": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["network"],
+ Optional: true,
+ PlanModifiers: []planmodifier.Object{
+ // No update endpoint exists for `network` — adding, removing, or
+ // changing the block requires recreating the instance.
+ objectplanmodifier.RequiresReplace(),
+ },
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: schemaDescriptions["network.id"],
+ Required: true,
+ Validators: []validator.String{
+ validate.UUID(),
+ validate.NoSeparator(),
+ },
+ },
+ },
+ },
+ "identity_provider": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["identity_provider"],
+ Required: true,
+ Attributes: map[string]schema.Attribute{
+ "type": schema.StringAttribute{
+ Description: schemaDescriptions["identity_provider.type"],
+ Required: true,
+ Validators: []validator.String{
+ stringvalidator.OneOf(identityProviderTypeOAuth2),
+ },
+ },
+ "name": schema.StringAttribute{
+ Description: schemaDescriptions["identity_provider.name"],
+ Optional: true,
+ },
+ "client_id": schema.StringAttribute{
+ Description: schemaDescriptions["identity_provider.client_id"],
+ Optional: true,
+ },
+ "client_secret": schema.StringAttribute{
+ Description: schemaDescriptions["identity_provider.client_secret"],
+ Optional: true,
+ Sensitive: true,
+ },
+ "scope": schema.StringAttribute{
+ Description: schemaDescriptions["identity_provider.scope"],
+ Optional: true,
+ },
+ "discovery_endpoint": schema.StringAttribute{
+ Description: schemaDescriptions["identity_provider.discovery_endpoint"],
+ Optional: true,
+ Validators: []validator.String{
+ workflowsUtils.URLHTTPSOnly(),
+ },
+ },
+ "api_audience": schema.SetAttribute{
+ Description: schemaDescriptions["identity_provider.api_audience"],
+ Optional: true,
+ Computed: true,
+ ElementType: types.StringType,
+ PlanModifiers: []planmodifier.Set{
+ setplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "resource": schema.StringAttribute{
+ Description: schemaDescriptions["identity_provider.resource"],
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "roles_claim": schema.StringAttribute{
+ Description: schemaDescriptions["identity_provider.roles_claim"],
+ Optional: true,
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ },
+ },
+ "endpoints": schema.SingleNestedAttribute{
+ Description: schemaDescriptions["endpoints"],
+ Computed: true,
+ PlanModifiers: []planmodifier.Object{
+ // Endpoints don't change on update; reuse state to keep plan output quiet.
+ objectplanmodifier.UseStateForUnknown(),
+ },
+ Attributes: map[string]schema.Attribute{
+ "url": schema.StringAttribute{
+ Description: schemaDescriptions["endpoints.url"],
+ Computed: true,
+ },
+ "redirect_url": schema.StringAttribute{
+ Description: schemaDescriptions["endpoints.redirect_url"],
+ Computed: true,
+ },
+ },
+ },
+ "status": schema.StringAttribute{
+ Description: schemaDescriptions["status"],
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "status_message": schema.StringAttribute{
+ Description: schemaDescriptions["status_message"],
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ "created_at": schema.StringAttribute{
+ Description: schemaDescriptions["created_at"],
+ Computed: true,
+ PlanModifiers: []planmodifier.String{
+ stringplanmodifier.UseStateForUnknown(),
+ },
+ },
+ },
+ }
+}
+
+func (r *instanceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := model.ProjectID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+
+ payload, err := toCreatePayload(ctx, &model)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Workflows instance", fmt.Sprintf("Building API payload: %v", err))
+ return
+ }
+
+ createResp, err := r.client.DefaultAPI.CreateInstance(ctx, projectID, region).CreateInstancePayload(*payload).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Workflows instance", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ if createResp == nil || createResp.Id == "" {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Workflows instance", "Create API response: incomplete response (id missing)")
+ return
+ }
+ instanceID := createResp.Id
+
+ // Persist identifiers before the wait so cancellation/timeouts don't orphan state.
+ ctx = tfutils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
+ "project_id": projectID,
+ "region": region,
+ "instance_id": instanceID,
+ })
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ waitResp, err := wait.CreateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectID, region, instanceID).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Workflows instance", fmt.Sprintf("Waiting for instance to become active: %v", err))
+ return
+ }
+
+ if err := mapFields(ctx, waitResp, &model, region); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error creating Workflows instance", fmt.Sprintf("Processing response: %v", err))
+ return
+ }
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Info(ctx, "Workflows instance created", map[string]any{"instance_id": instanceID})
+}
+
+func (r *instanceResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.State.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := model.ProjectID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ instanceID := model.InstanceID.ValueString()
+
+ if instanceID == "" {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "instance_id", instanceID)
+
+ instance, err := r.client.DefaultAPI.GetInstance(ctx, projectID, region, instanceID).Execute()
+ if err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ resp.State.RemoveResource(ctx)
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Workflows instance", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ if err := mapFields(ctx, instance, &model, region); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Workflows instance", fmt.Sprintf("Processing response: %v", err))
+ return
+ }
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
+ tflog.Info(ctx, "Workflows instance read", map[string]any{"instance_id": instanceID})
+}
+
+func (r *instanceResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { //nolint:gocritic // function signature required by Terraform
+ var plan, state Model
+ resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ resp.Diagnostics.Append(req.State.Get(ctx, &state)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := plan.ProjectID.ValueString()
+ region := r.providerData.GetRegionWithOverride(plan.Region)
+ instanceID := plan.InstanceID.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "instance_id", instanceID)
+
+ // Capture planned values that mapFields would overwrite from the server
+ // response BEFORE persist runs, so sub-step gating later in Update still
+ // compares plan vs prior-state (not plan vs persisted-state).
+ plannedObservabilityID := plan.ObservabilityID
+ plannedIdentityProvider := plan.IdentityProvider
+
+ // Persist state after each successful sub-step. mapFields carries forward
+ // the prior client_secret from plan.IdentityProvider; that's correct AFTER
+ // the IdP sub-step has succeeded, but BEFORE it runs we must use the
+ // PRIOR state's secret — otherwise a partial-failure scenario (instance
+ // PATCH succeeds, IdP PATCH then 5xxs) would land the new planned secret
+ // in state while the server still holds the old one, and the API never
+ // returns the secret to detect the drift.
+ idpStepRan := false
+ persist := func(waitResp *workflows.Instance) bool {
+ if !idpStepRan {
+ savedIdP := plan.IdentityProvider
+ plan.IdentityProvider = state.IdentityProvider
+ defer func() { plan.IdentityProvider = savedIdP }()
+ }
+ if err := mapFields(ctx, waitResp, &plan, region); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows instance", fmt.Sprintf("Processing response: %v", err))
+ return false
+ }
+ resp.Diagnostics.Append(resp.State.Set(ctx, plan)...)
+ return !resp.Diagnostics.HasError()
+ }
+
+ // Each sub-step writes state twice: once with the API response (so a later
+ // wait timeout doesn't lose the user's input — e.g. a rotated client_secret),
+ // then again with the settled wait response (refreshes status/endpoints).
+ if instanceFieldsChanged(&plan, &state) {
+ payload := toUpdateInstancePayload(&plan, &state)
+ apiResp, err := r.client.DefaultAPI.UpdateInstance(ctx, projectID, region, instanceID).UpdateInstancePayload(*payload).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows instance", fmt.Sprintf("Calling UpdateInstance: %v", err))
+ return
+ }
+ if !persist(apiResp) {
+ return
+ }
+ waitResp, err := wait.UpdateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectID, region, instanceID).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows instance", fmt.Sprintf("Waiting for instance update: %v", err))
+ return
+ }
+ if !persist(waitResp) {
+ return
+ }
+ }
+
+ if !plannedIdentityProvider.Equal(state.IdentityProvider) {
+ // Restore the planned IdP onto `plan` so toUpdateIdentityProviderPayload
+ // reads the user's intent (prior persist may have substituted state.IdP).
+ plan.IdentityProvider = plannedIdentityProvider
+ payload, err := toUpdateIdentityProviderPayload(ctx, &plan, &state)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows instance", fmt.Sprintf("Building identity provider payload: %v", err))
+ return
+ }
+ apiResp, err := r.client.DefaultAPI.UpdateIdentityProvider(ctx, projectID, region, instanceID).UpdateIdentityProviderPayload(*payload).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows instance", fmt.Sprintf("Calling UpdateIdentityProvider: %v", err))
+ return
+ }
+ idpStepRan = true
+ if !persist(apiResp) {
+ return
+ }
+ waitResp, err := wait.UpdateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectID, region, instanceID).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows instance", fmt.Sprintf("Waiting for identity provider update: %v", err))
+ return
+ }
+ if !persist(waitResp) {
+ return
+ }
+ }
+
+ if !plannedObservabilityID.Equal(state.ObservabilityID) {
+ payload := &workflows.UpdateObservabilityPayload{
+ ObservabilityId: conversion.ClearableString(plannedObservabilityID, state.ObservabilityID),
+ }
+ apiResp, err := r.client.DefaultAPI.UpdateObservability(ctx, projectID, region, instanceID).UpdateObservabilityPayload(*payload).Execute()
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows instance", fmt.Sprintf("Calling UpdateObservability: %v", err))
+ return
+ }
+ if !persist(apiResp) {
+ return
+ }
+ waitResp, err := wait.UpdateInstanceWaitHandler(ctx, r.client.DefaultAPI, projectID, region, instanceID).WaitWithContext(ctx)
+ if err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error updating Workflows instance", fmt.Sprintf("Waiting for observability update: %v", err))
+ return
+ }
+ if !persist(waitResp) {
+ return
+ }
+ }
+
+ tflog.Info(ctx, "Workflows instance updated", map[string]any{"instance_id": instanceID})
+}
+
+func (r *instanceResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.State.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := model.ProjectID.ValueString()
+ region := r.providerData.GetRegionWithOverride(model.Region)
+ instanceID := model.InstanceID.ValueString()
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+ ctx = tflog.SetField(ctx, "instance_id", instanceID)
+
+ if err := r.client.DefaultAPI.DeleteInstance(ctx, projectID, region, instanceID).Execute(); err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Workflows instance", fmt.Sprintf("Calling API: %v", err))
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ if _, err := wait.DeleteInstanceWaitHandler(ctx, r.client.DefaultAPI, projectID, region, instanceID).WaitWithContext(ctx); err != nil {
+ var oapiErr *oapierror.GenericOpenAPIError
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ return
+ }
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error deleting Workflows instance", fmt.Sprintf("Waiting for deletion: %v", err))
+ return
+ }
+ tflog.Info(ctx, "Workflows instance deleted", map[string]any{"instance_id": instanceID})
+}
+
+// The expected format of the resource import identifier is: project_id,region,instance_id
+func (r *instanceResource) ImportState(ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse) {
+ idParts := strings.Split(req.ID, core.Separator)
+ if len(idParts) != 3 || idParts[0] == "" || idParts[1] == "" || idParts[2] == "" {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing Workflows instance", fmt.Sprintf("Invalid import ID %q: expected format is `project_id`,`region`,`instance_id`", req.ID))
+ return
+ }
+ if !uuidRE.MatchString(idParts[0]) || !uuidRE.MatchString(idParts[2]) {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error importing Workflows instance", fmt.Sprintf("Invalid import ID %q: project_id and instance_id must be UUIDs", req.ID))
+ return
+ }
+ ctx = tfutils.SetAndLogStateFields(ctx, &resp.Diagnostics, &resp.State, map[string]any{
+ "project_id": idParts[0],
+ "region": idParts[1],
+ "instance_id": idParts[2],
+ })
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ tflog.Debug(ctx, "Workflows instance state imported")
+}
+
+var uuidRE = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$`)
+
+func boolOrFalse(b *bool) types.Bool {
+ if b == nil {
+ return types.BoolValue(false)
+ }
+ return types.BoolPointerValue(b)
+}
+
+func toCreatePayload(ctx context.Context, model *Model) (*workflows.CreateInstancePayload, error) {
+ if model == nil {
+ return nil, errors.New("missing model")
+ }
+
+ idp, err := buildIdentityProvider(ctx, model)
+ if err != nil {
+ return nil, fmt.Errorf("building identity provider: %w", err)
+ }
+
+ // Description: empty literal is treated as "not set" so a Create with
+ // `description = ""` does not seed "" into server state and then drift to
+ // null on the next Update.
+ payload := &workflows.CreateInstancePayload{
+ DisplayName: model.DisplayName.ValueString(),
+ Description: conversion.NilIfEmpty(model.Description),
+ Version: model.Version.ValueString(),
+ EnableStackitExampleDags: conversion.BoolValueToPointer(model.EnableStackitExampleDags),
+ EnableAirflowExampleDags: conversion.BoolValueToPointer(model.EnableAirflowExampleDags),
+ ObservabilityId: conversion.StringValueToPointer(model.ObservabilityID),
+ IdentityProvider: idp,
+ }
+
+ network, err := buildNetwork(ctx, model)
+ if err != nil {
+ return nil, fmt.Errorf("building network: %w", err)
+ }
+ payload.Network = network
+
+ return payload, nil
+}
+
+// instanceFieldsChanged returns true when at least one UpdateInstance-routable
+// field differs. display_name is intentionally NOT checked here — the server
+// rejects it on update, so the schema marks it RequiresReplace.
+func instanceFieldsChanged(plan, state *Model) bool {
+ return !plan.Description.Equal(state.Description) ||
+ !plan.Version.Equal(state.Version) ||
+ !plan.EnableStackitExampleDags.Equal(state.EnableStackitExampleDags) ||
+ !plan.EnableAirflowExampleDags.Equal(state.EnableAirflowExampleDags)
+}
+
+// toUpdateInstancePayload assembles an UpdateInstance PATCH payload. The
+// server treats description == "" as a clear, so we send "" when the user
+// removed a previously-set description. DisplayName is intentionally omitted —
+// server rejects it on update.
+func toUpdateInstancePayload(plan, state *Model) *workflows.UpdateInstancePayload {
+ return &workflows.UpdateInstancePayload{
+ Description: conversion.ClearableString(plan.Description, state.Description),
+ Version: conversion.StringValueToPointer(plan.Version),
+ EnableStackitExampleDags: conversion.BoolValueToPointer(plan.EnableStackitExampleDags),
+ EnableAirflowExampleDags: conversion.BoolValueToPointer(plan.EnableAirflowExampleDags),
+ }
+}
+
+func buildNetwork(ctx context.Context, model *Model) (*workflows.Network, error) {
+ if model.Network.IsNull() || model.Network.IsUnknown() {
+ return nil, nil
+ }
+ var nm networkModel
+ diags := model.Network.As(ctx, &nm, basetypes.ObjectAsOptions{})
+ if diags.HasError() {
+ return nil, fmt.Errorf("converting network object: %w", core.DiagsToError(diags))
+ }
+ return &workflows.Network{Id: conversion.StringValueToPointer(nm.ID)}, nil
+}
+
+func buildIdentityProvider(ctx context.Context, model *Model) (*workflows.IdentityProvider, error) {
+ if model.IdentityProvider.IsNull() || model.IdentityProvider.IsUnknown() {
+ return nil, errors.New("identity_provider is required")
+ }
+ var ipm identityProviderModel
+ diags := model.IdentityProvider.As(ctx, &ipm, basetypes.ObjectAsOptions{})
+ if diags.HasError() {
+ return nil, fmt.Errorf("converting identity_provider object: %w", core.DiagsToError(diags))
+ }
+
+ // Only oauth2 is reachable: the schema validator rejects any other value at
+ // plan time. The default branch is defensive in case the validator and the
+ // builder ever drift.
+ switch ipm.Type.ValueString() {
+ case identityProviderTypeOAuth2:
+ audience, err := conversion.StringSetToSlice(ipm.APIAudience)
+ if err != nil {
+ return nil, fmt.Errorf("api_audience: %w", err)
+ }
+ oauth := &workflows.OAuth2IdentityProvider{
+ Type: workflows.OAUTH2IDENTITYPROVIDERTYPE_OAUTH2,
+ Name: ipm.Name.ValueString(),
+ ClientId: ipm.ClientID.ValueString(),
+ ClientSecret: ipm.ClientSecret.ValueString(),
+ Scope: ipm.Scope.ValueString(),
+ DiscoveryEndpoint: ipm.DiscoveryEndpoint.ValueString(),
+ ApiAudience: audience,
+ Resource: conversion.StringValueToPointer(ipm.Resource),
+ RolesClaim: conversion.StringValueToPointer(ipm.RolesClaim),
+ }
+ wrapped := workflows.OAuth2IdentityProviderAsIdentityProvider(oauth)
+ return &wrapped, nil
+ default:
+ return nil, fmt.Errorf("unsupported identity_provider type %q", ipm.Type.ValueString())
+ }
+}
+
+// toUpdateIdentityProviderPayload builds the PATCH payload for the IdP.
+//
+// client_secret is always sent (when present in plan). The server requires
+// re-supplying the secret whenever client_id or discovery_endpoint change as a
+// credential-leak defense — without it, an attacker who could rotate just the
+// URL would inherit the existing secret. Resource/roles_claim use "empty
+// string clears" semantics, so a user removing the field translates to "".
+//
+// Note: OAuth2IdentityProviderPatch has no `Type` field in the OAS — the
+// server's discriminated union resolver infers the variant from the absence of
+// StackIT-specific fields.
+func toUpdateIdentityProviderPayload(ctx context.Context, plan, state *Model) (*workflows.UpdateIdentityProviderPayload, error) {
+ if plan.IdentityProvider.IsNull() || plan.IdentityProvider.IsUnknown() {
+ return nil, errors.New("identity_provider is required")
+ }
+ var ipm identityProviderModel
+ diags := plan.IdentityProvider.As(ctx, &ipm, basetypes.ObjectAsOptions{})
+ if diags.HasError() {
+ return nil, fmt.Errorf("converting identity_provider object: %w", core.DiagsToError(diags))
+ }
+
+ priorResource, priorRolesClaim := types.StringNull(), types.StringNull()
+ if !state.IdentityProvider.IsNull() && !state.IdentityProvider.IsUnknown() {
+ var prior identityProviderModel
+ if diags := state.IdentityProvider.As(ctx, &prior, basetypes.ObjectAsOptions{}); !diags.HasError() {
+ priorResource = prior.Resource
+ priorRolesClaim = prior.RolesClaim
+ }
+ }
+
+ switch ipm.Type.ValueString() {
+ case identityProviderTypeOAuth2:
+ audience, err := conversion.StringSetToSlice(ipm.APIAudience)
+ if err != nil {
+ return nil, fmt.Errorf("api_audience: %w", err)
+ }
+ patch := &workflows.OAuth2IdentityProviderPatch{
+ Name: conversion.StringValueToPointer(ipm.Name),
+ ClientId: conversion.StringValueToPointer(ipm.ClientID),
+ ClientSecret: conversion.StringValueToPointer(ipm.ClientSecret),
+ Scope: conversion.StringValueToPointer(ipm.Scope),
+ DiscoveryEndpoint: conversion.StringValueToPointer(ipm.DiscoveryEndpoint),
+ ApiAudience: audience,
+ Resource: conversion.ClearableString(ipm.Resource, priorResource),
+ RolesClaim: conversion.ClearableString(ipm.RolesClaim, priorRolesClaim),
+ }
+ wrapped := workflows.OAuth2IdentityProviderPatchAsUpdateIdentityProviderPayload(patch)
+ return &wrapped, nil
+ default:
+ return nil, fmt.Errorf("unsupported identity_provider type %q", ipm.Type.ValueString())
+ }
+}
+
+func mapFields(ctx context.Context, instance *workflows.Instance, model *Model, region string) error {
+ if instance == nil {
+ return errors.New("instance is nil")
+ }
+ if model == nil {
+ return errors.New("model is nil")
+ }
+
+ var instanceID string
+ switch {
+ case model.InstanceID.ValueString() != "":
+ instanceID = model.InstanceID.ValueString()
+ case instance.Id != "":
+ instanceID = instance.Id
+ default:
+ return errors.New("instance id not present")
+ }
+
+ model.ID = tfutils.BuildInternalTerraformId(model.ProjectID.ValueString(), region, instanceID)
+ model.InstanceID = types.StringValue(instanceID)
+ model.Region = types.StringValue(region)
+ model.DisplayName = types.StringValue(instance.DisplayName)
+ model.Description = types.StringPointerValue(instance.Description)
+ model.Version = types.StringValue(instance.Version)
+ // The server returns null for the example-dag flags on every instance we've
+ // observed (Airflow 2 and 3). To avoid a perpetual diff against the schema
+ // default of `false`, treat null as `false`.
+ model.EnableStackitExampleDags = boolOrFalse(instance.EnableStackitExampleDags)
+ model.EnableAirflowExampleDags = boolOrFalse(instance.EnableAirflowExampleDags)
+ model.ObservabilityID = types.StringPointerValue(instance.ObservabilityId)
+ model.Status = types.StringValue(string(instance.Status))
+ model.StatusMessage = types.StringPointerValue(instance.StatusMessage)
+ model.CreatedAt = types.StringValue(instance.CreatedAt.Format(time.RFC3339))
+
+ if err := mapNetwork(ctx, instance, model); err != nil {
+ return fmt.Errorf("mapping network: %w", err)
+ }
+ if err := mapIdentityProvider(ctx, instance, model); err != nil {
+ return fmt.Errorf("mapping identity_provider: %w", err)
+ }
+ if err := mapEndpoints(ctx, instance, model); err != nil {
+ return fmt.Errorf("mapping endpoints: %w", err)
+ }
+
+ return nil
+}
+
+func mapNetwork(ctx context.Context, instance *workflows.Instance, model *Model) error {
+ if instance.Network == nil {
+ model.Network = types.ObjectNull(networkTypes)
+ return nil
+ }
+ val, diags := types.ObjectValueFrom(ctx, networkTypes, networkModel{
+ ID: types.StringPointerValue(instance.Network.Id),
+ })
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ model.Network = val
+ return nil
+}
+
+// mapIdentityProvider carries the existing client_secret forward — the API
+// never returns it. The plan/state value loaded into `model` before mapFields
+// is the source of truth for the secret.
+func mapIdentityProvider(ctx context.Context, instance *workflows.Instance, model *Model) error {
+ existingClientSecret := types.StringNull()
+ if !model.IdentityProvider.IsNull() && !model.IdentityProvider.IsUnknown() {
+ var prior identityProviderModel
+ if diags := model.IdentityProvider.As(ctx, &prior, basetypes.ObjectAsOptions{}); !diags.HasError() {
+ existingClientSecret = prior.ClientSecret
+ }
+ }
+
+ ipm := identityProviderModel{
+ APIAudience: types.SetNull(types.StringType),
+ ClientSecret: existingClientSecret,
+ }
+
+ switch {
+ case instance.IdentityProvider.OAuth2IdentityProvider != nil:
+ oauth := instance.IdentityProvider.OAuth2IdentityProvider
+ ipm.Type = types.StringValue(string(oauth.Type))
+ ipm.Name = types.StringValue(oauth.Name)
+ ipm.ClientID = types.StringValue(oauth.ClientId)
+ ipm.Scope = types.StringValue(oauth.Scope)
+ ipm.DiscoveryEndpoint = types.StringValue(oauth.DiscoveryEndpoint)
+ ipm.Resource = types.StringPointerValue(oauth.Resource)
+ ipm.RolesClaim = types.StringPointerValue(oauth.RolesClaim)
+ // Distinguish nil (API field omitted → null) from [] (empty list).
+ if oauth.ApiAudience != nil {
+ audience, diags := types.SetValueFrom(ctx, types.StringType, oauth.ApiAudience)
+ if diags.HasError() {
+ return fmt.Errorf("api_audience: %w", core.DiagsToError(diags))
+ }
+ ipm.APIAudience = audience
+ }
+ case instance.IdentityProvider.StackITIdentityProvider != nil:
+ // The schema only accepts oauth2 in config; if the server returns a
+ // stackit-typed IdP, writing `type = "stackit"` into state would brick
+ // every subsequent plan against the OneOf validator. Refuse with an
+ // actionable message instead.
+ return fmt.Errorf("server returned a STACKIT identity provider, which this provider version does not support; upgrade the provider")
+ default:
+ return fmt.Errorf("server returned an unknown identity_provider variant; upgrade the provider to a version that supports it")
+ }
+
+ val, diags := types.ObjectValueFrom(ctx, identityProviderTypes, ipm)
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ model.IdentityProvider = val
+ return nil
+}
+
+func mapEndpoints(ctx context.Context, instance *workflows.Instance, model *Model) error {
+ val, diags := types.ObjectValueFrom(ctx, endpointsTypes, endpointsModel{
+ URL: types.StringValue(instance.Endpoints.Url),
+ RedirectURL: types.StringValue(instance.Endpoints.RedirectUrl),
+ })
+ if diags.HasError() {
+ return core.DiagsToError(diags)
+ }
+ model.Endpoints = val
+ return nil
+}
diff --git a/stackit/internal/services/workflows/instance/resource_test.go b/stackit/internal/services/workflows/instance/resource_test.go
new file mode 100644
index 000000000..7230533b7
--- /dev/null
+++ b/stackit/internal/services/workflows/instance/resource_test.go
@@ -0,0 +1,552 @@
+package instance
+
+import (
+ "context"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/go-cmp/cmp"
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ sdkUtils "github.com/stackitcloud/stackit-sdk-go/core/utils"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+)
+
+var testTime = time.Now().UTC()
+
+func ptrString(p *string) string {
+ if p == nil {
+ return ""
+ }
+ return *p
+}
+
+func fixtureInstance(mods ...func(instance *workflows.Instance)) *workflows.Instance {
+ oauth := &workflows.OAuth2IdentityProvider{
+ Type: workflows.OAUTH2IDENTITYPROVIDERTYPE_OAUTH2,
+ Name: "azure",
+ ClientId: "client-id",
+ ClientSecret: "REDACTED-NEVER-RETURNED",
+ Scope: "openid email",
+ DiscoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
+ }
+ instance := &workflows.Instance{
+ Id: "iid",
+ ProjectId: "pid",
+ RegionId: "eu01",
+ DisplayName: "myinst",
+ Version: "workflows-3.0-airflow-3.1",
+ Status: workflows.INSTANCESTATUS_ACTIVE,
+ CreatedAt: testTime,
+ Endpoints: workflows.Endpoints{Url: "https://...stackit.cloud", RedirectUrl: "https://...stackit.cloud/oauth-callback"},
+ IdentityProvider: workflows.OAuth2IdentityProviderAsIdentityProvider(oauth),
+ }
+ for _, mod := range mods {
+ mod(instance)
+ }
+ return instance
+}
+
+func fixtureIdentityProviderObject(t *testing.T, mods ...func(*identityProviderModel)) types.Object {
+ t.Helper()
+ ipm := identityProviderModel{
+ Type: types.StringValue("oauth2"),
+ Name: types.StringValue("azure"),
+ ClientID: types.StringValue("client-id"),
+ ClientSecret: types.StringValue("PLANNED-SECRET"),
+ Scope: types.StringValue("openid email"),
+ DiscoveryEndpoint: types.StringValue("https://idp.example.com/.well-known/openid-configuration"),
+ APIAudience: types.SetNull(types.StringType),
+ Resource: types.StringNull(),
+ RolesClaim: types.StringNull(),
+ }
+ for _, mod := range mods {
+ mod(&ipm)
+ }
+ v, diags := types.ObjectValueFrom(context.Background(), identityProviderTypes, ipm)
+ if diags.HasError() {
+ t.Fatalf("building identity_provider fixture: %v", diags.Errors())
+ }
+ return v
+}
+
+func fixtureEndpointsObject(t *testing.T) types.Object {
+ t.Helper()
+ v, diags := types.ObjectValueFrom(context.Background(), endpointsTypes, endpointsModel{
+ URL: types.StringValue("https://...stackit.cloud"),
+ RedirectURL: types.StringValue("https://...stackit.cloud/oauth-callback"),
+ })
+ if diags.HasError() {
+ t.Fatalf("building endpoints fixture: %v", diags.Errors())
+ }
+ return v
+}
+
+func TestMapFields(t *testing.T) {
+ tests := []struct {
+ description string
+ input *workflows.Instance
+ priorModel *Model
+ expected *Model
+ expectErr bool
+ }{
+ {
+ description: "default — oauth2, no network, no observability, no audience",
+ input: fixtureInstance(),
+ priorModel: &Model{
+ ProjectID: types.StringValue("pid"),
+ InstanceID: types.StringValue("iid"),
+ IdentityProvider: fixtureIdentityProviderObject(t),
+ },
+ expected: &Model{
+ ID: types.StringValue("pid,eu01,iid"),
+ InstanceID: types.StringValue("iid"),
+ Region: types.StringValue("eu01"),
+ ProjectID: types.StringValue("pid"),
+ DisplayName: types.StringValue("myinst"),
+ Description: types.StringNull(),
+ Version: types.StringValue("workflows-3.0-airflow-3.1"),
+ EnableStackitExampleDags: types.BoolValue(false),
+ EnableAirflowExampleDags: types.BoolValue(false),
+ ObservabilityID: types.StringNull(),
+ Network: types.ObjectNull(networkTypes),
+ IdentityProvider: fixtureIdentityProviderObject(t),
+ Endpoints: fixtureEndpointsObject(t),
+ Status: types.StringValue("active"),
+ CreatedAt: types.StringValue(testTime.Format(time.RFC3339)),
+ },
+ },
+ {
+ description: "with network + observability + audience + flags",
+ input: fixtureInstance(func(i *workflows.Instance) {
+ i.Description = sdkUtils.Ptr("hello")
+ i.EnableStackitExampleDags = sdkUtils.Ptr(true)
+ i.EnableAirflowExampleDags = sdkUtils.Ptr(false)
+ i.ObservabilityId = sdkUtils.Ptr("00000000-0000-0000-0000-000000000001")
+ i.Network = &workflows.Network{Id: sdkUtils.Ptr("00000000-0000-0000-0000-000000000002")}
+ oauth := &workflows.OAuth2IdentityProvider{
+ Type: workflows.OAUTH2IDENTITYPROVIDERTYPE_OAUTH2,
+ Name: "azure",
+ ClientId: "client-id",
+ ClientSecret: "REDACTED-NEVER-RETURNED",
+ Scope: "openid email",
+ DiscoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
+ ApiAudience: []string{"audience-a", "audience-b"},
+ }
+ i.IdentityProvider = workflows.OAuth2IdentityProviderAsIdentityProvider(oauth)
+ }),
+ priorModel: &Model{
+ ProjectID: types.StringValue("pid"),
+ InstanceID: types.StringValue("iid"),
+ IdentityProvider: fixtureIdentityProviderObject(t),
+ },
+ expected: &Model{
+ ID: types.StringValue("pid,eu01,iid"),
+ InstanceID: types.StringValue("iid"),
+ Region: types.StringValue("eu01"),
+ ProjectID: types.StringValue("pid"),
+ DisplayName: types.StringValue("myinst"),
+ Description: types.StringValue("hello"),
+ Version: types.StringValue("workflows-3.0-airflow-3.1"),
+ EnableStackitExampleDags: types.BoolValue(true),
+ EnableAirflowExampleDags: types.BoolValue(false),
+ ObservabilityID: types.StringValue("00000000-0000-0000-0000-000000000001"),
+ Network: types.ObjectValueMust(networkTypes, map[string]attr.Value{
+ "id": types.StringValue("00000000-0000-0000-0000-000000000002"),
+ }),
+ IdentityProvider: fixtureIdentityProviderObject(t, func(ipm *identityProviderModel) {
+ ipm.APIAudience = types.SetValueMust(types.StringType, []attr.Value{
+ types.StringValue("audience-a"),
+ types.StringValue("audience-b"),
+ })
+ }),
+ Endpoints: fixtureEndpointsObject(t),
+ Status: types.StringValue("active"),
+ CreatedAt: types.StringValue(testTime.Format(time.RFC3339)),
+ },
+ },
+ {
+ description: "preserves client_secret from prior model when API doesn't return it",
+ input: fixtureInstance(),
+ priorModel: &Model{
+ ProjectID: types.StringValue("pid"),
+ InstanceID: types.StringValue("iid"),
+ IdentityProvider: fixtureIdentityProviderObject(t, func(ipm *identityProviderModel) {
+ ipm.ClientSecret = types.StringValue("ORIGINAL-PLAN-SECRET")
+ }),
+ },
+ expected: &Model{
+ ID: types.StringValue("pid,eu01,iid"),
+ InstanceID: types.StringValue("iid"),
+ Region: types.StringValue("eu01"),
+ ProjectID: types.StringValue("pid"),
+ DisplayName: types.StringValue("myinst"),
+ Description: types.StringNull(),
+ Version: types.StringValue("workflows-3.0-airflow-3.1"),
+ EnableStackitExampleDags: types.BoolValue(false),
+ EnableAirflowExampleDags: types.BoolValue(false),
+ ObservabilityID: types.StringNull(),
+ Network: types.ObjectNull(networkTypes),
+ IdentityProvider: fixtureIdentityProviderObject(t, func(ipm *identityProviderModel) {
+ ipm.ClientSecret = types.StringValue("ORIGINAL-PLAN-SECRET")
+ }),
+ Endpoints: fixtureEndpointsObject(t),
+ Status: types.StringValue("active"),
+ CreatedAt: types.StringValue(testTime.Format(time.RFC3339)),
+ },
+ },
+ {
+ description: "empty api_audience list yields empty (not null) list in state",
+ input: fixtureInstance(func(i *workflows.Instance) {
+ oauth := &workflows.OAuth2IdentityProvider{
+ Type: workflows.OAUTH2IDENTITYPROVIDERTYPE_OAUTH2,
+ Name: "azure",
+ ClientId: "client-id",
+ ClientSecret: "REDACTED",
+ Scope: "openid email",
+ DiscoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
+ ApiAudience: []string{},
+ }
+ i.IdentityProvider = workflows.OAuth2IdentityProviderAsIdentityProvider(oauth)
+ }),
+ priorModel: &Model{
+ ProjectID: types.StringValue("pid"),
+ InstanceID: types.StringValue("iid"),
+ IdentityProvider: fixtureIdentityProviderObject(t),
+ },
+ expected: &Model{
+ ID: types.StringValue("pid,eu01,iid"),
+ InstanceID: types.StringValue("iid"),
+ Region: types.StringValue("eu01"),
+ ProjectID: types.StringValue("pid"),
+ DisplayName: types.StringValue("myinst"),
+ Description: types.StringNull(),
+ Version: types.StringValue("workflows-3.0-airflow-3.1"),
+ EnableStackitExampleDags: types.BoolValue(false),
+ EnableAirflowExampleDags: types.BoolValue(false),
+ ObservabilityID: types.StringNull(),
+ Network: types.ObjectNull(networkTypes),
+ IdentityProvider: fixtureIdentityProviderObject(t, func(ipm *identityProviderModel) {
+ ipm.APIAudience = types.SetValueMust(types.StringType, []attr.Value{})
+ }),
+ Endpoints: fixtureEndpointsObject(t),
+ Status: types.StringValue("active"),
+ CreatedAt: types.StringValue(testTime.Format(time.RFC3339)),
+ },
+ },
+ {
+ description: "nil instance fails",
+ input: nil,
+ priorModel: &Model{},
+ expectErr: true,
+ },
+ {
+ description: "missing instance id fails",
+ input: fixtureInstance(func(i *workflows.Instance) { i.Id = "" }),
+ priorModel: &Model{ProjectID: types.StringValue("pid")},
+ expectErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ err := mapFields(context.Background(), tt.input, tt.priorModel, "eu01")
+ if (err != nil) != tt.expectErr {
+ t.Fatalf("mapFields error = %v, expectErr = %v", err, tt.expectErr)
+ }
+ if tt.expectErr {
+ return
+ }
+ if diff := cmp.Diff(tt.expected, tt.priorModel, cmp.Comparer(func(a, b basetypes.ObjectValue) bool { return a.Equal(b) })); diff != "" {
+ t.Errorf("mapFields mismatch (-want +got):\n%s", diff)
+ }
+ })
+ }
+}
+
+func TestToCreatePayload(t *testing.T) {
+ ctx := context.Background()
+ model := &Model{
+ DisplayName: types.StringValue("myinst"),
+ Description: types.StringValue("hello"),
+ Version: types.StringValue("workflows-3.0-airflow-3.1"),
+ EnableStackitExampleDags: types.BoolValue(true),
+ EnableAirflowExampleDags: types.BoolValue(false),
+ ObservabilityID: types.StringValue("00000000-0000-0000-0000-000000000001"),
+ Network: types.ObjectValueMust(networkTypes, map[string]attr.Value{
+ "id": types.StringValue("00000000-0000-0000-0000-000000000002"),
+ }),
+ IdentityProvider: fixtureIdentityProviderObject(t),
+ }
+
+ payload, err := toCreatePayload(ctx, model)
+ if err != nil {
+ t.Fatalf("toCreatePayload: %v", err)
+ }
+ if payload.DisplayName != "myinst" {
+ t.Errorf("DisplayName = %q, want %q", payload.DisplayName, "myinst")
+ }
+ if got := ptrString(payload.Description); got != "hello" {
+ t.Errorf("Description = %q, want %q", got, "hello")
+ }
+ if payload.Version != "workflows-3.0-airflow-3.1" {
+ t.Errorf("Version = %q", payload.Version)
+ }
+ if payload.EnableStackitExampleDags == nil || *payload.EnableStackitExampleDags != true {
+ t.Errorf("EnableStackitExampleDags = %v, want true", payload.EnableStackitExampleDags)
+ }
+ if payload.Network == nil || ptrString(payload.Network.Id) != "00000000-0000-0000-0000-000000000002" {
+ t.Errorf("Network.Id mismatch: %+v", payload.Network)
+ }
+ if payload.IdentityProvider == nil || payload.IdentityProvider.OAuth2IdentityProvider == nil {
+ t.Fatalf("IdentityProvider not wrapped as OAuth2")
+ }
+ if payload.IdentityProvider.OAuth2IdentityProvider.ClientSecret != "PLANNED-SECRET" {
+ t.Errorf("ClientSecret = %q, want PLANNED-SECRET", payload.IdentityProvider.OAuth2IdentityProvider.ClientSecret)
+ }
+}
+
+func TestBuildUpdateInstancePayload_OmitsDisplayName(t *testing.T) {
+ plan := &Model{
+ DisplayName: types.StringValue("does-not-matter"),
+ Description: types.StringValue("hello"),
+ Version: types.StringValue("v"),
+ EnableStackitExampleDags: types.BoolValue(true),
+ EnableAirflowExampleDags: types.BoolValue(false),
+ }
+ state := &Model{Description: types.StringValue("hello")}
+ payload := toUpdateInstancePayload(plan, state)
+
+ raw, err := payload.MarshalJSON()
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if got := string(raw); strings.Contains(got, "displayName") {
+ t.Errorf("payload must not include displayName, got: %s", got)
+ }
+
+ if got := ptrString(payload.Description); got != "hello" {
+ t.Errorf("Description = %q", got)
+ }
+ if got := ptrString(payload.Version); got != "v" {
+ t.Errorf("Version = %q", got)
+ }
+ if payload.EnableStackitExampleDags == nil || *payload.EnableStackitExampleDags != true {
+ t.Errorf("EnableStackitExampleDags = %v", payload.EnableStackitExampleDags)
+ }
+ if payload.EnableAirflowExampleDags == nil || *payload.EnableAirflowExampleDags != false {
+ t.Errorf("EnableAirflowExampleDags = %v", payload.EnableAirflowExampleDags)
+ }
+}
+
+// TestBuildUpdateInstancePayload_ClearsDescription verifies that removing
+// description from the config (plan-null while state had a value) results in
+// `""` being sent — the server uses empty string as the clear signal.
+func TestBuildUpdateInstancePayload_ClearsDescription(t *testing.T) {
+ plan := &Model{Description: types.StringNull()}
+ state := &Model{Description: types.StringValue("had this")}
+ payload := toUpdateInstancePayload(plan, state)
+ if payload.Description == nil {
+ t.Fatalf("Description should be set to \"\" to clear, got nil")
+ }
+ if *payload.Description != "" {
+ t.Errorf("Description = %q, want \"\"", *payload.Description)
+ }
+}
+
+// TestBuildUpdateInstancePayload_OmitsUnsetDescription verifies that an
+// always-unset description doesn't end up in the payload as "". Sending "" on
+// every update would clobber server-side defaults / future schema changes.
+func TestBuildUpdateInstancePayload_OmitsUnsetDescription(t *testing.T) {
+ plan := &Model{Description: types.StringNull()}
+ state := &Model{Description: types.StringNull()}
+ payload := toUpdateInstancePayload(plan, state)
+ if payload.Description != nil {
+ t.Errorf("Description should be nil when never set, got %q", *payload.Description)
+ }
+}
+
+func TestBuildUpdateIdentityProviderPayload_OAuth2_OmitsType(t *testing.T) {
+ plan := &Model{IdentityProvider: fixtureIdentityProviderObject(t)}
+ state := &Model{IdentityProvider: fixtureIdentityProviderObject(t)}
+ payload, err := toUpdateIdentityProviderPayload(context.Background(), plan, state)
+ if err != nil {
+ t.Fatalf("toUpdateIdentityProviderPayload: %v", err)
+ }
+ if payload.OAuth2IdentityProviderPatch == nil {
+ t.Fatalf("expected OAuth2IdentityProviderPatch variant")
+ }
+
+ raw, err := payload.MarshalJSON()
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ if got := string(raw); strings.Contains(got, `"type"`) {
+ t.Errorf("OAuth2IdentityProviderPatch payload must not include 'type', got: %s", got)
+ }
+ if ptrString(payload.OAuth2IdentityProviderPatch.ClientId) != "client-id" {
+ t.Errorf("ClientId mismatch")
+ }
+ if ptrString(payload.OAuth2IdentityProviderPatch.ClientSecret) != "PLANNED-SECRET" {
+ t.Errorf("ClientSecret mismatch")
+ }
+}
+
+// TestBuildUpdateIdentityProviderPayload_ClearsOptionalStrings verifies that
+// removing optional IdP fields (resource, roles_claim) from the config
+// translates to "" so the server clears them.
+func TestBuildUpdateIdentityProviderPayload_ClearsOptionalStrings(t *testing.T) {
+ plan := &Model{
+ IdentityProvider: fixtureIdentityProviderObject(t, func(ipm *identityProviderModel) {
+ ipm.Resource = types.StringNull()
+ ipm.RolesClaim = types.StringNull()
+ }),
+ }
+ state := &Model{
+ IdentityProvider: fixtureIdentityProviderObject(t, func(ipm *identityProviderModel) {
+ ipm.Resource = types.StringValue("had-resource")
+ ipm.RolesClaim = types.StringValue("had-claim")
+ }),
+ }
+ payload, err := toUpdateIdentityProviderPayload(context.Background(), plan, state)
+ if err != nil {
+ t.Fatalf("toUpdateIdentityProviderPayload: %v", err)
+ }
+ if payload.OAuth2IdentityProviderPatch.Resource == nil || *payload.OAuth2IdentityProviderPatch.Resource != "" {
+ t.Errorf("Resource = %v, want pointer to \"\"", payload.OAuth2IdentityProviderPatch.Resource)
+ }
+ if payload.OAuth2IdentityProviderPatch.RolesClaim == nil || *payload.OAuth2IdentityProviderPatch.RolesClaim != "" {
+ t.Errorf("RolesClaim = %v, want pointer to \"\"", payload.OAuth2IdentityProviderPatch.RolesClaim)
+ }
+}
+
+func TestBuildIdentityProvider_UnsupportedType(t *testing.T) {
+ model := &Model{
+ IdentityProvider: fixtureIdentityProviderObject(t, func(ipm *identityProviderModel) {
+ ipm.Type = types.StringValue("ftp")
+ }),
+ }
+ if _, err := buildIdentityProvider(context.Background(), model); err == nil {
+ t.Errorf("expected error for unsupported type")
+ }
+}
+
+func TestInstanceFieldsChanged(t *testing.T) {
+ base := &Model{
+ DisplayName: types.StringValue("a"),
+ Description: types.StringValue("a"),
+ Version: types.StringValue("v"),
+ EnableStackitExampleDags: types.BoolValue(false),
+ EnableAirflowExampleDags: types.BoolValue(false),
+ }
+ tests := []struct {
+ desc string
+ mut func(*Model)
+ want bool
+ }{
+ {"unchanged", func(m *Model) {}, false},
+ // display_name is RequiresReplace (server rejects on update) — not in instanceFieldsChanged.
+ {"displayName changed → no UpdateInstance call", func(m *Model) { m.DisplayName = types.StringValue("b") }, false},
+ {"description changed", func(m *Model) { m.Description = types.StringValue("b") }, true},
+ {"version changed", func(m *Model) { m.Version = types.StringValue("v2") }, true},
+ {"enable_stackit_example_dags changed", func(m *Model) { m.EnableStackitExampleDags = types.BoolValue(true) }, true},
+ {"enable_airflow_example_dags changed", func(m *Model) { m.EnableAirflowExampleDags = types.BoolValue(true) }, true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ plan := *base
+ tt.mut(&plan)
+ if got := instanceFieldsChanged(&plan, base); got != tt.want {
+ t.Errorf("got %v, want %v", got, tt.want)
+ }
+ })
+ }
+}
+
+// TestToCreatePayload_EmptyDescriptionOmitted regression: writing
+// `description = ""` on Create must not send "" to the server — that would
+// race with the server's normalize-on-Update logic.
+func TestToCreatePayload_EmptyDescriptionOmitted(t *testing.T) {
+ model := &Model{
+ DisplayName: types.StringValue("x"),
+ Description: types.StringValue(""),
+ Version: types.StringValue("v"),
+ IdentityProvider: fixtureIdentityProviderObject(t),
+ }
+ payload, err := toCreatePayload(context.Background(), model)
+ if err != nil {
+ t.Fatalf("toCreatePayload: %v", err)
+ }
+ if payload.Description != nil {
+ t.Errorf("Description should be nil for empty-string plan, got %q", *payload.Description)
+ }
+}
+
+func TestValidateInstanceConfig(t *testing.T) {
+ ctx := context.Background()
+ tests := []struct {
+ desc string
+ mut func(*identityProviderModel)
+ idpType string // "oauth2" (default), "stackit", or "none" to test top-level nil/skip paths
+ wantErrs []string
+ }{
+ {desc: "oauth2 happy path", mut: nil},
+ {desc: "oauth2 missing client_id", mut: func(ipm *identityProviderModel) { ipm.ClientID = types.StringValue("") }, wantErrs: []string{"client_id"}},
+ {desc: "oauth2 null client_secret", mut: func(ipm *identityProviderModel) { ipm.ClientSecret = types.StringNull() }, wantErrs: []string{"client_secret"}},
+ {desc: "oauth2 missing scope", mut: func(ipm *identityProviderModel) { ipm.Scope = types.StringValue("") }, wantErrs: []string{"scope"}},
+ {desc: "oauth2 missing discovery_endpoint", mut: func(ipm *identityProviderModel) { ipm.DiscoveryEndpoint = types.StringValue("") }, wantErrs: []string{"discovery_endpoint"}},
+ {desc: "oauth2 multiple missing", mut: func(ipm *identityProviderModel) {
+ ipm.ClientID = types.StringValue("")
+ ipm.Scope = types.StringNull()
+ }, wantErrs: []string{"client_id", "scope"}},
+ {desc: "unknown defers (e.g. unresolved variable)", mut: func(ipm *identityProviderModel) { ipm.ClientSecret = types.StringUnknown() }},
+ {desc: "stackit type skipped (no oauth2 required fields)", idpType: "stackit"},
+ {desc: "null identity_provider skipped (schema marks Required, framework catches null)", idpType: "none"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ var idp types.Object
+ switch tt.idpType {
+ case "stackit":
+ idp = fixtureIdentityProviderObject(t, func(ipm *identityProviderModel) { ipm.Type = types.StringValue("stackit") })
+ case "none":
+ idp = types.ObjectNull(identityProviderTypes)
+ default:
+ if tt.mut != nil {
+ idp = fixtureIdentityProviderObject(t, tt.mut)
+ } else {
+ idp = fixtureIdentityProviderObject(t)
+ }
+ }
+ model := &Model{IdentityProvider: idp}
+ var diags diag.Diagnostics
+ validateInstanceConfig(ctx, model, &diags)
+
+ if len(tt.wantErrs) == 0 {
+ if diags.HasError() {
+ t.Fatalf("expected no errors, got: %v", diags.Errors())
+ }
+ return
+ }
+ gotMsgs := make([]string, 0, len(diags.Errors()))
+ for _, d := range diags.Errors() {
+ gotMsgs = append(gotMsgs, d.Detail())
+ }
+ for _, want := range tt.wantErrs {
+ found := false
+ for _, msg := range gotMsgs {
+ if strings.Contains(msg, want) {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("expected an error mentioning %q, got: %v", want, gotMsgs)
+ }
+ }
+ })
+ }
+}
diff --git a/stackit/internal/services/workflows/instances/datasource.go b/stackit/internal/services/workflows/instances/datasource.go
new file mode 100644
index 000000000..1ed7d3d02
--- /dev/null
+++ b/stackit/internal/services/workflows/instances/datasource.go
@@ -0,0 +1,176 @@
+package instances
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
+ workflowsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/utils"
+ tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/validate"
+)
+
+var _ datasource.DataSource = &instancesDataSource{}
+
+type Model struct {
+ ID types.String `tfsdk:"id"`
+ ProjectID types.String `tfsdk:"project_id"`
+ Region types.String `tfsdk:"region"`
+ Instances types.List `tfsdk:"instances"`
+}
+
+type instanceSummary struct {
+ InstanceID types.String `tfsdk:"instance_id"`
+ DisplayName types.String `tfsdk:"display_name"`
+ Description types.String `tfsdk:"description"`
+ Version types.String `tfsdk:"version"`
+ Status types.String `tfsdk:"status"`
+ CreatedAt types.String `tfsdk:"created_at"`
+}
+
+var instanceSummaryTypes = map[string]attr.Type{
+ "instance_id": basetypes.StringType{},
+ "display_name": basetypes.StringType{},
+ "description": basetypes.StringType{},
+ "version": basetypes.StringType{},
+ "status": basetypes.StringType{},
+ "created_at": basetypes.StringType{},
+}
+
+type instancesDataSource struct {
+ client *workflows.APIClient
+ providerData core.ProviderData
+}
+
+func NewWorkflowsInstancesDataSource() datasource.DataSource {
+ return &instancesDataSource{}
+}
+
+// Metadata returns the data source type name.
+func (d *instancesDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_workflows_instances"
+}
+
+// Configure adds the provider configured client to the data source.
+func (d *instancesDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
+ if !ok {
+ return
+ }
+ d.providerData = providerData
+
+ features.CheckExperimentEnabled(ctx, &d.providerData, features.WorkflowsExperiment, "stackit_workflows_instances", core.Datasource, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ apiClient := workflowsUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ d.client = apiClient
+}
+
+// Schema defines the schema for the data source.
+func (d *instancesDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ description := fmt.Sprintf("Lists all Workflows instances in a project. %s", core.DatasourceRegionFallbackDocstring)
+ resp.Schema = schema.Schema{
+ Description: description,
+ MarkdownDescription: features.AddExperimentDescription(description, features.WorkflowsExperiment, core.Datasource),
+ Attributes: map[string]schema.Attribute{
+ "id": schema.StringAttribute{
+ Description: "Terraform's internal data-source ID. It is structured as \"`project_id`,`region`\".",
+ Computed: true,
+ },
+ "project_id": schema.StringAttribute{
+ Description: "STACKIT project ID.",
+ Required: true,
+ Validators: []validator.String{validate.UUID(), validate.NoSeparator()},
+ },
+ "region": schema.StringAttribute{
+ Description: "STACKIT region name. If not defined, the provider region is used.",
+ Optional: true,
+ Computed: true,
+ },
+ "instances": schema.ListNestedAttribute{
+ Description: "All Workflows instances in this project + region.",
+ Computed: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "instance_id": schema.StringAttribute{Description: "The Workflows instance ID.", Computed: true},
+ "display_name": schema.StringAttribute{Description: "Display name of the instance.", Computed: true},
+ "description": schema.StringAttribute{Description: "User-provided description.", Computed: true},
+ "version": schema.StringAttribute{Description: "Workflows version.", Computed: true},
+ "status": schema.StringAttribute{Description: "Lifecycle status.", Computed: true},
+ "created_at": schema.StringAttribute{Description: "Creation timestamp (RFC 3339).", Computed: true},
+ },
+ },
+ },
+ },
+ }
+}
+
+// Read reads the data source and writes its result to Terraform state.
+func (d *instancesDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ projectID := model.ProjectID.ValueString()
+ region := d.providerData.GetRegionWithOverride(model.Region)
+ ctx = tflog.SetField(ctx, "project_id", projectID)
+ ctx = tflog.SetField(ctx, "region", region)
+
+ listResp, err := d.client.DefaultAPI.ListInstances(ctx, projectID, region).Execute()
+ if err != nil {
+ tfutils.LogError(ctx, &resp.Diagnostics, err, "Error listing Workflows instances", fmt.Sprintf("Project %q region %q", projectID, region), nil)
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ model.Region = types.StringValue(region)
+ model.ID = types.StringValue(fmt.Sprintf("%s,%s", projectID, region))
+ objType := types.ObjectType{AttrTypes: instanceSummaryTypes}
+ elements := make([]attr.Value, 0, len(listResp.GetInstances()))
+ for i := range listResp.Instances {
+ inst := listResp.Instances[i]
+ obj, diags := types.ObjectValueFrom(ctx, instanceSummaryTypes, instanceSummary{
+ InstanceID: types.StringValue(inst.Id),
+ DisplayName: types.StringValue(inst.DisplayName),
+ Description: types.StringPointerValue(inst.Description),
+ Version: types.StringValue(inst.Version),
+ Status: types.StringValue(string(inst.Status)),
+ CreatedAt: types.StringValue(inst.CreatedAt.Format(time.RFC3339)),
+ })
+ if diags.HasError() {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error listing Workflows instances", fmt.Sprintf("Mapping instance %s: %v", inst.Id, diags.Errors()))
+ return
+ }
+ elements = append(elements, obj)
+ }
+ list, diags := types.ListValue(objType, elements)
+ if diags.HasError() {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error listing Workflows instances", fmt.Sprintf("Building list: %v", diags.Errors()))
+ return
+ }
+ model.Instances = list
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
+ tflog.Info(ctx, "Workflows instances listed", map[string]any{"count": len(elements)})
+}
diff --git a/stackit/internal/services/workflows/provideroptions/datasource.go b/stackit/internal/services/workflows/provideroptions/datasource.go
new file mode 100644
index 000000000..2d5ed3631
--- /dev/null
+++ b/stackit/internal/services/workflows/provideroptions/datasource.go
@@ -0,0 +1,172 @@
+package provideroptions
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/hashicorp/terraform-plugin-framework/attr"
+ "github.com/hashicorp/terraform-plugin-framework/datasource"
+ "github.com/hashicorp/terraform-plugin-framework/datasource/schema"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+ "github.com/hashicorp/terraform-plugin-framework/types/basetypes"
+ "github.com/hashicorp/terraform-plugin-log/tflog"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/conversion"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/features"
+ workflowsUtils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/utils"
+ tfutils "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+)
+
+var _ datasource.DataSource = &providerOptionsDataSource{}
+
+type Model struct {
+ Region types.String `tfsdk:"region"`
+ Versions types.List `tfsdk:"versions"`
+}
+
+type versionModel struct {
+ Version types.String `tfsdk:"version"`
+ State types.String `tfsdk:"state"`
+ ExpirationDate types.String `tfsdk:"expiration_date"`
+}
+
+var versionTypes = map[string]attr.Type{
+ "version": basetypes.StringType{},
+ "state": basetypes.StringType{},
+ "expiration_date": basetypes.StringType{},
+}
+
+type providerOptionsDataSource struct {
+ client *workflows.APIClient
+ providerData core.ProviderData
+}
+
+func NewWorkflowsProviderOptionsDataSource() datasource.DataSource {
+ return &providerOptionsDataSource{}
+}
+
+// Metadata returns the data source type name.
+func (d *providerOptionsDataSource) Metadata(_ context.Context, req datasource.MetadataRequest, resp *datasource.MetadataResponse) {
+ resp.TypeName = req.ProviderTypeName + "_workflows_provider_options"
+}
+
+// Configure adds the provider configured client to the data source.
+func (d *providerOptionsDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) {
+ providerData, ok := conversion.ParseProviderData(ctx, req.ProviderData, &resp.Diagnostics)
+ if !ok {
+ return
+ }
+ d.providerData = providerData
+
+ features.CheckExperimentEnabled(ctx, &d.providerData, features.WorkflowsExperiment, "stackit_workflows_provider_options", core.Datasource, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ apiClient := workflowsUtils.ConfigureClient(ctx, &providerData, &resp.Diagnostics)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+ d.client = apiClient
+}
+
+// Schema defines the schema for the data source.
+func (d *providerOptionsDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, resp *datasource.SchemaResponse) {
+ description := fmt.Sprintf("Lists Workflows versions supported by the Workflows API in a region. %s", core.DatasourceRegionFallbackDocstring)
+ resp.Schema = schema.Schema{
+ Description: description,
+ MarkdownDescription: features.AddExperimentDescription(description, features.WorkflowsExperiment, core.Datasource),
+ Attributes: map[string]schema.Attribute{
+ "region": schema.StringAttribute{
+ Description: "STACKIT region to query. If not defined, the provider region is used.",
+ Optional: true,
+ Computed: true,
+ },
+ "versions": schema.ListNestedAttribute{
+ Description: "Supported Workflows versions.",
+ Computed: true,
+ NestedObject: schema.NestedAttributeObject{
+ Attributes: map[string]schema.Attribute{
+ "version": schema.StringAttribute{
+ Description: "Version identifier (e.g. `workflows-3.0-airflow-3.1`).",
+ Computed: true,
+ },
+ "state": schema.StringAttribute{
+ Description: "Lifecycle state of the version.",
+ Computed: true,
+ },
+ "expiration_date": schema.StringAttribute{
+ Description: "RFC 3339 timestamp at which the version expires, or null if there is no scheduled expiry.",
+ Computed: true,
+ },
+ },
+ },
+ },
+ },
+ }
+}
+
+// Read reads the data source and writes its result to Terraform state.
+func (d *providerOptionsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { //nolint:gocritic // function signature required by Terraform
+ var model Model
+ resp.Diagnostics.Append(req.Config.Get(ctx, &model)...)
+ if resp.Diagnostics.HasError() {
+ return
+ }
+
+ ctx = core.InitProviderContext(ctx)
+
+ region := d.providerData.GetRegionWithOverride(model.Region)
+ ctx = tflog.SetField(ctx, "region", region)
+
+ options, err := d.client.DefaultAPI.GetProviderOptions(ctx, region).Execute()
+ if err != nil {
+ tfutils.LogError(ctx, &resp.Diagnostics, err, "Error reading Workflows provider options", fmt.Sprintf("Region %q", region), nil)
+ return
+ }
+ ctx = core.LogResponse(ctx)
+
+ model.Region = types.StringValue(region)
+ if err := mapVersions(ctx, options, &model); err != nil {
+ core.LogAndAddError(ctx, &resp.Diagnostics, "Error reading Workflows provider options", fmt.Sprintf("Processing response: %v", err))
+ return
+ }
+
+ resp.Diagnostics.Append(resp.State.Set(ctx, model)...)
+ tflog.Info(ctx, "Workflows provider options read", map[string]any{"region": region, "count": len(options.GetVersions())})
+}
+
+func mapVersions(ctx context.Context, options *workflows.ProviderOptions, model *Model) error {
+ objType := types.ObjectType{AttrTypes: versionTypes}
+ if options == nil || options.Versions == nil {
+ model.Versions = types.ListNull(objType)
+ return nil
+ }
+
+ elements := make([]attr.Value, 0, len(options.Versions))
+ for i := range options.Versions {
+ v := options.Versions[i]
+ exp := types.StringNull()
+ if v.ExpirationDate != nil {
+ exp = types.StringValue(v.ExpirationDate.Format(time.RFC3339))
+ }
+ obj, diags := types.ObjectValueFrom(ctx, versionTypes, versionModel{
+ Version: types.StringValue(v.Version),
+ State: types.StringValue(v.State),
+ ExpirationDate: exp,
+ })
+ if diags.HasError() {
+ return fmt.Errorf("%v", diags.Errors())
+ }
+ elements = append(elements, obj)
+ }
+ list, diags := types.ListValue(objType, elements)
+ if diags.HasError() {
+ return fmt.Errorf("%v", diags.Errors())
+ }
+ model.Versions = list
+ return nil
+}
diff --git a/stackit/internal/services/workflows/testdata/dagbundle-git-no-subdir.tf b/stackit/internal/services/workflows/testdata/dagbundle-git-no-subdir.tf
new file mode 100644
index 000000000..6f42090c4
--- /dev/null
+++ b/stackit/internal/services/workflows/testdata/dagbundle-git-no-subdir.tf
@@ -0,0 +1,48 @@
+variable "project_id" {}
+variable "region" {}
+variable "display_name" {}
+variable "instance_version" {}
+variable "idp_name" {}
+variable "idp_client_id" {}
+variable "idp_client_secret" {}
+variable "idp_scope" {}
+variable "idp_discovery_endpoint" {}
+variable "bundle_name" {}
+variable "bundle_url" {}
+variable "bundle_branch" {}
+variable "bundle_username" {}
+variable "bundle_password" {}
+variable "bundle_subdir" {} // ignored — subdir intentionally omitted to test PATCH-clearing
+
+resource "stackit_workflows_instance" "workflow" {
+ project_id = var.project_id
+ region = var.region
+ display_name = var.display_name
+ version = var.instance_version
+
+ identity_provider = {
+ type = "oauth2"
+ name = var.idp_name
+ client_id = var.idp_client_id
+ client_secret = var.idp_client_secret
+ scope = var.idp_scope
+ discovery_endpoint = var.idp_discovery_endpoint
+ }
+}
+
+resource "stackit_workflows_dag_bundle" "bundle" {
+ project_id = var.project_id
+ region = var.region
+ instance_id = stackit_workflows_instance.workflow.instance_id
+
+ name = var.bundle_name
+ git = {
+ url = var.bundle_url
+ branch = var.bundle_branch
+ auth = {
+ type = "basic"
+ username = var.bundle_username
+ password = var.bundle_password
+ }
+ }
+}
diff --git a/stackit/internal/services/workflows/testdata/dagbundle-git.tf b/stackit/internal/services/workflows/testdata/dagbundle-git.tf
new file mode 100644
index 000000000..86f63a777
--- /dev/null
+++ b/stackit/internal/services/workflows/testdata/dagbundle-git.tf
@@ -0,0 +1,49 @@
+variable "project_id" {}
+variable "region" {}
+variable "display_name" {}
+variable "instance_version" {}
+variable "idp_name" {}
+variable "idp_client_id" {}
+variable "idp_client_secret" {}
+variable "idp_scope" {}
+variable "idp_discovery_endpoint" {}
+variable "bundle_name" {}
+variable "bundle_url" {}
+variable "bundle_branch" {}
+variable "bundle_username" {}
+variable "bundle_password" {}
+variable "bundle_subdir" {}
+
+resource "stackit_workflows_instance" "workflow" {
+ project_id = var.project_id
+ region = var.region
+ display_name = var.display_name
+ version = var.instance_version
+
+ identity_provider = {
+ type = "oauth2"
+ name = var.idp_name
+ client_id = var.idp_client_id
+ client_secret = var.idp_client_secret
+ scope = var.idp_scope
+ discovery_endpoint = var.idp_discovery_endpoint
+ }
+}
+
+resource "stackit_workflows_dag_bundle" "bundle" {
+ project_id = var.project_id
+ region = var.region
+ instance_id = stackit_workflows_instance.workflow.instance_id
+
+ name = var.bundle_name
+ git = {
+ url = var.bundle_url
+ branch = var.bundle_branch
+ subdir = var.bundle_subdir
+ auth = {
+ type = "basic"
+ username = var.bundle_username
+ password = var.bundle_password
+ }
+ }
+}
diff --git a/stackit/internal/services/workflows/testdata/dagbundle-s3.tf b/stackit/internal/services/workflows/testdata/dagbundle-s3.tf
new file mode 100644
index 000000000..c273977f6
--- /dev/null
+++ b/stackit/internal/services/workflows/testdata/dagbundle-s3.tf
@@ -0,0 +1,49 @@
+variable "project_id" {}
+variable "region" {}
+variable "display_name" {}
+variable "instance_version" {}
+variable "idp_name" {}
+variable "idp_client_id" {}
+variable "idp_client_secret" {}
+variable "idp_scope" {}
+variable "idp_discovery_endpoint" {}
+variable "bundle_name" {}
+variable "bucket_name" {}
+variable "endpoint" {}
+variable "prefix" {}
+variable "access_key_id" {}
+variable "secret_access_key" {}
+
+resource "stackit_workflows_instance" "workflow" {
+ project_id = var.project_id
+ region = var.region
+ display_name = var.display_name
+ version = var.instance_version
+
+ identity_provider = {
+ type = "oauth2"
+ name = var.idp_name
+ client_id = var.idp_client_id
+ client_secret = var.idp_client_secret
+ scope = var.idp_scope
+ discovery_endpoint = var.idp_discovery_endpoint
+ }
+}
+
+resource "stackit_workflows_dag_bundle" "bundle" {
+ project_id = var.project_id
+ region = var.region
+ instance_id = stackit_workflows_instance.workflow.instance_id
+
+ name = var.bundle_name
+ s3 = {
+ bucket_name = var.bucket_name
+ endpoint = var.endpoint
+ prefix = var.prefix
+ auth = {
+ type = "access_key"
+ access_key_id = var.access_key_id
+ secret_access_key = var.secret_access_key
+ }
+ }
+}
diff --git a/stackit/internal/services/workflows/testdata/instance-no-description.tf b/stackit/internal/services/workflows/testdata/instance-no-description.tf
new file mode 100644
index 000000000..74e9784d3
--- /dev/null
+++ b/stackit/internal/services/workflows/testdata/instance-no-description.tf
@@ -0,0 +1,26 @@
+variable "project_id" {}
+variable "region" {}
+variable "display_name" {}
+variable "description" {} // ignored — description intentionally omitted from the resource to test PATCH-clearing
+variable "instance_version" {}
+variable "idp_name" {}
+variable "idp_client_id" {}
+variable "idp_client_secret" {}
+variable "idp_scope" {}
+variable "idp_discovery_endpoint" {}
+
+resource "stackit_workflows_instance" "workflow" {
+ project_id = var.project_id
+ region = var.region
+ display_name = var.display_name
+ version = var.instance_version
+
+ identity_provider = {
+ type = "oauth2"
+ name = var.idp_name
+ client_id = var.idp_client_id
+ client_secret = var.idp_client_secret
+ scope = var.idp_scope
+ discovery_endpoint = var.idp_discovery_endpoint
+ }
+}
diff --git a/stackit/internal/services/workflows/testdata/instance-stackit-idp.tf b/stackit/internal/services/workflows/testdata/instance-stackit-idp.tf
new file mode 100644
index 000000000..959ef3911
--- /dev/null
+++ b/stackit/internal/services/workflows/testdata/instance-stackit-idp.tf
@@ -0,0 +1,15 @@
+variable "project_id" {}
+variable "region" {}
+variable "display_name" {}
+variable "instance_version" {}
+
+resource "stackit_workflows_instance" "workflow" {
+ project_id = var.project_id
+ region = var.region
+ display_name = var.display_name
+ version = var.instance_version
+
+ identity_provider = {
+ type = "stackit"
+ }
+}
diff --git a/stackit/internal/services/workflows/testdata/instance.tf b/stackit/internal/services/workflows/testdata/instance.tf
new file mode 100644
index 000000000..cbff56c3a
--- /dev/null
+++ b/stackit/internal/services/workflows/testdata/instance.tf
@@ -0,0 +1,27 @@
+variable "project_id" {}
+variable "region" {}
+variable "display_name" {}
+variable "description" {}
+variable "instance_version" {}
+variable "idp_name" {}
+variable "idp_client_id" {}
+variable "idp_client_secret" {}
+variable "idp_scope" {}
+variable "idp_discovery_endpoint" {}
+
+resource "stackit_workflows_instance" "workflow" {
+ project_id = var.project_id
+ region = var.region
+ display_name = var.display_name
+ description = var.description
+ version = var.instance_version
+
+ identity_provider = {
+ type = "oauth2"
+ name = var.idp_name
+ client_id = var.idp_client_id
+ client_secret = var.idp_client_secret
+ scope = var.idp_scope
+ discovery_endpoint = var.idp_discovery_endpoint
+ }
+}
diff --git a/stackit/internal/services/workflows/utils/util.go b/stackit/internal/services/workflows/utils/util.go
new file mode 100644
index 000000000..6fb29b9d9
--- /dev/null
+++ b/stackit/internal/services/workflows/utils/util.go
@@ -0,0 +1,36 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/hashicorp/terraform-plugin-framework/diag"
+ "github.com/stackitcloud/stackit-sdk-go/core/config"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/core"
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
+)
+
+// DAG bundle source types used across the workflows packages.
+const (
+ BundleTypeGit = "git"
+ BundleTypeS3 = "s3"
+)
+
+func ConfigureClient(ctx context.Context, providerData *core.ProviderData, diags *diag.Diagnostics) *workflows.APIClient {
+ apiClientConfigOptions := []config.ConfigurationOption{
+ config.WithCustomAuth(providerData.RoundTripper),
+ utils.UserAgentConfigOption(providerData.Version),
+ }
+ if providerData.WorkflowsCustomEndpoint != "" {
+ apiClientConfigOptions = append(apiClientConfigOptions, config.WithEndpoint(providerData.WorkflowsCustomEndpoint))
+ }
+ apiClient, err := workflows.NewAPIClient(apiClientConfigOptions...)
+ if err != nil {
+ core.LogAndAddError(ctx, diags, "Error configuring API client", fmt.Sprintf("Configuring client: %v. This is an error related to the provider configuration, not to the resource configuration", err))
+ return nil
+ }
+
+ return apiClient
+}
diff --git a/stackit/internal/services/workflows/utils/validators.go b/stackit/internal/services/workflows/utils/validators.go
new file mode 100644
index 000000000..196299f27
--- /dev/null
+++ b/stackit/internal/services/workflows/utils/validators.go
@@ -0,0 +1,87 @@
+package utils
+
+import (
+ "context"
+ "fmt"
+ "net/url"
+ "regexp"
+ "strconv"
+
+ "github.com/hashicorp/terraform-plugin-framework-validators/helpers/validatordiag"
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+)
+
+// URL returns a validator that checks the value is a syntactically valid
+// absolute HTTP or HTTPS URL. Catches typos at plan time rather than letting
+// them surface as opaque server-side errors after a wait.
+func URL() validator.String {
+ return urlValidator{description: "value must be a valid http:// or https:// URL"}
+}
+
+// URLHTTPSOnly is URL() but rejects http:// — for endpoints where plaintext is
+// never acceptable (OIDC discovery, anything carrying credentials).
+func URLHTTPSOnly() validator.String {
+ return urlValidator{description: "value must be a valid https:// URL", httpsOnly: true}
+}
+
+type urlValidator struct {
+ description string
+ httpsOnly bool
+}
+
+func (v urlValidator) Description(_ context.Context) string { return v.description }
+func (v urlValidator) MarkdownDescription(_ context.Context) string { return v.description }
+
+func (v urlValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { //nolint:gocritic // function signature required by Terraform
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+ return
+ }
+ val := req.ConfigValue.ValueString()
+ u, err := url.Parse(val)
+ schemeOK := u != nil && (u.Scheme == "https" || (!v.httpsOnly && u.Scheme == "http"))
+ if err != nil || u == nil || !u.IsAbs() || !schemeOK || u.Host == "" {
+ resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
+ req.Path,
+ v.description,
+ val,
+ ))
+ }
+}
+
+// Airflow3Version rejects Workflows version strings that aren't Airflow 3+.
+// The version format is `workflows-X.Y-airflow-A.B`. Airflow 2 instances need
+// a `dagsRepository` field that the provider doesn't expose; this validator
+// surfaces the constraint at plan time instead of failing with a server 400.
+func Airflow3Version() validator.String { return airflow3VersionValidator{} }
+
+type airflow3VersionValidator struct{}
+
+var airflowMajorRE = regexp.MustCompile(`airflow-(\d+)\.`)
+
+func (airflow3VersionValidator) Description(_ context.Context) string {
+ return "version must use Airflow 3 or newer"
+}
+
+func (v airflow3VersionValidator) MarkdownDescription(ctx context.Context) string {
+ return v.Description(ctx)
+}
+
+func (airflow3VersionValidator) ValidateString(_ context.Context, req validator.StringRequest, resp *validator.StringResponse) { //nolint:gocritic // function signature required by Terraform
+ if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
+ return
+ }
+ val := req.ConfigValue.ValueString()
+ m := airflowMajorRE.FindStringSubmatch(val)
+ if m == nil {
+ return
+ }
+ major, err := strconv.Atoi(m[1])
+ if err != nil || major >= 3 {
+ return
+ }
+ resp.Diagnostics.Append(validatordiag.InvalidAttributeValueDiagnostic(
+ req.Path,
+ fmt.Sprintf("Unsupported Airflow version: %q is Airflow %d. This provider only supports Airflow 3+ — older versions require the deprecated `dagsRepository` field that is not exposed here. Use the `stackit_workflows_provider_options` data source to discover supported versions.", val, major),
+ val,
+ ))
+}
diff --git a/stackit/internal/services/workflows/utils/validators_test.go b/stackit/internal/services/workflows/utils/validators_test.go
new file mode 100644
index 000000000..3f94e4ffe
--- /dev/null
+++ b/stackit/internal/services/workflows/utils/validators_test.go
@@ -0,0 +1,107 @@
+package utils
+
+import (
+ "context"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-framework/schema/validator"
+ "github.com/hashicorp/terraform-plugin-framework/types"
+)
+
+func TestURL(t *testing.T) {
+ tests := []struct {
+ description string
+ input string
+ wantErr bool
+ }{
+ {description: "http", input: "http://example.com"},
+ {description: "https with path and query", input: "https://example.com/path?q=1"},
+ {description: "https with userinfo", input: "https://user:pw@host.example.com"},
+ {description: "https with ipv6 host", input: "https://[::1]:8080/foo"},
+
+ {description: "empty", input: "", wantErr: true},
+ {description: "missing scheme", input: "example.com/path", wantErr: true},
+ {description: "relative path", input: "/path", wantErr: true},
+ {description: "non http scheme", input: "ftp://example.com", wantErr: true},
+ {description: "scheme only", input: "https://", wantErr: true},
+ {description: "garbage", input: "::not a url", wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ r := validator.StringResponse{}
+ URL().ValidateString(context.Background(), validator.StringRequest{
+ ConfigValue: types.StringValue(tt.input),
+ }, &r)
+ if tt.wantErr && !r.Diagnostics.HasError() {
+ t.Fatalf("URL(%q): expected error, got none", tt.input)
+ }
+ if !tt.wantErr && r.Diagnostics.HasError() {
+ t.Fatalf("URL(%q): expected pass, got errors: %v", tt.input, r.Diagnostics.Errors())
+ }
+ })
+ }
+}
+
+func TestAirflow3Version(t *testing.T) {
+ tests := []struct {
+ desc string
+ input string
+ wantErr bool
+ }{
+ {"airflow 3.1 ok", "workflows-3.0-airflow-3.1", false},
+ {"airflow 3.0 ok", "workflows-3.0-airflow-3.0", false},
+ {"future airflow 4 ok", "workflows-4.0-airflow-4.0", false},
+ {"non-matching string is allowed (server validates exact version)", "workflows-x", false},
+ {"empty is allowed (Required will catch it separately)", "", false},
+
+ {"airflow 2.11 rejected", "workflows-2.3-airflow-2.11", true},
+ {"airflow 2.10 rejected", "workflows-2.2-airflow-2.10", true},
+ {"airflow 1.x rejected", "workflows-1.0-airflow-1.10", true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.desc, func(t *testing.T) {
+ r := validator.StringResponse{}
+ Airflow3Version().ValidateString(context.Background(), validator.StringRequest{
+ ConfigValue: types.StringValue(tt.input),
+ }, &r)
+ if tt.wantErr && !r.Diagnostics.HasError() {
+ t.Fatalf("Airflow3Version(%q): expected error, got none", tt.input)
+ }
+ if !tt.wantErr && r.Diagnostics.HasError() {
+ t.Fatalf("Airflow3Version(%q): expected pass, got errors: %v", tt.input, r.Diagnostics.Errors())
+ }
+ })
+ }
+}
+
+func TestURLHTTPSOnly(t *testing.T) {
+ tests := []struct {
+ description string
+ input string
+ wantErr bool
+ }{
+ {description: "https ok", input: "https://example.com/.well-known/openid-configuration"},
+ {description: "https with ipv6 host", input: "https://[::1]:8443"},
+
+ {description: "http rejected", input: "http://example.com", wantErr: true},
+ {description: "empty", input: "", wantErr: true},
+ {description: "missing scheme", input: "example.com/x", wantErr: true},
+ {description: "scheme only", input: "https://", wantErr: true},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.description, func(t *testing.T) {
+ r := validator.StringResponse{}
+ URLHTTPSOnly().ValidateString(context.Background(), validator.StringRequest{
+ ConfigValue: types.StringValue(tt.input),
+ }, &r)
+ if tt.wantErr && !r.Diagnostics.HasError() {
+ t.Fatalf("URLHTTPSOnly(%q): expected error, got none", tt.input)
+ }
+ if !tt.wantErr && r.Diagnostics.HasError() {
+ t.Fatalf("URLHTTPSOnly(%q): expected pass, got errors: %v", tt.input, r.Diagnostics.Errors())
+ }
+ })
+ }
+}
diff --git a/stackit/internal/services/workflows/workflows_acc_test.go b/stackit/internal/services/workflows/workflows_acc_test.go
new file mode 100644
index 000000000..cea31a81f
--- /dev/null
+++ b/stackit/internal/services/workflows/workflows_acc_test.go
@@ -0,0 +1,521 @@
+package workflows_test
+
+import (
+ "context"
+ _ "embed"
+ "errors"
+ "fmt"
+ "net/http"
+ "os"
+ "regexp"
+ "testing"
+
+ "github.com/hashicorp/terraform-plugin-testing/config"
+ "github.com/hashicorp/terraform-plugin-testing/helper/acctest"
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ "github.com/hashicorp/terraform-plugin-testing/plancheck"
+ "github.com/hashicorp/terraform-plugin-testing/terraform"
+ "github.com/stackitcloud/stackit-sdk-go/core/oapierror"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
+)
+
+// runSuffix is shared across all tests in this process so a re-run won't
+// collide with an instance left behind by a previous failed run. Six chars
+// keeps room under the server's 25-char display_name cap.
+var runSuffix = acctest.RandStringFromCharSet(6, acctest.CharSetAlpha)
+
+func instanceDisplayName(kind string) string {
+ return fmt.Sprintf("tf-%s-%s", runSuffix, kind)
+}
+
+var (
+ //go:embed testdata/instance.tf
+ instanceConfig string
+
+ //go:embed testdata/instance-no-description.tf
+ instanceNoDescriptionConfig string
+
+ //go:embed testdata/instance-stackit-idp.tf
+ instanceStackITIdPConfig string
+
+ //go:embed testdata/dagbundle-git.tf
+ dagBundleGitConfig string
+
+ //go:embed testdata/dagbundle-git-no-subdir.tf
+ dagBundleGitNoSubdirConfig string
+
+ //go:embed testdata/dagbundle-s3.tf
+ dagBundleS3Config string
+)
+
+// requireEnv collects a set of required env vars for these tests; if any are
+// missing, skips the test with a clear message. Keeps test runs cheap when
+// workflows-specific secrets aren't provisioned in the runner.
+func requireEnv(t *testing.T, keys ...string) map[string]string {
+ t.Helper()
+ out := make(map[string]string, len(keys))
+ missing := []string{}
+ for _, k := range keys {
+ v := os.Getenv(k)
+ if v == "" {
+ missing = append(missing, k)
+ }
+ out[k] = v
+ }
+ if len(missing) > 0 {
+ t.Skipf("Skipping: missing required env var(s): %v", missing)
+ }
+ return out
+}
+
+func baseInstanceVars(t *testing.T, displayName, description string) config.Variables {
+ env := requireEnv(t,
+ "TF_ACC_WORKFLOWS_VERSION",
+ "TF_ACC_WORKFLOWS_IDP_NAME",
+ "TF_ACC_WORKFLOWS_IDP_CLIENT_ID",
+ "TF_ACC_WORKFLOWS_IDP_CLIENT_SECRET",
+ "TF_ACC_WORKFLOWS_IDP_SCOPE",
+ "TF_ACC_WORKFLOWS_IDP_DISCOVERY_ENDPOINT",
+ )
+ return config.Variables{
+ "project_id": config.StringVariable(testutil.ProjectId),
+ "region": config.StringVariable(testutil.Region),
+ "display_name": config.StringVariable(displayName),
+ "description": config.StringVariable(description),
+ "instance_version": config.StringVariable(env["TF_ACC_WORKFLOWS_VERSION"]),
+ "idp_name": config.StringVariable(env["TF_ACC_WORKFLOWS_IDP_NAME"]),
+ "idp_client_id": config.StringVariable(env["TF_ACC_WORKFLOWS_IDP_CLIENT_ID"]),
+ "idp_client_secret": config.StringVariable(env["TF_ACC_WORKFLOWS_IDP_CLIENT_SECRET"]),
+ "idp_scope": config.StringVariable(env["TF_ACC_WORKFLOWS_IDP_SCOPE"]),
+ "idp_discovery_endpoint": config.StringVariable(env["TF_ACC_WORKFLOWS_IDP_DISCOVERY_ENDPOINT"]),
+ }
+}
+
+func bundleVars(t *testing.T, base config.Variables, bundleName, subdir string) config.Variables {
+ env := requireEnv(t,
+ "TF_ACC_WORKFLOWS_DAGS_GIT_URL",
+ "TF_ACC_WORKFLOWS_DAGS_GIT_BRANCH",
+ "TF_ACC_WORKFLOWS_DAGS_GIT_USER",
+ "TF_ACC_WORKFLOWS_DAGS_GIT_PAT",
+ )
+ out := make(config.Variables, len(base)+5)
+ for k, v := range base {
+ out[k] = v
+ }
+ out["bundle_name"] = config.StringVariable(bundleName)
+ out["bundle_url"] = config.StringVariable(env["TF_ACC_WORKFLOWS_DAGS_GIT_URL"])
+ out["bundle_branch"] = config.StringVariable(env["TF_ACC_WORKFLOWS_DAGS_GIT_BRANCH"])
+ out["bundle_username"] = config.StringVariable(env["TF_ACC_WORKFLOWS_DAGS_GIT_USER"])
+ out["bundle_password"] = config.StringVariable(env["TF_ACC_WORKFLOWS_DAGS_GIT_PAT"])
+ out["bundle_subdir"] = config.StringVariable(subdir)
+ return out
+}
+
+func TestAccWorkflowsInstance(t *testing.T) {
+ vars := baseInstanceVars(t, instanceDisplayName("wf"), "Acceptance test instance")
+ updated := cloneVars(vars)
+ updated["description"] = config.StringVariable("Acceptance test instance — updated")
+
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ CheckDestroy: testAccCheckWorkflowsInstanceDestroy,
+ Steps: []resource.TestStep{
+ {
+ ConfigVariables: vars,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + instanceConfig,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("stackit_workflows_instance.workflow", "project_id", testutil.ConvertConfigVariable(vars["project_id"])),
+ resource.TestCheckResourceAttr("stackit_workflows_instance.workflow", "region", testutil.ConvertConfigVariable(vars["region"])),
+ resource.TestCheckResourceAttr("stackit_workflows_instance.workflow", "display_name", testutil.ConvertConfigVariable(vars["display_name"])),
+ resource.TestCheckResourceAttr("stackit_workflows_instance.workflow", "description", testutil.ConvertConfigVariable(vars["description"])),
+ resource.TestCheckResourceAttr("stackit_workflows_instance.workflow", "version", testutil.ConvertConfigVariable(vars["instance_version"])),
+ resource.TestCheckResourceAttr("stackit_workflows_instance.workflow", "identity_provider.type", "oauth2"),
+ resource.TestCheckResourceAttr("stackit_workflows_instance.workflow", "identity_provider.client_secret", testutil.ConvertConfigVariable(vars["idp_client_secret"])),
+ resource.TestCheckResourceAttrSet("stackit_workflows_instance.workflow", "instance_id"),
+ resource.TestCheckResourceAttrSet("stackit_workflows_instance.workflow", "endpoints.url"),
+ resource.TestCheckResourceAttrSet("stackit_workflows_instance.workflow", "endpoints.redirect_url"),
+ resource.TestCheckResourceAttrSet("stackit_workflows_instance.workflow", "status"),
+ resource.TestCheckResourceAttrSet("stackit_workflows_instance.workflow", "created_at"),
+ ),
+ },
+ {
+ ConfigVariables: vars,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + instanceConfig + `
+ data "stackit_workflows_instance" "workflow" {
+ project_id = stackit_workflows_instance.workflow.project_id
+ region = stackit_workflows_instance.workflow.region
+ instance_id = stackit_workflows_instance.workflow.instance_id
+ }
+ data "stackit_workflows_instances" "all" {
+ project_id = stackit_workflows_instance.workflow.project_id
+ region = stackit_workflows_instance.workflow.region
+ }
+ data "stackit_workflows_provider_options" "options" {
+ region = stackit_workflows_instance.workflow.region
+ }
+ `,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrPair("stackit_workflows_instance.workflow", "instance_id", "data.stackit_workflows_instance.workflow", "instance_id"),
+ resource.TestCheckResourceAttrPair("stackit_workflows_instance.workflow", "display_name", "data.stackit_workflows_instance.workflow", "display_name"),
+ resource.TestCheckResourceAttrPair("stackit_workflows_instance.workflow", "endpoints.url", "data.stackit_workflows_instance.workflow", "endpoints.url"),
+ resource.TestCheckResourceAttrPair("stackit_workflows_instance.workflow", "status", "data.stackit_workflows_instance.workflow", "status"),
+ resource.TestCheckResourceAttrSet("data.stackit_workflows_instances.all", "instances.#"),
+ resource.TestCheckResourceAttrSet("data.stackit_workflows_provider_options.options", "versions.#"),
+ ),
+ },
+ // client_secret is not returned by the API; expect it to be absent on import.
+ {
+ ConfigVariables: vars,
+ ResourceName: "stackit_workflows_instance.workflow",
+ ImportStateIdFunc: func(state *terraform.State) (string, error) {
+ rs, ok := state.RootModule().Resources["stackit_workflows_instance.workflow"]
+ if !ok {
+ return "", fmt.Errorf("not found: stackit_workflows_instance.workflow")
+ }
+ instanceID, ok := rs.Primary.Attributes["instance_id"]
+ if !ok {
+ return "", fmt.Errorf("instance_id not set")
+ }
+ return fmt.Sprintf("%s,%s,%s", testutil.ProjectId, testutil.Region, instanceID), nil
+ },
+ ImportState: true,
+ ImportStateVerify: true,
+ ImportStateVerifyIgnore: []string{"identity_provider.client_secret"},
+ },
+ {
+ ConfigVariables: updated,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + instanceConfig,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("stackit_workflows_instance.workflow", "description", testutil.ConvertConfigVariable(updated["description"])),
+ ),
+ },
+ // Verify clearing description: server treats "" as the clear sentinel; provider sends it via ClearableString.
+ // Server treats "" as the clear sentinel; provider sends it via clearableString.
+ {
+ ConfigVariables: updated,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + instanceNoDescriptionConfig,
+ ConfigPlanChecks: resource.ConfigPlanChecks{
+ PostApplyPostRefresh: []plancheck.PlanCheck{
+ plancheck.ExpectEmptyPlan(),
+ },
+ },
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckNoResourceAttr("stackit_workflows_instance.workflow", "description"),
+ ),
+ },
+ // Verify client_secret rotation: server requires the secret on every IdP update (credential-leak defense).
+ // Server requires the secret on every IdP update (credential-leak defense).
+ {
+ ConfigVariables: rotatedIdPSecret(updated, "rotated-secret-value"),
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + instanceNoDescriptionConfig,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("stackit_workflows_instance.workflow", "identity_provider.client_secret", "rotated-secret-value"),
+ ),
+ },
+ },
+ })
+}
+
+func bundleS3Vars(t *testing.T, base config.Variables, bundleName, prefix string) config.Variables {
+ env := requireEnv(t,
+ "TF_ACC_WORKFLOWS_DAGS_S3_BUCKET",
+ "TF_ACC_WORKFLOWS_DAGS_S3_ENDPOINT",
+ "TF_ACC_WORKFLOWS_DAGS_S3_ACCESS_KEY_ID",
+ "TF_ACC_WORKFLOWS_DAGS_S3_SECRET_ACCESS_KEY",
+ )
+ out := make(config.Variables, len(base)+6)
+ for k, v := range base {
+ out[k] = v
+ }
+ out["bundle_name"] = config.StringVariable(bundleName)
+ out["bucket_name"] = config.StringVariable(env["TF_ACC_WORKFLOWS_DAGS_S3_BUCKET"])
+ out["endpoint"] = config.StringVariable(env["TF_ACC_WORKFLOWS_DAGS_S3_ENDPOINT"])
+ out["prefix"] = config.StringVariable(prefix)
+ out["access_key_id"] = config.StringVariable(env["TF_ACC_WORKFLOWS_DAGS_S3_ACCESS_KEY_ID"])
+ out["secret_access_key"] = config.StringVariable(env["TF_ACC_WORKFLOWS_DAGS_S3_SECRET_ACCESS_KEY"])
+ return out
+}
+
+func TestAccWorkflowsDagBundleS3(t *testing.T) {
+ base := baseInstanceVars(t, instanceDisplayName("wfs3"), "Acceptance test instance for S3 bundles")
+ vars := bundleS3Vars(t, base, "backup-dags", "dags/")
+ updated := bundleS3Vars(t, base, "backup-dags", "dags/v2/")
+
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ CheckDestroy: testAccCheckWorkflowsInstanceDestroy,
+ Steps: []resource.TestStep{
+ {
+ ConfigVariables: vars,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + dagBundleS3Config,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "name", testutil.ConvertConfigVariable(vars["bundle_name"])),
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "s3.bucket_name", testutil.ConvertConfigVariable(vars["bucket_name"])),
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "s3.auth.type", "access_key"),
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "s3.auth.access_key_id", testutil.ConvertConfigVariable(vars["access_key_id"])),
+ resource.TestCheckNoResourceAttr("stackit_workflows_dag_bundle.bundle", "git"),
+ ),
+ },
+ {
+ ConfigVariables: vars,
+ ResourceName: "stackit_workflows_dag_bundle.bundle",
+ ImportStateIdFunc: importDagBundleID,
+ ImportState: true,
+ ImportStateVerify: true,
+ ImportStateVerifyIgnore: []string{"s3.auth.secret_access_key"},
+ },
+ {
+ ConfigVariables: updated,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + dagBundleS3Config,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "s3.prefix", testutil.ConvertConfigVariable(updated["prefix"])),
+ ),
+ },
+ // Rotate secret_access_key — exercise UpdateDagBundle credential path.
+ {
+ ConfigVariables: rotateS3Secret(updated, "rotated-s3-secret"),
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + dagBundleS3Config,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "s3.auth.secret_access_key", "rotated-s3-secret"),
+ ),
+ },
+ },
+ })
+}
+
+func importDagBundleID(state *terraform.State) (string, error) {
+ rs, ok := state.RootModule().Resources["stackit_workflows_dag_bundle.bundle"]
+ if !ok {
+ return "", fmt.Errorf("not found: stackit_workflows_dag_bundle.bundle")
+ }
+ instanceID := rs.Primary.Attributes["instance_id"]
+ name := rs.Primary.Attributes["name"]
+ return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, testutil.Region, instanceID, name), nil
+}
+
+func TestAccWorkflowsDagBundle(t *testing.T) {
+ base := baseInstanceVars(t, instanceDisplayName("wfbn"), "Acceptance test instance for bundles")
+ vars := bundleVars(t, base, "main-dags", "dags")
+ updated := bundleVars(t, base, "main-dags", "dags/v2")
+
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ CheckDestroy: testAccCheckWorkflowsInstanceDestroy,
+ Steps: []resource.TestStep{
+ {
+ ConfigVariables: vars,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + dagBundleGitConfig,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrSet("stackit_workflows_instance.workflow", "instance_id"),
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "name", testutil.ConvertConfigVariable(vars["bundle_name"])),
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "git.url", testutil.ConvertConfigVariable(vars["bundle_url"])),
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "git.branch", testutil.ConvertConfigVariable(vars["bundle_branch"])),
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "git.auth.type", "basic"),
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "git.auth.username", testutil.ConvertConfigVariable(vars["bundle_username"])),
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "git.auth.password", testutil.ConvertConfigVariable(vars["bundle_password"])),
+ resource.TestCheckNoResourceAttr("stackit_workflows_dag_bundle.bundle", "s3"),
+ ),
+ },
+ {
+ ConfigVariables: vars,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + dagBundleGitConfig + `
+ data "stackit_workflows_dag_bundle" "bundle" {
+ project_id = stackit_workflows_dag_bundle.bundle.project_id
+ region = stackit_workflows_dag_bundle.bundle.region
+ instance_id = stackit_workflows_dag_bundle.bundle.instance_id
+ name = stackit_workflows_dag_bundle.bundle.name
+ }
+ data "stackit_workflows_dag_bundles" "all" {
+ project_id = stackit_workflows_dag_bundle.bundle.project_id
+ region = stackit_workflows_dag_bundle.bundle.region
+ instance_id = stackit_workflows_dag_bundle.bundle.instance_id
+ }
+ `,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttrPair("stackit_workflows_dag_bundle.bundle", "git.url", "data.stackit_workflows_dag_bundle.bundle", "git.url"),
+ resource.TestCheckResourceAttrPair("stackit_workflows_dag_bundle.bundle", "git.branch", "data.stackit_workflows_dag_bundle.bundle", "git.branch"),
+ resource.TestCheckResourceAttr("data.stackit_workflows_dag_bundles.all", "dag_bundles.#", "1"),
+ resource.TestCheckResourceAttr("data.stackit_workflows_dag_bundles.all", "dag_bundles.0.type", "git"),
+ ),
+ },
+ {
+ ConfigVariables: vars,
+ ResourceName: "stackit_workflows_dag_bundle.bundle",
+ ImportStateIdFunc: func(state *terraform.State) (string, error) {
+ rs, ok := state.RootModule().Resources["stackit_workflows_dag_bundle.bundle"]
+ if !ok {
+ return "", fmt.Errorf("not found: stackit_workflows_dag_bundle.bundle")
+ }
+ instanceID := rs.Primary.Attributes["instance_id"]
+ name := rs.Primary.Attributes["name"]
+ return fmt.Sprintf("%s,%s,%s,%s", testutil.ProjectId, testutil.Region, instanceID, name), nil
+ },
+ ImportState: true,
+ ImportStateVerify: true,
+ ImportStateVerifyIgnore: []string{
+ "git.auth.password",
+ },
+ },
+ {
+ ConfigVariables: updated,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + dagBundleGitConfig,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "git.subdir", testutil.ConvertConfigVariable(updated["bundle_subdir"])),
+ ),
+ },
+ // Clear subdir by switching to a config that omits it. Server uses
+ // "" as the clear sentinel; provider sends it via clearableString.
+ {
+ ConfigVariables: updated,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + dagBundleGitNoSubdirConfig,
+ ConfigPlanChecks: resource.ConfigPlanChecks{
+ PostApplyPostRefresh: []plancheck.PlanCheck{
+ plancheck.ExpectEmptyPlan(),
+ },
+ },
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckNoResourceAttr("stackit_workflows_dag_bundle.bundle", "git.subdir"),
+ ),
+ },
+ // Rotate password — exercises UpdateDagBundle credential path.
+ {
+ ConfigVariables: rotateBundlePassword(updated, "rotated-pat-value"),
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + dagBundleGitConfig,
+ Check: resource.ComposeAggregateTestCheckFunc(
+ resource.TestCheckResourceAttr("stackit_workflows_dag_bundle.bundle", "git.auth.password", "rotated-pat-value"),
+ ),
+ },
+ },
+ })
+}
+
+// TestAccWorkflowsDagBundle_RejectsSubdirWithSlashes verifies that
+// `subdir = "/dags/"` is rejected at plan time so the user is forced to write
+// the canonical form. Without this guard, the server's "" normalization would
+// cause a perpetual diff against the user's literal value.
+func TestAccWorkflowsDagBundle_RejectsSubdirWithSlashes(t *testing.T) {
+ base := baseInstanceVars(t, instanceDisplayName("wfnm"), "Acceptance test subdir validation")
+ vars := bundleVars(t, base, "norm-dags", "/dags/")
+
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ ConfigVariables: vars,
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + dagBundleGitConfig,
+ ExpectError: regexp.MustCompile(`(?s)subdir.*must not have leading or trailing slashes`),
+ },
+ },
+ })
+}
+
+// TestAccWorkflowsInstance_RequiresExperimentFlag verifies the experiment
+// gate: a workflows resource declared without `experiments = ["workflows"]`
+// in the provider block fails Configure with an actionable error. No API
+// call is made because the failure happens before plan.
+func TestAccWorkflowsInstance_RequiresExperimentFlag(t *testing.T) {
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ // NOTE: provider block intentionally omits .Experiments(...).
+ Config: testutil.NewConfigBuilder().BuildProviderConfig() + `
+ resource "stackit_workflows_instance" "x" {
+ project_id = "00000000-0000-0000-0000-000000000000"
+ region = "eu01"
+ display_name = "x"
+ version = "workflows-3.0-airflow-3.1"
+ identity_provider = {
+ type = "oauth2"
+ name = "n"
+ client_id = "id"
+ client_secret = "s"
+ scope = "openid"
+ discovery_endpoint = "https://idp.example.com/.well-known/openid-configuration"
+ }
+ }
+ `,
+ ExpectError: regexp.MustCompile(`(?s)workflows experiment.*disabled by default`),
+ },
+ },
+ })
+}
+
+// TestAccWorkflowsInstance_StackITIdPRejected verifies that requesting the
+// `stackit` IdP type is rejected at plan time by the schema validator (the
+// backend doesn't yet support it).
+func TestAccWorkflowsInstance_StackITIdPRejected(t *testing.T) {
+ // Plan-time validator check — no API call, so no env gating.
+ resource.Test(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ ConfigVariables: config.Variables{
+ "project_id": config.StringVariable("00000000-0000-0000-0000-000000000000"),
+ "region": config.StringVariable("eu01"),
+ "display_name": config.StringVariable("tf-stackit-idp"),
+ "instance_version": config.StringVariable("workflows-3.0-airflow-3.1"),
+ },
+ Config: testutil.NewConfigBuilder().Experiments(testutil.ExperimentWorkflows).BuildProviderConfig() + "\n" + instanceStackITIdPConfig,
+ ExpectError: regexp.MustCompile(`(?s)(must be one of|value must be one of).*oauth2`),
+ },
+ },
+ })
+}
+
+func cloneVars(in config.Variables) config.Variables {
+ out := make(config.Variables, len(in))
+ for k, v := range in {
+ out[k] = v
+ }
+ return out
+}
+
+func rotatedIdPSecret(in config.Variables, newSecret string) config.Variables {
+ out := cloneVars(in)
+ out["idp_client_secret"] = config.StringVariable(newSecret)
+ return out
+}
+
+func rotateBundlePassword(in config.Variables, newPassword string) config.Variables {
+ out := cloneVars(in)
+ out["bundle_password"] = config.StringVariable(newPassword)
+ return out
+}
+
+func rotateS3Secret(in config.Variables, newSecret string) config.Variables {
+ out := cloneVars(in)
+ out["secret_access_key"] = config.StringVariable(newSecret)
+ return out
+}
+
+func testAccCheckWorkflowsInstanceDestroy(s *terraform.State) error {
+ ctx := context.Background()
+ client, err := workflows.NewAPIClient(testutil.NewConfigBuilder().BuildClientOptions(testutil.WorkflowsCustomEndpoint, false)...)
+ if err != nil {
+ return fmt.Errorf("creating client: %w", err)
+ }
+
+ instancesToDestroy := []string{}
+ for _, rs := range s.RootModule().Resources {
+ if rs.Type != "stackit_workflows_instance" {
+ continue
+ }
+ instancesToDestroy = append(instancesToDestroy, rs.Primary.Attributes["instance_id"])
+ }
+
+ for _, id := range instancesToDestroy {
+ _, err := client.DefaultAPI.GetInstance(ctx, testutil.ProjectId, testutil.Region, id).Execute()
+ if err == nil {
+ return fmt.Errorf("Workflows instance %s still exists", id)
+ }
+ var oapiErr *oapierror.GenericOpenAPIError
+ if errors.As(err, &oapiErr) && oapiErr.StatusCode == http.StatusNotFound {
+ continue
+ }
+ return fmt.Errorf("unexpected error checking instance %s: %w", id, err)
+ }
+ return nil
+}
diff --git a/stackit/internal/services/workflows/workflows_test.go b/stackit/internal/services/workflows/workflows_test.go
new file mode 100644
index 000000000..1156b0165
--- /dev/null
+++ b/stackit/internal/services/workflows/workflows_test.go
@@ -0,0 +1,107 @@
+package workflows_test
+
+import (
+ "fmt"
+ "net/http"
+ "regexp"
+ "testing"
+
+ "github.com/google/uuid"
+ "github.com/hashicorp/terraform-plugin-testing/helper/resource"
+ workflows "github.com/stackitcloud/stackit-sdk-go/services/workflows/v1alphaapi"
+
+ "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/testutil"
+)
+
+// TestWorkflowsInstanceSavesIDsOnError confirms that when Create succeeds at
+// the API but the subsequent wait fails, the instance_id is persisted to
+// state so the user can recover via `terraform import` instead of orphaning
+// the server-side instance.
+//
+// Per CONTRIBUTING.md §99-102 every async resource must carry this regression.
+func TestWorkflowsInstanceSavesIDsOnError(t *testing.T) {
+ projectID := uuid.NewString()
+ instanceID := uuid.NewString()
+ const region = "eu01"
+
+ s := testutil.NewMockServer(t)
+ defer s.Server.Close()
+
+ tfConfig := fmt.Sprintf(`
+provider "stackit" {
+ default_region = "%s"
+ workflows_custom_endpoint = "%s"
+ service_account_token = "mock-server-needs-no-auth"
+ experiments = ["workflows"]
+}
+resource "stackit_workflows_instance" "example" {
+ project_id = "%s"
+ display_name = "tf-savesid"
+ version = "workflows-3.0-airflow-3.1"
+ identity_provider = {
+ type = "oauth2"
+ name = "azure"
+ client_id = "client"
+ client_secret = "secret"
+ scope = "openid"
+ discovery_endpoint = "https://idp.example.com/.well-known/openid-configuration"
+ }
+}
+`, region, s.Server.URL, projectID)
+
+ resource.UnitTest(t, resource.TestCase{
+ ProtoV6ProviderFactories: testutil.TestAccProtoV6ProviderFactories,
+ Steps: []resource.TestStep{
+ {
+ PreConfig: func() {
+ s.Reset(
+ testutil.MockResponse{
+ Description: "create instance returns id",
+ ToJsonBody: workflows.Instance{
+ Id: instanceID,
+ ProjectId: projectID,
+ RegionId: region,
+ DisplayName: "tf-savesid",
+ Status: workflows.INSTANCESTATUS_CREATING,
+ IdentityProvider: workflows.OAuth2IdentityProviderAsIdentityProvider(&workflows.OAuth2IdentityProvider{
+ Type: workflows.OAUTH2IDENTITYPROVIDERTYPE_OAUTH2,
+ Name: "azure",
+ ClientId: "client",
+ ClientSecret: "secret",
+ Scope: "openid",
+ DiscoveryEndpoint: "https://idp.example.com/.well-known/openid-configuration",
+ }),
+ },
+ },
+ testutil.MockResponse{
+ Description: "wait poll fails",
+ StatusCode: http.StatusInternalServerError,
+ },
+ )
+ },
+ Config: tfConfig,
+ ExpectError: regexp.MustCompile("Error creating Workflows instance.*"),
+ },
+ {
+ PreConfig: func() {
+ s.Reset(
+ testutil.MockResponse{
+ Description: "refresh hits the persisted instance_id",
+ Handler: func(w http.ResponseWriter, req *http.Request) {
+ expected := fmt.Sprintf("/v1alpha/projects/%s/regions/%s/instances/%s", projectID, region, instanceID)
+ if req.URL.Path != expected {
+ t.Errorf("expected request to %s, got %s", expected, req.URL.Path)
+ }
+ w.WriteHeader(http.StatusInternalServerError)
+ },
+ },
+ testutil.MockResponse{Description: "delete", StatusCode: http.StatusAccepted},
+ testutil.MockResponse{Description: "delete waiter", StatusCode: http.StatusNotFound},
+ )
+ },
+ RefreshState: true,
+ ExpectError: regexp.MustCompile("Error reading Workflows instance.*"),
+ },
+ },
+ })
+}
diff --git a/stackit/internal/testutil/testutil.go b/stackit/internal/testutil/testutil.go
index e119666ad..a0129863a 100644
--- a/stackit/internal/testutil/testutil.go
+++ b/stackit/internal/testutil/testutil.go
@@ -103,6 +103,7 @@ var (
IntakeCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_INTAKE_CUSTOM_ENDPOINT", providerName: "intake_custom_endpoint"}
TelemetryRouterCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TELEMETRYROUTER_CUSTOM_ENDPOINT", providerName: "telemetryrouter_custom_endpoint"}
TelemetryLinkCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_TELEMETRYLINK_CUSTOM_ENDPOINT", providerName: "telemetrylink_custom_endpoint"}
+ WorkflowsCustomEndpoint = customEndpointConfig{envVarName: "TF_ACC_WORKFLOWS_CUSTOM_ENDPOINT", providerName: "workflows_custom_endpoint"}
allCustomEndpoints = []customEndpointConfig{
ALBCustomEndpoint,
@@ -139,6 +140,7 @@ var (
SKECustomEndpoint,
TelemetryRouterCustomEndpoint,
TelemetryLinkCustomEndpoint,
+ WorkflowsCustomEndpoint,
}
)
@@ -149,6 +151,7 @@ const (
ExperimentNetwork Experiment = "network"
ExperimentIAM Experiment = "iam"
ExperimentDremio Experiment = "dremio"
+ ExperimentWorkflows Experiment = "workflows"
)
type customEndpointConfig struct {
diff --git a/stackit/provider.go b/stackit/provider.go
index 0470af21a..be5da65ee 100644
--- a/stackit/provider.go
+++ b/stackit/provider.go
@@ -129,6 +129,11 @@ import (
telemetryRouterDestination "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/destination"
telemetryRouterInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/telemetryrouter/instance"
vpnGateway "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/vpn/gateway"
+ workflowsDagBundle "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/dagbundle"
+ workflowsDagBundles "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/dagbundles"
+ workflowsInstance "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/instance"
+ workflowsInstances "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/instances"
+ workflowsProviderOptions "github.com/stackitcloud/terraform-provider-stackit/stackit/internal/services/workflows/provideroptions"
"github.com/stackitcloud/terraform-provider-stackit/stackit/internal/utils"
)
@@ -211,6 +216,7 @@ type providerModel struct {
TelemetryRouterCustomEndpoint types.String `tfsdk:"telemetryrouter_custom_endpoint"`
TokenCustomEndpoint types.String `tfsdk:"token_custom_endpoint"`
VpnCustomEndpoint types.String `tfsdk:"vpn_custom_endpoint"`
+ WorkflowsCustomEndpoint types.String `tfsdk:"workflows_custom_endpoint"`
OIDCTokenRequestURL types.String `tfsdk:"oidc_request_url"`
OIDCTokenRequestToken types.String `tfsdk:"oidc_request_token"`
@@ -272,6 +278,7 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
"telemetryrouter_custom_endpoint": "Custom endpoint for the Telemetry Router service",
"token_custom_endpoint": "Custom endpoint for the token API, which is used to request access tokens when using the key flow",
"vpn_custom_endpoint": "Custom endpoint for the VPN service",
+ "workflows_custom_endpoint": "Custom endpoint for the Workflows service",
"enable_beta_resources": "Enable beta resources. Default is false.",
"experiments": fmt.Sprintf("Enables experiments. These are unstable features without official support. More information can be found in the README. Available Experiments: %v", strings.Join(features.AvailableExperiments, ", ")),
}
@@ -502,6 +509,10 @@ func (p *Provider) Schema(_ context.Context, _ provider.SchemaRequest, resp *pro
Optional: true,
Description: descriptions["token_custom_endpoint"],
},
+ "workflows_custom_endpoint": schema.StringAttribute{
+ Optional: true,
+ Description: descriptions["workflows_custom_endpoint"],
+ },
},
}
}
@@ -589,6 +600,7 @@ func (p *Provider) Configure(ctx context.Context, req provider.ConfigureRequest,
setStringField(providerConfig.TelemetryRouterCustomEndpoint, func(v string) { providerData.TelemetryRouterCustomEndpoint = v })
setStringField(providerConfig.TelemetryLinkCustomEndpoint, func(v string) { providerData.TelemetryLinkCustomEndpoint = v })
setStringField(providerConfig.VpnCustomEndpoint, func(v string) { providerData.VpnCustomEndpoint = v })
+ setStringField(providerConfig.WorkflowsCustomEndpoint, func(v string) { providerData.WorkflowsCustomEndpoint = v })
if !(providerConfig.Experiments.IsUnknown() || providerConfig.Experiments.IsNull()) {
var experimentValues []string
@@ -757,6 +769,11 @@ func (p *Provider) DataSources(_ context.Context) []func() datasource.DataSource
telemetryRouterDestination.NewTelemetryRouterDestinationDataSource,
telemetryLink.NewTelemetryLinkDataSource,
vpnGateway.NewVPNGatewayDataSource,
+ workflowsInstance.NewWorkflowsInstanceDataSource,
+ workflowsInstances.NewWorkflowsInstancesDataSource,
+ workflowsDagBundle.NewWorkflowsDagBundleDataSource,
+ workflowsDagBundles.NewWorkflowsDagBundlesDataSource,
+ workflowsProviderOptions.NewWorkflowsProviderOptionsDataSource,
}
dataSources = append(dataSources, customRole.NewCustomRoleDataSources()...)
dataSources = append(dataSources, iamRoleBindingsV1.NewRoleBindingsDatasources()...)
@@ -857,6 +874,8 @@ func (p *Provider) Resources(_ context.Context) []func() resource.Resource {
telemetryRouterDestination.NewTelemetryRouterDestinationResource,
telemetryLink.NewTelemetryLinkResource,
vpnGateway.NewGatewayResource,
+ workflowsInstance.NewWorkflowsInstanceResource,
+ workflowsDagBundle.NewWorkflowsDagBundleResource,
}
resources = append(resources, roleAssignements.NewRoleAssignmentResources()...)
resources = append(resources, customRole.NewCustomRoleResources()...)
diff --git a/stackit/testdata/provider-all-attributes.tf b/stackit/testdata/provider-all-attributes.tf
index 6f8067792..cd12f5e74 100644
--- a/stackit/testdata/provider-all-attributes.tf
+++ b/stackit/testdata/provider-all-attributes.tf
@@ -33,6 +33,7 @@ provider "stackit" {
ske_custom_endpoint = "https://ske.api.stackit.cloud"
service_enablement_custom_endpoint = "https://service-enablement.api.stackit.cloud"
token_custom_endpoint = "https://token.api.stackit.cloud"
+ workflows_custom_endpoint = "https://workflows.api.stackit.cloud"
enable_beta_resources = "true"
}