From 8a20b25b4dd1ce83020a6c151a0775e25ed1e9f9 Mon Sep 17 00:00:00 2001 From: Christian Thiel Date: Fri, 19 Jun 2026 11:03:04 +0200 Subject: [PATCH] feat(workflows): Onboarding STACKIT Workflows Adds Terraform support for STACKIT Workflows (Apache Airflow 3 as a service): resources: stackit_workflows_instance, stackit_workflows_dag_bundle data sources: stackit_workflows_instance, stackit_workflows_instances, stackit_workflows_dag_bundle, stackit_workflows_dag_bundles, stackit_workflows_provider_options All seven surfaces are gated behind the `workflows` experiment flag. Notes for reviewers: * The workflows Go SDK is not yet on a published tag; this PR uses a replace-directive in `go.mod` pointing at the bot-generated branch. This is per the dev-tools team's request to enable an early review pass. The replace will be dropped once the SDK release lands. * `dag_bundle` list/get endpoints currently return 403 in prod for service-account-authenticated callers. The server-side fix is merged upstream and awaiting deploy; acceptance tests against those endpoints will only pass once that ships. * A separate cluster-side issue (helm chart unconditionally provisions an ExternalSecret for the git-bundle credentials) means Airflow 3 instances cannot reach `active` unless a dag_bundle is configured at Create time. This is owned by the API team. Subsequent provider PR will inline dag_bundles onto the instance Create payload to work around it cleanly. --- README.md | 7 +- docs/data-sources/workflows_dag_bundle.md | 86 ++ docs/data-sources/workflows_dag_bundles.md | 56 + docs/data-sources/workflows_instance.md | 101 ++ docs/data-sources/workflows_instances.md | 51 + .../workflows_provider_options.md | 58 + docs/index.md | 3 +- docs/resources/workflows_dag_bundle.md | 146 +++ docs/resources/workflows_instance.md | 131 ++ .../data-source.tf | 6 + .../data-source.tf | 5 + .../stackit_workflows_instance/data-source.tf | 5 + .../data-source.tf | 4 + .../data-source.tf | 19 + .../stackit_workflows_dag_bundle/resource.tf | 54 + .../stackit_workflows_instance/resource.tf | 49 + go.mod | 3 + stackit/internal/conversion/conversion.go | 44 + .../internal/conversion/conversion_test.go | 71 ++ stackit/internal/core/core.go | 1 + stackit/internal/features/experiments.go | 3 +- .../workflows/dagbundle/datasource.go | 161 +++ .../services/workflows/dagbundle/resource.go | 1109 +++++++++++++++++ .../workflows/dagbundle/resource_test.go | 756 +++++++++++ .../workflows/dagbundles/datasource.go | 195 +++ .../services/workflows/instance/datasource.go | 347 ++++++ .../services/workflows/instance/resource.go | 1056 ++++++++++++++++ .../workflows/instance/resource_test.go | 552 ++++++++ .../workflows/instances/datasource.go | 176 +++ .../workflows/provideroptions/datasource.go | 172 +++ .../testdata/dagbundle-git-no-subdir.tf | 48 + .../workflows/testdata/dagbundle-git.tf | 49 + .../workflows/testdata/dagbundle-s3.tf | 49 + .../testdata/instance-no-description.tf | 26 + .../testdata/instance-stackit-idp.tf | 15 + .../services/workflows/testdata/instance.tf | 27 + .../internal/services/workflows/utils/util.go | 36 + .../services/workflows/utils/validators.go | 87 ++ .../workflows/utils/validators_test.go | 107 ++ .../services/workflows/workflows_acc_test.go | 521 ++++++++ .../services/workflows/workflows_test.go | 107 ++ stackit/internal/testutil/testutil.go | 3 + stackit/provider.go | 19 + stackit/testdata/provider-all-attributes.tf | 1 + 44 files changed, 6519 insertions(+), 3 deletions(-) create mode 100644 docs/data-sources/workflows_dag_bundle.md create mode 100644 docs/data-sources/workflows_dag_bundles.md create mode 100644 docs/data-sources/workflows_instance.md create mode 100644 docs/data-sources/workflows_instances.md create mode 100644 docs/data-sources/workflows_provider_options.md create mode 100644 docs/resources/workflows_dag_bundle.md create mode 100644 docs/resources/workflows_instance.md create mode 100644 examples/data-sources/stackit_workflows_dag_bundle/data-source.tf create mode 100644 examples/data-sources/stackit_workflows_dag_bundles/data-source.tf create mode 100644 examples/data-sources/stackit_workflows_instance/data-source.tf create mode 100644 examples/data-sources/stackit_workflows_instances/data-source.tf create mode 100644 examples/data-sources/stackit_workflows_provider_options/data-source.tf create mode 100644 examples/resources/stackit_workflows_dag_bundle/resource.tf create mode 100644 examples/resources/stackit_workflows_instance/resource.tf create mode 100644 stackit/internal/services/workflows/dagbundle/datasource.go create mode 100644 stackit/internal/services/workflows/dagbundle/resource.go create mode 100644 stackit/internal/services/workflows/dagbundle/resource_test.go create mode 100644 stackit/internal/services/workflows/dagbundles/datasource.go create mode 100644 stackit/internal/services/workflows/instance/datasource.go create mode 100644 stackit/internal/services/workflows/instance/resource.go create mode 100644 stackit/internal/services/workflows/instance/resource_test.go create mode 100644 stackit/internal/services/workflows/instances/datasource.go create mode 100644 stackit/internal/services/workflows/provideroptions/datasource.go create mode 100644 stackit/internal/services/workflows/testdata/dagbundle-git-no-subdir.tf create mode 100644 stackit/internal/services/workflows/testdata/dagbundle-git.tf create mode 100644 stackit/internal/services/workflows/testdata/dagbundle-s3.tf create mode 100644 stackit/internal/services/workflows/testdata/instance-no-description.tf create mode 100644 stackit/internal/services/workflows/testdata/instance-stackit-idp.tf create mode 100644 stackit/internal/services/workflows/testdata/instance.tf create mode 100644 stackit/internal/services/workflows/utils/util.go create mode 100644 stackit/internal/services/workflows/utils/validators.go create mode 100644 stackit/internal/services/workflows/utils/validators_test.go create mode 100644 stackit/internal/services/workflows/workflows_acc_test.go create mode 100644 stackit/internal/services/workflows/workflows_test.go 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" }