Repository: getmoto/moto (python)

Issue:
Directory Service: DescribeDirectories expects LaunchTime to be a JSON Number, got string instead
Hello,
I'm using moto server with Terraform, creating a AWS Directory Service instance. I think there was a change in the API for `ds:DescribeDirectories`, or maybe just new validation on the response. But using the latest moto version, 5.0.12, I'm seeing the following error:

```
Error: reading Directory Service Directory (d-7b4fdb73d6): operation error Directory Service: DescribeDirectories, https response error StatusCode: 200, RequestID: QjGxElo4976zUbXqHjhAzLjSYn2WHHZ0VA46AmJTfyjldHWH6hVP, deserialization failed, failed to decode response body, expected LaunchTime to be a JSON Number, got string instead

  with module.directory_service.aws_directory_service_directory.this,
  on ../../main.tf line 1, in resource "aws_directory_service_directory" "this":
   1: resource "aws_directory_service_directory" "this" {
```

Checking the code, I see it is writing a datetime string in isoformat:

https://github.com/getmoto/moto/blob/2a8bb4a6fc06ad91ad1b234ccb11c1199b0898f2/moto/ds/models.py#L83

Looking up the API doc for the command, the response syntax for LaunchTime is actually a number.

https://docs.aws.amazon.com/directoryservice/latest/devguide/API_DescribeDirectories.html#API_DescribeDirectories_Examples

Pull request:
Codebuild & DS - Improve AWS parity
Fixes #7947 

Improves the response format of both CodeBuild and DS to get more parity, and ensure that these services can be used with Terraform.

Patch:
diff --git a/moto/codebuild/models.py b/moto/codebuild/models.py
index 34b96311d..2c0de3445 100644
--- a/moto/codebuild/models.py
+++ b/moto/codebuild/models.py
@@ -6,7 +6,7 @@ from dateutil import parser
 
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel
-from moto.core.utils import iso_8601_datetime_with_milliseconds
+from moto.core.utils import iso_8601_datetime_with_milliseconds, unix_time
 from moto.moto_api._internal import mock_random
 from moto.utilities.utils import get_partition
 
@@ -98,24 +98,38 @@ class CodeBuild(BaseModel):
         account_id: str,
         region: str,
         project_name: str,
+        description: Optional[str],
         project_source: Dict[str, Any],
         artifacts: Dict[str, Any],
         environment: Dict[str, Any],
         serviceRole: str = "some_role",
+        tags: Optional[List[Dict[str, str]]] = None,
+        cache: Optional[Dict[str, Any]] = None,
+        timeout: Optional[int] = 0,
+        queued_timeout: Optional[int] = 0,
+        source_version: Optional[str] = None,
+        logs_config: Optional[Dict[str, Any]] = None,
+        vpc_config: Optional[Dict[str, Any]] = None,
     ):
-        current_date = iso_8601_datetime_with_milliseconds()
+        self.arn = f"arn:{get_partition(region)}:codebuild:{region}:{account_id}:project/{project_name}"
+        self.service_role = serviceRole
+        self.tags = tags
+        current_date = unix_time()
         self.project_metadata: Dict[str, Any] = dict()
 
         self.project_metadata["name"] = project_name
-        self.project_metadata["arn"] = (
-            f"arn:{get_partition(region)}:codebuild:{region}:{account_id}:project/{project_name}"
-        )
+        if description:
+            self.project_metadata["description"] = description
+        self.project_metadata["arn"] = self.arn
         self.project_metadata["encryptionKey"] = (
             f"arn:{get_partition(region)}:kms:{region}:{account_id}:alias/aws/s3"
         )
-        self.project_metadata["serviceRole"] = (
-            f"arn:{get_partition(region)}:iam::{account_id}:role/service-role/{serviceRole}"
-        )
+        if serviceRole.startswith("arn:"):
+            self.project_metadata["serviceRole"] = serviceRole
+        else:
+            self.project_metadata["serviceRole"] = (
+                f"arn:{get_partition(region)}:iam::{account_id}:role/service-role/{serviceRole}"
+            )
         self.project_metadata["lastModifiedDate"] = current_date
         self.project_metadata["created"] = current_date
         self.project_metadata["badge"] = dict()
