diff --git a/CHANGELOG.md b/CHANGELOG.md index dbad98e6..d136e66c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- `miactl deploy latest` for getting the latest successful deployment for a project in a specified environment - `miactl runtime create job` has two additional new flags: - `--waitJobCompletion` (default `false`) - if enabled, the `miactl` will wait for the job completion - `--waitJobTimeoutSeconds` (default `600` seconds) - if `--waitJobCompletion` is enabled, the maximum wait timeout diff --git a/internal/clioptions/clioptions.go b/internal/clioptions/clioptions.go index 53516a88..a3a75b73 100644 --- a/internal/clioptions/clioptions.go +++ b/internal/clioptions/clioptions.go @@ -175,6 +175,10 @@ func (o *CLIOptions) AddDeployAddStatusFlags(flags *pflag.FlagSet) { flags.StringVar(&o.TriggerID, "trigger-id", "", "trigger-id of the pipeline to update") } +func (o *CLIOptions) AddDeployLatestFlags(flags *pflag.FlagSet) { + flags.StringVar(&o.Environment, "environment", "", "the environment scope for the command") +} + func (o *CLIOptions) AddContextAuthFlags(flags *pflag.FlagSet) { flags.StringVar(&o.BasicClientID, "client-id", "", "the client ID of the service account") flags.StringVar(&o.BasicClientSecret, "client-secret", "", "the client secret of the service account") diff --git a/internal/cmd/deploy/deploy.go b/internal/cmd/deploy/deploy.go index 72351174..4231d2bf 100644 --- a/internal/cmd/deploy/deploy.go +++ b/internal/cmd/deploy/deploy.go @@ -33,6 +33,7 @@ Can trigger deployments to specific environments and monitor their status.`, cmd.AddCommand( triggerCmd(options), newStatusAddCmd(options), + latestDeploymentCmd(options), ) return cmd diff --git a/internal/cmd/deploy/latest.go b/internal/cmd/deploy/latest.go new file mode 100644 index 00000000..58974988 --- /dev/null +++ b/internal/cmd/deploy/latest.go @@ -0,0 +1,101 @@ +// Copyright Mia srl +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package deploy + +import ( + "context" + "errors" + "fmt" + + "github.com/spf13/cobra" + + "github.com/mia-platform/miactl/internal/client" + "github.com/mia-platform/miactl/internal/clioptions" + "github.com/mia-platform/miactl/internal/resources" +) + +const ( + deploymentsLatestEndpointTemplate = "/api/deploy/projects/%s/deployment/" +) + +func latestDeploymentCmd(options *clioptions.CLIOptions) *cobra.Command { + cmd := &cobra.Command{ + Use: "latest", + Short: "Get the latest deployment for the project", + Long: "Get the latest deployment for the project in the specified environment.", + RunE: func(cmd *cobra.Command, args []string) error { + return runLatestDeployment(cmd.Context(), options) + }, + } + + flags := cmd.Flags() + options.AddDeployLatestFlags(flags) + + return cmd +} + +func runLatestDeployment(ctx context.Context, options *clioptions.CLIOptions) error { + restConfig, err := options.ToRESTConfig() + if err != nil { + return err + } + + projectID := restConfig.ProjectID + if len(projectID) == 0 { + return errors.New("projectId is required") + } + + client, err := client.APIClientForConfig(restConfig) + if err != nil { + return err + } + + resp, err := client. + Get(). + APIPath(fmt.Sprintf(deploymentsLatestEndpointTemplate, projectID)). + SetParam("page", "1"). + SetParam("per_page", "1"). + SetParam("scope", "success"). + SetParam("environment", options.Environment). + Do(ctx) + + if err != nil { + return fmt.Errorf("error executing request: %w", err) + } + + if err := resp.Error(); err != nil { + return err + } + + var deployments []resources.DeploymentHistory + if err := resp.ParseResponse(&deployments); err != nil { + return fmt.Errorf("cannot parse server response: %w", err) + } + + if len(deployments) == 0 { + fmt.Println("No successful deployments found") + return nil + } + + latest := deployments[0] + fmt.Printf("Latest deployment for environment %s:\n", latest.Environment) + fmt.Printf("ID: %s\n", latest.ID) + fmt.Printf("Ref: %s\n", latest.Ref) + fmt.Printf("Status: %s\n", latest.Status) + fmt.Printf("Finished At: %s\n", latest.FinishedAt) + + return nil +} diff --git a/internal/cmd/deploy/latest_test.go b/internal/cmd/deploy/latest_test.go new file mode 100644 index 00000000..f1c19378 --- /dev/null +++ b/internal/cmd/deploy/latest_test.go @@ -0,0 +1,87 @@ +// Copyright Mia srl +// SPDX-License-Identifier: Apache-2.0 +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package deploy + +import ( + "fmt" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/mia-platform/miactl/internal/clioptions" + "github.com/mia-platform/miactl/internal/resources" +) + +func TestLatestDeployment(t *testing.T) { + testProjectID := "test-project-id" + testEnv := "dev" + now := time.Now().Truncate(time.Second) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, fmt.Sprintf("/api/deploy/projects/%s/deployment/", testProjectID), r.URL.Path) + assert.Equal(t, "1", r.URL.Query().Get("page")) + assert.Equal(t, "1", r.URL.Query().Get("per_page")) + assert.Equal(t, "success", r.URL.Query().Get("scope")) + assert.Equal(t, testEnv, r.URL.Query().Get("environment")) + + resp := []resources.DeploymentHistory{ + { + ID: "deploy-123", + Ref: "main", + PipelineID: "pipe-123", + Status: "success", + FinishedAt: now, + Environment: testEnv, + }, + } + data, _ := resources.EncodeResourceToJSON(resp) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(data) + })) + defer server.Close() + + options := clioptions.NewCLIOptions() + options.Endpoint = server.URL + options.ProjectID = testProjectID + options.Environment = testEnv + + err := runLatestDeployment(t.Context(), options) + assert.NoError(t, err) +} + +func TestLatestDeploymentNoResults(t *testing.T) { + testProjectID := "test-project-id" + testEnv := "dev" + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("[]")) + })) + defer server.Close() + + options := clioptions.NewCLIOptions() + options.Endpoint = server.URL + options.ProjectID = testProjectID + options.Environment = testEnv + + err := runLatestDeployment(t.Context(), options) + assert.NoError(t, err) +} diff --git a/internal/resources/responses.go b/internal/resources/responses.go index c0d4f4a2..57a22aa0 100644 --- a/internal/resources/responses.go +++ b/internal/resources/responses.go @@ -187,6 +187,21 @@ type Deployment struct { Age time.Time `json:"creationTimestamp"` //nolint: tagliatelle } +type DeploymentHistory struct { + ID string `json:"id"` + Ref string `json:"ref"` + PipelineID string `json:"pipelineId"` + Status string `json:"status"` + FinishedAt time.Time `json:"finishedAt"` + Environment string `json:"env"` //nolint:tagliatelle + EnvironmentInfo EnvironmentInfo `json:"environmentInfo"` +} + +type EnvironmentInfo struct { + EnvID string `json:"envId"` + Label string `json:"label"` +} + type Service struct { Name string `json:"name"` Type string `json:"type"`