forked from toolshed/abra
		
	Compare commits
	
		
			113 Commits
		
	
	
		
			deploy-uns
			...
			private-re
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 7a24bf9cd5 | |||
| 7c3b740e14 | |||
| 
						
						
							
						
						2fbef41a3a
	
				 | 
					
					
						|||
| 6fb41e5300 | |||
| 1432f480c7 | |||
| 
						
						
							
						
						83af39771b
	
				 | 
					
					
						|||
| 
						
						
							
						
						4d1333202e
	
				 | 
					
					
						|||
| 
						
						
							
						
						55c24f070c
	
				 | 
					
					
						|||
| 229e8eb9da | |||
| 
						
						
							
						
						b3ab95750e
	
				 | 
					
					
						|||
| de009921a2 | |||
| 
						
						
							
						
						d081bbaefa
	
				 | 
					
					
						|||
| 
						
						
							
						
						515b5466ca
	
				 | 
					
					
						|||
| 
						
						
							
						
						6965799bdc
	
				 | 
					
					
						|||
| 
						
						
							
						
						f75c9a6259
	
				 | 
					
					
						|||
| a43a092ba7 | |||
| fa084a61d2 | |||
| 
						
						
							
						
						895a7fe7d6
	
				 | 
					
					
						|||
| 
						
						
							
						
						742a726778
	
				 | 
					
					
						|||
| 
						
						
							
						
						2b9a185aff
	
				 | 
					
					
						|||
| 
						
						
							
						
						b7c1e87c0b
	
				 | 
					
					
						|||
| 
						
						
							
						
						cdfb8a08bb
	
				 | 
					
					
						|||
| 
						
						
							
						
						8943cea13f
	
				 | 
					
					
						|||
| 
						
						
							
						
						6d64e0edd3
	
				 | 
					
					
						|||
| 47045ca8f1 | |||
| d0f982456e | |||
| 80ad6c6681 | |||
| 
						
						
							
						
						cb63cfe9c2
	
				 | 
					
					
						|||
| 
						
						
							
						
						d1e49d17ce
	
				 | 
					
					
						|||
| 
						
						
							
						
						1574aa0631
	
				 | 
					
					
						|||
| 
						
						
							
						
						1723025fbf
	
				 | 
					
					
						|||
| 
						
						
							
						
						a2b678caf6
	
				 | 
					
					
						|||
| 0a371ec360 | |||
| e58a716fe1 | |||
| d09a19a385 | |||
| cee808ff06 | |||
| 4326d1d259 | |||
| b976872f77 | |||
| 7b6ea76437 | |||
| 9069758969 | |||
| 15d6b1a2a5 | |||
| 
						
						
							
						
						8a7fe4ca07
	
				 | 
					
					
						|||
| 64ad60663f | |||
| cb3f46b46e | |||
| 
						
						
							
						
						41e514ae9a
	
				 | 
					
					
						|||
| 
						
						
							
						
						086b4828ff
	
				 | 
					
					
						|||
| 
						
						
							
						
						ed263854d4
	
				 | 
					
					
						|||
| 
						
						
							
						
						eb6fe4ba6e
	
				 | 
					
					
						|||
| 993172d31b | |||
| c70b6e72a7 | |||
| 22e4dd7fca | |||
| 
						
						
							
						
						b6009057a8
	
				 | 
					
					
						|||
| 
						
						
							
						
						b978f04910
	
				 | 
					
					
						|||
| 
						
						
							
						
						3ac29d54d9
	
				 | 
					
					
						|||
| 
						
						
							
						
						877c17fab5
	
				 | 
					
					
						|||
| 
						
						
							
						
						f01fd26ce3
	
				 | 
					
					
						|||
| 
						
						
							
						
						273c165a41
	
				 | 
					
					
						|||
| 
						
						
							
						
						c88fc66c99
	
				 | 
					
					
						|||
| 
						
						
							
						
						9b271a6963
	
				 | 
					
					
						|||
| 
						
						
							
						
						8af87aa382
	
				 | 
					
					
						|||
| 
						
						
							
						
						ac0b9cd052
	
				 | 
					
					
						|||
| 
						
						
							
						
						4923984e84
	
				 | 
					
					
						|||
| 
						
						
							
						
						2bc77de751
	
				 | 
					
					
						|||
| 
						
						
							
						
						b3a2402cec
	
				 | 
					
					
						|||
| 
						
						
							
						
						a773fd4256
	
				 | 
					
					
						|||
| 
						
						
							
						
						b1a0d54bd3
	
				 | 
					
					
						|||
| 
						
						
							
						
						3869d6bce9
	
				 | 
					
					
						|||
| 
						
						
							
						
						0ff07ab224
	
				 | 
					
					
						|||
| 
						
						
							
						
						936c1b0626
	
				 | 
					
					
						|||
| 
						
						
							
						
						b576cba227
	
				 | 
					
					
						|||
| 
						
						
							
						
						d087f3debf
	
				 | 
					
					
						|||
| 
						
						
							
						
						e57a6d87a3
	
				 | 
					
					
						|||
| 
						
						
							
						
						74b64099de
	
				 | 
					
					
						|||
| 
						
						
							
						
						354712ca46
	
				 | 
					
					
						|||
| 
						
						
							
						
						81cdc843ec
	
				 | 
					
					
						|||
| 
						
						
							
						
						d2931e3af0
	
				 | 
					
					
						|||
| 
						
						
							
						
						b9f2d1f568
	
				 | 
					
					
						|||
| a379b31a19 | |||
| 
						
						
							
						
						17e15dba77
	
				 | 
					
					
						|||
| 
						
						
							
						
						1194f3b228
	
				 | 
					
					
						|||
| 
						
						
							
						
						2dc8034c16
	
				 | 
					
					
						|||
| 
						
						
							
						
						c5ddeb2d8a
	
				 | 
					
					
						|||
| 
						
						
							
						
						0a63f9ce27
	
				 | 
					
					
						|||
| 
						
						
							
						
						3a71dc47f8
	
				 | 
					
					
						|||
| 
						
						
							
						
						f07c64f7b8
	
				 | 
					
					
						|||
| 
						
						
							
						
						dd03c40e10
	
				 | 
					
					
						|||
| 
						
						
							
						
						48198d55bd
	
				 | 
					
					
						|||
| 
						
						
							
						
						c0931b96d8
	
				 | 
					
					
						|||
| 
						
						
							
						
						64ea0f9684
	
				 | 
					
					
						|||
| 
						
						
							
						
						b0cd8ccbb9
	
				 | 
					
					
						|||
| 
						
						
							
						
						5975be6870
	
				 | 
					
					
						|||
| 
						
						
							
						
						bfed51a69c
	
				 | 
					
					
						|||
| 
						
						
							
						
						5d0faf5e13
	
				 | 
					
					
						|||
| 
						
						
							
						
						cd6af9708c
	
				 | 
					
					
						|||
| 
						
						
							
						
						ef95bce1e4
	
				 | 
					
					
						|||
| 
						
						
							
						
						a159583874
	
				 | 
					
					
						|||
| 
						
						
							
						
						e3b0500875
	
				 | 
					
					
						|||
| 
						
						
							
						
						994310a4ff
	
				 | 
					
					
						|||
| 74108b0dd9 | |||
| 
						
						
							
						
						3727c7fa78
	
				 | 
					
					
						|||
| 
						
						
							
						
						9a4414fd13
	
				 | 
					
					
						|||
| 
						
						
							
						
						9f189680f3
	
				 | 
					
					
						|||
| 
						
						
							
						
						356e527f1f
	
				 | 
					
					
						|||
| 
						
						
							
						
						7ec61c6d03
	
				 | 
					
					
						|||
| 
						
						
							
						
						fab93a559a
	
				 | 
					
					
						|||
| 
						
						
							
						
						8ac31330be
	
				 | 
					
					
						|||
| 
						
						
							
						
						03000c25e0
	
				 | 
					
					
						|||
| 
						
						
							
						
						3f32dbb1a3
	
				 | 
					
					
						|||
| 
						
						
							
						
						27f68b1dc5
	
				 | 
					
					
						|||
| a0da5299fe | |||
| 
						
						
							
						
						866c5c4536
	
				 | 
					
					
						|||
| 
						
						
							
						
						dc4c6784cb
	
				 | 
					
					
						|||
| 97959ef5da | 
							
								
								
									
										47
									
								
								.drone.yml
									
									
									
									
									
								
							
							
						
						
									
										47
									
								
								.drone.yml
									
									
									
									
									
								
							@ -3,14 +3,14 @@ kind: pipeline
 | 
			
		||||
name: coopcloud.tech/abra
 | 
			
		||||
steps:
 | 
			
		||||
  - name: make check
 | 
			
		||||
    image: golang:1.22
 | 
			
		||||
    image: golang:1.24
 | 
			
		||||
    commands:
 | 
			
		||||
      - make check
 | 
			
		||||
 | 
			
		||||
  - name: make test
 | 
			
		||||
    image: golang:1.22
 | 
			
		||||
    image: golang:1.24
 | 
			
		||||
    environment:
 | 
			
		||||
      CATL_URL: https://git.coopcloud.tech/coop-cloud/recipes-catalogue-json.git
 | 
			
		||||
      CATL_URL: https://git.coopcloud.tech/toolshed/recipes-catalogue-json.git
 | 
			
		||||
    commands:
 | 
			
		||||
      - mkdir -p $HOME/.abra
 | 
			
		||||
      - git clone $CATL_URL $HOME/.abra/catalogue
 | 
			
		||||
@ -29,7 +29,7 @@ steps:
 | 
			
		||||
      event: tag
 | 
			
		||||
 | 
			
		||||
  - name: release
 | 
			
		||||
    image: goreleaser/goreleaser:v1.24.0
 | 
			
		||||
    image: goreleaser/goreleaser:v2.5.1
 | 
			
		||||
    environment:
 | 
			
		||||
      GITEA_TOKEN:
 | 
			
		||||
        from_secret: goreleaser_gitea_token
 | 
			
		||||
@ -47,10 +47,10 @@ steps:
 | 
			
		||||
    image: plugins/docker
 | 
			
		||||
    settings:
 | 
			
		||||
      auto_tag: true
 | 
			
		||||
      username: 3wordchant
 | 
			
		||||
      username: abra-bot
 | 
			
		||||
      password:
 | 
			
		||||
        from_secret: git_coopcloud_tech_token_3wc
 | 
			
		||||
      repo: git.coopcloud.tech/coop-cloud/abra
 | 
			
		||||
        from_secret: git_coopcloud_tech_token_abra_bot
 | 
			
		||||
      repo: git.coopcloud.tech/toolshed/abra
 | 
			
		||||
      tags: dev
 | 
			
		||||
      registry: git.coopcloud.tech
 | 
			
		||||
    when:
 | 
			
		||||
@ -60,7 +60,7 @@ steps:
 | 
			
		||||
      - make check
 | 
			
		||||
      - make test
 | 
			
		||||
 | 
			
		||||
  - name: integration test
 | 
			
		||||
  - name: on-demand integration test
 | 
			
		||||
    image: appleboy/drone-ssh
 | 
			
		||||
    settings:
 | 
			
		||||
      host:
 | 
			
		||||
@ -74,7 +74,31 @@ steps:
 | 
			
		||||
      request_pty: true
 | 
			
		||||
      script:
 | 
			
		||||
        - |
 | 
			
		||||
          wget https://git.coopcloud.tech/coop-cloud/abra/raw/branch/main/scripts/tests/run-ci-int -O run-ci-int
 | 
			
		||||
          wget https://git.coopcloud.tech/toolshed/abra/raw/branch/main/scripts/tests/run-ci-int -O run-ci-int
 | 
			
		||||
          chmod +x run-ci-int
 | 
			
		||||
          sh run-ci-int
 | 
			
		||||
    when:
 | 
			
		||||
      ref:
 | 
			
		||||
        - refs/heads/int-*
 | 
			
		||||
    depends_on:
 | 
			
		||||
      - make check
 | 
			
		||||
      - make test
 | 
			
		||||
 | 
			
		||||
  - name: nightly integration test
 | 
			
		||||
    image: appleboy/drone-ssh
 | 
			
		||||
    settings:
 | 
			
		||||
      host:
 | 
			
		||||
        - int.coopcloud.tech
 | 
			
		||||
      username: abra
 | 
			
		||||
      key:
 | 
			
		||||
        from_secret: abra_int_private_key
 | 
			
		||||
      port: 22
 | 
			
		||||
      command_timeout: 60m
 | 
			
		||||
      script_stop: true
 | 
			
		||||
      request_pty: true
 | 
			
		||||
      script:
 | 
			
		||||
        - |
 | 
			
		||||
          wget https://git.coopcloud.tech/toolshed/abra/raw/branch/main/scripts/tests/run-ci-int -O run-ci-int
 | 
			
		||||
          chmod +x run-ci-int
 | 
			
		||||
          sh run-ci-int
 | 
			
		||||
    when:
 | 
			
		||||
@ -87,3 +111,8 @@ steps:
 | 
			
		||||
volumes:
 | 
			
		||||
  - name: deps
 | 
			
		||||
    temp: {}
 | 
			
		||||
 | 
			
		||||
trigger:
 | 
			
		||||
  action:
 | 
			
		||||
    exclude:
 | 
			
		||||
      - synchronized
 | 
			
		||||
 | 
			
		||||
@ -1,8 +0,0 @@
 | 
			
		||||
---
 | 
			
		||||
name: "Do not use this issue tracker"
 | 
			
		||||
about: "Do not use this issue tracker"
 | 
			
		||||
title: "Do not use this issue tracker"
 | 
			
		||||
labels: []
 | 
			
		||||
---
 | 
			
		||||
 | 
			
		||||
Please report your issue on [`coop-cloud/organising`](https://git.coopcloud.tech/coop-cloud/organising)
 | 
			
		||||
@ -4,6 +4,7 @@
 | 
			
		||||
> please do add yourself! This is a community project, let's show some 💞
 | 
			
		||||
 | 
			
		||||
- 3wordchant
 | 
			
		||||
- ammaratef45
 | 
			
		||||
- cassowary
 | 
			
		||||
- codegod100
 | 
			
		||||
- decentral1se
 | 
			
		||||
@ -17,3 +18,5 @@
 | 
			
		||||
- roxxers
 | 
			
		||||
- vera
 | 
			
		||||
- yksflip
 | 
			
		||||
- basebuilder
 | 
			
		||||
- mayel
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
# Build image
 | 
			
		||||
FROM golang:1.22-alpine AS build
 | 
			
		||||
FROM golang:1.24-alpine AS build
 | 
			
		||||
 | 
			
		||||
ENV GOPRIVATE coopcloud.tech
 | 
			
		||||
ENV GOPRIVATE=coopcloud.tech
 | 
			
		||||
 | 
			
		||||
RUN apk add --no-cache \
 | 
			
		||||
  gcc \
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										2
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										2
									
								
								Makefile
									
									
									
									
									
								
							@ -2,7 +2,7 @@ ABRA         := ./cmd/abra
 | 
			
		||||
KADABRA      := ./cmd/kadabra
 | 
			
		||||
COMMIT       := $(shell git rev-list -1 HEAD)
 | 
			
		||||
GOPATH       := $(shell go env GOPATH)
 | 
			
		||||
GOVERSION    := 1.22
 | 
			
		||||
GOVERSION    := 1.24
 | 
			
		||||
LDFLAGS      := "-X 'main.Commit=$(COMMIT)'"
 | 
			
		||||
DIST_LDFLAGS := $(LDFLAGS)" -s -w"
 | 
			
		||||
GCFLAGS      := "all=-l -B"
 | 
			
		||||
 | 
			
		||||
@ -1,7 +1,7 @@
 | 
			
		||||
# `abra`
 | 
			
		||||
 | 
			
		||||
[](https://build.coopcloud.tech/coop-cloud/abra)
 | 
			
		||||
[](https://goreportcard.com/report/git.coopcloud.tech/coop-cloud/abra)
 | 
			
		||||
[](https://build.coopcloud.tech/toolshed/abra)
 | 
			
		||||
[](https://goreportcard.com/report/git.coopcloud.tech/toolshed/abra)
 | 
			
		||||
[](https://pkg.go.dev/coopcloud.tech/abra)
 | 
			
		||||
 | 
			
		||||
The Co-op Cloud utility belt 🎩🐇
 | 
			
		||||
 | 
			
		||||
@ -11,7 +11,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppBackupListCommand = &cobra.Command{
 | 
			
		||||
	Use:     "list <app> [flags]",
 | 
			
		||||
	Use:     "list <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"ls"},
 | 
			
		||||
	Short:   "List the contents of a snapshot",
 | 
			
		||||
	Args:    cobra.ExactArgs(1),
 | 
			
		||||
@ -61,7 +61,7 @@ var AppBackupListCommand = &cobra.Command{
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var AppBackupDownloadCommand = &cobra.Command{
 | 
			
		||||
	Use:     "download <app> [flags]",
 | 
			
		||||
	Use:     "download <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"d"},
 | 
			
		||||
	Short:   "Download a snapshot",
 | 
			
		||||
	Long: `Downloads a backup.tar.gz to the current working directory.
 | 
			
		||||
@ -78,7 +78,7 @@ var AppBackupDownloadCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -130,7 +130,7 @@ var AppBackupDownloadCommand = &cobra.Command{
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var AppBackupCreateCommand = &cobra.Command{
 | 
			
		||||
	Use:     "create <app> [flags]",
 | 
			
		||||
	Use:     "create <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"c"},
 | 
			
		||||
	Short:   "Create a new snapshot",
 | 
			
		||||
	Args:    cobra.ExactArgs(1),
 | 
			
		||||
@ -143,7 +143,7 @@ var AppBackupCreateCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -174,7 +174,7 @@ var AppBackupCreateCommand = &cobra.Command{
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var AppBackupSnapshotsCommand = &cobra.Command{
 | 
			
		||||
	Use:     "snapshots <app> [flags]",
 | 
			
		||||
	Use:     "snapshots <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"s"},
 | 
			
		||||
	Short:   "List all snapshots",
 | 
			
		||||
	Args:    cobra.ExactArgs(1),
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppCheckCommand = &cobra.Command{
 | 
			
		||||
	Use:     "check <app> [flags]",
 | 
			
		||||
	Use:     "check <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"chk"},
 | 
			
		||||
	Short:   "Ensure an app is well configured",
 | 
			
		||||
	Long: `Compare env vars in both the app ".env" and recipe ".env.sample" file.
 | 
			
		||||
@ -36,7 +36,7 @@ ${FOO:<default>} syntax). "check" does not confirm or deny this for you.`,
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -46,7 +46,10 @@ ${FOO:<default>} syntax). "check" does not confirm or deny this for you.`,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		table.
 | 
			
		||||
			Headers("RECIPE ENV SAMPLE", "APP ENV").
 | 
			
		||||
			Headers(
 | 
			
		||||
				fmt.Sprintf("%s .env.sample", app.Recipe.Name),
 | 
			
		||||
				fmt.Sprintf("%s.env", app.Name),
 | 
			
		||||
			).
 | 
			
		||||
			StyleFunc(func(row, col int) lipgloss.Style {
 | 
			
		||||
				switch {
 | 
			
		||||
				case col == 1:
 | 
			
		||||
@ -71,7 +74,9 @@ ${FOO:<default>} syntax). "check" does not confirm or deny this for you.`,
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Println(table)
 | 
			
		||||
		if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppCmdCommand = &cobra.Command{
 | 
			
		||||
	Use:     "command <app> [service | --local] <cmd> [[args] [flags] | [flags] -- [args]]",
 | 
			
		||||
	Use:     "command <domain> [service | --local] <cmd> [[args] [flags] | [flags] -- [args]]",
 | 
			
		||||
	Aliases: []string{"cmd"},
 | 
			
		||||
	Short:   "Run app commands",
 | 
			
		||||
	Long: `Run an app specific command.
 | 
			
		||||
@ -92,7 +92,7 @@ does not).`,
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -183,7 +183,7 @@ does not).`,
 | 
			
		||||
		if err := internal.RunCmdRemote(
 | 
			
		||||
			cl,
 | 
			
		||||
			app,
 | 
			
		||||
			requestTTY,
 | 
			
		||||
			disableTTY,
 | 
			
		||||
			app.Recipe.AbraShPath,
 | 
			
		||||
			targetServiceName, cmdName, parsedCmdArgs, remoteUser); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
@ -192,14 +192,14 @@ does not).`,
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var AppCmdListCommand = &cobra.Command{
 | 
			
		||||
	Use:     "list <app> [flags]",
 | 
			
		||||
	Use:     "list <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"ls"},
 | 
			
		||||
	Short:   "List all available commands",
 | 
			
		||||
	Args:    cobra.MinimumNArgs(1),
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -238,7 +238,7 @@ func parseCmdArgs(args []string, isLocal bool) (bool, string) {
 | 
			
		||||
var (
 | 
			
		||||
	local      bool
 | 
			
		||||
	remoteUser string
 | 
			
		||||
	requestTTY bool
 | 
			
		||||
	disableTTY bool
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
@ -259,11 +259,11 @@ func init() {
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	AppCmdCommand.Flags().BoolVarP(
 | 
			
		||||
		&requestTTY,
 | 
			
		||||
		&disableTTY,
 | 
			
		||||
		"tty",
 | 
			
		||||
		"t",
 | 
			
		||||
		"T",
 | 
			
		||||
		false,
 | 
			
		||||
		"request remote TTY",
 | 
			
		||||
		"disable remote TTY",
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	AppCmdCommand.Flags().BoolVarP(
 | 
			
		||||
 | 
			
		||||
@ -13,7 +13,7 @@ func TestParseCmdArgs(t *testing.T) {
 | 
			
		||||
	}{
 | 
			
		||||
		// `--` is not parsed when passed in from the command-line e.g. -- foo bar baz
 | 
			
		||||
		// so we need to eumlate that as missing when testing if bash args are passed in
 | 
			
		||||
		// see https://git.coopcloud.tech/coop-cloud/organising/issues/336 for more
 | 
			
		||||
		// see https://git.coopcloud.tech/toolshed/organising/issues/336 for more
 | 
			
		||||
		{[]string{"foo.com", "app", "test"}, false, ""},
 | 
			
		||||
		{[]string{"foo.com", "app", "test", "foo"}, true, "foo "},
 | 
			
		||||
		{[]string{"foo.com", "app", "test", "foo", "bar", "baz"}, true, "foo bar baz "},
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppConfigCommand = &cobra.Command{
 | 
			
		||||
	Use:     "config <app> [flags]",
 | 
			
		||||
	Use:     "config <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"cfg"},
 | 
			
		||||
	Short:   "Edit app config",
 | 
			
		||||
	Example: "  abra config 1312.net",
 | 
			
		||||
 | 
			
		||||
@ -18,7 +18,7 @@ import (
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/upstream/container"
 | 
			
		||||
	"github.com/docker/cli/cli/command"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	containertypes "github.com/docker/docker/api/types/container"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
	"github.com/docker/docker/errdefs"
 | 
			
		||||
	"github.com/docker/docker/pkg/archive"
 | 
			
		||||
@ -26,14 +26,14 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppCpCommand = &cobra.Command{
 | 
			
		||||
	Use:     "cp <app> <src> <dst> [flags]",
 | 
			
		||||
	Use:     "cp <domain> <src> <dst> [flags]",
 | 
			
		||||
	Aliases: []string{"c"},
 | 
			
		||||
	Short:   "Copy files to/from a deployed app service",
 | 
			
		||||
	Example: `  # copy myfile.txt to the root of the app service
 | 
			
		||||
  abra app cp 1312.net myfile.txt app:/
 | 
			
		||||
 | 
			
		||||
  # copy that file back to your current working directory locally
 | 
			
		||||
  abra app cp 1312.net app:/myfile.txt`,
 | 
			
		||||
  abra app cp 1312.net app:/myfile.txt ./`,
 | 
			
		||||
	Args: cobra.ExactArgs(3),
 | 
			
		||||
	ValidArgsFunction: func(
 | 
			
		||||
		cmd *cobra.Command,
 | 
			
		||||
@ -49,7 +49,7 @@ var AppCpCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -134,7 +134,7 @@ func CopyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath stri
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		if _, err := container.RunExec(dcli, cl, containerID, &types.ExecConfig{
 | 
			
		||||
		if _, err := container.RunExec(dcli, cl, containerID, &containertypes.ExecOptions{
 | 
			
		||||
			AttachStderr: true,
 | 
			
		||||
			AttachStdin:  true,
 | 
			
		||||
			AttachStdout: true,
 | 
			
		||||
@ -162,7 +162,7 @@ func CopyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath stri
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Debugf("copy %s from local to %s on container", srcPath, dstPath)
 | 
			
		||||
	copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
 | 
			
		||||
	copyOpts := containertypes.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
 | 
			
		||||
	if err := cl.CopyToContainer(context.Background(), containerID, dstPath, content, copyOpts); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
@ -173,7 +173,7 @@ func CopyToContainer(cl *dockerClient.Client, containerID, srcPath, dstPath stri
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		if _, err := container.RunExec(dcli, cl, containerID, &types.ExecConfig{
 | 
			
		||||
		if _, err := container.RunExec(dcli, cl, containerID, &containertypes.ExecOptions{
 | 
			
		||||
			AttachStderr: true,
 | 
			
		||||
			AttachStdin:  true,
 | 
			
		||||
			AttachStdout: true,
 | 
			
		||||
 | 
			
		||||
@ -2,10 +2,11 @@ package app
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/app"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/config"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/envfile"
 | 
			
		||||
@ -18,18 +19,19 @@ import (
 | 
			
		||||
	"coopcloud.tech/abra/pkg/lint"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/upstream/stack"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppDeployCommand = &cobra.Command{
 | 
			
		||||
	Use:     "deploy <app> [version] [flags]",
 | 
			
		||||
	Use:     "deploy <domain> [version] [flags]",
 | 
			
		||||
	Aliases: []string{"d"},
 | 
			
		||||
	Short:   "Deploy an app",
 | 
			
		||||
	Long: `Deploy an app.
 | 
			
		||||
 | 
			
		||||
This command supports chaos operations. Use "--chaos/-c" to deploy your recipe
 | 
			
		||||
checkout as-is. Recipe commit hashes are also supported values for "[version]".
 | 
			
		||||
Please note, "upgrade"/"rollback" do not support chaos operations.`,
 | 
			
		||||
This command supports chaos operations. Use "--chaos/-C" to deploy your recipe
 | 
			
		||||
checkout as-is. Recipe commit hashes are also supported as values for
 | 
			
		||||
"[version]". Please note, "upgrade"/"rollback" do not support chaos operations.`,
 | 
			
		||||
	Example: `  # standard deployment
 | 
			
		||||
  abra app deploy 1312.net
 | 
			
		||||
 | 
			
		||||
@ -45,15 +47,16 @@ Please note, "upgrade"/"rollback" do not support chaos operations.`,
 | 
			
		||||
	ValidArgsFunction: func(
 | 
			
		||||
		cmd *cobra.Command,
 | 
			
		||||
		args []string,
 | 
			
		||||
		toComplete string) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
		toComplete string,
 | 
			
		||||
	) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
		switch l := len(args); l {
 | 
			
		||||
		case 0:
 | 
			
		||||
			return autocomplete.AppNameComplete()
 | 
			
		||||
		case 1:
 | 
			
		||||
			app, err := appPkg.Get(args[0])
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
				return nil, cobra.ShellCompDirectiveDefault
 | 
			
		||||
				errMsg := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
				return []string{errMsg}, cobra.ShellCompDirectiveError
 | 
			
		||||
			}
 | 
			
		||||
			return autocomplete.RecipeVersionComplete(app.Recipe.Name)
 | 
			
		||||
		default:
 | 
			
		||||
@ -61,131 +64,55 @@ Please note, "upgrade"/"rollback" do not support chaos operations.`,
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		var warnMessages []string
 | 
			
		||||
		var (
 | 
			
		||||
			deployWarnMessages []string
 | 
			
		||||
			toDeployVersion    string
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
		stackName := app.StackName()
 | 
			
		||||
 | 
			
		||||
		ok, err := validateChaosXORVersion(args)
 | 
			
		||||
		if !ok {
 | 
			
		||||
			log.Fatalf(err.Error())
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		specificVersion := getSpecifiedVersion(args)
 | 
			
		||||
 | 
			
		||||
		if specificVersion != "" {
 | 
			
		||||
			log.Debugf("overriding env file version (%s) with %s", app.Recipe.Version, specificVersion)
 | 
			
		||||
			app.Recipe.Version = specificVersion
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if specificVersion == "" && app.Recipe.Version != "" && !internal.Chaos {
 | 
			
		||||
			log.Debugf("retrieved %s as version from env file", app.Recipe.Version)
 | 
			
		||||
			specificVersion = app.Recipe.Version
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := validateArgsAndFlags(args); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := lint.LintForErrors(app.Recipe); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("checking whether %s is already deployed", stackName)
 | 
			
		||||
 | 
			
		||||
		cl, err := client.New(app.Server)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
 | 
			
		||||
		log.Debugf("checking whether %s is already deployed", app.StackName())
 | 
			
		||||
 | 
			
		||||
		deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// NOTE(d1): handles "<version> as git hash" use case
 | 
			
		||||
		var isChaosCommit bool
 | 
			
		||||
		if deployMeta.IsDeployed && !(internal.Force || internal.Chaos) {
 | 
			
		||||
			log.Fatalf("%s is already deployed", app.Name)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// NOTE(d1): check out specific version before dealing with secrets. This
 | 
			
		||||
		// is because we need to deal with GetComposeFiles under the hood and these
 | 
			
		||||
		// files change from version to version which therefore affects which
 | 
			
		||||
		// secrets might be generated
 | 
			
		||||
		version := deployMeta.Version
 | 
			
		||||
		if specificVersion != "" {
 | 
			
		||||
			version = specificVersion
 | 
			
		||||
			log.Debugf("choosing %s as version to deploy", version)
 | 
			
		||||
		toDeployVersion, err = getDeployVersion(args, deployMeta, app)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(fmt.Errorf("get deploy version: %s", err))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
			var err error
 | 
			
		||||
			isChaosCommit, err = app.Recipe.EnsureVersion(version)
 | 
			
		||||
		if !internal.Chaos {
 | 
			
		||||
			_, err = app.Recipe.EnsureVersion(toDeployVersion)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if isChaosCommit {
 | 
			
		||||
				log.Debugf("assuming '%s' is a chaos commit", version)
 | 
			
		||||
				internal.Chaos = true
 | 
			
		||||
				log.Fatalf("ensure recipe: %s", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		secStats, err := secret.PollSecretsStatus(cl, app)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
		if err := lint.LintForErrors(app.Recipe); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for _, secStat := range secStats {
 | 
			
		||||
			if !secStat.CreatedOnRemote {
 | 
			
		||||
				log.Fatalf("unable to deploy, secrets not generated (%s)?", secStat.LocalName)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if deployMeta.IsDeployed {
 | 
			
		||||
			if internal.Force || internal.Chaos {
 | 
			
		||||
				warnMessages = append(warnMessages, fmt.Sprintf("%s is already deployed", app.Name))
 | 
			
		||||
			} else {
 | 
			
		||||
				log.Fatalf("%s is already deployed", app.Name)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !internal.Chaos && specificVersion == "" {
 | 
			
		||||
			versions, err := app.Recipe.Tags()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if len(versions) > 0 && !internal.Chaos {
 | 
			
		||||
				version = versions[len(versions)-1]
 | 
			
		||||
				log.Debugf("choosing %s as version to deploy", version)
 | 
			
		||||
				if _, err := app.Recipe.EnsureVersion(version); err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				head, err := app.Recipe.Head()
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
				version = formatter.SmallSHA(head.String())
 | 
			
		||||
				warnMessages = append(warnMessages, fmt.Sprintf("no versions detected, using latest commit"))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		chaosVersion := config.CHAOS_DEFAULT
 | 
			
		||||
		if internal.Chaos {
 | 
			
		||||
			warnMessages = append(warnMessages, "chaos mode engaged")
 | 
			
		||||
 | 
			
		||||
			if isChaosCommit {
 | 
			
		||||
				chaosVersion = specificVersion
 | 
			
		||||
				versionLabelLocal, err := app.Recipe.GetVersionLabelLocal()
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
				version = versionLabelLocal
 | 
			
		||||
			} else {
 | 
			
		||||
				var err error
 | 
			
		||||
				chaosVersion, err = app.Recipe.ChaosVersion()
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		if err := validateSecrets(cl, app); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		abraShEnv, err := envfile.ReadAbraShEnvVars(app.Recipe.AbraShPath)
 | 
			
		||||
@ -201,6 +128,7 @@ Please note, "upgrade"/"rollback" do not support chaos operations.`,
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		stackName := app.StackName()
 | 
			
		||||
		deployOpts := stack.Deploy{
 | 
			
		||||
			Composefiles: composeFiles,
 | 
			
		||||
			Namespace:    stackName,
 | 
			
		||||
@ -216,8 +144,11 @@ Please note, "upgrade"/"rollback" do not support chaos operations.`,
 | 
			
		||||
		appPkg.ExposeAllEnv(stackName, compose, app.Env)
 | 
			
		||||
		appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
 | 
			
		||||
		appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
 | 
			
		||||
		appPkg.SetChaosVersionLabel(compose, stackName, chaosVersion)
 | 
			
		||||
		if internal.Chaos {
 | 
			
		||||
			appPkg.SetChaosVersionLabel(compose, stackName, toDeployVersion)
 | 
			
		||||
		}
 | 
			
		||||
		appPkg.SetUpdateLabel(compose, stackName, app.Env)
 | 
			
		||||
		appPkg.SetVersionLabel(compose, stackName, toDeployVersion)
 | 
			
		||||
 | 
			
		||||
		envVars, err := appPkg.CheckEnv(app)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
@ -226,26 +157,36 @@ Please note, "upgrade"/"rollback" do not support chaos operations.`,
 | 
			
		||||
 | 
			
		||||
		for _, envVar := range envVars {
 | 
			
		||||
			if !envVar.Present {
 | 
			
		||||
				warnMessages = append(warnMessages,
 | 
			
		||||
					fmt.Sprintf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain),
 | 
			
		||||
				deployWarnMessages = append(deployWarnMessages,
 | 
			
		||||
					fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain),
 | 
			
		||||
				)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !internal.NoDomainChecks {
 | 
			
		||||
			domainName, ok := app.Env["DOMAIN"]
 | 
			
		||||
			if ok {
 | 
			
		||||
			if domainName, ok := app.Env["DOMAIN"]; ok {
 | 
			
		||||
				if _, err = dns.EnsureDomainsResolveSameIPv4(domainName, app.Server); err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
				warnMessages = append(warnMessages, "skipping domain checks as no DOMAIN=... configured for app")
 | 
			
		||||
				log.Debug("skipping domain checks, no DOMAIN=... configured")
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			warnMessages = append(warnMessages, "skipping domain checks as requested")
 | 
			
		||||
			log.Debug("skipping domain checks")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := internal.DeployOverview(app, warnMessages, version, chaosVersion); err != nil {
 | 
			
		||||
		deployedVersion := config.NO_VERSION_DEFAULT
 | 
			
		||||
		if deployMeta.IsDeployed {
 | 
			
		||||
			deployedVersion = deployMeta.Version
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := internal.DeployOverview(
 | 
			
		||||
			app,
 | 
			
		||||
			deployedVersion,
 | 
			
		||||
			toDeployVersion,
 | 
			
		||||
			"",
 | 
			
		||||
			deployWarnMessages,
 | 
			
		||||
		); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -253,9 +194,28 @@ Please note, "upgrade"/"rollback" do not support chaos operations.`,
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
		log.Debugf("set waiting timeout to %d s", stack.WaitTimeout)
 | 
			
		||||
 | 
			
		||||
		if err := stack.RunDeploy(cl, deployOpts, compose, app.Name, internal.DontWaitConverge); err != nil {
 | 
			
		||||
		log.Debugf("set waiting timeout to %d second(s)", stack.WaitTimeout)
 | 
			
		||||
 | 
			
		||||
		serviceNames, err := appPkg.GetAppServiceNames(app.Name)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		f, err := app.Filters(true, false, serviceNames...)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := stack.RunDeploy(
 | 
			
		||||
			cl,
 | 
			
		||||
			deployOpts,
 | 
			
		||||
			compose,
 | 
			
		||||
			app.Name,
 | 
			
		||||
			app.Server,
 | 
			
		||||
			internal.DontWaitConverge,
 | 
			
		||||
			f,
 | 
			
		||||
		); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -267,31 +227,92 @@ Please note, "upgrade"/"rollback" do not support chaos operations.`,
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		app.Recipe.Version = version
 | 
			
		||||
		if chaosVersion != config.CHAOS_DEFAULT {
 | 
			
		||||
			app.Recipe.Version = chaosVersion
 | 
			
		||||
		}
 | 
			
		||||
		log.Debugf("choosing %s as version to save to env file", app.Recipe.Version)
 | 
			
		||||
		if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil {
 | 
			
		||||
			log.Fatalf("writing new recipe version in env file: %s", err)
 | 
			
		||||
		if err := app.WriteRecipeVersion(toDeployVersion, false); err != nil {
 | 
			
		||||
			log.Fatalf("writing recipe version failed: %s", err)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// validateChaosXORVersion xor checks version/chaos mode
 | 
			
		||||
func validateChaosXORVersion(args []string) (bool, error) {
 | 
			
		||||
	if getSpecifiedVersion(args) != "" && internal.Chaos {
 | 
			
		||||
		return false, errors.New("cannot use <version> and --chaos together")
 | 
			
		||||
func getLatestVersionOrCommit(app app.App) (string, error) {
 | 
			
		||||
	versions, err := app.Recipe.Tags()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
	return true, nil
 | 
			
		||||
 | 
			
		||||
	if len(versions) > 0 && !internal.Chaos {
 | 
			
		||||
		return versions[len(versions)-1], nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	head, err := app.Recipe.Head()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return formatter.SmallSHA(head.String()), nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getSpecifiedVersion retrieves the specific version if available
 | 
			
		||||
func getSpecifiedVersion(args []string) string {
 | 
			
		||||
	if len(args) >= 2 {
 | 
			
		||||
		return args[1]
 | 
			
		||||
// validateArgsAndFlags ensures compatible args/flags.
 | 
			
		||||
func validateArgsAndFlags(args []string) error {
 | 
			
		||||
	if len(args) == 2 && args[1] != "" && internal.Chaos {
 | 
			
		||||
		return fmt.Errorf("cannot use [version] and --chaos together")
 | 
			
		||||
	}
 | 
			
		||||
	return ""
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func validateSecrets(cl *dockerClient.Client, app app.App) error {
 | 
			
		||||
	secStats, err := secret.PollSecretsStatus(cl, app)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, secStat := range secStats {
 | 
			
		||||
		if !secStat.CreatedOnRemote {
 | 
			
		||||
			return fmt.Errorf("secret not generated: %s", secStat.LocalName)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getDeployVersion(cliArgs []string, deployMeta stack.DeployMeta, app app.App) (string, error) {
 | 
			
		||||
	// Chaos mode overrides everything
 | 
			
		||||
	if internal.Chaos {
 | 
			
		||||
		v, err := app.Recipe.ChaosVersion()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", err
 | 
			
		||||
		}
 | 
			
		||||
		log.Debugf("version: taking chaos version: %s", v)
 | 
			
		||||
		return v, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if the deploy version is set with a cli argument
 | 
			
		||||
	if len(cliArgs) == 2 && cliArgs[1] != "" {
 | 
			
		||||
		log.Debugf("version: taking version from cli arg: %s", cliArgs[1])
 | 
			
		||||
		return cliArgs[1], nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Check if the recipe has a version in the .env file
 | 
			
		||||
	if app.Recipe.EnvVersion != "" && !internal.IgnoreEnvVersion {
 | 
			
		||||
		if strings.HasSuffix(app.Recipe.EnvVersionRaw, "+U") {
 | 
			
		||||
			return "", fmt.Errorf("version: can not redeploy chaos version %s", app.Recipe.EnvVersionRaw)
 | 
			
		||||
		}
 | 
			
		||||
		log.Debugf("version: taking version from .env file: %s", app.Recipe.EnvVersion)
 | 
			
		||||
		return app.Recipe.EnvVersion, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Take deployed version
 | 
			
		||||
	if deployMeta.IsDeployed {
 | 
			
		||||
		log.Debugf("version: taking deployed version: %s", deployMeta.Version)
 | 
			
		||||
		return deployMeta.Version, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	v, err := getLatestVersionOrCommit(app)
 | 
			
		||||
	log.Debugf("version: taking new recipe version: %s", v)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
	return v, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
@ -324,6 +345,6 @@ func init() {
 | 
			
		||||
		"no-converge-checks",
 | 
			
		||||
		"c",
 | 
			
		||||
		false,
 | 
			
		||||
		"do not wait for converge logic checks",
 | 
			
		||||
		"disable converge logic checks",
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,57 +0,0 @@
 | 
			
		||||
package app
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestGetSpecificVersion(t *testing.T) {
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		input          []string
 | 
			
		||||
		expectedOutput string
 | 
			
		||||
	}{
 | 
			
		||||
		// No specified version when command has one or less args
 | 
			
		||||
		{[]string{}, ""},
 | 
			
		||||
		{[]string{"arg0"}, ""},
 | 
			
		||||
		// Second in arg (index-1) is the specified result when command has more than 1 args
 | 
			
		||||
		{[]string{"arg0", "arg1"}, "arg1"},
 | 
			
		||||
		{[]string{"arg0", "arg1", "arg2"}, "arg1"},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, test := range tests {
 | 
			
		||||
		if test.expectedOutput != getSpecifiedVersion(test.input) {
 | 
			
		||||
			t.Fatalf("result for %s should be %s", test.input, test.expectedOutput)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestValidateChaosXORVersion(t *testing.T) {
 | 
			
		||||
	tests := []struct {
 | 
			
		||||
		input          []string
 | 
			
		||||
		isChaos        bool
 | 
			
		||||
		expectedResult bool
 | 
			
		||||
	}{
 | 
			
		||||
		// Chaos = true, Specified Version absent
 | 
			
		||||
		{[]string{}, true, true},
 | 
			
		||||
		// Chaos = false, Specified Version absent
 | 
			
		||||
		{[]string{}, false, true},
 | 
			
		||||
		// Chaos = true, Specified Version present
 | 
			
		||||
		{[]string{"arg0", "arg1"}, true, false},
 | 
			
		||||
		// Chaos = false, Specified Version present
 | 
			
		||||
		{[]string{"arg0", "arg1", "arg2"}, false, true},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, test := range tests {
 | 
			
		||||
		internal.Chaos = test.isChaos
 | 
			
		||||
		res, _ := validateChaosXORVersion(test.input)
 | 
			
		||||
		if res != test.expectedResult {
 | 
			
		||||
			t.Fatalf(
 | 
			
		||||
				"When args are %s and Chaos mode is %t result needs to be %t",
 | 
			
		||||
				test.input,
 | 
			
		||||
				test.isChaos,
 | 
			
		||||
				test.expectedResult,
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										43
									
								
								cli/app/env.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										43
									
								
								cli/app/env.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,43 @@
 | 
			
		||||
package app
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"sort"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppEnvCommand = &cobra.Command{
 | 
			
		||||
	Use:     "env <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"e"},
 | 
			
		||||
	Short:   "Show app .env values",
 | 
			
		||||
	Example: "  abra app env 1312.net",
 | 
			
		||||
	Args:    cobra.ExactArgs(1),
 | 
			
		||||
	ValidArgsFunction: func(
 | 
			
		||||
		cmd *cobra.Command,
 | 
			
		||||
		args []string,
 | 
			
		||||
		toComplete string) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
		return autocomplete.AppNameComplete()
 | 
			
		||||
	},
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		var envKeys []string
 | 
			
		||||
		for k := range app.Env {
 | 
			
		||||
			envKeys = append(envKeys, k)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		sort.Strings(envKeys)
 | 
			
		||||
 | 
			
		||||
		var rows [][]string
 | 
			
		||||
		for _, k := range envKeys {
 | 
			
		||||
			rows = append(rows, []string{k, app.Env[k]})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		overview := formatter.CreateOverview("ENV OVERVIEW", rows)
 | 
			
		||||
		fmt.Println(overview)
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										139
									
								
								cli/app/labels.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								cli/app/labels.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,139 @@
 | 
			
		||||
package app
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"sort"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/client"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/upstream/convert"
 | 
			
		||||
	composetypes "github.com/docker/cli/cli/compose/types"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	"github.com/docker/docker/api/types/filters"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppLabelsCommand = &cobra.Command{
 | 
			
		||||
	Use:     "labels <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"lb"},
 | 
			
		||||
	Short:   "Show deployment labels",
 | 
			
		||||
	Long:    "Both local recipe and live deployment labels are shown.",
 | 
			
		||||
	Example: "  abra app labels 1312.net",
 | 
			
		||||
	Args:    cobra.ExactArgs(1),
 | 
			
		||||
	ValidArgsFunction: func(
 | 
			
		||||
		cmd *cobra.Command,
 | 
			
		||||
		args []string,
 | 
			
		||||
		toComplete string) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
		return autocomplete.AppNameComplete()
 | 
			
		||||
	},
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		cl, err := client.New(app.Server)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		remoteLabels, err := getLabels(cl, app.StackName())
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rows := [][]string{
 | 
			
		||||
			{"DEPLOYED LABELS", "---"},
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		remoteLabelKeys := make([]string, 0, len(remoteLabels))
 | 
			
		||||
		for k := range remoteLabels {
 | 
			
		||||
			remoteLabelKeys = append(remoteLabelKeys, k)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		sort.Strings(remoteLabelKeys)
 | 
			
		||||
 | 
			
		||||
		for _, k := range remoteLabelKeys {
 | 
			
		||||
			rows = append(rows, []string{
 | 
			
		||||
				k,
 | 
			
		||||
				remoteLabels[k],
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(remoteLabelKeys) == 0 {
 | 
			
		||||
			rows = append(rows, []string{"unknown"})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rows = append(rows, []string{"RECIPE LABELS", "---"})
 | 
			
		||||
 | 
			
		||||
		config, err := app.Recipe.GetComposeConfig(app.Env)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var localLabelKeys []string
 | 
			
		||||
		var appServiceConfig composetypes.ServiceConfig
 | 
			
		||||
		for _, service := range config.Services {
 | 
			
		||||
			if service.Name == "app" {
 | 
			
		||||
				appServiceConfig = service
 | 
			
		||||
 | 
			
		||||
				for k := range service.Deploy.Labels {
 | 
			
		||||
					localLabelKeys = append(localLabelKeys, k)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		sort.Strings(localLabelKeys)
 | 
			
		||||
 | 
			
		||||
		for _, k := range localLabelKeys {
 | 
			
		||||
			rows = append(rows, []string{
 | 
			
		||||
				k,
 | 
			
		||||
				appServiceConfig.Deploy.Labels[k],
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		overview := formatter.CreateOverview("LABELS OVERVIEW", rows)
 | 
			
		||||
		fmt.Println(overview)
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// getLabels reads docker labels from running services in the format of "coop-cloud.${STACK_NAME}.${LABEL}".
 | 
			
		||||
func getLabels(cl *dockerClient.Client, stackName string) (map[string]string, error) {
 | 
			
		||||
	labels := make(map[string]string)
 | 
			
		||||
 | 
			
		||||
	filter := filters.NewArgs()
 | 
			
		||||
	filter.Add("label", fmt.Sprintf("%s=%s", convert.LabelNamespace, stackName))
 | 
			
		||||
 | 
			
		||||
	services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: filter})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return labels, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, service := range services {
 | 
			
		||||
		if service.Spec.Name != fmt.Sprintf("%s_app", stackName) {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for k, v := range service.Spec.Labels {
 | 
			
		||||
			labels[k] = v
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return labels, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	AppLabelsCommand.Flags().BoolVarP(
 | 
			
		||||
		&internal.Chaos,
 | 
			
		||||
		"chaos",
 | 
			
		||||
		"C",
 | 
			
		||||
		false,
 | 
			
		||||
		"ignore uncommitted recipes changes",
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
@ -142,10 +142,14 @@ Use "--status/-S" flag to query all servers for the live deployment status.`,
 | 
			
		||||
					appStats.AutoUpdate = autoUpdate
 | 
			
		||||
 | 
			
		||||
					var newUpdates []string
 | 
			
		||||
					if version != "unknown" {
 | 
			
		||||
					if version != "unknown" && chaosVersion == "unknown" {
 | 
			
		||||
						if err := app.Recipe.EnsureExists(); err != nil {
 | 
			
		||||
							log.Fatalf("unable to clone %s: %s", app.Name, err)
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						updates, err := app.Recipe.Tags()
 | 
			
		||||
						if err != nil {
 | 
			
		||||
							log.Fatal(err)
 | 
			
		||||
							log.Fatalf("unable to retrieve tags for %s: %s", app.Name, err)
 | 
			
		||||
						}
 | 
			
		||||
 | 
			
		||||
						parsedVersion, err := tagcmp.Parse(version)
 | 
			
		||||
@ -173,7 +177,7 @@ Use "--status/-S" flag to query all servers for the live deployment status.`,
 | 
			
		||||
							stats.LatestCount++
 | 
			
		||||
						}
 | 
			
		||||
					} else {
 | 
			
		||||
						newUpdates = internal.ReverseStringList(newUpdates)
 | 
			
		||||
						newUpdates = internal.SortVersionsDesc(newUpdates)
 | 
			
		||||
						appStats.Upgrade = strings.Join(newUpdates, "\n")
 | 
			
		||||
						stats.UpgradeCount++
 | 
			
		||||
					}
 | 
			
		||||
@ -208,7 +212,7 @@ Use "--status/-S" flag to query all servers for the live deployment status.`,
 | 
			
		||||
 | 
			
		||||
			serverStat := allStats[app.Server]
 | 
			
		||||
 | 
			
		||||
			headers := []string{"RECIPE", "DOMAIN"}
 | 
			
		||||
			headers := []string{"RECIPE", "DOMAIN", "SERVER"}
 | 
			
		||||
			if status {
 | 
			
		||||
				headers = append(headers, []string{
 | 
			
		||||
					"STATUS",
 | 
			
		||||
@ -228,7 +232,7 @@ Use "--status/-S" flag to query all servers for the live deployment status.`,
 | 
			
		||||
 | 
			
		||||
			var rows [][]string
 | 
			
		||||
			for _, appStat := range serverStat.Apps {
 | 
			
		||||
				row := []string{appStat.Recipe, appStat.Domain}
 | 
			
		||||
				row := []string{appStat.Recipe, appStat.Domain, appStat.Server}
 | 
			
		||||
				if status {
 | 
			
		||||
					chaosStatus := appStat.Chaos
 | 
			
		||||
					if chaosStatus != "unknown" {
 | 
			
		||||
@ -256,20 +260,8 @@ Use "--status/-S" flag to query all servers for the live deployment status.`,
 | 
			
		||||
			table.Rows(rows...)
 | 
			
		||||
 | 
			
		||||
			if len(rows) > 0 {
 | 
			
		||||
				fmt.Println(table)
 | 
			
		||||
 | 
			
		||||
				if status {
 | 
			
		||||
					fmt.Println(fmt.Sprintf(
 | 
			
		||||
						"SERVER: %s | TOTAL APPS: %v | VERSIONED: %v | UNVERSIONED: %v | LATEST : %v | UPGRADE: %v",
 | 
			
		||||
						app.Server,
 | 
			
		||||
						serverStat.AppCount,
 | 
			
		||||
						serverStat.VersionCount,
 | 
			
		||||
						serverStat.UnversionedCount,
 | 
			
		||||
						serverStat.LatestCount,
 | 
			
		||||
						serverStat.UpgradeCount,
 | 
			
		||||
					))
 | 
			
		||||
				} else {
 | 
			
		||||
					log.Infof("SERVER: %s TOTAL APPS: %v", app.Server, serverStat.AppCount)
 | 
			
		||||
				if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if len(allStats) > 1 && len(rows) > 0 {
 | 
			
		||||
@ -279,12 +271,6 @@ Use "--status/-S" flag to query all servers for the live deployment status.`,
 | 
			
		||||
 | 
			
		||||
			alreadySeen[app.Server] = true
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(allStats) > 1 {
 | 
			
		||||
			totalServers := formatter.BoldStyle.Render("TOTAL SERVERS")
 | 
			
		||||
			totalApps := formatter.BoldStyle.Render("TOTAL APPS")
 | 
			
		||||
			log.Infof("%s: %v | %s: %v ", totalServers, totalServersCount, totalApps, totalAppsCount)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										101
									
								
								cli/app/logs.go
									
									
									
									
									
								
							
							
						
						
									
										101
									
								
								cli/app/logs.go
									
									
									
									
									
								
							@ -2,28 +2,20 @@ package app
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"io"
 | 
			
		||||
	"os"
 | 
			
		||||
	"slices"
 | 
			
		||||
	"sync"
 | 
			
		||||
	"time"
 | 
			
		||||
	"fmt"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	appPkg "coopcloud.tech/abra/pkg/app"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/client"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/logs"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/upstream/stack"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	containerTypes "github.com/docker/docker/api/types/container"
 | 
			
		||||
	"github.com/docker/docker/api/types/filters"
 | 
			
		||||
	"github.com/docker/docker/api/types/swarm"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppLogsCommand = &cobra.Command{
 | 
			
		||||
	Use:     "logs <app> [service] [flags]",
 | 
			
		||||
	Use:     "logs <domain> [service] [flags]",
 | 
			
		||||
	Aliases: []string{"l"},
 | 
			
		||||
	Short:   "Tail app logs",
 | 
			
		||||
	Args:    cobra.RangeArgs(1, 2),
 | 
			
		||||
@ -37,8 +29,8 @@ var AppLogsCommand = &cobra.Command{
 | 
			
		||||
		case 1:
 | 
			
		||||
			app, err := appPkg.Get(args[0])
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
				return nil, cobra.ShellCompDirectiveDefault
 | 
			
		||||
				errMsg := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
				return []string{errMsg}, cobra.ShellCompDirectiveError
 | 
			
		||||
			}
 | 
			
		||||
			return autocomplete.ServiceNameComplete(app.Name)
 | 
			
		||||
		default:
 | 
			
		||||
@ -72,80 +64,25 @@ var AppLogsCommand = &cobra.Command{
 | 
			
		||||
			serviceNames = []string{args[1]}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err = tailLogs(cl, app, serviceNames); err != nil {
 | 
			
		||||
		f, err := app.Filters(true, false, serviceNames...)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		opts := logs.TailOpts{
 | 
			
		||||
			AppName:  app.Name,
 | 
			
		||||
			Services: serviceNames,
 | 
			
		||||
			StdErr:   stdErr,
 | 
			
		||||
			Since:    sinceLogs,
 | 
			
		||||
			Filters:  f,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := logs.TailLogs(cl, opts); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// tailLogs prints logs for the given app with optional service names to be
 | 
			
		||||
// filtered on. It also checks if the latest task is not runnning and then
 | 
			
		||||
// prints the past tasks.
 | 
			
		||||
func tailLogs(cl *dockerClient.Client, app appPkg.App, serviceNames []string) error {
 | 
			
		||||
	f, err := app.Filters(true, false, serviceNames...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	services, err := cl.ServiceList(context.Background(), types.ServiceListOptions{Filters: f})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var wg sync.WaitGroup
 | 
			
		||||
	for _, service := range services {
 | 
			
		||||
		filters := filters.NewArgs()
 | 
			
		||||
		filters.Add("name", service.Spec.Name)
 | 
			
		||||
		tasks, err := cl.TaskList(context.Background(), types.TaskListOptions{Filters: f})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
		if len(tasks) > 0 {
 | 
			
		||||
			// Need to sort the tasks by the CreatedAt field in the inverse order.
 | 
			
		||||
			// Otherwise they are in the reversed order and not sorted properly.
 | 
			
		||||
			slices.SortFunc[[]swarm.Task](tasks, func(t1, t2 swarm.Task) int {
 | 
			
		||||
				return int(t2.Meta.CreatedAt.Unix() - t1.Meta.CreatedAt.Unix())
 | 
			
		||||
			})
 | 
			
		||||
			lastTask := tasks[0].Status
 | 
			
		||||
			if lastTask.State != swarm.TaskStateRunning {
 | 
			
		||||
				for _, task := range tasks {
 | 
			
		||||
					log.Errorf("[%s] %s State %s: %s", service.Spec.Name, task.Meta.CreatedAt.Format(time.RFC3339), task.Status.State, task.Status.Err)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// Collect the logs in a go routine, so the logs from all services are
 | 
			
		||||
		// collected in parallel.
 | 
			
		||||
		wg.Add(1)
 | 
			
		||||
		go func(serviceID string) {
 | 
			
		||||
			logs, err := cl.ServiceLogs(context.Background(), serviceID, containerTypes.LogsOptions{
 | 
			
		||||
				ShowStderr: true,
 | 
			
		||||
				ShowStdout: !stdErr,
 | 
			
		||||
				Since:      sinceLogs,
 | 
			
		||||
				Until:      "",
 | 
			
		||||
				Timestamps: true,
 | 
			
		||||
				Follow:     true,
 | 
			
		||||
				Tail:       "20",
 | 
			
		||||
				Details:    false,
 | 
			
		||||
			})
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
			defer logs.Close()
 | 
			
		||||
 | 
			
		||||
			_, err = io.Copy(os.Stdout, logs)
 | 
			
		||||
			if err != nil && err != io.EOF {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
		}(service.ID)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// Wait for all log streams to be closed.
 | 
			
		||||
	wg.Wait()
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	stdErr    bool
 | 
			
		||||
	sinceLogs string
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										123
									
								
								cli/app/new.go
									
									
									
									
									
								
							
							
						
						
									
										123
									
								
								cli/app/new.go
									
									
									
									
									
								
							@ -25,7 +25,7 @@ This new app configuration is stored in your $ABRA_DIR directory under the
 | 
			
		||||
appropriate server.
 | 
			
		||||
 | 
			
		||||
This command does not deploy your app for you. You will need to run "abra app
 | 
			
		||||
deploy <app>" to do so.
 | 
			
		||||
deploy <domain>" to do so.
 | 
			
		||||
 | 
			
		||||
You can see what recipes are available (i.e. values for the [recipe] argument)
 | 
			
		||||
by running "abra recipe ls".
 | 
			
		||||
@ -64,53 +64,60 @@ var AppNewCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		recipe := internal.ValidateRecipe(args, cmd.Name())
 | 
			
		||||
 | 
			
		||||
		if len(args) == 2 && internal.Chaos {
 | 
			
		||||
			log.Fatal("cannot use [version] and --chaos together")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var recipeVersion string
 | 
			
		||||
		if !internal.Chaos {
 | 
			
		||||
		if len(args) == 2 {
 | 
			
		||||
			recipeVersion = args[1]
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		chaosVersion := config.CHAOS_DEFAULT
 | 
			
		||||
		if internal.Chaos {
 | 
			
		||||
			var err error
 | 
			
		||||
			chaosVersion, err = recipe.ChaosVersion()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			recipeVersion = chaosVersion
 | 
			
		||||
		} else {
 | 
			
		||||
			if err := recipe.EnsureIsClean(); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
			if !internal.Offline {
 | 
			
		||||
				if err := recipe.EnsureUpToDate(); err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if len(args) == 2 {
 | 
			
		||||
				recipeVersion = args[1]
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var recipeVersions recipePkg.RecipeVersions
 | 
			
		||||
			if recipeVersion == "" {
 | 
			
		||||
				recipeVersions, err := recipe.GetRecipeVersions()
 | 
			
		||||
				var err error
 | 
			
		||||
				recipeVersions, _, err = recipe.GetRecipeVersions()
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
				if len(recipeVersions) > 0 {
 | 
			
		||||
					latest := recipeVersions[len(recipeVersions)-1]
 | 
			
		||||
					for tag := range latest {
 | 
			
		||||
						recipeVersion = tag
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					if _, err := recipe.EnsureVersion(recipeVersion); err != nil {
 | 
			
		||||
						log.Fatal(err)
 | 
			
		||||
					}
 | 
			
		||||
				} else {
 | 
			
		||||
					if err := recipe.EnsureLatest(); err != nil {
 | 
			
		||||
						log.Fatal(err)
 | 
			
		||||
					}
 | 
			
		||||
			if len(recipeVersions) > 0 {
 | 
			
		||||
				latest := recipeVersions[len(recipeVersions)-1]
 | 
			
		||||
				for tag := range latest {
 | 
			
		||||
					recipeVersion = tag
 | 
			
		||||
				}
 | 
			
		||||
			} else {
 | 
			
		||||
 | 
			
		||||
				if _, err := recipe.EnsureVersion(recipeVersion); err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
			} else {
 | 
			
		||||
				if err := recipe.EnsureLatest(); err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
		if internal.Chaos && recipeVersion == "" {
 | 
			
		||||
			var err error
 | 
			
		||||
			recipeVersion, err = recipe.ChaosVersion()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
				if recipeVersion == "" {
 | 
			
		||||
					head, err := recipe.Head()
 | 
			
		||||
					if err != nil {
 | 
			
		||||
						log.Fatalf("failed to retrieve latest commit for %s: %s", recipe.Name, err)
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					recipeVersion = formatter.SmallSHA(head.String())
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -187,39 +194,22 @@ var AppNewCommand = &cobra.Command{
 | 
			
		||||
			newAppServer = "local"
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		table, err := formatter.CreateTable()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		headers := []string{"SERVER", "DOMAIN", "RECIPE", "VERSION"}
 | 
			
		||||
		table.Headers(headers...)
 | 
			
		||||
 | 
			
		||||
		table.Row(newAppServer, appDomain, recipe.Name, recipeVersion)
 | 
			
		||||
 | 
			
		||||
		log.Infof("new app '%s' created 🌞", recipe.Name)
 | 
			
		||||
 | 
			
		||||
		fmt.Println("")
 | 
			
		||||
		fmt.Println(table)
 | 
			
		||||
		fmt.Println("")
 | 
			
		||||
 | 
			
		||||
		fmt.Println("Configure this app:")
 | 
			
		||||
		fmt.Println(fmt.Sprintf("\n    abra app config %s", appDomain))
 | 
			
		||||
 | 
			
		||||
		fmt.Println("")
 | 
			
		||||
		fmt.Println("Deploy this app:")
 | 
			
		||||
		fmt.Println(fmt.Sprintf("\n    abra app deploy %s", appDomain))
 | 
			
		||||
		log.Infof("%s created (version: %s)", appDomain, recipeVersion)
 | 
			
		||||
 | 
			
		||||
		if len(appSecrets) > 0 {
 | 
			
		||||
			fmt.Println("")
 | 
			
		||||
			fmt.Println("Generated secrets:")
 | 
			
		||||
			fmt.Println("")
 | 
			
		||||
			fmt.Println(secretsTable)
 | 
			
		||||
			rows := [][]string{}
 | 
			
		||||
			for k, v := range appSecrets {
 | 
			
		||||
				rows = append(rows, []string{k, v})
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			overview := formatter.CreateOverview("SECRETS OVERVIEW", rows)
 | 
			
		||||
 | 
			
		||||
			fmt.Println(overview)
 | 
			
		||||
 | 
			
		||||
			log.Warnf(
 | 
			
		||||
				"generated secrets %s shown again, please take note of them %s",
 | 
			
		||||
				formatter.BoldStyle.Render("NOT"),
 | 
			
		||||
				formatter.BoldStyle.Render("NOW"),
 | 
			
		||||
				"secrets are %s shown again, please save them %s",
 | 
			
		||||
				formatter.BoldUnderlineStyle.Render("NOT"),
 | 
			
		||||
				formatter.BoldUnderlineStyle.Render("NOW"),
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -228,9 +218,8 @@ var AppNewCommand = &cobra.Command{
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("choosing %s as version to save to env file", recipeVersion)
 | 
			
		||||
		if err := app.WriteRecipeVersion(recipeVersion, false); err != nil {
 | 
			
		||||
			log.Fatalf("writing new recipe version in env file: %s", err)
 | 
			
		||||
			log.Fatalf("writing recipe version failed: %s", err)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
@ -313,6 +302,12 @@ func ensureServerFlag() error {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(servers) == 1 {
 | 
			
		||||
		newAppServer = servers[0]
 | 
			
		||||
		log.Infof("single server detected, choosing %s automatically", newAppServer)
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if newAppServer == "" && !internal.NoInput {
 | 
			
		||||
		prompt := &survey.Select{
 | 
			
		||||
			Message: "Select app server:",
 | 
			
		||||
 | 
			
		||||
@ -4,6 +4,8 @@ import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	appPkg "coopcloud.tech/abra/pkg/app"
 | 
			
		||||
@ -22,9 +24,9 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppPsCommand = &cobra.Command{
 | 
			
		||||
	Use:     "ps <app> [flags]",
 | 
			
		||||
	Use:     "ps <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"p"},
 | 
			
		||||
	Short:   "Check app status",
 | 
			
		||||
	Short:   "Check app deployment status",
 | 
			
		||||
	Args:    cobra.ExactArgs(1),
 | 
			
		||||
	ValidArgsFunction: func(
 | 
			
		||||
		cmd *cobra.Command,
 | 
			
		||||
@ -35,7 +37,7 @@ var AppPsCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -57,9 +59,11 @@ var AppPsCommand = &cobra.Command{
 | 
			
		||||
		statuses, err := appPkg.GetAppStatuses([]appPkg.App{app}, true)
 | 
			
		||||
		if statusMeta, ok := statuses[app.StackName()]; ok {
 | 
			
		||||
			if isChaos, exists := statusMeta["chaos"]; exists && isChaos == "true" {
 | 
			
		||||
				chaosVersion, err = app.Recipe.ChaosVersion()
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				if cVersion, exists := statusMeta["chaosVersion"]; exists {
 | 
			
		||||
					chaosVersion = cVersion
 | 
			
		||||
					if strings.HasSuffix(chaosVersion, config.DIRTY_DEFAULT) {
 | 
			
		||||
						chaosVersion = formatter.BoldDirtyDefault(chaosVersion)
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
@ -88,9 +92,14 @@ func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chao
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	services := compose.Services
 | 
			
		||||
	sort.Slice(services, func(i, j int) bool {
 | 
			
		||||
		return services[i].Name < services[j].Name
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	var rows [][]string
 | 
			
		||||
	allContainerStats := make(map[string]map[string]string)
 | 
			
		||||
	for _, service := range compose.Services {
 | 
			
		||||
	for _, service := range services {
 | 
			
		||||
		filters := filters.NewArgs()
 | 
			
		||||
		filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), service.Name))
 | 
			
		||||
 | 
			
		||||
@ -128,24 +137,35 @@ func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chao
 | 
			
		||||
 | 
			
		||||
		allContainerStats[containerStats["service"]] = containerStats
 | 
			
		||||
 | 
			
		||||
		// NOTE(d1): don't clobber these variables for --machine output
 | 
			
		||||
		dVersion := deployedVersion
 | 
			
		||||
		cVersion := chaosVersion
 | 
			
		||||
 | 
			
		||||
		if containerStats["service"] != "app" {
 | 
			
		||||
			// NOTE(d1): don't repeat info which only relevant for the "app" service
 | 
			
		||||
			dVersion = ""
 | 
			
		||||
			cVersion = ""
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		row := []string{
 | 
			
		||||
			containerStats["service"],
 | 
			
		||||
			containerStats["image"],
 | 
			
		||||
			containerStats["created"],
 | 
			
		||||
			containerStats["status"],
 | 
			
		||||
			containerStats["state"],
 | 
			
		||||
			containerStats["ports"],
 | 
			
		||||
			containerStats["image"],
 | 
			
		||||
			dVersion,
 | 
			
		||||
			cVersion,
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rows = append(rows, row)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if internal.MachineReadable {
 | 
			
		||||
		jsonstring, err := json.Marshal(allContainerStats)
 | 
			
		||||
		rendered, err := json.Marshal(allContainerStats)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal("unable to convert to JSON: %s", err)
 | 
			
		||||
		}
 | 
			
		||||
		fmt.Println(string(jsonstring))
 | 
			
		||||
 | 
			
		||||
		fmt.Println(string(rendered))
 | 
			
		||||
 | 
			
		||||
		return
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -156,20 +176,19 @@ func showPSOutput(app appPkg.App, cl *dockerClient.Client, deployedVersion, chao
 | 
			
		||||
 | 
			
		||||
	headers := []string{
 | 
			
		||||
		"SERVICE",
 | 
			
		||||
		"IMAGE",
 | 
			
		||||
		"CREATED",
 | 
			
		||||
		"STATUS",
 | 
			
		||||
		"STATE",
 | 
			
		||||
		"PORTS",
 | 
			
		||||
		"IMAGE",
 | 
			
		||||
		"VERSION",
 | 
			
		||||
		"CHAOS",
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	table.
 | 
			
		||||
		Headers(headers...).
 | 
			
		||||
		Rows(rows...)
 | 
			
		||||
 | 
			
		||||
	fmt.Println(table)
 | 
			
		||||
 | 
			
		||||
	log.Infof("VERSION: %s CHAOS: %s", deployedVersion, chaosVersion)
 | 
			
		||||
	if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
 | 
			
		||||
@ -16,7 +16,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppRemoveCommand = &cobra.Command{
 | 
			
		||||
	Use:     "remove <app> [flags]",
 | 
			
		||||
	Use:     "remove <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"rm"},
 | 
			
		||||
	Short:   "Remove all app data, locally and remotely",
 | 
			
		||||
	Long: `Remove everything related to an app which is already undeployed.
 | 
			
		||||
 | 
			
		||||
@ -9,18 +9,20 @@ import (
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/client"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/ui"
 | 
			
		||||
	upstream "coopcloud.tech/abra/pkg/upstream/service"
 | 
			
		||||
	stack "coopcloud.tech/abra/pkg/upstream/stack"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppRestartCommand = &cobra.Command{
 | 
			
		||||
	Use:     "restart <app> [[service] | --all-services] [flags]",
 | 
			
		||||
	Use:     "restart <domain> [[service] | --all-services] [flags]",
 | 
			
		||||
	Aliases: []string{"re"},
 | 
			
		||||
	Short:   "Restart an app",
 | 
			
		||||
	Long: `This command restarts services within a deployed app.
 | 
			
		||||
 | 
			
		||||
Run "abra app ps <app>" to see a list of service names.
 | 
			
		||||
Run "abra app ps <domain>" to see a list of service names.
 | 
			
		||||
 | 
			
		||||
Pass "--all-services/-a" to restart all services.`,
 | 
			
		||||
	Example: `  # restart a single app service
 | 
			
		||||
@ -48,7 +50,7 @@ Pass "--all-services/-a" to restart all services.`,
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(false, false); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -93,13 +95,36 @@ Pass "--all-services/-a" to restart all services.`,
 | 
			
		||||
		for _, serviceName := range serviceNames {
 | 
			
		||||
			stackServiceName := fmt.Sprintf("%s_%s", app.StackName(), serviceName)
 | 
			
		||||
 | 
			
		||||
			service, _, err := cl.ServiceInspectWithRaw(
 | 
			
		||||
				context.Background(),
 | 
			
		||||
				stackServiceName,
 | 
			
		||||
				types.ServiceInspectOptions{},
 | 
			
		||||
			)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			log.Debugf("attempting to scale %s to 0", stackServiceName)
 | 
			
		||||
 | 
			
		||||
			if err := upstream.RunServiceScale(context.Background(), cl, stackServiceName, 0); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if err := stack.WaitOnService(context.Background(), cl, stackServiceName, app.Name); err != nil {
 | 
			
		||||
			f, err := app.Filters(true, false, serviceName)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			waitOpts := stack.WaitOpts{
 | 
			
		||||
				Services:   []ui.ServiceMeta{{Name: stackServiceName, ID: service.ID}},
 | 
			
		||||
				AppName:    app.Name,
 | 
			
		||||
				ServerName: app.Server,
 | 
			
		||||
				Filters:    f,
 | 
			
		||||
				NoLog:      true,
 | 
			
		||||
				Quiet:      true,
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if err := stack.WaitOnServices(cmd.Context(), cl, waitOpts); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
@ -110,7 +135,7 @@ Pass "--all-services/-a" to restart all services.`,
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if err := stack.WaitOnService(context.Background(), cl, stackServiceName, app.Name); err != nil {
 | 
			
		||||
			if err := stack.WaitOnServices(cmd.Context(), cl, waitOpts); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
@ -123,6 +148,13 @@ Pass "--all-services/-a" to restart all services.`,
 | 
			
		||||
var allServices bool
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	AppRestartCommand.Flags().BoolVarP(
 | 
			
		||||
		&internal.Chaos,
 | 
			
		||||
		"chaos",
 | 
			
		||||
		"C",
 | 
			
		||||
		false,
 | 
			
		||||
		"ignore uncommitted recipes changes",
 | 
			
		||||
	)
 | 
			
		||||
	AppRestartCommand.Flags().BoolVarP(
 | 
			
		||||
		&allServices,
 | 
			
		||||
		"all-services",
 | 
			
		||||
 | 
			
		||||
@ -12,7 +12,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppRestoreCommand = &cobra.Command{
 | 
			
		||||
	Use:     "restore <app> [flags]",
 | 
			
		||||
	Use:     "restore <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"rs"},
 | 
			
		||||
	Short:   "Restore a snapshot",
 | 
			
		||||
	Long: `Snapshots are restored while apps are deployed.
 | 
			
		||||
@ -28,7 +28,7 @@ Some restore scenarios may require service / app restarts.`,
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,14 @@
 | 
			
		||||
package app
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/app"
 | 
			
		||||
	appPkg "coopcloud.tech/abra/pkg/app"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/config"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/envfile"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/lint"
 | 
			
		||||
	stack "coopcloud.tech/abra/pkg/upstream/stack"
 | 
			
		||||
	"coopcloud.tech/tagcmp"
 | 
			
		||||
@ -20,16 +21,23 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppRollbackCommand = &cobra.Command{
 | 
			
		||||
	Use:     "rollback <app> [version] [flags]",
 | 
			
		||||
	Use:     "rollback <domain> [version] [flags]",
 | 
			
		||||
	Aliases: []string{"rl"},
 | 
			
		||||
	Short:   "Roll an app back to a previous version",
 | 
			
		||||
	Long: `This command rolls an app back to a previous version.
 | 
			
		||||
 | 
			
		||||
Unlike "deploy", chaos operations are not supported here. Only recipe versions
 | 
			
		||||
are supported values for "[<version>]".
 | 
			
		||||
Unlike "abra app deploy", chaos operations are not supported here. Only recipe
 | 
			
		||||
versions are supported values for "[version]".
 | 
			
		||||
 | 
			
		||||
A rollback can be destructive, please ensure you have a copy of your app data
 | 
			
		||||
beforehand.`,
 | 
			
		||||
It is possible to "--force/-f" an downgrade if you want to re-deploy a specific
 | 
			
		||||
version.
 | 
			
		||||
 | 
			
		||||
Only the deployed version is consulted when trying to determine what downgrades
 | 
			
		||||
are available. The live deployment version is the "source of truth" in this
 | 
			
		||||
case. The stored .env version is not consulted.
 | 
			
		||||
 | 
			
		||||
A downgrade can be destructive, please ensure you have a copy of your app data
 | 
			
		||||
beforehand. See "abra app backup" for more.`,
 | 
			
		||||
	Example: ` # standard rollback
 | 
			
		||||
  abra app rollback 1312.net
 | 
			
		||||
 | 
			
		||||
@ -46,8 +54,8 @@ beforehand.`,
 | 
			
		||||
		case 1:
 | 
			
		||||
			app, err := appPkg.Get(args[0])
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
				return nil, cobra.ShellCompDirectiveDefault
 | 
			
		||||
				errMsg := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
				return []string{errMsg}, cobra.ShellCompDirectiveError
 | 
			
		||||
			}
 | 
			
		||||
			return autocomplete.RecipeVersionComplete(app.Recipe.Name)
 | 
			
		||||
		default:
 | 
			
		||||
@ -55,26 +63,15 @@ beforehand.`,
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		var warnMessages []string
 | 
			
		||||
		var (
 | 
			
		||||
			downgradeWarnMessages []string
 | 
			
		||||
			chosenDowngrade       string
 | 
			
		||||
			availableDowngrades   []string
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
		stackName := app.StackName()
 | 
			
		||||
 | 
			
		||||
		var specificVersion string
 | 
			
		||||
		if len(args) == 2 {
 | 
			
		||||
			specificVersion = args[1]
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if specificVersion != "" {
 | 
			
		||||
			log.Debugf("overriding env file version (%s) with %s", app.Recipe.Version, specificVersion)
 | 
			
		||||
			app.Recipe.Version = specificVersion
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := lint.LintForErrors(app.Recipe); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -83,15 +80,13 @@ beforehand.`,
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("checking whether %s is already deployed", stackName)
 | 
			
		||||
 | 
			
		||||
		deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
 | 
			
		||||
		deployMeta, err := ensureDeployed(cl, app)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !deployMeta.IsDeployed {
 | 
			
		||||
			log.Fatalf("%s is not deployed?", app.Name)
 | 
			
		||||
		if err := lint.LintForErrors(app.Recipe); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		versions, err := app.Recipe.Tags()
 | 
			
		||||
@ -99,84 +94,56 @@ beforehand.`,
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var availableDowngrades []string
 | 
			
		||||
		if deployMeta.Version == "unknown" {
 | 
			
		||||
		// NOTE(d1): we've no idea what the live deployment version is, so every
 | 
			
		||||
		// possible downgrade can be shown. it's up to the user to make the choice
 | 
			
		||||
		if deployMeta.Version == config.UNKNOWN_DEFAULT {
 | 
			
		||||
			availableDowngrades = versions
 | 
			
		||||
			warnMessages = append(warnMessages, fmt.Sprintf("failed to determine deployed version of %s", app.Name))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if specificVersion != "" {
 | 
			
		||||
			parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatalf("'%s' is not a known version for %s", deployMeta.Version, app.Recipe.Name)
 | 
			
		||||
		if len(args) == 2 && args[1] != "" {
 | 
			
		||||
			chosenDowngrade = args[1]
 | 
			
		||||
 | 
			
		||||
			if err := validateDowngradeVersionArg(chosenDowngrade, app, deployMeta); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatalf("'%s' is not a known version for %s", specificVersion, app.Recipe.Name)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if parsedSpecificVersion.IsGreaterThan(parsedDeployedVersion) && !parsedSpecificVersion.Equals(parsedDeployedVersion) {
 | 
			
		||||
				log.Fatalf("%s is not a downgrade for %s?", deployMeta.Version, specificVersion)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
 | 
			
		||||
				log.Fatalf("%s is not a downgrade for %s?", deployMeta.Version, specificVersion)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			availableDowngrades = append(availableDowngrades, specificVersion)
 | 
			
		||||
			availableDowngrades = append(availableDowngrades, chosenDowngrade)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if deployMeta.Version != "unknown" && specificVersion == "" {
 | 
			
		||||
			if deployMeta.IsChaos {
 | 
			
		||||
				warnMessages = append(warnMessages, fmt.Sprintf("attempting to rollback a chaos deployment"))
 | 
			
		||||
		if deployMeta.Version != config.UNKNOWN_DEFAULT && chosenDowngrade == "" {
 | 
			
		||||
			downgradeAvailable, err := ensureDowngradesAvailable(versions, &availableDowngrades, deployMeta)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			for _, version := range versions {
 | 
			
		||||
				parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				parsedVersion, err := tagcmp.Parse(version)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if parsedVersion.IsLessThan(parsedDeployedVersion) && !(parsedVersion.Equals(parsedDeployedVersion)) {
 | 
			
		||||
					availableDowngrades = append(availableDowngrades, version)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if len(availableDowngrades) == 0 && !internal.Force {
 | 
			
		||||
			if !downgradeAvailable {
 | 
			
		||||
				log.Info("no available downgrades")
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var chosenDowngrade string
 | 
			
		||||
		if len(availableDowngrades) > 0 {
 | 
			
		||||
			if internal.Force || internal.NoInput || specificVersion != "" {
 | 
			
		||||
		if internal.Force || internal.NoInput || chosenDowngrade != "" {
 | 
			
		||||
			if len(availableDowngrades) > 0 {
 | 
			
		||||
				chosenDowngrade = availableDowngrades[len(availableDowngrades)-1]
 | 
			
		||||
				log.Debugf("choosing %s as version to downgrade to (--force/--no-input)", chosenDowngrade)
 | 
			
		||||
			} else {
 | 
			
		||||
				msg := fmt.Sprintf("please select a downgrade (version: %s):", deployMeta.Version)
 | 
			
		||||
				if deployMeta.IsChaos {
 | 
			
		||||
					msg = fmt.Sprintf("please select a downgrade (version: %s, chaosVersion: %s):", deployMeta.Version, deployMeta.ChaosVersion)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				prompt := &survey.Select{
 | 
			
		||||
					Message: msg,
 | 
			
		||||
					Options: internal.ReverseStringList(availableDowngrades),
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if err := survey.AskOne(prompt, &chosenDowngrade); err != nil {
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			if err := chooseDowngrade(availableDowngrades, deployMeta, &chosenDowngrade); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if internal.Force &&
 | 
			
		||||
			chosenDowngrade == "" &&
 | 
			
		||||
			deployMeta.Version != config.UNKNOWN_DEFAULT {
 | 
			
		||||
			chosenDowngrade = deployMeta.Version
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if chosenDowngrade == "" {
 | 
			
		||||
			log.Fatal("unknown deployed version, unable to downgrade")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("choosing %s as version to rollback", chosenDowngrade)
 | 
			
		||||
 | 
			
		||||
		if _, err := app.Recipe.EnsureVersion(chosenDowngrade); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
@ -194,6 +161,7 @@ beforehand.`,
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		stackName := app.StackName()
 | 
			
		||||
		deployOpts := stack.Deploy{
 | 
			
		||||
			Composefiles: composeFiles,
 | 
			
		||||
			Namespace:    stackName,
 | 
			
		||||
@ -210,38 +178,145 @@ beforehand.`,
 | 
			
		||||
		appPkg.ExposeAllEnv(stackName, compose, app.Env)
 | 
			
		||||
		appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
 | 
			
		||||
		appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
 | 
			
		||||
		appPkg.SetChaosVersionLabel(compose, stackName, chosenDowngrade)
 | 
			
		||||
		if internal.Chaos {
 | 
			
		||||
			appPkg.SetChaosVersionLabel(compose, stackName, chosenDowngrade)
 | 
			
		||||
		}
 | 
			
		||||
		appPkg.SetUpdateLabel(compose, stackName, app.Env)
 | 
			
		||||
 | 
			
		||||
		chaosVersion := config.CHAOS_DEFAULT
 | 
			
		||||
		if deployMeta.IsChaos {
 | 
			
		||||
			chaosVersion = deployMeta.ChaosVersion
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// NOTE(d1): no release notes implemeneted for rolling back
 | 
			
		||||
		if err := internal.NewVersionOverview(
 | 
			
		||||
		if err := internal.DeployOverview(
 | 
			
		||||
			app,
 | 
			
		||||
			warnMessages,
 | 
			
		||||
			"rollback",
 | 
			
		||||
			deployMeta.Version,
 | 
			
		||||
			chaosVersion,
 | 
			
		||||
			chosenDowngrade,
 | 
			
		||||
			""); err != nil {
 | 
			
		||||
			"",
 | 
			
		||||
			downgradeWarnMessages,
 | 
			
		||||
		); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := stack.RunDeploy(cl, deployOpts, compose, stackName, internal.DontWaitConverge); err != nil {
 | 
			
		||||
		stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		app.Recipe.Version = chosenDowngrade
 | 
			
		||||
		log.Debugf("choosing %s as version to save to env file", app.Recipe.Version)
 | 
			
		||||
		if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil {
 | 
			
		||||
			log.Fatalf("writing new recipe version in env file: %s", err)
 | 
			
		||||
		log.Debugf("set waiting timeout to %d second(s)", stack.WaitTimeout)
 | 
			
		||||
 | 
			
		||||
		serviceNames, err := appPkg.GetAppServiceNames(app.Name)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		f, err := app.Filters(true, false, serviceNames...)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := stack.RunDeploy(
 | 
			
		||||
			cl,
 | 
			
		||||
			deployOpts,
 | 
			
		||||
			compose,
 | 
			
		||||
			stackName,
 | 
			
		||||
			app.Server,
 | 
			
		||||
			internal.DontWaitConverge,
 | 
			
		||||
			f,
 | 
			
		||||
		); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := app.WriteRecipeVersion(chosenDowngrade, false); err != nil {
 | 
			
		||||
			log.Fatalf("writing recipe version failed: %s", err)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// chooseDowngrade prompts the user to choose an downgrade interactively.
 | 
			
		||||
func chooseDowngrade(
 | 
			
		||||
	availableDowngrades []string,
 | 
			
		||||
	deployMeta stack.DeployMeta,
 | 
			
		||||
	chosenDowngrade *string,
 | 
			
		||||
) error {
 | 
			
		||||
	msg := fmt.Sprintf("please select a downgrade (version: %s):", deployMeta.Version)
 | 
			
		||||
 | 
			
		||||
	if deployMeta.IsChaos {
 | 
			
		||||
		chaosVersion := formatter.BoldDirtyDefault(deployMeta.ChaosVersion)
 | 
			
		||||
 | 
			
		||||
		msg = fmt.Sprintf(
 | 
			
		||||
			"please select a downgrade (version: %s, chaos: %s):",
 | 
			
		||||
			deployMeta.Version,
 | 
			
		||||
			chaosVersion,
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	prompt := &survey.Select{
 | 
			
		||||
		Message: msg,
 | 
			
		||||
		Options: internal.SortVersionsDesc(availableDowngrades),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := survey.AskOne(prompt, chosenDowngrade); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// validateDownpgradeVersionArg validates the specific version.
 | 
			
		||||
func validateDowngradeVersionArg(
 | 
			
		||||
	specificVersion string,
 | 
			
		||||
	app app.App,
 | 
			
		||||
	deployMeta stack.DeployMeta,
 | 
			
		||||
) error {
 | 
			
		||||
	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("current deployment '%s' is not a known version for %s", deployMeta.Version, app.Recipe.Name)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("'%s' is not a known version for %s", specificVersion, app.Recipe.Name)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if parsedSpecificVersion.IsGreaterThan(parsedDeployedVersion) &&
 | 
			
		||||
		!parsedSpecificVersion.Equals(parsedDeployedVersion) {
 | 
			
		||||
		return fmt.Errorf("%s is not a downgrade for %s?", deployMeta.Version, specificVersion)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
 | 
			
		||||
		return fmt.Errorf("%s is not a downgrade for %s?", deployMeta.Version, specificVersion)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ensureDowngradesAvailable ensures that there are available downgrades.
 | 
			
		||||
func ensureDowngradesAvailable(
 | 
			
		||||
	versions []string,
 | 
			
		||||
	availableDowngrades *[]string,
 | 
			
		||||
	deployMeta stack.DeployMeta,
 | 
			
		||||
) (bool, error) {
 | 
			
		||||
	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, version := range versions {
 | 
			
		||||
		parsedVersion, err := tagcmp.Parse(version)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return false, err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if parsedVersion.IsLessThan(parsedDeployedVersion) &&
 | 
			
		||||
			!(parsedVersion.Equals(parsedDeployedVersion)) {
 | 
			
		||||
			*availableDowngrades = append(*availableDowngrades, version)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(*availableDowngrades) == 0 && !internal.Force {
 | 
			
		||||
		return false, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return true, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	AppRollbackCommand.Flags().BoolVarP(
 | 
			
		||||
		&internal.Force,
 | 
			
		||||
@ -263,6 +338,6 @@ func init() {
 | 
			
		||||
		&internal.DontWaitConverge, "no-converge-checks",
 | 
			
		||||
		"c",
 | 
			
		||||
		false,
 | 
			
		||||
		"do not wait for converge logic checks",
 | 
			
		||||
		"disable converge logic checks",
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -11,13 +11,13 @@ import (
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/upstream/container"
 | 
			
		||||
	"github.com/docker/cli/cli/command"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	containertypes "github.com/docker/docker/api/types/container"
 | 
			
		||||
	"github.com/docker/docker/api/types/filters"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppRunCommand = &cobra.Command{
 | 
			
		||||
	Use:     "run <app> <service> <cmd> [[args] [flags] | [flags] -- [args]]",
 | 
			
		||||
	Use:     "run <domain> <service> <cmd> [[args] [flags] | [flags] -- [args]]",
 | 
			
		||||
	Aliases: []string{"r"},
 | 
			
		||||
	Short:   "Run a command inside a service container",
 | 
			
		||||
	Example: `  # run <cmd> with args/flags
 | 
			
		||||
@ -64,7 +64,7 @@ var AppRunCommand = &cobra.Command{
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		userCmd := args[2:]
 | 
			
		||||
		execCreateOpts := types.ExecConfig{
 | 
			
		||||
		execCreateOpts := containertypes.ExecOptions{
 | 
			
		||||
			AttachStderr: true,
 | 
			
		||||
			AttachStdin:  true,
 | 
			
		||||
			AttachStdout: true,
 | 
			
		||||
 | 
			
		||||
@ -20,7 +20,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppSecretGenerateCommand = &cobra.Command{
 | 
			
		||||
	Use:     "generate <app> [[secret] [version] | --all] [flags]",
 | 
			
		||||
	Use:     "generate <domain> [[secret] [version] | --all] [flags]",
 | 
			
		||||
	Aliases: []string{"g"},
 | 
			
		||||
	Short:   "Generate secrets",
 | 
			
		||||
	Args:    cobra.RangeArgs(1, 3),
 | 
			
		||||
@ -34,8 +34,8 @@ var AppSecretGenerateCommand = &cobra.Command{
 | 
			
		||||
		case 1:
 | 
			
		||||
			app, err := appPkg.Get(args[0])
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
				return nil, cobra.ShellCompDirectiveDefault
 | 
			
		||||
				errMsg := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
				return []string{errMsg}, cobra.ShellCompDirectiveError
 | 
			
		||||
			}
 | 
			
		||||
			return autocomplete.SecretComplete(app.Recipe.Name)
 | 
			
		||||
		default:
 | 
			
		||||
@ -45,15 +45,15 @@ var AppSecretGenerateCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(args) == 1 && !generateAllSecrets {
 | 
			
		||||
		if len(args) <= 2 && !generateAllSecrets {
 | 
			
		||||
			log.Fatal("missing arguments [secret]/[version] or '--all'")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(args) > 1 && generateAllSecrets {
 | 
			
		||||
		if len(args) > 2 && generateAllSecrets {
 | 
			
		||||
			log.Fatal("cannot use '[secret] [version]' and '--all' together")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -127,7 +127,9 @@ var AppSecretGenerateCommand = &cobra.Command{
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Println(table)
 | 
			
		||||
		if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Warnf(
 | 
			
		||||
			"generated secrets %s shown again, please take note of them %s",
 | 
			
		||||
@ -138,7 +140,7 @@ var AppSecretGenerateCommand = &cobra.Command{
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var AppSecretInsertCommand = &cobra.Command{
 | 
			
		||||
	Use:     "insert <app> <secret> <version> <data> [flags]",
 | 
			
		||||
	Use:     "insert <domain> <secret> <version> <data> [flags]",
 | 
			
		||||
	Aliases: []string{"i"},
 | 
			
		||||
	Short:   "Insert secret",
 | 
			
		||||
	Long: `This command inserts a secret into an app environment.
 | 
			
		||||
@ -157,8 +159,8 @@ environment. Typically, you can let Abra generate them for you on app creation
 | 
			
		||||
		case 1:
 | 
			
		||||
			app, err := appPkg.Get(args[0])
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
				return nil, cobra.ShellCompDirectiveDefault
 | 
			
		||||
				errMsg := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
				return []string{errMsg}, cobra.ShellCompDirectiveError
 | 
			
		||||
			}
 | 
			
		||||
			return autocomplete.SecretComplete(app.Recipe.Name)
 | 
			
		||||
		default:
 | 
			
		||||
@ -168,7 +170,7 @@ environment. Typically, you can let Abra generate them for you on app creation
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -228,7 +230,7 @@ func secretRm(cl *dockerClient.Client, app appPkg.App, secretName, parsed string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var AppSecretRmCommand = &cobra.Command{
 | 
			
		||||
	Use:     "remove <app> [[secret] | --all] [flags]",
 | 
			
		||||
	Use:     "remove <domain> [[secret] | --all] [flags]",
 | 
			
		||||
	Aliases: []string{"rm"},
 | 
			
		||||
	Short:   "Remove a secret",
 | 
			
		||||
	Args:    cobra.RangeArgs(1, 2),
 | 
			
		||||
@ -243,8 +245,8 @@ var AppSecretRmCommand = &cobra.Command{
 | 
			
		||||
			if !rmAllSecrets {
 | 
			
		||||
				app, err := appPkg.Get(args[0])
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
					return nil, cobra.ShellCompDirectiveDefault
 | 
			
		||||
					errMsg := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
					return []string{errMsg}, cobra.ShellCompDirectiveError
 | 
			
		||||
				}
 | 
			
		||||
				return autocomplete.SecretComplete(app.Recipe.Name)
 | 
			
		||||
			}
 | 
			
		||||
@ -256,7 +258,7 @@ var AppSecretRmCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -336,7 +338,7 @@ var AppSecretRmCommand = &cobra.Command{
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var AppSecretLsCommand = &cobra.Command{
 | 
			
		||||
	Use:     "list <app>",
 | 
			
		||||
	Use:     "list <domain>",
 | 
			
		||||
	Aliases: []string{"ls"},
 | 
			
		||||
	Short:   "List all secrets",
 | 
			
		||||
	Args:    cobra.MinimumNArgs(1),
 | 
			
		||||
@ -349,7 +351,7 @@ var AppSecretLsCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -394,7 +396,10 @@ var AppSecretLsCommand = &cobra.Command{
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			fmt.Println(table)
 | 
			
		||||
			if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -17,7 +17,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppServicesCommand = &cobra.Command{
 | 
			
		||||
	Use:     "services <app> [flags]",
 | 
			
		||||
	Use:     "services <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"sr"},
 | 
			
		||||
	Short:   "Display all services of an app",
 | 
			
		||||
	Args:    cobra.ExactArgs(1),
 | 
			
		||||
@ -30,7 +30,7 @@ var AppServicesCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -63,7 +63,7 @@ var AppServicesCommand = &cobra.Command{
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		headers := []string{"SERVICE (SHORT)", "SERVICE (LONG)", "IMAGE"}
 | 
			
		||||
		headers := []string{"SERVICE (SHORT)", "SERVICE (LONG)"}
 | 
			
		||||
		table.Headers(headers...)
 | 
			
		||||
 | 
			
		||||
		var rows [][]string
 | 
			
		||||
@ -80,7 +80,6 @@ var AppServicesCommand = &cobra.Command{
 | 
			
		||||
			row := []string{
 | 
			
		||||
				serviceShortName,
 | 
			
		||||
				serviceLongName,
 | 
			
		||||
				formatter.RemoveSha(container.Image),
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			rows = append(rows, row)
 | 
			
		||||
@ -89,7 +88,9 @@ var AppServicesCommand = &cobra.Command{
 | 
			
		||||
		table.Rows(rows...)
 | 
			
		||||
 | 
			
		||||
		if len(rows) > 0 {
 | 
			
		||||
			fmt.Println(table)
 | 
			
		||||
			if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -18,10 +18,10 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppUndeployCommand = &cobra.Command{
 | 
			
		||||
	Use:     "undeploy <app> [flags]",
 | 
			
		||||
	Use:     "undeploy <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"un"},
 | 
			
		||||
	Short:   "Undeploy an app",
 | 
			
		||||
	Long: `This does not destroy any of the application data.
 | 
			
		||||
	Long: `This does not destroy any application data.
 | 
			
		||||
 | 
			
		||||
However, you should remain vigilant, as your swarm installation will consider
 | 
			
		||||
any previously attached volumes as eligible for pruning once undeployed.
 | 
			
		||||
@ -54,17 +54,36 @@ Passing "--prune/-p" does not remove those volumes.`,
 | 
			
		||||
			log.Fatalf("%s is not deployed?", app.Name)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		chaosVersion := config.CHAOS_DEFAULT
 | 
			
		||||
		if deployMeta.IsChaos {
 | 
			
		||||
			chaosVersion = deployMeta.ChaosVersion
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := internal.DeployOverview(app, []string{}, deployMeta.Version, chaosVersion); err != nil {
 | 
			
		||||
		if err := internal.DeployOverview(
 | 
			
		||||
			app,
 | 
			
		||||
			deployMeta.Version,
 | 
			
		||||
			config.NO_DOMAIN_DEFAULT,
 | 
			
		||||
			"",
 | 
			
		||||
			nil,
 | 
			
		||||
		); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		composeFiles, err := app.Recipe.GetComposeFiles(app.Env)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		opts := stack.Deploy{Composefiles: composeFiles, Namespace: stackName}
 | 
			
		||||
		compose, err := appPkg.GetAppComposeConfig(app.Name, opts, app.Env)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		stack.WaitTimeout, err = appPkg.GetTimeoutFromLabel(compose, stackName)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Info("initialising undeploy")
 | 
			
		||||
 | 
			
		||||
		rmOpts := stack.Remove{
 | 
			
		||||
			Namespaces: []string{app.StackName()},
 | 
			
		||||
			Namespaces: []string{stackName},
 | 
			
		||||
			Detach:     false,
 | 
			
		||||
		}
 | 
			
		||||
		if err := stack.RunRemove(context.Background(), cl, rmOpts); err != nil {
 | 
			
		||||
@ -76,6 +95,12 @@ Passing "--prune/-p" does not remove those volumes.`,
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Info("undeploy succeeded 🟢")
 | 
			
		||||
 | 
			
		||||
		if err := app.WriteRecipeVersion(deployMeta.Version, false); err != nil {
 | 
			
		||||
			log.Fatalf("writing recipe version failed: %s", err)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,45 +3,58 @@ package app
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/app"
 | 
			
		||||
	appPkg "coopcloud.tech/abra/pkg/app"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/client"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/config"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/envfile"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/lint"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/recipe"
 | 
			
		||||
	stack "coopcloud.tech/abra/pkg/upstream/stack"
 | 
			
		||||
	"coopcloud.tech/tagcmp"
 | 
			
		||||
	"github.com/AlecAivazis/survey/v2"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppUpgradeCommand = &cobra.Command{
 | 
			
		||||
	Use:     "upgrade <app> [version] [flags]",
 | 
			
		||||
	Use:     "upgrade <domain> [version] [flags]",
 | 
			
		||||
	Aliases: []string{"up"},
 | 
			
		||||
	Short:   "Upgrade an app",
 | 
			
		||||
	Long: `Upgrade an app.
 | 
			
		||||
 | 
			
		||||
Unlike "deploy", chaos operations are not supported here. Only recipe versions
 | 
			
		||||
are supported values for "[version]".
 | 
			
		||||
Unlike "abra app deploy", chaos operations are not supported here. Only recipe
 | 
			
		||||
versions are supported values for "[version]".
 | 
			
		||||
 | 
			
		||||
It is possible to "--force/-f" an upgrade if you want to re-deploy a specific
 | 
			
		||||
version.
 | 
			
		||||
 | 
			
		||||
Only the deployed version is consulted when trying to determine what upgrades
 | 
			
		||||
are available. The live deployment version is the "source of truth" in this
 | 
			
		||||
case. The stored .env version is not consulted.
 | 
			
		||||
 | 
			
		||||
An upgrade can be destructive, please ensure you have a copy of your app data
 | 
			
		||||
beforehand.`,
 | 
			
		||||
beforehand. See "abra app backup" for more.`,
 | 
			
		||||
	Args: cobra.RangeArgs(1, 2),
 | 
			
		||||
	ValidArgsFunction: func(
 | 
			
		||||
		cmd *cobra.Command,
 | 
			
		||||
		args []string,
 | 
			
		||||
		toComplete string) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
		toComplete string,
 | 
			
		||||
	) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
		switch l := len(args); l {
 | 
			
		||||
		case 0:
 | 
			
		||||
			return autocomplete.AppNameComplete()
 | 
			
		||||
		case 1:
 | 
			
		||||
			app, err := appPkg.Get(args[0])
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
				return nil, cobra.ShellCompDirectiveDefault
 | 
			
		||||
				errMsg := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
				return []string{errMsg}, cobra.ShellCompDirectiveError
 | 
			
		||||
			}
 | 
			
		||||
			return autocomplete.RecipeVersionComplete(app.Recipe.Name)
 | 
			
		||||
		default:
 | 
			
		||||
@ -49,22 +62,32 @@ beforehand.`,
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		var warnMessages []string
 | 
			
		||||
		var (
 | 
			
		||||
			upgradeWarnMessages []string
 | 
			
		||||
			chosenUpgrade       string
 | 
			
		||||
			availableUpgrades   []string
 | 
			
		||||
			upgradeReleaseNotes string
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		app := internal.ValidateApp(args)
 | 
			
		||||
		stackName := app.StackName()
 | 
			
		||||
 | 
			
		||||
		var specificVersion string
 | 
			
		||||
		if len(args) == 2 {
 | 
			
		||||
			specificVersion = args[1]
 | 
			
		||||
		if err := app.Recipe.Ensure(recipe.EnsureContext{
 | 
			
		||||
			Chaos:   internal.Chaos,
 | 
			
		||||
			Offline: internal.Offline,
 | 
			
		||||
			// Ignore the env version for now, to make sure we are at the latest commit.
 | 
			
		||||
			// This enables us to get release notes, that were added after a release.
 | 
			
		||||
			IgnoreEnvVersion: true,
 | 
			
		||||
		}); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if specificVersion != "" {
 | 
			
		||||
			log.Debugf("overriding env file version (%s) with %s", app.Recipe.Version, specificVersion)
 | 
			
		||||
			app.Recipe.Version = specificVersion
 | 
			
		||||
		cl, err := client.New(app.Server)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := app.Recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		deployMeta, err := ensureDeployed(cl, app)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -72,134 +95,68 @@ beforehand.`,
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("checking whether %s is already deployed", stackName)
 | 
			
		||||
 | 
			
		||||
		cl, err := client.New(app.Server)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		deployMeta, err := stack.IsDeployed(context.Background(), cl, stackName)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !deployMeta.IsDeployed {
 | 
			
		||||
			log.Fatalf("%s is not deployed?", app.Name)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		versions, err := app.Recipe.Tags()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var availableUpgrades []string
 | 
			
		||||
		if deployMeta.Version == "unknown" {
 | 
			
		||||
		// NOTE(d1): we've no idea what the live deployment version is, so every
 | 
			
		||||
		// possible upgrade can be shown. it's up to the user to make the choice
 | 
			
		||||
		if deployMeta.Version == config.UNKNOWN_DEFAULT {
 | 
			
		||||
			availableUpgrades = versions
 | 
			
		||||
			warnMessages = append(warnMessages, fmt.Sprintf("failed to determine deployed version of %s", app.Name))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if specificVersion != "" {
 | 
			
		||||
			parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | 
			
		||||
		if len(args) == 2 && args[1] != "" {
 | 
			
		||||
			chosenUpgrade = args[1]
 | 
			
		||||
 | 
			
		||||
			if err := validateUpgradeVersionArg(chosenUpgrade, app, deployMeta); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			availableUpgrades = append(availableUpgrades, chosenUpgrade)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if deployMeta.Version != config.UNKNOWN_DEFAULT && chosenUpgrade == "" {
 | 
			
		||||
			upgradeAvailable, err := ensureUpgradesAvailable(versions, &availableUpgrades, deployMeta)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatalf("'%s' is not a known version for %s", deployMeta.Version, app.Recipe.Name)
 | 
			
		||||
			}
 | 
			
		||||
			parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatalf("'%s' is not a known version for %s", specificVersion, app.Recipe.Name)
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if parsedSpecificVersion.IsLessThan(parsedDeployedVersion) && !parsedSpecificVersion.Equals(parsedDeployedVersion) {
 | 
			
		||||
				log.Fatalf("%s is not an upgrade for %s?", deployMeta.Version, specificVersion)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
 | 
			
		||||
				log.Fatalf("%s is not an upgrade for %s?", deployMeta.Version, specificVersion)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			availableUpgrades = append(availableUpgrades, specificVersion)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if deployMeta.Version != "unknown" && specificVersion == "" {
 | 
			
		||||
			if deployMeta.IsChaos {
 | 
			
		||||
				warnMessages = append(warnMessages, fmt.Sprintf("attempting to upgrade a chaos deployment"))
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			for _, version := range versions {
 | 
			
		||||
				parsedVersion, err := tagcmp.Parse(version)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
				if parsedVersion.IsGreaterThan(parsedDeployedVersion) && !(parsedVersion.Equals(parsedDeployedVersion)) {
 | 
			
		||||
					availableUpgrades = append(availableUpgrades, version)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if len(availableUpgrades) == 0 && !internal.Force {
 | 
			
		||||
			if !upgradeAvailable {
 | 
			
		||||
				log.Info("no available upgrades")
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var chosenUpgrade string
 | 
			
		||||
		if len(availableUpgrades) > 0 {
 | 
			
		||||
			if internal.Force || internal.NoInput || specificVersion != "" {
 | 
			
		||||
		if internal.Force || internal.NoInput || chosenUpgrade != "" {
 | 
			
		||||
			if len(availableUpgrades) > 0 {
 | 
			
		||||
				chosenUpgrade = availableUpgrades[len(availableUpgrades)-1]
 | 
			
		||||
				log.Debugf("choosing %s as version to upgrade to", chosenUpgrade)
 | 
			
		||||
			} else {
 | 
			
		||||
				msg := fmt.Sprintf("please select an upgrade (version: %s):", deployMeta.Version)
 | 
			
		||||
				if deployMeta.IsChaos {
 | 
			
		||||
					msg = fmt.Sprintf("please select an upgrade (version: %s, chaosVersion: %s):", deployMeta.Version, deployMeta.ChaosVersion)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				prompt := &survey.Select{
 | 
			
		||||
					Message: msg,
 | 
			
		||||
					Options: internal.ReverseStringList(availableUpgrades),
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if err := survey.AskOne(prompt, &chosenUpgrade); err != nil {
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		} else {
 | 
			
		||||
			if err := chooseUpgrade(availableUpgrades, deployMeta, &chosenUpgrade); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if internal.Force && chosenUpgrade == "" {
 | 
			
		||||
			warnMessages = append(warnMessages, fmt.Sprintf("%s is already upgraded to latest", app.Name))
 | 
			
		||||
		if internal.Force &&
 | 
			
		||||
			chosenUpgrade == "" &&
 | 
			
		||||
			deployMeta.Version != config.UNKNOWN_DEFAULT {
 | 
			
		||||
			chosenUpgrade = deployMeta.Version
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// if release notes written after git tag published, read them before we
 | 
			
		||||
		// check out the tag and then they'll appear to be missing. this covers
 | 
			
		||||
		// when we obviously will forget to write release notes before publishing
 | 
			
		||||
		var releaseNotes string
 | 
			
		||||
		if chosenUpgrade != "" {
 | 
			
		||||
			parsedChosenUpgrade, err := tagcmp.Parse(chosenUpgrade)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
			for _, version := range versions {
 | 
			
		||||
				parsedVersion, err := tagcmp.Parse(version)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatal(err)
 | 
			
		||||
				}
 | 
			
		||||
				if parsedVersion.IsGreaterThan(parsedDeployedVersion) && parsedVersion.IsLessThan(parsedChosenUpgrade) {
 | 
			
		||||
					note, err := app.Recipe.GetReleaseNotes(version)
 | 
			
		||||
					if err != nil {
 | 
			
		||||
						log.Fatal(err)
 | 
			
		||||
					}
 | 
			
		||||
					if note != "" {
 | 
			
		||||
						releaseNotes += fmt.Sprintf("%s\n", note)
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		if chosenUpgrade == "" {
 | 
			
		||||
			log.Fatal("unknown deployed version, unable to upgrade")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("choosing %s as version to upgrade", chosenUpgrade)
 | 
			
		||||
 | 
			
		||||
		// Get the release notes before checking out the new version in the
 | 
			
		||||
		// recipe. This enables us to get release notes, that were added after
 | 
			
		||||
		// a release.
 | 
			
		||||
		if err := getReleaseNotes(app, versions, chosenUpgrade, deployMeta, &upgradeReleaseNotes); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if _, err := app.Recipe.EnsureVersion(chosenUpgrade); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
@ -217,6 +174,7 @@ beforehand.`,
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		stackName := app.StackName()
 | 
			
		||||
		deployOpts := stack.Deploy{
 | 
			
		||||
			Composefiles: composeFiles,
 | 
			
		||||
			Namespace:    stackName,
 | 
			
		||||
@ -233,7 +191,9 @@ beforehand.`,
 | 
			
		||||
		appPkg.ExposeAllEnv(stackName, compose, app.Env)
 | 
			
		||||
		appPkg.SetRecipeLabel(compose, stackName, app.Recipe.Name)
 | 
			
		||||
		appPkg.SetChaosLabel(compose, stackName, internal.Chaos)
 | 
			
		||||
		appPkg.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
 | 
			
		||||
		if internal.Chaos {
 | 
			
		||||
			appPkg.SetChaosVersionLabel(compose, stackName, chosenUpgrade)
 | 
			
		||||
		}
 | 
			
		||||
		appPkg.SetUpdateLabel(compose, stackName, app.Env)
 | 
			
		||||
 | 
			
		||||
		envVars, err := appPkg.CheckEnv(app)
 | 
			
		||||
@ -243,31 +203,31 @@ beforehand.`,
 | 
			
		||||
 | 
			
		||||
		for _, envVar := range envVars {
 | 
			
		||||
			if !envVar.Present {
 | 
			
		||||
				warnMessages = append(warnMessages,
 | 
			
		||||
					fmt.Sprintf("env var %s missing from %s.env, present in recipe .env.sample", envVar.Name, app.Domain),
 | 
			
		||||
				upgradeWarnMessages = append(upgradeWarnMessages,
 | 
			
		||||
					fmt.Sprintf("%s missing from %s.env", envVar.Name, app.Domain),
 | 
			
		||||
				)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if showReleaseNotes {
 | 
			
		||||
			fmt.Println()
 | 
			
		||||
			fmt.Print(releaseNotes)
 | 
			
		||||
			fmt.Print(upgradeReleaseNotes)
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		chaosVersion := config.CHAOS_DEFAULT
 | 
			
		||||
		if deployMeta.IsChaos {
 | 
			
		||||
			chaosVersion = deployMeta.ChaosVersion
 | 
			
		||||
		if upgradeReleaseNotes == "" {
 | 
			
		||||
			upgradeWarnMessages = append(
 | 
			
		||||
				upgradeWarnMessages,
 | 
			
		||||
				fmt.Sprintf("no release notes available for %s", chosenUpgrade),
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := internal.NewVersionOverview(
 | 
			
		||||
		if err := internal.DeployOverview(
 | 
			
		||||
			app,
 | 
			
		||||
			warnMessages,
 | 
			
		||||
			"upgrade",
 | 
			
		||||
			deployMeta.Version,
 | 
			
		||||
			chaosVersion,
 | 
			
		||||
			chosenUpgrade,
 | 
			
		||||
			releaseNotes); err != nil {
 | 
			
		||||
			upgradeReleaseNotes,
 | 
			
		||||
			upgradeWarnMessages,
 | 
			
		||||
		); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -275,31 +235,196 @@ beforehand.`,
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
		log.Debugf("set waiting timeout to %d s", stack.WaitTimeout)
 | 
			
		||||
 | 
			
		||||
		if err := stack.RunDeploy(cl, deployOpts, compose, stackName, internal.DontWaitConverge); err != nil {
 | 
			
		||||
		log.Debugf("set waiting timeout to %d second(s)", stack.WaitTimeout)
 | 
			
		||||
 | 
			
		||||
		serviceNames, err := appPkg.GetAppServiceNames(app.Name)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		f, err := app.Filters(true, false, serviceNames...)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := stack.RunDeploy(
 | 
			
		||||
			cl,
 | 
			
		||||
			deployOpts,
 | 
			
		||||
			compose,
 | 
			
		||||
			stackName,
 | 
			
		||||
			app.Server,
 | 
			
		||||
			internal.DontWaitConverge,
 | 
			
		||||
			f,
 | 
			
		||||
		); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		postDeployCmds, ok := app.Env["POST_UPGRADE_CMDS"]
 | 
			
		||||
		if ok && !internal.DontWaitConverge {
 | 
			
		||||
			log.Debugf("run the following post-deploy commands: %s", postDeployCmds)
 | 
			
		||||
 | 
			
		||||
			if err := internal.PostCmds(cl, app, postDeployCmds); err != nil {
 | 
			
		||||
				log.Fatalf("attempting to run post deploy commands, saw: %s", err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		app.Recipe.Version = chosenUpgrade
 | 
			
		||||
		log.Debugf("choosing %s as version to save to env file", app.Recipe.Version)
 | 
			
		||||
		if err := app.WriteRecipeVersion(app.Recipe.Version, false); err != nil {
 | 
			
		||||
			log.Fatalf("writing new recipe version in env file: %s", err)
 | 
			
		||||
		if err := app.WriteRecipeVersion(chosenUpgrade, false); err != nil {
 | 
			
		||||
			log.Fatalf("writing recipe version failed: %s", err)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	showReleaseNotes bool
 | 
			
		||||
)
 | 
			
		||||
// chooseUpgrade prompts the user to choose an upgrade interactively.
 | 
			
		||||
func chooseUpgrade(
 | 
			
		||||
	availableUpgrades []string,
 | 
			
		||||
	deployMeta stack.DeployMeta,
 | 
			
		||||
	chosenUpgrade *string,
 | 
			
		||||
) error {
 | 
			
		||||
	msg := fmt.Sprintf("please select an upgrade (version: %s):", deployMeta.Version)
 | 
			
		||||
 | 
			
		||||
	if deployMeta.IsChaos {
 | 
			
		||||
		chaosVersion := formatter.BoldDirtyDefault(deployMeta.ChaosVersion)
 | 
			
		||||
 | 
			
		||||
		msg = fmt.Sprintf(
 | 
			
		||||
			"please select an upgrade (version: %s, chaos: %s):",
 | 
			
		||||
			deployMeta.Version,
 | 
			
		||||
			chaosVersion,
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	prompt := &survey.Select{
 | 
			
		||||
		Message: msg,
 | 
			
		||||
		Options: internal.SortVersionsDesc(availableUpgrades),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := survey.AskOne(prompt, chosenUpgrade); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getReleaseNotes(
 | 
			
		||||
	app app.App,
 | 
			
		||||
	versions []string,
 | 
			
		||||
	chosenUpgrade string,
 | 
			
		||||
	deployMeta stack.DeployMeta,
 | 
			
		||||
	upgradeReleaseNotes *string,
 | 
			
		||||
) error {
 | 
			
		||||
	parsedChosenUpgrade, err := tagcmp.Parse(chosenUpgrade)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, version := range internal.SortVersionsDesc(versions) {
 | 
			
		||||
		parsedVersion, err := tagcmp.Parse(version)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
 | 
			
		||||
			parsedVersion.IsLessThan(parsedChosenUpgrade) {
 | 
			
		||||
			note, err := app.Recipe.GetReleaseNotes(version)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if note != "" {
 | 
			
		||||
				// NOTE(d1): trim any final newline on the end of the note itself before
 | 
			
		||||
				//           we manually handle newlines (for multiple release notes and
 | 
			
		||||
				//           ensuring space between the warning messages)
 | 
			
		||||
				note = strings.TrimSuffix(note, "\n")
 | 
			
		||||
 | 
			
		||||
				*upgradeReleaseNotes += fmt.Sprintf("%s\n", note)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ensureUpgradesAvailable ensures that there are available upgrades.
 | 
			
		||||
func ensureUpgradesAvailable(
 | 
			
		||||
	versions []string,
 | 
			
		||||
	availableUpgrades *[]string,
 | 
			
		||||
	deployMeta stack.DeployMeta,
 | 
			
		||||
) (bool, error) {
 | 
			
		||||
	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, version := range versions {
 | 
			
		||||
		parsedVersion, err := tagcmp.Parse(version)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return false, err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if parsedVersion.IsGreaterThan(parsedDeployedVersion) &&
 | 
			
		||||
			!(parsedVersion.Equals(parsedDeployedVersion)) {
 | 
			
		||||
			*availableUpgrades = append(*availableUpgrades, version)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(*availableUpgrades) == 0 && !internal.Force {
 | 
			
		||||
		return false, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return true, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// validateUpgradeVersionArg validates the specific version.
 | 
			
		||||
func validateUpgradeVersionArg(
 | 
			
		||||
	specificVersion string,
 | 
			
		||||
	app app.App,
 | 
			
		||||
	deployMeta stack.DeployMeta,
 | 
			
		||||
) error {
 | 
			
		||||
	parsedSpecificVersion, err := tagcmp.Parse(specificVersion)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("'%s' is not a known version for %s", specificVersion, app.Recipe.Name)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	parsedDeployedVersion, err := tagcmp.Parse(deployMeta.Version)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("'%s' is not a known version", deployMeta.Version)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if parsedSpecificVersion.IsLessThan(parsedDeployedVersion) &&
 | 
			
		||||
		!parsedSpecificVersion.Equals(parsedDeployedVersion) {
 | 
			
		||||
		return fmt.Errorf("%s is not an upgrade for %s?", deployMeta.Version, specificVersion)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if parsedSpecificVersion.Equals(parsedDeployedVersion) && !internal.Force {
 | 
			
		||||
		return fmt.Errorf("%s is not an upgrade for %s?", deployMeta.Version, specificVersion)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ensureDeployed ensures the app is deployed and if so, returns deployment
 | 
			
		||||
// meta info.
 | 
			
		||||
func ensureDeployed(cl *dockerClient.Client, app app.App) (stack.DeployMeta, error) {
 | 
			
		||||
	log.Debugf("checking whether %s is already deployed", app.StackName())
 | 
			
		||||
 | 
			
		||||
	deployMeta, err := stack.IsDeployed(context.Background(), cl, app.StackName())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return stack.DeployMeta{}, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !deployMeta.IsDeployed {
 | 
			
		||||
		return stack.DeployMeta{}, fmt.Errorf("%s is not deployed?", app.Name)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return deployMeta, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var showReleaseNotes bool
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	AppUpgradeCommand.Flags().BoolVarP(
 | 
			
		||||
@ -322,7 +447,7 @@ func init() {
 | 
			
		||||
		&internal.DontWaitConverge, "no-converge-checks",
 | 
			
		||||
		"c",
 | 
			
		||||
		false,
 | 
			
		||||
		"do not wait for converge logic checks",
 | 
			
		||||
		"disable converge logic checks",
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	AppUpgradeCommand.Flags().BoolVarP(
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,6 @@ package app
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
@ -15,7 +14,7 @@ import (
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var AppVolumeListCommand = &cobra.Command{
 | 
			
		||||
	Use:     "list <app> [flags]",
 | 
			
		||||
	Use:     "list <domain> [flags]",
 | 
			
		||||
	Aliases: []string{"ls"},
 | 
			
		||||
	Short:   "List volumes associated with an app",
 | 
			
		||||
	Args:    cobra.ExactArgs(1),
 | 
			
		||||
@ -43,7 +42,7 @@ var AppVolumeListCommand = &cobra.Command{
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		headers := []string{"name", "created", "mounted"}
 | 
			
		||||
		headers := []string{"NAME", "ON SERVER"}
 | 
			
		||||
 | 
			
		||||
		table, err := formatter.CreateTable()
 | 
			
		||||
		if err != nil {
 | 
			
		||||
@ -54,14 +53,16 @@ var AppVolumeListCommand = &cobra.Command{
 | 
			
		||||
 | 
			
		||||
		var rows [][]string
 | 
			
		||||
		for _, volume := range volumes {
 | 
			
		||||
			row := []string{volume.Name, volume.CreatedAt, volume.Mountpoint}
 | 
			
		||||
			row := []string{volume.Name, volume.Mountpoint}
 | 
			
		||||
			rows = append(rows, row)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		table.Rows(rows...)
 | 
			
		||||
 | 
			
		||||
		if len(rows) > 0 {
 | 
			
		||||
			fmt.Println(table)
 | 
			
		||||
			if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -70,12 +71,12 @@ var AppVolumeListCommand = &cobra.Command{
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var AppVolumeRemoveCommand = &cobra.Command{
 | 
			
		||||
	Use:   "remove <app> [flags]",
 | 
			
		||||
	Use:   "remove <domain> [flags]",
 | 
			
		||||
	Short: "Remove volume(s) associated with an app",
 | 
			
		||||
	Long: `Remove volumes associated with an app.
 | 
			
		||||
 | 
			
		||||
The app in question must be undeployed before you try to remove volumes. See
 | 
			
		||||
"abra app undeploy <app>" for more.
 | 
			
		||||
"abra app undeploy <domain>" for more.
 | 
			
		||||
 | 
			
		||||
The command is interactive and will show a multiple select input which allows
 | 
			
		||||
you to make a seclection. Use the "?" key to see more help on navigating this
 | 
			
		||||
 | 
			
		||||
@ -5,6 +5,7 @@ import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io/ioutil"
 | 
			
		||||
	"path"
 | 
			
		||||
	"slices"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
@ -24,12 +25,17 @@ var CatalogueGenerateCommand = &cobra.Command{
 | 
			
		||||
	Short:   "Generate the recipe catalogue",
 | 
			
		||||
	Long: `Generate a new copy of the recipe catalogue.
 | 
			
		||||
 | 
			
		||||
N.B. this command **will** wipe local unstaged changes from your local recipes
 | 
			
		||||
if present. "--chaos/-C" on this command refers to the catalogue repository
 | 
			
		||||
("$ABRA_DIR/catalogue") and not the recipes. Please take care not to lose your
 | 
			
		||||
changes.
 | 
			
		||||
 | 
			
		||||
It is possible to generate new metadata for a single recipe by passing
 | 
			
		||||
[recipe]. The existing local catalogue will be updated, not overwritten.
 | 
			
		||||
 | 
			
		||||
It is quite easy to get rate limited by Docker Hub when running this command.
 | 
			
		||||
If you have a Hub account you can have Abra log you in to avoid this. Pass
 | 
			
		||||
"--user" and "--pass".
 | 
			
		||||
If you have a Hub account you can "docker login" and Abra will automatically
 | 
			
		||||
use those details.
 | 
			
		||||
 | 
			
		||||
Push your new release to git.coopcloud.tech with "--publish/-p". This requires
 | 
			
		||||
that you have permission to git push to these repositories and have your SSH
 | 
			
		||||
@ -47,56 +53,62 @@ keys configured on your account.`,
 | 
			
		||||
			recipeName = args[0]
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		r := recipe.Get(recipeName)
 | 
			
		||||
 | 
			
		||||
		if recipeName != "" {
 | 
			
		||||
			internal.ValidateRecipe(args, cmd.Name())
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := catalogue.EnsureCatalogue(); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !internal.Chaos {
 | 
			
		||||
			if err := catalogue.EnsureIsClean(); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		repos, err := recipe.ReadReposMetadata()
 | 
			
		||||
		repos, err := recipe.ReadReposMetadata(internal.Debug)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var barLength int
 | 
			
		||||
		var logMsg string
 | 
			
		||||
		barLength := len(repos)
 | 
			
		||||
		if recipeName != "" {
 | 
			
		||||
			barLength = 1
 | 
			
		||||
			logMsg = fmt.Sprintf("ensuring %v recipe is cloned & up-to-date", barLength)
 | 
			
		||||
		} else {
 | 
			
		||||
			barLength = len(repos)
 | 
			
		||||
			logMsg = fmt.Sprintf("ensuring %v recipes are cloned & up-to-date, this could take some time...", barLength)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !skipUpdates {
 | 
			
		||||
			log.Warn(logMsg)
 | 
			
		||||
			if err := recipe.UpdateRepositories(repos, recipeName); err != nil {
 | 
			
		||||
			if err := recipe.UpdateRepositories(repos, recipeName, internal.Debug); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var warnings []string
 | 
			
		||||
		catl := make(recipe.RecipeCatalogue)
 | 
			
		||||
		catlBar := formatter.CreateProgressbar(barLength, "generating catalogue metadata...")
 | 
			
		||||
		catlBar := formatter.CreateProgressbar(barLength, "collecting catalogue metadata")
 | 
			
		||||
		for _, recipeMeta := range repos {
 | 
			
		||||
			if recipeName != "" && recipeName != recipeMeta.Name {
 | 
			
		||||
				catlBar.Add(1)
 | 
			
		||||
				if !internal.Debug {
 | 
			
		||||
					catlBar.Add(1)
 | 
			
		||||
				}
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			versions, err := r.GetRecipeVersions()
 | 
			
		||||
			r := recipe.Get(recipeMeta.Name)
 | 
			
		||||
			versions, warnMsgs, err := r.GetRecipeVersions()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Warn(err)
 | 
			
		||||
				warnings = append(warnings, err.Error())
 | 
			
		||||
			}
 | 
			
		||||
			if len(warnMsgs) > 0 {
 | 
			
		||||
				warnings = append(warnings, warnMsgs...)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			features, category, err := recipe.GetRecipeFeaturesAndCategory(r)
 | 
			
		||||
			features, category, warnMsgs, err := recipe.GetRecipeFeaturesAndCategory(r)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Warn(err)
 | 
			
		||||
				warnings = append(warnings, err.Error())
 | 
			
		||||
			}
 | 
			
		||||
			if len(warnMsgs) > 0 {
 | 
			
		||||
				warnings = append(warnings, warnMsgs...)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			catl[recipeMeta.Name] = recipe.RecipeMeta{
 | 
			
		||||
@ -112,7 +124,24 @@ keys configured on your account.`,
 | 
			
		||||
				Features:      features,
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			catlBar.Add(1)
 | 
			
		||||
			if !internal.Debug {
 | 
			
		||||
				catlBar.Add(1)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := catlBar.Close(); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var uniqueWarnings []string
 | 
			
		||||
		for _, w := range warnings {
 | 
			
		||||
			if !slices.Contains(uniqueWarnings, w) {
 | 
			
		||||
				uniqueWarnings = append(uniqueWarnings, w)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for _, warnMsg := range uniqueWarnings {
 | 
			
		||||
			log.Warn(warnMsg)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		recipesJSON, err := json.MarshalIndent(catl, "", "    ")
 | 
			
		||||
@ -142,7 +171,7 @@ keys configured on your account.`,
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Infof("generated new recipe catalogue in %s", config.RECIPES_JSON)
 | 
			
		||||
		log.Infof("generated recipe catalogue: %s", config.RECIPES_JSON)
 | 
			
		||||
 | 
			
		||||
		cataloguePath := path.Join(config.ABRA_DIR, "catalogue")
 | 
			
		||||
		if publishChanges {
 | 
			
		||||
@ -168,7 +197,7 @@ keys configured on your account.`,
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			sshURL := fmt.Sprintf(config.SSH_URL_TEMPLATE, config.CATALOGUE_JSON_REPO_NAME)
 | 
			
		||||
			sshURL := fmt.Sprintf(config.TOOLSHED_SSH_URL_TEMPLATE, config.CATALOGUE_JSON_REPO_NAME)
 | 
			
		||||
			if err := gitPkg.CreateRemote(repo, "origin-ssh", sshURL, internal.Dry); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
@ -12,17 +12,16 @@ var AutocompleteCommand = &cobra.Command{
 | 
			
		||||
	Long: `To load completions:
 | 
			
		||||
 | 
			
		||||
Bash:
 | 
			
		||||
 | 
			
		||||
  # Load autocompletion for the current Bash session
 | 
			
		||||
  $ source <(abra autocomplete bash)
 | 
			
		||||
 | 
			
		||||
  # To load autocompletion for each session, execute once:
 | 
			
		||||
  # Linux:
 | 
			
		||||
  $ abra autocomplete bash > /etc/bash_completion.d/abra
 | 
			
		||||
  $ abra autocomplete bash | sudo tee /etc/bash_completion.d/abra
 | 
			
		||||
  # macOS:
 | 
			
		||||
  $ abra autocomplete bash > $(brew --prefix)/etc/bash_completion.d/abra
 | 
			
		||||
  $ abra autocomplete bash | sudo tee $(brew --prefix)/etc/bash_completion.d/abra
 | 
			
		||||
 | 
			
		||||
Zsh:
 | 
			
		||||
 | 
			
		||||
  # If shell autocompletion is not already enabled in your environment,
 | 
			
		||||
  # you will need to enable it.  You can execute the following once:
 | 
			
		||||
 | 
			
		||||
@ -34,14 +33,12 @@ Zsh:
 | 
			
		||||
  # You will need to start a new shell for this setup to take effect.
 | 
			
		||||
 | 
			
		||||
fish:
 | 
			
		||||
 | 
			
		||||
  $ abra autocomplete fish | source
 | 
			
		||||
 | 
			
		||||
  # To load autocompletions for each session, execute once:
 | 
			
		||||
  $ abra autocomplete fish > ~/.config/fish/completions/abra.fish
 | 
			
		||||
 | 
			
		||||
PowerShell:
 | 
			
		||||
 | 
			
		||||
  PS> abra autocomplete powershell | Out-String | Invoke-Expression
 | 
			
		||||
 | 
			
		||||
  # To load autocompletions for every new session, run:
 | 
			
		||||
 | 
			
		||||
@ -12,6 +12,7 @@ import (
 | 
			
		||||
	"coopcloud.tech/abra/pkg/upstream/container"
 | 
			
		||||
	"github.com/docker/cli/cli/command"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	containertypes "github.com/docker/docker/api/types/container"
 | 
			
		||||
	"github.com/docker/docker/api/types/filters"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
)
 | 
			
		||||
@ -47,7 +48,7 @@ func RunBackupCmdRemote(
 | 
			
		||||
	backupCmd string,
 | 
			
		||||
	containerID string,
 | 
			
		||||
	execEnv []string) (io.Writer, error) {
 | 
			
		||||
	execBackupListOpts := types.ExecConfig{
 | 
			
		||||
	execBackupListOpts := containertypes.ExecOptions{
 | 
			
		||||
		AttachStderr: true,
 | 
			
		||||
		AttachStdin:  true,
 | 
			
		||||
		AttachStdout: true,
 | 
			
		||||
 | 
			
		||||
@ -2,9 +2,10 @@ package internal
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	// NOTE(d1): global
 | 
			
		||||
	Debug   bool
 | 
			
		||||
	NoInput bool
 | 
			
		||||
	Offline bool
 | 
			
		||||
	Debug            bool
 | 
			
		||||
	NoInput          bool
 | 
			
		||||
	Offline          bool
 | 
			
		||||
	IgnoreEnvVersion bool
 | 
			
		||||
 | 
			
		||||
	// NOTE(d1): sub-command specific
 | 
			
		||||
	Chaos            bool
 | 
			
		||||
 | 
			
		||||
@ -14,7 +14,7 @@ import (
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/upstream/container"
 | 
			
		||||
	"github.com/docker/cli/cli/command"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	containertypes "github.com/docker/docker/api/types/container"
 | 
			
		||||
	"github.com/docker/docker/api/types/filters"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
	"github.com/docker/docker/pkg/archive"
 | 
			
		||||
@ -24,7 +24,7 @@ import (
 | 
			
		||||
func RunCmdRemote(
 | 
			
		||||
	cl *dockerClient.Client,
 | 
			
		||||
	app appPkg.App,
 | 
			
		||||
	requestTTY bool,
 | 
			
		||||
	disableTTY bool,
 | 
			
		||||
	abraSh, serviceName, cmdName, cmdArgs, remoteUser string) error {
 | 
			
		||||
	filters := filters.NewArgs()
 | 
			
		||||
	filters.Add("name", fmt.Sprintf("^%s_%s", app.StackName(), serviceName))
 | 
			
		||||
@ -42,7 +42,7 @@ func RunCmdRemote(
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	copyOpts := types.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
 | 
			
		||||
	copyOpts := containertypes.CopyToContainerOptions{AllowOverwriteDirWithFile: false, CopyUIDGID: false}
 | 
			
		||||
	if err := cl.CopyToContainer(context.Background(), targetContainer.ID, "/tmp", content, copyOpts); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
@ -55,7 +55,7 @@ func RunCmdRemote(
 | 
			
		||||
 | 
			
		||||
	shell := "/bin/bash"
 | 
			
		||||
	findShell := []string{"test", "-e", shell}
 | 
			
		||||
	execCreateOpts := types.ExecConfig{
 | 
			
		||||
	execCreateOpts := containertypes.ExecOptions{
 | 
			
		||||
		AttachStderr: true,
 | 
			
		||||
		AttachStdin:  true,
 | 
			
		||||
		AttachStdout: true,
 | 
			
		||||
@ -84,8 +84,10 @@ func RunCmdRemote(
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	execCreateOpts.Cmd = cmd
 | 
			
		||||
	execCreateOpts.Tty = requestTTY
 | 
			
		||||
	if !requestTTY {
 | 
			
		||||
 | 
			
		||||
	execCreateOpts.Tty = true
 | 
			
		||||
	if disableTTY {
 | 
			
		||||
		execCreateOpts.Tty = false
 | 
			
		||||
		log.Debugf("not requesting a remote TTY")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,10 +3,14 @@ package internal
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	appPkg "coopcloud.tech/abra/pkg/app"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/config"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/tagcmp"
 | 
			
		||||
	"github.com/AlecAivazis/survey/v2"
 | 
			
		||||
	"github.com/charmbracelet/lipgloss"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
@ -20,7 +24,8 @@ var borderStyle = lipgloss.NewStyle().
 | 
			
		||||
 | 
			
		||||
var headerStyle = lipgloss.NewStyle().
 | 
			
		||||
	Underline(true).
 | 
			
		||||
	Bold(true)
 | 
			
		||||
	Bold(true).
 | 
			
		||||
	PaddingBottom(1)
 | 
			
		||||
 | 
			
		||||
var leftStyle = lipgloss.NewStyle().
 | 
			
		||||
	Bold(true)
 | 
			
		||||
@ -32,18 +37,21 @@ func horizontal(left, mid, right string) string {
 | 
			
		||||
	return lipgloss.JoinHorizontal(lipgloss.Left, left, mid, right)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// NewVersionOverview shows an upgrade or downgrade overview
 | 
			
		||||
func NewVersionOverview(
 | 
			
		||||
func formatComposeFiles(composeFiles string) string {
 | 
			
		||||
	return strings.ReplaceAll(composeFiles, ":", "\n")
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DeployOverview shows a deployment overview
 | 
			
		||||
func DeployOverview(
 | 
			
		||||
	app appPkg.App,
 | 
			
		||||
	deployedVersion string,
 | 
			
		||||
	toDeployVersion string,
 | 
			
		||||
	releaseNotes string,
 | 
			
		||||
	warnMessages []string,
 | 
			
		||||
	kind,
 | 
			
		||||
	currentVersion,
 | 
			
		||||
	chaosVersion,
 | 
			
		||||
	newVersion,
 | 
			
		||||
	releaseNotes string) error {
 | 
			
		||||
) error {
 | 
			
		||||
	deployConfig := "compose.yml"
 | 
			
		||||
	if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
 | 
			
		||||
		deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
 | 
			
		||||
		deployConfig = formatComposeFiles(composeFiles)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	server := app.Server
 | 
			
		||||
@ -51,32 +59,34 @@ func NewVersionOverview(
 | 
			
		||||
		server = "local"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	body := strings.Builder{}
 | 
			
		||||
	body.WriteString(
 | 
			
		||||
		borderStyle.Render(
 | 
			
		||||
			lipgloss.JoinVertical(
 | 
			
		||||
				lipgloss.Center,
 | 
			
		||||
				headerStyle.Render(fmt.Sprintf("%s OVERVIEW", strings.ToUpper(kind))),
 | 
			
		||||
				lipgloss.JoinVertical(
 | 
			
		||||
					lipgloss.Left,
 | 
			
		||||
					horizontal(leftStyle.Render("SERVER"), "   ", rightStyle.Render(server)),
 | 
			
		||||
					horizontal(leftStyle.Render("DOMAIN"), "   ", rightStyle.Render(app.Domain)),
 | 
			
		||||
					horizontal(leftStyle.Render("RECIPE"), "   ", rightStyle.Render(app.Recipe.Name)),
 | 
			
		||||
					horizontal(leftStyle.Render("CONFIG"), "   ", rightStyle.Render(deployConfig)),
 | 
			
		||||
					horizontal(leftStyle.Render("VERSION"), "  ", rightStyle.Render(currentVersion)),
 | 
			
		||||
					horizontal(leftStyle.Render("CHAOS"), "    ", rightStyle.Render(chaosVersion)),
 | 
			
		||||
					horizontal(leftStyle.Render("DEPLOY"), "   ", rightStyle.Padding(0).Render(newVersion)),
 | 
			
		||||
				),
 | 
			
		||||
			),
 | 
			
		||||
		),
 | 
			
		||||
	)
 | 
			
		||||
	fmt.Println(body.String())
 | 
			
		||||
	domain := app.Domain
 | 
			
		||||
	if domain == "" {
 | 
			
		||||
		domain = config.NO_DOMAIN_DEFAULT
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if releaseNotes != "" && newVersion != "" {
 | 
			
		||||
		fmt.Println()
 | 
			
		||||
	envVersion := app.Recipe.EnvVersionRaw
 | 
			
		||||
	if envVersion == "" {
 | 
			
		||||
		envVersion = config.NO_VERSION_DEFAULT
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rows := [][]string{
 | 
			
		||||
		{"DOMAIN", domain},
 | 
			
		||||
		{"RECIPE", app.Recipe.Name},
 | 
			
		||||
		{"SERVER", server},
 | 
			
		||||
		{"CONFIG", deployConfig},
 | 
			
		||||
		{"", ""},
 | 
			
		||||
		{"CURRENT DEPLOYMENT", formatter.BoldDirtyDefault(deployedVersion)},
 | 
			
		||||
		{"ENV VERSION", formatter.BoldDirtyDefault(envVersion)},
 | 
			
		||||
		{"NEW DEPLOYMENT", formatter.BoldDirtyDefault(toDeployVersion)},
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	deployType := getDeployType(deployedVersion, toDeployVersion)
 | 
			
		||||
	overview := formatter.CreateOverview(fmt.Sprintf("%s OVERVIEW", deployType), rows)
 | 
			
		||||
 | 
			
		||||
	fmt.Println(overview)
 | 
			
		||||
 | 
			
		||||
	if releaseNotes != "" {
 | 
			
		||||
		fmt.Print(releaseNotes)
 | 
			
		||||
	} else {
 | 
			
		||||
		warnMessages = append(warnMessages, fmt.Sprintf("no release notes available for %s", newVersion))
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, msg := range warnMessages {
 | 
			
		||||
@ -100,57 +110,34 @@ func NewVersionOverview(
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// DeployOverview shows a deployment overview
 | 
			
		||||
func DeployOverview(app appPkg.App, warnMessages []string, version, chaosVersion string) error {
 | 
			
		||||
	deployConfig := "compose.yml"
 | 
			
		||||
	if composeFiles, ok := app.Env["COMPOSE_FILE"]; ok {
 | 
			
		||||
		deployConfig = strings.Join(strings.Split(composeFiles, ":"), "\n")
 | 
			
		||||
func getDeployType(currentVersion, newVersion string) string {
 | 
			
		||||
	if newVersion == config.NO_DOMAIN_DEFAULT {
 | 
			
		||||
		return "UNDEPLOY"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	server := app.Server
 | 
			
		||||
	if app.Server == "default" {
 | 
			
		||||
		server = "local"
 | 
			
		||||
	if strings.Contains(newVersion, "+U") {
 | 
			
		||||
		return "CHAOS DEPLOY"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	body := strings.Builder{}
 | 
			
		||||
	body.WriteString(
 | 
			
		||||
		borderStyle.Render(
 | 
			
		||||
			lipgloss.JoinVertical(
 | 
			
		||||
				lipgloss.Center,
 | 
			
		||||
				headerStyle.Render("DEPLOY OVERVIEW"),
 | 
			
		||||
				lipgloss.JoinVertical(
 | 
			
		||||
					lipgloss.Left,
 | 
			
		||||
					horizontal(leftStyle.Render("SERVER"), "   ", rightStyle.Render(server)),
 | 
			
		||||
					horizontal(leftStyle.Render("DOMAIN"), "   ", rightStyle.Render(app.Domain)),
 | 
			
		||||
					horizontal(leftStyle.Render("RECIPE"), "   ", rightStyle.Render(app.Recipe.Name)),
 | 
			
		||||
					horizontal(leftStyle.Render("CONFIG"), "   ", rightStyle.Render(deployConfig)),
 | 
			
		||||
					horizontal(leftStyle.Render("VERSION"), "  ", rightStyle.Render(version)),
 | 
			
		||||
					horizontal(leftStyle.Render("CHAOS"), "    ", rightStyle.Padding(0).Render(chaosVersion)),
 | 
			
		||||
				),
 | 
			
		||||
			),
 | 
			
		||||
		),
 | 
			
		||||
	)
 | 
			
		||||
	fmt.Println(body.String())
 | 
			
		||||
 | 
			
		||||
	for _, msg := range warnMessages {
 | 
			
		||||
		log.Warn(msg)
 | 
			
		||||
	if strings.Contains(currentVersion, "+U") {
 | 
			
		||||
		return "UNCHAOS DEPLOY"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if NoInput {
 | 
			
		||||
		return nil
 | 
			
		||||
	if currentVersion == newVersion {
 | 
			
		||||
		return "REDEPLOY"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	response := false
 | 
			
		||||
	prompt := &survey.Confirm{Message: "proceed?"}
 | 
			
		||||
	if err := survey.AskOne(prompt, &response); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	if currentVersion == config.NO_VERSION_DEFAULT {
 | 
			
		||||
		return "NEW DEPLOY"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !response {
 | 
			
		||||
		log.Fatal("deployment cancelled")
 | 
			
		||||
	currentParsed, err := tagcmp.Parse(currentVersion)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "DEPLOY"
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
	newParsed, err := tagcmp.Parse(newVersion)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "DEPLOY"
 | 
			
		||||
	}
 | 
			
		||||
	if currentParsed.IsLessThan(newParsed) {
 | 
			
		||||
		return "UPGRADE"
 | 
			
		||||
	}
 | 
			
		||||
	return "DOWNGRADE"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// PostCmds parses a string of commands and executes them inside of the respective services
 | 
			
		||||
@ -210,3 +197,22 @@ func PostCmds(cl *dockerClient.Client, app appPkg.App, commands string) error {
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SortVersionsDesc sorts versions in descending order.
 | 
			
		||||
func SortVersionsDesc(versions []string) []string {
 | 
			
		||||
	var tags []tagcmp.Tag
 | 
			
		||||
 | 
			
		||||
	for _, v := range versions {
 | 
			
		||||
		parsed, _ := tagcmp.Parse(v) // skips unsupported tags
 | 
			
		||||
		tags = append(tags, parsed)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sort.Sort(tagcmp.ByTagDesc(tags))
 | 
			
		||||
 | 
			
		||||
	var desc []string
 | 
			
		||||
	for _, t := range tags {
 | 
			
		||||
		desc = append(desc, t.String())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return desc
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										17
									
								
								cli/internal/deploy_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								cli/internal/deploy_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,17 @@
 | 
			
		||||
package internal
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestSortVersionsDesc(t *testing.T) {
 | 
			
		||||
	versions := SortVersionsDesc([]string{
 | 
			
		||||
		"0.2.3+1.2.2",
 | 
			
		||||
		"1.0.0+2.2.2",
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	assert.Equal(t, "1.0.0+2.2.2", versions[0])
 | 
			
		||||
	assert.Equal(t, "0.2.3+1.2.2", versions[1])
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										11
									
								
								cli/internal/ensure.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								cli/internal/ensure.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
			
		||||
package internal
 | 
			
		||||
 | 
			
		||||
import "coopcloud.tech/abra/pkg/recipe"
 | 
			
		||||
 | 
			
		||||
func GetEnsureContext() recipe.EnsureContext {
 | 
			
		||||
	return recipe.EnsureContext{
 | 
			
		||||
		Chaos,
 | 
			
		||||
		Offline,
 | 
			
		||||
		IgnoreEnvVersion,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -1,18 +0,0 @@
 | 
			
		||||
package internal
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"github.com/urfave/cli/v3"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// ShowSubcommandHelpAndError exits the program on error, logs the error to the
 | 
			
		||||
// terminal, and shows the help command.
 | 
			
		||||
func ShowSubcommandHelpAndError(cmd *cli.Command, err interface{}) {
 | 
			
		||||
	if err2 := cli.ShowSubcommandHelp(cmd); err2 != nil {
 | 
			
		||||
		log.Error(err2)
 | 
			
		||||
	}
 | 
			
		||||
	log.Error(err)
 | 
			
		||||
	os.Exit(1)
 | 
			
		||||
}
 | 
			
		||||
@ -1,10 +0,0 @@
 | 
			
		||||
package internal
 | 
			
		||||
 | 
			
		||||
// ReverseStringList reverses a list of a strings. Roll on Go generics.
 | 
			
		||||
func ReverseStringList(strings []string) []string {
 | 
			
		||||
	for i, j := 0, len(strings)-1; i < j; i, j = i+1, j-1 {
 | 
			
		||||
		strings[i], strings[j] = strings[j], strings[i]
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return strings
 | 
			
		||||
}
 | 
			
		||||
@ -1,20 +1,32 @@
 | 
			
		||||
package recipe
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"os"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/recipe"
 | 
			
		||||
	"github.com/go-git/go-git/v5"
 | 
			
		||||
	gitCfg "github.com/go-git/go-git/v5/config"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var RecipeFetchCommand = &cobra.Command{
 | 
			
		||||
	Use:     "fetch [recipe] [flags]",
 | 
			
		||||
	Use:     "fetch [recipe | --all] [flags]",
 | 
			
		||||
	Aliases: []string{"f"},
 | 
			
		||||
	Short:   "Fetch recipe(s)",
 | 
			
		||||
	Long:    "Retrieves all recipes if no [recipe] argument is passed.",
 | 
			
		||||
	Short:   "Clone recipe(s) locally",
 | 
			
		||||
	Long:    `Using "--force/-f" Git syncs an existing recipe. It does not erase unstaged changes.`,
 | 
			
		||||
	Args:    cobra.RangeArgs(0, 1),
 | 
			
		||||
	Example: `  # fetch from recipe catalogue
 | 
			
		||||
  abra recipe fetch gitea
 | 
			
		||||
 | 
			
		||||
  # fetch from remote recipe
 | 
			
		||||
  abra recipe fetch git.foo.org/recipes/myrecipe
 | 
			
		||||
 | 
			
		||||
  # fetch with ssh remote for hacking
 | 
			
		||||
  abra recipe fetch gitea --ssh`,
 | 
			
		||||
	ValidArgsFunction: func(
 | 
			
		||||
		cmd *cobra.Command,
 | 
			
		||||
		args []string,
 | 
			
		||||
@ -27,11 +39,49 @@ var RecipeFetchCommand = &cobra.Command{
 | 
			
		||||
			recipeName = args[0]
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if recipeName == "" && !fetchAllRecipes {
 | 
			
		||||
			log.Fatal("missing [recipe] or --all/-a")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if recipeName != "" && fetchAllRecipes {
 | 
			
		||||
			log.Fatal("cannot use [recipe] and --all/-a together")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		ensureCtx := internal.GetEnsureContext()
 | 
			
		||||
		if recipeName != "" {
 | 
			
		||||
			r := internal.ValidateRecipe(args, cmd.Name())
 | 
			
		||||
			if err := r.Ensure(false, false); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			r := recipe.Get(recipeName)
 | 
			
		||||
			if _, err := os.Stat(r.Dir); !os.IsNotExist(err) {
 | 
			
		||||
				if !force {
 | 
			
		||||
					log.Warnf("%s is already fetched", r.Name)
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			r = internal.ValidateRecipe(args, cmd.Name())
 | 
			
		||||
 | 
			
		||||
			if sshRemote {
 | 
			
		||||
				if r.SSHURL == "" {
 | 
			
		||||
					log.Warnf("unable to discover SSH remote for %s", r.Name)
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				repo, err := git.PlainOpen(r.Dir)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					log.Fatalf("unable to open %s: %s", r.Dir, err)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if err = repo.DeleteRemote("origin"); err != nil {
 | 
			
		||||
					log.Fatalf("unable to remove default remote in %s: %s", r.Dir, err)
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if _, err := repo.CreateRemote(&gitCfg.RemoteConfig{
 | 
			
		||||
					Name: "origin",
 | 
			
		||||
					URLs: []string{r.SSHURL},
 | 
			
		||||
				}); err != nil {
 | 
			
		||||
					log.Fatalf("unable to set SSH remote in %s: %s", r.Dir, err)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -43,10 +93,42 @@ var RecipeFetchCommand = &cobra.Command{
 | 
			
		||||
		catlBar := formatter.CreateProgressbar(len(catalogue), "fetching latest recipes...")
 | 
			
		||||
		for recipeName := range catalogue {
 | 
			
		||||
			r := recipe.Get(recipeName)
 | 
			
		||||
			if err := r.Ensure(false, false); err != nil {
 | 
			
		||||
			if err := r.Ensure(ensureCtx); err != nil {
 | 
			
		||||
				log.Error(err)
 | 
			
		||||
			}
 | 
			
		||||
			catlBar.Add(1)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	fetchAllRecipes bool
 | 
			
		||||
	sshRemote       bool
 | 
			
		||||
	force           bool
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func init() {
 | 
			
		||||
	RecipeFetchCommand.Flags().BoolVarP(
 | 
			
		||||
		&fetchAllRecipes,
 | 
			
		||||
		"all",
 | 
			
		||||
		"a",
 | 
			
		||||
		false,
 | 
			
		||||
		"fetch all recipes",
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	RecipeFetchCommand.Flags().BoolVarP(
 | 
			
		||||
		&sshRemote,
 | 
			
		||||
		"ssh",
 | 
			
		||||
		"s",
 | 
			
		||||
		false,
 | 
			
		||||
		"automatically set ssh remote",
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	RecipeFetchCommand.Flags().BoolVarP(
 | 
			
		||||
		&force,
 | 
			
		||||
		"force",
 | 
			
		||||
		"f",
 | 
			
		||||
		false,
 | 
			
		||||
		"force re-fetch",
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,8 +1,6 @@
 | 
			
		||||
package recipe
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/cli/internal"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/autocomplete"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
@ -25,7 +23,7 @@ var RecipeLintCommand = &cobra.Command{
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		recipe := internal.ValidateRecipe(args, cmd.Name())
 | 
			
		||||
 | 
			
		||||
		if err := recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -104,7 +102,9 @@ var RecipeLintCommand = &cobra.Command{
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(rows) > 0 {
 | 
			
		||||
			fmt.Println(table)
 | 
			
		||||
			if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			for _, warnMsg := range warnMessages {
 | 
			
		||||
				log.Warn(warnMsg)
 | 
			
		||||
 | 
			
		||||
@ -79,8 +79,9 @@ var RecipeListCommand = &cobra.Command{
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			fmt.Println(table)
 | 
			
		||||
			log.Infof("total recipes: %v", len(rows))
 | 
			
		||||
			if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -58,7 +58,7 @@ var RecipeNewCommand = &cobra.Command{
 | 
			
		||||
		if err := os.RemoveAll(gitRepo); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
		log.Debugf("removed example git repo in %s", gitRepo)
 | 
			
		||||
		log.Debugf("removed .git repo in %s", gitRepo)
 | 
			
		||||
 | 
			
		||||
		meta := newRecipeMeta(recipeName)
 | 
			
		||||
 | 
			
		||||
@ -76,7 +76,6 @@ var RecipeNewCommand = &cobra.Command{
 | 
			
		||||
			if err := os.WriteFile(path, templated.Bytes(), 0o644); err != nil {
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if err := git.Init(r.Dir, true, gitName, gitEmail); err != nil {
 | 
			
		||||
 | 
			
		||||
@ -252,7 +252,14 @@ func getTagCreateOptions(tag string) (git.CreateTagOptions, error) {
 | 
			
		||||
// addReleaseNotes checks if the release/next release note exists and moves the
 | 
			
		||||
// file to release/<tag>.
 | 
			
		||||
func addReleaseNotes(recipe recipe.Recipe, tag string) error {
 | 
			
		||||
	tagReleaseNotePath := path.Join(recipe.Dir, "release", tag)
 | 
			
		||||
	releaseDir := path.Join(recipe.Dir, "release")
 | 
			
		||||
	if _, err := os.Stat(releaseDir); errors.Is(err, os.ErrNotExist) {
 | 
			
		||||
		if err := os.Mkdir(releaseDir, 0755); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tagReleaseNotePath := path.Join(releaseDir, tag)
 | 
			
		||||
	if _, err := os.Stat(tagReleaseNotePath); err == nil {
 | 
			
		||||
		// Release note for current tag already exist exists.
 | 
			
		||||
		return nil
 | 
			
		||||
@ -260,7 +267,9 @@ func addReleaseNotes(recipe recipe.Recipe, tag string) error {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	nextReleaseNotePath := path.Join(recipe.Dir, "release", "next")
 | 
			
		||||
	var addNextAsReleaseNotes bool
 | 
			
		||||
 | 
			
		||||
	nextReleaseNotePath := path.Join(releaseDir, "next")
 | 
			
		||||
	if _, err := os.Stat(nextReleaseNotePath); err == nil {
 | 
			
		||||
		// release/next note exists. Move it to release/<tag>
 | 
			
		||||
		if internal.Dry {
 | 
			
		||||
@ -269,38 +278,37 @@ func addReleaseNotes(recipe recipe.Recipe, tag string) error {
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if !internal.NoInput {
 | 
			
		||||
			prompt := &survey.Input{
 | 
			
		||||
			prompt := &survey.Confirm{
 | 
			
		||||
				Message: "Use release note in release/next?",
 | 
			
		||||
			}
 | 
			
		||||
			var addReleaseNote bool
 | 
			
		||||
			if err := survey.AskOne(prompt, &addReleaseNote); err != nil {
 | 
			
		||||
 | 
			
		||||
			if err := survey.AskOne(prompt, &addNextAsReleaseNotes); err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
			if !addReleaseNote {
 | 
			
		||||
 | 
			
		||||
			if !addNextAsReleaseNotes {
 | 
			
		||||
				return nil
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err := os.Rename(nextReleaseNotePath, tagReleaseNotePath)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
		if err := os.Rename(nextReleaseNotePath, tagReleaseNotePath); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err = gitPkg.Add(recipe.Dir, path.Join("release", "next"), internal.Dry)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
		if err := gitPkg.Add(recipe.Dir, path.Join("release", "next"), internal.Dry); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err = gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
		if err := gitPkg.Add(recipe.Dir, path.Join("release", tag), internal.Dry); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	} else if !errors.Is(err, os.ErrNotExist) {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// No release note exists for the current release.
 | 
			
		||||
	if internal.NoInput {
 | 
			
		||||
	// NOTE(d1): No release note exists for the current release. Or, we've
 | 
			
		||||
	// already used release/next as the release note
 | 
			
		||||
	if internal.NoInput || addNextAsReleaseNotes {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -63,7 +63,7 @@ You may invoke this command in "wizard" mode and be prompted for input.`,
 | 
			
		||||
	Run: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
		recipe := internal.ValidateRecipe(args, cmd.Name())
 | 
			
		||||
 | 
			
		||||
		if err := recipe.Ensure(internal.Chaos, internal.Offline); err != nil {
 | 
			
		||||
		if err := recipe.Ensure(internal.GetEnsureContext()); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -37,10 +37,13 @@ var RecipeVersionCommand = &cobra.Command{
 | 
			
		||||
		if !ok {
 | 
			
		||||
			warnMessages = append(warnMessages, "retrieved versions from local recipe repository")
 | 
			
		||||
 | 
			
		||||
			recipeVersions, err := recipe.GetRecipeVersions()
 | 
			
		||||
			recipeVersions, warnMsg, err := recipe.GetRecipeVersions()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				warnMessages = append(warnMessages, err.Error())
 | 
			
		||||
			}
 | 
			
		||||
			if len(warnMsg) > 0 {
 | 
			
		||||
				warnMessages = append(warnMessages, warnMsg...)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			recipeMeta = recipePkg.RecipeMeta{Versions: recipeVersions}
 | 
			
		||||
		}
 | 
			
		||||
@ -55,15 +58,32 @@ var RecipeVersionCommand = &cobra.Command{
 | 
			
		||||
				log.Fatal(err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			table.Headers("SERVICE", "NAME", "TAG")
 | 
			
		||||
			table.Headers("SERVICE", "IMAGE", "TAG", "VERSION")
 | 
			
		||||
 | 
			
		||||
			for version, meta := range recipeMeta.Versions[i] {
 | 
			
		||||
				var allRows [][]string
 | 
			
		||||
				var rows [][]string
 | 
			
		||||
 | 
			
		||||
				for service, serviceMeta := range meta {
 | 
			
		||||
					rows = append(rows, []string{service, serviceMeta.Image, serviceMeta.Tag})
 | 
			
		||||
					allRows = append(allRows, []string{version, service, serviceMeta.Image, serviceMeta.Tag})
 | 
			
		||||
					recipeVersion := version
 | 
			
		||||
					if service != "app" {
 | 
			
		||||
						recipeVersion = ""
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					rows = append(rows, []string{
 | 
			
		||||
						service,
 | 
			
		||||
						serviceMeta.Image,
 | 
			
		||||
						serviceMeta.Tag,
 | 
			
		||||
						recipeVersion,
 | 
			
		||||
					})
 | 
			
		||||
 | 
			
		||||
					allRows = append(allRows, []string{
 | 
			
		||||
						version,
 | 
			
		||||
						service,
 | 
			
		||||
						serviceMeta.Image,
 | 
			
		||||
						serviceMeta.Tag,
 | 
			
		||||
						recipeVersion,
 | 
			
		||||
					})
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				sort.Slice(rows, sortServiceByName(rows))
 | 
			
		||||
@ -71,9 +91,9 @@ var RecipeVersionCommand = &cobra.Command{
 | 
			
		||||
				table.Rows(rows...)
 | 
			
		||||
 | 
			
		||||
				if !internal.MachineReadable {
 | 
			
		||||
					fmt.Println(table)
 | 
			
		||||
					log.Infof("VERSION: %s", version)
 | 
			
		||||
					fmt.Println()
 | 
			
		||||
					if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
						log.Fatal(err)
 | 
			
		||||
					}
 | 
			
		||||
					continue
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
@ -100,11 +120,7 @@ var RecipeVersionCommand = &cobra.Command{
 | 
			
		||||
 | 
			
		||||
func sortServiceByName(versions [][]string) func(i, j int) bool {
 | 
			
		||||
	return func(i, j int) bool {
 | 
			
		||||
		// NOTE(d1): corresponds to the `tableCol` definition below
 | 
			
		||||
		if versions[i][1] == "app" {
 | 
			
		||||
			return true
 | 
			
		||||
		}
 | 
			
		||||
		return versions[i][1] < versions[j][1]
 | 
			
		||||
		return versions[i][0] < versions[j][0]
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										34
									
								
								cli/run.go
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								cli/run.go
									
									
									
									
									
								
							@ -35,6 +35,7 @@ func Run(version, commit string) {
 | 
			
		||||
				config.ABRA_DIR,
 | 
			
		||||
				config.SERVERS_DIR,
 | 
			
		||||
				config.RECIPES_DIR,
 | 
			
		||||
				config.LOGS_DIR,
 | 
			
		||||
				config.VENDOR_DIR, // TODO(d1): remove > 0.9.x
 | 
			
		||||
				config.BACKUP_DIR, // TODO(d1): remove > 0.9.x
 | 
			
		||||
			}
 | 
			
		||||
@ -48,9 +49,13 @@ func Run(version, commit string) {
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			log.Logger.SetStyles(log.Styles())
 | 
			
		||||
			log.Logger.SetStyles(charmLog.DefaultStyles())
 | 
			
		||||
			charmLog.SetDefault(log.Logger)
 | 
			
		||||
 | 
			
		||||
			if internal.MachineReadable {
 | 
			
		||||
				log.SetOutput(os.Stderr)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if internal.Debug {
 | 
			
		||||
				log.SetLevel(log.DebugLevel)
 | 
			
		||||
				log.SetOutput(os.Stderr)
 | 
			
		||||
@ -95,20 +100,37 @@ func Run(version, commit string) {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	rootCmd.PersistentFlags().BoolVarP(
 | 
			
		||||
		&internal.Debug, "debug", "d", false,
 | 
			
		||||
		&internal.Debug,
 | 
			
		||||
		"debug",
 | 
			
		||||
		"d",
 | 
			
		||||
		false,
 | 
			
		||||
		"show debug messages",
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	rootCmd.PersistentFlags().BoolVarP(
 | 
			
		||||
		&internal.NoInput, "no-input", "n", false,
 | 
			
		||||
		&internal.NoInput,
 | 
			
		||||
		"no-input",
 | 
			
		||||
		"n",
 | 
			
		||||
		false,
 | 
			
		||||
		"toggle non-interactive mode",
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	rootCmd.PersistentFlags().BoolVarP(
 | 
			
		||||
		&internal.Offline, "offline", "o", false,
 | 
			
		||||
		&internal.Offline,
 | 
			
		||||
		"offline",
 | 
			
		||||
		"o",
 | 
			
		||||
		false,
 | 
			
		||||
		"prefer offline & filesystem access",
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	rootCmd.PersistentFlags().BoolVarP(
 | 
			
		||||
		&internal.IgnoreEnvVersion,
 | 
			
		||||
		"ignore-env-version",
 | 
			
		||||
		"i",
 | 
			
		||||
		false,
 | 
			
		||||
		"ignore .env version checkout",
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	catalogue.CatalogueCommand.AddCommand(
 | 
			
		||||
		catalogue.CatalogueGenerateCommand,
 | 
			
		||||
	)
 | 
			
		||||
@ -187,9 +209,11 @@ func Run(version, commit string) {
 | 
			
		||||
		app.AppUndeployCommand,
 | 
			
		||||
		app.AppUpgradeCommand,
 | 
			
		||||
		app.AppVolumeCommand,
 | 
			
		||||
		app.AppLabelsCommand,
 | 
			
		||||
		app.AppEnvCommand,
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	if err := rootCmd.Execute(); err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
		os.Exit(1)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -88,10 +88,6 @@ developer machine. The domain is then set to "default".`,
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if _, err := dns.EnsureIPv4(name); err != nil {
 | 
			
		||||
			log.Warn(err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		_, err := createServerDir(name)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
@ -100,21 +96,27 @@ developer machine. The domain is then set to "default".`,
 | 
			
		||||
		created, err := newContext(name)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			cleanUp(name)
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
			log.Fatalf("unable to create local context: %s", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("attempting to create client for %s", name)
 | 
			
		||||
 | 
			
		||||
		if _, err := client.New(name, timeout); err != nil {
 | 
			
		||||
			cleanUp(name)
 | 
			
		||||
			log.Fatal(sshPkg.Fatal(name, err))
 | 
			
		||||
			log.Fatalf("ssh %s error: %s", name, sshPkg.Fatal(name, err))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if created {
 | 
			
		||||
			log.Infof("%s successfully added", name)
 | 
			
		||||
		} else {
 | 
			
		||||
			log.Warnf("%s already exists", name)
 | 
			
		||||
 | 
			
		||||
			if _, err := dns.EnsureIPv4(name); err != nil {
 | 
			
		||||
				log.Warnf("unable to resolve IPv4 for %s", name)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Warnf("%s already exists", name)
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -86,7 +86,9 @@ var ServerListCommand = &cobra.Command{
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		fmt.Println(table)
 | 
			
		||||
		if err := formatter.PrintTable(table); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
	},
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -441,7 +441,25 @@ func upgrade(cl *dockerclient.Client, stackName, recipeName, upgradeVersion stri
 | 
			
		||||
 | 
			
		||||
	log.Infof("upgrade %s (%s) to version %s", stackName, recipeName, upgradeVersion)
 | 
			
		||||
 | 
			
		||||
	err = stack.RunDeploy(cl, deployOpts, compose, stackName, true)
 | 
			
		||||
	serviceNames, err := appPkg.GetAppServiceNames(app.Name)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	f, err := app.Filters(true, false, serviceNames...)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	err = stack.RunDeploy(
 | 
			
		||||
		cl,
 | 
			
		||||
		deployOpts,
 | 
			
		||||
		compose,
 | 
			
		||||
		stackName,
 | 
			
		||||
		app.Server,
 | 
			
		||||
		true,
 | 
			
		||||
		f,
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	return err
 | 
			
		||||
}
 | 
			
		||||
@ -452,7 +470,7 @@ func newKadabraApp(version, commit string) *cobra.Command {
 | 
			
		||||
		Version: fmt.Sprintf("%s-%s", version, commit[:7]),
 | 
			
		||||
		Short:   "The Co-op Cloud auto-updater 🤖 🚀",
 | 
			
		||||
		PersistentPreRun: func(cmd *cobra.Command, args []string) {
 | 
			
		||||
			log.Logger.SetStyles(log.Styles())
 | 
			
		||||
			log.Logger.SetStyles(charmLog.DefaultStyles())
 | 
			
		||||
			charmLog.SetDefault(log.Logger)
 | 
			
		||||
 | 
			
		||||
			if internal.Debug {
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										114
									
								
								go.mod
									
									
									
									
									
								
							
							
						
						
									
										114
									
								
								go.mod
									
									
									
									
									
								
							@ -1,49 +1,50 @@
 | 
			
		||||
module coopcloud.tech/abra
 | 
			
		||||
 | 
			
		||||
go 1.22.7
 | 
			
		||||
go 1.23.0
 | 
			
		||||
 | 
			
		||||
toolchain go1.23.1
 | 
			
		||||
 | 
			
		||||
replace github.com/urfave/cli/v3 => github.com/urfave/cli/v3 v3.0.0-alpha9.1.0.20241019193437-5053ec708a44
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	coopcloud.tech/tagcmp v0.0.0-20230809071031-eb3e7758d4eb
 | 
			
		||||
	git.coopcloud.tech/coop-cloud/godotenv v1.5.2-0.20231130100509-01bff8284355
 | 
			
		||||
	git.coopcloud.tech/toolshed/godotenv v1.5.2-0.20250103171850-4d0ca41daa5c
 | 
			
		||||
	github.com/AlecAivazis/survey/v2 v2.3.7
 | 
			
		||||
	github.com/charmbracelet/lipgloss v1.0.0
 | 
			
		||||
	github.com/charmbracelet/log v0.4.0
 | 
			
		||||
	github.com/charmbracelet/bubbletea v1.3.4
 | 
			
		||||
	github.com/charmbracelet/lipgloss v1.1.0
 | 
			
		||||
	github.com/charmbracelet/log v0.4.1
 | 
			
		||||
	github.com/distribution/reference v0.6.0
 | 
			
		||||
	github.com/docker/cli v27.4.1+incompatible
 | 
			
		||||
	github.com/docker/docker v27.4.1+incompatible
 | 
			
		||||
	github.com/docker/cli v28.0.1+incompatible
 | 
			
		||||
	github.com/docker/docker v28.0.1+incompatible
 | 
			
		||||
	github.com/docker/go-units v0.5.0
 | 
			
		||||
	github.com/go-git/go-git/v5 v5.12.0
 | 
			
		||||
	github.com/google/go-cmp v0.6.0
 | 
			
		||||
	github.com/go-git/go-git/v5 v5.14.0
 | 
			
		||||
	github.com/google/go-cmp v0.7.0
 | 
			
		||||
	github.com/moby/sys/signal v0.7.1
 | 
			
		||||
	github.com/moby/term v0.5.0
 | 
			
		||||
	github.com/moby/term v0.5.2
 | 
			
		||||
	github.com/pkg/errors v0.9.1
 | 
			
		||||
	github.com/schollz/progressbar/v3 v3.17.1
 | 
			
		||||
	github.com/urfave/cli/v3 v3.0.0-alpha9
 | 
			
		||||
	golang.org/x/term v0.27.0
 | 
			
		||||
	github.com/schollz/progressbar/v3 v3.18.0
 | 
			
		||||
	golang.org/x/term v0.30.0
 | 
			
		||||
	gopkg.in/yaml.v3 v3.0.1
 | 
			
		||||
	gotest.tools/v3 v3.5.1
 | 
			
		||||
	gotest.tools/v3 v3.5.2
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
require (
 | 
			
		||||
	dario.cat/mergo v1.0.1 // indirect
 | 
			
		||||
	github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 // indirect
 | 
			
		||||
	github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
 | 
			
		||||
	github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
 | 
			
		||||
	github.com/BurntSushi/toml v1.4.0 // indirect
 | 
			
		||||
	github.com/Microsoft/go-winio v0.6.2 // indirect
 | 
			
		||||
	github.com/ProtonMail/go-crypto v1.1.3 // indirect
 | 
			
		||||
	github.com/ProtonMail/go-crypto v1.1.6 // indirect
 | 
			
		||||
	github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
 | 
			
		||||
	github.com/beorn7/perks v1.0.1 // indirect
 | 
			
		||||
	github.com/cenkalti/backoff/v4 v4.3.0 // indirect
 | 
			
		||||
	github.com/cespare/xxhash/v2 v2.3.0 // indirect
 | 
			
		||||
	github.com/charmbracelet/x/ansi v0.6.0 // indirect
 | 
			
		||||
	github.com/cloudflare/circl v1.5.0 // indirect
 | 
			
		||||
	github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
 | 
			
		||||
	github.com/charmbracelet/x/ansi v0.8.0 // indirect
 | 
			
		||||
	github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
 | 
			
		||||
	github.com/charmbracelet/x/term v0.2.1 // indirect
 | 
			
		||||
	github.com/cloudflare/circl v1.6.0 // indirect
 | 
			
		||||
	github.com/containerd/log v0.1.0 // indirect
 | 
			
		||||
	github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect
 | 
			
		||||
	github.com/cyphar/filepath-securejoin v0.3.6 // indirect
 | 
			
		||||
	github.com/cyphar/filepath-securejoin v0.4.1 // indirect
 | 
			
		||||
	github.com/davecgh/go-spew v1.1.1 // indirect
 | 
			
		||||
	github.com/docker/distribution v2.8.3+incompatible // indirect
 | 
			
		||||
	github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c // indirect
 | 
			
		||||
@ -51,11 +52,12 @@ require (
 | 
			
		||||
	github.com/docker/go-metrics v0.0.1 // indirect
 | 
			
		||||
	github.com/docker/libtrust v0.0.0-20160708172513-aabc10ec26b7 // indirect
 | 
			
		||||
	github.com/emirpasic/gods v1.18.1 // indirect
 | 
			
		||||
	github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
 | 
			
		||||
	github.com/felixge/httpsnoop v1.0.4 // indirect
 | 
			
		||||
	github.com/fsnotify/fsnotify v1.6.0 // indirect
 | 
			
		||||
	github.com/ghodss/yaml v1.0.0 // indirect
 | 
			
		||||
	github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
 | 
			
		||||
	github.com/go-git/go-billy/v5 v5.6.0 // indirect
 | 
			
		||||
	github.com/go-git/go-billy/v5 v5.6.2 // indirect
 | 
			
		||||
	github.com/go-logfmt/logfmt v0.6.0 // indirect
 | 
			
		||||
	github.com/go-logr/logr v1.4.2 // indirect
 | 
			
		||||
	github.com/go-logr/stdr v1.2.2 // indirect
 | 
			
		||||
@ -63,16 +65,17 @@ require (
 | 
			
		||||
	github.com/gogo/protobuf v1.3.2 // indirect
 | 
			
		||||
	github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
 | 
			
		||||
	github.com/google/uuid v1.6.0 // indirect
 | 
			
		||||
	github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 // indirect
 | 
			
		||||
	github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 // indirect
 | 
			
		||||
	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 | 
			
		||||
	github.com/inconshreveable/mousetrap v1.1.0 // indirect
 | 
			
		||||
	github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
 | 
			
		||||
	github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
 | 
			
		||||
	github.com/kevinburke/ssh_config v1.2.0 // indirect
 | 
			
		||||
	github.com/klauspost/compress v1.17.11 // indirect
 | 
			
		||||
	github.com/klauspost/compress v1.18.0 // indirect
 | 
			
		||||
	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
 | 
			
		||||
	github.com/mattn/go-colorable v0.1.13 // indirect
 | 
			
		||||
	github.com/mattn/go-colorable v0.1.14 // indirect
 | 
			
		||||
	github.com/mattn/go-isatty v0.0.20 // indirect
 | 
			
		||||
	github.com/mattn/go-localereader v0.0.1 // indirect
 | 
			
		||||
	github.com/mattn/go-runewidth v0.0.16 // indirect
 | 
			
		||||
	github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
 | 
			
		||||
	github.com/miekg/pkcs11 v1.1.1 // indirect
 | 
			
		||||
@ -83,47 +86,50 @@ require (
 | 
			
		||||
	github.com/moby/sys/user v0.3.0 // indirect
 | 
			
		||||
	github.com/moby/sys/userns v0.1.0 // indirect
 | 
			
		||||
	github.com/morikuni/aec v1.0.0 // indirect
 | 
			
		||||
	github.com/muesli/termenv v0.15.2 // indirect
 | 
			
		||||
	github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
 | 
			
		||||
	github.com/muesli/cancelreader v0.2.2 // indirect
 | 
			
		||||
	github.com/muesli/termenv v0.16.0 // indirect
 | 
			
		||||
	github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
 | 
			
		||||
	github.com/opencontainers/go-digest v1.0.0 // indirect
 | 
			
		||||
	github.com/opencontainers/runc v1.1.13 // indirect
 | 
			
		||||
	github.com/opencontainers/runtime-spec v1.1.0 // indirect
 | 
			
		||||
	github.com/pelletier/go-toml v1.9.5 // indirect
 | 
			
		||||
	github.com/pjbgf/sha1cd v0.3.0 // indirect
 | 
			
		||||
	github.com/pjbgf/sha1cd v0.3.2 // indirect
 | 
			
		||||
	github.com/pmezard/go-difflib v1.0.0 // indirect
 | 
			
		||||
	github.com/prometheus/client_model v0.6.1 // indirect
 | 
			
		||||
	github.com/prometheus/common v0.61.0 // indirect
 | 
			
		||||
	github.com/prometheus/common v0.63.0 // indirect
 | 
			
		||||
	github.com/prometheus/procfs v0.15.1 // indirect
 | 
			
		||||
	github.com/rivo/uniseg v0.4.7 // indirect
 | 
			
		||||
	github.com/russross/blackfriday/v2 v2.1.0 // indirect
 | 
			
		||||
	github.com/sirupsen/logrus v1.9.3 // indirect
 | 
			
		||||
	github.com/skeema/knownhosts v1.3.0 // indirect
 | 
			
		||||
	github.com/spf13/pflag v1.0.5 // indirect
 | 
			
		||||
	github.com/skeema/knownhosts v1.3.1 // indirect
 | 
			
		||||
	github.com/spf13/pflag v1.0.6 // indirect
 | 
			
		||||
	github.com/xanzy/ssh-agent v0.3.3 // indirect
 | 
			
		||||
	github.com/xeipuuv/gojsonreference v0.0.0-20180127040603-bd5ef7bd5415 // indirect
 | 
			
		||||
	github.com/xeipuuv/gojsonschema v1.2.0 // indirect
 | 
			
		||||
	github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
 | 
			
		||||
	go.opentelemetry.io/auto/sdk v1.1.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel v1.33.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel v1.35.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/metric v1.33.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/sdk v1.33.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/sdk/metric v1.33.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/trace v1.33.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/proto/otlp v1.4.0 // indirect
 | 
			
		||||
	golang.org/x/crypto v0.31.0 // indirect
 | 
			
		||||
	golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
 | 
			
		||||
	golang.org/x/net v0.33.0 // indirect
 | 
			
		||||
	golang.org/x/sync v0.10.0 // indirect
 | 
			
		||||
	golang.org/x/text v0.21.0 // indirect
 | 
			
		||||
	golang.org/x/time v0.8.0 // indirect
 | 
			
		||||
	google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 // indirect
 | 
			
		||||
	google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 // indirect
 | 
			
		||||
	google.golang.org/grpc v1.69.2 // indirect
 | 
			
		||||
	google.golang.org/protobuf v1.36.1 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/metric v1.35.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/sdk v1.35.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/sdk/metric v1.35.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/otel/trace v1.35.0 // indirect
 | 
			
		||||
	go.opentelemetry.io/proto/otlp v1.5.0 // indirect
 | 
			
		||||
	golang.org/x/crypto v0.36.0 // indirect
 | 
			
		||||
	golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
 | 
			
		||||
	golang.org/x/net v0.37.0 // indirect
 | 
			
		||||
	golang.org/x/sync v0.12.0 // indirect
 | 
			
		||||
	golang.org/x/text v0.23.0 // indirect
 | 
			
		||||
	golang.org/x/time v0.11.0 // indirect
 | 
			
		||||
	google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4 // indirect
 | 
			
		||||
	google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 // indirect
 | 
			
		||||
	google.golang.org/grpc v1.71.0 // indirect
 | 
			
		||||
	google.golang.org/protobuf v1.36.5 // indirect
 | 
			
		||||
	gopkg.in/warnings.v0 v0.1.2 // indirect
 | 
			
		||||
	gopkg.in/yaml.v2 v2.4.0 // indirect
 | 
			
		||||
)
 | 
			
		||||
@ -132,19 +138,19 @@ require (
 | 
			
		||||
	github.com/containers/image v3.0.2+incompatible
 | 
			
		||||
	github.com/containers/storage v1.38.2 // indirect
 | 
			
		||||
	github.com/decentral1se/passgen v1.0.1
 | 
			
		||||
	github.com/docker/docker-credential-helpers v0.8.2 // indirect
 | 
			
		||||
	github.com/docker/docker-credential-helpers v0.9.3 // indirect
 | 
			
		||||
	github.com/fvbommel/sortorder v1.1.0 // indirect
 | 
			
		||||
	github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect
 | 
			
		||||
	github.com/gorilla/mux v1.8.1 // indirect
 | 
			
		||||
	github.com/hashicorp/go-retryablehttp v0.7.7
 | 
			
		||||
	github.com/moby/patternmatcher v0.6.0 // indirect
 | 
			
		||||
	github.com/moby/sys/sequential v0.6.0 // indirect
 | 
			
		||||
	github.com/opencontainers/image-spec v1.1.0 // indirect
 | 
			
		||||
	github.com/prometheus/client_golang v1.20.5 // indirect
 | 
			
		||||
	github.com/opencontainers/image-spec v1.1.1 // indirect
 | 
			
		||||
	github.com/prometheus/client_golang v1.21.1 // indirect
 | 
			
		||||
	github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
 | 
			
		||||
	github.com/spf13/cobra v1.8.1
 | 
			
		||||
	github.com/spf13/cobra v1.9.1
 | 
			
		||||
	github.com/stretchr/testify v1.10.0
 | 
			
		||||
	github.com/theupdateframework/notary v0.7.0 // indirect
 | 
			
		||||
	github.com/xeipuuv/gojsonpointer v0.0.0-20190905194746-02993c407bfb // indirect
 | 
			
		||||
	golang.org/x/sys v0.28.0
 | 
			
		||||
	golang.org/x/sys v0.31.0
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										294
									
								
								go.sum
									
									
									
									
									
								
							
							
						
						
									
										294
									
								
								go.sum
									
									
									
									
									
								
							@ -27,16 +27,16 @@ coopcloud.tech/tagcmp v0.0.0-20230809071031-eb3e7758d4eb/go.mod h1:ESVm0wQKcbcFi
 | 
			
		||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
 | 
			
		||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
 | 
			
		||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
 | 
			
		||||
git.coopcloud.tech/coop-cloud/godotenv v1.5.2-0.20231130100509-01bff8284355 h1:tCv2B4qoN6RMheKDnCzIafOkWS5BB1h7hwhmo+9bVeE=
 | 
			
		||||
git.coopcloud.tech/coop-cloud/godotenv v1.5.2-0.20231130100509-01bff8284355/go.mod h1:Q8V1zbtPAlzYSr/Dvky3wS6x58IQAl3rot2me1oSO2Q=
 | 
			
		||||
git.coopcloud.tech/toolshed/godotenv v1.5.2-0.20250103171850-4d0ca41daa5c h1:oeKnUB79PKYD8D0/unYuu7MRcWryQQWOns8+JL+acrs=
 | 
			
		||||
git.coopcloud.tech/toolshed/godotenv v1.5.2-0.20250103171850-4d0ca41daa5c/go.mod h1:fQuhwrpg6qb9NlFXKYi/LysWu1wxjraS8sxyW12CUF0=
 | 
			
		||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24 h1:bvDV9vkmnHYOMsOr4WLk+Vo07yKIzd94sVoIqshQ4bU=
 | 
			
		||||
github.com/AdaLogics/go-fuzz-headers v0.0.0-20230811130428-ced1acdcaa24/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
 | 
			
		||||
github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ=
 | 
			
		||||
github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo=
 | 
			
		||||
github.com/Azure/azure-sdk-for-go v16.2.1+incompatible/go.mod h1:9XXNKU+eRnpl9moKnB4QOLf1HestfXbmab5FXxiDBjc=
 | 
			
		||||
github.com/Azure/go-ansiterm v0.0.0-20170929234023-d6e3b3328b78/go.mod h1:LmzpDX56iTiv29bbRTIsUNlaFfuhWRQBWjQdVyAevI8=
 | 
			
		||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 h1:L/gRVlceqvL25UVaW/CKtUDjefjrs0SPonmDGUVOYP0=
 | 
			
		||||
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
 | 
			
		||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
 | 
			
		||||
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
 | 
			
		||||
github.com/Azure/go-autorest v10.8.1+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 | 
			
		||||
github.com/Azure/go-autorest v14.2.0+incompatible/go.mod h1:r+4oMnoxhatjLLJ6zxSWATqVooLgysK6ZNox3g/xq24=
 | 
			
		||||
github.com/Azure/go-autorest/autorest v0.11.1/go.mod h1:JFgpikqFJ/MleTTxwepExTKnFUKKszPS8UavbQYUMuw=
 | 
			
		||||
@ -79,8 +79,8 @@ github.com/NYTimes/gziphandler v0.0.0-20170623195520-56545f4a5d46/go.mod h1:3wb0
 | 
			
		||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2 h1:+vx7roKuyA63nhn5WAunQHLTznkw5W8b1Xc0dNjp83s=
 | 
			
		||||
github.com/Netflix/go-expect v0.0.0-20220104043353-73e0943537d2/go.mod h1:HBCaDeC1lPdgDeDbhX8XFpy1jqjK0IBG8W5K+xYqA0w=
 | 
			
		||||
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
 | 
			
		||||
github.com/ProtonMail/go-crypto v1.1.3 h1:nRBOetoydLeUb4nHajyO2bKqMLfWQ/ZPwkXqXxPxCFk=
 | 
			
		||||
github.com/ProtonMail/go-crypto v1.1.3/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
 | 
			
		||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
 | 
			
		||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
 | 
			
		||||
github.com/PuerkitoBio/purell v1.0.0/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
 | 
			
		||||
github.com/PuerkitoBio/purell v1.1.1/go.mod h1:c11w/QuzBsJSee3cPx9rAFu61PvFxuPbtSwDGJws/X0=
 | 
			
		||||
github.com/PuerkitoBio/urlesc v0.0.0-20160726150825-5bd2802263f2/go.mod h1:uGdkoq3SwY9Y+13GIhn11/XLaGBb4BfwItxLd5jeuXE=
 | 
			
		||||
@ -131,21 +131,26 @@ github.com/cenkalti/backoff/v4 v4.1.1/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInq
 | 
			
		||||
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
 | 
			
		||||
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
 | 
			
		||||
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
 | 
			
		||||
github.com/cespare/xxhash v1.1.0 h1:a6HrQnmkObjyL+Gs60czilIUGqrzKutQD6XZog3p+ko=
 | 
			
		||||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
 | 
			
		||||
github.com/cespare/xxhash/v2 v2.1.1/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 | 
			
		||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
 | 
			
		||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
 | 
			
		||||
github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg=
 | 
			
		||||
github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo=
 | 
			
		||||
github.com/charmbracelet/log v0.4.0 h1:G9bQAcx8rWA2T3pWvx7YtPTPwgqpk7D68BX21IRW8ZM=
 | 
			
		||||
github.com/charmbracelet/log v0.4.0/go.mod h1:63bXt/djrizTec0l11H20t8FDSvA4CRZJ1KH22MdptM=
 | 
			
		||||
github.com/charmbracelet/x/ansi v0.5.2 h1:dEa1x2qdOZXD/6439s+wF7xjV+kZLu/iN00GuXXrU9E=
 | 
			
		||||
github.com/charmbracelet/x/ansi v0.5.2/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
 | 
			
		||||
github.com/charmbracelet/x/ansi v0.6.0 h1:qOznutrb93gx9oMiGf7caF7bqqubh6YIM0SWKyA08pA=
 | 
			
		||||
github.com/charmbracelet/x/ansi v0.6.0/go.mod h1:KBUFw1la39nl0dLl10l5ORDAqGXaeurTQmwyyVKse/Q=
 | 
			
		||||
github.com/charmbracelet/bubbletea v1.3.4 h1:kCg7B+jSCFPLYRA52SDZjr51kG/fMUEoPoZrkaDHyoI=
 | 
			
		||||
github.com/charmbracelet/bubbletea v1.3.4/go.mod h1:dtcUCyCGEX3g9tosuYiut3MXgY/Jsv9nKVdibKKRRXo=
 | 
			
		||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
 | 
			
		||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
 | 
			
		||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
 | 
			
		||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
 | 
			
		||||
github.com/charmbracelet/log v0.4.1 h1:6AYnoHKADkghm/vt4neaNEXkxcXLSV2g1rdyFDOpTyk=
 | 
			
		||||
github.com/charmbracelet/log v0.4.1/go.mod h1:pXgyTsqsVu4N9hGdHmQ0xEA4RsXof402LX9ZgiITn2I=
 | 
			
		||||
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
 | 
			
		||||
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
 | 
			
		||||
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
 | 
			
		||||
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
 | 
			
		||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
 | 
			
		||||
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
 | 
			
		||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
 | 
			
		||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
 | 
			
		||||
github.com/checkpoint-restore/go-criu/v4 v4.1.0/go.mod h1:xUQBLp4RLc5zJtWY++yjOoMoB5lihDt7fai+75m+rGw=
 | 
			
		||||
github.com/checkpoint-restore/go-criu/v5 v5.0.0/go.mod h1:cfwC0EG7HMUenopBsUf9d89JlCLQIfgVcNsNN0t6T2M=
 | 
			
		||||
github.com/checkpoint-restore/go-criu/v5 v5.3.0/go.mod h1:E/eQpaFtUKGOOSEBZgmKAcn+zUUwWxqcaKZlF54wK8E=
 | 
			
		||||
@ -163,8 +168,8 @@ github.com/cilium/ebpf v0.7.0/go.mod h1:/oI2+1shJiTGAMgl6/RgJr36Eo1jzrRcAWbcXO2u
 | 
			
		||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
 | 
			
		||||
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004 h1:lkAMpLVBDaj17e85keuznYcH5rqI438v41pKcBl4ZxQ=
 | 
			
		||||
github.com/cloudflare/cfssl v0.0.0-20180223231731-4e2dcbde5004/go.mod h1:yMWuSON2oQp+43nFtAV/uvKQIFpSPerB57DCt9t8sSA=
 | 
			
		||||
github.com/cloudflare/circl v1.5.0 h1:hxIWksrX6XN5a1L2TI/h53AGPhNHoUBo+TD1ms9+pys=
 | 
			
		||||
github.com/cloudflare/circl v1.5.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
 | 
			
		||||
github.com/cloudflare/circl v1.6.0 h1:cr5JKic4HI+LkINy2lg3W2jF8sHCVTBncJr5gIIq7qk=
 | 
			
		||||
github.com/cloudflare/circl v1.6.0/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
 | 
			
		||||
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
 | 
			
		||||
github.com/cncf/udpa/go v0.0.0-20201120205902-5459f2c99403/go.mod h1:WmhPx2Nbnhtbo57+VJT5O0JRkEi1Wbu0z5j0R8u5Hbk=
 | 
			
		||||
github.com/cncf/xds/go v0.0.0-20210312221358-fbca930ec8ed/go.mod h1:eXthEFrGJvWHgFFCl3hGmgk+/aYT6PnTQLykKQRLhEs=
 | 
			
		||||
@ -278,8 +283,6 @@ github.com/coreos/pkg v0.0.0-20160727233714-3ac0863d7acf/go.mod h1:E3G3o1h8I7cfc
 | 
			
		||||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
 | 
			
		||||
github.com/cpuguy83/go-md2man/v2 v2.0.0-20190314233015-f79a8a8ca69d/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 | 
			
		||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
 | 
			
		||||
github.com/cpuguy83/go-md2man/v2 v2.0.4 h1:wfIWP927BUkWJb2NmU/kNDYIBTh/ziUX91+lVfRxZq4=
 | 
			
		||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
 | 
			
		||||
github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0=
 | 
			
		||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 | 
			
		||||
github.com/creack/pty v1.1.7/go.mod h1:lj5s0c3V2DBrqTV7llrYr5NG6My20zk30Fl46Y7DoTY=
 | 
			
		||||
@ -289,10 +292,8 @@ github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY=
 | 
			
		||||
github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
 | 
			
		||||
github.com/cyphar/filepath-securejoin v0.2.2/go.mod h1:FpkQEhXnPnOthhzymB7CGsFk2G9VLXONKD9G7QGMM+4=
 | 
			
		||||
github.com/cyphar/filepath-securejoin v0.2.3/go.mod h1:aPGpWjXOXUn2NCNjFvBE6aRxGGx79pTxQpKOJNYHHl4=
 | 
			
		||||
github.com/cyphar/filepath-securejoin v0.3.4 h1:VBWugsJh2ZxJmLFSM06/0qzQyiQX2Qs0ViKrUAcqdZ8=
 | 
			
		||||
github.com/cyphar/filepath-securejoin v0.3.4/go.mod h1:8s/MCNJREmFK0H02MF6Ihv1nakJe4L/w3WZLHNkvlYM=
 | 
			
		||||
github.com/cyphar/filepath-securejoin v0.3.6 h1:4d9N5ykBnSp5Xn2JkhocYDkOpURL/18CYMpo6xB9uWM=
 | 
			
		||||
github.com/cyphar/filepath-securejoin v0.3.6/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
 | 
			
		||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
 | 
			
		||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
 | 
			
		||||
github.com/d2g/dhcp4 v0.0.0-20170904100407-a1d1b6c41b1c/go.mod h1:Ct2BUK8SB0YC1SMSibvLzxjeJLnrYEVLULFNiHY9YfQ=
 | 
			
		||||
github.com/d2g/dhcp4client v1.0.0/go.mod h1:j0hNfjhrt2SxUOw55nL0ATM/z4Yt3t2Kd1mW34z5W5s=
 | 
			
		||||
github.com/d2g/dhcp4server v0.0.0-20181031114812-7d4a0a7f59a5/go.mod h1:Eo87+Kg/IX2hfWJfwxMzLyuSZyxSoAug2nGa1G2QAi8=
 | 
			
		||||
@ -311,23 +312,19 @@ github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5Qvfr
 | 
			
		||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
 | 
			
		||||
github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
 | 
			
		||||
github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 | 
			
		||||
github.com/docker/cli v27.3.1+incompatible h1:qEGdFBF3Xu6SCvCYhc7CzaQTlBmqDuzxPDpigSyeKQQ=
 | 
			
		||||
github.com/docker/cli v27.3.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 | 
			
		||||
github.com/docker/cli v27.4.1+incompatible h1:VzPiUlRJ/xh+otB75gva3r05isHMo5wXDfPRi5/b4hI=
 | 
			
		||||
github.com/docker/cli v27.4.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 | 
			
		||||
github.com/docker/cli v28.0.1+incompatible h1:g0h5NQNda3/CxIsaZfH4Tyf6vpxFth7PYl3hgCPOKzs=
 | 
			
		||||
github.com/docker/cli v28.0.1+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 | 
			
		||||
github.com/docker/distribution v0.0.0-20190905152932-14b96e55d84c/go.mod h1:0+TTO4EOBfRPhZXAeF1Vu+W3hHZ8eLp8PgKVZlcvtFY=
 | 
			
		||||
github.com/docker/distribution v2.7.1-0.20190205005809-0d3efadf0154+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 | 
			
		||||
github.com/docker/distribution v2.7.1+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 | 
			
		||||
github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk=
 | 
			
		||||
github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w=
 | 
			
		||||
github.com/docker/docker v1.4.2-0.20190924003213-a8608b5b67c7/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 | 
			
		||||
github.com/docker/docker v27.3.1+incompatible h1:KttF0XoteNTicmUtBO0L2tP+J7FGRFTjaEF4k6WdhfI=
 | 
			
		||||
github.com/docker/docker v27.3.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 | 
			
		||||
github.com/docker/docker v27.4.1+incompatible h1:ZJvcY7gfwHn1JF48PfbyXg7Jyt9ZCWDW+GGXOIxEwp4=
 | 
			
		||||
github.com/docker/docker v27.4.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 | 
			
		||||
github.com/docker/docker v28.0.1+incompatible h1:FCHjSRdXhNRFjlHMTv4jUNlIBbTeRjrWfeFuJp7jpo0=
 | 
			
		||||
github.com/docker/docker v28.0.1+incompatible/go.mod h1:eEKB0N0r5NX/I1kEveEz05bcu8tLC/8azJZsviup8Sk=
 | 
			
		||||
github.com/docker/docker-credential-helpers v0.6.3/go.mod h1:WRaJzqw3CTB9bk10avuGsjVBZsD05qeibJ1/TYlvc0Y=
 | 
			
		||||
github.com/docker/docker-credential-helpers v0.8.2 h1:bX3YxiGzFP5sOXWc3bTPEXdEaZSeVMrFgOr3T+zrFAo=
 | 
			
		||||
github.com/docker/docker-credential-helpers v0.8.2/go.mod h1:P3ci7E3lwkZg6XiHdRKft1KckHiO9a2rNtyFbZ/ry9M=
 | 
			
		||||
github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8=
 | 
			
		||||
github.com/docker/docker-credential-helpers v0.9.3/go.mod h1:x+4Gbw9aGmChi3qTLZj8Dfn0TD20M/fuWy0E5+WDeCo=
 | 
			
		||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c h1:lzqkGL9b3znc+ZUgi7FlLnqjQhcXxkNM/quxIjBVMD0=
 | 
			
		||||
github.com/docker/go v1.5.1-1.0.20160303222718-d30aec9fd63c/go.mod h1:CADgU4DSXK5QUlFslkQu2yW2TKzFZcXq/leZfM0UH5Q=
 | 
			
		||||
github.com/docker/go-connections v0.4.0/go.mod h1:Gbd7IOopHjR8Iph03tsViu4nIes5XhDvyHbTtUxmeec=
 | 
			
		||||
@ -350,8 +347,8 @@ github.com/dustin/go-humanize v0.0.0-20171111073723-bb3d318650d4/go.mod h1:Htrtb
 | 
			
		||||
github.com/dustin/go-humanize v1.0.0/go.mod h1:HtrtbFcZ19U5GC7JDqmcUSB87Iq5E25KnS6fMYU6eOk=
 | 
			
		||||
github.com/dvsekhvalnov/jose2go v0.0.0-20170216131308-f21a8cedbbae/go.mod h1:7BvyPhdbLxMXIYTFPLsyJRFMsKmOZnQmzh6Gb+uquuM=
 | 
			
		||||
github.com/elazarl/goproxy v0.0.0-20180725130230-947c36da3153/go.mod h1:/Zj4wYkgs4iZTTu3o/KG3Itv/qCCa8VVMlb3i9OVuzc=
 | 
			
		||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
 | 
			
		||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
 | 
			
		||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
 | 
			
		||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
 | 
			
		||||
github.com/emicklei/go-restful v0.0.0-20170410110728-ff4f55a20633/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
 | 
			
		||||
github.com/emicklei/go-restful v2.9.5+incompatible/go.mod h1:otzb+WCGbkyDHkqmQmT5YD2WR4BBwUdeQoFo8l/7tVs=
 | 
			
		||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
 | 
			
		||||
@ -362,6 +359,8 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
 | 
			
		||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20201210154907-fd9021fe5dad/go.mod h1:cXg6YxExXjJnVBQHBLXeUAgxn2UodCpnH306RInaBQk=
 | 
			
		||||
github.com/envoyproxy/go-control-plane v0.9.9-0.20210512163311-63b5d3c536b0/go.mod h1:hliV/p42l8fGbc6Y9bQ70uLwIvmJyVE5k4iMKlh8wCQ=
 | 
			
		||||
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
 | 
			
		||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
 | 
			
		||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
 | 
			
		||||
github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0=
 | 
			
		||||
github.com/evanphx/json-patch v4.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk=
 | 
			
		||||
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
 | 
			
		||||
@ -382,16 +381,16 @@ github.com/garyburd/redigo v0.0.0-20150301180006-535138d7bcd7/go.mod h1:NR3MbYis
 | 
			
		||||
github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 | 
			
		||||
github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk=
 | 
			
		||||
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
 | 
			
		||||
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
 | 
			
		||||
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
 | 
			
		||||
github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
 | 
			
		||||
github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
 | 
			
		||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
 | 
			
		||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
 | 
			
		||||
github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cjio8=
 | 
			
		||||
github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM=
 | 
			
		||||
github.com/go-git/go-billy/v5 v5.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
 | 
			
		||||
github.com/go-git/go-billy/v5 v5.6.2/go.mod h1:rcFC2rAsp/erv7CMz9GczHcuD0D32fWzH+MJAU+jaUU=
 | 
			
		||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
 | 
			
		||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
 | 
			
		||||
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
 | 
			
		||||
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
 | 
			
		||||
github.com/go-git/go-git/v5 v5.14.0 h1:/MD3lCrGjCen5WfEAzKg00MJJffKhC8gzS80ycmCi60=
 | 
			
		||||
github.com/go-git/go-git/v5 v5.14.0/go.mod h1:Z5Xhoia5PcWA3NF8vRLURn9E5FRhSl7dGj9ItW3Wk5k=
 | 
			
		||||
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
 | 
			
		||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 | 
			
		||||
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
 | 
			
		||||
@ -488,8 +487,8 @@ github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
 | 
			
		||||
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 | 
			
		||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 | 
			
		||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 | 
			
		||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
 | 
			
		||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
 | 
			
		||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
 | 
			
		||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
 | 
			
		||||
github.com/google/go-containerregistry v0.5.1/go.mod h1:Ct15B4yir3PLOP5jsy0GNeYVaIZs/MK/Jz5any1wFW0=
 | 
			
		||||
github.com/google/go-intervals v0.0.2/go.mod h1:MkaR3LNRfeKLPmqgJYs4E66z5InYjmCjbbr4TQlcT6Y=
 | 
			
		||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
 | 
			
		||||
@ -529,12 +528,9 @@ github.com/grpc-ecosystem/go-grpc-middleware v1.0.1-0.20190118093823-f849b5445de
 | 
			
		||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway v1.9.5/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0 h1:gmcG1KaJ57LophUzW0Hy8NmPhnMZb4M0+kPpLofRdBo=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway v1.16.0/go.mod h1:BDjrQk3hbvj6Nolgz8mAMFbcEtjT1g+wF4CSlocrBnw=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0 h1:TmHmbvxPmaegwhDubVz0lICL0J5Ka2vwTzhoePEXsGE=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.24.0/go.mod h1:qztMSjm835F2bXf+5HKAPIS5qsmQDqZna/PgVt4rWtI=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1 h1:VNqngBF40hVlDloBruUehVYC3ArSgIyScOAyMRqBxRg=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.25.1/go.mod h1:RBRO7fro65R6tjKzYgLAFo0t1QEXY1Dp+i/bvpRiqiQ=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3 h1:5ZPtiqj0JL5oKWmcsq4VMaAW5ukBEgSGXEN89zeH1Jo=
 | 
			
		||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.26.3/go.mod h1:ndYquD05frm2vACXE1nsccT4oJzjhw2arTS2cpUD1PI=
 | 
			
		||||
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed h1:5upAirOpQc1Q53c0bnx2ufif5kANL7bfZWcc6VJWJd8=
 | 
			
		||||
github.com/hailocab/go-hostpool v0.0.0-20160125115350-e80d13ce29ed/go.mod h1:tMWxXQ9wFIaZeTI9F+hmhFiGpFmhOHzyShyFUhRm0H4=
 | 
			
		||||
github.com/hashicorp/errwrap v0.0.0-20141028054710-7554cd9344ce/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
 | 
			
		||||
@ -596,8 +592,8 @@ github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+o
 | 
			
		||||
github.com/klauspost/compress v1.11.3/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 | 
			
		||||
github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
 | 
			
		||||
github.com/klauspost/compress v1.14.2/go.mod h1:/3/Vjq9QcHkK5uEr5lBEmyoZ1iFhe47etQ6QUkpK6sk=
 | 
			
		||||
github.com/klauspost/compress v1.17.11 h1:In6xLpyWOi1+C7tXUUWv2ot1QvBjxevKAaI6IXrJmUc=
 | 
			
		||||
github.com/klauspost/compress v1.17.11/go.mod h1:pMDklpSncoRMuLFrf1W9Ss9KT+0rH90U12bZKk7uwG0=
 | 
			
		||||
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
 | 
			
		||||
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
 | 
			
		||||
github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
 | 
			
		||||
github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 | 
			
		||||
github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
 | 
			
		||||
@ -629,13 +625,14 @@ github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7
 | 
			
		||||
github.com/marstr/guid v1.1.0/go.mod h1:74gB1z2wpxxInTG6yaqA7KrtM0NZ+RbrcqDvYHefzho=
 | 
			
		||||
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
 | 
			
		||||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
 | 
			
		||||
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
 | 
			
		||||
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 | 
			
		||||
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
 | 
			
		||||
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
 | 
			
		||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
 | 
			
		||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
 | 
			
		||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
 | 
			
		||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
 | 
			
		||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 | 
			
		||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
 | 
			
		||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
 | 
			
		||||
github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU=
 | 
			
		||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
 | 
			
		||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
 | 
			
		||||
@ -682,8 +679,8 @@ github.com/moby/sys/user v0.3.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85
 | 
			
		||||
github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g=
 | 
			
		||||
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
 | 
			
		||||
github.com/moby/term v0.0.0-20200312100748-672ec06f55cd/go.mod h1:DdlQx2hp0Ss5/fLikoLlEeIYiATotOjgB//nb973jeo=
 | 
			
		||||
github.com/moby/term v0.5.0 h1:xt8Q1nalod/v7BqbG21f8mQPqH+xAaC9C3N3wfWbVP0=
 | 
			
		||||
github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3Y=
 | 
			
		||||
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
 | 
			
		||||
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
 | 
			
		||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 | 
			
		||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
 | 
			
		||||
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
 | 
			
		||||
@ -692,8 +689,12 @@ github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjY
 | 
			
		||||
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
 | 
			
		||||
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
 | 
			
		||||
github.com/mrunalp/fileutils v0.5.0/go.mod h1:M1WthSahJixYnrXQl/DFQuteStB1weuxD2QJNHXfbSQ=
 | 
			
		||||
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
 | 
			
		||||
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
 | 
			
		||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
 | 
			
		||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
 | 
			
		||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
 | 
			
		||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
 | 
			
		||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
 | 
			
		||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
 | 
			
		||||
github.com/munnerz/goautoneg v0.0.0-20120707110453-a547fc61f48d/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 | 
			
		||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
 | 
			
		||||
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 | 
			
		||||
@ -730,8 +731,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
 | 
			
		||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
 | 
			
		||||
github.com/opencontainers/image-spec v1.0.0/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 | 
			
		||||
github.com/opencontainers/image-spec v1.0.1/go.mod h1:BtxoFyWECRxE4U/7sNtV5W15zMzWCbyJoFRP3s7yZA0=
 | 
			
		||||
github.com/opencontainers/image-spec v1.1.0 h1:8SG7/vwALn54lVB/0yZ/MMwhFrPYtpEHQb2IpWsCzug=
 | 
			
		||||
github.com/opencontainers/image-spec v1.1.0/go.mod h1:W4s4sFTMaBeK1BQLXbG4AdM2szdn85PY75RI83NrTrM=
 | 
			
		||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
 | 
			
		||||
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
 | 
			
		||||
github.com/opencontainers/runc v0.0.0-20190115041553-12f6a991201f/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
 | 
			
		||||
github.com/opencontainers/runc v0.1.1/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
 | 
			
		||||
github.com/opencontainers/runc v1.0.0-rc8.0.20190926000215-3e425f80a8c9/go.mod h1:qT5XzbpPznkRYVz/mWwUaVBUv2rmF59PVA73FjuZG0U=
 | 
			
		||||
@ -761,8 +762,8 @@ github.com/pelletier/go-toml v1.8.1/go.mod h1:T2/BmBdy8dvIRq1a/8aqjN41wvWlN4lrap
 | 
			
		||||
github.com/pelletier/go-toml v1.9.5 h1:4yBQzkHv+7BHq2PQUZF3Mx0IYxG7LsP222s7Agd3ve8=
 | 
			
		||||
github.com/pelletier/go-toml v1.9.5/go.mod h1:u1nR/EPcESfeI/szUZKdtJ0xRNbUoANCkoOuaOx1Y+c=
 | 
			
		||||
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
 | 
			
		||||
github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4=
 | 
			
		||||
github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI=
 | 
			
		||||
github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4=
 | 
			
		||||
github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A=
 | 
			
		||||
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 | 
			
		||||
github.com/pkg/errors v0.8.1-0.20171018195549-f15c970de5b7/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 | 
			
		||||
github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
 | 
			
		||||
@ -778,8 +779,8 @@ github.com/prometheus/client_golang v0.9.3/go.mod h1:/TN21ttK/J9q6uSwhBd54HahCDf
 | 
			
		||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
 | 
			
		||||
github.com/prometheus/client_golang v1.1.0/go.mod h1:I1FGZT9+L76gKKOs5djB6ezCbFQP1xR9D75/vuwEF3g=
 | 
			
		||||
github.com/prometheus/client_golang v1.7.1/go.mod h1:PY5Wy2awLA44sXw4AOSfFBetzPP4j5+D6mVACh+pe2M=
 | 
			
		||||
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
 | 
			
		||||
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
 | 
			
		||||
github.com/prometheus/client_golang v1.21.1 h1:DOvXXTqVzvkIewV/CDPFdejpMCGeMcbGCQ8YOmu+Ibk=
 | 
			
		||||
github.com/prometheus/client_golang v1.21.1/go.mod h1:U9NM32ykUErtVBxdvD3zfi+EuFkkaBvMb09mIfe0Zgg=
 | 
			
		||||
github.com/prometheus/client_model v0.0.0-20171117100541-99fa1f4be8e5/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 | 
			
		||||
github.com/prometheus/client_model v0.0.0-20180712105110-5c3871d89910/go.mod h1:MbSGuTsp3dbXC40dX6PRTWyKYBIrTGTE9sqQNg2J8bo=
 | 
			
		||||
github.com/prometheus/client_model v0.0.0-20190129233127-fd36f4220a90/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
 | 
			
		||||
@ -793,10 +794,8 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
 | 
			
		||||
github.com/prometheus/common v0.4.1/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y86RQel1bk4=
 | 
			
		||||
github.com/prometheus/common v0.6.0/go.mod h1:eBmuwkDJBwy6iBfxCBob6t6dR6ENT/y+J+Zk0j9GMYc=
 | 
			
		||||
github.com/prometheus/common v0.10.0/go.mod h1:Tlit/dnDKsSWFlCLTWaA1cyBgKHSMdTB80sz/V91rCo=
 | 
			
		||||
github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc=
 | 
			
		||||
github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw=
 | 
			
		||||
github.com/prometheus/common v0.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
 | 
			
		||||
github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
 | 
			
		||||
github.com/prometheus/common v0.63.0 h1:YR/EIY1o3mEFP/kZCD7iDMnLPlGyuU2Gb3HIcXnA98k=
 | 
			
		||||
github.com/prometheus/common v0.63.0/go.mod h1:VVFF/fBIoToEnWRVkYoXEkq3R3paCoxG9PXP74SnV18=
 | 
			
		||||
github.com/prometheus/procfs v0.0.0-20180125133057-cb4147076ac7/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 | 
			
		||||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
 | 
			
		||||
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
 | 
			
		||||
@ -817,16 +816,15 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
 | 
			
		||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
 | 
			
		||||
github.com/rogpeppe/fastuuid v1.2.0/go.mod h1:jVj6XXZzXRy/MSR5jhDC/2q6DgLz+nrA6LYCDYWNEvQ=
 | 
			
		||||
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 | 
			
		||||
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
 | 
			
		||||
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
 | 
			
		||||
github.com/russross/blackfriday v1.6.0 h1:KqfZb0pUVN2lYqZUYRddxF4OR8ZMURnJIG5Y3VRLtww=
 | 
			
		||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
 | 
			
		||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
 | 
			
		||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 | 
			
		||||
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
 | 
			
		||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
 | 
			
		||||
github.com/safchain/ethtool v0.0.0-20190326074333-42ed695e3de8/go.mod h1:Z0q5wiBQGYcxhMZ6gUqHn6pYNLypFAvaL3UvgZLR0U4=
 | 
			
		||||
github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 | 
			
		||||
github.com/schollz/progressbar/v3 v3.17.1 h1:bI1MTaoQO+v5kzklBjYNRQLoVpe0zbyRZNK6DFkVC5U=
 | 
			
		||||
github.com/schollz/progressbar/v3 v3.17.1/go.mod h1:RzqpnsPQNjUyIgdglUjRLgD7sVnxN1wpmBMV+UiEbL4=
 | 
			
		||||
github.com/schollz/progressbar/v3 v3.18.0 h1:uXdoHABRFmNIjUfte/Ex7WtuyVslrw2wVPQmCN62HpA=
 | 
			
		||||
github.com/schollz/progressbar/v3 v3.18.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=
 | 
			
		||||
github.com/sclevine/spec v1.2.0/go.mod h1:W4J29eT/Kzv7/b9IWLB055Z+qvVC9vt0Arko24q7p+U=
 | 
			
		||||
github.com/seccomp/libseccomp-golang v0.9.1/go.mod h1:GbW5+tmTXfcxTToHLXlScSlAvWlF4P2Ca7zGrPiEpWo=
 | 
			
		||||
github.com/seccomp/libseccomp-golang v0.9.2-0.20210429002308-3879420cc921/go.mod h1:JA8cRccbGaA1s33RQf7Y1+q9gHmZX1yB/z9WDN1C6fg=
 | 
			
		||||
@ -843,8 +841,8 @@ github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic
 | 
			
		||||
github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
 | 
			
		||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
 | 
			
		||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
 | 
			
		||||
github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY=
 | 
			
		||||
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
 | 
			
		||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
 | 
			
		||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
 | 
			
		||||
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
 | 
			
		||||
github.com/smartystreets/goconvey v0.0.0-20190330032615-68dc04aab96a/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
 | 
			
		||||
github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4kGIyLM=
 | 
			
		||||
@ -859,8 +857,8 @@ github.com/spf13/cobra v0.0.1/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3
 | 
			
		||||
github.com/spf13/cobra v0.0.2-0.20171109065643-2da4a54c5cee/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
 | 
			
		||||
github.com/spf13/cobra v0.0.3/go.mod h1:1l0Ry5zgKvJasoi3XT1TypsSe7PqH0Sj9dhYf7v3XqQ=
 | 
			
		||||
github.com/spf13/cobra v1.0.0/go.mod h1:/6GTrnGXV9HjY+aR4k0oJ5tcvakLuG6EuKReYlHNrgE=
 | 
			
		||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
 | 
			
		||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
 | 
			
		||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
 | 
			
		||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
 | 
			
		||||
github.com/spf13/jwalterweatherman v0.0.0-20141219030609-3d60171a6431/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 | 
			
		||||
github.com/spf13/jwalterweatherman v1.0.0 h1:XHEdyB+EcvlqZamSM4ZOMGlc93t6AcsBEu9Gc1vn7yk=
 | 
			
		||||
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
 | 
			
		||||
@ -869,8 +867,9 @@ github.com/spf13/pflag v1.0.0/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnIn
 | 
			
		||||
github.com/spf13/pflag v1.0.1-0.20171106142849-4c012f6dcd95/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 | 
			
		||||
github.com/spf13/pflag v1.0.1/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 | 
			
		||||
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
 | 
			
		||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
 | 
			
		||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 | 
			
		||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
 | 
			
		||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
 | 
			
		||||
github.com/spf13/viper v0.0.0-20150530192845-be5ff3e4840c/go.mod h1:A8kyI5cUJhb8N+3pkfONlcEcZbueH6nhAm0Fq7SrnBM=
 | 
			
		||||
github.com/spf13/viper v1.4.0 h1:yXHLWeravcrgGyFSyCgdYpXQ9dR9c/WED3pg1RhxqEU=
 | 
			
		||||
github.com/spf13/viper v1.4.0/go.mod h1:PTJ7Z/lr49W6bUbkmS1V3by4uWynFiR9p7+dSq/yZzE=
 | 
			
		||||
@ -905,8 +904,6 @@ github.com/urfave/cli v1.20.0/go.mod h1:70zkFmudgCuE/ngEzBv17Jvp/497gISqfk5gWijb
 | 
			
		||||
github.com/urfave/cli v1.22.1/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 | 
			
		||||
github.com/urfave/cli v1.22.2/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 | 
			
		||||
github.com/urfave/cli v1.22.4/go.mod h1:Gos4lmkARVdJ6EkW0WaNv/tZAAMe9V7XWyB60NtXRu0=
 | 
			
		||||
github.com/urfave/cli/v3 v3.0.0-alpha9.1.0.20241019193437-5053ec708a44 h1:BeSTAZEDkDVNv9EOrycIGCkEg+6EhRRgSsbdc93Q3OM=
 | 
			
		||||
github.com/urfave/cli/v3 v3.0.0-alpha9.1.0.20241019193437-5053ec708a44/go.mod h1:FnIeEMYu+ko8zP1F9Ypr3xkZMIDqW3DR92yUtY39q1Y=
 | 
			
		||||
github.com/vbatts/tar-split v0.11.2/go.mod h1:vV3ZuO2yWSVsz+pfFzDG/upWH1JhjOiEaWq6kXyQ3VI=
 | 
			
		||||
github.com/vishvananda/netlink v0.0.0-20181108222139-023a6dafdcdf/go.mod h1:+SR5DhBJrl6ZM7CoCKvpw5BKroDKQ+PJqOg65H/2ktk=
 | 
			
		||||
github.com/vishvananda/netlink v1.1.0/go.mod h1:cTgwzPIzzgDAYoQrMm0EdrjRUBkTqKYppBueQtXaqoE=
 | 
			
		||||
@ -927,6 +924,8 @@ github.com/xeipuuv/gojsonschema v0.0.0-20180618132009-1d523034197f/go.mod h1:5yf
 | 
			
		||||
github.com/xeipuuv/gojsonschema v1.2.0 h1:LhYJRs+L4fBtjZUfuSZIKGeVu0QRy8e5Xi7D17UxZ74=
 | 
			
		||||
github.com/xeipuuv/gojsonschema v1.2.0/go.mod h1:anYRn/JVcOK2ZgGU+IjEV4nwlhoK5sQluxsYJ78Id3Y=
 | 
			
		||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
 | 
			
		||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
 | 
			
		||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
 | 
			
		||||
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
 | 
			
		||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 | 
			
		||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
 | 
			
		||||
@ -947,49 +946,29 @@ go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 | 
			
		||||
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
 | 
			
		||||
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
 | 
			
		||||
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
 | 
			
		||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0 h1:DheMAlT6POBP+gh8RUH19EOTnQIor5QE0uSRPtzCpSw=
 | 
			
		||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.57.0/go.mod h1:wZcGmeVO9nzP67aYSLDqXNWK87EZWhi7JWj1v7ZXf94=
 | 
			
		||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0 h1:yd02MEjBdJkG3uabWP9apV+OuWRIXGDuJEUJbOHmCFU=
 | 
			
		||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.58.0/go.mod h1:umTcuxiv1n/s/S6/c2AT/g2CQ7u5C59sHDNmfSwgz7Q=
 | 
			
		||||
go.opentelemetry.io/otel v1.32.0 h1:WnBN+Xjcteh0zdk01SVqV55d/m62NJLJdIyb4y/WO5U=
 | 
			
		||||
go.opentelemetry.io/otel v1.32.0/go.mod h1:00DCVSB0RQcnzlwyTfqtxSm+DRr9hpYrHjNGiBHVQIg=
 | 
			
		||||
go.opentelemetry.io/otel v1.33.0 h1:/FerN9bax5LoK51X/sI0SVYrjSE0/yUL7DpxW4K3FWw=
 | 
			
		||||
go.opentelemetry.io/otel v1.33.0/go.mod h1:SUUkR6csvUQl+yjReHu5uM3EtVV7MBm5FHKRlNx4I8I=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0 h1:j7ZSD+5yn+lo3sGV69nW04rRR0jhYnBwjuX3r0HvnK0=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.32.0/go.mod h1:WXbYJTUaZXAbYd8lbgGuvih0yuCfOFC5RJoYnoLcGz8=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0 h1:7F29RDmnlqk6B5d+sUqemt8TBfDqxryYW5gX6L74RFA=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.33.0/go.mod h1:ZiGDq7xwDMKmWDrN1XsXAj0iC7hns+2DhxBFSncNHSE=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0 h1:IJFEoHiytixx8cMiVAO+GmHR6Frwu+u5Ur8njpFO6Ac=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.32.0/go.mod h1:3rHrKNtLIoS0oZwkY2vxi+oJcwFRWdtUyRII+so45p8=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0 h1:Vh5HayB/0HHfOQA7Ctx69E/Y/DcQSMPpKANYVMQ7fBA=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.33.0/go.mod h1:cpgtDBaqD/6ok/UG0jT15/uKjAY8mRA53diogHBg3UI=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0 h1:9kV11HXBHZAvuPUZxmMWrH8hZn/6UnHX4K0mu36vNsU=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.32.0/go.mod h1:JyA0FHXe22E1NeNiHmVp7kFHglnexDQ7uRWDiiJ1hKQ=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0 h1:5pojmb1U1AogINhN3SurB+zm/nIcusopeBNp42f45QM=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.33.0/go.mod h1:57gTHJSE5S1tqg+EKsLPlTWhpHMsWlVmer+LA926XiA=
 | 
			
		||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0 h1:sbiXRNDSWJOTobXh5HyQKjq6wUC5tNybqjIqDpAY4CU=
 | 
			
		||||
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.60.0/go.mod h1:69uWxva0WgAA/4bu2Yy70SLDBwZXuQ6PbBpbsa5iZrQ=
 | 
			
		||||
go.opentelemetry.io/otel v1.35.0 h1:xKWKPxrxB6OtMCbmMY021CqC45J+3Onta9MqjhnusiQ=
 | 
			
		||||
go.opentelemetry.io/otel v1.35.0/go.mod h1:UEqy8Zp11hpkUrL73gSlELM0DupHoiq72dR+Zqel/+Y=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0 h1:QcFwRrZLc82r8wODjvyCbP7Ifp3UANaBSmhDSFjnqSc=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.35.0/go.mod h1:CXIWhUomyWBG/oY2/r/kLp6K/cmx9e/7DLpBuuGdLCA=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0 h1:1fTNlAIJZGWLP5FVu0fikVry1IsiUnXjf7QFvoNN3Xw=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.35.0/go.mod h1:zjPK58DtkqQFn+YUMbx0M2XV3QgKU0gS9LeGohREyK4=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0 h1:m639+BofXTvcY1q8CGs4ItwQarYtJPOWmVobfM1HpVI=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.35.0/go.mod h1:LjReUci/F4BUyv+y4dwnq3h/26iNOeC3wAIqgvTIZVo=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
 | 
			
		||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
 | 
			
		||||
go.opentelemetry.io/otel/metric v1.32.0 h1:xV2umtmNcThh2/a/aCP+h64Xx5wsj8qqnkYZktzNa0M=
 | 
			
		||||
go.opentelemetry.io/otel/metric v1.32.0/go.mod h1:jH7CIbbK6SH2V2wE16W05BHCtIDzauciCRLoc/SyMv8=
 | 
			
		||||
go.opentelemetry.io/otel/metric v1.33.0 h1:r+JOocAyeRVXD8lZpjdQjzMadVZp2M4WmQ+5WtEnklQ=
 | 
			
		||||
go.opentelemetry.io/otel/metric v1.33.0/go.mod h1:L9+Fyctbp6HFTddIxClbQkjtubW6O9QS3Ann/M82u6M=
 | 
			
		||||
go.opentelemetry.io/otel/sdk v1.32.0 h1:RNxepc9vK59A8XsgZQouW8ue8Gkb4jpWtJm9ge5lEG4=
 | 
			
		||||
go.opentelemetry.io/otel/sdk v1.32.0/go.mod h1:LqgegDBjKMmb2GC6/PrTnteJG39I8/vJCAP9LlJXEjU=
 | 
			
		||||
go.opentelemetry.io/otel/sdk v1.33.0 h1:iax7M131HuAm9QkZotNHEfstof92xM+N8sr3uHXc2IM=
 | 
			
		||||
go.opentelemetry.io/otel/sdk v1.33.0/go.mod h1:A1Q5oi7/9XaMlIWzPSxLRWOI8nG3FnzHJNbiENQuihM=
 | 
			
		||||
go.opentelemetry.io/otel/sdk/metric v1.32.0 h1:rZvFnvmvawYb0alrYkjraqJq0Z4ZUJAiyYCU9snn1CU=
 | 
			
		||||
go.opentelemetry.io/otel/sdk/metric v1.32.0/go.mod h1:PWeZlq0zt9YkYAp3gjKZ0eicRYvOh1Gd+X99x6GHpCQ=
 | 
			
		||||
go.opentelemetry.io/otel/sdk/metric v1.33.0 h1:Gs5VK9/WUJhNXZgn8MR6ITatvAmKeIuCtNbsP3JkNqU=
 | 
			
		||||
go.opentelemetry.io/otel/sdk/metric v1.33.0/go.mod h1:dL5ykHZmm1B1nVRk9dDjChwDmt81MjVp3gLkQRwKf/Q=
 | 
			
		||||
go.opentelemetry.io/otel/trace v1.32.0 h1:WIC9mYrXf8TmY/EXuULKc8hR17vE+Hjv2cssQDe03fM=
 | 
			
		||||
go.opentelemetry.io/otel/trace v1.32.0/go.mod h1:+i4rkvCraA+tG6AzwloGaCtkx53Fa+L+V8e9a7YvhT8=
 | 
			
		||||
go.opentelemetry.io/otel/trace v1.33.0 h1:cCJuF7LRjUFso9LPnEAHJDB2pqzp+hbO8eu1qqW2d/s=
 | 
			
		||||
go.opentelemetry.io/otel/trace v1.33.0/go.mod h1:uIcdVUZMpTAmz0tI1z04GoVSezK37CbGV4fr1f2nBck=
 | 
			
		||||
go.opentelemetry.io/otel/metric v1.35.0 h1:0znxYu2SNyuMSQT4Y9WDWej0VpcsxkuklLa4/siN90M=
 | 
			
		||||
go.opentelemetry.io/otel/metric v1.35.0/go.mod h1:nKVFgxBZ2fReX6IlyW28MgZojkoAkJGaE8CpgeAU3oE=
 | 
			
		||||
go.opentelemetry.io/otel/sdk v1.35.0 h1:iPctf8iprVySXSKJffSS79eOjl9pvxV9ZqOWT0QejKY=
 | 
			
		||||
go.opentelemetry.io/otel/sdk v1.35.0/go.mod h1:+ga1bZliga3DxJ3CQGg3updiaAJoNECOgJREo9KHGQg=
 | 
			
		||||
go.opentelemetry.io/otel/sdk/metric v1.35.0 h1:1RriWBmCKgkeHEhM7a2uMjMUfP7MsOF5JpUCaEqEI9o=
 | 
			
		||||
go.opentelemetry.io/otel/sdk/metric v1.35.0/go.mod h1:is6XYCUMpcKi+ZsOvfluY5YstFnhW0BidkR+gL+qN+w=
 | 
			
		||||
go.opentelemetry.io/otel/trace v1.35.0 h1:dPpEfJu1sDIqruz7BHFG3c7528f6ddfSWfFDVt/xgMs=
 | 
			
		||||
go.opentelemetry.io/otel/trace v1.35.0/go.mod h1:WUk7DtFp1Aw2MkvqGdwiXYDZZNvA/1J8o6xRXLrIkyc=
 | 
			
		||||
go.opentelemetry.io/proto/otlp v0.7.0/go.mod h1:PqfVotwruBrMGOCsRd/89rSnXhoiJIqeYNgFYFoEGnI=
 | 
			
		||||
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
 | 
			
		||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
 | 
			
		||||
go.opentelemetry.io/proto/otlp v1.4.0 h1:TA9WRvW6zMwP+Ssb6fLoUIuirti1gGbP28GcKG1jgeg=
 | 
			
		||||
go.opentelemetry.io/proto/otlp v1.4.0/go.mod h1:PPBWZIP98o2ElSqI35IHfu7hIhSwvc5N38Jw8pXuGFY=
 | 
			
		||||
go.opentelemetry.io/proto/otlp v1.5.0 h1:xJvq7gMzB31/d406fB8U5CBdyQGw4P399D1aQWU/3i4=
 | 
			
		||||
go.opentelemetry.io/proto/otlp v1.5.0/go.mod h1:keN8WnHxOy8PG0rQZjJJ5A2ebUoafqWp0eVQ4yIXvJ4=
 | 
			
		||||
go.uber.org/atomic v1.3.2/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 | 
			
		||||
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
 | 
			
		||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
 | 
			
		||||
@ -1014,10 +993,8 @@ golang.org/x/crypto v0.0.0-20201117144127-c1f2f97bffc9/go.mod h1:jdWPYTVW3xRLrWP
 | 
			
		||||
golang.org/x/crypto v0.0.0-20210322153248-0c34fe9e7dc2/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 | 
			
		||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 | 
			
		||||
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ=
 | 
			
		||||
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg=
 | 
			
		||||
golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
 | 
			
		||||
golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
 | 
			
		||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
 | 
			
		||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
 | 
			
		||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 | 
			
		||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
 | 
			
		||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
 | 
			
		||||
@ -1028,10 +1005,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0
 | 
			
		||||
golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
 | 
			
		||||
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
 | 
			
		||||
golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU=
 | 
			
		||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f h1:XdNn9LlyWAhLVp6P/i8QYBW+hlyhrhei9uErw2B5GJo=
 | 
			
		||||
golang.org/x/exp v0.0.0-20241108190413-2d47ceb2692f/go.mod h1:D5SMRVC3C2/4+F/DB1wZsLRnSNimn2Sp/NPsCrsv8ak=
 | 
			
		||||
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
 | 
			
		||||
golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw=
 | 
			
		||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM=
 | 
			
		||||
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
 | 
			
		||||
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
 | 
			
		||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
 | 
			
		||||
@ -1095,10 +1070,8 @@ golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96b
 | 
			
		||||
golang.org/x/net v0.0.0-20210825183410-e898025ed96a/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 | 
			
		||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
 | 
			
		||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
 | 
			
		||||
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo=
 | 
			
		||||
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM=
 | 
			
		||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
 | 
			
		||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
 | 
			
		||||
golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
 | 
			
		||||
golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 | 
			
		||||
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
 | 
			
		||||
@ -1116,10 +1089,8 @@ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJ
 | 
			
		||||
golang.org/x/sync v0.0.0-20201207232520-09787c993a3a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
 | 
			
		||||
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ=
 | 
			
		||||
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 | 
			
		||||
golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
 | 
			
		||||
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
 | 
			
		||||
golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw=
 | 
			
		||||
golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
 | 
			
		||||
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
golang.org/x/sys v0.0.0-20180905080454-ebe1bf3edb33/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 | 
			
		||||
@ -1189,6 +1160,7 @@ golang.org/x/sys v0.0.0-20210426230700-d19ff857e887/go.mod h1:h1NjWce9XRLGQEsW7w
 | 
			
		||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20210906170528-6f6e22806c34/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20211025201205-69cdffdb9359/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20211116061358-0a5406a5449c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
@ -1196,20 +1168,15 @@ golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBc
 | 
			
		||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.0.0-20220908164124-27713097b956/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 | 
			
		||||
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s=
 | 
			
		||||
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 | 
			
		||||
golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
 | 
			
		||||
golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
 | 
			
		||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
 | 
			
		||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
 | 
			
		||||
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 | 
			
		||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 | 
			
		||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
 | 
			
		||||
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU=
 | 
			
		||||
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E=
 | 
			
		||||
golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
 | 
			
		||||
golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
 | 
			
		||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
 | 
			
		||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
 | 
			
		||||
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 | 
			
		||||
@ -1219,18 +1186,16 @@ golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 | 
			
		||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 | 
			
		||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
 | 
			
		||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
 | 
			
		||||
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug=
 | 
			
		||||
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4=
 | 
			
		||||
golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
 | 
			
		||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
 | 
			
		||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
 | 
			
		||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
 | 
			
		||||
golang.org/x/time v0.0.0-20180412165947-fbb02b2291d2/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 | 
			
		||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 | 
			
		||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 | 
			
		||||
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 | 
			
		||||
golang.org/x/time v0.0.0-20200416051211-89c76fbcd5d1/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 | 
			
		||||
golang.org/x/time v0.0.0-20200630173020-3af7569d3a1e/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
 | 
			
		||||
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
 | 
			
		||||
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 | 
			
		||||
golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0=
 | 
			
		||||
golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 | 
			
		||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 | 
			
		||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 | 
			
		||||
golang.org/x/tools v0.0.0-20181011042414-1f849cf54d09/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 | 
			
		||||
@ -1324,14 +1289,10 @@ google.golang.org/genproto v0.0.0-20200513103714-09dca8ec2884/go.mod h1:55QSHmfG
 | 
			
		||||
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
 | 
			
		||||
google.golang.org/genproto v0.0.0-20200527145253-8367513e4ece/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
 | 
			
		||||
google.golang.org/genproto v0.0.0-20201110150050-8816d57aaa9a/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
 | 
			
		||||
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697 h1:pgr/4QbFyktUv9CtQ/Fq4gzEE6/Xs7iCXbktaGzLHbQ=
 | 
			
		||||
google.golang.org/genproto/googleapis/api v0.0.0-20241118233622-e639e219e697/go.mod h1:+D9ySVjN8nY8YCVjc5O7PZDIdZporIDY3KaGfJunh88=
 | 
			
		||||
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8 h1:st3LcW/BPi75W4q1jJTEor/QWwbNlPlDG0JTn6XhZu0=
 | 
			
		||||
google.golang.org/genproto/googleapis/api v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:klhJGKFyG8Tn50enBn7gizg4nXGXJ+jqEREdCWaPcV4=
 | 
			
		||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697 h1:LWZqQOEjDyONlF1H6afSWpAL/znlREo2tHfLoe+8LMA=
 | 
			
		||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241118233622-e639e219e697/go.mod h1:5uTbfoYQed2U9p3KIj2/Zzm02PYhndfdmML0qC3q3FU=
 | 
			
		||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8 h1:TqExAhdPaB60Ux47Cn0oLV07rGnxZzIsaRhQaqS666A=
 | 
			
		||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20241223144023-3abc09e42ca8/go.mod h1:lcTa1sDdWEIHMWlITnIczmw5w60CF9ffkb8Z+DVmmjA=
 | 
			
		||||
google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4 h1:IFnXJq3UPB3oBREOodn1v1aGQeZYQclEmvWRMN0PSsY=
 | 
			
		||||
google.golang.org/genproto/googleapis/api v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:c8q6Z6OCqnfVIqUFJkCzKcrj8eCvUrz+K4KRzSTuANg=
 | 
			
		||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4 h1:iK2jbkWL86DXjEx0qiHcRE9dE4/Ahua5k6V8OWFb//c=
 | 
			
		||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20250313205543-e70fdf4c4cb4/go.mod h1:LuRYeWDFV6WOn90g357N17oMCaxpgCnbi/44qJvDn2I=
 | 
			
		||||
google.golang.org/grpc v0.0.0-20160317175043-d3ddb4469d5a/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 | 
			
		||||
google.golang.org/grpc v1.0.5/go.mod h1:yo6s7OP7yaDglbqo1J04qKzAhqBH6lvTonzMVmEdcZw=
 | 
			
		||||
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
 | 
			
		||||
@ -1351,10 +1312,8 @@ google.golang.org/grpc v1.33.1/go.mod h1:fr5YgcSWrqhRRxogOsw7RzIpsmvOZ6IcH4kBYTp
 | 
			
		||||
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
 | 
			
		||||
google.golang.org/grpc v1.36.0/go.mod h1:qjiiYl8FncCW8feJPdyg3v6XW24KsRHe+dy9BAGRRjU=
 | 
			
		||||
google.golang.org/grpc v1.40.0/go.mod h1:ogyxbiOoUXAkP+4+xa6PZSE9DZgIHtSpzjDTB9KAK34=
 | 
			
		||||
google.golang.org/grpc v1.68.0 h1:aHQeeJbo8zAkAa3pRzrVjZlbz6uSfeOXlJNQM0RAbz0=
 | 
			
		||||
google.golang.org/grpc v1.68.0/go.mod h1:fmSPC5AsjSBCK54MyHRx48kpOti1/jRfOlwEWywNjWA=
 | 
			
		||||
google.golang.org/grpc v1.69.2 h1:U3S9QEtbXC0bYNvRtcoklF3xGtLViumSYxWykJS+7AU=
 | 
			
		||||
google.golang.org/grpc v1.69.2/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4=
 | 
			
		||||
google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg=
 | 
			
		||||
google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec=
 | 
			
		||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
 | 
			
		||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
 | 
			
		||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
 | 
			
		||||
@ -1368,10 +1327,8 @@ google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlba
 | 
			
		||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
 | 
			
		||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 | 
			
		||||
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
 | 
			
		||||
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io=
 | 
			
		||||
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
 | 
			
		||||
google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
 | 
			
		||||
google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
 | 
			
		||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
 | 
			
		||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
 | 
			
		||||
gopkg.in/airbrake/gobrake.v2 v2.0.9/go.mod h1:/h5ZAUhDkGaJfjzjKLSjv6zCL6O0LLBxU4K+aSYdM/U=
 | 
			
		||||
gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw=
 | 
			
		||||
gopkg.in/cenkalti/backoff.v2 v2.2.1 h1:eJ9UAg01/HIHG987TwxvnzK2MgxXq97YY6rYDpY9aII=
 | 
			
		||||
@ -1411,12 +1368,11 @@ gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
 | 
			
		||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
 | 
			
		||||
gotest.tools v2.2.0+incompatible h1:VsBPFP1AI068pPrMxtb/S8Zkgf9xEmTLJjfM+P5UIEo=
 | 
			
		||||
gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw=
 | 
			
		||||
gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk=
 | 
			
		||||
gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8=
 | 
			
		||||
gotest.tools/v3 v3.5.1 h1:EENdUnS3pdur5nybKYIh2Vfgc8IUNBjxDPSjtiJcOzU=
 | 
			
		||||
gotest.tools/v3 v3.5.1/go.mod h1:isy3WKz7GK6uNw/sbHzfKBLvlvXwUyV06n6brMxxopU=
 | 
			
		||||
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
 | 
			
		||||
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
 | 
			
		||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 | 
			
		||||
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 | 
			
		||||
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 | 
			
		||||
 | 
			
		||||
@ -36,7 +36,7 @@ func Get(appName string) (App, error) {
 | 
			
		||||
		return App{}, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Debugf("retrieved %s for %s", app, appName)
 | 
			
		||||
	log.Debugf("loaded app %s: %s", appName, app)
 | 
			
		||||
 | 
			
		||||
	return app, nil
 | 
			
		||||
}
 | 
			
		||||
@ -91,6 +91,17 @@ type App struct {
 | 
			
		||||
	Path   string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// String outputs a human-friendly string representation.
 | 
			
		||||
func (a App) String() string {
 | 
			
		||||
	out := fmt.Sprintf("{name: %s, ", a.Name)
 | 
			
		||||
	out += fmt.Sprintf("recipe: %s, ", a.Recipe)
 | 
			
		||||
	out += fmt.Sprintf("domain: %s, ", a.Domain)
 | 
			
		||||
	out += fmt.Sprintf("env %s, ", a.Env)
 | 
			
		||||
	out += fmt.Sprintf("server %s, ", a.Server)
 | 
			
		||||
	out += fmt.Sprintf("path %s}", a.Path)
 | 
			
		||||
	return out
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Type aliases to make code hints easier to understand
 | 
			
		||||
 | 
			
		||||
// AppName is AppName
 | 
			
		||||
@ -235,8 +246,6 @@ func ReadAppEnvFile(appFile AppFile, name AppName) (App, error) {
 | 
			
		||||
		return App{}, fmt.Errorf("env file for %s couldn't be read: %s", name, err.Error())
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Debugf("read env %s from %s", env, appFile.Path)
 | 
			
		||||
 | 
			
		||||
	app, err := NewApp(env, name, appFile)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return App{}, fmt.Errorf("env file for %s has issues: %s", name, err.Error())
 | 
			
		||||
@ -494,13 +503,13 @@ func GetAppComposeConfig(recipe string, opts stack.Deploy, appEnv envfile.AppEnv
 | 
			
		||||
func ExposeAllEnv(stackName string, compose *composetypes.Config, appEnv envfile.AppEnv) {
 | 
			
		||||
	for _, service := range compose.Services {
 | 
			
		||||
		if service.Name == "app" {
 | 
			
		||||
			log.Debugf("add the following environment to the app service config of %s:", stackName)
 | 
			
		||||
			log.Debugf("adding env vars to %s service config", stackName)
 | 
			
		||||
			for k, v := range appEnv {
 | 
			
		||||
				_, exists := service.Environment[k]
 | 
			
		||||
				if !exists {
 | 
			
		||||
					value := v
 | 
			
		||||
					service.Environment[k] = &value
 | 
			
		||||
					log.Debugf("add env var: %s value: %s to %s", k, value, stackName)
 | 
			
		||||
					log.Debugf("%s: %s: %s", stackName, k, value)
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
@ -569,16 +578,19 @@ func ReadAbraShCmdNames(abraSh string) ([]string, error) {
 | 
			
		||||
	return cmdNames, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (a App) WriteRecipeVersion(version string, dryRun bool) error {
 | 
			
		||||
// Wipe removes the version from the app .env file.
 | 
			
		||||
func (a App) WipeRecipeVersion() error {
 | 
			
		||||
	file, err := os.Open(a.Path)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer file.Close()
 | 
			
		||||
 | 
			
		||||
	skipped := false
 | 
			
		||||
	scanner := bufio.NewScanner(file)
 | 
			
		||||
	lines := []string{}
 | 
			
		||||
	var (
 | 
			
		||||
		lines   []string
 | 
			
		||||
		scanner = bufio.NewScanner(file)
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	for scanner.Scan() {
 | 
			
		||||
		line := scanner.Text()
 | 
			
		||||
		if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") {
 | 
			
		||||
@ -591,13 +603,58 @@ func (a App) WriteRecipeVersion(version string, dryRun bool) error {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if strings.Contains(line, version) {
 | 
			
		||||
		splitted := strings.Split(line, ":")
 | 
			
		||||
		lines = append(lines, splitted[0])
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := scanner.Err(); err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Debugf("version wiped from %s.env", a.Domain)
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// WriteRecipeVersion writes the recipe version to the app .env file.
 | 
			
		||||
func (a App) WriteRecipeVersion(version string, dryRun bool) error {
 | 
			
		||||
	file, err := os.Open(a.Path)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
	defer file.Close()
 | 
			
		||||
 | 
			
		||||
	var (
 | 
			
		||||
		dirtyVersion string
 | 
			
		||||
		skipped      bool
 | 
			
		||||
		lines        []string
 | 
			
		||||
		scanner      = bufio.NewScanner(file)
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	for scanner.Scan() {
 | 
			
		||||
		line := scanner.Text()
 | 
			
		||||
		if !strings.HasPrefix(line, "RECIPE=") && !strings.HasPrefix(line, "TYPE=") {
 | 
			
		||||
			lines = append(lines, line)
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if strings.HasPrefix(line, "#") {
 | 
			
		||||
			lines = append(lines, line)
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if strings.Contains(line, version) && !a.Recipe.Dirty && !strings.HasSuffix(line, config.DIRTY_DEFAULT) {
 | 
			
		||||
			skipped = true
 | 
			
		||||
			lines = append(lines, line)
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		splitted := strings.Split(line, ":")
 | 
			
		||||
 | 
			
		||||
		line = fmt.Sprintf("%s:%s", splitted[0], version)
 | 
			
		||||
		lines = append(lines, line)
 | 
			
		||||
	}
 | 
			
		||||
@ -606,6 +663,10 @@ func (a App) WriteRecipeVersion(version string, dryRun bool) error {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if a.Recipe.Dirty && dirtyVersion != "" {
 | 
			
		||||
		version = dirtyVersion
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !dryRun {
 | 
			
		||||
		if err := os.WriteFile(a.Path, []byte(strings.Join(lines, "\n")), os.ModePerm); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
@ -615,7 +676,7 @@ func (a App) WriteRecipeVersion(version string, dryRun bool) error {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !skipped {
 | 
			
		||||
		log.Infof("version %s saved to %s.env", version, a.Domain)
 | 
			
		||||
		log.Debugf("version %s saved to %s.env", version, a.Domain)
 | 
			
		||||
	} else {
 | 
			
		||||
		log.Debugf("skipping version %s write as already exists in %s.env", version, a.Domain)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -198,3 +198,29 @@ func compareFilter(t *testing.T, f1 filters.Args, f2 map[string]map[string]bool)
 | 
			
		||||
		t.Errorf("filters mismatch (-want +got):\n%s", diff)
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestWriteRecipeVersionOverwrite(t *testing.T) {
 | 
			
		||||
	app, err := appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	defer t.Cleanup(func() {
 | 
			
		||||
		if err := app.WipeRecipeVersion(); err != nil {
 | 
			
		||||
			t.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	assert.Equal(t, "", app.Recipe.EnvVersion)
 | 
			
		||||
 | 
			
		||||
	if err := app.WriteRecipeVersion("foo", false); err != nil {
 | 
			
		||||
		t.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	app, err = appPkg.GetApp(testPkg.ExpectedAppFiles, testPkg.AppName)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	assert.Equal(t, "foo", app.Recipe.EnvVersion)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -44,6 +44,16 @@ func SetChaosVersionLabel(compose *composetypes.Config, stackName string, chaosV
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func SetVersionLabel(compose *composetypes.Config, stackName string, version string) {
 | 
			
		||||
	for _, service := range compose.Services {
 | 
			
		||||
		if service.Name == "app" {
 | 
			
		||||
			log.Debugf("set label 'coop-cloud.%s.version' to %v for %s", stackName, version, stackName)
 | 
			
		||||
			labelKey := fmt.Sprintf("coop-cloud.%s.version", stackName)
 | 
			
		||||
			service.Deploy.Labels[labelKey] = version
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// SetUpdateLabel adds env ENABLE_AUTO_UPDATE as label to enable/disable the
 | 
			
		||||
// auto update process for this app. The default if this variable is not set is to disable
 | 
			
		||||
// the auto update process.
 | 
			
		||||
 | 
			
		||||
@ -1,21 +1,26 @@
 | 
			
		||||
package autocomplete
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"sort"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/app"
 | 
			
		||||
	appPkg "coopcloud.tech/abra/pkg/app"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/recipe"
 | 
			
		||||
	"github.com/spf13/cobra"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// AppNameComplete copletes app names.
 | 
			
		||||
func AppNameComplete() ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
	appNames, err := app.GetAppNames()
 | 
			
		||||
	appFiles, err := app.LoadAppFiles("")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
		return nil, cobra.ShellCompDirectiveError
 | 
			
		||||
		err := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
		return []string{err}, cobra.ShellCompDirectiveError
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var appNames []string
 | 
			
		||||
	for appName := range appFiles {
 | 
			
		||||
		appNames = append(appNames, appName)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return appNames, cobra.ShellCompDirectiveDefault
 | 
			
		||||
@ -24,8 +29,8 @@ func AppNameComplete() ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
func ServiceNameComplete(appName string) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
	serviceNames, err := app.GetAppServiceNames(appName)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
		return nil, cobra.ShellCompDirectiveError
 | 
			
		||||
		err := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
		return []string{err}, cobra.ShellCompDirectiveError
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return serviceNames, cobra.ShellCompDirectiveDefault
 | 
			
		||||
@ -35,8 +40,8 @@ func ServiceNameComplete(appName string) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
func RecipeNameComplete() ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
	catl, err := recipe.ReadRecipeCatalogue(false)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
		return nil, cobra.ShellCompDirectiveError
 | 
			
		||||
		err := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
		return []string{err}, cobra.ShellCompDirectiveError
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var recipeNames []string
 | 
			
		||||
@ -49,10 +54,10 @@ func RecipeNameComplete() ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
 | 
			
		||||
// RecipeVersionComplete completes versions for the recipe.
 | 
			
		||||
func RecipeVersionComplete(recipeName string) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
	catl, err := recipe.ReadRecipeCatalogue(false)
 | 
			
		||||
	catl, err := recipe.ReadRecipeCatalogue(true)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
		return nil, cobra.ShellCompDirectiveError
 | 
			
		||||
		err := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
		return []string{err}, cobra.ShellCompDirectiveError
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var recipeVersions []string
 | 
			
		||||
@ -69,8 +74,8 @@ func RecipeVersionComplete(recipeName string) ([]string, cobra.ShellCompDirectiv
 | 
			
		||||
func ServerNameComplete() ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
	files, err := app.LoadAppFiles("")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
		return nil, cobra.ShellCompDirectiveError
 | 
			
		||||
		err := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
		return []string{err}, cobra.ShellCompDirectiveError
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var serverNames []string
 | 
			
		||||
@ -85,14 +90,14 @@ func ServerNameComplete() ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
func CommandNameComplete(appName string) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
	app, err := app.Get(appName)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
		return nil, cobra.ShellCompDirectiveError
 | 
			
		||||
		err := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
		return []string{err}, cobra.ShellCompDirectiveError
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmdNames, err := appPkg.ReadAbraShCmdNames(app.Recipe.AbraShPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
		return nil, cobra.ShellCompDirectiveError
 | 
			
		||||
		err := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
		return []string{err}, cobra.ShellCompDirectiveError
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sort.Strings(cmdNames)
 | 
			
		||||
@ -106,8 +111,8 @@ func SecretComplete(recipeName string) ([]string, cobra.ShellCompDirective) {
 | 
			
		||||
 | 
			
		||||
	config, err := r.GetComposeConfig(nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Debugf("autocomplete failed: %s", err)
 | 
			
		||||
		return nil, cobra.ShellCompDirectiveError
 | 
			
		||||
		err := fmt.Sprintf("autocomplete failed: %s", err)
 | 
			
		||||
		return []string{err}, cobra.ShellCompDirectiveError
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var secretNames []string
 | 
			
		||||
 | 
			
		||||
@ -16,13 +16,12 @@ import (
 | 
			
		||||
func EnsureCatalogue() error {
 | 
			
		||||
	catalogueDir := path.Join(config.ABRA_DIR, "catalogue")
 | 
			
		||||
	if _, err := os.Stat(catalogueDir); err != nil && os.IsNotExist(err) {
 | 
			
		||||
		log.Warnf("local recipe catalogue is missing, retrieving now")
 | 
			
		||||
		log.Debugf("catalogue is missing, retrieving now")
 | 
			
		||||
 | 
			
		||||
		url := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, config.CATALOGUE_JSON_REPO_NAME)
 | 
			
		||||
		if err := gitPkg.Clone(catalogueDir, url); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("cloned catalogue repository to %s", catalogueDir)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
 | 
			
		||||
@ -90,6 +90,7 @@ func (a Abra) GetAbraDir() string {
 | 
			
		||||
 | 
			
		||||
func (a Abra) GetServersDir() string   { return path.Join(a.GetAbraDir(), "servers") }
 | 
			
		||||
func (a Abra) GetRecipesDir() string   { return path.Join(a.GetAbraDir(), "recipes") }
 | 
			
		||||
func (a Abra) GetLogsDir() string      { return path.Join(a.GetAbraDir(), "logs") }
 | 
			
		||||
func (a Abra) GetVendorDir() string    { return path.Join(a.GetAbraDir(), "vendor") }
 | 
			
		||||
func (a Abra) GetBackupDir() string    { return path.Join(a.GetAbraDir(), "backups") }
 | 
			
		||||
func (a Abra) GetCatalogueDir() string { return path.Join(a.GetAbraDir(), "catalogue") }
 | 
			
		||||
@ -97,15 +98,29 @@ func (a Abra) GetCatalogueDir() string { return path.Join(a.GetAbraDir(), "catal
 | 
			
		||||
var config = LoadAbraConfig()
 | 
			
		||||
 | 
			
		||||
var (
 | 
			
		||||
	ABRA_DIR                 = config.GetAbraDir()
 | 
			
		||||
	SERVERS_DIR              = config.GetServersDir()
 | 
			
		||||
	RECIPES_DIR              = config.GetRecipesDir()
 | 
			
		||||
	VENDOR_DIR               = config.GetVendorDir()
 | 
			
		||||
	BACKUP_DIR               = config.GetBackupDir()
 | 
			
		||||
	CATALOGUE_DIR            = config.GetCatalogueDir()
 | 
			
		||||
	RECIPES_JSON             = path.Join(config.GetCatalogueDir(), "recipes.json")
 | 
			
		||||
	REPOS_BASE_URL           = "https://git.coopcloud.tech/coop-cloud"
 | 
			
		||||
	CATALOGUE_JSON_REPO_NAME = "recipes-catalogue-json"
 | 
			
		||||
	SSH_URL_TEMPLATE         = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git"
 | 
			
		||||
	CHAOS_DEFAULT            = "false"
 | 
			
		||||
	ABRA_DIR                  = config.GetAbraDir()
 | 
			
		||||
	SERVERS_DIR               = config.GetServersDir()
 | 
			
		||||
	RECIPES_DIR               = config.GetRecipesDir()
 | 
			
		||||
	LOGS_DIR                  = config.GetLogsDir()
 | 
			
		||||
	VENDOR_DIR                = config.GetVendorDir()
 | 
			
		||||
	BACKUP_DIR                = config.GetBackupDir()
 | 
			
		||||
	CATALOGUE_DIR             = config.GetCatalogueDir()
 | 
			
		||||
	RECIPES_JSON              = path.Join(config.GetCatalogueDir(), "recipes.json")
 | 
			
		||||
	REPOS_BASE_URL            = "https://git.coopcloud.tech/coop-cloud"
 | 
			
		||||
	CATALOGUE_JSON_REPO_NAME  = "recipes-catalogue-json"
 | 
			
		||||
	TOOLSHED_SSH_URL_TEMPLATE = "ssh://git@git.coopcloud.tech:2222/toolshed/%s.git"
 | 
			
		||||
	RECIPES_SSH_URL_TEMPLATE  = "ssh://git@git.coopcloud.tech:2222/coop-cloud/%s.git"
 | 
			
		||||
 | 
			
		||||
	// NOTE(d1): please note, this was done purely out of laziness on our part
 | 
			
		||||
	// AFAICR. it's easy to punt the value into the label because that is what is
 | 
			
		||||
	// expects. it's not particularly useful in terms of UI/UX but hey, nobody
 | 
			
		||||
	// complained yet!
 | 
			
		||||
	CHAOS_DEFAULT = "false"
 | 
			
		||||
 | 
			
		||||
	DIRTY_DEFAULT = "+U"
 | 
			
		||||
 | 
			
		||||
	NO_DOMAIN_DEFAULT  = "N/A"
 | 
			
		||||
	NO_VERSION_DEFAULT = "N/A"
 | 
			
		||||
 | 
			
		||||
	UNKNOWN_DEFAULT = "unknown"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -26,9 +26,16 @@ func GetServers() ([]string, error) {
 | 
			
		||||
		return servers, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Debugf("retrieved %v servers: %s", len(servers), servers)
 | 
			
		||||
	var filtered []string
 | 
			
		||||
	for _, s := range servers {
 | 
			
		||||
		if !strings.HasPrefix(s, ".") {
 | 
			
		||||
			filtered = append(filtered, s)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return servers, nil
 | 
			
		||||
	log.Debugf("retrieved %v servers: %s", len(filtered), filtered)
 | 
			
		||||
 | 
			
		||||
	return filtered, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ReadServerNames retrieves all server names.
 | 
			
		||||
 | 
			
		||||
@ -9,12 +9,11 @@ import (
 | 
			
		||||
func EnsureIPv4(domainName string) (string, error) {
 | 
			
		||||
	ipv4, err := net.ResolveIPAddr("ip4", domainName)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return "", fmt.Errorf("unable to resolve ipv4 address for %s, %s", domainName, err)
 | 
			
		||||
		return "", fmt.Errorf("%s: unable to resolve IPv4 address: %s", domainName, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// NOTE(d1): e.g. when there is only an ipv6 record available
 | 
			
		||||
	if ipv4 == nil {
 | 
			
		||||
		return "", fmt.Errorf("unable to resolve ipv4 address for %s", domainName)
 | 
			
		||||
		return "", fmt.Errorf("%s: no IPv4 available", domainName)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return ipv4.String(), nil
 | 
			
		||||
 | 
			
		||||
@ -15,7 +15,7 @@ func TestEnsureDomainsResolveSameIPv4(t *testing.T) {
 | 
			
		||||
	}{
 | 
			
		||||
		// NOTE(d1): DNS records get checked, so use something that is maintained
 | 
			
		||||
		// within the federation. if you're here because of a failing test, try
 | 
			
		||||
		// `dig +short <app>` to ensure stuff matches first! If flakyness
 | 
			
		||||
		// `dig +short <domain>` to ensure stuff matches first! If flakyness
 | 
			
		||||
		// becomes an issue we can look into mocking
 | 
			
		||||
		{"docs.coopcloud.tech", "swarm-0.coopcloud.tech", true},
 | 
			
		||||
		{"docs.coopcloud.tech", "coopcloud.tech", true},
 | 
			
		||||
@ -43,7 +43,7 @@ func TestEnsureDomainsResolveSameIPv4(t *testing.T) {
 | 
			
		||||
func TestEnsureIpv4(t *testing.T) {
 | 
			
		||||
	// NOTE(d1): DNS records get checked, so use something that is maintained
 | 
			
		||||
	// within the federation. if you're here because of a failing test, try `dig
 | 
			
		||||
	// +short <app>` to ensure stuff matches first! If flakyness becomes an
 | 
			
		||||
	// +short <domain>` to ensure stuff matches first! If flakyness becomes an
 | 
			
		||||
	// issue we can look into mocking
 | 
			
		||||
	domainName := "collabora.ostrom.collective.tools"
 | 
			
		||||
	serverName := "ostrom.collective.tools"
 | 
			
		||||
 | 
			
		||||
@ -8,7 +8,7 @@ import (
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"git.coopcloud.tech/coop-cloud/godotenv"
 | 
			
		||||
	"git.coopcloud.tech/toolshed/godotenv"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// envVarModifiers is a list of env var modifier strings. These are added to
 | 
			
		||||
@ -31,8 +31,6 @@ func ReadEnv(filePath string) (AppEnv, error) {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Debugf("read %s from %s", envVars, filePath)
 | 
			
		||||
 | 
			
		||||
	return envVars, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -192,7 +192,7 @@ func TestEnvVarCommentsRemoved(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
	envVar, exists = envSample["SECRET_TEST_PASS_TWO_VERSION"]
 | 
			
		||||
	if !exists {
 | 
			
		||||
		t.Fatal("WITH_COMMENT env var should be present in .env.sample")
 | 
			
		||||
		t.Fatal("SECRET_TEST_PASS_TWO_VERSION env var should be present in .env.sample")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if strings.Contains(envVar, "length") {
 | 
			
		||||
 | 
			
		||||
@ -13,11 +13,15 @@ import (
 | 
			
		||||
	"github.com/docker/go-units"
 | 
			
		||||
	"golang.org/x/term"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/config"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"github.com/schollz/progressbar/v3"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var BoldStyle = lipgloss.NewStyle().
 | 
			
		||||
	Bold(true)
 | 
			
		||||
 | 
			
		||||
var BoldUnderlineStyle = lipgloss.NewStyle().
 | 
			
		||||
	Bold(true).
 | 
			
		||||
	Underline(true)
 | 
			
		||||
 | 
			
		||||
@ -43,33 +47,134 @@ func HumanDuration(timestamp int64) string {
 | 
			
		||||
 | 
			
		||||
// CreateTable prepares a table layout for output.
 | 
			
		||||
func CreateTable() (*table.Table, error) {
 | 
			
		||||
	var (
 | 
			
		||||
		renderer    = lipgloss.NewRenderer(os.Stdout)
 | 
			
		||||
		headerStyle = renderer.NewStyle().Bold(true).Align(lipgloss.Center)
 | 
			
		||||
		cellStyle   = renderer.NewStyle().Padding(0, 1)
 | 
			
		||||
		borderStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("63"))
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	table := table.New().
 | 
			
		||||
		Border(lipgloss.ThickBorder()).
 | 
			
		||||
		BorderStyle(
 | 
			
		||||
			lipgloss.NewStyle().
 | 
			
		||||
				Foreground(lipgloss.Color("63")),
 | 
			
		||||
		)
 | 
			
		||||
		BorderStyle(borderStyle).
 | 
			
		||||
		StyleFunc(func(row, col int) lipgloss.Style {
 | 
			
		||||
			var style lipgloss.Style
 | 
			
		||||
 | 
			
		||||
			switch {
 | 
			
		||||
			case row == table.HeaderRow:
 | 
			
		||||
				return headerStyle
 | 
			
		||||
			default:
 | 
			
		||||
				style = cellStyle
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return style
 | 
			
		||||
		})
 | 
			
		||||
 | 
			
		||||
	return table, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func PrintTable(t *table.Table) error {
 | 
			
		||||
	if isAbraCI, ok := os.LookupEnv("ABRA_CI"); ok && isAbraCI == "1" {
 | 
			
		||||
		// NOTE(d1): no width limits for CI testing since we test against outputs
 | 
			
		||||
		log.Debug("detected ABRA_CI=1")
 | 
			
		||||
		return table, nil
 | 
			
		||||
		fmt.Println(t)
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tWidth, _ := lipgloss.Size(t.String())
 | 
			
		||||
 | 
			
		||||
	width, _, err := term.GetSize(0)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if width-10 < 79 {
 | 
			
		||||
		// NOTE(d1): maintain standard minimum width
 | 
			
		||||
		table.Width(79)
 | 
			
		||||
	} else {
 | 
			
		||||
		// NOTE(d1): tests show that this produces stable border drawing
 | 
			
		||||
		table.Width(width - 10)
 | 
			
		||||
	if tWidth > width {
 | 
			
		||||
		t.Width(width - 10)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return table, nil
 | 
			
		||||
	fmt.Println(t)
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// horizontal is a JoinHorizontal helper function.
 | 
			
		||||
func horizontal(left, mid, right string) string {
 | 
			
		||||
	return lipgloss.JoinHorizontal(lipgloss.Right, left, mid, right)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func CreateOverview(header string, rows [][]string) string {
 | 
			
		||||
	var borderStyle = lipgloss.NewStyle().
 | 
			
		||||
		BorderStyle(lipgloss.ThickBorder()).
 | 
			
		||||
		Padding(0, 1, 0, 1).
 | 
			
		||||
		BorderForeground(lipgloss.Color("63"))
 | 
			
		||||
 | 
			
		||||
	var headerStyle = lipgloss.NewStyle().
 | 
			
		||||
		Underline(true).
 | 
			
		||||
		Bold(true).
 | 
			
		||||
		PaddingBottom(1)
 | 
			
		||||
 | 
			
		||||
	var leftStyle = lipgloss.NewStyle()
 | 
			
		||||
	var rightStyle = lipgloss.NewStyle()
 | 
			
		||||
 | 
			
		||||
	var longest int
 | 
			
		||||
	for _, row := range rows {
 | 
			
		||||
		if len(row[0]) > longest {
 | 
			
		||||
			longest = len(row[0])
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var renderedRows []string
 | 
			
		||||
	for _, row := range rows {
 | 
			
		||||
		if len(row) < 2 {
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(row) > 2 {
 | 
			
		||||
			panic("CreateOverview: only accepts rows of len == 2")
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		lenOffset := 4
 | 
			
		||||
		if len(row[0]) < longest {
 | 
			
		||||
			lenOffset += longest - len(row[0])
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		offset := ""
 | 
			
		||||
		for range lenOffset {
 | 
			
		||||
			offset = offset + " "
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		rendered := horizontal(leftStyle.Render(row[0]), offset, rightStyle.Render(row[1]))
 | 
			
		||||
 | 
			
		||||
		if row[1] == "---" {
 | 
			
		||||
			rendered = horizontal(
 | 
			
		||||
				leftStyle.
 | 
			
		||||
					Bold(true).
 | 
			
		||||
					Underline(true).
 | 
			
		||||
					PaddingTop(1).
 | 
			
		||||
					Render(row[0]),
 | 
			
		||||
				offset,
 | 
			
		||||
				rightStyle.Render(""),
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		renderedRows = append(renderedRows, rendered)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	body := strings.Builder{}
 | 
			
		||||
	body.WriteString(
 | 
			
		||||
		borderStyle.Render(
 | 
			
		||||
			lipgloss.JoinVertical(
 | 
			
		||||
				lipgloss.Center,
 | 
			
		||||
				headerStyle.Render(header),
 | 
			
		||||
				lipgloss.JoinVertical(
 | 
			
		||||
					lipgloss.Left,
 | 
			
		||||
					renderedRows...,
 | 
			
		||||
				),
 | 
			
		||||
			),
 | 
			
		||||
		),
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	return body.String()
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ToJSON converts a lipgloss.Table to JSON representation. It's not a robust
 | 
			
		||||
@ -112,7 +217,6 @@ func CreateProgressbar(length int, title string) *progressbar.ProgressBar {
 | 
			
		||||
		progressbar.OptionClearOnFinish(),
 | 
			
		||||
		progressbar.OptionSetPredictTime(false),
 | 
			
		||||
		progressbar.OptionShowCount(),
 | 
			
		||||
		progressbar.OptionFullWidth(),
 | 
			
		||||
		progressbar.OptionSetDescription(title),
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
@ -153,3 +257,18 @@ func ByteCountSI(b uint64) string {
 | 
			
		||||
 | 
			
		||||
	return fmt.Sprintf("%.1f %cB", float64(b)/float64(div), "kMGTPE"[exp])
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// BoldDirtyDefault ensures a dirty modifier is rendered in bold.
 | 
			
		||||
func BoldDirtyDefault(v string) string {
 | 
			
		||||
	if strings.HasSuffix(v, config.DIRTY_DEFAULT) {
 | 
			
		||||
		vBold := BoldStyle.Render(config.DIRTY_DEFAULT)
 | 
			
		||||
		v = strings.Replace(v, config.DIRTY_DEFAULT, vBold, 1)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return v
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// AddDirtyMarker adds the dirty marker to a version string.
 | 
			
		||||
func AddDirtyMarker(v string) string {
 | 
			
		||||
	return fmt.Sprintf("%s%s", v, config.DIRTY_DEFAULT)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										11
									
								
								pkg/formatter/formatter_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								pkg/formatter/formatter_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,11 @@
 | 
			
		||||
package formatter
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestBoldDirtyDefault(t *testing.T) {
 | 
			
		||||
	assert.Equal(t, "foo", BoldDirtyDefault("foo"))
 | 
			
		||||
}
 | 
			
		||||
@ -1,9 +1,10 @@
 | 
			
		||||
package git
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"os/signal"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
@ -11,39 +12,94 @@ import (
 | 
			
		||||
	"github.com/go-git/go-git/v5/plumbing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Clone runs a git clone which accounts for different default branches.
 | 
			
		||||
// gitCloneIgnoreErr checks whether we can ignore a git clone error or not.
 | 
			
		||||
func gitCloneIgnoreErr(err error) bool {
 | 
			
		||||
	if strings.Contains(err.Error(), "authentication required") {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if strings.Contains(err.Error(), "remote repository is empty") {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Clone runs a git clone which accounts for different default branches. This
 | 
			
		||||
// function respects Ctrl+C (SIGINT) calls from the user, cancelling the
 | 
			
		||||
// context and deleting the (typically) half-baked clone of the repository.
 | 
			
		||||
// This avoids broken state for future clone / recipe ops.
 | 
			
		||||
func Clone(dir, url string) error {
 | 
			
		||||
	if _, err := os.Stat(dir); os.IsNotExist(err) {
 | 
			
		||||
		log.Debugf("%s does not exist, attempting to git clone from %s", dir, url)
 | 
			
		||||
	ctx := context.Background()
 | 
			
		||||
	ctx, cancelCtx := context.WithCancel(ctx)
 | 
			
		||||
 | 
			
		||||
		_, err := git.PlainClone(dir, false, &git.CloneOptions{
 | 
			
		||||
			URL:           url,
 | 
			
		||||
			Tags:          git.AllTags,
 | 
			
		||||
			ReferenceName: plumbing.ReferenceName("refs/heads/master"),
 | 
			
		||||
			SingleBranch:  true,
 | 
			
		||||
		})
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			log.Debugf("cloning %s default branch failed, attempting from main branch", url)
 | 
			
		||||
	sigIntCh := make(chan os.Signal, 1)
 | 
			
		||||
	signal.Notify(sigIntCh, os.Interrupt)
 | 
			
		||||
	defer func() {
 | 
			
		||||
		signal.Stop(sigIntCh)
 | 
			
		||||
		cancelCtx()
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
			_, err := git.PlainClone(dir, false, &git.CloneOptions{
 | 
			
		||||
	errCh := make(chan error)
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		if _, err := os.Stat(dir); os.IsNotExist(err) {
 | 
			
		||||
			log.Debugf("git clone: %s", url)
 | 
			
		||||
 | 
			
		||||
			_, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
 | 
			
		||||
				URL:           url,
 | 
			
		||||
				Tags:          git.AllTags,
 | 
			
		||||
				ReferenceName: plumbing.ReferenceName("refs/heads/main"),
 | 
			
		||||
				SingleBranch:  true,
 | 
			
		||||
			})
 | 
			
		||||
 | 
			
		||||
			if err != nil && gitCloneIgnoreErr(err) {
 | 
			
		||||
				log.Debugf("git clone: %s cloned successfully", dir)
 | 
			
		||||
				errCh <- nil
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if err := ctx.Err(); err != nil {
 | 
			
		||||
				errCh <- fmt.Errorf("git clone %s: cancelled due to interrupt", dir)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				if strings.Contains(err.Error(), "authentication required") {
 | 
			
		||||
					name := filepath.Base(dir)
 | 
			
		||||
					return fmt.Errorf("unable to clone %s, does %s exist?", name, url)
 | 
			
		||||
				log.Debug("git clone: main branch failed, attempting master branch")
 | 
			
		||||
 | 
			
		||||
				_, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
 | 
			
		||||
					URL:           url,
 | 
			
		||||
					Tags:          git.AllTags,
 | 
			
		||||
					ReferenceName: plumbing.ReferenceName("refs/heads/master"),
 | 
			
		||||
					SingleBranch:  true,
 | 
			
		||||
				})
 | 
			
		||||
 | 
			
		||||
				if err != nil && gitCloneIgnoreErr(err) {
 | 
			
		||||
					log.Debugf("git clone: %s cloned successfully", dir)
 | 
			
		||||
					errCh <- nil
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				return err
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					errCh <- err
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			log.Debugf("git clone: %s cloned successfully", dir)
 | 
			
		||||
		} else {
 | 
			
		||||
			log.Debugf("git clone: %s already exists", dir)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("%s has been git cloned successfully", dir)
 | 
			
		||||
	} else {
 | 
			
		||||
		log.Debugf("%s already exists", dir)
 | 
			
		||||
		errCh <- nil
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	select {
 | 
			
		||||
	case <-sigIntCh:
 | 
			
		||||
		cancelCtx()
 | 
			
		||||
		fmt.Println() // NOTE(d1): newline after ^C
 | 
			
		||||
		if err := os.RemoveAll(dir); err != nil {
 | 
			
		||||
			return fmt.Errorf("unable to clean up git clone of %s: %s", dir, err)
 | 
			
		||||
		}
 | 
			
		||||
		return fmt.Errorf("git clone %s: cancelled due to interrupt", dir)
 | 
			
		||||
	case err := <-errCh:
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										48
									
								
								pkg/git/clone_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								pkg/git/clone_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,48 @@
 | 
			
		||||
package git
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path"
 | 
			
		||||
	"syscall"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/config"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestClone(t *testing.T) {
 | 
			
		||||
	dir := path.Join(config.RECIPES_DIR, "gitea")
 | 
			
		||||
	os.RemoveAll(dir)
 | 
			
		||||
 | 
			
		||||
	gitURL := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "gitea")
 | 
			
		||||
	if err := Clone(dir, gitURL); err != nil {
 | 
			
		||||
		t.Fatalf("unable to git clone gitea: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if _, err := os.Stat(dir); err != nil && os.IsNotExist(err) {
 | 
			
		||||
		t.Fatal("gitea repo was not cloned successfully")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestCancelGitClone(t *testing.T) {
 | 
			
		||||
	dir := path.Join(config.RECIPES_DIR, "gitea")
 | 
			
		||||
	os.RemoveAll(dir)
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		p, err := os.FindProcess(os.Getpid())
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			t.Fatalf("unable to find current process: %s", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		p.Signal(syscall.SIGINT)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	gitURL := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, "gitea")
 | 
			
		||||
	if err := Clone(dir, gitURL); err == nil {
 | 
			
		||||
		t.Fatal("cloning should have been interrupted")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if _, err := os.Stat(dir); err != nil && !os.IsNotExist(err) {
 | 
			
		||||
		t.Fatal("recipe repo was not deleted")
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
@ -15,9 +15,11 @@ func Init(repoPath string, commit bool, gitName, gitEmail string) error {
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("git init: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err = SwitchToMain(repo); err != nil {
 | 
			
		||||
		return fmt.Errorf("git branch rename: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Debugf("initialised new git repo in %s", repoPath)
 | 
			
		||||
 | 
			
		||||
	if commit {
 | 
			
		||||
@ -39,9 +41,11 @@ func Init(repoPath string, commit bool, gitName, gitEmail string) error {
 | 
			
		||||
		if gitName != "" && gitEmail != "" {
 | 
			
		||||
			author = &object.Signature{Name: gitName, Email: gitEmail}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if _, err = commitWorktree.Commit("init", &git.CommitOptions{Author: author}); err != nil {
 | 
			
		||||
			return fmt.Errorf("git commit: %s", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("init committed all files for new git repo in %s", repoPath)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -54,14 +58,18 @@ func SwitchToMain(repo *git.Repository) error {
 | 
			
		||||
	if err := repo.Storer.SetReference(ref); err != nil {
 | 
			
		||||
		return fmt.Errorf("set reference: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cfg, err := repo.Config()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("repo config: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cfg.Init.DefaultBranch = "main"
 | 
			
		||||
	if err := repo.SetConfig(cfg); err != nil {
 | 
			
		||||
		return fmt.Errorf("repo set config: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
	log.Debug("set 'main' as the default branch.")
 | 
			
		||||
 | 
			
		||||
	log.Debug("set 'main' as the default branch")
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -1,6 +1,8 @@
 | 
			
		||||
package git
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io/ioutil"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/user"
 | 
			
		||||
@ -17,12 +19,16 @@ import (
 | 
			
		||||
func IsClean(repoPath string) (bool, error) {
 | 
			
		||||
	repo, err := git.PlainOpen(repoPath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
		if errors.Is(err, git.ErrRepositoryNotExists) {
 | 
			
		||||
			return false, git.ErrRepositoryNotExists
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return false, fmt.Errorf("unable to open %s: %s", repoPath, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	worktree, err := repo.Worktree()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
		return false, fmt.Errorf("unable to open worktree of %s: %s", repoPath, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	patterns, err := GetExcludesFiles()
 | 
			
		||||
@ -36,13 +42,14 @@ func IsClean(repoPath string) (bool, error) {
 | 
			
		||||
 | 
			
		||||
	status, err := worktree.Status()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
		return false, fmt.Errorf("unable to query status of %s: %s", repoPath, err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if status.String() != "" {
 | 
			
		||||
		log.Debugf("discovered git status in %s: %s", repoPath, status.String())
 | 
			
		||||
		noNewline := strings.TrimSuffix(status.String(), "\n")
 | 
			
		||||
		log.Debugf("git status: %s: %s", repoPath, noNewline)
 | 
			
		||||
	} else {
 | 
			
		||||
		log.Debugf("discovered clean git status in %s", repoPath)
 | 
			
		||||
		log.Debugf("git status: %s: clean", repoPath)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return status.IsClean(), nil
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										15
									
								
								pkg/git/read_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								pkg/git/read_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,15 @@
 | 
			
		||||
package git
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/go-git/go-git/v5"
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestIsClean(t *testing.T) {
 | 
			
		||||
	isClean, err := IsClean("/tmp")
 | 
			
		||||
	assert.Equal(t, isClean, false)
 | 
			
		||||
	assert.True(t, errors.Is(err, git.ErrRepositoryNotExists))
 | 
			
		||||
}
 | 
			
		||||
@ -15,8 +15,10 @@ import (
 | 
			
		||||
	"github.com/go-git/go-git/v5/plumbing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var Warn = "warn"
 | 
			
		||||
var Critical = "critical"
 | 
			
		||||
var (
 | 
			
		||||
	Warn     = "warn"
 | 
			
		||||
	Critical = "critical"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type LintFunction func(recipe.Recipe) (bool, error)
 | 
			
		||||
 | 
			
		||||
@ -137,6 +139,13 @@ var LintRules = map[string][]LintRule{
 | 
			
		||||
			HowToResolve: "name a servce 'app'",
 | 
			
		||||
			Function:     LintAppService,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Ref:          "R015",
 | 
			
		||||
			Level:        "error",
 | 
			
		||||
			Description:  "deploy labels stanza present",
 | 
			
		||||
			HowToResolve: "include \"deploy: labels: ...\" stanza",
 | 
			
		||||
			Function:     LintDeployLabelsPresent,
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			Ref:           "R010",
 | 
			
		||||
			Level:         "error",
 | 
			
		||||
@ -187,7 +196,7 @@ func LintForErrors(recipe recipe.Recipe) error {
 | 
			
		||||
 | 
			
		||||
			ok, err := rule.Function(recipe)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
				return fmt.Errorf("lint %s: %s", rule.Ref, err)
 | 
			
		||||
			}
 | 
			
		||||
			if !ok {
 | 
			
		||||
				return fmt.Errorf("lint error in %s configs: \"%s\" failed lint checks (%s)", recipe.Name, rule.Description, rule.Ref)
 | 
			
		||||
@ -269,6 +278,21 @@ func LintTraefikEnabled(recipe recipe.Recipe) (bool, error) {
 | 
			
		||||
	return false, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func LintDeployLabelsPresent(recipe recipe.Recipe) (bool, error) {
 | 
			
		||||
	config, err := recipe.GetComposeConfig(nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	for _, service := range config.Services {
 | 
			
		||||
		if service.Name == "app" && service.Deploy.Labels != nil {
 | 
			
		||||
			return true, nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return false, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func LintHealthchecks(recipe recipe.Recipe) (bool, error) {
 | 
			
		||||
	config, err := recipe.GetComposeConfig(nil)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@ -387,7 +411,7 @@ func LintHasPublishedVersion(recipe recipe.Recipe) (bool, error) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func LintMetadataFilledIn(r recipe.Recipe) (bool, error) {
 | 
			
		||||
	features, category, err := recipe.GetRecipeFeaturesAndCategory(r)
 | 
			
		||||
	features, category, _, err := recipe.GetRecipeFeaturesAndCategory(r)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -2,10 +2,10 @@
 | 
			
		||||
package log
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"math"
 | 
			
		||||
	"os"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/charmbracelet/lipgloss"
 | 
			
		||||
	tea "github.com/charmbracelet/bubbletea"
 | 
			
		||||
	charmLog "github.com/charmbracelet/log"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@ -35,41 +35,12 @@ var DebugLevel = charmLog.DebugLevel
 | 
			
		||||
var SetOutput = charmLog.SetOutput
 | 
			
		||||
var SetReportCaller = charmLog.SetReportCaller
 | 
			
		||||
 | 
			
		||||
func Styles() *charmLog.Styles {
 | 
			
		||||
	styles := charmLog.DefaultStyles()
 | 
			
		||||
type f func() (tea.Model, error)
 | 
			
		||||
 | 
			
		||||
	styles.Levels = map[charmLog.Level]lipgloss.Style{
 | 
			
		||||
		charmLog.DebugLevel: lipgloss.NewStyle().
 | 
			
		||||
			SetString(strings.ToUpper(DebugLevel.String())).
 | 
			
		||||
			Bold(true).
 | 
			
		||||
			Padding(0, 1, 0, 1).
 | 
			
		||||
			Background(lipgloss.Color("63")).
 | 
			
		||||
			Foreground(lipgloss.Color("15")),
 | 
			
		||||
		charmLog.InfoLevel: lipgloss.NewStyle().
 | 
			
		||||
			SetString(strings.ToUpper(charmLog.InfoLevel.String())).
 | 
			
		||||
			Bold(true).
 | 
			
		||||
			Padding(0, 1, 0, 1).
 | 
			
		||||
			Background(lipgloss.Color("86")).
 | 
			
		||||
			Foreground(lipgloss.Color("16")),
 | 
			
		||||
		charmLog.WarnLevel: lipgloss.NewStyle().
 | 
			
		||||
			SetString(strings.ToUpper(charmLog.WarnLevel.String())).
 | 
			
		||||
			Bold(true).
 | 
			
		||||
			Padding(0, 1, 0, 1).
 | 
			
		||||
			Background(lipgloss.Color("192")).
 | 
			
		||||
			Foreground(lipgloss.Color("16")),
 | 
			
		||||
		charmLog.ErrorLevel: lipgloss.NewStyle().
 | 
			
		||||
			SetString(strings.ToUpper(charmLog.ErrorLevel.String())).
 | 
			
		||||
			Bold(true).
 | 
			
		||||
			Padding(0, 1, 0, 1).
 | 
			
		||||
			Background(lipgloss.Color("204")).
 | 
			
		||||
			Foreground(lipgloss.Color("15")),
 | 
			
		||||
		charmLog.FatalLevel: lipgloss.NewStyle().
 | 
			
		||||
			SetString(strings.ToUpper(charmLog.FatalLevel.String())).
 | 
			
		||||
			Bold(true).
 | 
			
		||||
			Padding(0, 1, 0, 1).
 | 
			
		||||
			Background(lipgloss.Color("134")).
 | 
			
		||||
			Foreground(lipgloss.Color("15")),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return styles
 | 
			
		||||
func Without(fn f) (tea.Model, error) {
 | 
			
		||||
	l := Logger.GetLevel()
 | 
			
		||||
	Logger.SetLevel(math.MaxInt)
 | 
			
		||||
	m, err := fn()
 | 
			
		||||
	Logger.SetLevel(l)
 | 
			
		||||
	return m, err
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										104
									
								
								pkg/logs/logs.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										104
									
								
								pkg/logs/logs.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,104 @@
 | 
			
		||||
package logs
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"bufio"
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/signal"
 | 
			
		||||
	"sync"
 | 
			
		||||
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	containerTypes "github.com/docker/docker/api/types/container"
 | 
			
		||||
	"github.com/docker/docker/api/types/filters"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
type TailOpts struct {
 | 
			
		||||
	AppName  string
 | 
			
		||||
	Services []string
 | 
			
		||||
	StdErr   bool
 | 
			
		||||
	Since    string
 | 
			
		||||
	Buffer   *[]string
 | 
			
		||||
	ToBuffer bool
 | 
			
		||||
	Filters  filters.Args
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TailLogs gathers logs for the given app with optional service names to be
 | 
			
		||||
// filtered on. These logs can be printed to os.Stdout or gathered to a buffer.
 | 
			
		||||
func TailLogs(
 | 
			
		||||
	cl *dockerClient.Client,
 | 
			
		||||
	opts TailOpts,
 | 
			
		||||
) error {
 | 
			
		||||
	sigIntCh := make(chan os.Signal, 1)
 | 
			
		||||
	signal.Notify(sigIntCh, os.Interrupt)
 | 
			
		||||
	defer signal.Stop(sigIntCh)
 | 
			
		||||
 | 
			
		||||
	services, err := cl.ServiceList(
 | 
			
		||||
		context.Background(),
 | 
			
		||||
		types.ServiceListOptions{Filters: opts.Filters},
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	errCh := make(chan error)
 | 
			
		||||
	waitCh := make(chan struct{})
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		var wg sync.WaitGroup
 | 
			
		||||
		for _, service := range services {
 | 
			
		||||
			wg.Add(1)
 | 
			
		||||
			go func(serviceID string) {
 | 
			
		||||
				tail := "50"
 | 
			
		||||
				if opts.ToBuffer {
 | 
			
		||||
					// NOTE(d1): more logs from before deployment when analysing via file
 | 
			
		||||
					tail = "150"
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				logs, err := cl.ServiceLogs(context.Background(), serviceID, containerTypes.LogsOptions{
 | 
			
		||||
					ShowStderr: true,
 | 
			
		||||
					ShowStdout: !opts.StdErr,
 | 
			
		||||
					Since:      opts.Since,
 | 
			
		||||
					Until:      "",
 | 
			
		||||
					Timestamps: true,
 | 
			
		||||
					Follow:     true,
 | 
			
		||||
					Tail:       tail,
 | 
			
		||||
					Details:    false,
 | 
			
		||||
				})
 | 
			
		||||
 | 
			
		||||
				if err == nil {
 | 
			
		||||
					defer logs.Close()
 | 
			
		||||
					if opts.ToBuffer {
 | 
			
		||||
						buf := bufio.NewScanner(logs)
 | 
			
		||||
						for buf.Scan() {
 | 
			
		||||
							line := fmt.Sprintf("%s: %s", service.Spec.Name, buf.Text())
 | 
			
		||||
							*opts.Buffer = append(*opts.Buffer, line)
 | 
			
		||||
						}
 | 
			
		||||
						logs.Close()
 | 
			
		||||
						return
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					if _, err = io.Copy(os.Stdout, logs); err != nil && err != io.EOF {
 | 
			
		||||
						errCh <- fmt.Errorf("tailLogs: unable to copy buffer: %s", err)
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}(service.ID)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		wg.Wait()
 | 
			
		||||
		close(waitCh)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	select {
 | 
			
		||||
	case <-waitCh:
 | 
			
		||||
		return nil
 | 
			
		||||
	case <-sigIntCh:
 | 
			
		||||
		return nil
 | 
			
		||||
	case err := <-errCh:
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
@ -6,6 +6,7 @@ import (
 | 
			
		||||
	"path"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/envfile"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func (r Recipe) SampleEnv() (map[string]string, error) {
 | 
			
		||||
@ -29,7 +30,10 @@ func (r Recipe) GetReleaseNotes(version string) (string, error) {
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return "", err
 | 
			
		||||
		}
 | 
			
		||||
		withTitle := fmt.Sprintf("%s release notes:\n%s", version, string(releaseNotes))
 | 
			
		||||
 | 
			
		||||
		title := formatter.BoldStyle.Render(fmt.Sprintf("%s release notes:", version))
 | 
			
		||||
		withTitle := fmt.Sprintf("%s\n%s\n", title, releaseNotes)
 | 
			
		||||
 | 
			
		||||
		return withTitle, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -1,26 +1,36 @@
 | 
			
		||||
package recipe
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"slices"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/config"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
	gitPkg "coopcloud.tech/abra/pkg/git"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/tagcmp"
 | 
			
		||||
	"github.com/distribution/reference"
 | 
			
		||||
	"github.com/go-git/go-git/v5"
 | 
			
		||||
	"github.com/go-git/go-git/v5/plumbing"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// Ensure makes sure the recipe exists, is up to date and has the latest version checked out.
 | 
			
		||||
func (r Recipe) Ensure(chaos bool, offline bool) error {
 | 
			
		||||
type EnsureContext struct {
 | 
			
		||||
	Chaos            bool
 | 
			
		||||
	Offline          bool
 | 
			
		||||
	IgnoreEnvVersion bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Ensure makes sure the recipe exists, is up to date and has the specific
 | 
			
		||||
// version checked out.
 | 
			
		||||
func (r Recipe) Ensure(ctx EnsureContext) error {
 | 
			
		||||
	if err := r.EnsureExists(); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if chaos {
 | 
			
		||||
	if ctx.Chaos {
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -28,15 +38,19 @@ func (r Recipe) Ensure(chaos bool, offline bool) error {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !offline {
 | 
			
		||||
	if !ctx.Offline {
 | 
			
		||||
		if err := r.EnsureUpToDate(); err != nil {
 | 
			
		||||
			log.Fatal(err)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if r.Version != "" {
 | 
			
		||||
		log.Debugf("ensuring version %s", r.Version)
 | 
			
		||||
		if _, err := r.EnsureVersion(r.Version); err != nil {
 | 
			
		||||
	if r.EnvVersion != "" && !ctx.IgnoreEnvVersion {
 | 
			
		||||
		log.Debugf("ensuring env version %s", r.EnvVersion)
 | 
			
		||||
		if strings.Contains(r.EnvVersion, "+U") {
 | 
			
		||||
			log.Fatalf("can not redeploy chaos version (%s) without --chaos", r.EnvVersion)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if _, err := r.EnsureVersion(r.EnvVersion); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -53,7 +67,6 @@ func (r Recipe) Ensure(chaos bool, offline bool) error {
 | 
			
		||||
// EnsureExists ensures that the recipe is locally cloned
 | 
			
		||||
func (r Recipe) EnsureExists() error {
 | 
			
		||||
	if _, err := os.Stat(r.Dir); os.IsNotExist(err) {
 | 
			
		||||
		log.Debugf("%s does not exist, attemmpting to clone", r.Dir)
 | 
			
		||||
		if err := gitPkg.Clone(r.Dir, r.GitURL); err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
@ -66,11 +79,43 @@ func (r Recipe) EnsureExists() error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// IsChaosCommit determines if a version sttring is a chaos commit or not.
 | 
			
		||||
func (r Recipe) IsChaosCommit(version string) (bool, error) {
 | 
			
		||||
	isChaosCommit := false
 | 
			
		||||
 | 
			
		||||
	if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
 | 
			
		||||
		return isChaosCommit, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	repo, err := git.PlainOpen(r.Dir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return isChaosCommit, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	tags, err := repo.Tags()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return isChaosCommit, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var tagRef plumbing.ReferenceName
 | 
			
		||||
	if err := tags.ForEach(func(ref *plumbing.Reference) (err error) {
 | 
			
		||||
		if ref.Name().Short() == version {
 | 
			
		||||
			tagRef = ref.Name()
 | 
			
		||||
		}
 | 
			
		||||
		return nil
 | 
			
		||||
	}); err != nil {
 | 
			
		||||
		return isChaosCommit, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if tagRef.String() == "" {
 | 
			
		||||
		isChaosCommit = true
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return isChaosCommit, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// EnsureVersion checks whether a specific version exists for a recipe.
 | 
			
		||||
func (r Recipe) EnsureVersion(version string) (bool, error) {
 | 
			
		||||
	if strings.Contains(version, "+ unstaged changes") {
 | 
			
		||||
		return true, errors.New("A chaos version is configured. \nMaybe your coworker deployed a local version of the recipe?\nOr did you forget to add --chaos?")
 | 
			
		||||
	}
 | 
			
		||||
	isChaosCommit := false
 | 
			
		||||
 | 
			
		||||
	if err := gitPkg.EnsureGitRepo(r.Dir); err != nil {
 | 
			
		||||
@ -141,8 +186,7 @@ func (r Recipe) EnsureIsClean() error {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !isClean {
 | 
			
		||||
		msg := "%s (%s) has locally unstaged changes? please commit/remove your changes before proceeding"
 | 
			
		||||
		return fmt.Errorf(msg, r.Name, r.Dir)
 | 
			
		||||
		return fmt.Errorf("%s (%s) has locally unstaged changes?", r.Name, r.Dir)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
@ -234,8 +278,18 @@ func (r Recipe) EnsureUpToDate() error {
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// IsDirty checks whether a recipe is dirty or not.
 | 
			
		||||
func (r *Recipe) IsDirty() (bool, error) {
 | 
			
		||||
	isClean, err := gitPkg.IsClean(r.Dir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return false, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return !isClean, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ChaosVersion constructs a chaos mode recipe version.
 | 
			
		||||
func (r Recipe) ChaosVersion() (string, error) {
 | 
			
		||||
func (r *Recipe) ChaosVersion() (string, error) {
 | 
			
		||||
	var version string
 | 
			
		||||
 | 
			
		||||
	head, err := r.Head()
 | 
			
		||||
@ -245,13 +299,12 @@ func (r Recipe) ChaosVersion() (string, error) {
 | 
			
		||||
 | 
			
		||||
	version = formatter.SmallSHA(head.String())
 | 
			
		||||
 | 
			
		||||
	isClean, err := gitPkg.IsClean(r.Dir)
 | 
			
		||||
	dirty, err := r.IsDirty()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return version, err
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if !isClean {
 | 
			
		||||
		version = fmt.Sprintf("%s + unstaged changes", version)
 | 
			
		||||
	if dirty {
 | 
			
		||||
		return fmt.Sprintf("%s%s", version, config.DIRTY_DEFAULT), nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return version, nil
 | 
			
		||||
@ -297,29 +350,44 @@ func (r Recipe) Tags() ([]string, error) {
 | 
			
		||||
		return tags, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sort.Slice(tags, func(i, j int) bool {
 | 
			
		||||
		version1, err := tagcmp.Parse(tags[i])
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return false
 | 
			
		||||
		}
 | 
			
		||||
		version2, err := tagcmp.Parse(tags[j])
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return false
 | 
			
		||||
		}
 | 
			
		||||
		return version1.IsLessThan(version2)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	log.Debugf("detected %s as tags for recipe %s", strings.Join(tags, ", "), r.Name)
 | 
			
		||||
 | 
			
		||||
	return tags, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetRecipeVersions retrieves all recipe versions.
 | 
			
		||||
func (r Recipe) GetRecipeVersions() (RecipeVersions, error) {
 | 
			
		||||
func (r Recipe) GetRecipeVersions() (RecipeVersions, []string, error) {
 | 
			
		||||
	var warnMsg []string
 | 
			
		||||
 | 
			
		||||
	versions := RecipeVersions{}
 | 
			
		||||
	log.Debugf("attempting to open git repository in %s", r.Dir)
 | 
			
		||||
 | 
			
		||||
	log.Debugf("git: opening repository in %s", r.Dir)
 | 
			
		||||
 | 
			
		||||
	repo, err := git.PlainOpen(r.Dir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return versions, err
 | 
			
		||||
		return versions, warnMsg, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	worktree, err := repo.Worktree()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return versions, err
 | 
			
		||||
		return versions, warnMsg, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	gitTags, err := repo.Tags()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return versions, err
 | 
			
		||||
		return versions, warnMsg, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if err := gitTags.ForEach(func(ref *plumbing.Reference) (err error) {
 | 
			
		||||
@ -337,7 +405,7 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, error) {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Debugf("successfully checked out %s in %s", ref.Name(), r.Dir)
 | 
			
		||||
		log.Debugf("git checkout: %s in %s", ref.Name(), r.Dir)
 | 
			
		||||
 | 
			
		||||
		config, err := r.GetComposeConfig(nil)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
@ -361,7 +429,7 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, error) {
 | 
			
		||||
			case reference.NamedTagged:
 | 
			
		||||
				tag = img.(reference.NamedTagged).Tag()
 | 
			
		||||
			case reference.Named:
 | 
			
		||||
				log.Warnf("%s service is missing image tag?", path)
 | 
			
		||||
				warnMsg = append(warnMsg, fmt.Sprintf("%s service is missing image tag?", path))
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
@ -375,19 +443,26 @@ func (r Recipe) GetRecipeVersions() (RecipeVersions, error) {
 | 
			
		||||
 | 
			
		||||
		return nil
 | 
			
		||||
	}); err != nil {
 | 
			
		||||
		return versions, err
 | 
			
		||||
		return versions, warnMsg, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	_, err = gitPkg.CheckoutDefaultBranch(repo, r.Dir)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return versions, err
 | 
			
		||||
		return versions, warnMsg, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sortRecipeVersions(versions)
 | 
			
		||||
 | 
			
		||||
	log.Debugf("collected %s for %s", versions, r.Dir)
 | 
			
		||||
 | 
			
		||||
	return versions, nil
 | 
			
		||||
	var uniqueWarnings []string
 | 
			
		||||
	for _, w := range warnMsg {
 | 
			
		||||
		if !slices.Contains(uniqueWarnings, w) {
 | 
			
		||||
			uniqueWarnings = append(uniqueWarnings, w)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return versions, uniqueWarnings, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Head retrieves latest HEAD metadata.
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										36
									
								
								pkg/recipe/git_test.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								pkg/recipe/git_test.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,36 @@
 | 
			
		||||
package recipe
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"os"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	"github.com/stretchr/testify/assert"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
func TestIsDirty(t *testing.T) {
 | 
			
		||||
	r := Get("abra-test-recipe")
 | 
			
		||||
 | 
			
		||||
	if err := r.EnsureExists(); err != nil {
 | 
			
		||||
		t.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	assert.False(t, r.Dirty)
 | 
			
		||||
 | 
			
		||||
	fpath := filepath.Join(r.Dir, "foo.txt")
 | 
			
		||||
	f, err := os.Create(fpath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
	defer f.Close()
 | 
			
		||||
	defer t.Cleanup(func() {
 | 
			
		||||
		os.Remove(fpath)
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	dirty, err := r.IsDirty()
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		t.Fatal(err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	assert.True(t, dirty)
 | 
			
		||||
}
 | 
			
		||||
@ -2,16 +2,18 @@ package recipe
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"errors"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io/ioutil"
 | 
			
		||||
	"net/url"
 | 
			
		||||
	"os"
 | 
			
		||||
	"path"
 | 
			
		||||
	"slices"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	"github.com/go-git/go-git/v5"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/catalogue"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/config"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
@ -57,11 +59,6 @@ type RecipeMeta struct {
 | 
			
		||||
	Website       string         `json:"website"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// TopicMeta represents a list of topics for a repository.
 | 
			
		||||
type TopicMeta struct {
 | 
			
		||||
	Topics []string `json:"topics"`
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// LatestVersion returns the latest version of a recipe.
 | 
			
		||||
func (r RecipeMeta) LatestVersion() string {
 | 
			
		||||
	var version string
 | 
			
		||||
@ -125,17 +122,24 @@ type Features struct {
 | 
			
		||||
 | 
			
		||||
func Get(name string) Recipe {
 | 
			
		||||
	version := ""
 | 
			
		||||
	versionRaw := ""
 | 
			
		||||
	if strings.Contains(name, ":") {
 | 
			
		||||
		split := strings.Split(name, ":")
 | 
			
		||||
		if len(split) > 2 {
 | 
			
		||||
			log.Fatalf("version seems invalid: %s", name)
 | 
			
		||||
		}
 | 
			
		||||
		name = split[0]
 | 
			
		||||
 | 
			
		||||
		version = split[1]
 | 
			
		||||
		versionRaw = version
 | 
			
		||||
		if strings.HasSuffix(version, config.DIRTY_DEFAULT) {
 | 
			
		||||
			version = strings.Replace(split[1], config.DIRTY_DEFAULT, "", 1)
 | 
			
		||||
			log.Debugf("removed dirty suffix from .env version: %s -> %s", split[1], version)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	gitURL := fmt.Sprintf("%s/%s.git", config.REPOS_BASE_URL, name)
 | 
			
		||||
	sshURL := fmt.Sprintf(config.SSH_URL_TEMPLATE, name)
 | 
			
		||||
	sshURL := fmt.Sprintf(config.RECIPES_SSH_URL_TEMPLATE, name)
 | 
			
		||||
	if strings.Contains(name, "/") {
 | 
			
		||||
		u, err := url.Parse(name)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
@ -151,26 +155,37 @@ func Get(name string) Recipe {
 | 
			
		||||
 | 
			
		||||
	dir := path.Join(config.RECIPES_DIR, escapeRecipeName(name))
 | 
			
		||||
 | 
			
		||||
	return Recipe{
 | 
			
		||||
		Name:    name,
 | 
			
		||||
		Version: version,
 | 
			
		||||
		Dir:     dir,
 | 
			
		||||
		GitURL:  gitURL,
 | 
			
		||||
		SSHURL:  sshURL,
 | 
			
		||||
	r := Recipe{
 | 
			
		||||
		Name:          name,
 | 
			
		||||
		EnvVersion:    version,
 | 
			
		||||
		EnvVersionRaw: versionRaw,
 | 
			
		||||
		Dir:           dir,
 | 
			
		||||
		GitURL:        gitURL,
 | 
			
		||||
		SSHURL:        sshURL,
 | 
			
		||||
 | 
			
		||||
		ComposePath:   path.Join(dir, "compose.yml"),
 | 
			
		||||
		ReadmePath:    path.Join(dir, "README.md"),
 | 
			
		||||
		SampleEnvPath: path.Join(dir, ".env.sample"),
 | 
			
		||||
		AbraShPath:    path.Join(dir, "abra.sh"),
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	dirty, err := r.IsDirty()
 | 
			
		||||
	if err != nil && !errors.Is(err, git.ErrRepositoryNotExists) {
 | 
			
		||||
		log.Fatalf("failed to check git status of %s: %s", r.Name, err)
 | 
			
		||||
	}
 | 
			
		||||
	r.Dirty = dirty
 | 
			
		||||
 | 
			
		||||
	return r
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Recipe struct {
 | 
			
		||||
	Name    string
 | 
			
		||||
	Version string
 | 
			
		||||
	Dir     string
 | 
			
		||||
	GitURL  string
 | 
			
		||||
	SSHURL  string
 | 
			
		||||
	Name          string
 | 
			
		||||
	EnvVersion    string
 | 
			
		||||
	EnvVersionRaw string
 | 
			
		||||
	Dirty         bool // NOTE(d1): git terminology for unstaged changes
 | 
			
		||||
	Dir           string
 | 
			
		||||
	GitURL        string
 | 
			
		||||
	SSHURL        string
 | 
			
		||||
 | 
			
		||||
	ComposePath   string
 | 
			
		||||
	ReadmePath    string
 | 
			
		||||
@ -178,6 +193,21 @@ type Recipe struct {
 | 
			
		||||
	AbraShPath    string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// String outputs a human-friendly string representation.
 | 
			
		||||
func (r Recipe) String() string {
 | 
			
		||||
	out := fmt.Sprintf("{name: %s, ", r.Name)
 | 
			
		||||
	out += fmt.Sprintf("version : %s, ", r.EnvVersion)
 | 
			
		||||
	out += fmt.Sprintf("dirty: %v, ", r.Dirty)
 | 
			
		||||
	out += fmt.Sprintf("dir: %s, ", r.Dir)
 | 
			
		||||
	out += fmt.Sprintf("git url: %s, ", r.GitURL)
 | 
			
		||||
	out += fmt.Sprintf("ssh url: %s, ", r.SSHURL)
 | 
			
		||||
	out += fmt.Sprintf("compose: %s, ", r.ComposePath)
 | 
			
		||||
	out += fmt.Sprintf("readme: %s, ", r.ReadmePath)
 | 
			
		||||
	out += fmt.Sprintf("sample env: %s, ", r.SampleEnvPath)
 | 
			
		||||
	out += fmt.Sprintf("abra.sh: %s}", r.AbraShPath)
 | 
			
		||||
	return out
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func escapeRecipeName(recipeName string) string {
 | 
			
		||||
	recipeName = strings.ReplaceAll(recipeName, "/", "_")
 | 
			
		||||
	recipeName = strings.ReplaceAll(recipeName, ".", "_")
 | 
			
		||||
@ -196,16 +226,18 @@ func GetRecipesLocal() ([]string, error) {
 | 
			
		||||
	return recipes, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func GetRecipeFeaturesAndCategory(r Recipe) (Features, string, error) {
 | 
			
		||||
	feat := Features{}
 | 
			
		||||
func GetRecipeFeaturesAndCategory(r Recipe) (Features, string, []string, error) {
 | 
			
		||||
	var (
 | 
			
		||||
		category string
 | 
			
		||||
		warnMsgs []string
 | 
			
		||||
		feat     = Features{}
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	var category string
 | 
			
		||||
 | 
			
		||||
	log.Debugf("attempting to open %s for recipe metadata parsing", r.ReadmePath)
 | 
			
		||||
	log.Debugf("%s: attempt recipe metadata parse", r.ReadmePath)
 | 
			
		||||
 | 
			
		||||
	readmeFS, err := ioutil.ReadFile(r.ReadmePath)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return feat, category, err
 | 
			
		||||
		return feat, category, warnMsgs, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	readmeMetadata, err := GetStringInBetween( // Find text between delimiters
 | 
			
		||||
@ -214,7 +246,7 @@ func GetRecipeFeaturesAndCategory(r Recipe) (Features, string, error) {
 | 
			
		||||
		"<!-- metadata -->", "<!-- endmetadata -->",
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return feat, category, err
 | 
			
		||||
		return feat, category, warnMsgs, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	readmeLines := strings.Split( // Array item from lines
 | 
			
		||||
@ -258,20 +290,25 @@ func GetRecipeFeaturesAndCategory(r Recipe) (Features, string, error) {
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
		if strings.Contains(val, "**Image**") {
 | 
			
		||||
			imageMetadata, err := GetImageMetadata(strings.TrimSpace(
 | 
			
		||||
			imageMetadata, warnings, err := GetImageMetadata(strings.TrimSpace(
 | 
			
		||||
				strings.TrimPrefix(val, "* **Image**:"),
 | 
			
		||||
			), r.Name)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
			if len(warnings) > 0 {
 | 
			
		||||
				warnMsgs = append(warnMsgs, warnings...)
 | 
			
		||||
			}
 | 
			
		||||
			feat.Image = imageMetadata
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return feat, category, nil
 | 
			
		||||
	return feat, category, warnMsgs, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func GetImageMetadata(imageRowString, recipeName string) (Image, error) {
 | 
			
		||||
func GetImageMetadata(imageRowString, recipeName string) (Image, []string, error) {
 | 
			
		||||
	var warnMsgs []string
 | 
			
		||||
 | 
			
		||||
	img := Image{}
 | 
			
		||||
 | 
			
		||||
	imgFields := strings.Split(imageRowString, ",")
 | 
			
		||||
@ -282,11 +319,18 @@ func GetImageMetadata(imageRowString, recipeName string) (Image, error) {
 | 
			
		||||
 | 
			
		||||
	if len(imgFields) < 3 {
 | 
			
		||||
		if imageRowString != "" {
 | 
			
		||||
			log.Warnf("%s image meta has incorrect format: %s", recipeName, imageRowString)
 | 
			
		||||
			warnMsgs = append(
 | 
			
		||||
				warnMsgs,
 | 
			
		||||
				fmt.Sprintf("%s: image meta has incorrect format: %s", recipeName, imageRowString),
 | 
			
		||||
			)
 | 
			
		||||
		} else {
 | 
			
		||||
			log.Warnf("%s image meta is empty?", recipeName)
 | 
			
		||||
			warnMsgs = append(
 | 
			
		||||
				warnMsgs,
 | 
			
		||||
				fmt.Sprintf("%s: image meta is empty?", recipeName),
 | 
			
		||||
			)
 | 
			
		||||
		}
 | 
			
		||||
		return img, nil
 | 
			
		||||
 | 
			
		||||
		return img, warnMsgs, nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	img.Rating = imgFields[1]
 | 
			
		||||
@ -296,17 +340,17 @@ func GetImageMetadata(imageRowString, recipeName string) (Image, error) {
 | 
			
		||||
 | 
			
		||||
	imageName, err := GetStringInBetween(recipeName, imgString, "[", "]")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
		return img, warnMsgs, err
 | 
			
		||||
	}
 | 
			
		||||
	img.Image = strings.ReplaceAll(imageName, "`", "")
 | 
			
		||||
 | 
			
		||||
	imageURL, err := GetStringInBetween(recipeName, imgString, "(", ")")
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Fatal(err)
 | 
			
		||||
		return img, warnMsgs, err
 | 
			
		||||
	}
 | 
			
		||||
	img.URL = imageURL
 | 
			
		||||
 | 
			
		||||
	return img, nil
 | 
			
		||||
	return img, warnMsgs, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GetStringInBetween returns empty string if no start or end string found
 | 
			
		||||
@ -497,11 +541,11 @@ type InternalTracker struct {
 | 
			
		||||
type RepoCatalogue map[string]RepoMeta
 | 
			
		||||
 | 
			
		||||
// ReadReposMetadata retrieves coop-cloud/... repo metadata from Gitea.
 | 
			
		||||
func ReadReposMetadata() (RepoCatalogue, error) {
 | 
			
		||||
func ReadReposMetadata(debug bool) (RepoCatalogue, error) {
 | 
			
		||||
	reposMeta := make(RepoCatalogue)
 | 
			
		||||
 | 
			
		||||
	pageIdx := 1
 | 
			
		||||
	bar := formatter.CreateProgressbar(-1, "retrieving recipe repos list from git.coopcloud.tech...")
 | 
			
		||||
	bar := formatter.CreateProgressbar(-1, "collecting recipe listing")
 | 
			
		||||
	for {
 | 
			
		||||
		var reposList []RepoMeta
 | 
			
		||||
 | 
			
		||||
@ -514,28 +558,32 @@ func ReadReposMetadata() (RepoCatalogue, error) {
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(reposList) == 0 {
 | 
			
		||||
			bar.Add(1)
 | 
			
		||||
			if !debug {
 | 
			
		||||
				bar.Add(1)
 | 
			
		||||
			}
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for idx, repo := range reposList {
 | 
			
		||||
			var topicMeta TopicMeta
 | 
			
		||||
 | 
			
		||||
			topicsURL := getReposTopicUrl(repo.Name)
 | 
			
		||||
			if err := web.ReadJSON(topicsURL, &topicMeta); err != nil {
 | 
			
		||||
				return reposMeta, err
 | 
			
		||||
			// NOTE(d1): the "example" recipe is a temporary special case
 | 
			
		||||
			// https://git.coopcloud.tech/toolshed/organising/issues/666
 | 
			
		||||
			if repo.Name == "example" {
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if slices.Contains(topicMeta.Topics, "recipe") && repo.Name != "example" {
 | 
			
		||||
				reposMeta[repo.Name] = reposList[idx]
 | 
			
		||||
			}
 | 
			
		||||
			reposMeta[repo.Name] = reposList[idx]
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		pageIdx++
 | 
			
		||||
		bar.Add(1)
 | 
			
		||||
 | 
			
		||||
		if !debug {
 | 
			
		||||
			bar.Add(1)
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	fmt.Println() // newline for spinner
 | 
			
		||||
	if err := bar.Close(); err != nil {
 | 
			
		||||
		return reposMeta, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return reposMeta, nil
 | 
			
		||||
}
 | 
			
		||||
@ -597,7 +645,7 @@ func GetRecipeCatalogueVersions(recipeName string, catl RecipeCatalogue) ([]stri
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// UpdateRepositories clones and updates all recipe repositories locally.
 | 
			
		||||
func UpdateRepositories(repos RepoCatalogue, recipeName string) error {
 | 
			
		||||
func UpdateRepositories(repos RepoCatalogue, recipeName string, debug bool) error {
 | 
			
		||||
	var barLength int
 | 
			
		||||
	if recipeName != "" {
 | 
			
		||||
		barLength = 1
 | 
			
		||||
@ -605,9 +653,9 @@ func UpdateRepositories(repos RepoCatalogue, recipeName string) error {
 | 
			
		||||
		barLength = len(repos)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cloneLimiter := limit.New(10)
 | 
			
		||||
	cloneLimiter := limit.New(3)
 | 
			
		||||
 | 
			
		||||
	retrieveBar := formatter.CreateProgressbar(barLength, "ensuring recipes are cloned & up-to-date...")
 | 
			
		||||
	retrieveBar := formatter.CreateProgressbar(barLength, "retrieving recipes")
 | 
			
		||||
	ch := make(chan string, barLength)
 | 
			
		||||
	for _, repoMeta := range repos {
 | 
			
		||||
		go func(rm RepoMeta) {
 | 
			
		||||
@ -616,7 +664,9 @@ func UpdateRepositories(repos RepoCatalogue, recipeName string) error {
 | 
			
		||||
 | 
			
		||||
			if recipeName != "" && recipeName != rm.Name {
 | 
			
		||||
				ch <- rm.Name
 | 
			
		||||
				retrieveBar.Add(1)
 | 
			
		||||
				if !debug {
 | 
			
		||||
					retrieveBar.Add(1)
 | 
			
		||||
				}
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
@ -625,7 +675,9 @@ func UpdateRepositories(repos RepoCatalogue, recipeName string) error {
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			ch <- rm.Name
 | 
			
		||||
			retrieveBar.Add(1)
 | 
			
		||||
			if !debug {
 | 
			
		||||
				retrieveBar.Add(1)
 | 
			
		||||
			}
 | 
			
		||||
		}(repoMeta)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@ -633,12 +685,11 @@ func UpdateRepositories(repos RepoCatalogue, recipeName string) error {
 | 
			
		||||
		<-ch // wait for everything
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
	if err := retrieveBar.Close(); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
// getReposTopicUrl retrieves the repository specific topic listing.
 | 
			
		||||
func getReposTopicUrl(repoName string) string {
 | 
			
		||||
	return fmt.Sprintf("https://git.coopcloud.tech/api/v1/repos/coop-cloud/%s/topics", repoName)
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ensurePathExists ensures that a path exists.
 | 
			
		||||
 | 
			
		||||
@ -33,7 +33,8 @@ func TestGet(t *testing.T) {
 | 
			
		||||
			name: "foo:1.2.3",
 | 
			
		||||
			recipe: Recipe{
 | 
			
		||||
				Name:          "foo",
 | 
			
		||||
				Version:       "1.2.3",
 | 
			
		||||
				EnvVersion:    "1.2.3",
 | 
			
		||||
				EnvVersionRaw: "1.2.3",
 | 
			
		||||
				Dir:           path.Join(cfg.GetAbraDir(), "/recipes/foo"),
 | 
			
		||||
				GitURL:        "https://git.coopcloud.tech/coop-cloud/foo.git",
 | 
			
		||||
				SSHURL:        "ssh://git@git.coopcloud.tech:2222/coop-cloud/foo.git",
 | 
			
		||||
@ -60,7 +61,23 @@ func TestGet(t *testing.T) {
 | 
			
		||||
			name: "mygit.org/myorg/cool-recipe:1.2.4",
 | 
			
		||||
			recipe: Recipe{
 | 
			
		||||
				Name:          "mygit.org/myorg/cool-recipe",
 | 
			
		||||
				Version:       "1.2.4",
 | 
			
		||||
				EnvVersion:    "1.2.4",
 | 
			
		||||
				EnvVersionRaw: "1.2.4",
 | 
			
		||||
				Dir:           path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
 | 
			
		||||
				GitURL:        "https://mygit.org/myorg/cool-recipe.git",
 | 
			
		||||
				SSHURL:        "ssh://git@mygit.org/myorg/cool-recipe.git",
 | 
			
		||||
				ComposePath:   path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/compose.yml"),
 | 
			
		||||
				ReadmePath:    path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/README.md"),
 | 
			
		||||
				SampleEnvPath: path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/.env.sample"),
 | 
			
		||||
				AbraShPath:    path.Join(cfg.GetAbraDir(), "recipes/mygit_org_myorg_cool-recipe/abra.sh"),
 | 
			
		||||
			},
 | 
			
		||||
		},
 | 
			
		||||
		{
 | 
			
		||||
			name: "mygit.org/myorg/cool-recipe:1e83340e+U",
 | 
			
		||||
			recipe: Recipe{
 | 
			
		||||
				Name:          "mygit.org/myorg/cool-recipe",
 | 
			
		||||
				EnvVersion:    "1e83340e",
 | 
			
		||||
				EnvVersionRaw: "1e83340e+U",
 | 
			
		||||
				Dir:           path.Join(cfg.GetAbraDir(), "/recipes/mygit_org_myorg_cool-recipe"),
 | 
			
		||||
				GitURL:        "https://mygit.org/myorg/cool-recipe.git",
 | 
			
		||||
				SSHURL:        "ssh://git@mygit.org/myorg/cool-recipe.git",
 | 
			
		||||
 | 
			
		||||
@ -33,6 +33,10 @@ type Secret struct {
 | 
			
		||||
	// variable. For Example:
 | 
			
		||||
	//   SECRET_FOO=v1 # length=12
 | 
			
		||||
	Length int
 | 
			
		||||
	// Charset comes from the charset modifier at the secret version environment
 | 
			
		||||
	// variable. For Example:
 | 
			
		||||
	//   SECRET_FOO=v1 # charset=default,special
 | 
			
		||||
	Charset string
 | 
			
		||||
	// RemoteName is the name of the secret on the server. For example:
 | 
			
		||||
	//   name: ${STACK_NAME}_test_pass_two_${SECRET_TEST_PASS_TWO_VERSION}
 | 
			
		||||
	// With the following:
 | 
			
		||||
@ -43,38 +47,38 @@ type Secret struct {
 | 
			
		||||
	RemoteName string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GeneratePasswords generates passwords.
 | 
			
		||||
func GeneratePasswords(count, length uint) ([]string, error) {
 | 
			
		||||
// GeneratePassword generates passwords.
 | 
			
		||||
func GeneratePassword(length uint, charset string) (string, error) {
 | 
			
		||||
	passwords, err := passgen.GeneratePasswords(
 | 
			
		||||
		count,
 | 
			
		||||
		1,
 | 
			
		||||
		length,
 | 
			
		||||
		passgen.AlphabetDefault,
 | 
			
		||||
		charset,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Debugf("generated %s", strings.Join(passwords, ", "))
 | 
			
		||||
 | 
			
		||||
	return passwords, nil
 | 
			
		||||
	return passwords[0], nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GeneratePassphrases generates human readable and rememberable passphrases.
 | 
			
		||||
func GeneratePassphrases(count uint) ([]string, error) {
 | 
			
		||||
// GeneratePassphrase generates human readable and rememberable passphrases.
 | 
			
		||||
func GeneratePassphrase() (string, error) {
 | 
			
		||||
	passphrases, err := passgen.GeneratePassphrases(
 | 
			
		||||
		count,
 | 
			
		||||
		1,
 | 
			
		||||
		passgen.PassphraseWordCountDefault,
 | 
			
		||||
		rune('-'),
 | 
			
		||||
		passgen.PassphraseCasingDefault,
 | 
			
		||||
		passgen.WordListDefault,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
		return "", err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Debugf("generated %s", strings.Join(passphrases, ", "))
 | 
			
		||||
 | 
			
		||||
	return passphrases, nil
 | 
			
		||||
	return passphrases[0], nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// ReadSecretsConfig reads secret names/versions from the recipe config. The
 | 
			
		||||
@ -150,6 +154,8 @@ func ReadSecretsConfig(appEnvPath string, composeFiles []string, stackName strin
 | 
			
		||||
				}
 | 
			
		||||
				value.Length = length
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			value.Charset = resolveCharset(modifierValues["charset"])
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
		secretValues[secretId] = value
 | 
			
		||||
@ -158,6 +164,22 @@ func ReadSecretsConfig(appEnvPath string, composeFiles []string, stackName strin
 | 
			
		||||
	return secretValues, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// resolveCharset sets the passgen Alphabet required for a secret
 | 
			
		||||
func resolveCharset(input string) string {
 | 
			
		||||
	switch strings.ToLower(input) {
 | 
			
		||||
	case "special":
 | 
			
		||||
		return passgen.AlphabetSpecial
 | 
			
		||||
	case "safespecial":
 | 
			
		||||
		return "!@#%^&*_-+="
 | 
			
		||||
	case "default,special", "special,default":
 | 
			
		||||
		return passgen.AlphabetDefault + passgen.AlphabetSpecial
 | 
			
		||||
	case "default,safespecial", "safespecial,default":
 | 
			
		||||
		return passgen.AlphabetDefault + "!@#%^&*_-+="
 | 
			
		||||
	default:
 | 
			
		||||
		return passgen.AlphabetDefault // Fallback to default
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// GenerateSecrets generates secrets locally and sends them to a remote server for storage.
 | 
			
		||||
func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server string) (map[string]string, error) {
 | 
			
		||||
	secretsGenerated := map[string]string{}
 | 
			
		||||
@ -173,13 +195,13 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
 | 
			
		||||
			log.Debugf("attempting to generate and store %s on %s", secret.RemoteName, server)
 | 
			
		||||
 | 
			
		||||
			if secret.Length > 0 {
 | 
			
		||||
				passwords, err := GeneratePasswords(1, uint(secret.Length))
 | 
			
		||||
				password, err := GeneratePassword(uint(secret.Length), secret.Charset)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					ch <- err
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if err := client.StoreSecret(cl, secret.RemoteName, passwords[0], server); err != nil {
 | 
			
		||||
				if err := client.StoreSecret(cl, secret.RemoteName, password, server); err != nil {
 | 
			
		||||
					if strings.Contains(err.Error(), "AlreadyExists") {
 | 
			
		||||
						log.Warnf("%s already exists", secret.RemoteName)
 | 
			
		||||
						ch <- nil
 | 
			
		||||
@ -191,15 +213,15 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
 | 
			
		||||
 | 
			
		||||
				mutex.Lock()
 | 
			
		||||
				defer mutex.Unlock()
 | 
			
		||||
				secretsGenerated[secretName] = passwords[0]
 | 
			
		||||
				secretsGenerated[secretName] = password
 | 
			
		||||
			} else {
 | 
			
		||||
				passphrases, err := GeneratePassphrases(1)
 | 
			
		||||
				passphrase, err := GeneratePassphrase()
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					ch <- err
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if err := client.StoreSecret(cl, secret.RemoteName, passphrases[0], server); err != nil {
 | 
			
		||||
				if err := client.StoreSecret(cl, secret.RemoteName, passphrase, server); err != nil {
 | 
			
		||||
					if strings.Contains(err.Error(), "AlreadyExists") {
 | 
			
		||||
						log.Warnf("%s already exists", secret.RemoteName)
 | 
			
		||||
						ch <- nil
 | 
			
		||||
@ -211,7 +233,7 @@ func GenerateSecrets(cl *dockerClient.Client, secrets map[string]Secret, server
 | 
			
		||||
 | 
			
		||||
				mutex.Lock()
 | 
			
		||||
				defer mutex.Unlock()
 | 
			
		||||
				secretsGenerated[secretName] = passphrases[0]
 | 
			
		||||
				secretsGenerated[secretName] = passphrase
 | 
			
		||||
			}
 | 
			
		||||
			ch <- nil
 | 
			
		||||
		}(n, v)
 | 
			
		||||
 | 
			
		||||
@ -17,16 +17,37 @@ func TestReadSecretsConfig(t *testing.T) {
 | 
			
		||||
	assert.Equal(t, "test_example_com_test_pass_one_v2", secretsFromConfig["test_pass_one"].RemoteName)
 | 
			
		||||
	assert.Equal(t, "v2", secretsFromConfig["test_pass_one"].Version)
 | 
			
		||||
	assert.Equal(t, 0, secretsFromConfig["test_pass_one"].Length)
 | 
			
		||||
	assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_one"].Charset)
 | 
			
		||||
 | 
			
		||||
	// Has a length modifier
 | 
			
		||||
	assert.Equal(t, "test_example_com_test_pass_two_v1", secretsFromConfig["test_pass_two"].RemoteName)
 | 
			
		||||
	assert.Equal(t, "v1", secretsFromConfig["test_pass_two"].Version)
 | 
			
		||||
	assert.Equal(t, 10, secretsFromConfig["test_pass_two"].Length)
 | 
			
		||||
	assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_two"].Charset)
 | 
			
		||||
 | 
			
		||||
	// Secret name does not include the secret id
 | 
			
		||||
	assert.Equal(t, "test_example_com_pass_three_v2", secretsFromConfig["test_pass_three"].RemoteName)
 | 
			
		||||
	assert.Equal(t, "v2", secretsFromConfig["test_pass_three"].Version)
 | 
			
		||||
	assert.Equal(t, 0, secretsFromConfig["test_pass_three"].Length)
 | 
			
		||||
	assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789", secretsFromConfig["test_pass_three"].Charset)
 | 
			
		||||
 | 
			
		||||
	// Has a length modifier and a charset=default,safespecial modifier
 | 
			
		||||
	assert.Equal(t, "test_example_com_test_pass_four_v1", secretsFromConfig["test_pass_four"].RemoteName)
 | 
			
		||||
	assert.Equal(t, "v1", secretsFromConfig["test_pass_four"].Version)
 | 
			
		||||
	assert.Equal(t, 12, secretsFromConfig["test_pass_four"].Length)
 | 
			
		||||
	assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#%^&*_-+=", secretsFromConfig["test_pass_four"].Charset)
 | 
			
		||||
 | 
			
		||||
	// Has a length modifier and a charset=default,special modifier
 | 
			
		||||
	assert.Equal(t, "test_example_com_test_pass_five_v1", secretsFromConfig["test_pass_five"].RemoteName)
 | 
			
		||||
	assert.Equal(t, "v1", secretsFromConfig["test_pass_five"].Version)
 | 
			
		||||
	assert.Equal(t, 12, secretsFromConfig["test_pass_five"].Length)
 | 
			
		||||
	assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*_-+=", secretsFromConfig["test_pass_five"].Charset)
 | 
			
		||||
 | 
			
		||||
	// Has only a charset=default,special modifier, which gets setted but ignored in the generation
 | 
			
		||||
	assert.Equal(t, "test_example_com_test_pass_six_v1", secretsFromConfig["test_pass_six"].RemoteName)
 | 
			
		||||
	assert.Equal(t, "v1", secretsFromConfig["test_pass_six"].Version)
 | 
			
		||||
	assert.Equal(t, 0, secretsFromConfig["test_pass_six"].Length)
 | 
			
		||||
	assert.Equal(t, "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789!@#$%^&*_-+=", secretsFromConfig["test_pass_six"].Charset)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func TestReadSecretsConfigWithLongDomain(t *testing.T) {
 | 
			
		||||
 | 
			
		||||
@ -1,3 +1,6 @@
 | 
			
		||||
SECRET_TEST_PASS_ONE_VERSION=v2
 | 
			
		||||
SECRET_TEST_PASS_TWO_VERSION=v1 # length=10
 | 
			
		||||
SECRET_TEST_PASS_THREE_VERSION=v2
 | 
			
		||||
SECRET_TEST_PASS_FOUR_VERSION=v1 # length=12 charset=default,safespecial
 | 
			
		||||
SECRET_TEST_PASS_FIVE_VERSION=v1 # length=12 charset=default,special
 | 
			
		||||
SECRET_TEST_PASS_SIX_VERSION=v1 # charset=default,special
 | 
			
		||||
 | 
			
		||||
@ -8,6 +8,9 @@ services:
 | 
			
		||||
      - test_pass_one
 | 
			
		||||
      - test_pass_two
 | 
			
		||||
      - test_pass_three
 | 
			
		||||
      - test_pass_four
 | 
			
		||||
      - test_pass_five
 | 
			
		||||
      - test_pass_six
 | 
			
		||||
 | 
			
		||||
secrets:
 | 
			
		||||
  test_pass_one:
 | 
			
		||||
@ -19,3 +22,12 @@ secrets:
 | 
			
		||||
  test_pass_three:
 | 
			
		||||
    external: true
 | 
			
		||||
    name: ${STACK_NAME}_pass_three_${SECRET_TEST_PASS_THREE_VERSION} # secretId and name don't match
 | 
			
		||||
  test_pass_four:
 | 
			
		||||
    external: true
 | 
			
		||||
    name: ${STACK_NAME}_test_pass_four_${SECRET_TEST_PASS_FOUR_VERSION}
 | 
			
		||||
  test_pass_five:
 | 
			
		||||
    external: true
 | 
			
		||||
    name: ${STACK_NAME}_test_pass_five_${SECRET_TEST_PASS_FIVE_VERSION}
 | 
			
		||||
  test_pass_six:
 | 
			
		||||
    external: true
 | 
			
		||||
    name: ${STACK_NAME}_test_pass_six_${SECRET_TEST_PASS_SIX_VERSION}
 | 
			
		||||
 | 
			
		||||
@ -20,6 +20,8 @@ func Fatal(hostname string, err error) error {
 | 
			
		||||
		return fmt.Errorf("ssh auth: permission denied for %s", hostname)
 | 
			
		||||
	} else if strings.Contains(out, "Network is unreachable") {
 | 
			
		||||
		return fmt.Errorf("unable to connect to %s, please check your SSH config", hostname)
 | 
			
		||||
	} else if strings.Contains(out, "Is the docker daemon running") {
 | 
			
		||||
		return fmt.Errorf("docker: is the daemon running / your user has docker permissions?")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return err
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										353
									
								
								pkg/ui/deploy.go
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										353
									
								
								pkg/ui/deploy.go
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,353 @@
 | 
			
		||||
package ui
 | 
			
		||||
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"encoding/json"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/formatter"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/logs"
 | 
			
		||||
	tea "github.com/charmbracelet/bubbletea"
 | 
			
		||||
	"github.com/docker/cli/cli/command/service/progress"
 | 
			
		||||
	containerTypes "github.com/docker/docker/api/types/container"
 | 
			
		||||
	"github.com/docker/docker/api/types/filters"
 | 
			
		||||
	dockerClient "github.com/docker/docker/client"
 | 
			
		||||
	"github.com/docker/docker/pkg/jsonmessage"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
var IsRunning bool
 | 
			
		||||
 | 
			
		||||
type statusMsg struct {
 | 
			
		||||
	stream  stream
 | 
			
		||||
	jsonMsg jsonmessage.JSONMessage
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type progressCompleteMsg struct {
 | 
			
		||||
	stream stream
 | 
			
		||||
	failed bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type healthcheckMsg struct {
 | 
			
		||||
	stream stream
 | 
			
		||||
	health string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type ServiceMeta struct {
 | 
			
		||||
	Name string
 | 
			
		||||
	ID   string
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type Model struct {
 | 
			
		||||
	appName string
 | 
			
		||||
	cl      *dockerClient.Client
 | 
			
		||||
	count   int
 | 
			
		||||
	ctx     context.Context
 | 
			
		||||
	timeout time.Duration
 | 
			
		||||
	width   int
 | 
			
		||||
	filters filters.Args
 | 
			
		||||
 | 
			
		||||
	Streams  *[]stream
 | 
			
		||||
	Logs     *[]string
 | 
			
		||||
	Failed   bool
 | 
			
		||||
	TimedOut bool
 | 
			
		||||
	Quit     bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Model) complete() bool {
 | 
			
		||||
	if m.count == len(*m.Streams) {
 | 
			
		||||
		return true
 | 
			
		||||
	}
 | 
			
		||||
	return false
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type stream struct {
 | 
			
		||||
	Name string
 | 
			
		||||
	Err  error
 | 
			
		||||
 | 
			
		||||
	decoder  *json.Decoder
 | 
			
		||||
	id       string
 | 
			
		||||
	reader   *io.PipeReader
 | 
			
		||||
	writer   *io.PipeWriter
 | 
			
		||||
	status   string
 | 
			
		||||
	retries  int
 | 
			
		||||
	health   string
 | 
			
		||||
	rollback bool
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s stream) String() string {
 | 
			
		||||
	out := fmt.Sprintf("{decoder: %v, ", s.decoder)
 | 
			
		||||
	out += fmt.Sprintf("err: %v, ", s.Err)
 | 
			
		||||
	out += fmt.Sprintf("id: %s, ", s.id)
 | 
			
		||||
	out += fmt.Sprintf("name: %s, ", s.Name)
 | 
			
		||||
	out += fmt.Sprintf("reader: %v, ", s.reader)
 | 
			
		||||
	out += fmt.Sprintf("writer: %v, ", s.writer)
 | 
			
		||||
	out += fmt.Sprintf("status: %s, ", s.status)
 | 
			
		||||
	return out
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s stream) progress(m Model) tea.Msg {
 | 
			
		||||
	if err := progress.ServiceProgress(m.ctx, m.cl, s.id, s.writer); err != nil {
 | 
			
		||||
		return progressCompleteMsg{
 | 
			
		||||
			stream: s,
 | 
			
		||||
			failed: true,
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return progressCompleteMsg{stream: s}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s stream) process() tea.Msg {
 | 
			
		||||
	var jsonMsg jsonmessage.JSONMessage
 | 
			
		||||
 | 
			
		||||
	if err := s.decoder.Decode(&jsonMsg); err != nil {
 | 
			
		||||
		if err == io.EOF {
 | 
			
		||||
			// NOTE(d1): end processing messages
 | 
			
		||||
			return nil
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return statusMsg{
 | 
			
		||||
		stream:  s,
 | 
			
		||||
		jsonMsg: jsonMsg,
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (s stream) healthcheck(m Model) tea.Msg {
 | 
			
		||||
	filters := filters.NewArgs()
 | 
			
		||||
	filters.Add("name", fmt.Sprintf("^%s", s.Name))
 | 
			
		||||
 | 
			
		||||
	containers, err := m.cl.ContainerList(m.ctx, containerTypes.ListOptions{Filters: filters})
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		s.Err = err
 | 
			
		||||
		return healthcheckMsg{stream: s}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(containers) == 0 {
 | 
			
		||||
		return healthcheckMsg{stream: s}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	container := containers[0]
 | 
			
		||||
	containerState, err := m.cl.ContainerInspect(m.ctx, container.ID)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		s.Err = err
 | 
			
		||||
		return healthcheckMsg{stream: s}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var health string
 | 
			
		||||
	if containerState.State.Health != nil {
 | 
			
		||||
		health = containerState.State.Health.Status
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return healthcheckMsg{stream: s, health: health}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func DeployInitialModel(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	cl *dockerClient.Client,
 | 
			
		||||
	services []ServiceMeta,
 | 
			
		||||
	appName string,
 | 
			
		||||
	timeout time.Duration,
 | 
			
		||||
	filters filters.Args,
 | 
			
		||||
) Model {
 | 
			
		||||
	var streams []stream
 | 
			
		||||
	for _, service := range services {
 | 
			
		||||
		r, w := io.Pipe()
 | 
			
		||||
		d := json.NewDecoder(r)
 | 
			
		||||
		streams = append(streams, stream{
 | 
			
		||||
			Name:    service.Name,
 | 
			
		||||
			id:      service.ID,
 | 
			
		||||
			reader:  r,
 | 
			
		||||
			writer:  w,
 | 
			
		||||
			decoder: d,
 | 
			
		||||
			retries: -1, // NOTE(d1): skip first attempt
 | 
			
		||||
			health:  "?",
 | 
			
		||||
		})
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	sort.Slice(streams, func(i, j int) bool {
 | 
			
		||||
		return streams[i].Name < streams[j].Name
 | 
			
		||||
	})
 | 
			
		||||
 | 
			
		||||
	return Model{
 | 
			
		||||
		ctx:     ctx,
 | 
			
		||||
		cl:      cl,
 | 
			
		||||
		appName: appName,
 | 
			
		||||
		timeout: timeout,
 | 
			
		||||
		filters: filters,
 | 
			
		||||
		Streams: &streams,
 | 
			
		||||
		Logs:    &[]string{},
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Model) Init() tea.Cmd {
 | 
			
		||||
	var cmds []tea.Cmd
 | 
			
		||||
 | 
			
		||||
	for _, stream := range *m.Streams {
 | 
			
		||||
		cmds = append(
 | 
			
		||||
			cmds,
 | 
			
		||||
			[]tea.Cmd{
 | 
			
		||||
				func() tea.Msg { return stream.progress(m) },
 | 
			
		||||
				func() tea.Msg { return stream.process() },
 | 
			
		||||
				func() tea.Msg { return stream.healthcheck(m) },
 | 
			
		||||
			}...,
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	cmds = append(cmds, func() tea.Msg { return deployTimeout(m) })
 | 
			
		||||
	cmds = append(cmds, func() tea.Msg { return m.gatherLogs() })
 | 
			
		||||
 | 
			
		||||
	return tea.Batch(cmds...)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Model) gatherLogs() tea.Msg {
 | 
			
		||||
	var services []string
 | 
			
		||||
	for _, s := range *m.Streams {
 | 
			
		||||
		services = append(services, s.Name)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	opts := logs.TailOpts{
 | 
			
		||||
		AppName:  m.appName,
 | 
			
		||||
		Services: services,
 | 
			
		||||
		StdErr:   true,
 | 
			
		||||
		Buffer:   m.Logs,
 | 
			
		||||
		ToBuffer: true,
 | 
			
		||||
		Filters:  m.filters,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	// NOTE(d1): not interested in log polling errors. if we don't see logs it
 | 
			
		||||
	// will hopefully be self-evident based on what happened in the deployment
 | 
			
		||||
	logs.TailLogs(m.cl, opts)
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
type timeoutMsg struct{}
 | 
			
		||||
 | 
			
		||||
func deployTimeout(m Model) tea.Msg {
 | 
			
		||||
	<-time.After(m.timeout)
 | 
			
		||||
	return timeoutMsg{}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
 | 
			
		||||
	var cmds []tea.Cmd
 | 
			
		||||
 | 
			
		||||
	switch msg := msg.(type) {
 | 
			
		||||
	case tea.KeyMsg:
 | 
			
		||||
		switch msg.String() {
 | 
			
		||||
		case "ctrl+c", "q":
 | 
			
		||||
			m.Quit = true
 | 
			
		||||
			return m, tea.Quit
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	case tea.WindowSizeMsg:
 | 
			
		||||
		m.width = msg.Width
 | 
			
		||||
 | 
			
		||||
	case progressCompleteMsg:
 | 
			
		||||
		if msg.failed {
 | 
			
		||||
			m.Failed = true
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		m.count += 1
 | 
			
		||||
 | 
			
		||||
		if m.complete() {
 | 
			
		||||
			return m, tea.Quit
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
	case timeoutMsg:
 | 
			
		||||
		m.TimedOut = true
 | 
			
		||||
		return m, tea.Quit
 | 
			
		||||
 | 
			
		||||
	case healthcheckMsg:
 | 
			
		||||
		for idx, s := range *m.Streams {
 | 
			
		||||
			if s.id == msg.stream.id {
 | 
			
		||||
				h := "?"
 | 
			
		||||
				if s.health != "" {
 | 
			
		||||
					h = s.health
 | 
			
		||||
				}
 | 
			
		||||
				if msg.health != "" {
 | 
			
		||||
					h = msg.health
 | 
			
		||||
				}
 | 
			
		||||
				(*m.Streams)[idx].health = h
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		cmds = append(
 | 
			
		||||
			cmds,
 | 
			
		||||
			func() tea.Msg { return msg.stream.healthcheck(m) },
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
	case statusMsg:
 | 
			
		||||
		for idx, s := range *m.Streams {
 | 
			
		||||
			if s.id == msg.stream.id {
 | 
			
		||||
 | 
			
		||||
				if msg.jsonMsg.ID == "rollback" {
 | 
			
		||||
					m.Failed = true
 | 
			
		||||
					(*m.Streams)[idx].rollback = true
 | 
			
		||||
				}
 | 
			
		||||
 | 
			
		||||
				if msg.jsonMsg.ID != "overall progress" {
 | 
			
		||||
					newStatus := strings.ToLower(msg.jsonMsg.Status)
 | 
			
		||||
					currentStatus := (*m.Streams)[idx].status
 | 
			
		||||
 | 
			
		||||
					if !strings.Contains(currentStatus, "starting") &&
 | 
			
		||||
						strings.Contains(newStatus, "starting") {
 | 
			
		||||
						(*m.Streams)[idx].retries += 1
 | 
			
		||||
					}
 | 
			
		||||
 | 
			
		||||
					if (*m.Streams)[idx].rollback {
 | 
			
		||||
						if msg.jsonMsg.ID == "rollback" {
 | 
			
		||||
							(*m.Streams)[idx].status = newStatus
 | 
			
		||||
						}
 | 
			
		||||
					} else {
 | 
			
		||||
						(*m.Streams)[idx].status = newStatus
 | 
			
		||||
					}
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		cmds = append(
 | 
			
		||||
			cmds,
 | 
			
		||||
			func() tea.Msg { return msg.stream.process() },
 | 
			
		||||
		)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return m, tea.Batch(cmds...)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (m Model) View() string {
 | 
			
		||||
	body := strings.Builder{}
 | 
			
		||||
 | 
			
		||||
	for _, stream := range *m.Streams {
 | 
			
		||||
		split := strings.Split(stream.Name, "_")
 | 
			
		||||
		short := split[len(split)-1]
 | 
			
		||||
 | 
			
		||||
		status := stream.status
 | 
			
		||||
		if strings.Contains(stream.status, "converged") && !stream.rollback {
 | 
			
		||||
			status = "succeeded"
 | 
			
		||||
		}
 | 
			
		||||
		if strings.Contains(stream.status, "rolled back") {
 | 
			
		||||
			status = "rolled back"
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		retries := 0
 | 
			
		||||
		if stream.retries > 0 {
 | 
			
		||||
			retries = stream.retries
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		output := fmt.Sprintf("%s: %s (retries: %v, healthcheck: %s)",
 | 
			
		||||
			formatter.BoldStyle.Render(short),
 | 
			
		||||
			status,
 | 
			
		||||
			retries,
 | 
			
		||||
			stream.health,
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		body.WriteString(output)
 | 
			
		||||
		body.WriteString("\n")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return body.String()
 | 
			
		||||
}
 | 
			
		||||
@ -9,14 +9,14 @@ import (
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"github.com/docker/cli/cli"
 | 
			
		||||
	"github.com/docker/cli/cli/command"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	"github.com/docker/docker/api/types/container"
 | 
			
		||||
	apiclient "github.com/docker/docker/client"
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
// RunExec runs a command on a remote container. io.Writer corresponds to the
 | 
			
		||||
// command output.
 | 
			
		||||
func RunExec(dockerCli command.Cli, client *apiclient.Client, containerID string,
 | 
			
		||||
	execConfig *types.ExecConfig) (io.Writer, error) {
 | 
			
		||||
	execOptions *container.ExecOptions) (io.Writer, error) {
 | 
			
		||||
	ctx := context.Background()
 | 
			
		||||
 | 
			
		||||
	// We need to check the tty _before_ we do the ContainerExecCreate, because
 | 
			
		||||
@ -26,13 +26,13 @@ func RunExec(dockerCli command.Cli, client *apiclient.Client, containerID string
 | 
			
		||||
	if _, err := client.ContainerInspect(ctx, containerID); err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
	if !execConfig.Detach {
 | 
			
		||||
		if err := dockerCli.In().CheckTty(execConfig.AttachStdin, execConfig.Tty); err != nil {
 | 
			
		||||
	if !execOptions.Detach {
 | 
			
		||||
		if err := dockerCli.In().CheckTty(execOptions.AttachStdin, execOptions.Tty); err != nil {
 | 
			
		||||
			return nil, err
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	response, err := client.ContainerExecCreate(ctx, containerID, *execConfig)
 | 
			
		||||
	response, err := client.ContainerExecCreate(ctx, containerID, *execOptions)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
	}
 | 
			
		||||
@ -42,40 +42,40 @@ func RunExec(dockerCli command.Cli, client *apiclient.Client, containerID string
 | 
			
		||||
		return nil, errors.New("exec ID empty")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if execConfig.Detach {
 | 
			
		||||
		execStartCheck := types.ExecStartCheck{
 | 
			
		||||
			Detach: execConfig.Detach,
 | 
			
		||||
			Tty:    execConfig.Tty,
 | 
			
		||||
	if execOptions.Detach {
 | 
			
		||||
		execStartCheck := container.ExecStartOptions{
 | 
			
		||||
			Detach: execOptions.Detach,
 | 
			
		||||
			Tty:    execOptions.Tty,
 | 
			
		||||
		}
 | 
			
		||||
		return nil, client.ContainerExecStart(ctx, execID, execStartCheck)
 | 
			
		||||
	}
 | 
			
		||||
	return interactiveExec(ctx, dockerCli, client, execConfig, execID)
 | 
			
		||||
	return interactiveExec(ctx, dockerCli, client, execOptions, execID)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func interactiveExec(ctx context.Context, dockerCli command.Cli, client *apiclient.Client,
 | 
			
		||||
	execConfig *types.ExecConfig, execID string) (io.Writer, error) {
 | 
			
		||||
	execOpts *container.ExecOptions, execID string) (io.Writer, error) {
 | 
			
		||||
	// Interactive exec requested.
 | 
			
		||||
	var (
 | 
			
		||||
		out, stderr io.Writer
 | 
			
		||||
		in          io.ReadCloser
 | 
			
		||||
	)
 | 
			
		||||
 | 
			
		||||
	if execConfig.AttachStdin {
 | 
			
		||||
	if execOpts.AttachStdin {
 | 
			
		||||
		in = dockerCli.In()
 | 
			
		||||
	}
 | 
			
		||||
	if execConfig.AttachStdout {
 | 
			
		||||
	if execOpts.AttachStdout {
 | 
			
		||||
		out = dockerCli.Out()
 | 
			
		||||
	}
 | 
			
		||||
	if execConfig.AttachStderr {
 | 
			
		||||
		if execConfig.Tty {
 | 
			
		||||
	if execOpts.AttachStderr {
 | 
			
		||||
		if execOpts.Tty {
 | 
			
		||||
			stderr = dockerCli.Out()
 | 
			
		||||
		} else {
 | 
			
		||||
			stderr = dockerCli.Err()
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	execStartCheck := types.ExecStartCheck{
 | 
			
		||||
		Tty: execConfig.Tty,
 | 
			
		||||
	execStartCheck := container.ExecStartOptions{
 | 
			
		||||
		Tty: execOpts.Tty,
 | 
			
		||||
	}
 | 
			
		||||
	resp, err := client.ContainerExecAttach(ctx, execID, execStartCheck)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
@ -94,15 +94,15 @@ func interactiveExec(ctx context.Context, dockerCli command.Cli, client *apiclie
 | 
			
		||||
				outputStream: out,
 | 
			
		||||
				errorStream:  stderr,
 | 
			
		||||
				resp:         resp,
 | 
			
		||||
				tty:          execConfig.Tty,
 | 
			
		||||
				detachKeys:   execConfig.DetachKeys,
 | 
			
		||||
				tty:          execOpts.Tty,
 | 
			
		||||
				detachKeys:   execOpts.DetachKeys,
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			return streamer.stream(ctx)
 | 
			
		||||
		}()
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	if execConfig.Tty && dockerCli.In().IsTerminal() {
 | 
			
		||||
	if execOpts.Tty && dockerCli.In().IsTerminal() {
 | 
			
		||||
		if err := MonitorTtySize(ctx, client, dockerCli, execID, true); err != nil {
 | 
			
		||||
			fmt.Fprintln(dockerCli.Err(), "Error monitoring TTY size:", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
@ -5,7 +5,6 @@ import (
 | 
			
		||||
	"strings"
 | 
			
		||||
 | 
			
		||||
	composetypes "github.com/docker/cli/cli/compose/types"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	networktypes "github.com/docker/docker/api/types/network"
 | 
			
		||||
	"github.com/docker/docker/api/types/swarm"
 | 
			
		||||
)
 | 
			
		||||
@ -52,13 +51,13 @@ func AddStackLabel(namespace Namespace, labels map[string]string) map[string]str
 | 
			
		||||
type networkMap map[string]composetypes.NetworkConfig
 | 
			
		||||
 | 
			
		||||
// Networks from the compose-file type to the engine API type
 | 
			
		||||
func Networks(namespace Namespace, networks networkMap, servicesNetworks map[string]struct{}) (map[string]types.NetworkCreate, []string) {
 | 
			
		||||
func Networks(namespace Namespace, networks networkMap, servicesNetworks map[string]struct{}) (map[string]networktypes.CreateOptions, []string) {
 | 
			
		||||
	if networks == nil {
 | 
			
		||||
		networks = make(map[string]composetypes.NetworkConfig)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	externalNetworks := []string{}
 | 
			
		||||
	result := make(map[string]types.NetworkCreate)
 | 
			
		||||
	result := make(map[string]networktypes.CreateOptions)
 | 
			
		||||
	for internalName := range servicesNetworks {
 | 
			
		||||
		network := networks[internalName]
 | 
			
		||||
		if network.External.External {
 | 
			
		||||
@ -66,7 +65,7 @@ func Networks(namespace Namespace, networks networkMap, servicesNetworks map[str
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		createOpts := types.NetworkCreate{
 | 
			
		||||
		createOpts := networktypes.CreateOptions{
 | 
			
		||||
			Labels:     AddStackLabel(namespace, network.Labels),
 | 
			
		||||
			Driver:     network.Driver,
 | 
			
		||||
			Options:    network.DriverOpts,
 | 
			
		||||
 | 
			
		||||
@ -4,7 +4,6 @@ import (
 | 
			
		||||
	"testing"
 | 
			
		||||
 | 
			
		||||
	composetypes "github.com/docker/cli/cli/compose/types"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	"github.com/docker/docker/api/types/network"
 | 
			
		||||
	"gotest.tools/v3/assert"
 | 
			
		||||
	is "gotest.tools/v3/assert/cmp"
 | 
			
		||||
@ -67,7 +66,7 @@ func TestNetworks(t *testing.T) {
 | 
			
		||||
			Name: "othername",
 | 
			
		||||
		},
 | 
			
		||||
	}
 | 
			
		||||
	expected := map[string]types.NetworkCreate{
 | 
			
		||||
	expected := map[string]network.CreateOptions{
 | 
			
		||||
		"foo_default": {
 | 
			
		||||
			Labels: map[string]string{
 | 
			
		||||
				LabelNamespace: "foo",
 | 
			
		||||
 | 
			
		||||
@ -3,11 +3,15 @@ package stack // https://github.com/docker/cli/blob/master/cli/command/stack/rem
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/signal"
 | 
			
		||||
	"sort"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	"github.com/docker/docker/api/types/network"
 | 
			
		||||
	"github.com/docker/docker/api/types/swarm"
 | 
			
		||||
	"github.com/docker/docker/api/types/versions"
 | 
			
		||||
	"github.com/docker/docker/client"
 | 
			
		||||
@ -17,57 +21,87 @@ import (
 | 
			
		||||
 | 
			
		||||
// RunRemove is the swarm implementation of docker stack remove
 | 
			
		||||
func RunRemove(ctx context.Context, client *apiclient.Client, opts Remove) error {
 | 
			
		||||
	var errs []string
 | 
			
		||||
	for _, namespace := range opts.Namespaces {
 | 
			
		||||
		services, err := GetStackServices(ctx, client, namespace)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	sigIntCh := make(chan os.Signal, 1)
 | 
			
		||||
	signal.Notify(sigIntCh, os.Interrupt)
 | 
			
		||||
	defer signal.Stop(sigIntCh)
 | 
			
		||||
 | 
			
		||||
		networks, err := getStackNetworks(ctx, client, namespace)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return err
 | 
			
		||||
		}
 | 
			
		||||
	waitCh := make(chan struct{})
 | 
			
		||||
	errCh := make(chan error)
 | 
			
		||||
 | 
			
		||||
		var secrets []swarm.Secret
 | 
			
		||||
		if versions.GreaterThanOrEqualTo(client.ClientVersion(), "1.25") {
 | 
			
		||||
			secrets, err = getStackSecrets(ctx, client, namespace)
 | 
			
		||||
	go func() {
 | 
			
		||||
		var errs []string
 | 
			
		||||
		for _, namespace := range opts.Namespaces {
 | 
			
		||||
			services, err := GetStackServices(ctx, client, namespace)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
				errCh <- err
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			networks, err := getStackNetworks(ctx, client, namespace)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				errCh <- err
 | 
			
		||||
				return
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var secrets []swarm.Secret
 | 
			
		||||
			if versions.GreaterThanOrEqualTo(client.ClientVersion(), "1.25") {
 | 
			
		||||
				secrets, err = getStackSecrets(ctx, client, namespace)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					errCh <- err
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			var configs []swarm.Config
 | 
			
		||||
			if versions.GreaterThanOrEqualTo(client.ClientVersion(), "1.30") {
 | 
			
		||||
				configs, err = getStackConfigs(ctx, client, namespace)
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					errCh <- err
 | 
			
		||||
					return
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			if len(services)+len(networks)+len(secrets)+len(configs) == 0 {
 | 
			
		||||
				log.Warnf("nothing found in stack: %s", namespace)
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			hasError := removeServices(ctx, client, services)
 | 
			
		||||
			hasError = removeSecrets(ctx, client, secrets) || hasError
 | 
			
		||||
			hasError = removeConfigs(ctx, client, configs) || hasError
 | 
			
		||||
			hasError = removeNetworks(ctx, client, networks) || hasError
 | 
			
		||||
 | 
			
		||||
			if hasError {
 | 
			
		||||
				errs = append(errs, fmt.Sprintf("failed to remove some resources from stack: %s", namespace))
 | 
			
		||||
				continue
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			log.Info("polling undeploy status")
 | 
			
		||||
			timeout, err := waitOnTasks(ctx, client, namespace)
 | 
			
		||||
			if timeout {
 | 
			
		||||
				errs = append(errs, err.Error())
 | 
			
		||||
			} else {
 | 
			
		||||
				if err != nil {
 | 
			
		||||
					errs = append(errs, fmt.Sprintf("failed to wait on tasks of stack: %s: %s", namespace, err))
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		var configs []swarm.Config
 | 
			
		||||
		if versions.GreaterThanOrEqualTo(client.ClientVersion(), "1.30") {
 | 
			
		||||
			configs, err = getStackConfigs(ctx, client, namespace)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return err
 | 
			
		||||
			}
 | 
			
		||||
		if len(errs) > 0 {
 | 
			
		||||
			errCh <- errors.Errorf(strings.Join(errs, "\n"))
 | 
			
		||||
			return
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(services)+len(networks)+len(secrets)+len(configs) == 0 {
 | 
			
		||||
			log.Warnf("nothing found in stack: %s", namespace)
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		close(waitCh)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
		hasError := removeServices(ctx, client, services)
 | 
			
		||||
		hasError = removeSecrets(ctx, client, secrets) || hasError
 | 
			
		||||
		hasError = removeConfigs(ctx, client, configs) || hasError
 | 
			
		||||
		hasError = removeNetworks(ctx, client, networks) || hasError
 | 
			
		||||
 | 
			
		||||
		if hasError {
 | 
			
		||||
			errs = append(errs, fmt.Sprintf("failed to remove some resources from stack: %s", namespace))
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		err = waitOnTasks(ctx, client, namespace)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			errs = append(errs, fmt.Sprintf("failed to wait on tasks of stack: %s: %s", namespace, err))
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(errs) > 0 {
 | 
			
		||||
		return errors.Errorf(strings.Join(errs, "\n"))
 | 
			
		||||
	select {
 | 
			
		||||
	case <-waitCh:
 | 
			
		||||
		return nil
 | 
			
		||||
	case <-sigIntCh:
 | 
			
		||||
		return fmt.Errorf("skipping as requested, undeploy still in progress 🟠")
 | 
			
		||||
	case err := <-errCh:
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
@ -87,7 +121,7 @@ func removeServices(
 | 
			
		||||
	var hasError bool
 | 
			
		||||
	sort.Slice(services, sortServiceByName(services))
 | 
			
		||||
	for _, service := range services {
 | 
			
		||||
		log.Infof("removing service %s", service.Spec.Name)
 | 
			
		||||
		log.Debugf("removing service %s", service.Spec.Name)
 | 
			
		||||
		if err := client.ServiceRemove(ctx, service.ID); err != nil {
 | 
			
		||||
			hasError = true
 | 
			
		||||
			log.Fatalf("failed to remove service %s: %s", service.ID, err)
 | 
			
		||||
@ -99,11 +133,11 @@ func removeServices(
 | 
			
		||||
func removeNetworks(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	client *apiclient.Client,
 | 
			
		||||
	networks []types.NetworkResource,
 | 
			
		||||
	networks []network.Inspect,
 | 
			
		||||
) bool {
 | 
			
		||||
	var hasError bool
 | 
			
		||||
	for _, network := range networks {
 | 
			
		||||
		log.Infof("removing network %s", network.Name)
 | 
			
		||||
		log.Debugf("removing network %s", network.Name)
 | 
			
		||||
		if err := client.NetworkRemove(ctx, network.ID); err != nil {
 | 
			
		||||
			hasError = true
 | 
			
		||||
			log.Fatalf("failed to remove network %s: %s", network.ID, err)
 | 
			
		||||
@ -119,7 +153,7 @@ func removeSecrets(
 | 
			
		||||
) bool {
 | 
			
		||||
	var hasError bool
 | 
			
		||||
	for _, secret := range secrets {
 | 
			
		||||
		log.Infof("removing secret %s", secret.Spec.Name)
 | 
			
		||||
		log.Debugf("removing secret %s", secret.Spec.Name)
 | 
			
		||||
		if err := client.SecretRemove(ctx, secret.ID); err != nil {
 | 
			
		||||
			hasError = true
 | 
			
		||||
			log.Fatalf("Failed to remove secret %s: %s", secret.ID, err)
 | 
			
		||||
@ -135,7 +169,7 @@ func removeConfigs(
 | 
			
		||||
) bool {
 | 
			
		||||
	var hasError bool
 | 
			
		||||
	for _, config := range configs {
 | 
			
		||||
		log.Infof("removing config %s", config.Spec.Name)
 | 
			
		||||
		log.Debugf("removing config %s", config.Spec.Name)
 | 
			
		||||
		if err := client.ConfigRemove(ctx, config.ID); err != nil {
 | 
			
		||||
			hasError = true
 | 
			
		||||
			log.Fatalf("failed to remove config %s: %s", config.ID, err)
 | 
			
		||||
@ -169,12 +203,23 @@ func terminalState(state swarm.TaskState) bool {
 | 
			
		||||
	return numberedStates[state] > numberedStates[swarm.TaskStateRunning]
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func waitOnTasks(ctx context.Context, client apiclient.APIClient, namespace string) error {
 | 
			
		||||
func waitOnTasks(ctx context.Context, client apiclient.APIClient, namespace string) (bool, error) {
 | 
			
		||||
	var timedOut bool
 | 
			
		||||
 | 
			
		||||
	log.Debugf("waiting on undeploy tasks (timeout=%v secs)", WaitTimeout)
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		t := time.Duration(WaitTimeout) * time.Second
 | 
			
		||||
		<-time.After(t)
 | 
			
		||||
		log.Debug("timed out on undeploy")
 | 
			
		||||
		timedOut = true
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	terminalStatesReached := 0
 | 
			
		||||
	for {
 | 
			
		||||
		tasks, err := getStackTasks(ctx, client, namespace)
 | 
			
		||||
		if err != nil {
 | 
			
		||||
			return fmt.Errorf("failed to get tasks: %w", err)
 | 
			
		||||
			return false, fmt.Errorf("failed to get tasks: %w", err)
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for _, task := range tasks {
 | 
			
		||||
@ -187,6 +232,11 @@ func waitOnTasks(ctx context.Context, client apiclient.APIClient, namespace stri
 | 
			
		||||
		if terminalStatesReached == len(tasks) {
 | 
			
		||||
			break
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if timedOut {
 | 
			
		||||
			return true, fmt.Errorf("deployment timed out 🟠")
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return nil
 | 
			
		||||
 | 
			
		||||
	return false, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -3,24 +3,27 @@ package stack // https://github.com/docker/cli/blob/master/cli/command/stack/swa
 | 
			
		||||
import (
 | 
			
		||||
	"context"
 | 
			
		||||
	"fmt"
 | 
			
		||||
	"io"
 | 
			
		||||
	"io/ioutil"
 | 
			
		||||
	"os"
 | 
			
		||||
	"os/signal"
 | 
			
		||||
	"path/filepath"
 | 
			
		||||
	"strconv"
 | 
			
		||||
	"strings"
 | 
			
		||||
	"time"
 | 
			
		||||
 | 
			
		||||
	stdlibErr "errors"
 | 
			
		||||
 | 
			
		||||
	tea "github.com/charmbracelet/bubbletea"
 | 
			
		||||
 | 
			
		||||
	"coopcloud.tech/abra/pkg/config"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/log"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/ui"
 | 
			
		||||
	"coopcloud.tech/abra/pkg/upstream/convert"
 | 
			
		||||
	"github.com/docker/cli/cli/command/service/progress"
 | 
			
		||||
	"github.com/docker/cli/cli/command"
 | 
			
		||||
	"github.com/docker/cli/cli/command/stack/formatter"
 | 
			
		||||
	composetypes "github.com/docker/cli/cli/compose/types"
 | 
			
		||||
	"github.com/docker/docker/api/types"
 | 
			
		||||
	"github.com/docker/docker/api/types/container"
 | 
			
		||||
	"github.com/docker/docker/api/types/filters"
 | 
			
		||||
	networktypes "github.com/docker/docker/api/types/network"
 | 
			
		||||
	"github.com/docker/docker/api/types/swarm"
 | 
			
		||||
	"github.com/docker/docker/api/types/versions"
 | 
			
		||||
	"github.com/docker/docker/client"
 | 
			
		||||
@ -106,13 +109,22 @@ type DeployMeta struct {
 | 
			
		||||
	ChaosVersion string // the --chaos deployment version
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func (d DeployMeta) String() string {
 | 
			
		||||
	var out string
 | 
			
		||||
	out += fmt.Sprintf("{isDeployed: %v, ", d.IsDeployed)
 | 
			
		||||
	out += fmt.Sprintf("version: %s, ", d.Version)
 | 
			
		||||
	out += fmt.Sprintf("isChaos: %v, ", d.IsChaos)
 | 
			
		||||
	out += fmt.Sprintf("chaosVersion: %s}", d.ChaosVersion)
 | 
			
		||||
	return out
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// IsDeployed gathers metadata about an app deployment.
 | 
			
		||||
func IsDeployed(ctx context.Context, cl *dockerClient.Client, stackName string) (DeployMeta, error) {
 | 
			
		||||
	deployMeta := DeployMeta{
 | 
			
		||||
		IsDeployed:   false,
 | 
			
		||||
		Version:      "unknown",
 | 
			
		||||
		IsChaos:      false,
 | 
			
		||||
		ChaosVersion: "false", // NOTE(d1): match string type used on label
 | 
			
		||||
		ChaosVersion: config.CHAOS_DEFAULT,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	filter := filters.NewArgs()
 | 
			
		||||
@ -166,7 +178,7 @@ func IsDeployed(ctx context.Context, cl *dockerClient.Client, stackName string)
 | 
			
		||||
func pruneServices(ctx context.Context, cl *dockerClient.Client, namespace convert.Namespace, services map[string]struct{}) {
 | 
			
		||||
	oldServices, err := GetStackServices(ctx, cl, namespace.Name())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		log.Infof("failed to list services: %s", err)
 | 
			
		||||
		log.Warnf("failed to list services: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	pruneServices := []swarm.Service{}
 | 
			
		||||
@ -180,7 +192,17 @@ func pruneServices(ctx context.Context, cl *dockerClient.Client, namespace conve
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// RunDeploy is the swarm implementation of docker stack deploy
 | 
			
		||||
func RunDeploy(cl *dockerClient.Client, opts Deploy, cfg *composetypes.Config, appName string, dontWait bool) error {
 | 
			
		||||
func RunDeploy(
 | 
			
		||||
	cl *dockerClient.Client,
 | 
			
		||||
	opts Deploy,
 | 
			
		||||
	cfg *composetypes.Config,
 | 
			
		||||
	appName string,
 | 
			
		||||
	serverName string,
 | 
			
		||||
	dontWait bool,
 | 
			
		||||
	filters filters.Args,
 | 
			
		||||
) error {
 | 
			
		||||
	log.Info("initialising deployment")
 | 
			
		||||
 | 
			
		||||
	if err := validateResolveImageFlag(&opts); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
@ -190,7 +212,16 @@ func RunDeploy(cl *dockerClient.Client, opts Deploy, cfg *composetypes.Config, a
 | 
			
		||||
		opts.ResolveImage = ResolveImageNever
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return deployCompose(context.Background(), cl, opts, cfg, appName, dontWait)
 | 
			
		||||
	return deployCompose(
 | 
			
		||||
		context.Background(),
 | 
			
		||||
		cl,
 | 
			
		||||
		opts,
 | 
			
		||||
		cfg,
 | 
			
		||||
		appName,
 | 
			
		||||
		serverName,
 | 
			
		||||
		dontWait,
 | 
			
		||||
		filters,
 | 
			
		||||
	)
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// validateResolveImageFlag validates the opts.resolveImage command line option
 | 
			
		||||
@ -203,7 +234,16 @@ func validateResolveImageFlag(opts *Deploy) error {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func deployCompose(ctx context.Context, cl *dockerClient.Client, opts Deploy, config *composetypes.Config, appName string, dontWait bool) error {
 | 
			
		||||
func deployCompose(
 | 
			
		||||
	ctx context.Context,
 | 
			
		||||
	cl *dockerClient.Client,
 | 
			
		||||
	opts Deploy,
 | 
			
		||||
	config *composetypes.Config,
 | 
			
		||||
	appName string,
 | 
			
		||||
	serverName string,
 | 
			
		||||
	dontWait bool,
 | 
			
		||||
	filters filters.Args,
 | 
			
		||||
) error {
 | 
			
		||||
	namespace := convert.NewNamespace(opts.Namespace)
 | 
			
		||||
 | 
			
		||||
	if opts.Prune {
 | 
			
		||||
@ -244,7 +284,14 @@ func deployCompose(ctx context.Context, cl *dockerClient.Client, opts Deploy, co
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	serviceIDs, err := deployServices(ctx, cl, services, namespace, opts.SendRegistryAuth, opts.ResolveImage)
 | 
			
		||||
	serviceIDs, err := deployServices(
 | 
			
		||||
		ctx,
 | 
			
		||||
		cl,
 | 
			
		||||
		services,
 | 
			
		||||
		namespace,
 | 
			
		||||
		opts.SendRegistryAuth,
 | 
			
		||||
		opts.ResolveImage,
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
@ -254,13 +301,16 @@ func deployCompose(ctx context.Context, cl *dockerClient.Client, opts Deploy, co
 | 
			
		||||
		return nil
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Infof("waiting for %s to deploy... please hold 🤚", appName)
 | 
			
		||||
 | 
			
		||||
	if err := waitOnServices(ctx, cl, serviceIDs, appName); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	waitOpts := WaitOpts{
 | 
			
		||||
		Services:   serviceIDs,
 | 
			
		||||
		AppName:    appName,
 | 
			
		||||
		ServerName: serverName,
 | 
			
		||||
		Filters:    filters,
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	log.Infof("successfully deployed %s", appName)
 | 
			
		||||
	if err := WaitOnServices(ctx, cl, waitOpts); err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
@ -286,7 +336,7 @@ func validateExternalNetworks(ctx context.Context, client dockerClient.NetworkAP
 | 
			
		||||
			// local-scoped networks, so there's no need to inspect them.
 | 
			
		||||
			continue
 | 
			
		||||
		}
 | 
			
		||||
		network, err := client.NetworkInspect(ctx, networkName, types.NetworkInspectOptions{})
 | 
			
		||||
		network, err := client.NetworkInspect(ctx, networkName, networktypes.InspectOptions{})
 | 
			
		||||
		switch {
 | 
			
		||||
		case dockerClient.IsErrNotFound(err):
 | 
			
		||||
			return errors.Errorf("network %q is declared as external, but could not be found. You need to create a swarm-scoped network before the stack is deployed, which you can do by running this on the server: docker network create -d overlay proxy", networkName)
 | 
			
		||||
@ -332,7 +382,7 @@ func createConfigs(ctx context.Context, cl *dockerClient.Client, configs []swarm
 | 
			
		||||
			}
 | 
			
		||||
		case dockerClient.IsErrNotFound(err):
 | 
			
		||||
			// config does not exist, then we create a new one.
 | 
			
		||||
			log.Infof("creating config %s", configSpec.Name)
 | 
			
		||||
			log.Debugf("creating config %s", configSpec.Name)
 | 
			
		||||
			if _, err := cl.ConfigCreate(ctx, configSpec); err != nil {
 | 
			
		||||
				return errors.Wrapf(err, "failed to create config %s", configSpec.Name)
 | 
			
		||||
			}
 | 
			
		||||
@ -343,13 +393,13 @@ func createConfigs(ctx context.Context, cl *dockerClient.Client, configs []swarm
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func createNetworks(ctx context.Context, cl *dockerClient.Client, namespace convert.Namespace, networks map[string]types.NetworkCreate) error {
 | 
			
		||||
func createNetworks(ctx context.Context, cl *dockerClient.Client, namespace convert.Namespace, networks map[string]networktypes.CreateOptions) error {
 | 
			
		||||
	existingNetworks, err := getStackNetworks(ctx, cl, namespace.Name())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	existingNetworkMap := make(map[string]types.NetworkResource)
 | 
			
		||||
	existingNetworkMap := make(map[string]networktypes.Inspect)
 | 
			
		||||
	for _, network := range existingNetworks {
 | 
			
		||||
		existingNetworkMap[network.Name] = network
 | 
			
		||||
	}
 | 
			
		||||
@ -363,7 +413,7 @@ func createNetworks(ctx context.Context, cl *dockerClient.Client, namespace conv
 | 
			
		||||
			createOpts.Driver = defaultNetworkDriver
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		log.Infof("creating network %s", name)
 | 
			
		||||
		log.Debugf("creating network %s", name)
 | 
			
		||||
		if _, err := cl.NetworkCreate(ctx, name, createOpts); err != nil {
 | 
			
		||||
			return errors.Wrapf(err, "failed to create network %s", name)
 | 
			
		||||
		}
 | 
			
		||||
@ -377,10 +427,13 @@ func deployServices(
 | 
			
		||||
	services map[string]swarm.ServiceSpec,
 | 
			
		||||
	namespace convert.Namespace,
 | 
			
		||||
	sendAuth bool,
 | 
			
		||||
	resolveImage string) ([]string, error) {
 | 
			
		||||
	resolveImage string,
 | 
			
		||||
) ([]ui.ServiceMeta, error) {
 | 
			
		||||
	var servicesMeta []ui.ServiceMeta
 | 
			
		||||
 | 
			
		||||
	existingServices, err := GetStackServices(ctx, cl, namespace.Name())
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return nil, err
 | 
			
		||||
		return servicesMeta, err
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	existingServiceMap := make(map[string]swarm.Service)
 | 
			
		||||
@ -388,8 +441,6 @@ func deployServices(
 | 
			
		||||
		existingServiceMap[service.Spec.Name] = service
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	var serviceIDs []string
 | 
			
		||||
 | 
			
		||||
	for internalName, serviceSpec := range services {
 | 
			
		||||
		var (
 | 
			
		||||
			name        = namespace.Scope(internalName)
 | 
			
		||||
@ -397,8 +448,23 @@ func deployServices(
 | 
			
		||||
			encodedAuth string
 | 
			
		||||
		)
 | 
			
		||||
 | 
			
		||||
		// When sendAuth is set, use the docker cli to retrieve the auth token
 | 
			
		||||
		// for the image we are deploying.
 | 
			
		||||
		// This enables using a private registry by running docker login on the
 | 
			
		||||
		// machine, that abra is executed.
 | 
			
		||||
		if sendAuth {
 | 
			
		||||
			dockerCLI, err := command.NewDockerCli()
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Errorf("retrieving docker auth token: failed create docker cli: %s", err)
 | 
			
		||||
			}
 | 
			
		||||
			encodedAuth, err = command.RetrieveAuthTokenFromImage(dockerCLI.ConfigFile(), image)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				log.Errorf("failed to retrieve registry auth for image %s: %s", image, err)
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if service, exists := existingServiceMap[name]; exists {
 | 
			
		||||
			log.Infof("updating %s", name)
 | 
			
		||||
			log.Debugf("updating %s", name)
 | 
			
		||||
 | 
			
		||||
			updateOpts := types.ServiceUpdateOptions{EncodedRegistryAuth: encodedAuth}
 | 
			
		||||
 | 
			
		||||
@ -440,9 +506,12 @@ func deployServices(
 | 
			
		||||
				log.Warn(warning)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			serviceIDs = append(serviceIDs, service.ID)
 | 
			
		||||
			servicesMeta = append(servicesMeta, ui.ServiceMeta{
 | 
			
		||||
				Name: name,
 | 
			
		||||
				ID:   service.ID,
 | 
			
		||||
			})
 | 
			
		||||
		} else {
 | 
			
		||||
			log.Infof("creating %s", name)
 | 
			
		||||
			log.Debugf("creating %s", name)
 | 
			
		||||
 | 
			
		||||
			createOpts := types.ServiceCreateOptions{EncodedRegistryAuth: encodedAuth}
 | 
			
		||||
 | 
			
		||||
@ -456,15 +525,18 @@ func deployServices(
 | 
			
		||||
				return nil, errors.Wrapf(err, "failed to create %s", name)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			serviceIDs = append(serviceIDs, serviceCreateResponse.ID)
 | 
			
		||||
			servicesMeta = append(servicesMeta, ui.ServiceMeta{
 | 
			
		||||
				Name: name,
 | 
			
		||||
				ID:   serviceCreateResponse.ID,
 | 
			
		||||
			})
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return serviceIDs, nil
 | 
			
		||||
	return servicesMeta, nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getStackNetworks(ctx context.Context, dockerclient client.APIClient, namespace string) ([]types.NetworkResource, error) {
 | 
			
		||||
	return dockerclient.NetworkList(ctx, types.NetworkListOptions{Filters: getStackFilter(namespace)})
 | 
			
		||||
func getStackNetworks(ctx context.Context, dockerclient client.APIClient, namespace string) ([]networktypes.Inspect, error) {
 | 
			
		||||
	return dockerclient.NetworkList(ctx, networktypes.ListOptions{Filters: getStackFilter(namespace)})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func getStackSecrets(ctx context.Context, dockerclient client.APIClient, namespace string) ([]swarm.Secret, error) {
 | 
			
		||||
@ -475,67 +547,89 @@ func getStackConfigs(ctx context.Context, dockerclient client.APIClient, namespa
 | 
			
		||||
	return dockerclient.ConfigList(ctx, types.ConfigListOptions{Filters: getStackFilter(namespace)})
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func waitOnServices(ctx context.Context, cl *dockerClient.Client, serviceIDs []string, appName string) error {
 | 
			
		||||
	var errs []error
 | 
			
		||||
func timestamp() string {
 | 
			
		||||
	ts := time.Now().UTC().Format(time.RFC3339)
 | 
			
		||||
	return strings.Replace(ts, ":", "", -1) // get rid of offensive colons
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
	for _, serviceID := range serviceIDs {
 | 
			
		||||
		if err := WaitOnService(ctx, cl, serviceID, appName); err != nil {
 | 
			
		||||
			errs = append(errs, fmt.Errorf("%s: %w", serviceID, err))
 | 
			
		||||
		}
 | 
			
		||||
type WaitOpts struct {
 | 
			
		||||
	AppName    string
 | 
			
		||||
	Filters    filters.Args
 | 
			
		||||
	NoLog      bool
 | 
			
		||||
	Quiet      bool
 | 
			
		||||
	ServerName string
 | 
			
		||||
	Services   []ui.ServiceMeta
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
func WaitOnServices(ctx context.Context, cl *dockerClient.Client, opts WaitOpts) error {
 | 
			
		||||
	timeout := time.Duration(WaitTimeout) * time.Second
 | 
			
		||||
	model := ui.DeployInitialModel(ctx, cl, opts.Services, opts.AppName, timeout, opts.Filters)
 | 
			
		||||
	tui := tea.NewProgram(model)
 | 
			
		||||
 | 
			
		||||
	if !opts.Quiet {
 | 
			
		||||
		log.Info("polling deployment status")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	if len(errs) > 0 {
 | 
			
		||||
	m, err := log.Without(
 | 
			
		||||
		func() (tea.Model, error) {
 | 
			
		||||
			return tui.Run()
 | 
			
		||||
		},
 | 
			
		||||
	)
 | 
			
		||||
	if err != nil {
 | 
			
		||||
		return fmt.Errorf("waitOnServices: error running TUI: %s", err)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	deployModel := m.(ui.Model)
 | 
			
		||||
	if deployModel.TimedOut || deployModel.Failed || deployModel.Quit {
 | 
			
		||||
		var errs []error
 | 
			
		||||
 | 
			
		||||
		if deployModel.Failed {
 | 
			
		||||
			errs = append(errs, fmt.Errorf("deploy failed 🛑"))
 | 
			
		||||
		} else if deployModel.TimedOut {
 | 
			
		||||
			errs = append(errs, fmt.Errorf("deploy timed out 🟠"))
 | 
			
		||||
		} else {
 | 
			
		||||
			errs = append(errs, fmt.Errorf("deploy in progress 🟠"))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		for _, s := range *deployModel.Streams {
 | 
			
		||||
			if s.Err != nil {
 | 
			
		||||
				errs = append(errs, fmt.Errorf("%s: %s", s.Name, s.Err))
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if len(*deployModel.Logs) > 0 && !opts.NoLog {
 | 
			
		||||
			logsPath := filepath.Join(
 | 
			
		||||
				config.LOGS_DIR,
 | 
			
		||||
				opts.ServerName,
 | 
			
		||||
				fmt.Sprintf("%s_%s", opts.AppName, timestamp()),
 | 
			
		||||
			)
 | 
			
		||||
 | 
			
		||||
			if err := os.MkdirAll(filepath.Join(config.LOGS_DIR, opts.ServerName), 0o764); err != nil {
 | 
			
		||||
				return fmt.Errorf("waitOnServices: error creating log dir: %s", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			file, err := os.Create(logsPath)
 | 
			
		||||
			if err != nil {
 | 
			
		||||
				return fmt.Errorf("waitOnServices: error opening file: %s", err)
 | 
			
		||||
			}
 | 
			
		||||
			defer file.Close()
 | 
			
		||||
 | 
			
		||||
			s := strings.Join(*deployModel.Logs, "\n")
 | 
			
		||||
			if _, err := file.WriteString(s); err != nil {
 | 
			
		||||
				return fmt.Errorf("waitOnServices: writeFile: %s", err)
 | 
			
		||||
			}
 | 
			
		||||
 | 
			
		||||
			errs = append(errs, fmt.Errorf("logs: %s", logsPath))
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		return stdlibErr.Join(errs...)
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// https://github.com/docker/cli/blob/master/cli/command/service/helpers.go
 | 
			
		||||
// https://github.com/docker/cli/blob/master/cli/command/service/progress/progress.go
 | 
			
		||||
func WaitOnService(ctx context.Context, cl *dockerClient.Client, serviceID, appName string) error {
 | 
			
		||||
	errChan := make(chan error, 1)
 | 
			
		||||
	pipeReader, pipeWriter := io.Pipe()
 | 
			
		||||
 | 
			
		||||
	sigintChannel := make(chan os.Signal, 1)
 | 
			
		||||
	signal.Notify(sigintChannel, os.Interrupt)
 | 
			
		||||
	defer signal.Stop(sigintChannel)
 | 
			
		||||
 | 
			
		||||
	go func() {
 | 
			
		||||
		errChan <- progress.ServiceProgress(ctx, cl, serviceID, pipeWriter)
 | 
			
		||||
	}()
 | 
			
		||||
 | 
			
		||||
	go io.Copy(ioutil.Discard, pipeReader)
 | 
			
		||||
 | 
			
		||||
	timeout := time.Duration(WaitTimeout) * time.Second
 | 
			
		||||
 | 
			
		||||
	select {
 | 
			
		||||
	case err := <-errChan:
 | 
			
		||||
		return err
 | 
			
		||||
	case <-sigintChannel:
 | 
			
		||||
		return fmt.Errorf(`
 | 
			
		||||
Not waiting for %s to deploy. The deployment is ongoing...
 | 
			
		||||
 | 
			
		||||
If you want to stop the deployment, try:
 | 
			
		||||
 | 
			
		||||
    abra app undeploy %s`, appName, appName)
 | 
			
		||||
	case <-time.After(timeout):
 | 
			
		||||
		return fmt.Errorf(`
 | 
			
		||||
%s has not converged (%s second timeout reached).
 | 
			
		||||
 | 
			
		||||
This does not necessarily mean your deployment has failed, it may just be that
 | 
			
		||||
the app is taking longer to deploy based on your server resources or network
 | 
			
		||||
latency.
 | 
			
		||||
 | 
			
		||||
You can track latest deployment status with:
 | 
			
		||||
 | 
			
		||||
    abra app ps %s
 | 
			
		||||
 | 
			
		||||
And inspect the logs with:
 | 
			
		||||
 | 
			
		||||
    abra app logs %s
 | 
			
		||||
`, appName, timeout, appName, appName)
 | 
			
		||||
	if !opts.Quiet {
 | 
			
		||||
		log.Info("deploy succeeded 🟢")
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	return nil
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
// Copypasta from https://github.com/docker/cli/blob/master/cli/command/stack/swarm/list.go
 | 
			
		||||
 | 
			
		||||
@ -1,9 +1,9 @@
 | 
			
		||||
#!/usr/bin/env bash
 | 
			
		||||
 | 
			
		||||
ABRA_VERSION="0.9.0-beta"
 | 
			
		||||
ABRA_RELEASE_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$ABRA_VERSION"
 | 
			
		||||
RC_VERSION="0.8.0-rc1-beta"
 | 
			
		||||
RC_VERSION_URL="https://git.coopcloud.tech/api/v1/repos/coop-cloud/abra/releases/tags/$RC_VERSION"
 | 
			
		||||
ABRA_VERSION="0.10.0-beta"
 | 
			
		||||
ABRA_RELEASE_URL="https://git.coopcloud.tech/api/v1/repos/toolshed/abra/releases/tags/$ABRA_VERSION"
 | 
			
		||||
RC_VERSION="0.10.0-beta"
 | 
			
		||||
RC_VERSION_URL="https://git.coopcloud.tech/api/v1/repos/toolshed/abra/releases/tags/$RC_VERSION"
 | 
			
		||||
 | 
			
		||||
for arg in "$@"; do
 | 
			
		||||
  if [ "$arg" == "--rc" ]; then
 | 
			
		||||
@ -40,7 +40,7 @@ function install_abra_release {
 | 
			
		||||
  if ! type "wget" > /dev/null 2>&1; then
 | 
			
		||||
    echo "'wget' is not installed, cannot proceed..."
 | 
			
		||||
    echo "perhaps try installing manually via the releases URL?"
 | 
			
		||||
    echo "https://git.coopcloud.tech/coop-cloud/abra/releases"
 | 
			
		||||
    echo "https://git.coopcloud.tech/toolshed/abra/releases"
 | 
			
		||||
    exit 1
 | 
			
		||||
  fi
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@ -3,5 +3,5 @@ STACK := abra_installer_script
 | 
			
		||||
default: deploy
 | 
			
		||||
 | 
			
		||||
deploy:
 | 
			
		||||
	@DOCKER_CONTEXT=swarm.autonomic.zone docker stack rm $(STACK) && \
 | 
			
		||||
		DOCKER_CONTEXT=swarm.autonomic.zone docker stack deploy -c compose.yml $(STACK)
 | 
			
		||||
	@DOCKER_CONTEXT=swarm-0.coopcloud.tech docker stack rm $(STACK) && \
 | 
			
		||||
		DOCKER_CONTEXT=swarm-0.coopcloud.tech docker stack deploy -c compose.yml $(STACK)
 | 
			
		||||
 | 
			
		||||
@ -42,7 +42,7 @@ echo "========================================================================"
 | 
			
		||||
echo "CLONING ABRA"
 | 
			
		||||
echo "========================================================================"
 | 
			
		||||
rm -rf abra
 | 
			
		||||
git clone ssh://git@git.coopcloud.tech:2222/coop-cloud/abra.git
 | 
			
		||||
git clone ssh://git@git.coopcloud.tech:2222/toolshed/abra.git
 | 
			
		||||
cd abra
 | 
			
		||||
git checkout main
 | 
			
		||||
echo "========================================================================"
 | 
			
		||||
 | 
			
		||||
@ -50,6 +50,9 @@ teardown(){
 | 
			
		||||
  assert_failure
 | 
			
		||||
  assert_output --partial 'locally unstaged changes'
 | 
			
		||||
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_equal "$(_git_status)" "?? foo"
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
}
 | 
			
		||||
@ -62,6 +65,9 @@ teardown(){
 | 
			
		||||
  run $ABRA app check "$TEST_APP_DOMAIN" --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_equal "$(_git_status)" "?? foo"
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -53,6 +53,9 @@ teardown(){
 | 
			
		||||
  assert_failure
 | 
			
		||||
  assert_output --partial 'locally unstaged changes'
 | 
			
		||||
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_equal "$(_git_status)" "?? foo"
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
}
 | 
			
		||||
@ -66,6 +69,9 @@ teardown(){
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial 'baz'
 | 
			
		||||
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_equal "$(_git_status)" "?? foo"
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -24,6 +24,9 @@ teardown(){
 | 
			
		||||
  _rm_remote "/etc/*.txt"
 | 
			
		||||
 | 
			
		||||
  _rm "$BATS_TMPDIR/mydir"
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "validate app argument" {
 | 
			
		||||
@ -34,6 +37,42 @@ teardown(){
 | 
			
		||||
  assert_failure
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "bail if unstaged changes and no --chaos" {
 | 
			
		||||
  _mkdir "$BATS_TMPDIR/mydir"
 | 
			
		||||
  _mkfile "$BATS_TMPDIR/mydir/myfile.txt" "foo"
 | 
			
		||||
 | 
			
		||||
  run bash -c "echo foo >> $ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app cp "$TEST_APP_DOMAIN" "$BATS_TMPDIR/mydir" app:/etc
 | 
			
		||||
  assert_failure
 | 
			
		||||
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_equal "$(_git_status)" "?? foo"
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "do not bail if unstaged changes and --chaos" {
 | 
			
		||||
  _mkdir "$BATS_TMPDIR/mydir"
 | 
			
		||||
  _mkfile "$BATS_TMPDIR/mydir/myfile.txt" "foo"
 | 
			
		||||
 | 
			
		||||
  run bash -c "echo foo >> $ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app cp "$TEST_APP_DOMAIN" "$BATS_TMPDIR/mydir" app:/etc --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_equal "$(_git_status)" "?? foo"
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "error if missing src/dest arguments" {
 | 
			
		||||
  run $ABRA app cp "$TEST_APP_DOMAIN"
 | 
			
		||||
  assert_failure
 | 
			
		||||
 | 
			
		||||
@ -21,8 +21,10 @@ setup(){
 | 
			
		||||
 | 
			
		||||
teardown(){
 | 
			
		||||
  _reset_recipe
 | 
			
		||||
  _reset_app
 | 
			
		||||
  _undeploy_app
 | 
			
		||||
  _undeploy_app2 "gitea.$TEST_SERVER"
 | 
			
		||||
 | 
			
		||||
  _reset_app
 | 
			
		||||
  _reset_tags
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
@ -46,6 +48,9 @@ teardown(){
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial 'foo'
 | 
			
		||||
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_equal "$(_git_status)" "?? foo"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
 | 
			
		||||
  assert_failure
 | 
			
		||||
  assert_output --partial 'locally unstaged changes'
 | 
			
		||||
@ -62,10 +67,12 @@ teardown(){
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial 'foo'
 | 
			
		||||
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_equal "$(_git_status)" "?? foo"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --chaos --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial 'chaos'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@ -101,8 +108,6 @@ teardown(){
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "deploy latest commit if no published versions and no --chaos" {
 | 
			
		||||
  latestCommit="$(git -C "$ABRA_DIR/recipes/$TEST_RECIPE" rev-parse --short HEAD)"
 | 
			
		||||
 | 
			
		||||
  _remove_tags
 | 
			
		||||
  _wipe_env_version
 | 
			
		||||
 | 
			
		||||
@ -110,9 +115,7 @@ teardown(){
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --offline
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial "$latestCommit"
 | 
			
		||||
  assert_output --partial 'using latest commit'
 | 
			
		||||
  refute_output --partial 'chaos'
 | 
			
		||||
  assert_output --partial "${_get_head_hash:0:8}"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@ -130,7 +133,6 @@ teardown(){
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial "${wantHash:0:8}"
 | 
			
		||||
  assert_output --partial 'chaos'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@ -165,6 +167,32 @@ teardown(){
 | 
			
		||||
  assert_output --partial 'already deployed'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "no re-deploy after chaos deploy without --force/--chaos" {
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_failure
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --force
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "no re-deploy without --force" {
 | 
			
		||||
  _deploy_app
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_failure
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "re-deploy deployed app if --force/--chaos" {
 | 
			
		||||
  _deploy_app
 | 
			
		||||
@ -172,12 +200,10 @@ teardown(){
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --force
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial 'already deployed'
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial 'already deployed'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@ -196,19 +222,6 @@ teardown(){
 | 
			
		||||
  run $ABRA app deploy "gitea.$TEST_SERVER" --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial "$latestVersion"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app undeploy "gitea.$TEST_SERVER" --no-input
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app secret remove "gitea.$TEST_SERVER" --all --no-input
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app volume remove "gitea.$TEST_SERVER" --no-input
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app remove "gitea.$TEST_SERVER" --no-input
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/servers/$TEST_SERVER/gitea.$TEST_SERVER.env"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@ -228,7 +241,6 @@ teardown(){
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial 'no DOMAIN=... configured for app'
 | 
			
		||||
 | 
			
		||||
  run $ABRA app undeploy "$TEST_APP_DOMAIN" --no-input
 | 
			
		||||
  assert_success
 | 
			
		||||
@ -262,7 +274,6 @@ teardown(){
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --no-domain-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial 'skipping domain checks as requested'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "error if specific version does not exist" {
 | 
			
		||||
@ -310,7 +321,6 @@ teardown(){
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
 | 
			
		||||
  assert_failure
 | 
			
		||||
  assert_output --partial 'unable to deploy, secrets not generated'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@ -346,10 +356,70 @@ teardown(){
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.1+1.20.2" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
  refute_output --partial 'no such file or directory'
 | 
			
		||||
 | 
			
		||||
  _undeploy_app
 | 
			
		||||
 | 
			
		||||
  run $ABRA app secret rm "$TEST_APP_DOMAIN" --all
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "chaos version label includes dirty marker" {
 | 
			
		||||
  run bash -c "echo foo >> $ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app labels "$TEST_APP_DOMAIN" --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --regexp 'chaos-version.*+U'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "ignore env version checkout after deploy" {
 | 
			
		||||
  tagHash=$(_get_tag_hash "0.1.0+1.20.0")
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_equal $(_get_current_hash) "$tagHash"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app check --ignore-env-version "$TEST_APP_DOMAIN"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_equal $(_get_current_hash) "$(_get_head_hash)"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app check "$TEST_APP_DOMAIN"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_equal $(_get_current_hash) "$tagHash"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "ignore env version on new deploy" {
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  _undeploy_app
 | 
			
		||||
 | 
			
		||||
  latestRelease=$(_latest_release)
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --ignore-env-version
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial "$latestRelease"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "no chaos version label if no chaos" {
 | 
			
		||||
  _deploy_app
 | 
			
		||||
 | 
			
		||||
  run $ABRA app labels "$TEST_APP_DOMAIN" --no-input
 | 
			
		||||
  assert_success
 | 
			
		||||
  refute_output --regexp "coop-cloud.abra-test-recipe.$TEST_SERVER.chaos-version"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -25,17 +25,6 @@ teardown(){
 | 
			
		||||
  _reset_app
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "fails to deploy + unstaged changes version" {
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe.*/TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe:111111 + unstaged changes/g' \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input
 | 
			
		||||
  assert_failure
 | 
			
		||||
  assert_output --partial 'A chaos version is configured. \nMaybe your coworker deployed a local version of the recipe?\nOr did you forget to add --chaos?'
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "deploy version written to env" {
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input --no-converge-checks
 | 
			
		||||
@ -65,13 +54,21 @@ teardown(){
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "chaos commit written to env" {
 | 
			
		||||
@test "deploy commit written to env and redeploy keeps that version" {
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "1e83340e" --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:1e83340e" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --force --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:1e83340e" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@ -102,9 +99,30 @@ teardown(){
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "0.2.0+1.21.0" \
 | 
			
		||||
    --no-input --no-converge-checks --force --debug
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial "overriding env file version"
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:0.2.0+1.21.0" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "takes deployed version when no .env version is present " {
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.0+1.20.0" --no-input --no-converge-checks --ignore-env-version
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:0.1.0+1.20.0" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=abra-test-recipe/g' \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --force --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:0.1.0+1.20.0" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										256
									
								
								tests/integration/app_deploy_overview.bats
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										256
									
								
								tests/integration/app_deploy_overview.bats
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,256 @@
 | 
			
		||||
#!/usr/bin/env bash
 | 
			
		||||
 | 
			
		||||
setup_file(){
 | 
			
		||||
  load "$PWD/tests/integration/helpers/common"
 | 
			
		||||
  _common_setup
 | 
			
		||||
  _add_server
 | 
			
		||||
  _new_app
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
teardown_file(){
 | 
			
		||||
  _rm_app
 | 
			
		||||
  _rm_server
 | 
			
		||||
  _reset_recipe
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
setup(){
 | 
			
		||||
  load "$PWD/tests/integration/helpers/common"
 | 
			
		||||
  _common_setup
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
teardown(){
 | 
			
		||||
  _reset_recipe
 | 
			
		||||
  _undeploy_app
 | 
			
		||||
  _reset_app
 | 
			
		||||
  _reset_tags
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "first deploy with empty env version" {
 | 
			
		||||
  latestRelease=$(_latest_release)
 | 
			
		||||
 | 
			
		||||
  _wipe_env_version
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'NEW DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial 'CURRENT DEPLOYMENT    N/A'
 | 
			
		||||
  assert_output --partial 'ENV VERSION           N/A'
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${latestRelease}"
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:${latestRelease}" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "first deploy to latest version" {
 | 
			
		||||
  latestRelease=$(_latest_release)
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'NEW DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    N/A"
 | 
			
		||||
  assert_output --partial "ENV VERSION           ${latestRelease}"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${latestRelease}"
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:${latestRelease}" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "deploy, re-deploy, choose env version" {
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.1+1.20.2" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  _undeploy_app
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'NEW DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    N/A"
 | 
			
		||||
  assert_output --partial "ENV VERSION           0.1.1+1.20.2"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        0.1.1+1.20.2"
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:0.1.1+1.20.2" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "deploy, re-deploy, ignore env version" {
 | 
			
		||||
  latestRelease=$(_latest_release)
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "0.1.1+1.20.2" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  _undeploy_app
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --ignore-env-version
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'NEW DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    N/A"
 | 
			
		||||
  assert_output --partial "ENV VERSION           0.1.1+1.20.2"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${latestRelease}"
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:${latestRelease}" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "deploy then chaos deploy" {
 | 
			
		||||
  headHash=$(_get_head_hash)
 | 
			
		||||
  latestRelease=$(_latest_release)
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run bash -c 'echo "unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/foo"'
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'CHAOS DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    ${latestRelease}"
 | 
			
		||||
  assert_output --partial "ENV VERSION           ${latestRelease}"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${headHash:0:8}+U"
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:${headHash:0:8}" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "can not redeploy chaos version without --chaos" {
 | 
			
		||||
  headHash=$(_get_head_hash)
 | 
			
		||||
  latestRelease=$(_latest_release)
 | 
			
		||||
 | 
			
		||||
  run bash -c 'echo "unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/foo"'
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --force --debug
 | 
			
		||||
  assert_failure
 | 
			
		||||
  assert_output --regexp 'can not redeploy chaos version .*' + "${headHash:0:8}+U"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "deploy then force commit deploy" {
 | 
			
		||||
  headHash=$(_get_head_hash)
 | 
			
		||||
  latestRelease=$(_latest_release)
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:${latestRelease}" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "${headHash:0:8}" \
 | 
			
		||||
    --no-input --no-converge-checks --force
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    ${latestRelease}"
 | 
			
		||||
  assert_output --partial "ENV VERSION           ${latestRelease}"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${headHash:0:8}"
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:${headHash:0:8}" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "chaos deploy then chaos deploy with unstaged" {
 | 
			
		||||
  headHash=$(_get_head_hash)
 | 
			
		||||
  latestRelease=$(_latest_release)
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'NEW DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    N/A"
 | 
			
		||||
  assert_output --partial "ENV VERSION           ${latestRelease}"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${headHash:0:8}"
 | 
			
		||||
 | 
			
		||||
  run bash -c 'echo "unstaged changes" >> "$ABRA_DIR/recipes/$TEST_RECIPE/foo"'
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'CHAOS DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    ${headHash:0:8}"
 | 
			
		||||
  assert_output --partial "ENV VERSION           ${headHash:0:8}"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${headHash:0:8}+U"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'CHAOS DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    ${headHash:0:8}+U"
 | 
			
		||||
  assert_output --partial "ENV VERSION           ${headHash:0:8}+U"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${headHash:0:8}+U"
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --chaos
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'CHAOS DEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    ${headHash:0:8}+U"
 | 
			
		||||
  assert_output --partial "ENV VERSION           ${headHash:0:8}+U"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${headHash:0:8}+U"
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=$TEST_RECIPE:${headHash:0:8}" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run rm -rf "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
  assert_not_exists "$ABRA_DIR/recipes/$TEST_RECIPE/foo"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@test "hash deploy then force deploy" {
 | 
			
		||||
  headHash=$(_get_head_hash)
 | 
			
		||||
  latestRelease=$(_latest_release)
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" "${headHash:0:8}" \
 | 
			
		||||
    --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" \
 | 
			
		||||
    --no-input --no-converge-checks --force
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  assert_output --partial 'REDEPLOY OVERVIEW'
 | 
			
		||||
  assert_output --partial "CURRENT DEPLOYMENT    ${headHash:0:8}"
 | 
			
		||||
  assert_output --partial "ENV VERSION           ${headHash:0:8}"
 | 
			
		||||
  assert_output --partial "NEW DEPLOYMENT        ${headHash:0:8}"
 | 
			
		||||
}
 | 
			
		||||
@ -27,18 +27,18 @@ teardown(){
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "deploy remote recipe" {
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe/g' \
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/toolshed\/abra-test-recipe/g' \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
  assert_output --partial "git.coopcloud.tech/coop-cloud/abra-test-recipe"
 | 
			
		||||
  assert_output --partial "git.coopcloud.tech/toolshed/abra-test-recipe"
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "deploy remote recipe with version" {
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe:0.2.0+1.21.0/g' \
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/toolshed\/abra-test-recipe:0.2.0+1.21.0/g' \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
@ -49,7 +49,7 @@ teardown(){
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "deploy remote recipe with chaos commit" {
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe:1e83340e/g' \
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/toolshed\/abra-test-recipe:1e83340e/g' \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
@ -60,14 +60,14 @@ teardown(){
 | 
			
		||||
 | 
			
		||||
# bats test_tags=slow
 | 
			
		||||
@test "remote recipe version written to env" {
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe/g' \
 | 
			
		||||
  run sed -i 's/TYPE=abra-test-recipe:.*/TYPE=git.coopcloud.tech\/toolshed\/abra-test-recipe/g' \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run $ABRA app deploy "$TEST_APP_DOMAIN" --no-input --no-converge-checks
 | 
			
		||||
  assert_success
 | 
			
		||||
 | 
			
		||||
  run grep -q "TYPE=git.coopcloud.tech\/coop-cloud\/abra-test-recipe:$(_latest_release)" \
 | 
			
		||||
  run grep -q "TYPE=git.coopcloud.tech\/toolshed\/abra-test-recipe:$(_latest_release)" \
 | 
			
		||||
    "$ABRA_DIR/servers/$TEST_SERVER/$TEST_APP_DOMAIN.env"
 | 
			
		||||
  assert_success
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user