@@ -125,10 +139,16 @@ class CodeBuild(BaseModel):
         self.project_metadata["environment"] = environment
         self.project_metadata["artifacts"] = artifacts
         self.project_metadata["source"] = project_source
-        self.project_metadata["cache"] = dict()
-        self.project_metadata["cache"]["type"] = "NO_CACHE"
-        self.project_metadata["timeoutInMinutes"] = ""
-        self.project_metadata["queuedTimeoutInMinutes"] = ""
+        self.project_metadata["cache"] = cache or {"type": "NO_CACHE"}
+        self.project_metadata["timeoutInMinutes"] = timeout or 0
+        self.project_metadata["queuedTimeoutInMinutes"] = queued_timeout or 0
+        self.project_metadata["tags"] = tags
+        if source_version:
+            self.project_metadata["sourceVersion"] = source_version
+        if logs_config:
+            self.project_metadata["logsConfig"] = logs_config
+        if vpc_config:
+            self.project_metadata["vpcConfig"] = vpc_config
 
 
 class CodeBuildBackend(BaseBackend):
@@ -142,23 +162,35 @@ class CodeBuildBackend(BaseBackend):
     def create_project(
         self,
         project_name: str,
+        description: Optional[str],
         project_source: Dict[str, Any],
         artifacts: Dict[str, Any],
         environment: Dict[str, Any],
         service_role: str,
+        tags: Optional[List[Dict[str, str]]],
+        cache: Optional[Dict[str, Any]],
+        timeout: Optional[int],
+        queued_timeout: Optional[int],
+        source_version: Optional[str],
+        logs_config: Optional[Dict[str, Any]],
+        vpc_config: Optional[Dict[str, Any]],
     ) -> Dict[str, Any]:
-        # required in other functions that don't
-        self.project_name = project_name
-        self.service_role = service_role
-
         self.codebuild_projects[project_name] = CodeBuild(
             self.account_id,
             self.region_name,
-            project_name,
-            project_source,
-            artifacts,
-            environment,
-            service_role,
+            project_name=project_name,
+            description=description,
+            project_source=project_source,
+            artifacts=artifacts,
+            environment=environment,
+            serviceRole=service_role,
+            tags=tags,
+            cache=cache,
+            timeout=timeout,
+            queued_timeout=queued_timeout,
+            source_version=source_version,
+            logs_config=logs_config,
+            vpc_config=vpc_config,
         )
 
         # empty build history
@@ -174,12 +206,24 @@ class CodeBuildBackend(BaseBackend):
 
         return projects
 
+    def batch_get_projects(self, names: List[str]) -> List[Dict[str, Any]]:
+        result = []
+        for name in names:
+            if name in self.codebuild_projects:
+                result.append(self.codebuild_projects[name].project_metadata)
+            elif name.startswith("arn:"):
+                for project in self.codebuild_projects.values():
+                    if name == project.arn:
+                        result.append(project.project_metadata)
+        return result
+
     def start_build(
         self,
         project_name: str,
         source_version: Optional[str] = None,
         artifact_override: Optional[Dict[str, Any]] = None,
     ) -> Dict[str, Any]:
+        project = self.codebuild_projects[project_name]
         build_id = f"{project_name}:{mock_random.uuid4()}"
 
         # construct a new build
@@ -190,7 +234,7 @@ class CodeBuildBackend(BaseBackend):
             source_version,
             artifact_override,
             build_id,
-            self.service_role,
+            project.service_role,
         )
 
         self.build_history[project_name].append(build_id)
