diff --git a/internal/hcp/api/client.go b/internal/hcp/api/client.go index a379af61b..21a07156c 100644 --- a/internal/hcp/api/client.go +++ b/internal/hcp/api/client.go @@ -6,10 +6,14 @@ package api import ( "fmt" + "log" + "os" + "time" packerSvc "github.com/hashicorp/hcp-sdk-go/clients/cloud-packer-service/stable/2021-04-30/client/packer_service" organizationSvc "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/preview/2019-12-10/client/organization_service" projectSvc "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/preview/2019-12-10/client/project_service" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/preview/2019-12-10/models" rmmodels "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/preview/2019-12-10/models" "github.com/hashicorp/hcp-sdk-go/httpclient" "github.com/hashicorp/packer/internal/hcp/env" @@ -97,9 +101,54 @@ func (c *Client) loadProjectID() error { if err != nil { return fmt.Errorf("unable to fetch project id: %v", err) } - if len(listProjResp.Payload.Projects) > 1 { - return fmt.Errorf("this version of Packer does not support multiple projects") + + if env.HasProjectID() { + proj, err := findProjectByID(os.Getenv(env.HCPProjectID), listProjResp.Payload.Projects) + if err != nil { + return err + } + + c.ProjectID = proj.ID + } else { + if len(listProjResp.Payload.Projects) > 1 { + log.Printf("[WARNING] Multiple HCP projects found, will pick the oldest one by default\n" + + "To specify which project to use, set the HCP_PROJECT_ID environment variable to the one you want to use.") + } + + proj, err := findOldestProject(listProjResp.Payload.Projects) + if err != nil { + return err + } + + c.ProjectID = proj.ID } - c.ProjectID = listProjResp.Payload.Projects[0].ID + return nil } + +func findOldestProject(projs []*models.HashicorpCloudResourcemanagerProject) (*models.HashicorpCloudResourcemanagerProject, error) { + if len(projs) == 0 { + return nil, fmt.Errorf("no project found") + } + + proj := projs[0] + for i := 1; i < len(projs); i++ { + nxtProj := projs[i] + + if time.Time(nxtProj.CreatedAt).Before(time.Time(proj.CreatedAt)) { + proj = nxtProj + } + } + + return proj, nil +} + +func findProjectByID(projID string, projs []*models.HashicorpCloudResourcemanagerProject) (*models.HashicorpCloudResourcemanagerProject, error) { + for _, proj := range projs { + if proj.ID == projID { + return proj, nil + } + } + + return nil, fmt.Errorf("No project %q found", projID) +} diff --git a/internal/hcp/api/client_test.go b/internal/hcp/api/client_test.go new file mode 100644 index 000000000..47ff2b5fd --- /dev/null +++ b/internal/hcp/api/client_test.go @@ -0,0 +1,163 @@ +package api + +import ( + "testing" + "time" + + "github.com/go-openapi/strfmt" + "github.com/hashicorp/hcp-sdk-go/clients/cloud-resource-manager/preview/2019-12-10/models" +) + +func TestFindProjectID(t *testing.T) { + testcases := []struct { + Name string + ProjectID string + ProjectList []*models.HashicorpCloudResourcemanagerProject + ExpectProjectID string + ExpectErr bool + }{ + { + "Only one project, project exists, success", + "test-project-exists", + []*models.HashicorpCloudResourcemanagerProject{ + { + ID: "test-project-exists", + }, + }, + "test-project-exists", + false, + }, + { + "Multiple projects, project exists, success", + "test-project-exists", + []*models.HashicorpCloudResourcemanagerProject{ + { + ID: "other-project-exists", + }, + { + ID: "test-project-exists", + }, + }, + "test-project-exists", + false, + }, + { + "One project, no id match, fail", + "test-project-exists", + []*models.HashicorpCloudResourcemanagerProject{ + { + ID: "other-project-exists", + }, + }, + "", + true, + }, + { + "Multiple projects, no id match, fail", + "test-project-exists", + []*models.HashicorpCloudResourcemanagerProject{ + { + ID: "other-project-exists", + }, + { + ID: "yet-another-project-exists", + }, + }, + "", + true, + }, + { + "No projects, no id match, fail", + "test-project-exists", + []*models.HashicorpCloudResourcemanagerProject{}, + "", + true, + }, + } + + for _, tt := range testcases { + t.Run(tt.Name, func(t *testing.T) { + proj, err := findProjectByID(tt.ProjectID, tt.ProjectList) + if (err != nil) != tt.ExpectErr { + t.Errorf("test findProjectByID, expected %t, got %t", + tt.ExpectErr, + err != nil) + } + + if proj != nil && proj.ID != tt.ExpectProjectID { + t.Errorf("expected to select project %q, got %q", tt.ExpectProjectID, proj.ID) + } + }) + } +} + +func TestFindOldestProject(t *testing.T) { + testcases := []struct { + Name string + ProjectList []*models.HashicorpCloudResourcemanagerProject + ExpectProjectID string + ExpectErr bool + }{ + { + "Only one project, project exists, success", + []*models.HashicorpCloudResourcemanagerProject{ + { + ID: "test-project-exists", + }, + }, + "test-project-exists", + false, + }, + { + "Multiple projects, pick the oldest", + []*models.HashicorpCloudResourcemanagerProject{ + { + ID: "test-project-exists", + CreatedAt: strfmt.DateTime(time.Date(2023, 1, 1, 1, 0, 0, 0, time.UTC)), + }, + { + ID: "test-oldest-project", + CreatedAt: strfmt.DateTime(time.Date(2022, 1, 1, 1, 0, 0, 0, time.UTC)), + }, + }, + "test-oldest-project", + false, + }, + { + "Multiple projects, different order, pick the oldest", + []*models.HashicorpCloudResourcemanagerProject{ + { + ID: "test-oldest-project", + CreatedAt: strfmt.DateTime(time.Date(2022, 1, 1, 1, 0, 0, 0, time.UTC)), + }, + { + ID: "test-project-exists", + CreatedAt: strfmt.DateTime(time.Date(2023, 1, 1, 1, 0, 0, 0, time.UTC)), + }, + }, + "test-oldest-project", + false, + }, + { + "No projects, should error", + []*models.HashicorpCloudResourcemanagerProject{}, + "", + true, + }, + } + + for _, tt := range testcases { + t.Run(tt.Name, func(t *testing.T) { + proj, err := findOldestProject(tt.ProjectList) + if (err != nil) != tt.ExpectErr { + t.Errorf("test findProjectByID, expected %t, got %t", + tt.ExpectErr, + err != nil) + } + + if proj != nil && proj.ID != tt.ExpectProjectID { + t.Errorf("expected to select project %q, got %q", tt.ExpectProjectID, proj.ID) + } + }) + } +} diff --git a/internal/hcp/env/env.go b/internal/hcp/env/env.go index c07d9cac4..0cf459015 100644 --- a/internal/hcp/env/env.go +++ b/internal/hcp/env/env.go @@ -9,6 +9,10 @@ import ( "strings" ) +func HasProjectID() bool { + return hasEnvVar(HCPProjectID) +} + func HasClientID() bool { return hasEnvVar(HCPClientID) } diff --git a/internal/hcp/env/variables.go b/internal/hcp/env/variables.go index 0fc654494..71258c13b 100644 --- a/internal/hcp/env/variables.go +++ b/internal/hcp/env/variables.go @@ -6,6 +6,7 @@ package env const ( HCPClientID = "HCP_CLIENT_ID" HCPClientSecret = "HCP_CLIENT_SECRET" + HCPProjectID = "HCP_PROJECT_ID" HCPPackerRegistry = "HCP_PACKER_REGISTRY" HCPPackerBucket = "HCP_PACKER_BUCKET_NAME" HCPPackerBuildFingerprint = "HCP_PACKER_BUILD_FINGERPRINT" diff --git a/website/content/docs/hcp/index.mdx b/website/content/docs/hcp/index.mdx index de58397e8..35f7c2fd4 100644 --- a/website/content/docs/hcp/index.mdx +++ b/website/content/docs/hcp/index.mdx @@ -4,6 +4,8 @@ description: | page_title: HCP Packer --- +-> **Note:** On May 16th 2023, HCP introduced multi-project support to the platform. In order to use multiple projects in your organization, you will need to update Packer to version 1.9.1 or above. Starting with 1.9.1, you may specify a project ID to push builds to with the `HCP_PROJECT_ID` environment variable. If no project ID is specified, Packer will pick the project with the oldest creation date. Older versions of Packer are incompatible with multi-project support on HCP, and builds will fail for HCP organizations with multiple projects on versions before 1.9.1. + # HCP Packer The HCP Packer registry bridges the gap between image factories and image deployments, allowing development and security teams to work together to create, manage, and consume images in a centralized way.