diff --git a/azuredevops/internal/acceptancetests/resource_git_repository_file_test.go b/azuredevops/internal/acceptancetests/resource_git_repository_file_test.go new file mode 100644 index 000000000..fc9b24735 --- /dev/null +++ b/azuredevops/internal/acceptancetests/resource_git_repository_file_test.go @@ -0,0 +1,142 @@ +// +build all core resource_git_repository_file +// +build !exclude_resource_git_repository_file + +package acceptancetests + +import ( + "bytes" + "context" + "fmt" + "strings" + + "regexp" + "testing" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/terraform" + "github.com/microsoft/azure-devops-go-api/azuredevops/git" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/acceptancetests/testutils" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/client" +) + +// TestAccGitRepoFile_CreateUpdateDelete verifies that a file can +// be added to a repository and the contents can be updated +func TestAccGitRepoFile_CreateAndUpdate(t *testing.T) { + projectName := testutils.GenerateResourceName() + gitRepoName := testutils.GenerateResourceName() + tfRepoFileNode := "azuredevops_git_repository_file.file" + + branch := "refs/heads/master" + file := "foo.txt" + contentFirst := "bar" + contentSecond := "baz" + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testutils.PreCheck(t, nil) }, + Providers: testutils.GetProviders(), + Steps: []resource.TestStep{ + { + Config: testutils.HclGitRepoFileResource(projectName, gitRepoName, "Clean", branch, file, contentFirst), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(tfRepoFileNode, "file", file), + resource.TestCheckResourceAttr(tfRepoFileNode, "content", contentFirst), + resource.TestCheckResourceAttr(tfRepoFileNode, "branch", branch), + resource.TestCheckResourceAttrSet(tfRepoFileNode, "commit_message"), + checkGitRepoFileContent(contentFirst), + ), + }, + { + Config: testutils.HclGitRepoFileResource(projectName, gitRepoName, "Clean", branch, file, contentSecond), + Check: resource.ComposeTestCheckFunc( + resource.TestCheckResourceAttr(tfRepoFileNode, "file", file), + resource.TestCheckResourceAttr(tfRepoFileNode, "content", contentSecond), + resource.TestCheckResourceAttr(tfRepoFileNode, "branch", branch), + resource.TestCheckResourceAttrSet(tfRepoFileNode, "commit_message"), + checkGitRepoFileContent(contentSecond), + ), + }, + { + Config: testutils.HclGitRepoResource(projectName, gitRepoName, "Clean"), + Check: resource.ComposeTestCheckFunc( + checkGitRepoFileNotExists(file), + ), + }, + }, + }) +} + +// TestAccGitRepo_Create_IncorrectBranch verifies a file +// can't be added to a non existant branch +func TestAccGitRepoFile_Create_IncorrectBranch(t *testing.T) { + projectName := testutils.GenerateResourceName() + gitRepoName := testutils.GenerateResourceName() + + resource.Test(t, resource.TestCase{ + PreCheck: func() { testutils.PreCheck(t, nil) }, + Providers: testutils.GetProviders(), + Steps: []resource.TestStep{ + { + Config: testutils.HclGitRepoFileResource(projectName, gitRepoName, "Clean", "foobar", "foo", "bar"), + ExpectError: regexp.MustCompile(`errors during apply: Branch "foobar" does not exist`), + }, + }, + }) +} + +func checkGitRepoFileNotExists(fileName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + clients := testutils.GetProvider().Meta().(*client.AggregatedClient) + + repo, ok := s.RootModule().Resources["azuredevops_git_repository.repository"] + if !ok { + return fmt.Errorf("Did not find a repo definition in the TF state") + } + + ctx := context.Background() + _, err := clients.GitReposClient.GetItem(ctx, git.GetItemArgs{ + RepositoryId: &repo.Primary.ID, + Path: &fileName, + }) + if err != nil && !strings.Contains(err.Error(), "could not be found in the repository") { + return err + } + + return nil + } +} + +func checkGitRepoFileContent(expectedContent string) resource.TestCheckFunc { + return func(s *terraform.State) error { + clients := testutils.GetProvider().Meta().(*client.AggregatedClient) + + gitFile, ok := s.RootModule().Resources["azuredevops_git_repository_file.file"] + if !ok { + return fmt.Errorf("Did not find a repo definition in the TF state") + } + + fileID := gitFile.Primary.ID + comps := strings.Split(fileID, "/") + repoID := comps[0] + file := comps[1] + + ctx := context.Background() + r, err := clients.GitReposClient.GetItemContent(ctx, git.GetItemContentArgs{ + RepositoryId: &repoID, + Path: &file, + }) + if err != nil { + return err + } + + buf := new(bytes.Buffer) + if _, err = buf.ReadFrom(r); err != nil { + return err + } + + if buf.String() != expectedContent { + return fmt.Errorf("Unexpected git file content: %v", buf.String()) + } + + return nil + } +} diff --git a/azuredevops/internal/acceptancetests/testutils/hcl.go b/azuredevops/internal/acceptancetests/testutils/hcl.go index 41391f639..a617c08f3 100644 --- a/azuredevops/internal/acceptancetests/testutils/hcl.go +++ b/azuredevops/internal/acceptancetests/testutils/hcl.go @@ -39,6 +39,19 @@ func HclForkedGitRepoResource(projectName string, gitRepoName string, gitForkedR return fmt.Sprintf("%s\n%s", gitRepoResource, azureGitRepoResource) } +// HclGitRepoFileResource HCl describing a file in an AzDO GIT repository +func HclGitRepoFileResource(projectName, gitRepoName, initType, branch, file, content string) string { + gitRepoFileResource := fmt.Sprintf(` + resource "azuredevops_git_repository_file" "file" { + repository_id = azuredevops_git_repository.repository.id + file = "%s" + content = "%s" + branch = "%s" + }`, file, content, branch) + gitRepoResource := HclGitRepoResource(projectName, gitRepoName, initType) + return fmt.Sprintf("%s\n%s", gitRepoFileResource, gitRepoResource) +} + // HclGroupDataSource HCL describing an AzDO Group Data Source func HclGroupDataSource(projectName string, groupName string) string { if projectName == "" { diff --git a/azuredevops/internal/service/git/resource_git_repository_file.go b/azuredevops/internal/service/git/resource_git_repository_file.go new file mode 100644 index 000000000..b22d63459 --- /dev/null +++ b/azuredevops/internal/service/git/resource_git_repository_file.go @@ -0,0 +1,395 @@ +package git + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/hashicorp/terraform-plugin-sdk/helper/resource" + "github.com/hashicorp/terraform-plugin-sdk/helper/schema" + "github.com/hashicorp/terraform-plugin-sdk/helper/validation" + "github.com/microsoft/azure-devops-go-api/azuredevops/git" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/client" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils" + "github.com/microsoft/terraform-provider-azuredevops/azuredevops/internal/utils/converter" +) + +func ResourceGitRepositoryFile() *schema.Resource { + return &schema.Resource{ + Create: resourceGitRepositoryFileCreate, + Read: resourceGitRepositoryFileRead, + Update: resourceGitRepositoryFileUpdate, + Delete: resourceGitRepositoryFileDelete, + Importer: &schema.ResourceImporter{ + State: func(d *schema.ResourceData, m interface{}) ([]*schema.ResourceData, error) { + parts := strings.Split(d.Id(), ":") + branch := "refs/heads/master" + + if len(parts) > 2 { + return nil, fmt.Errorf("Invalid ID specified. Supplied ID must be written as / (when branch is \"master\") or /:") + } + + if len(parts) == 2 { + branch = parts[1] + } + + clients := m.(*client.AggregatedClient) + repoID, file := splitRepoFilePath(parts[0]) + if err := checkRepositoryFileExists(clients, repoID, file, branch); err != nil { + return nil, fmt.Errorf("Repository not found, repository ID: %s, branch: %s, file: %s. Error: %+v", repoID, branch, file, err) + } + + d.SetId(fmt.Sprintf("%s/%s", repoID, file)) + d.Set("branch", branch) + d.Set("overwrite_on_create", false) + + return []*schema.ResourceData{d}, nil + }, + }, + + Schema: map[string]*schema.Schema{ + "repository_id": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The repository ID", + ValidateFunc: validation.IsUUID, + }, + "file": { + Type: schema.TypeString, + Required: true, + ForceNew: true, + Description: "The file path to manage", + }, + "content": { + Type: schema.TypeString, + Required: true, + Description: "The file's content", + }, + "branch": { + Type: schema.TypeString, + Optional: true, + ForceNew: true, + Description: "The branch name, defaults to \"refs/heads/master\"", + Default: "refs/heads/master", + }, + "commit_message": { + Type: schema.TypeString, + Optional: true, + Computed: true, + Description: "The commit message when creating or updating the file", + }, + "overwrite_on_create": { + Type: schema.TypeBool, + Optional: true, + Description: "Enable overwriting existing files, defaults to \"false\"", + Default: false, + }, + }, + Timeouts: &schema.ResourceTimeout{ + Create: schema.DefaultTimeout(1 * time.Minute), + Read: schema.DefaultTimeout(5 * time.Second), + }, + } +} + +func resourceGitRepositoryFileCreate(d *schema.ResourceData, m interface{}) error { + ctx := context.Background() + clients := m.(*client.AggregatedClient) + + repoId := d.Get("repository_id").(string) + file := d.Get("file").(string) + branch := d.Get("branch").(string) + overwriteOnCreate := d.Get("overwrite_on_create").(bool) + + if err := checkRepositoryBranchExists(clients, repoId, branch); err != nil { + return err + } + repoItem, err := clients.GitReposClient.GetItem(ctx, git.GetItemArgs{ + RepositoryId: &repoId, + Path: &file, + }) + if err != nil && !utils.ResponseWasNotFound(err) { + return fmt.Errorf("Repository branch not found, repositoryID: %s, branch: %s. Error: %+v", repoId, branch, err) + } + + // Change type should be edit if overwrite is enabled when file exists + changeType := git.VersionControlChangeTypeValues.Add + if repoItem != nil { + if !overwriteOnCreate { + return fmt.Errorf("Refusing to overwrite existing file. Configure `overwrite_on_create` to `true` to override.") + } + changeType = git.VersionControlChangeTypeValues.Edit + } + + // Need to retry creating the file as multiple updates could happen at the same time + err = resource.Retry(d.Timeout(schema.TimeoutCreate), func() *resource.RetryError { + objectID, err := getLastCommitId(clients, repoId, branch) + if err != nil { + return resource.NonRetryableError(err) + } + args, err := resourceGitRepositoryPushArgs(d, objectID, changeType) + if err != nil { + return resource.NonRetryableError(err) + } + if (*args.Push.Commits)[0].Comment == nil { + m := fmt.Sprintf("Add %s", file) + (*args.Push.Commits)[0].Comment = &m + } + + _, err = clients.GitReposClient.CreatePush(ctx, *args) + if err != nil { + if utils.ResponseContainsStatusMessage(err, "has already been updated by another client") { + return resource.RetryableError(err) + } + return resource.NonRetryableError(err) + } + return nil + }) + if err != nil { + return fmt.Errorf("Create repositroy file failed, repositoryID: %s, branch: %s, file: %s. Error: %+v", repoId, branch, file, err) + } + + d.SetId(fmt.Sprintf("%s/%s", repoId, file)) + return resourceGitRepositoryFileRead(d, m) +} + +func resourceGitRepositoryFileRead(d *schema.ResourceData, m interface{}) error { + ctx := context.Background() + clients := m.(*client.AggregatedClient) + + repoId, file := splitRepoFilePath(d.Id()) + branch := d.Get("branch").(string) + + if err := checkRepositoryBranchExists(clients, repoId, branch); err != nil { + return err + } + + // Get the repository item if it exists + repoItem, err := clients.GitReposClient.GetItem(ctx, git.GetItemArgs{ + RepositoryId: &repoId, + Path: &file, + IncludeContent: converter.Bool(true), + VersionDescriptor: &git.GitVersionDescriptor{ + Version: converter.String(shortBranchName(branch)), + VersionType: &git.GitVersionTypeValues.Branch, + }, + }) + if err != nil { + if utils.ResponseWasNotFound(err) { + d.SetId("") + return err + } + return fmt.Errorf("Query repository item failed, repositoryID: %s, branch: %s, file: %s . Error: %+v", repoId, branch, file, err) + } + + d.Set("content", repoItem.Content) + d.Set("repository_id", repoId) + d.Set("file", file) + + commit, err := clients.GitReposClient.GetCommit(ctx, git.GetCommitArgs{ + RepositoryId: &repoId, + CommitId: repoItem.CommitId, + }) + if err != nil { + return fmt.Errorf("Get repository file commit failed , repositoryID: %s, branch: %s, file: %s . Error: %+v", repoId, branch, file, err) + } + + d.Set("commit_message", commit.Comment) + + return nil +} + +func resourceGitRepositoryFileUpdate(d *schema.ResourceData, m interface{}) error { + clients := m.(*client.AggregatedClient) + ctx := context.Background() + + repoId := d.Get("repository_id").(string) + file := d.Get("file").(string) + branch := d.Get("branch").(string) + + if err := checkRepositoryBranchExists(clients, repoId, branch); err != nil { + return err + } + + // Need to retry creating the file as multiple updates could happen at the same time + err := resource.Retry(d.Timeout(schema.TimeoutCreate), func() *resource.RetryError { + objectID, err := getLastCommitId(clients, repoId, branch) + if err != nil { + return resource.NonRetryableError(err) + } + args, err := resourceGitRepositoryPushArgs(d, objectID, git.VersionControlChangeTypeValues.Edit) + if err != nil { + return resource.NonRetryableError(err) + } + if *(*args.Push.Commits)[0].Comment == fmt.Sprintf("Add %s", file) { + m := fmt.Sprintf("Update %s", file) + (*args.Push.Commits)[0].Comment = &m + } + + _, err = clients.GitReposClient.CreatePush(ctx, *args) + if err != nil { + if utils.ResponseContainsStatusMessage(err, "has already been updated by another client") { + return resource.RetryableError(err) + } + return resource.NonRetryableError(err) + } + return nil + }) + if err != nil { + return fmt.Errorf("Update repository file failed, repositoryID: %s, branch: %s, file: %s . Error: %+v", repoId, branch, file, err) + } + + return resourceGitRepositoryFileRead(d, m) +} + +func resourceGitRepositoryFileDelete(d *schema.ResourceData, m interface{}) error { + clients := m.(*client.AggregatedClient) + ctx := context.Background() + + repoId := d.Get("repository_id").(string) + file := d.Get("file").(string) + branch := d.Get("branch").(string) + message := fmt.Sprintf("Delete %s", file) + + err := resource.Retry(d.Timeout(schema.TimeoutCreate), func() *resource.RetryError { + objectID, err := getLastCommitId(clients, repoId, branch) + if err != nil { + return resource.NonRetryableError(err) + } + + change := &git.GitChange{ + ChangeType: &git.VersionControlChangeTypeValues.Delete, + Item: git.GitItem{ + Path: &file, + }, + } + _, err = clients.GitReposClient.CreatePush(ctx, git.CreatePushArgs{ + RepositoryId: &repoId, + Push: &git.GitPush{ + RefUpdates: &[]git.GitRefUpdate{ + { + Name: &branch, + OldObjectId: &objectID, + }, + }, + Commits: &[]git.GitCommitRef{ + { + Comment: &message, + Changes: &[]interface{}{change}, + }, + }, + }, + }) + if err != nil { + if utils.ResponseContainsStatusMessage(err, "has already been updated by another client") { + return resource.RetryableError(err) + } + return resource.NonRetryableError(err) + } + return nil + }) + if err != nil { + return fmt.Errorf("Failed to destroy the repository file, repository ID: %s, branch: %s. file %s. Error %+v ", repoId, branch, file, err) + } + return nil +} + +// checkRepositoryBranchExists tests if a branch exists in a repository. +func checkRepositoryBranchExists(c *client.AggregatedClient, repoId, branch string) error { + ctx := context.Background() + _, err := c.GitReposClient.GetBranch(ctx, git.GetBranchArgs{ + RepositoryId: &repoId, + Name: converter.String(shortBranchName(branch)), + }) + return err +} + +// checkRepositoryFileExists tests if a file exists in a repository. +func checkRepositoryFileExists(c *client.AggregatedClient, repoId, file, branch string) error { + ctx := context.Background() + _, err := c.GitReposClient.GetItem(ctx, git.GetItemArgs{ + RepositoryId: &repoId, + Path: &file, + VersionDescriptor: &git.GitVersionDescriptor{ + Version: converter.String(shortBranchName(branch)), + }, + }) + if err != nil { + return err + } + return nil +} + +// getLastCommitId returns the last commit id in the given branhc and repository. +func getLastCommitId(c *client.AggregatedClient, repoId, branch string) (string, error) { + ctx := context.Background() + commits, err := c.GitReposClient.GetCommits(ctx, git.GetCommitsArgs{ + RepositoryId: &repoId, + Top: converter.Int(1), + SearchCriteria: &git.GitQueryCommitsCriteria{ + ItemVersion: &git.GitVersionDescriptor{ + Version: converter.String(shortBranchName(branch)), + }, + }, + }) + if err != nil { + return "", err + } + return *(*commits)[0].CommitId, nil +} + +// resourceGitRepositoryPushArgs returns args used to commit and push changes. +func resourceGitRepositoryPushArgs(d *schema.ResourceData, objectID string, changeType git.VersionControlChangeType) (*git.CreatePushArgs, error) { + var message *string + if commitMessage, hasCommitMessage := d.GetOk("commit_message"); hasCommitMessage { + cm := commitMessage.(string) + message = &cm + } + + repo := d.Get("repository_id").(string) + content := d.Get("content").(string) + file := d.Get("file").(string) + branch := d.Get("branch").(string) + + change := git.GitChange{ + ChangeType: &changeType, + Item: git.GitItem{ + Path: &file, + }, + NewContent: &git.ItemContent{ + Content: &content, + ContentType: &git.ItemContentTypeValues.RawText, + }, + } + args := &git.CreatePushArgs{ + RepositoryId: &repo, + Push: &git.GitPush{ + RefUpdates: &[]git.GitRefUpdate{ + { + Name: &branch, + OldObjectId: &objectID, + }, + }, + Commits: &[]git.GitCommitRef{ + { + Comment: message, + Changes: &[]interface{}{change}, + }, + }, + }, + } + return args, nil +} + +// shortBranchName removes the branch prefix which some API endpoints require. +func shortBranchName(branch string) string { + return strings.TrimPrefix(branch, "refs/heads/") +} + +// splitRepoFilePath splits the resource ID into separate repository id and file path components. +func splitRepoFilePath(path string) (string, string) { + parts := strings.Split(path, "/") + return parts[0], strings.Join(parts[1:], "/") +} diff --git a/azuredevops/internal/service/git/resource_git_repository_file_test.go b/azuredevops/internal/service/git/resource_git_repository_file_test.go new file mode 100644 index 000000000..3dbd99c89 --- /dev/null +++ b/azuredevops/internal/service/git/resource_git_repository_file_test.go @@ -0,0 +1,45 @@ +package git + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestShortBranchName(t *testing.T) { + tests := []struct { + name string + input string + expectedOutput string + }{ + {name: "basic", input: "refs/heads/master", expectedOutput: "master"}, + {name: "none", input: "master", expectedOutput: "master"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + output := shortBranchName(tt.input) + require.Equal(t, tt.expectedOutput, output) + }) + } +} + +func TestSplitRepoFilePath(t *testing.T) { + tests := []struct { + name string + input string + expectedRepoId string + expectedFilePath string + }{ + {name: "basic", input: "foo/bar", expectedRepoId: "foo", expectedFilePath: "bar"}, + {name: "nested", input: "foo/bar/baz.txt", expectedRepoId: "foo", expectedFilePath: "bar/baz.txt"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repoId, filePath := splitRepoFilePath(tt.input) + require.Equal(t, tt.expectedRepoId, repoId) + require.Equal(t, tt.expectedFilePath, filePath) + }) + } +} diff --git a/azuredevops/provider.go b/azuredevops/provider.go index ee3765bec..2ab66e89a 100644 --- a/azuredevops/provider.go +++ b/azuredevops/provider.go @@ -58,6 +58,7 @@ func Provider() *schema.Provider { "azuredevops_serviceendpoint_generic": serviceendpoint.ResourceServiceEndpointGeneric(), "azuredevops_serviceendpoint_generic_git": serviceendpoint.ResourceServiceEndpointGenericGit(), "azuredevops_git_repository": git.ResourceGitRepository(), + "azuredevops_git_repository_file": git.ResourceGitRepositoryFile(), "azuredevops_user_entitlement": memberentitlementmanagement.ResourceUserEntitlement(), "azuredevops_group_membership": graph.ResourceGroupMembership(), "azuredevops_agent_pool": taskagent.ResourceAgentPool(), diff --git a/azuredevops/provider_test.go b/azuredevops/provider_test.go index b91f0a278..ee3c15312 100644 --- a/azuredevops/provider_test.go +++ b/azuredevops/provider_test.go @@ -47,6 +47,7 @@ func TestProvider_HasChildResources(t *testing.T) { "azuredevops_repository_policy_reserved_names", "azuredevops_repository_policy_check_credentials", "azuredevops_git_repository", + "azuredevops_git_repository_file", "azuredevops_user_entitlement", "azuredevops_group_membership", "azuredevops_group", diff --git a/website/azuredevops.erb b/website/azuredevops.erb index bca9480b3..2adb601b6 100644 --- a/website/azuredevops.erb +++ b/website/azuredevops.erb @@ -118,6 +118,9 @@
  • azuredevops_git_repository
  • +
  • + azuredevops_git_repository_file +
  • azuredevops_group
  • diff --git a/website/docs/r/git_repository_file.html.markdown b/website/docs/r/git_repository_file.html.markdown new file mode 100644 index 000000000..70f9fb0df --- /dev/null +++ b/website/docs/r/git_repository_file.html.markdown @@ -0,0 +1,67 @@ +--- +layout: "azuredevops" +page_title: "AzureDevops: azuredevops_git_repository_file" +description: |- Manage files within an Azure DevOps Git repository. +--- + +# azuredevops_git_repository_file + +Manage files within an Azure DevOps Git repository. + +## Example Usage + +```hcl +resource "azuredevops_project" "project" { + name = "Sample Project" + visibility = "private" + version_control = "Git" + work_item_template = "Agile" +} + +resource "azuredevops_git_repository" "repo" { + project_id = azuredevops_project.project.id + name = "Sample Git Repository" + initialization { + init_type = "Clean" + } +} + +resource "azuredevops_git_repository_file" "repo_file" { + repository_id = azuredevops_git_repository.repo.id + file = ".gitignore" + content = "**/*.tfstate" + branch = "refs/heads/master" + commit_message = "First commit" + overwrite_on_create = false +} +``` + +## Argument Reference + +The following arguments are supported: + +- `repository_id` - (Required) The ID of the Git repository. +- `file` - (Required) The path of the file to manage. +- `content` - (Required) The file content. +- `branch` - (Optional) Git branch (defaults to `refs/heads/master`). The branch must already exist, it will not be created if it + does not already exist. +- `commit_message` - (Optional) Commit message when adding or updating the managed file. +- `overwrite_on_create` - (Optional) Enable overwriting existing files (defaults to `false`). + +## Import + +Repository files can be imported using a combination of the `repositroy ID` and `file`, e.g. + +```sh +terraform import azuredevops_git_repository_file.repo_file 00000000-0000-0000-0000-000000000000/.gitignore +``` + +To import a file from a branch other than `master`, append `:` and the branch name, e.g. + +```sh +terraform import azuredevops_git_repository_file.repo_file 00000000-0000-0000-0000-000000000000/.gitignore:refs/heads/master +``` + +## Relevant Links + +- [Azure DevOps Service REST API 5.1 - Git API](https://docs.microsoft.com/en-us/rest/api/azure/devops/git/?view=azure-devops-rest-5.1)