diff --git a/moto/codebuild/responses.py b/moto/codebuild/responses.py
index f516f6c2c..f8bba680e 100644
--- a/moto/codebuild/responses.py
+++ b/moto/codebuild/responses.py
@@ -136,11 +136,19 @@ class CodeBuildResponse(BaseResponse):
             )
 
         project_metadata = self.codebuild_backend.create_project(
-            self._get_param("name"),
-            self._get_param("source"),
-            self._get_param("artifacts"),
-            self._get_param("environment"),
+            project_name=self._get_param("name"),
+            description=self._get_param("description"),
+            project_source=self._get_param("source"),
+            artifacts=self._get_param("artifacts"),
+            environment=self._get_param("environment"),
             service_role=service_role,
+            tags=self._get_param("tags"),
+            cache=self._get_param("cache"),
+            timeout=self._get_param("timeoutInMinutes"),
+            queued_timeout=self._get_param("queuedTimeoutInMinutes"),
+            source_version=self._get_param("sourceVersion"),
+            logs_config=self._get_param("logsConfig"),
+            vpc_config=self._get_param("vpcConfig"),
         )
 
         return json.dumps({"project": project_metadata})
@@ -149,6 +157,11 @@ class CodeBuildResponse(BaseResponse):
         project_metadata = self.codebuild_backend.list_projects()
         return json.dumps({"projects": project_metadata})
 
+    def batch_get_projects(self) -> str:
+        names = self._get_param("names")
+        project_metadata = self.codebuild_backend.batch_get_projects(names)
+        return json.dumps({"projects": project_metadata})
+
     def start_build(self) -> str:
         _validate_required_params_project_name(self._get_param("projectName"))
 
diff --git a/moto/ds/models.py b/moto/ds/models.py
index ec1945e38..0145259b8 100644
--- a/moto/ds/models.py
+++ b/moto/ds/models.py
@@ -1,10 +1,10 @@
 """DirectoryServiceBackend class with methods for supported APIs."""
 
-from datetime import datetime, timezone
 from typing import Any, Dict, List, Optional, Tuple
 
 from moto.core.base_backend import BackendDict, BaseBackend
 from moto.core.common_models import BaseModel
