diff --git a/ibm_catalog.json b/ibm_catalog.json
index 4aa430bc..a095123e 100644
--- a/ibm_catalog.json
+++ b/ibm_catalog.json
@@ -352,6 +352,9 @@
{
"key": "builds"
},
+ {
+ "key": "container_registry_namespace"
+ },
{
"key": "domain_mappings"
},
diff --git a/modules/build/README.md b/modules/build/README.md
index c19dd651..1065be10 100644
--- a/modules/build/README.md
+++ b/modules/build/README.md
@@ -73,4 +73,6 @@ No modules.
| [build\_id](#output\_build\_id) | The ID of the created code engine build. |
| [id](#output\_id) | The unique identifier of the created code engine build. |
| [name](#output\_name) | The name of the created code engine build. |
+| [output\_image](#output\_output\_image) | The container image reference of the created code engine build. |
+| [output\_secret](#output\_output\_secret) | The registry secret of the created code engine build. |
diff --git a/modules/build/outputs.tf b/modules/build/outputs.tf
index 5f01b37d..240f76b1 100644
--- a/modules/build/outputs.tf
+++ b/modules/build/outputs.tf
@@ -16,3 +16,13 @@ output "name" {
description = "The name of the created code engine build."
value = resource.ibm_code_engine_build.ce_build.name
}
+
+output "output_image" {
+ description = "The container image reference of the created code engine build."
+ value = resource.ibm_code_engine_build.ce_build.output_image
+}
+
+output "output_secret" {
+ description = "The registry secret of the created code engine build."
+ value = resource.ibm_code_engine_build.ce_build.output_secret
+}
diff --git a/solutions/project/DA-inputs.md b/solutions/project/DA-inputs.md
index 1cc34928..add0c934 100644
--- a/solutions/project/DA-inputs.md
+++ b/solutions/project/DA-inputs.md
@@ -17,7 +17,7 @@ The `builds` input variable allows you to provide details of the of builds which
### Options for Builds
- - `output_image` (required): The name of the image.
+ - `output_image` (optional): The name of the image. A container image can be identified by a container image reference with the following structure: registry / namespace / repository : tag. If not provided, the name is automatically build using region registry / container_registry_namespace input / build name.
- `output_secret` (required): The secret that is required to access the image registry.
- `source_url` (required): The URL of the code repository.
- `strategy_type` (required): Specifies the type of source to determine if your build source is in a repository or based on local source code.
@@ -118,10 +118,30 @@ The `secrets` input variable allows you to provide a method to include sensitive
### Example for Secrets
```hcl
+# generic secret
{
"your-secret-name" = {
format = "generic"
data = { "key_1" : "value_1", "key_2" : "value_2" }
}
}
+
+# registry secret
+"registry_secret_name" = {
+ format = "registry"
+ optional("data") = {
+ "server" = "private.us.icr.io",
+ "username" = "iamapikey",
+ "password" = iam_api_key, # pragma: allowlist secret
+ }
+}
+
+# private repository
+"private_repo" = {
+ format = "generic"
+ "data" = {
+ "password" = github_token, # pragma: allowlist secret
+ "username" = github_user
+ }
+}
```
diff --git a/solutions/project/main.tf b/solutions/project/main.tf
index 5ab7dad2..72c5a48a 100644
--- a/solutions/project/main.tf
+++ b/solutions/project/main.tf
@@ -26,10 +26,54 @@ module "project" {
##############################################################################
# Code Engine Build
##############################################################################
+locals {
+ registry_region_result = data.external.container_registry_region.result
+ registry = lookup(local.registry_region_result, "registry", null)
+ container_registry = local.registry != null ? "private.${local.registry}" : null
+ registry_region_error = lookup(local.registry_region_result, "error", null)
+
+ # This will cause Terraform to fail if "error" is present in the external script output executed as a part of container_registry_region
+ # tflint-ignore: terraform_unused_declarations
+ fail_if_registry_region_error = local.registry_region_error != null ? tobool("Registry region script failed: ${local.registry_region_error}") : null
+
+ # if no build defines a container image reference (output_image), a new container registry namespace must be created using container_registry_namespace.
+ any_missing_output_image = anytrue([
+ for build in values(var.builds) :
+ !contains(keys(build), "output_image") || build.output_image == null
+ ])
+ image_container = local.any_missing_output_image && local.container_registry != null ? "${local.container_registry}/${resource.ibm_cr_namespace.my_namespace[0].name}" : ""
+
+ # if output_image not exists then a new created container image reference
+ updated_builds = {
+ for name, build in var.builds :
+ name => merge(
+ build,
+ {
+ output_image = coalesce(build.output_image, "${local.image_container}/${name}")
+ }
+ )
+ }
+}
+
+resource "ibm_cr_namespace" "my_namespace" {
+ count = local.any_missing_output_image && var.container_registry_namespace != null ? 1 : 0
+ name = var.container_registry_namespace
+}
+
+data "external" "container_registry_region" {
+ program = ["bash", "${path.module}/scripts/get-cr-region.sh"]
+
+ query = {
+ RESOURCE_GROUP_ID = module.resource_group.resource_group_id
+ REGION = var.region
+ IBMCLOUD_API_KEY = var.ibmcloud_api_key
+ }
+}
+
module "build" {
depends_on = [module.secret]
source = "../../modules/build"
- for_each = var.builds
+ for_each = local.updated_builds
project_id = module.project.project_id
name = each.key
output_image = each.value.output_image
@@ -43,7 +87,21 @@ module "build" {
strategy_size = each.value.strategy_size
strategy_spec_file = each.value.strategy_spec_file
timeout = each.value.timeout
+}
+resource "null_resource" "run_build" {
+ depends_on = [module.build]
+ provisioner "local-exec" {
+ interpreter = ["/bin/bash", "-c"]
+ command = "${path.module}/scripts/build-run.sh"
+ environment = {
+ IBMCLOUD_API_KEY = var.ibmcloud_api_key
+ RESOURCE_GROUP_ID = module.resource_group.resource_group_id
+ CE_PROJECT_NAME = module.project.name
+ REGION = var.region
+ BUILDS = join(" ", keys(local.updated_builds))
+ }
+ }
}
##############################################################################
@@ -72,9 +130,30 @@ module "config_map" {
##############################################################################
# Code Engine Secret
##############################################################################
+locals {
+ # if the secret is a registry type, inject generated credentials (username, password, server) if they're not already provided.
+ secrets = {
+ for name, secret in var.secrets :
+ name => merge(
+ secret,
+ {
+ data = (
+ secret.format == "registry"
+ ? merge(secret.data, {
+ password = lookup(secret.data, "password", var.ibmcloud_api_key),
+ username = lookup(secret.data, "username", "iamapikey"),
+ server = lookup(secret.data, "server", local.container_registry)
+ })
+ : secret.data
+ )
+ }
+ )
+ }
+}
+
module "secret" {
source = "../../modules/secret"
- for_each = var.secrets
+ for_each = local.secrets
project_id = module.project.project_id
name = each.key
data = sensitive(each.value.data)
diff --git a/solutions/project/scripts/build-run.sh b/solutions/project/scripts/build-run.sh
new file mode 100755
index 00000000..a9bc9477
--- /dev/null
+++ b/solutions/project/scripts/build-run.sh
@@ -0,0 +1,38 @@
+#!/bin/bash
+set -e
+
+if [[ -z "${IBMCLOUD_API_KEY}" ]]; then
+ echo "IBMCLOUD_API_KEY is required" >&2
+ exit 1
+fi
+
+if [[ -z "${RESOURCE_GROUP_ID}" ]]; then
+ echo "RESOURCE_GROUP_ID is required" >&2
+ exit 1
+fi
+
+if [[ -z "${CE_PROJECT_NAME}" ]]; then
+ echo "CE_PROJECT_NAME is required" >&2
+ exit 1
+fi
+
+if [[ -z "${REGION}" ]]; then
+ echo "REGION is required" >&2
+ exit 1
+fi
+
+if [[ -z "${BUILDS}" ]]; then
+ echo "BUILDS is required" >&2
+ exit 1
+fi
+
+ibmcloud login -r "${REGION}" -g "${RESOURCE_GROUP_ID}" --quiet
+
+# selecet the right code engine project
+ibmcloud ce project select -n "${CE_PROJECT_NAME}"
+
+# run a build for all builds
+for build in $BUILDS; do
+ echo "$build"
+ ibmcloud ce buildrun submit --build "$build"
+done
diff --git a/solutions/project/scripts/get-cr-region.sh b/solutions/project/scripts/get-cr-region.sh
new file mode 100755
index 00000000..b7230ee4
--- /dev/null
+++ b/solutions/project/scripts/get-cr-region.sh
@@ -0,0 +1,39 @@
+#!/bin/bash
+set -euo pipefail
+
+INPUT=$(cat)
+REGION=$(echo "$INPUT" | jq -r '.REGION')
+RESOURCE_GROUP_ID=$(echo "$INPUT" | jq -r '.RESOURCE_GROUP_ID')
+IBMCLOUD_API_KEY=$(echo "$INPUT" | jq -r '.IBMCLOUD_API_KEY')
+export IBMCLOUD_API_KEY
+
+if [[ -z "${IBMCLOUD_API_KEY}" || "${IBMCLOUD_API_KEY}" == "null" ]]; then
+ echo '{"error": "IBMCLOUD_API_KEY is required"}'
+ exit 0
+fi
+
+if [[ -z "${RESOURCE_GROUP_ID}" || "${RESOURCE_GROUP_ID}" == "null" ]]; then
+ echo '{"error": "RESOURCE_GROUP_ID is required"}'
+ exit 0
+fi
+
+if [[ -z "${REGION}" || "${REGION}" == "null" ]]; then
+ echo '{"error": "REGION is required"}'
+ exit 0
+fi
+
+if ! ibmcloud login -r "${REGION}" -g "${RESOURCE_GROUP_ID}" --quiet > /dev/null 2>&1; then
+ printf '{"error": "Failed to login using: ibmcloud login -r %s -g %s"}' "$REGION" "$RESOURCE_GROUP_ID"
+ exit 0
+fi
+
+# extract registry value from text "You are targeting region 'us-south', the registry is 'us.icr.io'."
+registry=$(ibmcloud cr region 2>/dev/null | grep registry | sed -E "s/.*registry is '([^']+)'.*/\1/")
+
+# Validate registry value
+if [[ -z "$registry" ]]; then
+ echo '{"error": "Failed to parse registry region from ibmcloud cr region"}'
+ exit 0
+fi
+
+echo "{\"registry\": \"${registry}\"}"
diff --git a/solutions/project/variables.tf b/solutions/project/variables.tf
index da305053..3c980ff6 100644
--- a/solutions/project/variables.tf
+++ b/solutions/project/variables.tf
@@ -46,7 +46,7 @@ variable "project_name" {
variable "builds" {
description = "A map of code engine builds to be created.[Learn more](https://github.com/terraform-ibm-modules/terraform-ibm-code-engine/blob/main/solutions/project/DA-inputs.md#builds)"
type = map(object({
- output_image = string
+ output_image = optional(string)
output_secret = string # pragma: allowlist secret
source_url = string
strategy_type = string
@@ -61,6 +61,20 @@ variable "builds" {
default = {}
}
+variable "container_registry_namespace" {
+ description = "The name of the namespace to create in IBM Cloud Container Registry for organizing container images. Used only for builds that do not have output_image set."
+ type = string
+ default = null
+
+ validation {
+ condition = alltrue([
+ for build in values(var.builds) :
+ contains(keys(build), "output_image") && build.output_image != null
+ ]) || var.container_registry_namespace != null
+ error_message = "container_registry_namespace is required because at least one build is missing an output_image"
+ }
+}
+
##############################################################################
# Code Engine Domain Mapping
##############################################################################
diff --git a/solutions/project/version.tf b/solutions/project/version.tf
index 6d7d1d96..365303ac 100644
--- a/solutions/project/version.tf
+++ b/solutions/project/version.tf
@@ -6,5 +6,13 @@ terraform {
source = "IBM-Cloud/ibm"
version = "1.79.2"
}
+ external = {
+ source = "hashicorp/external"
+ version = "2.3.5"
+ }
+ null = {
+ source = "hashicorp/null"
+ version = "3.2.4"
+ }
}
}
diff --git a/tests/pr_test.go b/tests/pr_test.go
index 11ba61f7..d4403a02 100644
--- a/tests/pr_test.go
+++ b/tests/pr_test.go
@@ -5,13 +5,11 @@ import (
"fmt"
"log"
"os"
+ "sort"
"strings"
"testing"
- "github.com/gruntwork-io/terratest/modules/files"
- "github.com/gruntwork-io/terratest/modules/logger"
"github.com/gruntwork-io/terratest/modules/random"
- "github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/terraform-ibm-modules/ibmcloud-terratest-wrapper/common"
@@ -215,103 +213,155 @@ func TestUpgradeCEProjectDA(t *testing.T) {
func TestDeployCEProjectDA(t *testing.T) {
t.Parallel()
- // Provision watsonx Assistant instance
- prefix := fmt.Sprintf("ce-data-%s", strings.ToLower(random.UniqueId()))
- realTerraformDir := ".."
- tempTerraformDir, _ := files.CopyTerraformFolderToTemp(realTerraformDir, fmt.Sprintf(prefix+"-%s", strings.ToLower(random.UniqueId())))
- // tags := common.GetTagsFromTravis()
-
// Verify ibmcloud_api_key variable is set
checkVariable := "TF_VAR_ibmcloud_api_key"
val, present := os.LookupEnv(checkVariable)
require.True(t, present, checkVariable+" environment variable not set")
require.NotEqual(t, "", val, checkVariable+" environment variable is empty")
- logger.Log(t, "Tempdir: ", tempTerraformDir)
- existingTerraformOptions := terraform.WithDefaultRetryableErrors(t, &terraform.Options{
- TerraformDir: tempTerraformDir + "/tests/resources",
- Vars: map[string]interface{}{
- "resource_group": resourceGroup,
- "prefix": prefix,
+ prefix := fmt.Sprintf("ce-data-%s", strings.ToLower(random.UniqueId()))
+ // set up the options for existing resource deployment
+ // needed by solution
+ existingResourceOptions := testhelper.TestOptionsDefault(&testhelper.TestOptions{
+ Testing: t,
+ TerraformDir: "tests/resources",
+ Prefix: prefix,
+ ResourceGroup: resourceGroup,
+ TerraformVars: map[string]interface{}{
"existing_sm_instance_guid": permanentResources["secretsManagerGuid"],
"existing_sm_instance_region": permanentResources["secretsManagerRegion"],
"existing_cert_secret_id": permanentResources["cePublicCertId"],
+ "resource_group": resourceGroup,
+ "prefix": prefix,
},
- // Set Upgrade to true to ensure latest version of providers and modules are used by terratest.
- // This is the same as setting the -upgrade=true flag with terraform.
- Upgrade: true,
})
+ // Creates temp dirs and runs InitAndApply for existing resources
+ // outputs will be in options after apply
+ existingResourceOptions.SkipTestTearDown = true
+ _, existDeployErr := existingResourceOptions.RunTest()
- terraform.WorkspaceSelectOrNew(t, existingTerraformOptions, prefix)
- _, existErr := terraform.InitAndApplyE(t, existingTerraformOptions)
- if existErr != nil {
- assert.True(t, existErr == nil, "Init and Apply of temp existing resource failed")
- } else {
- outputs, err := terraform.OutputAllE(t, existingTerraformOptions)
- require.NoError(t, err, "Failed to retrieve Terraform outputs")
- expectedOutputs := []string{"tls_cert", "tls_key", "cr_name"}
- _, tfOutputsErr := testhelper.ValidateTerraformOutputs(outputs, expectedOutputs...)
- if assert.Nil(t, tfOutputsErr, tfOutputsErr) {
- options := testhelper.TestOptionsDefault(&testhelper.TestOptions{
- Testing: t,
- TerraformDir: "solutions/project",
- // Do not hard fail the test if the implicit destroy steps fail to allow a full destroy of resource to occur
- ImplicitRequired: false,
- TerraformVars: map[string]interface{}{
- "prefix": prefix,
- "provider_visibility": "public",
- "project_name": prefix,
- "existing_resource_group_name": resourceGroup,
- "builds": map[string]interface{}{
- fmt.Sprintf("%s-build", prefix): map[string]interface{}{
- "output_image": fmt.Sprintf("us.icr.io/%s/%s", terraform.Output(t, existingTerraformOptions, "cr_name"), prefix),
- "output_secret": fmt.Sprintf("%s-registry", prefix), // pragma: allowlist secret
- "source_url": "https://github.com/IBM/CodeEngine",
- "strategy_type": "dockerfile",
- },
- },
- "config_maps": map[string]interface{}{
- fmt.Sprintf("%s-cm", prefix): map[string]interface{}{
- "data": map[string]interface{}{
- "key_1": "value_1",
- "key_2": "value_2",
- },
- },
- },
- "secrets": map[string]interface{}{
- fmt.Sprintf("%s-tls", prefix): map[string]interface{}{
- "format": "tls",
- "data": map[string]string{
- "tls_cert": strings.ReplaceAll(terraform.Output(t, existingTerraformOptions, "tls_cert"), "\n", `\n`),
- "tls_key": strings.ReplaceAll(terraform.Output(t, existingTerraformOptions, "tls_key"), "\n", `\n`),
- },
- },
- fmt.Sprintf("%s-registry", prefix): map[string]interface{}{
- "format": "registry",
- "data": map[string]string{
- "server": "us.icr.io",
- "username": "iamapikey",
- "password": val, // pragma: allowlist secret
- },
- },
- },
+ defer existingResourceOptions.TestTearDown() // public function ignores skip above
+
+ require.NoError(t, existDeployErr, "error creating needed existing VPC resources")
+
+ tfVars := map[string]interface{}{
+ "prefix": prefix,
+ "provider_visibility": "public",
+ "project_name": prefix,
+ "existing_resource_group_name": resourceGroup,
+ "container_registry_namespace": fmt.Sprintf("test_%s_ns", prefix),
+ "builds": map[string]interface{}{
+ fmt.Sprintf("%s-build", prefix): map[string]interface{}{
+ "output_image": fmt.Sprintf("private.us.icr.io/%s/%s", existingResourceOptions.LastTestTerraformOutputs["cr_name"].(string), prefix),
+ "output_secret": fmt.Sprintf("%s-registry", prefix), // pragma: allowlist secret
+ "source_url": "https://github.com/IBM/CodeEngine",
+ "strategy_type": "dockerfile",
+ },
+ fmt.Sprintf("%s-build-2", prefix): map[string]interface{}{
+ "output_secret": fmt.Sprintf("%s-registry", prefix), // pragma: allowlist secret
+ "source_url": "https://github.com/IBM/CodeEngine",
+ "strategy_type": "dockerfile",
+ "source_context_dir": "hello",
+ },
+ },
+ "config_maps": map[string]interface{}{
+ fmt.Sprintf("%s-cm", prefix): map[string]interface{}{
+ "data": map[string]interface{}{
+ "key_1": "value_1",
+ "key_2": "value_2",
},
- })
- output, err := options.RunTestConsistency()
- assert.Nil(t, err, "This should not have errored")
- assert.NotNil(t, output, "Expected some output")
+ },
+ },
+ "secrets": map[string]interface{}{
+ fmt.Sprintf("%s-tls", prefix): map[string]interface{}{
+ "format": "tls",
+ "data": map[string]string{
+ "tls_cert": strings.ReplaceAll(existingResourceOptions.LastTestTerraformOutputs["tls_cert"].(string), "\n", `\n`),
+ "tls_key": strings.ReplaceAll(existingResourceOptions.LastTestTerraformOutputs["tls_key"].(string), "\n", `\n`),
+ },
+ },
+ fmt.Sprintf("%s-registry", prefix): map[string]interface{}{
+ "format": "registry",
+ "data": map[string]string{
+ "server": "private.us.icr.io",
+ "username": "iamapikey",
+ "password": val, // pragma: allowlist secret
+ },
+ },
+ },
+ }
+
+ options := testhelper.TestOptionsDefault(&testhelper.TestOptions{
+ Testing: t,
+ TerraformDir: projectSolutionsDir,
+ Prefix: prefix,
+ ResourceGroup: resourceGroup,
+ PreApplyHook: func(options *testhelper.TestOptions) error {
+ // create tfvar file from tfVars variable
+ tfvarFileName := fmt.Sprintf("%s/%s-terraform.tfvars", options.TerraformDir, prefix)
+ err := writeTfvarsFile(t, tfvarFileName, tfVars)
+ if err == nil {
+ options.TerraformOptions.VarFiles = []string{tfvarFileName}
+ }
+ return err
+ },
+ })
+
+ _, err := options.RunTestConsistency()
+ assert.Nil(t, err, "This should not have errored")
+}
+
+// function to convert map into HCL format (needed for tfvar file)
+func toHCL(value interface{}, indentLevel int) string {
+ indent := strings.Repeat(" ", indentLevel)
+
+ switch val := value.(type) {
+ case string:
+ return fmt.Sprintf("\"%s\"", val)
+ case bool:
+ return fmt.Sprintf("%t", val)
+ case float64, int:
+ return fmt.Sprintf("%v", val)
+ case map[string]string:
+ var b strings.Builder
+ b.WriteString("{\n")
+ keys := make([]string, 0, len(val))
+ for k := range val {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ b.WriteString(fmt.Sprintf("%s %s = \"%s\"\n", indent, k, val[k]))
+ }
+ b.WriteString(fmt.Sprintf("%s}", indent))
+ return b.String()
+ case map[string]interface{}:
+ var b strings.Builder
+ b.WriteString("{\n")
+ keys := make([]string, 0, len(val))
+ for k := range val {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ b.WriteString(fmt.Sprintf("%s \"%s\" = %s\n", indent, k, toHCL(val[k], indentLevel+1)))
}
+ b.WriteString(fmt.Sprintf("%s}", indent))
+ return b.String()
+ default:
+ return fmt.Sprintf("\"%v\"", val)
+ }
+}
+
+func writeTfvarsFile(t *testing.T, path string, vars map[string]interface{}) error {
+ var sb strings.Builder
+ for k, v := range vars {
+ sb.WriteString(fmt.Sprintf("%s = %s\n", k, toHCL(v, 0)))
}
- // Check if "DO_NOT_DESTROY_ON_FAILURE" is set
- envVal, _ := os.LookupEnv("DO_NOT_DESTROY_ON_FAILURE")
- // Destroy the temporary existing resources if required
- if t.Failed() && strings.ToLower(envVal) == "true" {
- fmt.Println("Terratest failed. Debug the test and delete resources manually.")
- } else {
- logger.Log(t, "START: Destroy (existing resources)")
- terraform.Destroy(t, existingTerraformOptions)
- terraform.WorkspaceDelete(t, existingTerraformOptions, prefix)
- logger.Log(t, "END: Destroy (existing resources)")
+ err := os.WriteFile(path, []byte(sb.String()), 0644)
+ if err != nil {
+ t.Fatalf("Failed to write tfvars file: %v", err)
}
+ return err
}