refactor: move molecule cleanup to atmosphere-ci cmd
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 2a0c83a..7fb430a 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -57,7 +57,7 @@
           cache: true
 
       - name: Clean-up stale stacks
-        run: go run ci/molecule.go
+        run: go run ./cmd/atmosphere-ci molecule cleanup
         env:
           OS_AUTH_URL: https://auth.vexxhost.net/v3
           OS_REGION_NAME: ca-ymq-1
diff --git a/ci/molecule.go b/ci/molecule.go
deleted file mode 100644
index 1bc9aae..0000000
--- a/ci/molecule.go
+++ /dev/null
@@ -1,81 +0,0 @@
-package main
-
-import (
-	"context"
-	"fmt"
-	"strconv"
-	"strings"
-
-	"github.com/google/go-github/v47/github"
-	"github.com/gophercloud/gophercloud"
-	"github.com/gophercloud/gophercloud/openstack"
-	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
-	"github.com/gophercloud/gophercloud/pagination"
-	"github.com/gophercloud/utils/openstack/clientconfig"
-)
-
-func main() {
-	opts, err := clientconfig.AuthOptions(nil)
-	if err != nil {
-		panic(err)
-	}
-
-	provider, err := openstack.AuthenticatedClient(*opts)
-	if err != nil {
-		panic(err)
-	}
-
-	client, err := openstack.NewOrchestrationV1(provider, gophercloud.EndpointOpts{})
-	if err != nil {
-		panic(err)
-	}
-
-	stacks.List(client, stacks.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
-		allStacks, err := stacks.ExtractStacks(page)
-		if err != nil {
-			return false, err
-		}
-
-		for _, stack := range allStacks {
-			if stack.Status == "DELETE_IN_PROGRESS" {
-				fmt.Println("skip delete in progress stack: " + stack.Name)
-				continue
-			}
-
-			if !strings.HasPrefix(stack.Name, "atmosphere-") {
-				panic("stack name does not start with atmosphere: " + stack.Name)
-			}
-
-			s := strings.Split(stack.Name, "-")
-			if len(s) != 3 {
-				panic("stack name does not have 3 parts: " + stack.Name)
-			}
-
-			runId, err := strconv.ParseInt(s[1], 10, 64)
-			if err != nil {
-				panic(err)
-			}
-			runAttempt, err := strconv.ParseInt(s[2], 10, 0)
-			if err != nil {
-				panic(err)
-			}
-
-			githubClient := github.NewClient(nil)
-			run, _, err := githubClient.Actions.GetWorkflowRunAttempt(context.TODO(), "vexxhost", "atmosphere", runId, int(runAttempt), nil)
-			if err != nil {
-				panic(err)
-			}
-
-			if run.GetStatus() == "completed" {
-				fmt.Println("Deleting stack: " + stack.Name)
-
-				_ = stacks.Delete(client, stack.Name, stack.ID)
-				if err != nil {
-					panic(err)
-				}
-			}
-		}
-
-		return true, nil
-	})
-}
diff --git a/cmd/atmosphere-ci/molecule.go b/cmd/atmosphere-ci/molecule.go
new file mode 100644
index 0000000..0ad87e6
--- /dev/null
+++ b/cmd/atmosphere-ci/molecule.go
@@ -0,0 +1,14 @@
+package main
+
+import (
+	"github.com/spf13/cobra"
+)
+
+var moleculeCmd = &cobra.Command{
+	Use:   "molecule",
+	Short: "Molecule sub-commands",
+}
+
+func init() {
+	rootCmd.AddCommand(moleculeCmd)
+}
diff --git a/cmd/atmosphere-ci/molecule_cleanup.go b/cmd/atmosphere-ci/molecule_cleanup.go
new file mode 100644
index 0000000..1b3efac
--- /dev/null
+++ b/cmd/atmosphere-ci/molecule_cleanup.go
@@ -0,0 +1,98 @@
+package main
+
+import (
+	"context"
+	"strconv"
+	"strings"
+
+	"github.com/google/go-github/v47/github"
+	"github.com/gophercloud/gophercloud"
+	"github.com/gophercloud/gophercloud/openstack"
+	"github.com/gophercloud/gophercloud/openstack/orchestration/v1/stacks"
+	"github.com/gophercloud/gophercloud/pagination"
+	"github.com/gophercloud/utils/openstack/clientconfig"
+	log "github.com/sirupsen/logrus"
+	"github.com/spf13/cobra"
+)
+
+var (
+	moleculeCleanupCmd = &cobra.Command{
+		Use:   "cleanup",
+		Short: "Clean-up stale Heat stacks",
+
+		Run: func(cmd *cobra.Command, args []string) {
+			opts, err := clientconfig.AuthOptions(nil)
+			if err != nil {
+				log.Panic(err)
+			}
+
+			provider, err := openstack.AuthenticatedClient(*opts)
+			if err != nil {
+				log.Panic(err)
+			}
+
+			client, err := openstack.NewOrchestrationV1(provider, gophercloud.EndpointOpts{})
+			if err != nil {
+				log.Panic(err)
+			}
+
+			stacks.List(client, stacks.ListOpts{}).EachPage(func(page pagination.Page) (bool, error) {
+				allStacks, err := stacks.ExtractStacks(page)
+				if err != nil {
+					return false, err
+				}
+
+				for _, stack := range allStacks {
+					if stack.Status == "DELETE_IN_PROGRESS" {
+						log.WithFields(log.Fields{
+							"stack": stack.Name,
+						}).Info("Stack is already being deleted")
+
+						continue
+					}
+
+					if !strings.HasPrefix(stack.Name, "atmosphere-") {
+						log.Panic("stack name does not start with atmosphere: " + stack.Name)
+					}
+
+					s := strings.Split(stack.Name, "-")
+					if len(s) != 3 {
+						log.Panic("stack name does not have 3 parts: " + stack.Name)
+					}
+
+					runId, err := strconv.ParseInt(s[1], 10, 64)
+					if err != nil {
+						log.Panic(err)
+					}
+					runAttempt, err := strconv.ParseInt(s[2], 10, 0)
+					if err != nil {
+						log.Panic(err)
+					}
+
+					githubClient := github.NewClient(nil)
+					run, _, err := githubClient.Actions.GetWorkflowRunAttempt(context.TODO(), "vexxhost", "atmosphere", runId, int(runAttempt), nil)
+					if err != nil {
+						log.Panic(err)
+					}
+
+					if run.GetStatus() == "completed" {
+						log.WithFields(log.Fields{
+							"stack": stack.Name,
+						}).Info("Deleting stack")
+
+						_ = stacks.Delete(client, stack.Name, stack.ID)
+						if err != nil {
+							log.Panic(err)
+						}
+					}
+				}
+
+				return true, nil
+			})
+		},
+	}
+)
+
+func init() {
+	moleculeCmd.AddCommand(moleculeCleanupCmd)
+}