+from moto.core.utils import unix_time
 from moto.ds.exceptions import (
     ClientException,
     DirectoryLimitExceededException,
@@ -80,8 +80,8 @@ class Directory(BaseModel):  # pylint: disable=too-many-instance-attributes
         self.desired_number_of_domain_controllers = 0
         self.sso_enabled = False
         self.stage = "Active"
-        self.launch_time = datetime.now(timezone.utc).isoformat()
-        self.stage_last_updated_date_time = datetime.now(timezone.utc).isoformat()
+        self.launch_time = unix_time()
+        self.stage_last_updated_date_time = unix_time()
 
         if self.directory_type == "ADConnector":
             self.security_group_id = self.create_security_group(
diff --git a/other_langs/terraform/codebuild/project.tf b/other_langs/terraform/codebuild/project.tf
new file mode 100644
index 000000000..a244165c7
--- /dev/null
+++ b/other_langs/terraform/codebuild/project.tf
@@ -0,0 +1,234 @@
+resource "aws_vpc" "example" {
+  cidr_block = "10.0.0.0/16"
+}
+
+resource "aws_subnet" "example1" {
+  vpc_id     = aws_vpc.example.id
+  cidr_block = "10.0.1.0/24"
+}
+
+resource "aws_subnet" "example2" {
+  vpc_id     = aws_vpc.example.id
+  cidr_block = "10.0.2.0/24"
+}
+
+resource "aws_security_group" "example1" {
+  name        = "sg name"
+  description = "Desc"
+  vpc_id      = aws_vpc.example.id
+}
+
+resource "aws_security_group" "example2" {
+  name        = "sg name 2"
+  description = "Desc"
+  vpc_id      = aws_vpc.example.id
+}
+
+resource "aws_s3_bucket" "example" {
+  bucket = "example"
+}
+
+resource "aws_s3_bucket_acl" "example" {
+  bucket = aws_s3_bucket.example.id
+  acl    = "private"
+}
+
+data "aws_iam_policy_document" "assume_role" {
+  statement {
+    effect = "Allow"
+
+    principals {
+      type        = "Service"
+      identifiers = ["codebuild.amazonaws.com"]
+    }
+
+    actions = ["sts:AssumeRole"]
+  }
+}
+
+resource "aws_iam_role" "example" {
+  name               = "example"
+  assume_role_policy = data.aws_iam_policy_document.assume_role.json
+}
+
+data "aws_iam_policy_document" "example" {
+  statement {
+    effect = "Allow"
+
+    actions = [
+      "logs:CreateLogGroup",
+      "logs:CreateLogStream",
+      "logs:PutLogEvents",
+    ]
+
+    resources = ["*"]
+  }
+
+  statement {
+    effect = "Allow"
+
+    actions = [
+      "ec2:CreateNetworkInterface",
+      "ec2:DescribeDhcpOptions",
+      "ec2:DescribeNetworkInterfaces",
+      "ec2:DeleteNetworkInterface",
+      "ec2:DescribeSubnets",
+      "ec2:DescribeSecurityGroups",
+      "ec2:DescribeVpcs",
+    ]
+
+    resources = ["*"]
+  }
+
+  statement {
+    effect    = "Allow"
+    actions   = ["ec2:CreateNetworkInterfacePermission"]
+    resources = ["arn:aws:ec2:us-east-1:123456789012:network-interface/*"]
+
+    condition {
+      test     = "StringEquals"
+      variable = "ec2:Subnet"
+
+      values = [
+        aws_subnet.example1.arn,
+        aws_subnet.example2.arn,
+      ]
+    }
+
+    condition {
+      test     = "StringEquals"
+      variable = "ec2:AuthorizedService"
+      values   = ["codebuild.amazonaws.com"]
+    }
+  }
+
+  statement {
+    effect  = "Allow"
+    actions = ["s3:*"]
+    resources = [
+      aws_s3_bucket.example.arn,
+      "${aws_s3_bucket.example.arn}/*",
+    ]
+  }
+}
+
+resource "aws_iam_role_policy" "example" {
+  role   = aws_iam_role.example.name
+  policy = data.aws_iam_policy_document.example.json
+}
+
+resource "aws_codebuild_project" "example" {
+  name          = "test-project"
+  description   = "test_codebuild_project"
+  build_timeout = 5
+  service_role  = aws_iam_role.example.arn
+
+  artifacts {
+    type = "NO_ARTIFACTS"
+  }
+
+  cache {
+    type     = "S3"
+    location = aws_s3_bucket.example.bucket
+  }
+
+  environment {
+    compute_type                = "BUILD_GENERAL1_SMALL"
+    image                       = "aws/codebuild/amazonlinux2-x86_64-standard:4.0"
+    type                        = "LINUX_CONTAINER"
+    image_pull_credentials_type = "CODEBUILD"
+
+    environment_variable {
+      name  = "SOME_KEY1"
+      value = "SOME_VALUE1"
+    }
+
+    environment_variable {
+      name  = "SOME_KEY2"
+      value = "SOME_VALUE2"
+      type  = "PARAMETER_STORE"
+    }
+  }
+
+  logs_config {
+    cloudwatch_logs {
+      group_name  = "log-group"
+      stream_name = "log-stream"
+    }
+
+    s3_logs {
+      status   = "ENABLED"
+      location = "${aws_s3_bucket.example.id}/build-log"
+    }
+  }
+
+  source {
+    type            = "GITHUB"
+    location        = "https://github.com/mitchellh/packer.git"
+    git_clone_depth = 1
+
+    git_submodules_config {
+      fetch_submodules = true
+    }
+  }
+
+  source_version = "master"
+
+  vpc_config {
+    vpc_id = aws_vpc.example.id
+
+    subnets = [
+      aws_subnet.example1.id,
+      aws_subnet.example2.id,
+    ]
+
+    security_group_ids = [
+      aws_security_group.example1.id,
+      aws_security_group.example2.id,
+    ]
+  }
+
+  tags = {
+    Environment = "Test"
+  }
+}
+
+resource "aws_codebuild_project" "project-with-cache" {
+  name           = "test-project-cache"
+  description    = "test_codebuild_project_cache"
+  build_timeout  = 5
+  queued_timeout = 5
+
+  service_role = aws_iam_role.example.arn
+
+  artifacts {
+    type = "NO_ARTIFACTS"
+  }
+
+  cache {
+    type  = "LOCAL"
+    modes = ["LOCAL_DOCKER_LAYER_CACHE", "LOCAL_SOURCE_CACHE"]
+  }
+
+  environment {
+    compute_type                = "BUILD_GENERAL1_SMALL"
+    image                       = "aws/codebuild/amazonlinux2-x86_64-standard:4.0"
+    type                        = "LINUX_CONTAINER"
+    image_pull_credentials_type = "CODEBUILD"
+
+    environment_variable {
+      name  = "SOME_KEY1"
+      value = "SOME_VALUE1"
+    }
+  }
+
+  source {
+    type            = "GITHUB"
+    location        = "https://github.com/mitchellh/packer.git"
+    git_clone_depth = 1
+  }
+
+  tags = {
+    Environment = "Test"
+  }
+}
\ No newline at end of file
diff --git a/other_langs/terraform/codebuild/provider.tf b/other_langs/terraform/codebuild/provider.tf
new file mode 100644
index 000000000..25ef614c0
--- /dev/null
+++ b/other_langs/terraform/codebuild/provider.tf
@@ -0,0 +1,26 @@
+terraform {
+  required_providers {
+    aws = {
+      source = "hashicorp/aws"
+    }
+  }
+}
+
+provider "aws" {
+  region                      = "us-east-1"
+  s3_use_path_style           = true
+  skip_credentials_validation = true
+  skip_metadata_api_check     = true
+  skip_requesting_account_id  = true
+
+  endpoints {
+    ec2          = "http://localhost:5000"
+    codebuild    = "http://localhost:5000"
+    iam          = "http://localhost:5000"
+    logs         = "http://localhost:5000"
+    s3           = "http://localhost:5000"
+  }
+
+  access_key = "my-access-key"
+  secret_key = "my-secret-key"
+}
\ No newline at end of file
diff --git a/other_langs/terraform/ds/provider.tf b/other_langs/terraform/ds/provider.tf
new file mode 100644
index 000000000..b4101c772
--- /dev/null
+++ b/other_langs/terraform/ds/provider.tf
@@ -0,0 +1,23 @@
+terraform {
+  required_providers {
+    aws = {
+      source = "hashicorp/aws"
+    }
+  }
+}
+
+provider "aws" {
+  region                      = "us-east-1"
+  s3_use_path_style           = true
+  skip_credentials_validation = true
+  skip_metadata_api_check     = true
+  skip_requesting_account_id  = true
+
+  endpoints {
+    ec2   = "http://localhost:5000"
+    ds    = "http://localhost:5000"
+  }
+
+  access_key = "my-access-key"
+  secret_key = "my-secret-key"
+}
\ No newline at end of file
diff --git a/other_langs/terraform/ds/simple_ad.tf b/other_langs/terraform/ds/simple_ad.tf
new file mode 100644
index 000000000..3289b582c
--- /dev/null
+++ b/other_langs/terraform/ds/simple_ad.tf
@@ -0,0 +1,30 @@
+resource "aws_directory_service_directory" "bar" {
+  name     = "corp.notexample.com"
+  password = "SuperSecretPassw0rd"
+  size     = "Small"
+
+  vpc_settings {
+    vpc_id     = aws_vpc.main.id
+    subnet_ids = [aws_subnet.foo.id, aws_subnet.bar.id]
+  }
+
+  tags = {
+    Project = "foo"
+  }
+}
+
+resource "aws_vpc" "main" {
+  cidr_block = "10.0.0.0/16"
+}
+
+resource "aws_subnet" "foo" {
+  vpc_id            = aws_vpc.main.id
+  availability_zone = "us-west-2a"
+  cidr_block        = "10.0.1.0/24"
+}
+
+resource "aws_subnet" "bar" {
+  vpc_id            = aws_vpc.main.id
+  availability_zone = "us-west-2b"
+  cidr_block        = "10.0.2.0/24"
+}
\ No newline at end of file


Test patch:
diff --git a/.github/workflows/tests_terraform_examples.yml b/.github/workflows/tests_terraform_examples.yml
index 0dd188f7e..b24d748f6 100644
--- a/.github/workflows/tests_terraform_examples.yml
+++ b/.github/workflows/tests_terraform_examples.yml
@@ -11,7 +11,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        service: ["acm", "awslambda", "cloudfront", "elb", "iam", "rds", "route53", "sqs"]
+        service: ["acm", "awslambda", "cloudfront", "codebuild", "ds", "elb", "iam", "rds", "route53", "sqs"]
 
     steps:
     - uses: actions/checkout@v4
diff --git a/tests/test_codebuild/test_codebuild.py b/tests/test_codebuild/test_codebuild.py
index 057218eb1..a2129de35 100644
--- a/tests/test_codebuild/test_codebuild.py
+++ b/tests/test_codebuild/test_codebuild.py
@@ -37,6 +37,7 @@ def test_codebuild_create_project_s3_artifacts():
         artifacts=artifacts,
         environment=environment,
         serviceRole=service_role,
+        tags=[{"key": "k1", "value": "v1"}],
     )["project"]
 
     assert "serviceRole" in project
@@ -51,6 +52,8 @@ def test_codebuild_create_project_s3_artifacts():
     assert project["source"] == {"location": "bucketname/path/file.zip", "type": "S3"}
     assert project["artifacts"] == {"location": "bucketname", "type": "S3"}
 
+    assert project["tags"] == [{"key": "k1", "value": "v1"}]
+
 
 @mock_aws
 def test_codebuild_create_project_no_artifacts():
@@ -612,3 +615,42 @@ def test_codebuild_stop_build_bad_uid():
     with pytest.raises(client.exceptions.InvalidInputException) as err:
         client.stop_build(id=f"some_project{uuid1()}")
     assert err.value.response["Error"]["Code"] == "InvalidInputException"
+
+
+@mock_aws
+def test_codebuild_batch_get_projects():
+    client = boto3.client("codebuild", region_name="eu-central-1")
+
+    name = "some_project"
+    source = dict()
+    source["type"] = "S3"
+    # repository location for S3
+    source["location"] = "bucketname/path/file.zip"
+    artifacts = {"type": "NO_ARTIFACTS"}
+
+    environment = dict()
+    environment["type"] = "LINUX_CONTAINER"
+    environment["image"] = "contents_not_validated"
+    environment["computeType"] = "BUILD_GENERAL1_SMALL"
+    service_role = (
+        f"arn:aws:iam::{ACCOUNT_ID}:role/service-role/my-codebuild-service-role"
+    )
+
+    project_arn = client.create_project(
+        name=name,
+        source=source,
+        artifacts=artifacts,
+        environment=environment,
+        serviceRole=service_role,
+    )["project"]["arn"]
+
+    projects_batch = client.batch_get_projects(names=[name])["projects"]
+    assert len(projects_batch) == 1
+    assert projects_batch[0]["name"] == name
+    assert projects_batch[0]["arn"] == project_arn
+
+    # Assert we can also use the ARN
+    projects_batch = client.batch_get_projects(names=[project_arn])["projects"]
+    assert len(projects_batch) == 1
+    assert projects_batch[0]["name"] == name
+    assert projects_batch[0]["arn"] == project_arn